|
| 1 | +--- |
| 2 | +name: path-guard |
| 3 | +description: Audit and fix path duplication in this Socket repo. Apply the strict "1 path, 1 reference" rule — every build/test/runtime/config path is constructed exactly once; everywhere else references the constructed value. Default mode finds and fixes; `check` mode reports only; `install` mode drops the gate + hook + rule into a fresh repo. |
| 4 | +user-invocable: true |
| 5 | +allowed-tools: Task, Read, Edit, Write, Grep, Glob, AskUserQuestion, Bash(pnpm run check:*), Bash(node scripts/check-paths:*), Bash(rg:*), Bash(grep:*), Bash(find:*), Bash(git:*) |
| 6 | +--- |
| 7 | + |
| 8 | +# path-guard |
| 9 | + |
| 10 | +**Mantra: 1 path, 1 reference.** A path is constructed exactly once; everywhere else references the constructed value. Re-constructing the same path twice is the violation, not referencing the constructed value many times. |
| 11 | + |
| 12 | +## Modes |
| 13 | + |
| 14 | +- `/path-guard` — full audit-and-fix conversion of the current repo (default). |
| 15 | +- `/path-guard check` — read-only audit, report violations, no fixes. |
| 16 | +- `/path-guard fix <id>` — fix a single finding from a prior `check` run, by index. |
| 17 | +- `/path-guard install` — drop the gate + hook + rule + allowlist into a fresh repo (for new Socket repos). |
| 18 | + |
| 19 | +## Three-level enforcement |
| 20 | + |
| 21 | +The strategy lives in three artifacts that ship together: |
| 22 | + |
| 23 | +1. **CLAUDE.md rule** — the mantra and detection rules in plain language. Every Socket repo's CLAUDE.md carries `## 1 path, 1 reference`. Synced from `.claude/skills/_shared/path-guard-rule.md`. |
| 24 | +2. **Hook** — `.claude/hooks/path-guard/index.mts` runs `PreToolUse` on `Edit`/`Write` of `.mts`/`.cts` files. Blocks new violations at edit time. Mandatory across the fleet. |
| 25 | +3. **Gate** — `scripts/check-paths.mts` runs in `pnpm check` (and CI). Whole-repo scan. Fails the build on any unsanctioned violation. |
| 26 | + |
| 27 | +The hook and gate share their stage / build-root / mode / sibling-package vocabulary via `.claude/hooks/path-guard/segments.mts` — a single canonical source. Adding a new stage segment or fleet package means editing one file; the two consumers can never drift on what counts as a build-output path. |
| 28 | + |
| 29 | +This skill is the *audit-and-fix workflow* that makes a repo conform initially and validates conformance over time. |
| 30 | + |
| 31 | +## Detection rules |
| 32 | + |
| 33 | +The gate enforces six rules. The hook enforces a subset (A and B) since it sees only one diff at a time. |
| 34 | + |
| 35 | +| Rule | What it catches | Where checked | |
| 36 | +|---|---|---| |
| 37 | +| **A** | Multi-stage `path.join(...)` constructed inline. Two or more "stage" segments (Final, Release, Stripped, Compressed, Optimized, Synced, wasm, downloaded), or one stage + build-root + mode. | `.mts`/`.cts` files outside a `paths.mts`. Hook + gate. | |
| 38 | +| **B** | Cross-package traversal: `path.join(*, '..', '<sibling-package>', 'build', ...)` reaching into a sibling's output instead of importing via `exports`. | `.mts`/`.cts` files. Hook + gate. | |
| 39 | +| **C** | Workflow YAML constructs the same path string in 2+ steps outside a "Compute paths" step. | `.github/workflows/*.yml`. Gate. | |
| 40 | +| **D** | Comment encodes a fully-qualified multi-stage path string (e.g. `# build/dev/darwin-arm64/out/Final/binary`). | `.github/workflows/*.yml`. Gate. | |
| 41 | +| **F** | Same path shape constructed in 2+ different files. | All scanned files. Gate. | |
| 42 | +| **G** | Hand-built multi-stage path constructed 2+ times in the same Makefile/Dockerfile/shell stage. | `Makefile`, `*.mk`, `*.Dockerfile`, `Dockerfile.*`, `*.sh`. Gate. | |
| 43 | + |
| 44 | +Comments may describe path *structure* with placeholders (`<mode>/<arch>` or `${BUILD_MODE}/${PLATFORM_ARCH}`) but should not encode a complete literal path string. Code execution takes priority over docs: violations in `.mts`, Makefiles, Dockerfiles, workflow YAML, shell scripts are blocking. |
| 45 | + |
| 46 | +## Mode: audit-and-fix (default) |
| 47 | + |
| 48 | +When invoked as `/path-guard` with no arg: |
| 49 | + |
| 50 | +1. **Setup** — spawn a worktree off `main` per `CLAUDE.md` parallel-sessions rule: |
| 51 | + ```bash |
| 52 | + git worktree add -b paths-audit ../<repo>-paths-audit main |
| 53 | + cd ../<repo>-paths-audit |
| 54 | + ``` |
| 55 | + |
| 56 | +2. **Audit** — run the gate to enumerate findings: |
| 57 | + ```bash |
| 58 | + pnpm run check:paths --json > /tmp/paths-findings.json |
| 59 | + pnpm run check:paths --explain # human-readable |
| 60 | + ``` |
| 61 | + |
| 62 | +3. **Fix loop** — for each finding, apply the matching pattern below. After each fix, re-run the gate. Stop iterating when `pnpm run check:paths` exits 0. |
| 63 | + |
| 64 | +4. **Verify** — run the full check suite + zizmor on any modified workflow: |
| 65 | + ```bash |
| 66 | + pnpm check |
| 67 | + for w in .github/workflows/*.yml; do zizmor "$w"; done |
| 68 | + ``` |
| 69 | + |
| 70 | +5. **Commit and push** — group fixes by logical category (workflows, code, Dockerfiles). Push directly to `main` for repos that allow direct push, or open a PR for repos that require it (socket-cli, socket-sdk-js, socket-registry per their CLAUDE.md / memory entries). |
| 71 | + |
| 72 | +## Fix patterns |
| 73 | + |
| 74 | +### Rule A — Multi-stage path constructed inline (in `.mts`/`.cts`) |
| 75 | + |
| 76 | +**Bad**: |
| 77 | +```ts |
| 78 | +const finalBinary = path.join(PACKAGE_ROOT, 'build', BUILD_MODE, PLATFORM_ARCH, 'out', 'Final', 'binary') |
| 79 | +``` |
| 80 | + |
| 81 | +**Fix**: move the construction into the package's `scripts/paths.mts` (or `lib/paths.mts`), or use a build-infra helper: |
| 82 | +```ts |
| 83 | +// In packages/foo/scripts/paths.mts: |
| 84 | +export function getBuildPaths(mode, platformArch) { |
| 85 | + // ... constructs once ... |
| 86 | + return { outputFinalBinary: path.join(PACKAGE_ROOT, 'build', mode, platformArch, 'out', 'Final', binaryName) } |
| 87 | +} |
| 88 | + |
| 89 | +// In the consumer: |
| 90 | +import { getBuildPaths } from './paths.mts' |
| 91 | +const { outputFinalBinary } = getBuildPaths(mode, platformArch) |
| 92 | +``` |
| 93 | + |
| 94 | +For binsuite tools (binpress/binflate/binject) the canonical helper is `getFinalBinaryPath(packageRoot, mode, platformArch, binaryName)` from `build-infra/lib/paths`. For download caches use `getDownloadedDir(packageRoot)`. |
| 95 | + |
| 96 | +### Rule B — Cross-package traversal |
| 97 | + |
| 98 | +**Bad**: |
| 99 | +```ts |
| 100 | +const liefDir = path.join(PACKAGE_ROOT, '..', 'lief-builder', 'build', mode, platformArch, 'out', 'Final', 'lief') |
| 101 | +``` |
| 102 | + |
| 103 | +**Fix**: declare the workspace dep, expose `paths.mts` via the producer's `exports`, import the helper: |
| 104 | + |
| 105 | +1. In producer's `package.json`: |
| 106 | + ```json |
| 107 | + "exports": { |
| 108 | + "./scripts/paths": "./scripts/paths.mts" |
| 109 | + } |
| 110 | + ``` |
| 111 | +2. In consumer's `package.json` `dependencies`: |
| 112 | + ```json |
| 113 | + "lief-builder": "workspace:*" |
| 114 | + ``` |
| 115 | +3. In consumer: |
| 116 | + ```ts |
| 117 | + import { getBuildPaths as getLiefBuildPaths } from 'lief-builder/scripts/paths' |
| 118 | + const { outputFinalDir } = getLiefBuildPaths(mode, platformArch) |
| 119 | + ``` |
| 120 | + |
| 121 | +### Rule C — Workflow path repetition |
| 122 | + |
| 123 | +**Bad** (3 steps each rebuilding the same path): |
| 124 | +```yaml |
| 125 | +- name: Step A |
| 126 | + run: cd packages/foo/build/${BUILD_MODE}/${PLATFORM_ARCH}/out/Final && do-thing-1 |
| 127 | +- name: Step B |
| 128 | + run: cd packages/foo/build/${BUILD_MODE}/${PLATFORM_ARCH}/out/Final && do-thing-2 |
| 129 | +- name: Step C |
| 130 | + run: cd packages/foo/build/${BUILD_MODE}/${PLATFORM_ARCH}/out/Final && do-thing-3 |
| 131 | +``` |
| 132 | +
|
| 133 | +**Fix**: add a "Compute <pkg> paths" step early in the job that constructs the path once, expose via `$GITHUB_OUTPUT`, reference downstream: |
| 134 | + |
| 135 | +```yaml |
| 136 | +- name: Compute foo paths |
| 137 | + id: paths |
| 138 | + env: |
| 139 | + BUILD_MODE: ${{ steps.build-mode.outputs.mode }} |
| 140 | + PLATFORM_ARCH: ${{ steps.platform-arch.outputs.platform_arch }} |
| 141 | + run: | |
| 142 | + PACKAGE_DIR="packages/foo" |
| 143 | + PLATFORM_BUILD_DIR="${PACKAGE_DIR}/build/${BUILD_MODE}/${PLATFORM_ARCH}" |
| 144 | + FINAL_DIR="${PLATFORM_BUILD_DIR}/out/Final" |
| 145 | + { |
| 146 | + echo "package_dir=${PACKAGE_DIR}" |
| 147 | + echo "platform_build_dir=${PLATFORM_BUILD_DIR}" |
| 148 | + echo "final_dir=${FINAL_DIR}" |
| 149 | + } >> "$GITHUB_OUTPUT" |
| 150 | +
|
| 151 | +- name: Step A |
| 152 | + env: |
| 153 | + FINAL_DIR: ${{ steps.paths.outputs.final_dir }} |
| 154 | + run: cd "$FINAL_DIR" && do-thing-1 |
| 155 | +# ... etc |
| 156 | +``` |
| 157 | + |
| 158 | +For paths used inside `working-directory: packages/foo` steps, expose a `_rel` companion (e.g. `final_dir_rel=build/${BUILD_MODE}/${PLATFORM_ARCH}/out/Final`) and reference that. |
| 159 | + |
| 160 | +### Rule D — Comment-encoded paths |
| 161 | + |
| 162 | +**Bad**: |
| 163 | +```yaml |
| 164 | +# Path: packages/foo/build/dev/darwin-arm64/out/Final/binary |
| 165 | +COPY --from=builder /build/.../out/Final/binary /out/Final/binary |
| 166 | +``` |
| 167 | + |
| 168 | +**Fix**: cite the canonical `paths.mts` instead of duplicating the string: |
| 169 | +```yaml |
| 170 | +# Layout owned by packages/foo/scripts/paths.mts:getBuildPaths(). |
| 171 | +COPY --from=builder /build/packages/foo/build/${BUILD_MODE}/${PLATFORM_ARCH}/out/Final/binary /out/Final/binary |
| 172 | +``` |
| 173 | + |
| 174 | +The comment may describe structure (`<mode>/<arch>`) but should not be a parsable literal path. |
| 175 | + |
| 176 | +### Rule G — Dockerfile/Makefile/shell duplicate construction |
| 177 | + |
| 178 | +**Bad** (Dockerfile reconstructs the path 3 times in the same stage): |
| 179 | +```dockerfile |
| 180 | +RUN mkdir -p build/${BUILD_MODE}/${PLATFORM_ARCH}/out/Final && \ |
| 181 | + cp src build/${BUILD_MODE}/${PLATFORM_ARCH}/out/Final/output && \ |
| 182 | + ls build/${BUILD_MODE}/${PLATFORM_ARCH}/out/Final/ |
| 183 | +``` |
| 184 | + |
| 185 | +**Fix**: declare an `ENV` once, reference everywhere: |
| 186 | +```dockerfile |
| 187 | +# Layout owned by packages/foo/scripts/paths.mts. |
| 188 | +ENV FINAL_DIR=build/${BUILD_MODE}/${PLATFORM_ARCH}/out/Final |
| 189 | +RUN mkdir -p "$FINAL_DIR" && cp src "$FINAL_DIR/output" && ls "$FINAL_DIR/" |
| 190 | +``` |
| 191 | + |
| 192 | +Each Dockerfile `FROM` stage is its own scope — ENV from the build stage doesn't reach a subsequent `FROM scratch AS export` stage. The gate accounts for this. |
| 193 | + |
| 194 | +## Mode: check (read-only) |
| 195 | + |
| 196 | +When invoked as `/path-guard check`: |
| 197 | + |
| 198 | +```bash |
| 199 | +pnpm run check:paths --explain |
| 200 | +``` |
| 201 | + |
| 202 | +Print the gate's findings without making any edits. Exit 0 if clean, 1 if findings present. Useful for CI / pre-merge inspection. |
| 203 | + |
| 204 | +## Allowlisting a finding |
| 205 | + |
| 206 | +When a genuine exemption is needed (rare — most "false positives" should be reported as gate bugs), add an entry to `.github/paths-allowlist.yml`. Two ways to pin the entry to a specific site: |
| 207 | + |
| 208 | +- **`line:`** — exact line number. Strict; a single-line edit above shifts the entry off-target and the finding re-surfaces. |
| 209 | +- **`snippet_hash:`** — 12-char SHA-256 prefix of the offending snippet (whitespace-normalized). Drift-resistant: survives reformatting, but any content-changing edit invalidates it. Get the hash: |
| 210 | + ```bash |
| 211 | + pnpm run check:paths --show-hashes |
| 212 | + ``` |
| 213 | + |
| 214 | +Both may be set — either matching is sufficient. Prefer `snippet_hash` over raw `line:` when the exemption is expected to outlive routine reformatting; prefer `line:` when you specifically *want* the entry to fall off after any nearby edit. |
| 215 | + |
| 216 | +## Mode: install (new repo) |
| 217 | + |
| 218 | +When invoked as `/path-guard install` on a Socket repo that doesn't yet have the gate: |
| 219 | + |
| 220 | +1. Copy the gate file from this skill's reference dir: |
| 221 | + ```bash |
| 222 | + cp .claude/skills/path-guard/reference/check-paths.mts.tmpl scripts/check-paths.mts |
| 223 | + ``` |
| 224 | +2. Copy the empty allowlist: |
| 225 | + ```bash |
| 226 | + cp .claude/skills/path-guard/reference/paths-allowlist.yml.tmpl .github/paths-allowlist.yml |
| 227 | + ``` |
| 228 | +3. Add `"check:paths": "node scripts/check-paths.mts"` to `package.json`. |
| 229 | +4. Wire `runPathHygieneCheck()` into `scripts/check.mts` (after the existing checks). |
| 230 | +5. Append the rule snippet from `.claude/skills/_shared/path-guard-rule.md` to the repo's `CLAUDE.md` if a `1 path, 1 reference` section is missing. |
| 231 | +6. Add the hook entry to `.claude/settings.json` `PreToolUse` matcher `Edit|Write`: |
| 232 | + ```json |
| 233 | + { "type": "command", "command": "node .claude/hooks/path-guard/index.mts" } |
| 234 | + ``` |
| 235 | +7. Run the gate against the repo. Triage findings as you would in audit-and-fix mode. |
| 236 | + |
| 237 | +## Tie-in with quality-scan |
| 238 | + |
| 239 | +The `/quality-scan` skill should call `pnpm run check:paths --json` as one of its sub-scans and surface findings as part of its A-F graded report. Failures roll into the overall quality grade. The full audit-and-fix workflow lives here; quality-scan just *detects* during periodic scans. |
| 240 | + |
| 241 | +## Reference patterns |
| 242 | + |
| 243 | +When converting a repo to the strategy, the patterns I keep reusing: |
| 244 | + |
| 245 | +- **TS-first packages**: each package owns a `scripts/paths.mts` with `PACKAGE_ROOT`, `BUILD_ROOT`, `getBuildPaths(mode, platformArch)` returning at minimum `outputFinalDir` and `outputFinalBinary`/`outputFinalFile`. |
| 246 | +- **Cross-package consumers**: `package.json` `exports` whitelists `./scripts/paths`. Consumer adds `"<producer>: workspace:*"` and imports. |
| 247 | +- **Workflows**: each job has a "Compute <pkg> paths" step (`id: paths`) early in the job. Step outputs include `package_dir`, `platform_build_dir`, `final_dir`, named files. `_rel` companions when `working-directory:` is used. |
| 248 | +- **Docker stages**: each `FROM` stage declares `ENV PLATFORM_BUILD_DIR=...` and `ENV FINAL_DIR=...` once. Subsequent RUN steps reference the variables. |
| 249 | + |
| 250 | +The first repo (socket-btm) is the worked example. Read its `scripts/paths.mts` files and `.github/workflows/*.yml` for canonical patterns when applying the strategy elsewhere. |
0 commit comments