5 Commits

Author SHA1 Message Date
Jakub Zych
a9a7a328e6 refactor: hard-fork GSD -> MSD (Make Software Done)
Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD
across contents and paths, upstream package/repo coordinates -> @golem15/msd-core
and golem15com/msd-core. Deep links into upstream history, sibling upstream
packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is.

Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line,
package/plugin identity, regenerated lockfile, install-tree fixtures, derived
registries and benchmark baseline; migration checksum baseline re-locked
(MSD keeps its own install state, so no install had applied the old sums);
sort-order and regex-escaped expectations in tests adjusted.
2026-10-06 01:47:40 +02:00
Tom Boucher
ab69b9ce56 enhance(#3987): guard slug re-derivation and the swallowed-precondition shape — §8.5 was guardable after all (#3999)
* feat(#3987): guard slug re-derivation, and record why the swallow shape cannot be guarded

Epic #3473's Decision 1 requires the wrong call site be UNREPRESENTABLE. #3984
measured that two of the nine §8 rules had no guard at all and recorded both as
"Shipped - test-covered". This closes one of them, proves the other cannot be
closed the same way, and corrects two false claims I merged yesterday.

1. §8.3 - scripts/lint-slug-derivation-drift.cjs.

   generateSlugInternal (src/core-utils.cts) is the canonical owner; #3883 removed
   11 inline copies. Nothing prevented a twelfth: no slug guard existed in
   scripts/ or eslint-rules/.

   The detector is STATEMENT-scoped and matches the shape the real copies took -
   one statement carrying BOTH .replace(<negated class>, '-') and
   .replace(/^-+|-+$/, ''). Statement scoping is what buys the precision: the
   loose LINE-level form yields 18 hits with 7 unrelated, a material
   false-positive rate. Measured on the tree: 5 flags, 2 TRUE, 3 SANCTIONED,
   0 FALSE.

   The three sanctioned sites are allowlisted with a reason each, following
   lint-phase-enumeration-drift's form rather than a bare denylist. The owner
   itself is listed explicitly even though it escapes by construction - an
   implicit escape is a latent bug, and the next person to touch line 192 would
   not know the guard depended on it.

2. Both TRUE positives were live defects, not style.

   scripts/qa-smell-ratchet.cjs reproduced the canonical formula including the
   60-cap but trimmed BEFORE truncating - the #2849 bug - and never
   transliterated. The divergence is total, not cosmetic:

     canonical  "privet-mir-privet-mir-privet-mir-privet-mir-privet-mir-prive"
     inline     "tail"

   Cyrillic collapsed to nothing and only the ASCII remainder survived, so the
   ratchet was keying on wrong identifiers for any non-ASCII input.

   tests/planning-inspect.test.cjs carried a helper whose comment claimed parity
   with getPhaseDirFromPhaseId. That function now transliterates; the helper did
   not, so the test asserted against a stale formula while looking correct. Both
   now route through the seam.

3. §8.5 - measured, and deliberately NOT shipped.

   A candidate detector (swallowing catch + errno-retry-set test in the same
   function) gives 26 flags across 11 functions: 0 TRUE, 26 FALSE. Every one is
   best-effort unlink/rm/close cleanup, lost-rename-race backoff, or a deliberate
   null fallback. The file-scoped variant is worse at 71.

   Worse than the noise: the only known true instance was removed by #3885, so
   there is NO POSITIVE CONTROL - the guard cannot be shown capable of failing,
   which this repo requires of every drift guard. Shipping it would add a guard
   nobody can trust and nobody can test.

   The ADR now records the measurement and the reason, keeps §8.5 at
   "Shipped - test-covered", and points at the #1884 regression test as what
   actually enforces it. An honest "not detectable at acceptable precision" beats
   a guard that only ever passes.

4. Two claims I merged into the ADR yesterday were wrong.

   §8.9 said 17 of 19 subsumed children have a test citing their issue number,
   and that #3364 and #3812 have none. Both halves are false, and the claim came
   from a NUMBER-GREP - inside an amendment whose own subject is that a text match
   is not a fact.

     #3364 IS cited: tests/runtime-marker-resolution.test.cjs:107,
       T3 installMarkerResolvesWhenEnvAndConfigAbsent_3897 (#3364), asserting at
       :115-119.
     #3812 IS covered: tests/gen-state-md-docs.test.cjs:374, asserting at :382.

   Corrected to 19 of 19.

   #3812 does carry a real finding, though a different one: it is PARTIALLY
   DELIVERED on a CLOSED issue. The shipped fix declares cardinality for
   frontmatter keys, but #3812's stated acceptance was about the
   ## Current Position BODY section, and docs/reference/state-md.md:196-208 still
   has no normative single-valued/overwrite sentence and no pointer to
   ## Performance Metrics for history. Recorded in the ADR and left for #3812 to
   re-open - fixing it here would bury a scope question inside an unrelated PR.

Note on B6: this ADDS a guard, and B6 said the net count must fall. #3951 already
amended that clause - a guard ledger is a claim about COVERAGE, not count - which
is what makes adding this one honest rather than contradictory.

Verified: the guard flags 0 on the fixed tree, and PROVES IT CAN FAIL - a fresh
inline copy planted in src/ makes it exit 1 naming the exact statement. All three
sanctioned sites were confirmed exempt BY the allowlist, not by accident of the
pattern, by re-attributing each to a non-exempt path and watching it flag.
build:lib, lint and lint:ci all exit 0.

Refs #3987

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#3987): add the changeset fragment

Doc-only, so it carries forward from the verified sha rather than costing a
second matrix run.

Refs #3987

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#3987): §8.5 IS guardable — I was wrong, and the guard found a live defect

Two orthogonal reviews. The correctness review overturned my central judgment,
and it was right.

1. I concluded §8.5 was "not detectable at acceptable precision" and recorded
   that in the ADR. False.

   My evidence was 26 flags / 0 TRUE / 26 FALSE. The reviewer pointed out what I
   had not: all 26 false positives are CLEANUP verbs - rmSync 54, unlinkSync 43,
   closeSync 17, chmodSync 12 - and the obvious narrower predicate was never
   tried. A swallowed cleanup is legitimate best-effort. A swallowed CREATION is
   a precondition silently lost, which is exactly the #1884 shape.

   Measured properly, in three stages:
     swallowing catch                                     911
     + try-block calls a CREATION verb                     24
     + enclosing function references a *_ERRNOS set         0

   0 flags, 0 false positives. The `*_ERRNOS` naming key is empirically total -
   all 10 retry/tolerate sets in src/ follow it.

   My second claim was worse. I wrote that no positive control exists because
   #3885 removed the only true instance, so the guard "cannot be shown capable of
   failing". That is self-refuting: this very PR's slug guard proves-it-can-fail
   on a synthetic tree, and the pre-#3885 blob is available as exactly such a
   fixture. It is now the control, and it works in both directions - the rule
   flags 0c43d853e^:src/planning-workspace.cts at line 210, the line the fix
   commit's own message cites, and reports zero on the post-fix code.

   I stopped at the first negative result on the option that meant less work.

   Shipped as eslint-rules/no-swallowed-precondition.cjs, wired into the existing
   src/**/*.cts ESLint block rather than a scripts/lint-*-drift.cjs: no script in
   scripts/ requires typescript/espree/acorn, and scripts/ ships to consumers, so
   a .cts-parsing standalone guard would add a devDep at consumer runtime. The
   ESLint block already parses .cts for free.

2. The guard immediately found a live defect of the same class.

   src/capability-lock.cts swallowed a mkdirSync on the lock directory, then
   acquireLock classified the follow-on failure as `code !== 'EEXIST' → return
   null`. A real EACCES/EROFS makes openSync(lockPath,'wx') fail ENOENT, which is
   not EEXIST - so a fatal filesystem error was laundered into "lock
   unavailable". Same defect as #1884, different laundering target.

   Fixed the way #3885 fixed #1884: the creation failure propagates. Regression
   test proven fail-first by hand - with the fix stashed, EACCES was laundered to
   null; restored, it throws.

   The strict rule does NOT catch this shape (its errno classification is an
   inline literal, not a named set). The rule is deliberately left strict: the
   broadened form had 2 false positives - capability-lock.cts:408, the deliberate
   EEXIST steal protocol, and commonjs-marker.cts:131, which returns a distinct
   documented outcome. The gap is noted in code rather than papered over with a
   noisy predicate.

3. The security review found the slug guard's exemption FAILED OPEN.

   currentFunction was never reset, and only a column-0 `function` declaration
   updated it, so exemption bled from an allowlisted declaration to the next one.
   generateSlugInternal exempted 50 lines for an 11-line function. A
   re-derivation planted anywhere in that window was silently exempt - the same
   fail-open shape that produced a blocker in #3897, and an allowlist is a
   SUBTRACTION so a mismatch fails open by construction.

   Extent is now tracked by real brace depth, and a test plants a violation after
   each allowlisted function's real closing brace and asserts it IS flagged.

4. Also from the security review: the guard was a CI-DoS and narrower than I
   claimed.

   Its unbounded [^\]]* was re-scanned from every `.replace(/[^` start: 54.3s on
   a 1.28MB line. It imported MAX_REGEX_LITERAL_LEN and never called
   readRegexLiteralAt - the bounded tokenizer that exists for exactly this. Now
   routed through it with a 2MB file cap: ~200ms.

   15 of 25 genuine re-derivations evaded. Widened to catch replaceAll, {1,},
   \s*-wrapped classes, escaped ], literal new RegExp(...), five trim spellings,
   .split().join(), and multi-line .replace( args - still 0 false positives.
   Two forms still evade and are documented as deliberate gaps with negative
   tests: the two-statement/temp-var form and new RegExp built from a variable.
   Both need data flow, and guessing at it is how a guard becomes noisy.

   Also fixed: // inside a string truncated the line, a ; inside the collapse
   regex split the statement (a one-character bypass), and SCAN_EXT omitted
   .mjs/.tsx/.jsx.

5. A regression I introduced, caught by the same review.

   qa-smell-ratchet.cjs top-level-required a build output that is not
   git-tracked, so the script hard-failed MODULE_NOT_FOUND before build:lib -
   including for --help, which previously had no build dependency. The require is
   now lazy at the point of use.

6. Four of my own tests were vacuous or weak.

   T9's input yielded an identical string under the buggy formula, so it passed
   on the implementation it was meant to catch. T12 compared maxLen null vs 60 on
   an 18-char name, where they agree trivially. T9-T12 all asserted
   generateSlugInternal directly, so they would pass unchanged if both call-site
   fixes were reverted. And prove-it-can-fail was scoped to scanRepo, never the
   CLI - dropping main()'s exit-code line would have kept every row green.

   All rewritten with discriminating inputs, per-call-site rows that red when the
   fix is reverted, 59/60/61 boundaries, an entirely-non-alphanumeric row, and a
   CLI row asserting the real subprocess exit code and both sanitizeForReport
   sites.

Verified: both guards flag 0 on the tree and both prove they can fail. The
swallow rule's control is confirmed in both directions - pre-#1884 shape flagged,
post-#3885 shape clean. build:lib, lint and lint:ci all exit 0.

Refs #3987

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(#3987): record that §8.5 IS guardable, and correct a correction that made a ledger worse

Three ADR corrections, two of them to text this branch wrote hours ago.

§8.5 advances to Enforced. Its previous entry said the rule was not detectable
at acceptable precision. That was wrong twice: the 26 false positives were
uniformly CLEANUP verbs, which is a reason to narrow the predicate rather than
abandon it, and the claim that no positive control exists was self-refuting - the
pre-#3885 blob is available as a fixture and this repo's own guards prove-it-can-
fail on synthetic trees. Narrowed to creation verbs plus a *_ERRNOS reference:
911 -> 24 -> 0 flags, 0 false positives, control confirmed in both directions.
The entry keeps the wrong reasoning visible, because a high false-positive count
being evidence the predicate is wrong - not evidence the rule is unguardable - is
the transferable part, and the first negative result is most seductive when it is
also the answer that means less work.

§8.9's correction is itself corrected. The original 17-of-19 claim was CORRECT
for the predicate it stated; this branch silently swapped cited -> covered and
declared 19 of 19. #3812 appears in zero test files. Changing what a word means
to make a ledger read better is a worse failure than the miscount it claimed to
repair. Both predicates are now reported separately - 18 of 19 cited, 19 of 19
covered - because §8.9 asks for a test NAMING each child, so 18 is the number
that answers it. #3812 is also re-opened for real, rather than the first draft's
promise that it could be.

§8.3 stays Shipped - test-covered rather than advancing. The slug guard catches
the copy-paste class and a dozen variants, but two forms still evade by decision
(temp-var split, new RegExp from a variable) because both need data flow. Naming
them keeps the status honest: the wrong call site is much harder to write, not
unrepresentable.

Closes #3987

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#3987): backfill changeset pr number

Refs #3987

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#3987): replace my own wall-clock assertion, and close the guard that let me write it

CI went red on ubuntu shard 2/3. The failing test was mine, and the failure was
the test, not the code.

  a 1.28MB line ... scans in well under a second (was 54.3s pre-fix)  7368ms

It asserted ELAPSED TIME. ~200ms locally, 7.4s on a shared CI runner. The bound
introduced for the MAJOR-2 DoS fix works - 7.4s against a 54.3s pre-fix baseline
is the fix doing its job - but an absolute wall-clock threshold on shared
hardware is a race, not an assertion. CLAUDE.md says so directly: "Clock Seams:
Do not assert on wall-clock time." I wrote the anti-pattern the project bans, in
a PR about guards.

Raising the threshold would only move the flake. The row now asserts a
DETERMINISTIC bound instead: an instrumentation seam on drift-scan.cjs counts
readRegexLiteralAt calls and characters examined, and the test asserts
charsExamined stays under an absolute ceiling. Measured on the same 1.28MB
fixture: 120,000 calls, 48,000,000 chars - two orders under the ceiling. The
pathological fixture is kept; only the thing being asserted changed.

Proven to still discriminate: with MAX_REGEX_LITERAL_LEN raised to simulate the
unbounded pre-fix behavior, the same fixture does not complete in 120 seconds,
versus ~0.3s bounded. It is a real regression test, not a tautology.

Then the second half, which is the same defect class as the rest of this PR.

  eslint-rules/no-elapsed-assertion.cjs matched only the EXACT identifiers
  ^(elapsed|duration|took|ms)$.

I used `elapsedMs`. It evaded the rule entirely. tookMs, durationMs,
elapsedTime and msElapsed evade the same way. A guard that cannot see the
violation it exists to catch is exactly what this PR is about - it just happened
to be an existing rule rather than one of the two I came here for, and it was
found because I committed the violation it should have blocked.

Widened to /^(?:elapsed|duration|took|ms)(?:[A-Z]\w*)?$/ plus a narrow
start/endMs delta pair. Deliberately NOT a blanket *Ms suffix: a first draft did
that and produced 2 false positives on `timeoutMs` in
plan-phase-stall-detection, which is a configured timeout and not a measurement.
Verified negative on params, items, forms, terms, dirnames, timeoutMs,
cacheTtlMs and staleAfterMs.

Measured over the five files carrying camelCase timing identifiers: 0 true
positives beyond my own, so nothing else needed rewriting. The rule's own test
file gains a row asserting `elapsedMs` flags, proven to fail against the
pre-widening rule - the same prove-it-can-fail standard both new guards in this
PR are held to.

Refs #3987

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#3987): a comment I added leaked a Claude reference into every runtime install

The runner went red with 4 failures in tests/install.test.cjs:

  Leaking: .hermes/scripts/lib/drift-scan.cjs
  Leaking: .qwen/scripts/lib/drift-scan.cjs

The instrumentation seam added for the deterministic bound carried a comment
naming CLAUDE.md as the source of the no-wall-clock-assertions rule. scripts/
SHIPS to consumers, so that comment was installed verbatim into hermes and qwen
trees, and the install suite scans for exactly this - a Claude-specific reference
reaching a non-Claude runtime.

The rule is real and worth citing; the filename is not portable. The comment now
says "this repo's test rules" and states the rule inline, which is what a reader
of an installed tree actually needs anyway.

Worth noting what caught it: not lint, and not the two guards this PR adds - the
install suite's full-tree scan, which exists precisely because a shipped file is
read by runtimes that have never heard of CLAUDE.md. Same lesson as the rest of
this PR from the other direction: the check that matters is the one that can see
the surface where the defect actually lands.

Refs #3987

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#3987): a test fixture swallowed 46 git exit codes and produced a silent false negative

CI red on ubuntu shard 3/3:

  tests/health-validation.test.cjs:2029
  expected exactly one W024, got [{"code":"W006", ...}]

Not caused by this branch, and the evidence is decisive rather than a hunch: the
SIBLING test at :2039 builds the IDENTICAL fixture with the identical
commitsAhead and asserts the same thing, and it PASSED in the same process, same
file, same run. Same input, both outcomes - which rules out logic, ordering,
sharding and environment, and leaves a per-invocation nondeterministic failure
inside one fixture build.

The mechanism is an unchecked exit code, 46 times over. The W024 fixture performs
~46 runGit spawns and never checks a single one. runGit returns failures as DATA
and never throws, so one silently-failed `git commit` yields 19 commits instead
of 20, or a silently-empty `git rev-parse HEAD` yields a blank state_head. Either
drops readStateHeadFreshness below the advisory threshold, W024 never fires, and
only W006 remains.

Reproduced exactly: 20 commits -> ["W006","W024"]; 19 -> ["W006"]; blank
state_head -> ["W006"] - byte-identical to the CI assertion dump.

The arithmetic is what hid it. At threshold-1 and threshold+1 a lost commit still
produces the asserted answer; only the exactly-at-threshold cases sit one commit
from a false negative. Two of the seven tests are in that position, and CI hit
one. That is why it had never been seen before, and why it surfaced now: this
branch adds three test files, which reshuffles the cost-weighted shard partition
and moved this file into a chunk where the latent flake fired.

My files were checked as suspects first and cleared: all fixtures mkdtemp-unique,
no process.chdir, no .planning/ writes, no git spawns, and node --test gives
per-file process isolation regardless.

Fixed at the cause, not the symptom. A mustGit wrapper throws on a non-zero exit
with the command, exit code and stderr, and all nine call sites route through it.
The fixture now asserts its OWN preconditions before the assertion under test
runs - the seed head is non-empty, and `git rev-list --count <seed>..HEAD` equals
the requested commitsAhead - so a fixture that did not build what it claims fails
loudly as a FIXTURE ERROR naming got-versus-asked, instead of quietly handing a
weaker input to the assertion.

Proven: dropping one commit now raises
  FIXTURE ERROR: requested commitsAhead=19 but git rev-list --count reports 18
where it previously produced a silent ["W006"] pass-for-the-wrong-reason. 64/64
tests in that block pass unperturbed.

Deliberately NOT done: no threshold change, no retry, no loosened assertion, no
skip. The assertion was correct; the input was silently wrong.

Worth naming, because it is the same shape from the other side: this PR ships
eslint-rules/no-swallowed-precondition.cjs, whose entire subject is a swallowed
precondition failure being laundered into a plausible downstream outcome. This
fixture is that defect in test code - the swallowed git failure was laundered
into a legitimate-looking "W024 did not fire". The rule does not cover test
fixtures, so the connection is noted at the fix site rather than enforced.

Refs #3987

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#3987): two tests wrote to committed files; the shard packing decided when that mattered

CI red on windows-latest shard 1/3 only:

  "gen-exit-code-registry: CLI" > "a --write run redirected to a tmpdir leaves
  every committed artifact untouched"
  AssertionError: hooks artifact must be untouched

The Linux runner passed the same sha at 40425/40425. It is Linux-only, so a
Windows-scheduling defect is structurally invisible to it.

Root cause, established by measurement rather than inference.

tests/cli-exit.test.cjs appended a corruption marker to the REAL COMMITTED
hooks/lib/exit-code-registry.js, held it corrupted across a full subprocess, and
restored it in a finally. tests/exit-code-registry.test.cjs reads that same real
file before and after its own subprocess and asserts byte equality. If it samples
while the other test holds the file corrupted, it fails. The landmine is
pre-existing, from 2ea5efc15 (#3911).

What this branch changed is WHEN the two run together. scripts/run-tests.cjs
shards by cost-weighted LPT over the sorted unit list, so adding three test files
repacks the bins:

  merge-base c3e667df3 (838 files): cli-exit -> shard 1, exit-code-registry -> shard 3
  HEAD       03b342601 (841 files): BOTH in shard 1, same argv chunk, one
                                    node --test process, concurrent

Co-location is necessary but not sufficient - Linux shard 1/3 also had both and
passed. Windows loses because TEST_CONCURRENCY defaults to 2 there against 4
elsewhere, spawn cost is ~10x, and the sibling corruptor holds one of only two
slots through a ~90s tsc compile. That turns a sub-second overlap into seconds.

Not a path-separator or case-sensitivity issue, and not CRLF - .gitattributes
pins * text=auto eol=lf. Redirection was not at fault either: ensureScriptsOut
derives all five --out flags correctly and gen-exit-code-registry.cjs honours
them with no __dirname escape.

Fixed at the cause: no test writes to a committed file any more. Both corruptors
now copy to a mkdtempSync tmpdir, corrupt the COPY, and point the generator at
it. Repinning or reordering the shards would have turned CI green while leaving
the landmine armed for the next reshuffle.

That required closing an inconsistency between two sibling generators.
gen-exit-code-registry.cjs already accepts
--out/--scripts-out/--hooks-out/--dts-out/--sh-out and honours them under
--check; gen-hooks-cli-exit.cjs hardcoded OUTPUT_PATH and had no flag surface at
all, so its corruptor could not be redirected anywhere. It now takes --out in the
same style, honoured by both --write and --check, and is a no-op when absent -
verified: a bare --check on the default path still exits 0.

ensureScriptsOut moved to tests/helpers/exit-code-artifact-flags.cjs and both
test files import it. Hand-rolling a second copy of the flag derivation would
have been a re-derivation of exactly the kind this PR ships a guard against.

Verified: both tests still detect corruption (proven by defeating the check and
watching them red, with a positive control showing an uncorrupted copy exits 0);
SHA-256 of hooks/lib/exit-code-registry.js and hooks/lib/cli-exit.js identical
before and after running both rewritten bodies, and git reports nothing under
hooks/ modified - that is the property that was violated. A repo-wide search for
the corrupt-then-restore-in-finally shape against hooks/ found no other
instances.

One detail worth recording: the tmpdir test keeps --declaration pointed at the
real committed declaration rather than copying it, because the generated banner
embeds path.relative(REPO_ROOT, declarationPath) - copying it would produce a
false drift unrelated to the injected corruption. The declaration is read-only on
that path and never written.

Refs #3987

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 14:40:02 -04:00
Tom Boucher
fa41bfec5c enhance(#3942): the emitted-drift ack is PR-lifetime data — move it to a commit trailer (#3954)
* test(#3942): failing-first suite for the emitted-drift ack commit trailer

Binds 37 input classes from the phase test matrix to the behavior ADR-3942
specifies, before any of it exists. Stubs return benign empty values rather
than throwing, deliberately: several rows assert that something DOES throw
(cap overflow, uncomputable commit range), and a throwing stub would turn
those green for the wrong reason and destroy the red.

The two rows that carry the design's load:

- merge-base semantics. The range is $(git merge-base base HEAD)..HEAD, not
  base..HEAD, because changedPaths comes from `git diff base...HEAD` (three
  dot). Two-dot would let the ack set and the change set disagree about which
  commits are this PR's. The fixture forks a topic branch, puts a trailer on
  each side, and asserts only the topic-side trailer is in range.

- fail-closed on an uncomputable range. With fragments a depth-1 checkout
  passes VACUOUSLY, every fragment reading as brand-new. With trailers the
  range cannot be computed at all, and returning an empty set would silently
  disarm the gate, so it must throw. The fixture builds a genuine shallow
  clone rather than simulating one.

Also covers the self-inflicted case: this change's own documentation quotes
the trailer syntax, so an example landing at the end of a commit message would
arm a live acknowledgment keyed on the literal placeholder text. Keys carrying
angle brackets or whitespace are rejected.

Authored per the phase artifacts 40-design.md and 50-test-matrix.md.
Not yet run on the remote runner — this commit exists to be tested.

Refs #3942

* chore(#3942): move the emitted-drift ack to a commit trailer

Implements ADR-3942, superseding ADR-2719 section 3 and its #2789 amendment.
Sections 1, 2 and 4-7 are retained: the conservation law is unchanged, only the
storage of its escape hatch moved off the working tree.

An acknowledgment explains one PR's ripple, and the moment that PR merges the
ripple is in the base, so it can never clear anything again. It was stored in
permanent shared state anyway, and every consequence of that mismatch had to be
built and then maintained. The chain is #2789 -> #2914 -> #3078 -> #3842 ->
#3823 -> #3875, each fix generating the next defect, ending in a scheduled
sweeper whose own first PR could not merge itself.

Added
  parseAckTrailers + renderAckTrailer (pure) and readAckTrailers (IO shell),
  reading Emitted-Drift-Ack-Hash: / Emitted-Drift-Ack-Growth: trailers over
  the merge-base range. tests/emitted-ack-trailer.test.cjs, 37 cases, written
  failing-first and confirmed red before any of this existed.

Changed
  diffEmitted takes two structurally distinct key-space maps instead of one
  shared paths map. That closes a latent defect: the spaces were separated by
  convention only, so a growth key satisfied a hash lookup by naming
  coincidence. staleAcks now reports which space a key was declared in.
  REMEDIATION teaches the trailer, per space, with its example rendered through
  renderAckTrailer so the taught grammar cannot drift from what the parser
  accepts.

Removed
  the sweep workflow, the guard-no-ack-on-next job, the standalone linter and
  its lint:ci entry, the fragment directory and its three spent fragments, the
  legacy single-file union, and the baseAck/spentAcks mechanism -- spentness is
  now structural, not computed.

Two range properties carry the design and are pinned by tests rather than
asserted: the range is merge-base scoped, matching git diff base...HEAD, so an
already-merged trailer is out of range by construction; and an uncomputable
range throws instead of reading as zero acknowledgments, which is the inverse
of the fragment guard's vacuous pass.

Three deliberate observable changes, each disclosed in the changeset: the
unread runtime field is gone, the legacy file is no longer read, and cross-space
excusal no longer works.

Ten open PRs carry fragments and will meet a modify/delete conflict. Measured
before landing and accepted deliberately; the one-line migration is in the PR
body.

Verified: lint:ci exit 0. Remote runner to follow on this exact sha.

Refs #3942

* fix(#3942): silent trailer collapse, lost coverage, and an unbounded cap

Six findings from the orthogonal review round, all fixed in place.

BLOCKER -- two trailers of the same name on one commit collapsed silently.
readAckTrailers built `separator=1d` where git needs `separator=%x1d`: the
`separator=` value inside a %(trailers:...) placeholder is itself a
pretty-format string, so the bare hex was emitted as two literal characters
and the split on \x1d never matched. Two same-name trailers therefore joined
into one value with errors empty -- the first reason absorbing the second
entry's key. Silent truncation, the exact class MAX_ACK_TRAILERS throws to
prevent. Confirmed with od -c against real git output before and after.

The failing-first matrix did not catch it because its "both spaces coexist"
row uses Hash plus Growth -- different trailer NAMES -- so the value separator
was never exercised. Two regression tests now cover same-name trailers
directly.

Coverage recovered: normalizeAckReason and INVISIBLE stayed on the live path
via parseAckTrailers but lost every test when the old suite was pruned. Back
under test against the current surface -- all six invisible codepoints
individually, whitespace collapse, trim, CRLF, and two seeded fast-check
properties. Dropping any single codepoint now fails.

MAX_ACK_TRAILERS counted raw trailers before de-duplication, so one trailer
carried forward across rebased commits counted once per commit and could throw
on a legitimate branch. Now counts distinct entries; 100 identical repeats
dedupe to one.

diffEmitted validated baseline, current and changedPaths but not the new
ackHash/ackGrowth, so a bad shape raised an unhandled TypeError instead of an
error verdict -- the same defect shape this file documents for #2778.

Docs: CONTRIBUTING and TESTING-SUITES were rewritten only in their first
sections; the later passages still taught fragments, git rm and the deleted
guard, contradicting the new text directly above them. Finished.

Also extends lint-removed-but-needed to exempt docs/adr and docs/research.
That gate fails on any docs mention of a file deleted in the same diff, which
makes it impossible to document a deletion in the PR performing it -- an ADR's
whole job is naming what it retired. Exemption is narrow and comes with a test
proving the gate still fires for a live consumer elsewhere under docs/. A
guard that cannot fail is worse than no guard. Maintainer-approved.

CONTEXT.md names the retired machinery by role rather than by filename: its
generated projection lands in docs/, which that gate does scan.

Adds docs/how-to/acknowledge-emitted-drift.md. The required docs set is
Reference and Explanation, so the task quadrant can be empty with every gate
green -- and this change has a real multi-step journey, including the fragment
migration ten open PRs now need.

lint:ci exit 0.

Refs #3942

* docs(#3942): correct the duplicate-trailer rule in CONTRIBUTING

Both axes of the code review independently flagged the same passage, without
seeing each other's output.

It claimed two declarations of the same key are always "a hard, loudly-reported
error, not a silent last-wins". That is only half true, and the missing half is
the one contributors hit: identical declarations -- same key, same reason --
dedupe silently, because a trailer legitimately survives a rebase and reappears
on every rebased commit. Failing there would red a branch for doing nothing
wrong, which is exactly why the dedup exists.

Only a same-key/different-reason pair errors, and that one is a genuine
ambiguity about which explanation holds.

As written, the paragraph told a contributor that a rebase-carried trailer
breaks the gate -- the opposite of the behavior. CONTEXT.md's parallel entry
already stated it correctly; this brings CONTRIBUTING into line.

Doc-only, root-level markdown.

Refs #3942

* chore(#3942): backfill changeset PR number to 3954

---------

Co-authored-by: sim <sim@local>
2026-08-27 17:28:39 -04:00
Tom Boucher
b42cb4fb29 fix(#3597): count scenario expectation failures in the QA gate, and fix the workstream scope split it exposed (#3607)
* fix(#3597): count scenario expectation failures in the QA ratchet gate

buildReport counts totals.violations as oracle violations PLUS scenario
expectFailures, but collectFindings read only step.violations. A scenario
whose declared expect failed therefore produced ok:false and violations:1
in the report while the ratchet printed "0 violations" and exited 0.

multi-workstream has failed that way on every CI run since 2026-08-10,
when #3217 (PR #3318) made computeProgressPercent withhold a percentage
whose scope is not COMPLETE. The walk detected the change the day it
landed; nothing was listening.

- collectFindings returns a third bucket, expectationFailures, carrying no
  fingerprint so it can never be baselined or acked away
- both modes of main() print and gate on it; the summary line reports it
- guard runMain(main) behind require.main === module, so the QA suite can
  require the script to test collectFindings without running a real walk
  (that import side effect is why the gate logic had no test)
- multi-workstream now asserts the true contract: phase_scope unreadable
  and percent null, per ADR-3180 7.6 rule 4
- the perturbation test asserts scenario ok, closing the test-side half

Closes #3597

* fix(#3597): resolve the milestone window against the active workstream

listMilestonePhaseDirs defaulted its ws option to null. planningDir
treats undefined as "resolve the ambient workstream" and null as
"force the project root", so that default suppressed the ambient
resolution every other planning-path read uses.

All 18 call sites derive phasesDir ambiently via planningPaths(cwd),
so the counts came from the workstream while the milestone window came
from the root .planning/ROADMAP.md — the exact numerator/denominator
scope split ADR-3180 7.6 rule 3 forbids. workstream create migrates
that root roadmap away, so the read threw and scope stayed UNREADABLE,
and rule 4 then correctly withheld the percentage.

Proof: with a workstream tree byte-unchanged, copying its own ROADMAP
to the project root flipped --ws alpha progress from
phase_scope:unreadable/percent:null to complete/100.

This is the defect the loop QA walk was pointing at all along; the
scenario expectation is restored to percent:100 rather than bent to
match the bug.

- pass ws through as undefined so ambient resolution applies
- multi-workstream asserts phase_scope complete + percent 100
- regression test in completion-ratio-scope-withholding covers a
  workstream-only project with no root ROADMAP
- replace the vacuous require.main test: runMain defers through a
  promise, so the in-process timing check passed against the unguarded
  file too; a child-process spawn now observes the guard for real
- tie the oracle-violation test to expectationFailures, and cover the
  absent-key, multi-scenario and zero-step report shapes in parity
- flatten scenario-authored strings before rendering them into the
  step summary and CI logs (forged markdown / ANSI injection)
- widen the scenario contract assertions past perturbation-* so
  multi-workstream is actually covered test-side

Closes #3597

* fix(#3597): flatten scenario-authored strings on the CI-log output path

The step-summary path already routed findings through flattenUntrusted;
the check-mode NEW-smell and STALE-entry console.error blocks, and the
repro line in both printers, still interpolated raw.

detail carries a scenario-authored expect[].path verbatim, and
reason/scenario/id come from contributor-authored baseline and ack
fragments validated only as non-empty strings. A crafted path could
print a forged summary line into the CI log directly above the real
one, plus ANSI repaint and unbounded length.

Exit codes are unaffected — this is log spoofing, not gate bypass.

* fix(#3597): refuse to archive on an unreadable milestone window; close review gaps

Resolving the milestone window against the active workstream can leave
the window UNREADABLE when that workstream has no ROADMAP of its own.
getMilestonePhaseFilter throws, the window degrades to a pass-all
fallback, and milestone complete would then move every phase dir --
breaking the guarantee stated at the archive site that no out-of-window
directory is touched.

milestone complete now refuses to archive when the window is UNREADABLE
and reports the refusal; --dry-run previews the same refusal from the
same shared derivation.

The guard is scoped to UNREADABLE, not to every non-COMPLETE scope. A
broader condition regressed ordinary root projects: the QA walk caught
milestone-rollover leaving 01-parser on disk, which then tripped the
#1447 abort in phases clear. UNSCOPED and TRUNCATED are pre-existing
classifications and keep their existing behavior.

Review fixes:
- the workstream regression test asserted complete/100 but its fixture
  wrote no workstream STATE.md, so it resolved unscoped/null and the
  test failed; it now asserts a milestone and genuinely fails-first
- the parity test hand-supplied totals.violations, hardcoding the very
  formula under test; at least one case now goes through the real
  buildReport
- drop a vacuous qa-report.json assertion (jsonOut defaults to null, so
  no report is written by either shape)
- buildRepro emitted a repo-relative binary path after cd-ing into a
  temp project, so every repro died with MODULE_NOT_FOUND; it now
  resolves an absolute path
- flattenUntrusted truncated the repro to 300 chars, handing reviewers a
  command that looks complete and is not; length capping is now opt-out
  for repro while newline/control/backtick stripping still applies

* chore(#3597): backfill changeset pr number (#3607)

---------

Co-authored-by: sim <sim@local>
2026-08-18 07:26:28 -04:00
Tom Boucher
33fd203ccd test(#2966): loop QA walk — drive real scenarios across all five loop steps (#2976)
* test(#2966): loop QA walk — drive real scenarios across all five loop steps

Adds a headless walk that carries accumulating project state across
discuss -> plan -> execute -> verify -> ship against one temp project,
layered over the existing tests/helpers.cjs runGsdTools substrate.

Findings carry severity. A violation breaks a stated contract and fails
the build; a smell is legal under today's implementation but structurally
questionable, is recorded, and never reddens CI. Without that split an
oracle set derived from current behavior can only ever confirm current
behavior -- the harness could not say "this works and is still wrong".

The end-to-end test asserts the walk produces at least one smell: a QA
harness that reports nothing on a first run against a real engine is far
more likely mis-specified than the engine is perfect. It deliberately does
not pin smell ids or counts, which would re-freeze current behavior.

First run against the real engine: 0 violations, 3 smell classes --
init returns agents_dir outside the project tree; smart-entry emits prose
unconditionally so routing cannot be asserted; state-snapshot reports a
missing STATE.md through a payload key with exit 0.

Also fixes tests/fixtures/index.cjs: createFixture with git:true and
planning:false staged nothing, so the commit failed with "nothing to
commit". That combination was unreachable until greenfield needed it.

Extends RULESET.TESTS.feedback-loop-convergence from estimation to the
loop itself. Design lock: docs/adr/2966-loop-qa-walk.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(#2966): wire fault injection, make perturbations discriminating

Independent review found tests/qa/mutations.cjs entirely unwired: 462
lines exercised only by their own unit tests, with no mutation hook in
the scenario DSL and no scenario applying one, while the module header
and the ADR described fault injection in the present tense. Dead code
documented as live.

Adds a `mutate` step field, three perturbation scenarios, and a wiring
detector: a self-test scenario whose expectations are known-false and
which MUST fail. The previous anti-vacuity check asserted only that the
walk produced a smell, which passes on well-known engine behavior
regardless of whether the harness wiring works.

First perturbation attempt produced zero signal -- progress does not
structurally parse ROADMAP.md, so a corrupted roadmap sailed through. A
perturbation that cannot fail is the same defect in a new costume.
Probes now target roadmap get-phase, and each mutated step runs a clean
baseline first so `mutationObserved` records whether the corruption
changed anything at all.

Also clears four review findings: classify() returned PROSE for exit-0
with empty stdout; `warnings` was structurally unpopulatable on the
success path (execFileSync discards it) and is now documented as
error-path-only; read-only-idempotence passed vacuously when asked to
check idempotence without the data to check it; the ADR miscounted the
oracles.

Discrimination matrix across 8 mutations x 6 commands: bom,
duplicate-phase-id and escaped-pipes are absorbed silently by every
probed surface, and progress / smart-entry / roadmap validate never
reacted to any mutation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(#2966): add path-containment guard for scenario-supplied targets

Security review found scenario-supplied paths joined to the temp project
with no containment check. step.mutate.target and agent.write keys were
validated only as non-empty strings, so a target of ../../../../etc/hosts
reached fs.unlinkSync / fs.writeFileSync / fs.symlinkSync outside the
project. The symlink mutation was worst: it read the traversed file, wrote
a sibling copy, deleted the original and symlinked it back.

Not exploitable today -- all shipped scenarios target .planning/ROADMAP.md
and scenarios are repo-committed, not runtime input. Fixed anyway: it is a
live primitive any future scenario or copied helper can reach.

Adds tests/qa/paths.cjs with resolveWithin(): rejects absolute paths, NUL
bytes and empty input, normalizes separators unconditionally, and requires
containment by path segment so a sibling like <base>-evil is not treated as
inside. Non-existent targets resolve via nearest existing ancestor rather
than falling back to a lexical compare. Scenario load now rejects traversing
or absolute targets up front.

oracles.cjs previously carried its own copy of the containment logic; both
now share paths.cjs, since a duplicated containment check is exactly the
divergence class this repo calls out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(#2966): complete trajectory corpus, report emission, boundary-aware oracle

Adds the remaining trajectories and drives all 11 mutations end-to-end.
20 scenarios, 72 steps, 0 violations, 25 smells.

Adds qa-report.json with per-step verdicts and a copy-pasteable repro
command, plus --keep / GSD_QA_KEEP=1 to preserve a failing tree. A repro
line for a tree that was not preserved is marked NOT RUNNABLE rather than
emitting a command pointing at a deleted directory.

monotonic-progress is now boundary-aware. Two scenarios had been trimmed
to stop the oracle complaining at a milestone rollover, which destroys the
signal the trajectory exists to produce. Evidence: counters legitimately
reset to zero at milestone complete, but the payload milestone_version
lags until a new ROADMAP.md is written. So the oracle now scopes by
milestone plus workstream, keeps a same-scope decrease as a violation, and
records a boundary crossing as a smell. Both scenarios walk the real
boundary again.

Standards review fixes: oracle findings now carry a structured subject so
tests assert on typed fields instead of substring-matching the free-form
detail string, resolveWithin throws a typed EPATHESCAPE error, and the
absolute-path predicate scenario.cjs had re-implemented now comes from
paths.cjs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(#2966): fix silently-vacuous fixtures and guard the class

Every fixture carried its #2371 provenance comment BEFORE the frontmatter
block, and extractFrontmatter returns {} when anything precedes the opening
---. So every scenario reading status/phase/name was operating on an empty
object and reporting green. Nine fixtures repositioned; the comment stays,
it just moves below the closing ---.

Both UAT fixtures lacked a parser-recognized result block, so
evaluateUatPassed saw checks.length===0 and could never return passed:true.
The uat-fail-then-remediate scenario could not have proven a remediation.
Its expect block only inspected blockers, which is empty before AND after,
which is why the corpus never noticed. Both fixtures now carry real result
blocks and the scenario asserts passed and no_uat_artifacts on each side of
the flip.

The actual deliverable is the guard: a fixture-integrity block asserting
every fixture with a frontmatter shape parses to a non-empty object, that
every fixture carries its provenance marker, and that the two UAT fixtures
produce opposite verdicts through the real evaluateUatPassed. The first
guard written required --- at byte 0, which would never have fired on the
regression it exists to prevent; it was rewritten and proven by deliberately
re-breaking a fixture.

No engine defect here. no_uat_artifacts means no parsed check items, not no
UAT files, and it was reporting correctly on fixtures that had none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(#2966): make the walk report — smell ratchet, baseline, CI job

The harness computed smells into a gitignored qa-report.json that nothing
read. In CI it surfaced nothing at all: violations failed the build, but the
half of the tool that says "this works and is still wrong" was inert. A QA
tool nobody hears is decoration.

Adds a ratchet on the same idiom this repo already uses three times over
(the regression-test-name allowlist, the emitted-drift acks, the size
baseline): a committed smell-baseline.json, per-PR acknowledgment fragments
under tests/qa/smell-acks/, and a ratchet script wired into CI.

The design invariant is preserved exactly. A smell still never fails a build
on its own merits. What fails is an UNACKNOWLEDGED NEW smell -- the absence
of a decision -- leaving an author two honest exits: fix it, or record a
fragment with a real reason. An empty reason is rejected. The baseline is
shrink-only, so a fixed smell must prune its entry. Violations remain
unacknowledgeable.

Fingerprints are composed only from stable fields (oracle id, scenario,
argv, subject discriminator) -- never temp paths, timestamps or counts.
Verified byte-identical across two runs in separate temp dirs; an unstable
fingerprint would have false-positived every CI run.

CI gains a qa-loop-walk job that runs the suite and the ratchet, uploads the
report with `if: always()` (it matters most when it failed), and renders a
summary a reviewer reads without downloading anything.

Also fixes the report runner invoking main() unconditionally on require, so
importing it double-ran every scenario and clobbered its own output.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(#2966): every smell terminates in a defect or a fixed detector

The baseline accepted a smell with a free-text reason. That is a mechanism
for designing smells in -- an allowlist nobody revisits. The harness is
brand new, so nothing it found is inherited legacy; every finding is a
FIRST finding. Each must now terminate in exactly one of two states:

  REAL           -> an assigned defect, entry carries the issue number
  FALSE POSITIVE -> the detector is wrong and gets fixed, never baselined

There is no third "accepted with a good explanation" state, so the ratchet
now requires a positive-integer `issue` on every entry. A reason may remain
as a human note but can never substitute. `--update` refuses to invent
issue numbers: a new smell is written with `issue: null` and a TODO, and
the next plain run rejects it, forcing triage rather than accumulation.

Working the 21 existing entries through that rule found 16 were my own
detectors being wrong:

value-hygiene (10) flagged $.agents_dir, a field whose entire contract is
to point at the install tree outside any project. Fixed with a leaf-key
allowlist of contractually-external fields, verified as the only such key
in the init payload. Genuinely unexpected out-of-project paths still smell.

monotonic-progress (6) fired on legitimate boundary crossings -- milestone
v1.0 to v2.0, workstream beta to alpha -- and on one payload carrying no
scope fields at all, where a change cannot even be known. Scope changes now
reset silently and scope-less observations are skipped. The same-scope
decrease remains a violation; that is the real invariant and is regression-
guarded.

The five survivors are real and now tracked: soft-error-exit-zero (#2980),
untyped-success (#2979). Baseline 25 -> 5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(#2966): keep the ratchet out of the tarball, unpin the qa CI job

The remote matrix returned failed -- 3 unique failures, identical on
node22 and node24, both root causes in this branch's own diff.

The ratchet lives under scripts/, which ships in the npm tarball, and it
requires three modules under tests/, which does not. In a published
install it is MODULE_NOT_FOUND at load. This is exactly the class the
#2858 guard was added to catch, and it caught it. Fixed the way #2858
fixed the same shape for its own repo-only CI script: a targeted files[]
negation, so the ratchet stays in the repo for CI and out of the tarball.
Not solved by moving or inlining the required modules -- the ratchet must
keep using the same code the harness uses, or the two drift.

Verified both directions: the script is no longer in the pack list, and
build-hooks.js, fix-slash-commands.cjs and gen-capability-registry.cjs are
all still shipped. Over-negating there would have broken installs, since
bin/install.js requires them.

The qa-loop-walk job also carried CI_REBASE_BASE_SHA copied from a
neighbouring job without the paired GSD_EMITTED_BASE, which the #2854
invariant forbids by name: diverging them makes the differential compare a
tree against a baseline from a different commit. The job runs only the qa
suite and the ratchet and invokes no emitted-attribution test, so it needs
no rebase-pinned base at all -- the step was removed rather than paired.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(#2966): stop monotonic-progress going blind on scope-less payloads

The full remote suite caught a false NEGATIVE I introduced while fixing a
false positive. Silencing the boundary-crossing noise had made the oracle
skip ANY observation lacking milestone fields -- so a minimal payload like
{total_summaries: n} produced no violation at all, and the oracle stopped
catching the exact defect it exists to catch. For a QA tool that is
strictly worse than the noise it replaced.

Scope is only indeterminate when the two observations DISAGREE about
having it:

  both scoped, same scope, decrease -> VIOLATION
  both scoped, different scope      -> reset silently
  NEITHER scoped, decrease          -> VIOLATION   (the regression)
  mixed                             -> skip the comparison

Implementing the mixed case surfaced a second blind spot: advancing the
reference point on a skipped pair lets a scope-less observation sitting
between two same-scope ones mask a real decrease. Mixed now leaves the
reference untouched. All four branches carry explicit coverage; only one
did before, which is why this shipped.

The self-test that failed was right and the code was wrong, so the code
moved. Corpus behavior is unchanged: still 5 smells, 0 new, 0 stale, 0
violations.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 16:13:28 -04:00