Skip to content

T-IEAL-config-path-globs-survive-moves

Status: open/ready · Impact: medium · Complexity: small

AUTO-DEFINED: this spec was best-effort machine-authored by /sdlc:task-auto-define on 2026-07-20 because the task is autonomy: autonomous/pr. Review the Goal, Approach, Today, Files-to-touch, and Acceptance-criteria carefully before trusting it.

Two config files in this repo carry hand-written path globs that fail open when a directory moves: they silently stop matching instead of erroring, so a protection quietly disappears. This surfaced during T-C9RD-consolidate-augmented-into-solutions with real consequences — a lefthook exclude stopped matching and semantically rewrote 107 vendored files, and a .gitignore rule stopped covering eight moved packages. Close the gap so a directory move cannot silently unmatch a configured scope.

Two config files carry hand-written path globs that silently stopped matching when directories moved during T-C9RD, with real consequences. (1) lefthook.yml’s markdown-fmt hook excluded the learn_web tree (then apps/learn_web/**, since repaired to solutions/augmented/apps/learn_web/**); when the app moved, the exclude stopped matching and rumdl reflowed 107 vendored book-content files on commit — and the edits were SEMANTIC, not cosmetic: MD034 rewrote bare URLs as autolinks and MD025 demoted a book’s # Video Courses to ## Video Courses. (2) .gitignore’s packages/ts/*/dist/ rule stopped covering eight moved packages, leaving build output committable; nothing had landed yet, but the first moon run :build over the tier would have. Same root cause: a hand-written path glob that fails open on a move. Express these scopes as moon/workspace project references (or add a check that asserts each configured exclude glob still matches at least one path) so a move cannot silently unmatch. Touchpoints: lefthook.yml (the markdown-fmt exclude list) and .gitignore (the packages/ts/*/dist/ rule).

T-C9RD-consolidate-augmented-into-solutions

LocationRole today
lefthook.ymlThe markdown-fmt pre-commit command. Its exclude: key (line 49) holds three hand-written path globs — solutions/determined/**, apps/legacy-polish/**, and solutions/augmented/apps/learn_web/** — that fail open: if any of those directories moves, the exclude matches nothing, no error is raised, and rumdl reflows the vendored tree on the next commit
.gitignoreThe packages/ts/*/dist/ rule (line 35) — one hand-written path glob with the same fail-open shape; a packages/ts/ relocation leaves every moved package’s build output committable
.claude/skills/project-check/The project-local deterministic self-lint scripts (check_*.ts). Each is wired as its own project-check-* pre-commit command in lefthook.yml; this is the established home for dev-repo-specific drift gates (deliberately not an sdlc op)
.claude/skills/project-check/tests/Bun tests plus fixture trees, one check_<name>.test.ts per check script
.claude/skills/project-check/SKILL.mdThe /project-check catalogue: each check has one ### 1x. entry under “What this skill checks today” and one matching ### Step 1x entry under “Steps”
.moon/workspace.ymlmoon’s projects.globs (apps/*, packages/ts/*, packages/rust/*, sites/*, tools/*) plus explicit sources: pins. Discovery-only — moon has no exclude semantics for lefthook and no ignore semantics for git, so it cannot host these two scopes as-is

A deterministic pre-commit gate — check_config_globs.ts, a new sibling of the existing .claude/skills/project-check/check_*.ts scripts — that reads the repo’s marked config path globs and asserts each one still matches at least one path in the working tree. A glob that expands to zero matches is a hard failure with a file:line citation, so a directory move that orphans a configured scope stops the commit instead of silently voiding a protection.

Two glob sources, both opt-in so unrelated rules cannot false-fail:

  1. lefthook.yml — every entry under the markdown-fmt command’s exclude: key. These are always path scopes, so no sentinel is needed.
  2. .gitignore — only rules immediately preceded by a # project-check: glob-must-match sentinel comment. Most .gitignore rules name build output that legitimately does not exist on a clean tree (apps/sdlc/dist/, /target), so blanket-checking the file would fail constantly. packages/ts/*/dist/ gets the sentinel; nothing else does yet.

Matching is against the glob’s scope prefix, not its full pattern, because the tail names output that may not be built yet. The prefix rule: drop trailing ** segments; if a wildcard segment remains, truncate the pattern at and including the last wildcard segment and require ≥1 filesystem match; otherwise require the literal path to exist. Concretely solutions/augmented/apps/learn_web/**solutions/augmented/apps/learn_web (must exist), packages/ts/*/dist/packages/ts/* (must expand to ≥1 directory).

  1. Write .claude/skills/project-check/check_config_globs.ts as a #!/usr/bin/env bun script matching the house shape of check_no_legacy_script_paths.ts: module docstring stating the guard and its rationale, main(argv) returning an exit code, if (import.meta.main) process.exit(main(process.argv.slice(2))), per-glob OK/FAIL lines on stdout and a summary on stderr. Exit codes: 0 all globs match, 1 at least one zero-match, 2 the script itself failed (config file missing, unparseable YAML).
  2. Resolve the repo root from import.meta.url the same way the sibling scripts do, so the script works from any cwd and from a linked worktree.
  3. Implement glob collection:
    • lefthook.yml — parse the YAML and read pre-commit.commands.markdown-fmt.exclude[]. Record each entry with its 1-based source line (re-scan the raw text for the entry string) so the failure message can cite lefthook.yml:<line>.
    • .gitignore — walk the file line by line; a non-blank, non-comment line whose immediately preceding non-blank line is exactly # project-check: glob-must-match is collected with its line number.
  4. Implement scopePrefix(pattern) per the Proposed rule (strip a leading /, drop trailing ** and empty segments, truncate at the last remaining wildcard segment). Resolve each prefix with Bun.Glob when it still contains a wildcard, otherwise with a plain existsSync — counting a match only when it is inside the repo root.
  5. On any zero-match, print FAIL <source>:<line> <pattern> (scope prefix "<prefix>" matches nothing) and, in the stderr summary, state the remedy: the directory moved — update the glob to the new path or delete the rule.
  6. Add .claude/skills/project-check/tests/check_config_globs.test.ts exercising scopePrefix directly plus end-to-end runs over temp-dir fixtures: (a) all globs match → exit 0; (b) the solutions/augmented/apps/learn_web/** target renamed away → exit 1 citing lefthook.yml; (c) packages/ts/ renamed away → exit 1 citing .gitignore; (d) a .gitignore rule with no sentinel whose path is absent → still exit 0; (e) a missing lefthook.yml → exit 2.
  7. Add the # project-check: glob-must-match sentinel above packages/ts/*/dist/ in .gitignore, and a short comment above the markdown-fmt exclude: entries in lefthook.yml noting they are gate-covered by this check.
  8. Wire the gate into lefthook.yml as a new project-check-config-globs pre-commit command running bun run .claude/skills/project-check/check_config_globs.ts. Place it with the other project-check-* commands and give it no glob: key — a directory move need not touch either config file, so scoping the gate to staged config paths would reintroduce the same fail-open hole.
  9. Document the check in .claude/skills/project-check/SKILL.md: a ### 1h. Config path-glob liveness (deterministic) entry under “What this skill checks today” and a matching ### Step 1h entry under “Steps”, following the shape of the 1g pair.
  10. Verify: run the script (expect exit 0 on the current tree), run bun test .claude/skills/project-check/tests/check_config_globs.test.ts, and make a throwaway commit to confirm the lefthook wiring fires.
LocationKindChange
.claude/skills/project-check/check_config_globs.tsnewThe gate: collect marked config path globs from lefthook.yml and .gitignore, assert each scope prefix matches ≥1 path, exit 1 with file:line citations on any zero-match
.claude/skills/project-check/tests/check_config_globs.test.tsnewBun test over temp-dir fixtures: matching case, each moved-directory case, unmarked-.gitignore-rule case, missing-config case, plus direct scopePrefix cases
lefthook.ymlmodifyAdd the project-check-config-globs pre-commit command with no glob: key; annotate the markdown-fmt exclude: entries as gate-covered
.gitignoremodifyAdd the # project-check: glob-must-match sentinel comment above the packages/ts/*/dist/ rule
.claude/skills/project-check/SKILL.mdmodifyAdd the ### 1h. catalogue entry and the matching ### Step 1h runner entry
  • AC-1: bun run .claude/skills/project-check/check_config_globs.ts exits 0 against the current working tree and prints one OK line per collected glob.
  • AC-2: In a fixture tree where solutions/augmented/apps/learn_web/ has been renamed away, the script exits 1 and its output names the pattern solutions/augmented/apps/learn_web/** and the citation lefthook.yml:<line>.
  • AC-3: In a fixture tree where packages/ts/ has been renamed away, the script exits 1 and its output names the pattern packages/ts/*/dist/ and the citation .gitignore:<line>.
  • AC-4: The gate’s collected set is exactly the entries under the markdown-fmt command’s exclude: key in lefthook.yml plus the .gitignore rules immediately preceded by # project-check: glob-must-match. A .gitignore rule without that sentinel — asserted with the apps/sdlc/dist/ rule, which names a path absent from a clean tree — does not appear in the output and does not fail the gate.
  • AC-5: lefthook.yml contains a project-check-config-globs key under pre-commit.commands whose mapping has no glob: key, so the gate runs on every commit regardless of which paths are staged.
  • AC-6: bun test .claude/skills/project-check/tests/check_config_globs.test.ts passes, covering all five end-to-end cases in Approach step 6 plus the scopePrefix unit cases.
  • AC-7: .gitignore line 35’s packages/ts/*/dist/ rule is immediately preceded by the # project-check: glob-must-match sentinel line.
  • AC-8: .claude/skills/project-check/SKILL.md contains both a ### 1h. heading and a ### Step 1h heading naming check_config_globs.ts.
  • AC-9: A commit that stages only a file outside both config files (e.g. a docs edit) still runs the gate — demonstrable from lefthook’s own output listing project-check-config-globs.
  • Expressing these scopes as moon/workspace project references (the Goal’s first option). moon’s project graph has no exclude semantics for lefthook and no ignore semantics for git, so a reference-based expression would need a generator that emits both config files. Revisit if a third fail-open glob appears.
  • Auditing the rest of .gitignore. Most rules name build output that legitimately matches nothing on a clean tree, so coverage stays opt-in via the sentinel comment; only packages/ts/*/dist/ is marked in this task.
  • The glob: keys on the markdown-fmt and rust-fmt-check commands (*.md, **/*.rs). Those are extension filters, not path scopes — there is no directory for them to fail open on.
  • Path globs in .moon/workspace.yml, root package.json workspaces, and root Cargo.toml members. Those tools already error loudly on an unresolvable member, so they do not fail open.
  • Repairing the 107 semantically-reflowed vendored files or the uncommitted dist/ exposure from T-C9RD-consolidate-augmented-into-solutions; that remediation belongs to T-C9RD.
  • none

Spawned by /sdlc:spawn-task-pr on 2026-07-20 UTC from T-C9RD-consolidate-augmented-into-solutions in https://github.com/sksizer/dev.


← Back to Tasks