bf0547992814979b5dc7d0af8003ffbd75eec9dd
5217 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
bf05479928 | Merge pull request #3405 from open-gsd/refactor/3309-health-diagnostic-rule-table | ||
|
|
3e70e57e37 |
fix(#3309): applyRepairs risk-gating test used a fake, non-writable cwd
CI caught what the bench run didn't: "row 12" (every NONE-risk action
must actually apply) called applyRepairs('/fake/cwd', ...) — a literal
path that doesn't exist on disk. This was fine when applyRepairs's
handlers were stubs (pre-migration skeleton), but real handlers now
read/write actual files: createConfig writes config.json,
addNyquistKey/addAiIntegrationPhaseKey read it before patching. Against
a genuinely non-existent path these now correctly fail (ENOENT), and
an earlier fix in this same PR (applied only receives a code on real
success) correctly surfaces that as a failure instead of masking it —
so 3 of 4 codes stopped landing in `applied`, deterministically, on
any environment that actually enforces ENOENT against /fake/cwd.
Uses a real temp project (createTempProject + a valid config.json)
instead. Row 11 (DESTRUCTIVE refusal) and the ADVISE-skip test are
unaffected — both paths return before any handler touches the
filesystem, confirmed by reading applyRepairs's dispatch order.
|
||
|
|
1fdc953978 | docs(#3309): backfill changeset pr numbers with #3405 | ||
|
|
580a0c95a4 |
docs(#3309): update FEATURES.md's Health Validation requirements
REQ-HEALTH-05 said --repair auto-fixes recoverable issues without qualification — now inaccurate since DESTRUCTIVE-risk remedies are reported but never auto-applied. Adds REQ-HEALTH-06 for --backfill, previously unmentioned in this requirements register. |
||
|
|
c67992de87 |
docs(#3309): document --backfill and the --repair DESTRUCTIVE-refusal change
docs/COMMANDS.md's /gsd-health section never documented --backfill at all, and predates this phase's breaking changes: --repair no longer auto-applies resetConfig/regenerateState (both destructive), and W021/ W017 split into W026/W027 for their previously-conflated second subjects. Required by lint:docs, which needs a docs/ touch alongside any Changed-type changeset fragment. |
||
|
|
ce0999bf84 |
test(#3309): update stale W002/W020 test expectations to match the fixes
Both tests were written against the pre-fix behavior and never updated once the real fixes landed: - state-consistency.test.cjs's "KNOWN GAP" test hardcoded the expectation that W002 incorrectly fires for a STATE.md phase reference whose only home is an archived milestone — that gap is now closed (0 diagnostics, confirmed against real buildPlanningSnapshot output), so the test is renamed and its expectation flipped. - worktree-health.test.cjs's two W020 tests asserted the OLD single combined "timed out or failed" message/remedy — verified against the real pre-migration src/verify.cts:2204-2219 that git_timed_out and git_list_failed always had distinct messages; the fix that restored this distinction is correct, these tests just never caught up to it. |
||
|
|
7ddcc19823 |
fix(#3309): acknowledge health.md growth, fix stale doc-consistency tests
gsd-test found two independent gaps around the newly-generated health.md tables: - emitted-attribution.test.cjs requires an acknowledgment for health.md's 2271-byte growth (16-code hand-maintained table -> 34-row generated table, this phase's explicit acceptance criterion). Adds tests/emitted-drift-acks/3309-health-docs-generated.json. Removes 2573-state-head-freshness.json's now-inert health.md entry (that fragment's growth already landed on origin/next, the diff base this branch is compared against, so it has nothing left to acknowledge — and the guard forbids two fragments naming the same path). - runtime-converters.test.cjs's health.md content-consistency checks asserted stale text from the old hand-written table: a regex that false-positived on the new table's own unrelated W020 row (worktree scan degradation, a different diagnostic than the W025 isolation warning it was meant to detect), and anchors expecting the old table's exact last row / footnote wording. Narrowed the regex to require the literal use_worktrees config key, and updated the anchors to the new table's real shape (I001/I010 as the last rows, the new generated-table footnote). |
||
|
|
eae2b52e4a |
fix(#3309): W020 fires on any degraded worktree scan, not just real failures
gsd-test found buildWorktreeHealthField collapsed every inspectWorktreeHealth failure reason (git_timed_out, git_list_failed, not_a_git_repo) into one UNREADABLE scope, discarding which one. The migrated checkW020 then warned unconditionally on any UNREADABLE scope — but the original (verify.cts:2202-2217) only warned on git_timed_out/git_list_failed, staying silent on not_a_git_repo (a .planning/-only fixture with no git repo at all is not a degraded scan, just the absence of one). This spuriously degraded every test fixture that isn't a real git repo. planning-snapshot.cts's worktreeHealth field now carries `reason` through instead of discarding it; checkW020 branches on it exactly like the pre-migration code did. |
||
|
|
96c7ea9b35 |
fix(#3309): W023's message drops each colliding directory's status
gsd-test found the migrated W023 dropped a piece of information the original message included: each colliding phase directory's overall status (e.g. "Complete"), not just its raw plan/summary/verification counts. Adds derivePhaseStatusLabel, reconstructing the status label from already-exposed PhaseSnapshot fields (planCount/summaryCount/ complete/verificationStatus) — no new ambient I/O, no new snapshot field. Also fixes a non-conforming test fixture found while verifying: tests/health-validation.test.cjs's "05-real" fixture used a bare VERIFICATION.md, which readVerificationStatus never matches (the real convention, and every other fixture in this repo, use the *-VERIFICATION.md suffix) — the status was always reading as "missing" regardless of message formatting. Renamed to 05-real-VERIFICATION.md. |
||
|
|
a0c82f2bd8 |
fix(#3309): W002/W011/W026 state-consistency regressions
gsd-test found three real regressions in the migrated STATE.md checks: - W002 didn't exempt phase refs whose only directory lives in an archived milestone (#3652) — now consults planning-snapshot.cts's archivedPhaseTokens field (added alongside this fix, shared with W006's identical need). - W011 (STATE/ROADMAP cross-validation) never fired: currentPhaseLabel only read the current template's bare "Phase:" field, silently missing legacy STATE.md fixtures that use the older bold "**Current Phase:**" field (mirrors state.cts's own resolveStatePhase fallback ladder, which the migration didn't carry over). - W026 (STATE milestone-complete vs. unstarted ROADMAP phases) had two independent defects: roadmapDeclaredPhases's milestone attribution can't see <details>/<summary>-shaped ROADMAP sections, and current- milestone resolution could go null — both silently emptied the "unstarted" set every time. Fixed by scoping ROADMAP.md to the current milestone via the same <details>-tolerant extractCurrentMilestone every other milestone-aware consumer uses, in a new dedicated planning-snapshot.cts field (currentMilestoneRoadmapPhaseIds) rather than reusing roadmapDeclaredPhases, which exists for a narrower, <details>-blind derivation (W021's own original logic) and would have regressed it if repurposed. Also fixes an unrelated drift-guard violation this same rule file introduced: its own phase-token regex was independently re-derived instead of built from the canonical PHASE_NUMBER_TOKEN_SOURCE. |
||
|
|
ce57e60098 |
fix(#3309): W006/W007 miss archived phases and phase-id variant normalization
gsd-test found two real regressions in the migrated W006/W007: 1. A phase whose directory lives under an archived milestone (.planning/milestones/v*-phases/<phase>/) instead of the active phases/ dir read as "in ROADMAP but no directory on disk" — the original forEachArchivedPhaseToken(planBase, ...) fed archived tokens into the same existence check (verify.cts:2038); the migrated rule's allPhaseDirNames never included them. 2. Comparing a ROADMAP-declared phase id against a disk directory name dropped phaseVariants() normalization the original ran as a second, independent check (verify.cts:2071-2073/2092-2093) — a ROADMAP "01A" and a disk "1A-..." read as mismatched instead of the same phase, since matchPhaseDirs's own token comparison never unifies that padding/letter-suffix difference. Adds planning-snapshot.cts's archivedPhaseTokens field (mirrors forEachArchivedPhaseToken/listMilestoneArchiveDirs exactly, no new regex derivation) and a phaseVariants()-based fallback in dirsForPhase when matchPhaseDirs finds nothing. |
||
|
|
23884a6448 |
docs(#3309): add changeset fragments for the breaking changes
Three user-visible changes disclosed per CONTRIBUTING.md's changeset convention: --repair no longer auto-applies DESTRUCTIVE remedies (Changed), W021/W017 split into W026/W027 for their previously- conflated second subjects (Changed), and --backfill alone now actually works (Fixed, a latent-bug fix). pr:0 placeholder, backfilled once the PR number is known. |
||
|
|
041414c4ad |
feat(#3309): generate health.md's error-code and repair-action tables
Closes the issue's explicit acceptance criterion: "health.md's tables are generated rather than hand-maintained, closing the 16-vs-30+ documentation gap structurally." The published roster listed 16 codes against 30+ actually emitted; W010-W017 and W020-W023 had never been documented. Adds description/repairable as static fields on Rule (health-diagnostic-types.cts) — generation needs a fixed, human-readable summary per code, distinct from the dynamic per-instance Diagnostic.message a rule's check() produces. repairable is true only when --repair will actually apply the remedy: false for ADVISE-only rules AND for DESTRUCTIVE-risk rules (regenerateState/resetConfig), which are described but never auto-applied — matches verify.cts's diagnosticToIssueEntry semantics exactly, after fixing E004/E005's static field to agree with it (both were wrongly true, an inconsistency caught during this same commit's own review, not left for later). New scripts/gen-health-docs.cjs (--write/--check, wired into lint:generated-sync) regenerates the two tagged table regions in gsd-core/workflows/health.md from RULES (31 rules) plus the 3 pre-checks that stay outside the rule table by design (E001, E010, I010) plus a small static Effect/Risk lookup for the 6 real repair actions — including addAiIntegrationPhaseKey, live in code since an earlier phase but never documented until now. 34 error-code rows, 6 repair-action rows. The table's old "grep verify.cts for the next free number" footnote is rewritten to point at the rule table and its lint guard instead. |
||
|
|
6a1860c579 |
docs(#3309): fix CONTEXT.md's stale Health Diagnostic Module entry
Still said RULES ships empty and repair handlers are stubs — true when the skeleton batch first wrote this glossary entry, false since the migration landed (RULES holds 31 wired rules, applyRepairs has real per-action handlers). Found by the Standards-axis orthogonal review. |
||
|
|
4f9e5cf2ed |
fix(#3309): make the lint guard's W024 exemption explicit, not accidental
W024's committed rule (state-consistency.cts) is a documented permanent no-op — its real check runs in cmdValidateHealth itself, outside the rule table, since readStateHeadFreshness needs a git-log shell-out no Rule.check may perform. The guard's §8.5 fixture-proof check previously "passed" for W024 only because some test file's title happened to contain the string "W024" — not because any fixture actually proves it fires, which it structurally never can. Found by the Spec-axis orthogonal review. Adds an explicit PERMANENTLY_INERT_CODES map (currently just W024, with its reason recorded) that checkFixtureProofInvariant reports separately from real coverage. The guard's PASS output now says "30 covered by a real fixture, 1 exempted" instead of implying uniform proof — a code with no coverage and no exemption entry still fails. |
||
|
|
8e1ff76204 |
refactor(#3309): consolidate adviseRemedy() into the shared types leaf
adviseRemedy() was defined identically in two of the eight rule-group
files (config-validation.cts, agent-install.cts) while the other six
repeated the same {action: ADVISE, risk: NONE, args: {command}} object
literal inline ~20+ times. Found by the Standards-axis orthogonal
review (Duplicated Code smell).
Moves the one-line helper into health-diagnostic-types.cts, the leaf
module every rule-group file already imports for its enums/types, and
uses it consistently across all 8 files. Pure mechanical refactor — no
ADVISE remedy's command text, code, or action changed.
|
||
|
|
03258a07a2 |
fix(#3309): applyRepairs must not count a failed repair as applied
applyRepairs pushed a diagnostic's code onto applied unconditionally after the try/catch around runRepairAction, even when the handler threw (caught, recorded in details with success:false) or otherwise failed — making applied mean "attempted" rather than "succeeded," with no test exercising the failure path. Found by the Spec-axis orthogonal review. applied now only receives a code when the repair actually succeeded; a failed attempt is still fully recorded in details (success:false, the error message) but no longer misreported as applied. Adds a regression test forcing addNyquistKey to throw (ENOENT on a config.json that doesn't exist) and asserts it lands in details, not applied. |
||
|
|
1255f960db |
fix(#3309): restore W027's active-worktree exclusion
The migrated checkW027 (stale worktree) dropped the pre-migration exclusion of the CLI's own current worktree, since a Rule.check(snapshot) has no cwd access (§8.1 rule 1 forbids ambient I/O) — flagged as a disclosed regression during this phase's own design work, then confirmed as a real, fixable gap by the Spec-axis orthogonal review rather than an inherent limitation. Fixes it properly instead of accepting the regression: buildPlanningSnapshot(cwd) already receives cwd as its own input, so exposing it as snapshot.cwd is not new ambient I/O, just surfacing an existing parameter — fully consistent with §8.1 rule 2's "parsed value" allowance. checkW027 now excludes the entry matching snapshot.cwd before flagging, matching the original verify.cts:2233-2242 behavior exactly. |
||
|
|
42729b21fa |
fix: scan bin/lib subdirectories in the inventory-manifest generator
gen-inventory-manifest.cjs's cli_modules family did a flat readdirSync of gsd-core/bin/lib/, invisible to anything shipped in a subdirectory. Found while registering this phase's 8 health-diagnostic-rules/*.cjs files in docs/INVENTORY.md (Standards-axis review) — the automated manifest cross-check couldn't see them even though the manual INVENTORY.md rows were correct. Adds collectOneLevelSubdirs (mirrors the existing collectNested's defensive statOrNull style) and merges flat + one-level-subdirectory results into cli_modules's single sorted array, using the same <subdir>/<file>.cjs key format INVENTORY.md's rows already use. Regenerating the manifest surfaced that three OTHER existing subdirectories (installer-migrations/, host-integration-adapters/, observability/ — pre-existing, unrelated to this phase) were equally invisible and had zero docs/INVENTORY.md rows at all. Added all 15 missing rows rather than leave a gap the fix itself just exposed. Also fixes 3 pre-existing lint-legacy-dir-name violations in the installer-migrations rows (legitimate references to the historical get-shit-done -> gsd-core rename these migrations clean up — marked with the guard's own gsd-allow-legacy-name exemption) and a stale health-diagnostic.cjs row that still said "RULES ships empty." |
||
|
|
9f8bcfa3a6 |
docs(#3309): disclose hardcoded-slash-command fidelity reduction consistently
root-existence.cts (E002, E003) and phase-structure.cts (W009) hardcode a canonical hyphen-form slash command in their ADVISE remedy, same as config-validation.cts's already-disclosed W016 — forced by §8.1 rule 1 (a Rule's check(snapshot) cannot call the runtime-resolved slash() formatter, which needs cwd). Only config-validation.cts's own header disclosed this tradeoff; the other two sites had it happen without recording it in their own file. Adds the same disclosure to both, matching the established convention. No behavior change. |
||
|
|
d1760e3c31 |
refactor(#3309): migrate cmdValidateHealth onto the rule table
Replaces cmdValidateHealth's hand-rolled addIssue/switch accumulation
(961 lines) with buildPlanningSnapshot -> evaluateRules -> map to the
legacy {code, message, fix, repairable} shape, bucketed by severity.
Two pre-checks (home-dir E010/I010, .planning/-root-missing E001) stay
outside the rule table entirely, per ADR-3180 §8.2 rule 4 ("no
precedence system") — building "some rules suppress others" into the
table would itself be the forbidden precedence system.
W024 (STATE.md commit-age freshness) also stays outside the table:
its committed rule is a documented permanent no-op (readStateHeadFreshness's
git-log shell-out is ambient I/O a Rule.check may never perform, and no
PlanningSnapshot field carries a commits-behind count). Migrating onto
the rule table as designed would have silently regressed 7 passing
tests in tests/health-validation.test.cjs — found while wiring this
function, kept as a real check in the wrapper instead (same I/O
license applyRepairs already relies on), fixed inline per this repo's
no-defer policy rather than accepted as a silent loss.
Ports the real repair-handler bodies (createConfig/resetConfig,
regenerateState, addNyquistKey/addAiIntegrationPhaseKey,
backfillMilestones) into health-diagnostic.cts's applyRepairs,
replacing the skeleton's stub. DESTRUCTIVE-risk remedies
(resetConfig/regenerateState) are refused by --repair — a disclosed
breaking change; repairable now means "an automatic repair will
actually run," not merely "a remedy exists to describe," so E004/E005
now report repairable:false. --backfill alone now actually triggers
backfillMilestones, fixing a latent bug where its gate was unreachable
without --repair also being set (verify.cts:2504, confirmed dead code
pre-migration).
Test updates distinguish the two explicitly-authorized behavior
changes (DESTRUCTIVE refusal, backfill-alone fix, W021->W026 split)
from preservation — every changed assertion is commented with why, and
new regression tests were added for both changes plus W021/W026
mutual independence. Drift-guard bookkeeping (bypass-baseline shrunk
to the one disclosed W024 exception, milestone-window and
phase-enumeration exemptions, test-file-count allowlist) updated for
the relocated/new functions this migration introduces.
|
||
|
|
acc1a7abd6 |
feat(#3309): add health-diagnostic rule-table lint guard
Enforces ADR-3180 §8.2's 1:1 rule-code invariant (every code unique, every severity a property of the Rule) and §8.5's fixture-proof invariant (every code has a describe()/test() block naming it, verified statically against tests/health-diagnostic-rules/*.test.cjs and tests/health-diagnostic.test.cjs) for the new RULES table. Adapted from the design doc's original plan of separate tests/fixtures/health-diagnostic/<code>.* files: implementation used inline temp-dir fixtures instead (mirrors tests/planning-snapshot.test.cjs), so coverage is checked statically against test-file structure, mirroring lint-fix-has-regression-test.cjs's house style. Wired into lint:ci adjacent to lint-planning-snapshot-bypass-drift.cjs, its closest sibling. Passes clean against the real tree: 31 codes, all unique, all covered. |
||
|
|
e0021a2fed |
refactor(#3309): extract health-diagnostic-types leaf module, wire RULES
Wiring all 8 rule-group files into health-diagnostic.cts's RULES array created a genuine CJS circular dependency: each group file required health-diagnostic.cjs back for the shared enums, and health-diagnostic.cjs now required the group files forward, so the enums were undefined mid-load (destructuring health-diagnostic.cjs's still-unassigned exports). Fixes it by splitting the enums/types (SEVERITY, REMEDY_ACTION, REMEDY_RISK, Remedy, Diagnostic, Rule) into a dependency-free leaf module, health-diagnostic-types.cts, that both sides import instead of each other. health-diagnostic.cts re-exports the enums for existing consumers. RULES is now the real concatenation of all 8 groups (31 codes — E001 intentionally stays a pre-check outside the table). |
||
|
|
c469aeeacd |
refactor(#3309): add milestone-archive-hygiene health-diagnostic rules
W018, W019 — MILESTONES.md archive completeness and unrecognized root .md file checks, migrated onto the frozen rule table per ADR-3180 §8.2. |
||
|
|
a56679a4ca |
refactor(#3309): add worktree-health health-diagnostic rules
W020 (3 conditions), W017, W027 — git worktree list degradation, orphan and stale worktree checks, migrated onto the frozen rule table per ADR-3180 §8.2. W027 has a documented fidelity reduction: rules have no cwd access, so it can no longer exclude the active worktree. |
||
|
|
9e74f00ba0 |
refactor(#3309): add roadmap-disk-consistency health-diagnostic rules
W006, W007 — ROADMAP entries with no matching disk dir, and disk dirs with no ROADMAP entry, migrated onto the frozen rule table per ADR-3180 §8.2. |
||
|
|
f51f00ef0e |
refactor(#3309): add agent-install health-diagnostic rules
W010 — agent install completeness across its 4 internal conditions, migrated onto the frozen rule table per ADR-3180 §8.2. |
||
|
|
1b09fb13d3 |
refactor(#3309): add phase-structure health-diagnostic rules
W005, W023, I001, W009 — phase directory naming, duplicate phase keys, plan-summary presence, and validation-architecture checks, migrated onto the frozen rule table per ADR-3180 §8.2. |
||
|
|
80484249df |
refactor(#3309): add config-validation health-diagnostic rules
W003, E005, W004, W008, W016, W012, W013, W014, W015, W022 — config.json existence, parseability, and field-validity checks, migrated onto the frozen rule table per ADR-3180 §8.2. |
||
|
|
8a7ef78906 |
refactor(#3309): add state-consistency health-diagnostic rules
W024 (deliberately inert, no snapshot field yet for stale state_head), W002, W011, W021, W026 — STATE.md cross-checks against ROADMAP/config, migrated onto the frozen rule table per ADR-3180 §8.2. |
||
|
|
7195308384 |
refactor(#3309): add root-existence health-diagnostic rules
E002, E003, E004, W001 — PROJECT.md/ROADMAP.md/STATE.md existence and PROJECT.md section-completeness checks, migrated onto the frozen rule table per ADR-3180 §8.2. |
||
|
|
cc1ec5b0fd |
chore(#3309): register health-diagnostic-rules/*.cjs as generated artifacts
Mirrors the existing health-diagnostic.cjs / planning-snapshot.cjs pattern: gitignore the compiled output and exclude it from eslint so the generated JS isn't linted as hand-written source. Also adds the CONTEXT.md glossary entry and INVENTORY.md rows for the new src/health-diagnostic-rules/ directory. |
||
|
|
8c9ca3f7c6 |
refactor(#3309): add allPhaseDirNames field to planning-snapshot
W007 (orphan disk dir with no ROADMAP entry) cannot be sourced from phaseDirs, which is windowed to ROADMAP-declared phases only — an orphan dir can never appear in an already-ROADMAP-filtered set. Adds an unwindowed allPhaseDirNames field so the rule can actually fire. |
||
|
|
c5543e533c |
refactor(#3309): extend planning-snapshot.cts with 8 more parsed fields
Phase 11 of epic #3180 (ADR-3180 §8.1 rule 2). PlanningSnapshot grows from 7 fields to 15: projectSections, statePhaseTokens, stateStatus, roadmapDeclaredPhases, roadmapPhaseCheckboxes, researchValidationStatus, milestoneArchiveStatus, planningRootFiles. Every field is a reused owner (buildRoadmapPhaseVariants/ buildNotStartedPhaseVariants from src/validate.cts, stateFieldValue) or a small relocation of already-working verify.cts logic (PHASE_NUMBER_TOKEN_SOURCE scanning, the checkMilestonePrefixMismatches sectionRx walk, W009/W018's file-existence checks) — never a new algorithm, and never raw document text: §8.1 rule 2 forbids exposing raw text, not exposing a parsed list or boolean derived from it once by the snapshot builder. roadmapPhaseCheckboxes deliberately reads the same ROADMAP checkbox isPhaseComplete (§7.4, disk-strict) refuses to consult — that owner decides completion and must not read it; this field only exposes what the checkbox says, for a diagnostic (W011) whose whole purpose is flagging disagreement. Not a re-derivation of §7.4, recorded explicitly to prevent that reading. Adds PROJECT_UNREADABLE to UNUSABLE_REASON (ninth #1879 site), closing a gap the implementing agent correctly flagged rather than silently leaving absent-vs-corrupt collapsed for PROJECT.md, matching the STATE_UNREADABLE/ CONFIG_UNREADABLE precedent from this same effort's prior commits. currentPhaseLabel/statePhaseTokens/stateStatus share one STATE.md read (buildStateFields) rather than three independent reads. Additive only — all prior fields and worstScope/buildPhaseSnapshot unchanged. |
||
|
|
ef10bba707 |
refactor(#3309): add health-diagnostic.cts skeleton (types + evaluator)
Phase 11 of epic #3180 (ADR-3180 §8.2/§8.3/§8.5). New src/health-diagnostic.cts: SEVERITY/REMEDY_ACTION (7 members: 6 real repair actions + ADVISE)/REMEDY_RISK (NONE/DESTRUCTIVE) frozen enums, Diagnostic/Remedy/Rule types, an empty RULES table (rules land in the next commits), evaluateRules (with a duplicate-code defense-in-depth check ahead of the lint guard), and applyRepairs (the DESTRUCTIVE-risk-refusal dispatcher — §8.3 rule 3 — with stub handlers; real repair bodies port in the migration step). Six-gate .cts ripple: .gitignore, eslint.config.mjs, docs/INVENTORY.md + manifest, CONTEXT.md glossary entry. |
||
|
|
6aa378b261 |
refactor(#3309): extend planning-snapshot.cts with config/agentInstall/worktreeHealth
Phase 11 of epic #3180 (ADR-3180 §8.2/§8.3/§8.5) foundation. Extends the already-merged Phase-10 PlanningSnapshot additively with three fields the upcoming health-diagnostic rule table needs and Phase 10 never required: - config: {value, scope, exists} — parsed .planning/config.json. `exists` distinguishes absent (no diagnostic, non-answer) from present-but-invalid (CONFIG_UNREADABLE diagnostic, corruption) — both collapse to scope UNREADABLE, so a rule needs the extra bit to tell "not configured yet" apart from "config.json is broken." - agentInstall / worktreeHealth — not .planning/-sourced, wrap the existing checkAgentsInstalled/inspectWorktreeHealth owners with the same arguments cmdValidateHealth already passes them, so a later migration step reads these fields instead of calling the owners itself. Adds CONFIG_UNREADABLE to src/unusable-input.cts's UNUSABLE_REASON (eighth #1879 site), mirroring STATE_UNREADABLE's exact shape from Phase 10. Additive only — the four Phase-10 fields and worstScope/buildPhaseSnapshot are unchanged; existing tests for them are untouched. |
||
|
|
3c4df10a50 | Merge pull request #3402 from open-gsd/refactor/3308-planning-snapshot-parsed-projection | ||
|
|
2bd1d12369 |
fix(#3308): compare guard-produced file fields against POSIX-normalized paths in Windows CI
PR #3402's real Windows CI (windows-latest node22/24) caught what gsd-test's Linux-only lanes structurally cannot: tests/planning-snapshot-bypass-drift.test.cjs compared the guard's own POSIX-normalized output (findSnapshotBypassDrift's `file` field, dedupeViolationsForBaseline/sortEntries entries, a written baseline read back from disk) against REGISTERED_FILE, which is built via path.join('src', 'verify.cts') and is therefore backslash-separated on Windows. The guard always normalizes its OUTPUT to POSIX via toPosixRel regardless of the input separator form, so the comparison only ever coincidentally passed on POSIX. Adds REGISTERED_FILE_POSIX for every assertion against a guard-PRODUCED value (including baseline fixtures fed into diffAgainstBaseline, which are matched by exact string key against the guard's normalized output). REGISTERED_FILE itself is unchanged and still used, correctly, everywhere it is the relPath INPUT to findSnapshotBypassDrift or a DIAGNOSTIC_RULE_FUNCTIONS Map-key lookup — both need the platform-native form to match the guard's own Map key, which is also path.join-constructed. No behavior change on POSIX (both constants are byte-identical there). |
||
|
|
1f5fc24610 | chore(#3308): backfill changeset PR number (#3402) | ||
|
|
9ce44efe85 | docs(#3308): add changeset for planning-snapshot parsed projection | ||
|
|
2283168612 |
fix(#3170): anchor milestone one-liner extraction to a summary-shaped heading (#3401)
* test(#3170): extractOneLinerFromBody must anchor to a summary-shaped heading Regression for #3170: the function matched the first heading's first bold run, so an incidental first heading (rule list, deviation notes) contributed its bold text as the milestone accomplishment. Rows 1/2 fail RED on next; rows 3/4 guard Overview recognition and the #2660 Summary-heading form. * fix(#3170): anchor milestone one-liner extraction to a summary-shaped heading extractOneLinerFromBody matched the first heading's first bold run regardless of section, so an incidental first heading (rule list, deviation notes) contributed its bold text as the milestone accomplishment written into MILESTONES.md. Iterate headings and extract from the first Summary/Overview/ Accomplishments one with a bold run, falling back to null when none exists. The #2660 Summary-heading forms and the frontmatter one-liner precedence are preserved. * docs(#3170): add changeset * test(#3170): align extractOneLinerFromBody unit fixtures with summary-heading contract The core-utils unit fixtures used generic # Title headings encoding the old 'any first heading' contract; the #3170 fix anchors to a Summary/Overview/ Accomplishments heading (the function is summary-specific). Update the heading text to Summary-shaped; the extraction assertions (bold, frontmatter strip, colon-label, CRLF, unicode) are unchanged. * docs(#3170): backfill changeset PR number (3401) --------- Co-authored-by: sim <sim@local> |
||
|
|
21c46ecf52 |
fix: scope safe.directory ownership bypass to the real-repo-root git ls-files call
Found while running gsd-test for #3308: tests/commit-files-pathspec.test.cjs's repo-wide `--files` scan runs `git ls-files -z -- *.md` directly against the checked-out repo root (not a createTempGitProject() fixture, unlike every other gitOrThrow call in this file). Inside a container-provisioned test runner the checkout's on-disk owner can legitimately differ from the running UID, tripping git's CVE-2022-24765 dubious-ownership guard and failing the scan closed (exitCode 128) rather than reporting a real file-list result — reproduced on gsd-test's linux-node22 and linux-node24 lanes. Adds `-c safe.directory=*` to that ONE invocation only, so the bypass is scoped to this call rather than a global `git config` write that would leak into every other git call in the process. No source behavior changed; test-infrastructure resilience only. |
||
|
|
97876a77a4 |
docs(#3308): ADR-3180 §8.1 Enforced, Amendment 9 — Phase 10 validation
Updates docs/adr/3180-planning-semantic-model-single-owner.md to reflect Phase 10 shipping: §8.1 status Required -> Enforced (Phase 10, #3308), guard-roster row contract only -> enforced, phase-index table issue/status backfilled, and a new Amendment 9 recording the guard's real baseline (15 distinct raw-read sites, 21 total acknowledged occurrences in cmdValidateHealth) against the issue's own vaguer estimate, per Amendment 4a's standing "N found by the guard, never per the epic" rule. Also records the intended reading of an absent STATE.md as UNREADABLE-without-diagnostic, symmetric with every other §7 owner's absence-vs-corruption distinction. |
||
|
|
2538fd6344 |
refactor(#3308): add planning-snapshot.cts parsed projection per ADR-3180 §8.1
Phase 10 of epic #3180. src/planning-snapshot.cts is a new parsed projection of .planning/, composed exclusively from the already- consolidated §7 owners (getMilestoneInfo, listMilestonePhaseDirs, isPhaseComplete, scanPhasePlans, stateFieldValue, planningPaths) plus the frozen SCOPE enum. No new semantic derivation is introduced beyond worstScope, a pure combinator folding several independently-scoped owner answers into one composite signal. Adds STATE_UNREADABLE to src/unusable-input.cts's UNUSABLE_REASON (seventh #1879 site) for STATE.md exists-but-unreadable, distinct from absent. Adds scripts/lint-planning-snapshot-bypass-drift.cjs, a ratcheted drift guard (ADR-3180 Decision 4(e)) scoped to DIAGNOSTIC_RULE_FUNCTIONS (currently cmdValidateHealth in src/verify.cts only) preventing new raw .planning/ reads from bypassing the snapshot, while acknowledging cmdValidateHealth's existing 15 raw-read sites as debt owned by Phase 11 (#3309). Six-gate .cts ripple: .gitignore, eslint.config.mjs, docs/INVENTORY.md + manifest regen, CONTEXT.md glossary entry. Breaking changes: none. This phase adds the subject only; Phase 11 migrates cmdValidateHealth onto it. |
||
|
|
d1b659703e |
test(#3308): failing-first tests for planning-snapshot parsed projection
ADR-3180 epic #3180 Phase 10 (§8.1): tests for the not-yet-existing src/planning-snapshot.cts (buildPlanningSnapshot, worstScope), the not-yet-existing scripts/lint-planning-snapshot-bypass-drift.cjs guard, and the new STATE_UNREADABLE reason on tests/unusable-input.test.cjs's already-shipped UNUSABLE_REASON enum. RED by construction: the modules under test do not exist yet. |
||
|
|
b8cb031ce2 |
fix(#3163): scope phase.add insertion to the current milestone (#3400)
* test(#3163): phase add must insert in the active milestone, not the trailing archive Regression for #3163: cmdPhaseAdd/cmdPhaseAddBatch pick the insertion point via rawContent.lastIndexOf('\n---'), the file's last horizontal rule — which on a roadmap with shipped/history material after the active phase list sits deep in archive. Rows 1/2/4 fail RED on next (entry lands after the archive heading); row 3 guards the no-milestone legacy fallback. * fix(#3163): scope phase.add insertion to the current milestone window cmdPhaseAdd and cmdPhaseAddBatch picked the insertion point via rawContent.lastIndexOf('\n---') — the file's last horizontal rule, which on a roadmap with shipped/history material after the active phase list sits deep in archive. Extract phaseEntryInsertOffset(rawContent, cwd): scope the search to currentMilestoneRawRanges' primary window so the entry lands at the end of the active phase list. Fall back to the legacy whole-file heuristic when no current milestone resolves, preserving simple no-milestone roadmaps. Applies to both cmdPhaseAdd and cmdPhaseAddBatch (identical expression); the decimal insert path was already header-anchored and is untouched. * docs(#3163): add changeset * docs(#3163): backfill changeset PR number (3400) --------- Co-authored-by: sim <sim@local> |
||
|
|
67a860ca98 |
fix(#3319): mutation-gate has_work=false renders skipped, not success (#3399)
The has_work=false branch previously exit-0'd from a step that ran, so the job's conclusion was `success` -- identical to a PR that actually ran mutation testing and passed. Moved the trivial-pass condition to the job's own `if:`, so the job is SKIPPED (not run) when has_work=false, matching the `coverage-gate` precedent in test.yml and confirmed (via job-level `if:` research plus this repo's own live PR #3392) that a skipped required check does not block merge while still rendering visibly distinct from a pass. First design attempt (splitting into two step-level `if:`-gated steps) was verified WRONG before landing: a job whose every step is individually skipped via step-level `if:` still reports `success`, not `skipped` -- confirmed against documented GitHub Actions behavior, not assumed. Empirically verified via `workflow_dispatch`: pre-fix (run 31652963610, on next): mutation-gate conclusion = success post-fix (see PR): mutation-gate conclusion = skipped Co-authored-by: sim <sim@local> |
||
|
|
0f2a14e3a3 |
fix(#3320): widen c8 --include glob to cover nested gsd-core/bin/lib dirs (#3397)
--include 'gsd-core/bin/lib/*.cjs' (single-star) does not match the 15 nested .cjs files under installer-migrations/, host-integration-adapters/, and observability/ -- confirmed live via `find gsd-core/bin/lib -mindepth 2 -name '*.cjs'`. c8's --all zero-fill is scoped by --include (confirmed via c8 docs), so widening the glob alone fixes both --include and --all together; no separate --all change needed. Thresholds intentionally left unchanged in this commit -- the real lines/branches percentage including the now-visible nested files can only be measured via a real CI run (this repo hard-blocks local node --test), so this push observes CI's actual number before deciding whether scripts/check-coverage-gate.cjs's OVERALL_LINES/OVERALL_BRANCHES (the constants CI's coverage-gate job actually enforces) need re-baselining. Co-authored-by: sim <sim@local> |
||
|
|
3ff9a7ffcd |
fix(#2570): parse leading date from last_activity so stale_activity fires with a description suffix (#2571)
* fix(#2570): parse leading date from last_activity so stale_activity fires with a description suffix templates/state.md prescribes `Last activity: [YYYY-MM-DD] — [What happened]`, and gsd-core's own STATE.md mirrors that suffix into frontmatter. Date.parse on the whole string returned NaN, and because staleActivity treats null as "not stale" (fails open), the only idle/staleness detector never fired on any project whose last_activity kept its description. parseActivityTimestamp now reads the leading ISO date/time token when a whole-string parse fails, validating the calendar date (ADR-227: reject an impossible date rather than let Date.parse roll it forward) and preferring the whole-string parse when it succeeds so a trailing zone name is not dropped. Composes with #3099 (LAST_ACTIVITY_UNPARSEABLE diagnostic), which merged to next after this branch: both key off parseActivityTimestamp === null, so a value whose leading date now parses takes the stale path and does NOT emit the diagnostic. A regression test in tests/smart-entry.unit.test.cjs asserts exactly that (stale true, emission count 0), guarding against two staleness signals on one field. Rebased onto next (flattened): resolved the add/add test conflict by keeping both the #2570 and #3099 describe blocks. Tests: unit + property, 80 pass. * fix(#2570): fail open when a named zone can't be reconstructed from the token (#2571 B1) The 2026-08-08 flatten dropped the zone handling earlier rounds built, so the fallback path -- reached only when a description suffix makes the whole-string parse fail, the #2570 case -- reconstructed `${date}${time}` WITHOUT any named zone. ISO_LEADING_RE's offset group captures only Z / +-HH:MM, so " GMT"/" EST" land in the un-captured suffix; Date.parse then read the reconstruction as LOCAL time, shifting the instant by the host's UTC offset -- a wrong, host-dependent value the diff's own comment warned against but guarded only on the other branch. Fix (the simpler of the two offered in review): when the remainder after the matched token begins with a letter (a named zone we cannot preserve), return null -- fail open to not-stale, matching the base's honest behaviour and ADR-227's "never propagate a wrong instant". The #2570 template suffix (" -- description") starts with a separator, so it still reconstructs and reads stale as intended. Tests (both fail-first, verified RED on the pre-fix head): - smart-entry.unit: a named-zone + description suffix (54 days old) enters the fallback and must read not-stale, not a still-old local instant. Host- independent by construction. - smart-entry.property (f): named-zone + suffix over 1-week..1-year ages and 8 zones stays total and fails open. Discloses the removal M2 flagged: TRAILING_ZONE_RE / UTC_ZONE_NAMES / timeCarriesOffset were dropped by the flatten; this restores the SAFETY (no wrong instant) via the simpler null contract rather than the allowlist. * fix(#2570): narrow the stale_activity fallback guard to a zone-designator shape The round-9 fail-open guard `/^\s*[A-Za-z]/` treated any letter-led remainder as an unpreservable named zone, so a leading real date followed by a bare space/tab/colon and an ordinary description (a hand-edited STATE.md that omits the template em dash) returned null and re-opened #2570 for exactly those shapes. Narrow the guard to ZONE_DESIGNATOR_RE -- a standalone short all-caps run -- and consult it ONLY when the leading token captured a time-of-day: a zone qualifies a clock time, so a bare date carries no zone hazard and always reconstructs to its UTC midnight. A plain description (including one that opens with a tech acronym like "CI green") reconstructs; a real named zone on a timed value (GMT/EST/...) still fails open (ADR-227: never propagate a wrong, host-dependent instant). Widen the property generator to the non-em-dash separators (space/tab/colon), the arm that structurally could not reach the fallback before, and add unit cases for whitespace/tab/colon-separated and bare-date+acronym descriptions. All fail-first on the prior guard; green across UTC/LA/Tokyo/Kiritimati. --------- Co-authored-by: Tom Boucher <trekkie@nomorestars.com> |
||
|
|
6950ae3679 |
test(#2269): repo-wide regression guard for query commit --files scoping (#2290)
* test(#2269): repo-wide regression guard for query commit --files scoping regression protection that fix does not carry: a repo-wide scan, edge-case pins, property tests, and a behavioral test. - Repo-wide scan across all five directories that carry live invocations (gsd-core/workflows, gsd-core/references, agents, commands, skills), so a future unscoped `commit` / `query commit` site fails CI wherever it lands. Backslash-continued lines are joined first, and a quote-parity walk distinguishes a real `--files` flag from one mentioned inside the quoted commit message. - Edge-case pins for the shapes the live content does not exercise: the `query`-less spelling, a flag preceding the command (`--cwd`), a prose mid-sentence mention, and a `commit_docs` JSON-key false positive. - Three `fast-check` properties over the quote-parity logic, following the tests/adr-parser.property.test.cjs precedent. - A behavioral test that stands up a real temp git repo, leaves an unstaged `.planning/` stray plus an unrelated staged file, runs the real `gsd-tools commit --files`, and asserts via git status/diff that only the intended artifact landed. The scope is derived from secure-phase.md's own commit line, so reverting that line's `--files` fails this test too. Census over the five roots at this base: 87 invocations, 0 unscoped. * test(#2269): scan mid-prose argument-bearing invocations too The repo-wide guard's INVOCATION_RE is line-start-anchored, which keeps bare backtick mentions out of scope but also blinded the scan to fully argument-bearing `query commit` invocations embedded mid-sentence in instructional prose. Three such live invocations sit inside the scan's own roots today (new-milestone.md, new-project.md, plan-phase.md) — all scoped, but never entering the candidate set, so trimming their `--files` clause would reintroduce #2269 behind a green suite. Add a second tier: MIDLINE_INVOCATION_RE matches the invocation token anywhere a quoted commit message follows (`commit "` — the executable shape a bare mention never carries), and invocationCandidates() extracts the backtick-bounded invocation substring so hasScopedFiles's quote-parity walk is not skewed by surrounding prose quotes. Census after widening: 87 anchored + 3 mid-line = 90 invocations, 0 unscoped; the mid-line tier picks up exactly the three cited sites and nothing else across all five scan roots. * test(#2269): align the --files value predicate with the runtime's flag filter The scan's --files value test was /--files\s+\S/, which scores `--files --amend` as scoped because `-` is \S. routeCommit disagrees: args.slice(filesIndex + 1).filter(a => !a.startsWith('--')) drops every `--`-prefixed token, so `--files --amend` yields files=[] and lands on the same unscoped default branch as a trailing bare `--files` — #2269 verbatim. Two live sites sit one token-deletion from the shape: gsd-core/references/git-planning-commit.md and gsd-core/workflows/execute-plan.md both run `... commit "" --files .planning/codebase/*.md --amend`. The predicate now mirrors the runtime's own rule. Single-dash tokens stay values, because the runtime filters on '--', not '-'. Pinned: `--files --amend` and `--files --no-verify` as unscoped, plus the two live `--files <glob> --amend` shapes and `--files -weird-name.md` as scoped negative controls, so the fix cannot over-correct into "any --files near a flag is unscoped" with nothing failing. Also tightens the fast-check path generator, which is not cosmetic. It excluded only ["\s], so it could draw a `--`-prefixed token and assert it scoped: measured 26 hits in 200,000 draws (~1 in 7,700), i.e. ~1 CI run in 77 at the default 100 runs would have failed as a mystery flake once this fix landed. The complement is now its own property, and it fails against the pre-fix predicate (counterexample ["","--#"]). The behavioral test's --files presence check moves to the same predicate, and its two failure modes are now separate messages: a genuinely missing --files (the #2269 regression) versus an unquoted value that only breaks this test's own scope derivation. * test(#2269): score each invocation on a line separately, not the whole line invocationCandidates returned [line] for any line-start match, so scoring was satisfied by one --files anywhere on the line and a later scoped invocation vouched for an earlier unscoped one: gsd_run query commit "a" && gsd_run query commit "b" --files x.md gsd_run query commit "a" ; gsd-tools query phase-list --files y.md Same "one hit satisfies the whole candidate" class as the --files value bug in the previous commit. No live line has the shape today, so this is pinned rather than left to be rediscovered. A line-start invocation is now split at shell command separators before scoring. The separator must be followed by a binary token, so a binary inside a command substitution — `commit "$(gsd-tools query x)" --files a.md` — is not treated as a second invocation; that negative control is asserted, since the obvious "split at every binary token" implementation turns it into a false offender. Census on the current tree is unchanged: 89 anchored + 3 mid-line = 92 invocations, 0 unscoped, and the split produces zero extra segments on live content. * test(#2269): drop the unnecessary file-level allow-test-rule exemption no-source-grep only fires on a readFileSync whose path expression carries BOTH a .cjs/.js/.ts extension and a quoted bin|lib|gsd-core|src literal (looksLikeSourcePath, eslint-rules/no-source-grep.cjs). This scanner reads .md only, so the rule never triggered and the exemption bought nothing. It was not inert, though: the escape is file-level — getAllComments() sees the header and the rule returns {} for the whole file — so it silently disabled no-source-grep for the pre-existing #2112/#2523 tests here and for anything added later. Verified by removing it and running eslint on the file: clean. * test(#2269): mirror routeCommit by tokenizing, not by scanning line text The scan's predicate searched the whole line for `--files` with double-quote parity, while the runtime does an exact-token argv lookup scoped to a single command. Those semantics disagreed, and the disagreement was exploitable in both directions: guard=true runtime=false | ... commit "docs: update ROADMAP.md" && echo done --files unused.md guard=true runtime=false | ... commit 'docs: explain --files usage' guard=false runtime=true | ... commit 'prints a " sometimes' --files .planning/PLAN.md The first is #2269 verbatim: `--files` is echo's argument and never reaches gsd-tools argv, so cmdCommit takes the blanket-`.planning/` default while the guard stays silent. Injecting that line into secure-phase.md produced 0 offenders. Each earlier round fixed one of these by widening the approximation, which only moved the disagreement. So stop approximating: tokenize the line the way a shell would — honouring BOTH quote characters, backslash escapes and unquoted control operators — then run routeCommit's own predicate over the tokens. Two previously hand-encoded special cases now fall out for free: `--files=x` is unscoped (indexOf needs the exact token) and `--files -weird.md` is scoped (the runtime filters on '--', not '-'). Operators are marked structurally rather than re-identified by comparing a token's text against an operator set, because `commit '|' --files a.md` produces a token whose VALUE is `|` and which is ordinary message text. The property tests are part of this commit, not a follow-up: all three built their line from a hardcoded double-quoted template, so no number of runs could generate the single-quoted shape. They pinned one quoting dialect while reading as though they pinned the predicate, which is what let the above through. The delimiter is now drawn, and a fourth property asserts directly against a re-implementation of routeCommit's own predicate. Also removes a second copy of the old heuristic from the behavioral test, and widens the mid-prose tier to `'` — it keyed on `commit "` only, so a single-quoted mid-prose invocation was invisible to the scan entirely. * test(#2269): scan docs/, which carries live invocations the claim excluded The comment asserted the five roots were "every directory that carries live invocations". That was false against the current tree, not hypothetically: docs/zh-CN/references/ carries 7 live `query commit` invocations — the Chinese mirrors of three gsd-core/references/ files that ARE scanned. They are all scoped today, which is exactly why this needed its own assertion: adding or dropping the root does not move the offender count, so the coverage loss was silent in both directions. The scan now records which roots actually contributed invocations and asserts docs/ is among them. Stated as a reach property rather than a census figure, since a hardcoded count is the drift this file has already been bitten by. The locales are the right place to care about: ja-JP/ko-KR/pt-BR have no references/ subtree at all, so the translations already drift per-locale — the condition under which an unscoped example is reintroduced in one copy unnoticed. New files under an existing root were always picked up by the recursive walk; the gap was only ever at the root level. * test(#2269): don't flag invocations inside HTML comments; drop a stale census Two smaller findings from the same review. The scan treated any `gsd_run … commit "` occurrence as executable, so an HTML comment documenting the historical defect was reported as an offender: <!-- WRONG: gsd_run query commit "docs: message" (missing --files!) --> Nothing in the scanned roots trips this today, but it made the guard hostile to documenting the very bug it protects against — a plausible thing to add precisely BECAUSE this issue exists. Comment spans are now stripped before scanning, preserving newlines so a multi-line comment cannot fuse the text on either side of it into one logical line. The strip lives beside the other scan primitives rather than inside the test, so the assertion and the scan cannot drift apart. And the comment claiming "the total match count stays at 86" was stale (89). No assertion read it. Rather than correct the figure, state the invariant it was standing in for — dropping `.*` swaps onboard.md out of coverage WITHOUT changing the offender count, since that site is scoped either way. The number had already drifted three times; the property will not. * test(#2269): consume comments, single-&, and redirections before scoring argv Round-10 Major (davesienkowski): tokenize modelled &&/||/;/| and nothing else, so `--files >/dev/null 2>&1`, `--files > out.md`, `# --files`, and `& echo --files` all scored as scoped while routeCommit sees files=[] and takes the blanket-.planning/ default. The live tail in execute-phase-requirement-revert.md is one token-deletion from the silent false negative. The tokenizer now ends the command at a word-start unquoted `#`, treats a single `&` as a control operator, and consumes redirections (with glued IO numbers, `2>&1` included) as redir-marked tokens that hasScopedFiles excludes from argv. Quoted and mid-word `#` stay literal — ship.md's `PR #${PR_NUMBER}` message is pinned scoped. `$VAR` tokens deliberately stay values: statically undecidable, and the three original #2269 fix sites all pass `--files "${PHASE_DIR}/…"`. * test(#2269): a foreign command's `commit` no longer reads as a gsd offender Round-10 Minor (davesienkowski): segmentInvocations' no-hit fallback returned the whole line, so `gsd_run query state && git commit -m "x"` and `gsd_run query state | grep commit` — matched by the anchor's deliberately loose `.*` — were scored as unscoped gsd commits and false-flagged with the wrong failure message. The fallback now applies only to a segment that actually carries the gsd binary followed by a commit token (env-prefixed or wrapped invocations, where dropping the line would be silent coverage loss); a line whose `commit` belongs to another command contributes no candidates. * test(#2269): drop the commands/ exemption from the per-root reach assertion Round-10 Nit (davesienkowski): the exemption documented a gap that no longer exists — commands/gsd/review-backlog.md contributes a candidate, so commands/ is held to the same dead-weight test as every other root. * test(#2269): put the tokenizer's escape branches inside the tested domain Round-10 Minor 1 (trek-e): both backslash-escape branches in tokenize were unreachable by any assertion — every generator stripped every backslash, and no hand-pinned case carried one. Double-quoted property messages may now contain `"` and `\`: embedFor escapes them into the LINE while the property keeps the raw string as the argv the shell would deliver, so the escape branch sits inside the differential oracle's domain. Single-quoted messages still exclude backslashes — the shell has no escape inside '…', so there is no escaped spelling to generate. Four hand-pinned cases cover both branches in both directions (escaped quote before a real --files, escaped quote hiding a message-internal --files, escaped space in the message, escaped space in the value). * test(#2269): normalize path separators in the scan's diagnostic strings Round-10 Minor 2 (trek-e): scanned/offenders entries were built from raw readdirSync (recursive) names, so a failure message on Windows would render backslashed paths, against the repo's normalize-unconditionally convention. Join with the raw entry; report with the normalized one. * test(#2269): pin the unquoted escape branch in the unscoped direction too Claim-audit catch on the round's own response draft: the unquoted backslash-escape branch was pinned only in the scoped direction, and the unquoted property generator strips backslashes, so the unscoped direction was outside every assertion. One pin closes it. * test(#2269): decide an invocation by its command shape, not by its markup Both scan tiers answered "is this text executable" with a syntactic proxy — one anchored the command at line start, the other required a quoted commit message — and both were wrong, in opposite directions. The anchor flagged a fenced block that deliberately SHOWS the unscoped form. The quoted-message tier could not see an unquoted invocation at all: `gsd_run query commit fixup` reaches the identical cmdCommit, entered no candidate set, and trimming its --files clause reintroduced #2269 with nothing to fail. Markdown context was the obvious replacement and is refused, on measurement. Keying on fences means parsing them — fence character, opening run length, nesting, tilde fences, four-space indented blocks, unclosed markers — and every bug in that parser is a silent false negative. I built that version first and it lost four executable shapes before I stopped: `cd "$ROOT" && gsd_run query commit …`, `if …; then gsd_run …; fi`, four-space indented blocks, and a four-backtick fence containing three-backtick runs. Each loss was invisible; the census stayed at 99. So the discriminator is the command shape: <binary> [ query | -flag [value] ]* <commit-token> <at least one arg> The middle clause separates a command from a SENTENCE containing the same two words — `Update STATE.md using gsd-tools.cjs query (or legacy gsd-tools) commit mutations:` fails it at `(or`, with no markup parsed. The trailing clause separates an invocation from a mention. Each line is scanned whole and its inline code spans are scanned too, unioned: the whole-line pass reaches every executable shape regardless of markup, and the span pass reaches the one case it cannot, where a backtick glues to the binary token. The union is additive, so a mis-parsed span can only add a visible false positive, never hide an invocation. This closes the unquoted case in BOTH the code-span and bare-prose forms, so no part of it is left declined. Named residual, pinned as a test rather than left to be discovered: an undelimited prose mention running straight into its sentence (`see gsd_run query commit for the scoping rules`) is flagged, because nothing distinguishes it from an invocation with arguments without guessing at English. Bounded and measured — the roots carry 93 bare-prose mentions of the binary and 0 with a commit token, the repo's convention is to backtick a command reference, and the failure is a visible red. Two things fell out. An interpreter prefix (`node gsd-tools.cjs commit …`, live in docs/CLI-TOOLS.md) was invisible to both old tiers and is now in scope; that pulled in the CLI usage synopses, so a bracketed optional FLAG now marks synopsis notation — the discrimination is the bracketed flag, never "contains a bracket", since 24 live invocations carry brackets inside their quoted message (one as its --files value) and none brackets a flag. tokenize and hasScopedFiles are untouched — byte-identical. Only the question "is this text an invocation at all" changed. Census re-derived with the file's own primitives: 99 candidates across 62 files, 0 unscoped, workflows 67 / references 11 / agents 12 / commands 1 / skills 1 / docs 7 — identical to the previous tier logic. Against the pre-fix tree (200daa456^) it still flags exactly the 3 sites #2269 was filed about. * test(#2269): let a wrong-example declare itself, in comment position only A block showing the unscoped form on purpose scored as an offender. That is a false positive against content correct as written, and the worst kind: the fix a contributor reaches for is to mangle a teaching example until the linter stops complaining. The HTML-comment escape only covered examples written as comments. Exempting fenced blocks was the prescribed remedy and is refused on measurement: 96 of the 99 live invocations sit inside fences. Against the pre-fix tree (200daa456^) the scan flags exactly the 3 sites #2269 was filed about, and 0 with fences exempted. That does not narrow the guard, it disables it — and no property of the surrounding markup can stand in for the author's intent anyway, which is the general form of the same point. So intent is declared: `# gsd-scan-ignore: <reason>` exempts the line, and a reason is required because a bare token is not a declaration. Undeclared, the identical line stays an offender — it is byte-for-byte what a real regression looks like. The marker is honoured ONLY in comment position, via the same unquoted-`#` rule tokenize() already applies, so the two cannot disagree about where the command ends. Matching the token anywhere on the line would let the commit MESSAGE carry it — `commit "docs: explain gsd-scan-ignore: semantics"` — and silently exempt a real offender. A guard that can be talked out of firing by its own documentation is strictly worse than the false positive the marker exists to fix, so both directions are pinned. The marker is a new convention here and I would rather be redirected than assume: happy to rename it, key it to an HTML comment, or drop it for whatever shape you prefer. * test(#2269): pin the escaped-backtick case in both directions The span pass skips a backslash-escaped backtick, because an escaped backtick is literal text and pairing it invents a code span the rendered document does not have — the invented span then reads as an unscoped invocation, a false offender against prose that merely displays a backtick. That guard had no test: reverting it broke nothing, and a property with no test is one the next edit removes for free. Pinned in both directions, so the assertion covers the escape rather than the absence of span handling. * test(#2269): close a bypass in the ignore marker's comment-position test The marker's comment-position check keyed on "preceded by whitespace", which is not the rule the shell applies and not the rule tokenize() applies. In `commit docs:\ # gsd-scan-ignore: reason` the backslash escapes the space, so the shell keeps `docs: #` as ONE word: the `#` is literal, the command runs, and it runs UNSCOPED. The raw-text check saw a comment and exempted the line. That is the guard being disarmed by text the author controls, which is worse than the false positive the marker was added to fix — a guard that can be talked out of firing is not a guard. Found by an adversarial audit of this round's own claims, not by the tests, which is why both halves below are now pinned. Two independent conditions, deliberately: - commentPortion tracks WORD START the way tokenize does, rather than looking at the preceding character. That closes the escaped-separator case directly. - A marker that survives tokenization as an ARGUMENT disqualifies the line outright. tokenize() drops everything from a real comment onward, so a genuine declaration leaves no token carrying the marker; anything that does reached argv, which means the runtime executed it. The second is what makes the closure structural rather than a matter of getting commentPortion's edges exactly right — and it has edges. A REDIRECTION swallows `#` and its text into a redir token, so the shell passes it to the redirect target and never treats it as a comment, while raw-text reading still sees one. Pinned as its own case, since it is the shape only the cross-check separates. Reverting either half fails the marker test independently. * test(#2269): require a tracking reference on every scan-ignore declaration The gsd-scan-ignore: marker accepted any non-space reason text, giving the exemption no expiry and no ledger — the permanent-allow-test-rule shape RULESET.TESTS.delete-bad-tests names. ADR-456 already settled this for the sibling allow-test-rule: convention, so the marker now requires the same #NNN issue reference (or an https:// URL). scripts/lint-allow-test-rule-refs.cjs walks tests/ only and keys on the ESLint comment form, so it cannot see a marker living in a .md file. Rather than teach a second token to a script whose whole contract is that form, the scan enforces the rule over its own roots — it already runs in CI on every shard. An attempted declaration that carries no reference is reported AS one, asserted before the offender list. It is also an offender (it does not exempt), and letting the generic assertion win would tell an author who had already explained the line that their commit is unscoped — sending them to re-read a flag that was never the problem. Live declarations in the six scan roots: 0, so nothing is grandfathered. * test(#2269): name every remedy in the failure, and document the convention The offender assertion named only --files, but the guard has three distinct causes and only one of them is the bug. An ordinary English sentence that runs `gsd_run query commit` straight into its prose is flagged — by design, since nothing separates it from an invocation with arguments without guessing at English — and a contributor told only that their commit is unscoped will mangle the sentence until the guard shuts up. That is exactly the outcome the declaration marker was invented to prevent. The message now names all three remedies (scope it / backtick the mention / declare the wrong-example) and points at CONTRIBUTING.md. This is the standard the repo already states one section over for its sibling gate: "The failure output names its own remedy". The help text is hoisted out of the assertion so it can be pinned. A failure message is unreachable on the passing path, so nothing would have noticed a remedy being edited back out of it. The marker lives in .md files across six roots, so documenting it in this test's comments reaches nobody who hits it. CONTRIBUTING.md now carries the convention beside the sibling allow-test-rule: exception, and its example is a live instance of itself — removing the declaration makes the example an offender (verified). CONTRIBUTING.md is outside changeset lint's USER_FACING_PREFIXES, so the diff still returns ok_no_user_facing_changes (verified, not assumed). * test(#2269): decide synopsis notation by the first argument, not by the line SYNOPSIS_TOKEN_RE disqualified the whole segment, so notation anywhere on a line silently disqualified a real invocation. Wrong in both directions, and both reachable: See [--files](#anchor) then run gsd_run query commit "docs: x" ^ an ordinary markdown link, and docs/ is a scan root gsd_run query commit "docs: x" [--amend] ^ a real, executable, unscoped call Both scored 0 candidates. The second is the dangerous one: `[--amend]` is a literal word to the shell, so the line runs, reaches routeCommit with files=[], and sweeps the index — #2269 verbatim, with the guard silent on exactly the defect it exists to catch. Positionally there is no ambiguity. A synopsis documents a call it does not make, so its first argument is a placeholder; a real call's first argument is its commit message. The test moved to that position. `<message>` reaches the predicate as a REDIRECTION — `<` is a redirection character, so tokenize() reads it the way the shell would. That mangling is the identifying feature rather than an obstacle, and keying on it avoids the false negative a raw-text match would have introduced: inside a quoted message `<Widget>` is ordinary text, one token, no redirection. Pinned. All five live synopsis lines (docs/CLI-TOOLS.md and its four localized mirrors) remain excluded — verified by running the primitives over each. Reversion control: restoring the whole-line rule fails 'usage-synopsis notation documents the CLI and is not a call to it', and only that test. * test(#2269): reach invocations behind a subshell, a shell -c, and an escaped backtick Three shapes that were executable and invisible. (1) `(gsd_run query commit "docs: x")`. Subshell grouping changes no argv, so `(` was never a tokenizer metacharacter — which left `(gsd_run` as one token the binary anchor could not match. The anchor now strips a leading run of `(`. Backticks are deliberately NOT stripped with it, and the first attempt that did strip them is why this is stated rather than assumed: to a shell a backtick is never part of a binary name, so a backticked invocation belongs to the code-span pass, which extracts the command from inside the delimiters. Stripping them in the anchor made the whole-line pass find the same invocation a second time, absorbing the surrounding sentence as arguments — the census went 99 -> 102 with no verdict changing. Measured, reverted, and pinned as a comment so the next reader does not re-try it. (2) `bash -c "gsd_run query commit fixup"`. A shell invoked with -c runs its next argument AS a command, so the invocation sits inside a quoted token that no markup rule can reach. The recursion is keyed on the INVOKER, never on "a quoted token that parses as a command" — the wider rule would flag a commit MESSAGE that quotes an invocation, a false positive against ordinary documentation. Pinned in both directions. (3) codeSpans skipped an entire backtick run on an odd backslash prefix, which is stricter than the rule its own comment states: an escaped backtick consumes exactly ONE, and the rest of the run is still a delimiter. Fixed, and pinned with a case that now yields a span where it previously yielded none. The reviewer's own example stays at zero spans — correctly, because a 1-run opener does not pair with a 2-run closer — and that is pinned too, so the distinction is not re-litigated as a regression. Census re-derived at this head over the six roots with the file's own primitives: 99 candidates across 65 files, 0 unscoped (workflows 67 / references 11 / agents 12 / commands 1 / skills 1 / docs 7) — identical to the previous head, so the widening is coverage-neutral by measurement. Reversion controls: 3/3 fire against a named test. * test(#2269): assert scan coverage over the repo, not over each root The per-root reach assertion was wrong in both directions at once. Too strong: commands/ contributes exactly one invocation and skills/ one, so an unrelated PR retiring review-backlog or re-syncing the Chinese mirrors turned this red with a failure that had nothing to do with #2269. A root is now allowed to legitimately go to zero. Too weak, and this is the part worth having: it could only ever re-confirm the roots already listed. Removing a root from scanRoots was SILENT under it — the assertion iterates the roots that remain — and a directory that ACQUIRES invocations without being a root was invisible to it. That is the gap this file has actually been bitten by twice: agents/ in one round, docs/zh-CN/ in the next, each found by a reviewer rather than by the suite. Replaced with the property those checks were approximating: over every tracked .md in the repo, a file carrying a live invocation must be covered by a scan root. Dropping any root now fails (verified for docs/ and agents/), and so does a new directory acquiring one. This also closes the residual @davesienkowski raised in round 10 — completeness was asserted only for docs/, never as a general property — and the one I answered then by saying a git ls-files walk inside the test was something I would rather propose explicitly than smuggle in. Proposing it: git ls-files is already used against the repo by seven test files here, one of which fails closed on a non-zero exit exactly as this does. An unenumerable file list is an UNKNOWN coverage set, not an empty one. Measured: 1523 tracked .md, 99 candidates inside the roots, and exactly one candidate-bearing file outside them — CHANGELOG.md, whose invocation is scoped. It is excluded with its reason rather than silently: it is regenerated from .changeset/ fragments, so a marker added to it would not survive the next release, and it records commands that shipped rather than instructing anyone to run one. Suite duration 6.5s -> 9.3s for the wider walk. * test(#2269): put the recognition half inside the property domain All four existing properties aim at hasScopedFiles — the half backed by a runtime oracle, and the half that has been stable for rounds. Every defect found since lives in the other half: whether a line is an invocation at all. That asymmetry is the problem, because a miss there is a silent false negative, where a miss in the scope predicate has an oracle watching it. So the new properties generate the CONTEXT rather than the arguments. The recognition rule is "an invocation is found by its command shape, whatever markup surrounds it", and that is a claim about a domain a generator can cover: subshells, interpreters, env prefixes, shell keywords, prompts, list and blockquote markers, indentation, chaining, and `sh -c` wrapping. Each of those was previously a hand-pinned example, several added only after a reviewer found the gap. It paid immediately. Three defects, none of which exists on today's tree: - A markdown BLOCKQUOTE marker is the one markup form the tokenizer cannot ignore, because `>` is also a redirection: `> gsd_run query commit "docs: x"` reads as a redirection whose target is the binary. Found on the property's first run. 34 blockquoted lines in the six roots invoke this binary today; none carries a commit token yet. - `> sh -c "gsd_run commit a"` — the blockquote strip fed only one of the three passes. The passes now apply to every VIEW of the line. - `(sh -c "gsd_run commit a"` — the subshell strip was applied to the gsd binary test but not the shell-invoker test. One helper now, not two copies. The last two are COMPOSITIONS of shapes that each pass alone, which is the class hand-written examples are worst at and the specific reason this finding was worth taking as stated rather than as more examples. The wrapper axis is drawn only with an unquoted body: a -c payload is itself quoted, so a quoted message inside it needs a nested-quoting domain, and a wrong generator domain is this file's most repeated own-goal (two seed-dependent reds). Constraining the body is what makes the axis safe. Verified rather than claimed: - 20,000 generated cases, 0 counterexamples; suite green on three independent seeds. - Reverting each of the four recognition fixes in isolation is now caught by the property, not only by the hand-pinned case. Before the wrapper axis it caught 2 of 4 — measured, which is why the axis was added. - Census unchanged: 99 candidates / 65 files / 0 unscoped, same per-root split, over 1523 tracked .md. - Discrimination against the pre-fix tree (200daa456^): 97 candidates, exactly 3 offenders — next.md, secure-phase.md, validate-phase.md. * test(#2269): cover the scan's own assembly, found by three silent controls Running a reversion control over every fix in this round left three silent, and all three were real gaps rather than control artefacts. Two were the same shape: the WIRING between the walkers and the scan's result lists was covered by nothing. Every test drove documentCandidates / documentUntrackedDeclarations directly, so replacing the untracked- declaration source with an empty list — and emptying the uncovered-file list — both left the suite green. The real corpus cannot catch either: it is clean, so those assertions can only ever observe an empty result. That is the structural reason a synthetic corpus is needed and not a nicety. Factored the per-document classification into scanDocument() and the uncovered-file walk into uncoveredFiles(), both beside the other primitives for the reason already stated there — an assertion must not pass against a private copy while the scan does something else — and drove each with a synthetic input. Both controls now fire. The third was worse, because it looked like a check and was not one: `assert.match(OFFENDER_HELP, /backtick/i)` is satisfied by the word "backticked" in the clause explaining why a backticked mention is skipped, so deleting the backtick REMEDY left the assertion green. Matched on the instruction instead. Reversion-control matrix over the whole round: 12 controls, 12 fire against a named test. Suite 29/29, 0 skipped. * test(#2269): keep this file's own prose out of the exemption lint Self-found by running scripts/lint-allow-test-rule-refs.cjs, which the round had a specific reason to run: the lint extracts everything after the token on a line and requires a #NNN or URL in it, so the comment explaining that requirement was itself read as a new untracked exemption -- tests/commit-files-pathspec.test.cjs :: ` reason, and scripts/... -- and lint-tests would have gone red on a prose line. Reworded so no line carries the bare token; the lint now reports no novel offenders. Fitting rather than embarrassing: this is the exact class Major 1 is about, and the lint caught it on the file arguing for the same discipline. * test(#2269): fix four defects an adversarial review of this round found Ran a cross-AI adversarial review over the round's own claims before pushing. It confirmed 10 of 12 and returned four MISSED findings; all four were real. 1. `shellDashCPayloads` searched the whole segment for a shell name, so `echo bash -c "gsd_run query commit fixup"` became a candidate. echo PRINTS the string; it does not run it. The invoker must be at the command position — only an env assignment, a shell keyword, or a list/prompt marker may precede it. `echo` and `printf` are commands and no longer qualify; `then` / `$` / `FOO=1` / a subshell still do. Both directions pinned. 2. SHELL_INVOKER_RE was a guess at four spellings. `ash`, `csh`, `tcsh`, `fish` and `yash` all take -c and all run what follows, and missing them is a silent false negative in the one function whose job is reaching a command the tokenizer cannot see. All nine spellings pinned. 3. A marker with NO reason at all fell through to the generic offender diagnosis, because the loose detector required `\S` after the colon. That is the likeliest way to get the marker wrong, so it is the case that most needs the specific message. The reason is now EXTRACTED rather than matched in one shot, so an empty reason is still an ATTEMPT. 4. The predicate now MIRRORS scripts/lint-allow-test-rule-refs' own ISSUE_REF_RE (`/#\d+|https?:\/\//`) instead of approximating it. The review refuted my claim of an HTTPS-only rule: the regex accepted `http://` and the prose describing it did not. The regex was right and the sentence was wrong — so the sentence is gone and the constant is cited. A contributor who satisfies one marker and not the other would otherwise have been handed two conventions wearing one name. Also a generator-domain correction, not a narrowing: `node bash -c "..."` is not an executable line — node takes a script path and `bash` is not one — so the interpreter context is no longer drawn with a shell wrapper. Leaving it in had the property demanding recognition of a non-command. The review's other refutation is NOT actioned, and deliberately: it measured 9 failures, every one a fixture hook dying on `spawnSync /bin/sh EPERM` inside its own sandbox, with the scanner and property tests passing there. That is the documented reviewer-sandbox artefact class, not a defect in the claims. Locally the suite is 29/29, 0 skipped, on three independent seeds. Census re-derived after the rework: unchanged at 99 candidates / 65 files / 0 unscoped, same per-root split, 1523 tracked .md walked. * test(#2269): pin the invoker set exactly, and let command modifiers through Two follow-ons from the review's invoker findings, both verified rather than reasoned about. The invoker regex was hand-written and I did not trust it, so I enumerated it instead of sampling: over every <=2-letter prefix it admits exactly {sh, ash, bash, csh, dash, fish, ksh, tcsh, yash, zsh} and nothing else. `ssh` is the near-miss that mattered — admitting it would treat a REMOTE command as a local shell running the payload — and it is correctly rejected. Pinned along with cash / josh / publish / wish / rsh. The mirror of that gap is the prefix side: `time bash -c "..."` really does run the payload, and the command-position rule introduced one commit ago stopped at `time`. Command modifiers that pass straight through (time, exec, nohup, env, command) now skip like the shell keywords do. Named residual rather than a half-fix: a modifier carrying its OWN flags (`sudo -u alice bash -c ...`) still stops the search, because skipping arbitrary flag/value pairs means modelling each modifier's option grammar. No live instance in the six roots, and the failure direction is a missed candidate. Census unchanged: 99 / 65 files / 0 unscoped. Suite 29/29, 0 skipped. * test(#2269): say http(s):// where the predicate accepts it An audit of this round's own response comment caught that the fix for the HTTPS-only overclaim went only into the test's internal comment. The three strings a contributor actually READS — the offender help, the malformed- declaration message, and CONTRIBUTING.md — still said `https://` while the shared predicate is `/#\d+|https?:\/\//` and accepts `http://`. So the implementation mirrored the sibling lint while the documented convention stayed narrower than it: exactly the two-conventions-wearing-one- name problem the mirroring was adopted to avoid, reintroduced in the half a contributor sees. Both directions were already pinned as tests; only the prose was wrong. * test(#2269): reach inside command substitutions, whatever precedes them `$(gsd_run query commit "docs: x")` scored ZERO candidates in both directions. `$(` glues to the binary exactly as `(` does, leaving `$(gsd_run` as one token no command-name test could match — the same silent false negative the subshell opener produced, on the invocation idiom the tree uses most. The strip is widened rather than duplicated, so it covers the shell invoker test too: one helper, per the rule already stated beside it. WHAT PRECEDES THE OPENER IS NOT ENUMERATED, and that is the whole of the design. Keying on an assignment prefix covers most of the live substitution sites and misses the rest — measured with the file's own binary predicate rather than a line regex, because two regexes gave two different totals: 432 tokens whose last `$(` is followed by a real gsd binary, of which 423 are plain assignments and 9 are not. Those 9 span three distinct non-assignment prefixes, all live: for REVIEW_FLAG in $(gsd_run review-lane flags) (x3) ${PLAN_PRE_HOOKS_JSON:-$(gsd_run loop render-hooks plan:pre)} (x3) `ROADMAP=$(gsd_run ...) -- backtick-glued assignment (x3) Ordinary text (`pre$(...)`) and an indexed assignment (`A[0]=$(...)`) glue just as hard. Every such enumeration is one idiom behind the shell, and every miss is silent, so the rule is positional: strip to the LAST substitution opener in the token. Whatever preceded it was, by construction, not the command. Arithmetic falls out of the same mechanism rather than a special case: `$((x))` leaves a `(` in front of the name, which no binary carries, so it is refused — mirroring the shell, which itself needs `$( (` spaced before it reads a nested subshell there. Pinned as a zero-candidate case, as is the array literal `arr=(...)`, which contains no `$(` at all. Scan census re-derived with the file's own primitives: 99 candidates across 65 files, 0 unscoped — workflows 67 / references 11 / agents 12 / commands 1 / skills 1 / docs 7. Unchanged, so the widening is coverage-neutral by measurement: no live `$( ... )` site carries a commit token. Discrimination re-run: a planted unscoped substitution in a scan root is caught and named; its `--files` twin is not flagged. * test(#2269): stop the -c pass skipping a substitution-captured invoker Self-found by sweeping the defect class the round's finding names — a command context glues to the binary token — across every command-name test rather than only the one that was reported. `shellDashCPayloads` treats any `VAR=` token as a skippable prefix, on the reasoning that `FOO=1 bash -c "…"` prefixes the command. But `V=$(bash -c "…")` IS the command: skipping it walks the invoker search past the shell onto `-c`, which is not an invoker, so the payload is never reached and an unscoped commit inside it stays invisible. An assignment is now skippable only when it carries no substitution, and the prefix pattern admits the indexed form (`A[0]=`) for the same reason the strip above does. `FOO=1 bash -c "…"` and the full invoker matrix are unchanged and still pass, which is what makes this a narrowing of the skip rather than a removal of it. No live instance in the six roots — the failure direction is a missed candidate, which is the direction this file treats as the one it cannot afford. * test(#2269): quoting decides the opener, per character not per token Found by an adversarial pass over this round's own claims, then fixed again after that pass refuted the first fix. THE DEFECT. `printf %s '$(gsd_run' query commit fixup` runs no gsd command: the opener is single-quoted literal text. But tokenization removes quotes, so the token's value is `$(gsd_run` and the strip read it as a binary, fabricating an invocation out of a string argument. THE FIRST FIX WAS WRONG, IN THE UNAFFORDABLE DIRECTION. Recording "did this token consume a quote" and refusing the strip on it closes the case above and opens a worse one: a quote need not cover the opener. `echo "pre"$(gsd_run query commit fixup)` executes, and so does `$(gsd_""run query commit fixup)`, and a token-wide flag silences the guard on both. That trades a visible false positive for a silent miss, which is the trade this file refuses everywhere else. So the mask is PER CHARACTER: tokenize carries a '0'/'1' string parallel to each token's value, marking characters that were quoted or backslash-escaped, and the command-name helpers strip only an opener whose own characters were bare. Both directions are pinned — the three quoted-opener shapes score zero, the two mixed-quoting shapes score one. This also closes the same shape for the subshell opener (`'(gsd_run'`), which predates this round, and it retires BINARY_LEAD_MARKUP_RE: the walk it replaced could not consult the mask, and a regex plus a mask would have been two descriptions of one rule. NAMED RESIDUAL, unchanged by this and out of scope. `printf %s 'gsd_run' query commit fixup` still reads as an invocation, because no markup is stripped there — the token's value simply IS the binary name. Closing it needs quote provenance carried through the command-shape test, not just the strip. The direction is a visible false positive with a declared remedy, not a silent miss. Scan census unchanged: 99 candidates / 65 files / 0 unscoped. |