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 (thenapps/learn_web/**, since repaired tosolutions/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 Coursesto## Video Courses. (2).gitignore’spackages/ts/*/dist/rule stopped covering eight moved packages, leaving build output committable; nothing had landed yet, but the firstmoon run :buildover 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(thepackages/ts/*/dist/rule).
| Location | Role today |
|---|---|
lefthook.yml | The 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 |
.gitignore | The 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.md | The /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.yml | moon’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 |
Proposed
Section titled “Proposed”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:
lefthook.yml— every entry under themarkdown-fmtcommand’sexclude:key. These are always path scopes, so no sentinel is needed..gitignore— only rules immediately preceded by a# project-check: glob-must-matchsentinel comment. Most.gitignorerules 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).
Approach
Section titled “Approach”- Write
.claude/skills/project-check/check_config_globs.tsas a#!/usr/bin/env bunscript matching the house shape ofcheck_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-globOK/FAILlines on stdout and a summary on stderr. Exit codes:0all globs match,1at least one zero-match,2the script itself failed (config file missing, unparseable YAML). - Resolve the repo root from
import.meta.urlthe same way the sibling scripts do, so the script works from any cwd and from a linked worktree. - Implement glob collection:
lefthook.yml— parse the YAML and readpre-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 citelefthook.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-matchis collected with its line number.
- 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 withBun.Globwhen it still contains a wildcard, otherwise with a plainexistsSync— counting a match only when it is inside the repo root. - 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. - Add
.claude/skills/project-check/tests/check_config_globs.test.tsexercisingscopePrefixdirectly plus end-to-end runs over temp-dir fixtures: (a) all globs match → exit 0; (b) thesolutions/augmented/apps/learn_web/**target renamed away → exit 1 citinglefthook.yml; (c)packages/ts/renamed away → exit 1 citing.gitignore; (d) a.gitignorerule with no sentinel whose path is absent → still exit 0; (e) a missinglefthook.yml→ exit 2. - Add the
# project-check: glob-must-matchsentinel abovepackages/ts/*/dist/in.gitignore, and a short comment above themarkdown-fmtexclude:entries inlefthook.ymlnoting they are gate-covered by this check. - Wire the gate into
lefthook.ymlas a newproject-check-config-globspre-commit command runningbun run .claude/skills/project-check/check_config_globs.ts. Place it with the otherproject-check-*commands and give it noglob: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. - 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 1hentry under “Steps”, following the shape of the1gpair. - 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.
Files to touch
Section titled “Files to touch”| Location | Kind | Change |
|---|---|---|
.claude/skills/project-check/check_config_globs.ts | new | The 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.ts | new | Bun test over temp-dir fixtures: matching case, each moved-directory case, unmarked-.gitignore-rule case, missing-config case, plus direct scopePrefix cases |
lefthook.yml | modify | Add the project-check-config-globs pre-commit command with no glob: key; annotate the markdown-fmt exclude: entries as gate-covered |
.gitignore | modify | Add the # project-check: glob-must-match sentinel comment above the packages/ts/*/dist/ rule |
.claude/skills/project-check/SKILL.md | modify | Add the ### 1h. catalogue entry and the matching ### Step 1h runner entry |
Acceptance criteria
Section titled “Acceptance criteria”- AC-1:
bun run .claude/skills/project-check/check_config_globs.tsexits0against the current working tree and prints oneOKline per collected glob. - AC-2: In a fixture tree where
solutions/augmented/apps/learn_web/has been renamed away, the script exits1and its output names the patternsolutions/augmented/apps/learn_web/**and the citationlefthook.yml:<line>. - AC-3: In a fixture tree where
packages/ts/has been renamed away, the script exits1and its output names the patternpackages/ts/*/dist/and the citation.gitignore:<line>. - AC-4: The gate’s collected set is exactly the entries under the
markdown-fmtcommand’sexclude:key inlefthook.ymlplus the.gitignorerules immediately preceded by# project-check: glob-must-match. A.gitignorerule without that sentinel — asserted with theapps/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.ymlcontains aproject-check-config-globskey underpre-commit.commandswhose mapping has noglob: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.tspasses, covering all five end-to-end cases in Approach step 6 plus thescopePrefixunit cases. - AC-7:
.gitignoreline 35’spackages/ts/*/dist/rule is immediately preceded by the# project-check: glob-must-matchsentinel line. - AC-8:
.claude/skills/project-check/SKILL.mdcontains both a### 1h.heading and a### Step 1hheading namingcheck_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.
Out of scope
Section titled “Out of scope”- 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; onlypackages/ts/*/dist/is marked in this task. - The
glob:keys on themarkdown-fmtandrust-fmt-checkcommands (*.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, rootpackage.jsonworkspaces, and rootCargo.tomlmembers. 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.
Dependencies
Section titled “Dependencies”- none
Discovery context
Section titled “Discovery context”Spawned by /sdlc:spawn-task-pr on 2026-07-20 UTC from T-C9RD-consolidate-augmented-into-solutions in https://github.com/sksizer/dev.