From fa41bfec5cd556d80fa35cbb6a0e74f5f0c2ee10 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 27 Aug 2026 17:28:39 -0400 Subject: [PATCH] =?UTF-8?q?enhance(#3942):=20the=20emitted-drift=20ack=20i?= =?UTF-8?q?s=20PR-lifetime=20data=20=E2=80=94=20move=20it=20to=20a=20commi?= =?UTF-8?q?t=20trailer=20(#3954)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .changeset/serene-orcas-glide.md | 5 + .github/workflows/ack-fragment-sweep.yml | 280 -- .github/workflows/test.yml | 76 - CONTEXT.md | 8 +- CONTRIBUTING.md | 123 +- docs/CONTEXT-INDEX.json | 6 +- docs/README.md | 1 + docs/TESTING-SUITES.md | 53 +- docs/adr/2719-emitted-artifact-attribution.md | 2 +- docs/adr/3128-adaptive-runtime-evidence.md | 2 +- .../3942-emitted-drift-ack-commit-trailer.md | 34 +- docs/how-to/acknowledge-emitted-drift.md | 69 + .../CONTEXT-INDEX.json | 512 +-- package.json | 2 +- scripts/lint-emitted-drift-ack.cjs | 936 ----- scripts/lint-removed-but-needed.cjs | 42 +- scripts/qa-smell-ratchet.cjs | 10 +- tests/agent-tracked-source-rule.test.cjs | 20 +- tests/emitted-ack-trailer.test.cjs | 709 ++++ tests/emitted-attribution.test.cjs | 3164 ++--------------- .../3034-parallel-reviewer-lanes.json | 8 - .../3172-stated-failing-direction.json | 6 - .../3707-parse-gap-reporting.json | 12 - tests/emitted-drift-acks/README.md | 77 - tests/helpers/emitted-diff.cjs | 720 ++-- tests/helpers/emitted-runtime.cjs | 345 +- tests/qa/smell-acks/README.md | 15 +- tests/removed-but-needed-lint.test.cjs | 127 + 28 files changed, 2106 insertions(+), 5258 deletions(-) create mode 100644 .changeset/serene-orcas-glide.md delete mode 100644 .github/workflows/ack-fragment-sweep.yml create mode 100644 docs/how-to/acknowledge-emitted-drift.md delete mode 100644 scripts/lint-emitted-drift-ack.cjs create mode 100644 tests/emitted-ack-trailer.test.cjs delete mode 100644 tests/emitted-drift-acks/3034-parallel-reviewer-lanes.json delete mode 100644 tests/emitted-drift-acks/3172-stated-failing-direction.json delete mode 100644 tests/emitted-drift-acks/3707-parse-gap-reporting.json delete mode 100644 tests/emitted-drift-acks/README.md diff --git a/.changeset/serene-orcas-glide.md b/.changeset/serene-orcas-glide.md new file mode 100644 index 000000000..bd55eeaba --- /dev/null +++ b/.changeset/serene-orcas-glide.md @@ -0,0 +1,5 @@ +--- +type: Changed +pr: 3954 +--- +**Emitted-drift acknowledgments move from a committed file to a commit trailer.** A PR that legitimately ripples emitted-artifact bytes now declares it with an `Emitted-Drift-Ack-Hash:` or `Emitted-Drift-Ack-Growth:` trailer on one of its own commits instead of adding a JSON fragment under `tests/emitted-drift-acks/`. The acknowledgment was only ever valid for the life of the PR, so keeping it in the working tree meant every merged one became cruft that had to be detected and garbage-collected; the trailer leaves nothing behind and cannot conflict. Removes the shipped `scripts/lint-emitted-drift-ack.cjs`, the scheduled sweep workflow, and the `guard-no-ack-on-next` job. (#3942) diff --git a/.github/workflows/ack-fragment-sweep.yml b/.github/workflows/ack-fragment-sweep.yml deleted file mode 100644 index de94bb252..000000000 --- a/.github/workflows/ack-fragment-sweep.yml +++ /dev/null @@ -1,280 +0,0 @@ -name: Sweep spent ack fragments - -# Companion to the `guard-no-ack-on-next` job in test.yml. That job DETECTS a -# fully-spent emitted-drift-ack fragment surviving on `next` and reds the branch; -# until #3875 the only remedy was a human reading CI prose and hand-authoring a -# `git rm` PR. That remedy is structurally unable to keep up: the guard evaluates -# dynamically at MERGE time, while a hand-authored sweep is a static set of -# deletions fixed at BRANCH time, so any ack-carrying PR that merges in between -# invalidates it. #3823 lost that race to #3809 on its own merge commit and left -# `next` red for 24 consecutive pushes. -# -# This sweep closes that window by deriving the deletion list from the guard -# ITSELF (`--sweep-plan`), on a timer, immediately before acting on it. It opens a -# PR rather than pushing to `next` directly: `next` is protected, and an -# acknowledgment is a reviewed artifact, so a human still approves the deletion. - -on: - schedule: - - cron: '30 */6 * * *' - workflow_dispatch: - -concurrency: - group: ack-fragment-sweep - cancel-in-progress: false - -permissions: - contents: write - pull-requests: write - -jobs: - sweep: - name: Sweep all-spent ack fragments on next - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: next - # The guard reads each surviving fragment at the commit before HEAD. - # Full history, not depth 2: the sweep branch is pushed from here, and - # a shallow clone cannot be pushed to a protected-branch repo cleanly. - fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} - - - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 - with: - node-version: 24 - - - name: Compute the sweep plan - id: plan - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - # stdout is the machine-readable plan, stderr the guard's own prose — - # the prose is teed into the job log so a run that sweeps nothing still - # explains why (including which fragments the #3842 open-PR hold kept). - # - # `--defer-to-open-prs` is NOT optional here. Without it the plan names - # every all-spent fragment including ones an open PR still modifies, and - # deleting one of those hands that PR a modify/delete conflict it did not - # cause — the exact failure #3842 exists to prevent. - # - # The hold is evaluated at PLAN time, not at merge time, so it narrows but - # does not eliminate the conflict window: a PR opened after this step runs - # that re-arms a planned fragment by appending prose to it can still meet a - # modify/delete when this sweep lands. That residue is why the sweep opens a - # reviewable PR rather than pushing to `next` — a human sees the diff, and - # a conflict surfaces as a normal merge conflict on a bot PR rather than as - # a surprise on someone else's. - # - # No `--base-ref`: a scheduled run has no `github.event.before`, so the - # script's own `HEAD^` fallback applies. Note what that fallback actually - # does — it is NOT simply conservative. On a rebase-merge landing N>=2 - # commits whose first adds a fragment, `HEAD^` lands on that first commit, - # reads the fragment as already present, and plans it (the script says so - # itself, at `resolveBaseRef`). That is acceptable HERE, and only here: a - # fragment that has reached `next` is spent by the lifecycle definition - # regardless of which commit of the push carried it, and the open-PR hold - # below still protects any PR that is still touching it. The same fallback - # would be wrong for the push-lane guard, which is exactly why test.yml - # passes `github.event.before` explicitly instead. - # `|| true` would be wrong here. In plan mode the script exits 0 for - # EVERY verdict, so a non-zero status is a real fault — a crash, an - # unreadable base ref, a rejected flag — and must fail the job loudly. - # Swallowing it would leave plan.txt empty and report the fault as the - # cheerful "nothing to sweep", which is the silent-failure class this - # whole ack seam exists to end. - set +e - node scripts/lint-emitted-drift-ack.cjs \ - --guard-next --sweep-plan --defer-to-open-prs \ - > plan.txt 2> prose.txt - guard_status=$? - set -e - echo '--- guard output ---' - cat prose.txt - if [ "$guard_status" -ne 0 ]; then - echo "::error::guard exited ${guard_status} in plan mode — that is a fault, not a verdict" - exit 1 - fi - - if [ ! -s plan.txt ]; then - # An empty plan has two very different causes, and reporting both as - # "nothing to sweep" is how this automation would go quietly inert. - # - # Cause A: `next` is clean. Nothing to do, and silence is right. - # - # Cause B: every all-spent fragment is HELD by an open PR — and the - # commonest such PR is the sweep PR THIS WORKFLOW opened last run, which - # touches exactly the fragments it proposed to delete. So run #2 onward - # would report a cheerful green while `next` stays red and the sweep PR - # rots unmerged. Re-running the guard WITHOUT the hold separates the two: - # a non-empty unheld set with an empty plan means "blocked, not done". - set +e - node scripts/lint-emitted-drift-ack.cjs \ - --guard-next --sweep-plan > unheld.txt 2>/dev/null - unheld_status=$? - set -e - if [ "$unheld_status" -eq 0 ] && [ -s unheld.txt ]; then - open_sweep=$(gh pr list --base next --state open \ - --json number,headRefName \ - --jq '[.[] | select(.headRefName | startswith("chore/ack-sweep-"))] | .[0].number // empty' \ - 2>/dev/null || echo "") - if [ -n "$open_sweep" ]; then - echo "::warning::next still carries spent ack fragment(s); sweep PR #${open_sweep} is open and awaiting merge. Nothing further this run." - else - echo '::warning::next still carries spent ack fragment(s), but every one is held by an open PR that touches it (#3842). They will be swept once those PRs merge or close.' - fi - echo 'held fragments:' - cat unheld.txt - else - echo 'nothing to sweep' - fi - echo 'empty=true' >> "$GITHUB_OUTPUT" - exit 0 - fi - - # Every planned path is re-validated before anything is deleted, against - # a strict allowlist rather than a shape check. The plan comes from our - # own script, but the fragment BASENAMES in it come from readdirSync and - # are filtered only on the `.json` suffix, so any filename a merged PR - # can land in that directory reaches this point. - # - # Two concrete attacks this closes. (1) A file literally named `*.json` - # is a valid filename and passes a `tests/emitted-drift-acks/*.json` - # glob test — and `git rm` treats each argument as a PATHSPEC, so it - # would then expand to every fragment in the tree, including ones the - # #3842 open-PR hold deliberately withheld. Verified locally: one such - # file deletes all of them. (2) A filename containing backticks or - # newlines is interpolated into the PR body below, injecting markdown - # into a document this repo's review agents read. - # - # The allowlist admits only a leading alphanumeric followed by - # alphanumerics, dot, underscore and hyphen — no glob metacharacters, no - # whitespace, no backticks, no slash, so no traversal and no leading `-` - # for git to read as an option. A filename containing a newline arrives - # here as two lines, each of which fails the pattern: it fails closed. - # - # The plan carries two shapes: the legacy single document at a fixed path - # (`tests/emitted-drift-ack.json`), whose mere PRESENCE reds `next`, and - # fragment basenames under `tests/emitted-drift-acks/`. The legacy path is - # matched exactly and literally; only the fragment arm takes a name pattern. - while IFS= read -r line; do - [ -n "$line" ] || continue - if ! printf '%s' "$line" \ - | grep -qE '^(tests/emitted-drift-ack\.json|tests/emitted-drift-acks/[A-Za-z0-9][A-Za-z0-9._-]*\.json)$'; then - echo "::error::refusing to sweep unexpected path: $line" - exit 1 - fi - if [ ! -f "$line" ]; then - echo "::error::planned path is not a file: $line" - exit 1 - fi - done < plan.txt - - echo 'empty=false' >> "$GITHUB_OUTPUT" - echo 'planned fragments:' - cat plan.txt - - - name: Open the sweep PR - if: steps.plan.outputs.empty == 'false' - env: - GH_TOKEN: ${{ secrets.GSD_BOT_PR_TOKEN || secrets.GITHUB_TOKEN }} - HAS_BOT_TOKEN: ${{ secrets.GSD_BOT_PR_TOKEN != '' && '1' || '' }} - run: | - set -euo pipefail - # `GITHUB_TOKEN` cannot raise workflow runs for the events it creates, so a - # PR opened under the fallback never reports a single required check and - # sits permanently pending. That is a silent, confusing failure, so it is - # announced rather than discovered. - if [ -z "${HAS_BOT_TOKEN:-}" ]; then - echo '::warning::GSD_BOT_PR_TOKEN is unset; opening the sweep PR with GITHUB_TOKEN. No required checks will run on it and it will not be mergeable until a maintainer pushes to the branch.' - fi - SHORT_SHA=$(git rev-parse --short HEAD) - BR="chore/ack-sweep-${SHORT_SHA}" - - # An earlier run may already have opened a sweep PR for this same tip. - EXISTING=$(gh pr list --base next --head "$BR" --state open --json number --jq '.[0].number // empty' 2>/dev/null || echo "") - if [ -n "$EXISTING" ]; then - echo "sweep PR #${EXISTING} already open for ${SHORT_SHA}" - exit 0 - fi - - git config user.name 'github-actions[bot]' - git config user.email 'github-actions[bot]@users.noreply.github.com' - git checkout -b "$BR" - - # `:(literal)` pathspec magic, one path per call. `--` stops OPTION - # parsing but git still reads each remaining argument as a pathspec with - # wildmatch semantics, so a fragment named `*.json` would expand to every - # fragment in the directory. `:(literal)` disables that globbing and makes - # each argument mean exactly the bytes it contains. The allowlist in the - # plan step already rejects such a name; this is the second, independent - # layer, because over-deletion here is unrecoverable within the run. - while IFS= read -r frag; do - [ -n "$frag" ] || continue - git rm -q -- ":(literal)${frag}" - done < plan.txt - - COUNT=$(grep -c . plan.txt) - LIST=$(sed 's/^/- /' plan.txt) - - git commit -q -m "chore(#3875): sweep ${COUNT} spent ack fragment(s) from next" - - # A previous run can have pushed this branch and then died before - # `gh pr create`, or a human can have closed the PR without deleting the - # branch. In both cases the open-PR probe above finds nothing, the rebuilt - # commit gets a fresh committer timestamp and therefore a different sha, - # and a plain push is rejected as non-fast-forward — every six hours, - # forever, with no path to sweep that tip. Re-point the stale branch - # instead, under a lease so a branch someone else has moved is never - # clobbered blind. - if git ls-remote --exit-code --heads origin "$BR" >/dev/null 2>&1; then - git fetch -q origin "$BR" - git push -q --force-with-lease="${BR}:$(git rev-parse FETCH_HEAD)" origin "$BR" - else - git push -q origin "$BR" - fi - - # No apostrophes in this heredoc body: bash scans $( ) for quotes before - # it recognises the nested heredoc, so a lone ' here is an unterminated - # quote and a hard syntax error at runtime, not just under `bash -n`. - BODY=$(cat <)` and refuses with `WINDOWS_LEDGER_TABLE_DRIFT` before writing, so a hand-edited cell is never silently reverted and a table-only row is never silently erased. Deliberately NOT enforced in `parseLedger`: hardening the read would break `windows status` and the ship gate on exactly the ledgers an operator needs to inspect. Each entry: `{ id, kind, phase, file, line, description, status, reason, recorded_at, resolved_at }`; kinds are closed (`stub | todo | fixme | skipped-test | lint-warning | unmet-truth | unrun-verify | deviation`); statuses are closed (`open | waived | fixed`). The `broken-windows` Capability (`capabilities/broken-windows/capability.json`) registers one `ship:pre` gate with predicate `artifact-frontmatter-equals WINDOWS.md open_count == 0`; federated config key `workflow.windows_enforce` (default `false` — opt-in enforcement, tracking-only by default so a project can adopt the ledger before turning the gate on). Population is best-effort and never blocks execution: `agents/gsd-executor.md` appends stubs/skipped-tests/unrun-verifies via `gsd_run windows append` after writing SUMMARY.md. Source of truth: `src/broken-windows.cts` → `gsd-core/bin/lib/broken-windows.cjs` (pure `parseLedger`/`renderLedger`/`appendWindow`/`markWaived`/`markFixed` + I/O `cmdWindowsStatus`/`Append`/`Waive`/`MarkFixed`); CLI surface `gsd-tools windows status|append|waive|fixed`. Ship gate enforcement is a `capId == "broken-windows"` named specialization inside `gsd-core/workflows/ship.md` preflight's generic `kind == "gate"` dispatch loop (sibling to the `security` specialization; #3559 made that loop generic, so every OTHER capability's `ship:pre` gate is now evaluated through `gsd_run check predicate` instead of being resolved and silently dropped, while these two keep their bespoke fail-closed reads and are each visited exactly once); it reads `gsd_run windows status --raw` and fails closed on a non-zero/non-numeric `open_count` (an unparseable ledger is itself a broken window). `/gsd:progress` surfaces the open+waived count. The ledger is optional and backward-compatible: a project with no `.planning/WINDOWS.md` reports `open_count: 0` and ships cleanly, and with `workflow.windows_enforce=false` (the default) ship never blocks on it. Frozen `REASON` enum: `WINDOWS_LEDGER_MISSING | WINDOWS_LEDGER_MALFORMED | WINDOWS_LEDGER_TABLE_DRIFT | WINDOWS_ID_NOT_FOUND | WINDOWS_ALREADY_RESOLVED | WINDOWS_WAIVE_REASON_EMPTY | WINDOWS_INVALID_KIND | WINDOWS_INVALID_FILE | WINDOWS_INVALID_ID | WINDOWS_APPEND_MISSING_FIELD | WINDOWS_USAGE | WINDOWS_OK` — surfaced through `--json-errors` for typed test assertions. Test seam: `tests/broken-windows.test.cjs`. Origin: *The Pragmatic Programmer* Topic 3 (Hunt & Thomas — software transplant of Wilson & Kelling's broken-windows metaphor) plus Cunningham's debt metaphor (decay accrues interest ⇒ accounting, not just habit). ### Emitted Artifact Provenance -Cross-seam principle (ADR-2719, epic #2719): a committed artifact that is a pure function of the source tree is not reviewable state — it is derived state wearing a review costume, and it must be *attributable* rather than *pinned*. Concept, not a Module: it ships nothing, so it takes no `Module` suffix (follows the `### Resolution Provenance` precedent). Scope is the emitted-artifact family named by `RULESET.EMITTED_ATTRIBUTION`. The principle: every emitted path whose hash moved between `next` HEAD and PR HEAD must be attributable — through a declarative provenance table — to a path the pull request actually changed; unattributable deltas are a hard failure that *names them* rather than an anomaly a reviewer must notice inside 7,500 lines of hex. Totality is enforced, so an emitted path matching no rule fails loudly instead of passing through unattributed. The escape hatch is a committed acknowledgment — a per-PR fragment under `tests/emitted-drift-acks/` (#2914; the legacy single `tests/emitted-drift-ack.json` is still read and unioned in for pre-#2914 branches, with a duplicate key across two sources a hard, loudly-reported error rather than silent last-wins), deliberately not a flag or env var — a fragment appears in the changed-files list ONLY when something rippled unexpectedly, so adding one IS the alarm, whereas today 100% of emitted-byte changes touch fixtures and touching them signals nothing. Fragments exist because the single legacy file, rewritten wholesale by every PR needing an ack, was a guaranteed merge-conflict cell between any two such PRs (5 of 6 conflicting PRs in one open queue collided on it and nothing else) — the same shape `.changeset/` already solves the same way. Fragments end the FILE conflict but not the KEY conflict: two sources may never name the same path, so a fully-spent fragment left on `next` still walls off every path it owns until it is swept (#3078; see `RULESET.EMITTED_ATTRIBUTION`). The same differential machine carries the size ratchet: growth is reported with exact byte deltas and needs the same acknowledgment, so anti-creep survives without pinning a number. Distinguish from the absolute check that remains: `tests/fixtures/install-tree/*.json` stays committed and normally-merged (ADR-2719 §7) because "the installer stopped shipping X" must fail with no attribution reasoning involved. Delivery was phased — #2721 naming + interim merge relief, #2722 provenance table + totality guard, #2723 differential check dual-run beside `golden-install-parity.test.cjs`, #2724 cutover (COMPLETE: the dual-run window observed agreement on real PRs after fixing #2750/#2760, and the golden fixtures/test/generator/merge-driver bridge are now deleted; the differential is the sole gate). The table LANDED in #2722 as `tests/helpers/emitted-provenance.cjs` (19 rules, guarded by `tests/emitted-provenance.test.cjs`); it maps emitted path → repo source and is TOTAL over EVERY emitted path in all 19 manifests — exactly one rule per path, with zero-match, two-match, AND dead-rule (a rule matching nothing) all hard failures, so table rot is loud in both directions. Deliberately NO path/family counts are recorded here: those move with every shipped-content edit, and a hand-maintained number in glossary canon is the exact silent-drift failure this whole seam exists to end. The guard recomputes them from the fixtures on every run — read them from a failure message, never from prose. What IS stable is the rule count, which changes only when a new emitted family or host appears. Note the surface is materially wider than #2722 estimated from `claude.json` alone (its "13 families / 15-20 rules" was a single-runtime sample; the 19-manifest surface spans runtime-specific roots — `.agents/`, `.kimi/hooks/`, `command/`, `agents/subagents/`, `.clinerules/`, `plugins/`, `extensions/`, `.gsd/`, the hermes `skills/gsd/` category and the #69 nested `skills//skills//` layout). Two design invariants carry forward to #2723: emitted SHAPES are hard-coded (deriving them from the installer would make the guard tautological — it would follow any installer change silently), while source PATHS may read a first-party descriptor where that descriptor is the sole declaration (`hostBehaviors.nativePlugin.source`); and attribution is keyed on `(rel, runtime)`, never `rel` alone, because one emitted path has different sources per host (`plugins/gsd-core.js` ← `.opencode/` vs `.kilo/`). Emitted skills attribute to `commands/gsd/*.md`, NEVER the repo `skills/` dir — that dir is itself generated from `commands/gsd` by `scripts/gen-plugin-skills.cjs`, so attributing to it is false attribution that still passes totality. Totality does NOT catch a rule pointing at the WRONG source (the recorded residual); the guard against that is the companion assertion that every attributed source EXISTS in the repo, which caught three real cases while the table was built (Copilot's `.agent.md` rename, Kimi's code-literal `agents/gsd.{yaml,md}` root agent, and Copilot's `hooks/gsd-session.json`). A `sources` entry ending in `/` is a PREFIX, not a file — and prefix matching is SEGMENT-AWARE, so a source of `agents/` must not attribute `agentsfoo/x.md`. The differential check LANDED in #2723 as `tests/helpers/emitted-diff.cjs` (the conservation law, a PURE function — no fs/git/installer/clock) + `tests/helpers/emitted-baseline.cjs` (baseline resolution), guarded by `tests/emitted-attribution.test.cjs`. It ran DUAL beside `golden-install-parity.test.cjs` through the #2723 dual-run window with both green and fixtures untouched; #2724 deleted the golden fixtures/test/generator and the check is now the sole gate. Purity is deliberate and load-bearing: the naive one-big-integration-test shape would need ~38 installer spawns per assertion, so the four failing-first criteria would not in practice get written — which is exactly how a phase ships promised-but-not-built. Buckets are CONSERVED: every moved emitted path lands in exactly one of `attributed | unattributable | acked` (property-tested), and a path the provenance table cannot resolve surfaces as an ERROR rather than a silent skip. Four asymmetries worth knowing: an ADDED emitted key is a ripple too (not just modified ones); `synthesized` paths are exempt but `code-derived` ones are NOT (that is why Phase 2 refused to mark them exempt — exempt means permanently blind); SHRINKAGE needs no ack while growth does (gating shrinkage would punish what the ratchet wants); and a STALE ack is a hard failure — but ONLY for an ack THIS diff wrote or reworded (#2789). An ack is SCOPED TO THE DIFF THAT INTRODUCED IT: `diffEmitted` takes the document at the base ref (`baseAck`, read by `readAckFileAtRef`) alongside the working-tree one, and an entry already present at the base is SPENT — its ripple is absorbed into the base, so it can no longer clear a delta and is never reported stale (surfaced as `spentAcks`, informational, gating nothing). Before #2789 the ack set was the one ABSOLUTE input to an otherwise base-relative machine — `baseline` vs `current`, `changedPaths` from `git diff base...HEAD` — and that mismatch made a MERGED ack indistinguishable from one that never explained anything, since `staleAcks` asks only "did a delta consume you?": merging an ack the PR lane had already accepted reddened `next` and every PR branching off it (#2768). Making spent entries inert is also what finally closes the pre-clearing hazard the original design NAMED but could not prevent — a leftover ack used to silently clear the next ripple on its path; now that ripple must be explained on its own terms, and a reworded reason is how a contributor re-arms an ack deliberately. `baseAck` is REQUIRED once an ack DECLARES ENTRIES (omission is an error, never a silent "inherit nothing", same discipline as `changedPaths`; an entry is the only thing that can be misclassified, so an empty-but-legal document needs no base side). Absent AT THE REF returns null — the healthy steady state — but every other read failure THROWS, and that asymmetry is load-bearing in the direction that is easy to invert: returning null looks armed because every entry stays LIVE, yet a live entry's defining power is that it CONSUMES a delta, so null is armed on the staleness axis and DISARMED on the consumption axis — a genuinely new unexplained ripple on a path carrying an already-merged ack would come back `acked` instead of `unattributable`, silently restoring the whole pre-#2789 gate. `git show` cannot tell absence from fault (both say "does not exist in"), so absence is established with `ls-tree`. Re-arming a spent ack is legitimate and deliberate, but it costs ACTUAL PROSE: the comparison collapses internal whitespace and ignores `runtime`, because a doubled space or a decorative field would otherwise re-arm an ack whose recorded justification still describes the PREVIOUS ripple, showing a reviewer nothing new in the diff. Because a corrupt document ON THE BASE is expensive (it reds every PR carrying an ack until repaired), `scripts/lint-emitted-drift-ack.cjs` runs in `lint:ci` and refuses the merge before one can land — invalid JSON, a non-object, a bad version, a reasonless entry, or a present-but-entryless/`null` document. It is deliberately STANDALONE rather than importing `parseAck` (`scripts/` ships in the npm package and `tests/` does not, so the require would be MODULE_NOT_FOUND once published); the duplication is bounded by a parity test that runs both surfaces over one corpus and fails on any disagreement about schema validity. The two are MEANT to differ on exactly one axis: an entryless or `null` document is legal to PARSE (it is the gate's own absent-equals-no-acks sentinel) and still refused for COMMIT. Deadlock is separately foreclosed at the call site — a tree carrying no ack never reads the base at all, so the PR that DELETES a corrupt file still lands. Each ack source — a fragment under `tests/emitted-drift-acks/`, or the legacy `tests/emitted-drift-ack.json` (#2914; both read and UNIONED via `mergeAckSources`/`readAckSources`/`readAckSourcesAtRef` in `tests/helpers/emitted-diff.cjs` / `emitted-runtime.cjs`, a duplicate key across sources a hard error) — follows the same rule: absent = no acks; a LIVE entry is the alarm, a spent one is inert cruft; requires a non-empty `reason` per path — "name them and say why" is the contract, and a document that parses but is not an object is rejected rather than read as "no acks", which would silently disarm the gate. Baseline is CACHED not committed, keyed on the `next` sha; a stale key is REFUSED, never used — absence fails loudly and gets fixed, whereas staleness produces a confident wrong answer. An explicitly-pointed-at (`GSD_EMITTED_BASELINE`) stale baseline is a hard stop, while a stale CACHE falls through to the in-job build. No baseline-unavailable path may `return` (in `node:test` that is a PASS, not a skip — ADR-2719 §6). Supersedes ADR-2264 §2–§4 and its Amendment; ADR-2264 Phase 1 (`buildParityManifest` and the exclusion constants in `tests/helpers/install-shared.cjs`) is retained and depended upon. +Cross-seam principle (ADR-2719, epic #2719): a committed artifact that is a pure function of the source tree is not reviewable state — it is derived state wearing a review costume, and it must be *attributable* rather than *pinned*. Concept, not a Module: it ships nothing, so it takes no `Module` suffix (follows the `### Resolution Provenance` precedent). Scope is the emitted-artifact family named by `RULESET.EMITTED_ATTRIBUTION`. The principle: every emitted path whose hash moved between `next` HEAD and PR HEAD must be attributable — through a declarative provenance table — to a path the pull request actually changed; unattributable deltas are a hard failure that *names them* rather than an anomaly a reviewer must notice inside 7,500 lines of hex. Totality is enforced, so an emitted path matching no rule fails loudly instead of passing through unattributed. The escape hatch is a commit trailer on the PR's own commits (ADR-3942, superseding ADR-2719 §3), not a committed document — two structurally distinct key spaces (separate maps, closing a latent defect where a growth key could satisfy a hash lookup by naming coincidence and vice versa): `Emitted-Drift-Ack-Hash:` keyed on the emitted path (always contains `/`), `Emitted-Drift-Ack-Growth:` keyed on the bare filename under `gsd-core/workflows/` or `agents/` — key and reason separated by ` — ` (space, em dash, space; split on the FIRST occurrence), deliberately not a flag or env var — a trailer appears in the PR's commit list ONLY when something rippled unexpectedly, so adding one IS the alarm, whereas today 100% of emitted-byte changes touch fixtures and touching them signals nothing. Read from `git log $(git merge-base HEAD)..HEAD` — the SAME merge-base `changedPaths` uses via `git diff base...HEAD`, so the ack set and the change set cannot disagree about which commits are this PR's; a two-dot range would be a defect. "Spent" no longer exists: a merged trailer is out of range by construction, not by computation, since there is no base-side copy to compare against. An uncomputable range (shallow clone) THROWS rather than passing vacuously — zero trailers means a PR needing one fails, a false red rather than a false green. `staleAcks` is retained: a trailer declaring a key nothing consumed is still a hard error, and the message names WHICH key space. This design is the terminus of a chain that began because the ack was PR-lifetime data kept in permanent shared state: the single legacy `tests/emitted-drift-ack.json` was a guaranteed merge-conflict cell (#2789; 5 of 6 conflicting PRs in one open queue collided on it and nothing else); #2914 split it into per-PR fragments under `tests/emitted-drift-acks/`, ending the FILE conflict but not the KEY conflict, since two sources could never name the same path; #3078 found a fully-spent fragment left on `next` still walled off every path it owned until swept; #3842 and #3823 tried automated and hand-authored sweeps and each created the next conflict; #3875's timed sweeper automated the remedy but could not merge its own PRs. ADR-3942 ends the chain by moving the ack off the tree entirely (see `RULESET.EMITTED_ATTRIBUTION`). The same differential machine carries the size ratchet: growth is reported with exact byte deltas and needs the same acknowledgment, so anti-creep survives without pinning a number. Distinguish from the absolute check that remains: `tests/fixtures/install-tree/*.json` stays committed and normally-merged (ADR-2719 §7) because "the installer stopped shipping X" must fail with no attribution reasoning involved. Delivery was phased — #2721 naming + interim merge relief, #2722 provenance table + totality guard, #2723 differential check dual-run beside `golden-install-parity.test.cjs`, #2724 cutover (COMPLETE: the dual-run window observed agreement on real PRs after fixing #2750/#2760, and the golden fixtures/test/generator/merge-driver bridge are now deleted; the differential is the sole gate). The table LANDED in #2722 as `tests/helpers/emitted-provenance.cjs` (19 rules, guarded by `tests/emitted-provenance.test.cjs`); it maps emitted path → repo source and is TOTAL over EVERY emitted path in all 19 manifests — exactly one rule per path, with zero-match, two-match, AND dead-rule (a rule matching nothing) all hard failures, so table rot is loud in both directions. Deliberately NO path/family counts are recorded here: those move with every shipped-content edit, and a hand-maintained number in glossary canon is the exact silent-drift failure this whole seam exists to end. The guard recomputes them from the fixtures on every run — read them from a failure message, never from prose. What IS stable is the rule count, which changes only when a new emitted family or host appears. Note the surface is materially wider than #2722 estimated from `claude.json` alone (its "13 families / 15-20 rules" was a single-runtime sample; the 19-manifest surface spans runtime-specific roots — `.agents/`, `.kimi/hooks/`, `command/`, `agents/subagents/`, `.clinerules/`, `plugins/`, `extensions/`, `.gsd/`, the hermes `skills/gsd/` category and the #69 nested `skills//skills//` layout). Two design invariants carry forward to #2723: emitted SHAPES are hard-coded (deriving them from the installer would make the guard tautological — it would follow any installer change silently), while source PATHS may read a first-party descriptor where that descriptor is the sole declaration (`hostBehaviors.nativePlugin.source`); and attribution is keyed on `(rel, runtime)`, never `rel` alone, because one emitted path has different sources per host (`plugins/gsd-core.js` ← `.opencode/` vs `.kilo/`). Emitted skills attribute to `commands/gsd/*.md`, NEVER the repo `skills/` dir — that dir is itself generated from `commands/gsd` by `scripts/gen-plugin-skills.cjs`, so attributing to it is false attribution that still passes totality. Totality does NOT catch a rule pointing at the WRONG source (the recorded residual); the guard against that is the companion assertion that every attributed source EXISTS in the repo, which caught three real cases while the table was built (Copilot's `.agent.md` rename, Kimi's code-literal `agents/gsd.{yaml,md}` root agent, and Copilot's `hooks/gsd-session.json`). A `sources` entry ending in `/` is a PREFIX, not a file — and prefix matching is SEGMENT-AWARE, so a source of `agents/` must not attribute `agentsfoo/x.md`. The differential check LANDED in #2723 as `tests/helpers/emitted-diff.cjs` (the conservation law, a PURE function — no fs/git/installer/clock) + `tests/helpers/emitted-baseline.cjs` (baseline resolution), guarded by `tests/emitted-attribution.test.cjs`. It ran DUAL beside `golden-install-parity.test.cjs` through the #2723 dual-run window with both green and fixtures untouched; #2724 deleted the golden fixtures/test/generator and the check is now the sole gate. Purity is deliberate and load-bearing: the naive one-big-integration-test shape would need ~38 installer spawns per assertion, so the four failing-first criteria would not in practice get written — which is exactly how a phase ships promised-but-not-built. Buckets are CONSERVED: every moved emitted path lands in exactly one of `attributed | unattributable | acked` (property-tested), and a path the provenance table cannot resolve surfaces as an ERROR rather than a silent skip. Four asymmetries worth knowing: an ADDED emitted key is a ripple too (not just modified ones); `synthesized` paths are exempt but `code-derived` ones are NOT (that is why Phase 2 refused to mark them exempt — exempt means permanently blind); SHRINKAGE needs no ack while growth does (gating shrinkage would punish what the ratchet wants); and a STALE ack — a declared key nothing consumed — is a hard failure (#2789 built spent/live detection against a base-relative document that persisted after merge, requiring `readAckFileAtRef`/`baseAck`/`spentAcks` and a `git show`-vs-`ls-tree` absence check to tell "never explained anything" from "already merged, and reddening `next` and every branching PR when it wasn't told apart" (#2768); ADR-3942 makes staleness structural instead of computed — every trailer read is definitionally THIS diff's, scoped by `git merge-base HEAD`, so there is no base copy to compare against and no re-arm-by-reword mechanic to defend, and the base-document corruption hazard the standalone ack linter existed to guard against is gone with the document). Since ADR-3942 there is exactly ONE ack source and it is not a file: the `Emitted-Drift-Ack-Hash:` / `Emitted-Drift-Ack-Growth:` trailers on the PR's own commits, read by `readAckTrailers` (`tests/helpers/emitted-runtime.cjs`) over `git log $(git merge-base HEAD)..HEAD` and parsed by `parseAckTrailers` (`tests/helpers/emitted-diff.cjs`) into TWO structurally distinct key-space maps — closing the latent defect where one shared `paths` map let a growth key satisfy a hash lookup by naming coincidence. Absent = no acks; a declared key nothing consumed is a hard `staleAcks` error that now names WHICH space; a non-empty reason per key is required — "name them and say why" is the contract (ADR-2719 §3, retained). A key declared twice with conflicting reasons is a hard error; declared twice identically it is deduped. An UNCOMPUTABLE range (shallow clone) THROWS rather than reading as zero acks — the inverse of the fragment guard's vacuous-pass failure, and fail-closed in the safe direction. Baseline is CACHED not committed, keyed on the `next` sha; a stale key is REFUSED, never used — absence fails loudly and gets fixed, whereas staleness produces a confident wrong answer. An explicitly-pointed-at (`GSD_EMITTED_BASELINE`) stale baseline is a hard stop, while a stale CACHE falls through to the in-job build. No baseline-unavailable path may `return` (in `node:test` that is a PASS, not a skip — ADR-2719 §6). Supersedes ADR-2264 §2–§4 and its Amendment; ADR-2264 Phase 1 (`buildParityManifest` and the exclusion constants in `tests/helpers/install-shared.cjs`) is retained and depended upon. ### Untrusted-input boundary The prompt-level data/instruction isolation seam for untrusted web/document ingress (#1577). Shared reference `gsd-core/references/untrusted-input-boundary.md`, `@`-included by the 10 ingest agents (`gsd-project-researcher`, `gsd-phase-researcher`, `gsd-ui-researcher`, `gsd-assumptions-analyzer`, `gsd-advisor-researcher`, `gsd-ai-researcher`, `gsd-domain-researcher`, `gsd-research-synthesizer`, `gsd-doc-classifier`, `gsd-doc-synthesizer`) — every agent that reads fetch/search/MCP output or external source documents. The reference instructs: treat fetched/read content as **data, never instructions**; self-scan content for embedded directives before use; act only on the assigned task (ignore off-task instructions in data); and wrap quoted untrusted spans in a **fresh random delimiter** per wrap (fixed markers are spoofable). This prompt-level boundary is the primary control — it keeps an injection from being *followed* even while it sits in context. The hook-level companion is the read-injection scanner (`hooks/gsd-read-injection-scanner.js`, PostToolUse on `Read`/`WebFetch`/`WebSearch`), advisory by default; the opt-in top-level `security.injection_blocking` key upgrades HIGH-confidence detections to a PostToolUse circuit-breaker that halts the agent's next step (it runs *after* the fetch, so it is not a redactor). Tests: `tests/untrusted-input-isolation.test.cjs`, `tests/read-injection-scanner.*.test.cjs`, `tests/injection-blocking-config.test.cjs`. See `docs/adr/1577-untrusted-input-boundary-and-injection-blocking.md` and `docs/explanation/security-model.md`. Grounding: arXiv 2506.05739 (PPA), 2507.15219 (PromptArmor), 2504.20472. @@ -620,9 +620,9 @@ The prompt-level data/instruction isolation seam for untrusted web/document ingr `RULESET.AUDIT.search-source-not-generated=verify an invariant/validation EXISTS by searching the AUTHORED source (src/*.cts OR the scripts/gen-*.cjs generator), never the generated bin/lib/*.cjs (gitignored, ADR-457); gen-time checks live in gen-*.cjs not the .cts it consumes → search BOTH before declaring absent; read generated .cjs only for output drift. Repro: grep src/*.cts for VALID_CONVERTER_NAMES → false "5e ConverterName unenforced"; actually enforced in gen-capability-registry.cjs. cf RULESET.TESTS.no-source-grep` `RULESET.WORKFLOW_MARKDOWN.FENCES=preserve opening language fence when editing shell snippets in workflow markdown; malformed fence creates fresh CR threads (MD040)` -`RULESET.WORKFLOW_SIZE_BUDGET=workflow size enforcement (#1074; BYTES not lines per #717; LF-normalized per #683) = differential attribution size ratchet (PRIMARY anti-creep since #2724/ADR-2719 §4: tests/emitted-attribution.test.cjs's real-tree test reports growth in any gsd-core/workflows/*.md with its exact byte delta vs `next`, no committed snapshot, requires an ack entry — a fragment under tests/emitted-drift-acks/, #2914; the legacy tests/emitted-drift-ack.json is still honored and unioned in) + loose tier hard caps (outer red lines, NEVER raised on approach: XL<=98304 / LARGE<=61440 / DEFAULT<=40960) + discuss-phase<32000; a file that grew fails the differential guard — add an ack entry naming the file and reason, justify the growth in the PR (or extract LAZILY-loaded content; eager @-imports don't reduce loaded context); crossing a hard cap means EXTRACT, not bump. The prior per-file baseline (tests/workflow-size-baseline.json, `npm run size:baseline`) is REMOVED by #2724. Its new-file cap (ADR-1610 Decision point 3, un-baselined files <=32768, the Codex anchor) is REVIVED inside the differential's size ratchet itself (`NEW_FILE_CAP` in tests/helpers/emitted-diff.cjs) rather than lost: "not yet baselined" is exactly "present in sizeCurrent, absent from sizeBaseline", a signal the ratchet already computes for its own reasons. NOT ack-able — same as the tier hard caps, the fix is extraction. Narrower than the original: this check cannot see XL/LARGE tiering (tests/workflow-size-budget.test.cjs's classification, invisible to the pure differential module), so a legitimately large NEW file must extract rather than tier in, one release earlier than an existing file would need to — a disclosed, deliberate simplification` -`RULESET.AGENT_SIZE_BUDGET=agent-size-budget (#1074; sibling of WORKFLOW_SIZE_BUDGET; BYTES not lines per #717/#683, rebased from lines in PR 3/3) = differential attribution size ratchet (PRIMARY anti-creep since #2724/ADR-2719 §4, same mechanism and same ack fragments (tests/emitted-drift-acks/, #2914; legacy tests/emitted-drift-ack.json still honored) as WORKFLOW_SIZE_BUDGET, scoped to agents/gsd-*.md) + loose tier hard caps (red lines, never raised on approach: XL<=57344 / LARGE<=49152 / DEFAULT<=24576); net-new agents are DEFAULT-tier (no separate new-file cap). Sizes are measured via the shared scripts/workflow-size.cjs measureMdFiles(dir,predicate) counter (tests/helpers/emitted-runtime.cjs's currentSizes() and the guard's own tier-cap checks both import it). A grown agent fails the differential guard — ack + justify, or extract LAZILY to gsd-core/references/. DISTINCT from DEFECT.AGENT-FILE-SIZE-CAP-BREACH (a separate 45K-CHAR extraction-evidence threshold on gsd-planner via planner-decomposition/reachability tests): that guard proves mode-sections were extracted; this one bounds total agent bytes. Two guards, two units (chars vs bytes), two purposes. The prior per-file baseline (tests/agent-size-baseline.json, `npm run size:baseline`) is REMOVED by #2724` -`RULESET.EMITTED_ATTRIBUTION=the emitted-artifact family (ADR-2719, epic #2719) — POST-CUTOVER (#2724, Phase 4). Historically tests/fixtures/golden-install-parity/*.json (19 path→hash manifests) + tests/workflow-size-baseline.json + tests/agent-size-baseline.json were all committed, PURE FUNCTIONS of the source tree whose correct merge was ALWAYS "recompute" — 140 of 143 conflicted-file instances across the open PR queue were these files. #2724 DELETES all three, the golden test (tests/golden-install-parity.test.cjs), the generator (scripts/gen-golden-install-parity-zcode.cjs), `npm run gen:golden`, `UPDATE_GOLDEN`, the merge-driver bridge (scripts/git-merge-regen-driver.cjs, `npm run setup:merge-driver`, the .gitattributes merge=gsd-regen block), and scripts/update-size-baseline.cjs (`npm run size:baseline`). The differential attribution check (tests/emitted-attribution.test.cjs + tests/emitted-provenance.test.cjs) is now the SOLE gate for emitted-artifact propagation AND size growth — no committed artifact, nothing to hand-merge, nothing to regenerate. `npm run regen:derived` still exists for what remains committed and derived: build, registry, ADR index, capability matrix, inventory manifest, manifest versions, and `tests/fixtures/install-tree/*.json` (now `npm run gen:install-tree`, folded into `regen:derived`). tests/fixtures/install-tree/*.json is DELIBERATELY EXCLUDED from the cutover (ADR-2719 §7): it conflicts on 0 of 7, its diffs are readable, and it preserves "the installer stopped shipping X" as a hard absolute failure — capturing it would convert that absolute into an attribution-free auto-resolve. The baseline the differential compares against is now published by `scripts/gen-emitted-baseline.cjs` on every push to `next` (cached, keyed on sha) and restored in PR lanes via `GSD_EMITTED_BASELINE`/`resolveBaseline()` (tests/helpers/emitted-baseline.cjs); a cache miss falls back to an in-job build via a throwaway `git worktree` (tests/helpers/emitted-runtime.cjs's `buildBaselineAtRef`). REMEDIATION IS PART OF THE GATE (#2778): the failure output names its own remedy, because a gate that states a requirement and withholds the means of satisfying it is a maintainer round-trip, not a gate — ADR-2719 §3's "conspicuous declaration" only works if the contributor can discover how to make it. Both failing branches name a NEW fragment to create under `tests/emitted-drift-acks/` (#2914; pick a name nobody else is using), say it may not exist yet (absence is the healthy steady state), print a minimal valid document, and repeat "do NOT regenerate anything" — post-#2724 there is nothing left to regenerate, and hunting for a deleted baseline is the predictable wrong guess. The two branches key on DIFFERENT spaces and each says which: the hash pass keys on the EMITTED PATH (always contains a `/`), the size ratchet keys on the BARE FILENAME (`currentSizes` writes `sizes[entry.name]` from readdirSync over `gsd-core/workflows/` + `agents/`). A stale-ack failure additionally says to delete the FILE when removing its last entry, since an empty-but-present ack parses fine yet signals nothing; post-#2789 it also offers CORRECTING the entry to name the ripple actually made, which is the other honest resolution and the one a contributor usually wants. NOT ack-able and deliberately given no ack text: the `NEW_FILE_CAP` branch, whose remedy is extraction. Text is sourced from one frozen `REMEDIATION` export in tests/helpers/emitted-diff.cjs whose example document is rendered from `ACK_VERSION` via `JSON.stringify`, so the taught schema cannot drift from the accepted one (a round-trip test feeds the printed document back through `parseAck`); the message teaches ONE canonical shape even though `parseAck` also accepts a bare-string reason and a missing `version` — liberal in what it accepts, conservative in what it sends. Note the ADR's Consequences originally called the #2724 migration "terminal"; #2778 corrected that — it is terminal only for a PR that grows no shipped file. #2914 replaced the single shared ack file with per-PR fragments under `tests/emitted-drift-acks/` — exactly the shape `.changeset/` already uses for the identical "every PR rewrites one shared document" conflict problem — so two PRs needing an ack can no longer collide with each other on the FILE; the legacy file is still read and unioned in for branches that predate the split, and a duplicate path key across two sources is a hard, loudly-reported error, never silent last-wins — #3078 made that error name its two resolutions (git rm an already-merged, spent owner; APPEND prose to a still-live one, which re-arms it), because the guard runs post-merge and cannot stop the colliding PR. `tests/emitted-drift-ack.json` (the LEGACY file specifically) must NEVER persist on `next` (#2914): every entry is scoped to the diff that introduced it, so once merged it is by definition already at the base — spent and inert regardless of shape — and a persistent copy makes that ONE file a shared merge-conflict cell across every open PR that also carries an ack, exactly the "140 of 143" cost this whole cutover exists to remove; #2914 asserted a persisting FRAGMENT was harmless by construction and deliberately exempted the directory; #3078 REVERSED that — fragments do not share a FILE but they DO share a PATH KEY SPACE, so a fully-spent fragment on `next` owns keys it can no longer gate and the next PR growing one of those paths can declare it neither there (spent) nor in its own (duplicate), which is the #2914 wall one level down (measured at the sweep: 45 fragments owning 403 paths, up from 13/272 at triage 19 days earlier). A fragment is judged on INERTNESS, not presence: swept once EVERY entry is spent, left alone while PARTIALLY spent — the asymmetry is what keeps the re-arm-by-appending route (#2639, #2993) working, and the `0000` legacy-migration bucket #2923 created for the old shared file's 35 entries was NOT permanent (the issue's own open question resolved to NO) and went with the rest. This is enforced on `next` itself only, never as a PR-lane check: the `guard-no-ack-on-next` job in `.github/workflows/test.yml` (push-to-`next` trigger) runs `scripts/lint-emitted-drift-ack.cjs --guard-next`, which is now BOTH halves — `assertAbsentOnNext` (legacy file, fails on PRESENCE alone, valid or not) and `assertNoAllSpentFragments` (fragments, fails on all-entries-spent vs the copy at the PRE-PUSH TIP of next — CI passes `github.event.before` via `--base-ref`, because the default branch allows REBASE merges so one push can carry N commits and a bare `HEAD^` would flag a fragment the same push introduced; `HEAD^` remains only the local/manual fallback, using the SAME zero-width/whitespace-stripping prose comparison as `isSpent` so an invisible reword cannot fake a re-arm; duplicated across the scripts-ship/tests-do-not line and held by a parity test). The job's checkout REQUIRES `fetch-depth: 2` plus an explicit `git fetch --depth=1 origin $BEFORE` — at depth 1 no base commit exists locally, every fragment reads as brand-new, and the guard passes vacuously, which is exactly how the legacy half went blind after #2914 removed the file it was watching. The gate's `INVISIBLE`/`normalizeAckReason` are EXPORTED from tests/helpers/emitted-diff.cjs for the sole purpose of letting the parity test compare them against the script's duplicate; before #3078 neither was exported, so the "parity test" the comments promised was a tautology checking the script against itself. A PR-lane "base ack must be absent" check would red every open PR the instant a spent ack merged, which is the #2768 shape #2789 already ended — so this alerts AFTER the merge by design and never stops the offending PR. #3875 automated the REMEDY that alert asks for, because detection without an executable remedy is what actually failed: #3823 shipped the guard together with a static 45-fragment sweep computed at its own branch point, #3809's fragment merged to `next` while it was in flight, and the guard reddened on its own merge commit and stayed red for 24 consecutive pushes over two days — the sweep condition is computed DYNAMICALLY at merge time while a hand-authored `git rm` is fixed at BRANCH time, so on a moving branch the second can never reliably satisfy the first. `runGuardNext` therefore returns the set it reasoned about (`sweepable`, already narrowed by the #3842 hold, plus `legacyPresent` for the legacy document, which is a fixed path rather than a fragment basename and would otherwise be invisible to any sweeper), `--sweep-plan` emits that set as a work list on stdout with the prose diverted to stderr and exit 0 (a non-empty plan is the NORMAL case, and a non-zero exit would fail the step that asked for the list), and `.github/workflows/ack-fragment-sweep.yml` runs it on a timer and opens a reviewable PR rather than pushing to protected `next`. The plan is re-validated against a literal allowlist before any deletion and each path is removed under a `:(literal)` pathspec — `git rm` reads its arguments as PATHSPECS with wildmatch semantics, so a fragment named `*.json` (a legal filename that `listFragmentFiles` admits, since it filters only on the suffix) would otherwise expand to every fragment in the directory, including ones the #3842 hold deliberately withheld. An empty plan is NOT reported as success on its own: the guard is re-run without the hold to separate "next is clean" from "everything is held", the commonest holder being the sweep PR from the previous run, which touches precisely the fragments it proposed to delete and would otherwise make the automation go silently inert. cf `RULESET.WORKFLOW_SIZE_BUDGET`, `RULESET.AGENT_SIZE_BUDGET`; see `### Emitted Artifact Provenance`` +`RULESET.WORKFLOW_SIZE_BUDGET=workflow size enforcement (#1074; BYTES not lines per #717; LF-normalized per #683) = differential attribution size ratchet (PRIMARY anti-creep since #2724/ADR-2719 §4: tests/emitted-attribution.test.cjs's real-tree test reports growth in any gsd-core/workflows/*.md with its exact byte delta vs `next`, no committed snapshot, requires an `Emitted-Drift-Ack-Growth:` commit trailer on the PR's own commits (ADR-3942, superseding ADR-2719 §3's fragment model — key is the bare filename, reason follows ` — `)) + loose tier hard caps (outer red lines, NEVER raised on approach: XL<=98304 / LARGE<=61440 / DEFAULT<=40960) + discuss-phase<32000; a file that grew fails the differential guard — add an ack entry naming the file and reason, justify the growth in the PR (or extract LAZILY-loaded content; eager @-imports don't reduce loaded context); crossing a hard cap means EXTRACT, not bump. The prior per-file baseline (tests/workflow-size-baseline.json, `npm run size:baseline`) is REMOVED by #2724. Its new-file cap (ADR-1610 Decision point 3, un-baselined files <=32768, the Codex anchor) is REVIVED inside the differential's size ratchet itself (`NEW_FILE_CAP` in tests/helpers/emitted-diff.cjs) rather than lost: "not yet baselined" is exactly "present in sizeCurrent, absent from sizeBaseline", a signal the ratchet already computes for its own reasons. NOT ack-able — same as the tier hard caps, the fix is extraction. Narrower than the original: this check cannot see XL/LARGE tiering (tests/workflow-size-budget.test.cjs's classification, invisible to the pure differential module), so a legitimately large NEW file must extract rather than tier in, one release earlier than an existing file would need to — a disclosed, deliberate simplification` +`RULESET.AGENT_SIZE_BUDGET=agent-size-budget (#1074; sibling of WORKFLOW_SIZE_BUDGET; BYTES not lines per #717/#683, rebased from lines in PR 3/3) = differential attribution size ratchet (PRIMARY anti-creep since #2724/ADR-2719 §4, same mechanism and same `Emitted-Drift-Ack-Growth:` commit trailer (ADR-3942, superseding ADR-2719 §3's fragment model) as WORKFLOW_SIZE_BUDGET, scoped to agents/gsd-*.md) + loose tier hard caps (red lines, never raised on approach: XL<=57344 / LARGE<=49152 / DEFAULT<=24576); net-new agents are DEFAULT-tier (no separate new-file cap). Sizes are measured via the shared scripts/workflow-size.cjs measureMdFiles(dir,predicate) counter (tests/helpers/emitted-runtime.cjs's currentSizes() and the guard's own tier-cap checks both import it). A grown agent fails the differential guard — ack + justify, or extract LAZILY to gsd-core/references/. DISTINCT from DEFECT.AGENT-FILE-SIZE-CAP-BREACH (a separate 45K-CHAR extraction-evidence threshold on gsd-planner via planner-decomposition/reachability tests): that guard proves mode-sections were extracted; this one bounds total agent bytes. Two guards, two units (chars vs bytes), two purposes. The prior per-file baseline (tests/agent-size-baseline.json, `npm run size:baseline`) is REMOVED by #2724` +`RULESET.EMITTED_ATTRIBUTION=the emitted-artifact family (ADR-2719, epic #2719) — POST-CUTOVER (#2724, Phase 4). Historically tests/fixtures/golden-install-parity/*.json (19 path→hash manifests) + tests/workflow-size-baseline.json + tests/agent-size-baseline.json were all committed, PURE FUNCTIONS of the source tree whose correct merge was ALWAYS "recompute" — 140 of 143 conflicted-file instances across the open PR queue were these files. #2724 DELETES all three, the golden test (tests/golden-install-parity.test.cjs), the generator (scripts/gen-golden-install-parity-zcode.cjs), `npm run gen:golden`, `UPDATE_GOLDEN`, the merge-driver bridge (scripts/git-merge-regen-driver.cjs, `npm run setup:merge-driver`, the .gitattributes merge=gsd-regen block), and scripts/update-size-baseline.cjs (`npm run size:baseline`). The differential attribution check (tests/emitted-attribution.test.cjs + tests/emitted-provenance.test.cjs) is now the SOLE gate for emitted-artifact propagation AND size growth — no committed artifact, nothing to hand-merge, nothing to regenerate. `npm run regen:derived` still exists for what remains committed and derived: build, registry, ADR index, capability matrix, inventory manifest, manifest versions, and `tests/fixtures/install-tree/*.json` (now `npm run gen:install-tree`, folded into `regen:derived`). tests/fixtures/install-tree/*.json is DELIBERATELY EXCLUDED from the cutover (ADR-2719 §7): it conflicts on 0 of 7, its diffs are readable, and it preserves "the installer stopped shipping X" as a hard absolute failure — capturing it would convert that absolute into an attribution-free auto-resolve. The baseline the differential compares against is now published by `scripts/gen-emitted-baseline.cjs` on every push to `next` (cached, keyed on sha) and restored in PR lanes via `GSD_EMITTED_BASELINE`/`resolveBaseline()` (tests/helpers/emitted-baseline.cjs); a cache miss falls back to an in-job build via a throwaway `git worktree` (tests/helpers/emitted-runtime.cjs's `buildBaselineAtRef`). REMEDIATION IS PART OF THE GATE (#2778): the failure output names its own remedy, because a gate that states a requirement and withholds the means of satisfying it is a maintainer round-trip, not a gate — ADR-2719 §3's "conspicuous declaration" only works if the contributor can discover how to make it. Both failing branches name the commit trailer to add — `Emitted-Drift-Ack-Hash:` or `Emitted-Drift-Ack-Growth:` (ADR-3942) — print its exact grammar (` — `, key and reason split on the FIRST em dash), and repeat "do NOT regenerate anything" — post-#2724 there is nothing left to regenerate, and hunting for a deleted baseline is the predictable wrong guess. The two branches key on DIFFERENT, now STRUCTURALLY DISTINCT trailer key spaces (separate maps since ADR-3942, closing a latent defect where a growth key could satisfy a hash lookup by naming coincidence and vice versa) and each says which: the hash pass keys on the EMITTED PATH (always contains a `/`, `Emitted-Drift-Ack-Hash:`), the size ratchet keys on the BARE FILENAME (`Emitted-Drift-Ack-Growth:`; `currentSizes` writes `sizes[entry.name]` from readdirSync over `gsd-core/workflows/` + `agents/`). A stale-ack failure additionally says to drop the trailer line (amending the commit) when removing its last entry, since a lingering unused trailer signals nothing; post-#2789 it also offers CORRECTING the reason to name the ripple actually made, which is the other honest resolution and the one a contributor usually wants. NOT ack-able and deliberately given no ack text: the `NEW_FILE_CAP` branch, whose remedy is extraction. Text is sourced from one frozen `REMEDIATION` export in tests/helpers/emitted-diff.cjs, whose example line is rendered via `renderAckTrailer` (`: — `, ADR-3942) so the taught grammar cannot drift from what `parseAckTrailers` actually accepts (a round-trip test feeds the printed line back through the parser); a key that is reserved (`__proto__`/`constructor`/`prototype`) or contains `<`, `>`, or whitespace is rejected loudly, and a doc example like ` — ` must never parse as a real declaration. Note the ADR's Consequences originally called the #2724 migration "terminal"; #2778 corrected that — it is terminal only for a PR that grows no shipped file. The ack was PR-lifetime data kept in permanent, shared, merge-path state, and each fix generated the next defect until ADR-3942 moved it off the tree entirely (see `### Emitted Artifact Provenance`): the single shared `tests/emitted-drift-ack.json` was a guaranteed merge-conflict cell (#2789; 5 of 6 conflicting PRs in one open queue collided on it and nothing else); #2914 replaced it with per-PR fragments under `tests/emitted-drift-acks/` — the `.changeset/` shape — ending the FILE conflict but not the KEY conflict, since two sources could never name the same path; #3078 found a fully-spent fragment left on `next` still walled off every key it owned (measured at the sweep: 45 fragments owning 403 paths, up from 13/272 at triage 19 days earlier) and added the post-merge-only `guard-no-ack-on-next` job plus a manual sweep; #3842's hand sweep handed three in-flight external PRs a `modify/delete` conflict each; #3823's hand-authored sweep, computed at branch time against a guard that evaluates at merge time, lost the race to a fragment merged mid-flight and left `next` red for 24 consecutive pushes; #3875's timed sweeper workflow automated the remedy but could not merge its own PRs (three independent, deterministic defects — bad conventional-title match, wrong CI-lane classification, no auto-merge path). ADR-3942 ends the chain: the escape hatch is now a commit trailer scoped to the PR's own commits, so there is no shared file, no shared key namespace, and nothing to sweep — the fragment directory, the next-lane guard job, the scheduled sweep workflow and the standalone ack linter are all DELETED (named by ROLE rather than by filename on purpose: a backticked path here asserts a LIVE repo path and `check-glossary-refs.cjs` fails on one that does not exist, while `lint-removed-but-needed.cjs` additionally fails on a deleted file's bare BASENAME appearing anywhere it scans — and this predicate's generated projection lands in docs/, which it does scan. ADR-3942 carries the exact paths; it sits under docs/adr/, which that guard exempts as a historical record). cf `RULESET.WORKFLOW_SIZE_BUDGET`, `RULESET.AGENT_SIZE_BUDGET`; see `### Emitted Artifact Provenance`` `RULESET.WORKFLOW_FILE_NAMES=workflow files use hyphens; XML attributes must match (extract-learnings not extract_learnings); tests should pin exact hyphenated name` `RULESET.WORKFLOW_EXECUTION_CONTEXT=@-ref in commands/gsd/*.md must resolve to an existing file on disk; regression test in tests/docs-update.test.cjs (folds former \`bug-3135-capture-backlog-workflow\`, consolidation epic #1969); INVENTORY.md row + INVENTORY-MANIFEST.json families.workflows must stay in sync; "Invoked by" attribution must move when a flag absorbs a micro-skill` `RULESET.WORKFLOW_EXECUTE_END_TO_END=standard for single-workflow commands is "Execute end-to-end." (no bolded **Follow the X workflow** fragments); flag-dispatch routing uses "execute the X workflow end-to-end." in routing bullets — convention verified live across ~20 commands/gsd/*.md files; no ADR currently documents this specific phrasing rule (ADR-0002 covers the adjacent but distinct command-contract/@-ref-resolution seam, not this convention)` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9c6343d45..5de478395 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -337,9 +337,7 @@ several PRs in flight everyone picked the same next integer, and two PRs adding 165 → 166 → 167 → 168 across successive rebases — the last collision landing *during* a verification run — and because every rebase invalidates the sha-keyed pass marker, each collision also cost a full remote matrix run. This is the same -fix `.changeset/` already applies to `CHANGELOG.md` and -`tests/emitted-drift-acks/` applies to the ack ledger -([#2914](https://github.com/open-gsd/gsd-core/issues/2914)): one file per +fix `.changeset/` already applies to `CHANGELOG.md`: one file per contribution, consolidated by a generator. You add a new file and touch no shared file, so there is nothing to collide on. @@ -1061,86 +1059,54 @@ what your PR changed against `next` and requires every emitted-artifact hash tha to be attributable to your diff. If it is not, the check fails and names the paths. Legitimate cases where emitted bytes move for a reason your diff cannot show directly — -a converter change, for example — go through a **per-PR fragment** under -`tests/emitted-drift-acks/` (#2914; name the path, say why); see `CONTEXT.md`'s -`### Emitted Artifact Provenance` entry for the full model. Growth in a +a converter change, for example — go through a **commit trailer on one of your own +commits** (ADR-3942; name the key, say why): + +``` +Emitted-Drift-Ack-Hash: skills/gsd-add-tests/SKILL.md — the converter rewrote every skill header +Emitted-Drift-Ack-Growth: explore.md — new dispatch section, reasoning ships with the block +``` + +See `CONTEXT.md`'s `### Emitted Artifact Provenance` entry for the full model. Growth in a `gsd-core/workflows/*.md` or `agents/gsd-*.md` file is reported with its exact byte delta and needs the same acknowledgment; the outer tier hard caps in `tests/workflow-size-budget.test.cjs` / `tests/agent-size-budget.test.cjs` are unaffected -and still apply. The legacy single `tests/emitted-drift-ack.json` is still read and -unioned in for any branch that still carries it, but new acknowledgments go in a NEW -fragment, never that file. +and still apply. + +**Why a trailer and not a file (ADR-3942).** An acknowledgment explains one PR's ripple. +The moment that PR merges the ripple is in the base, so the acknowledgment can never clear +anything again — its useful life is exactly your PR's open window. Storing it in the +working tree meant storing PR-lifetime data in permanent shared state, and every +consequence of that mismatch had to be built and then maintained: a guard to detect spent +files on `next`, a scheduled bot to delete them, a hold so the bot did not conflict +in-flight PRs, and a shared key namespace that walled off the next PR to touch the same +path. A trailer has no file, so it has no merge-conflict surface, never becomes spent, and +needs no garbage collector. The trailer is read from `git log $(git merge-base +HEAD)..HEAD` — your commits and no others — which is the same merge-base the differential +check already uses to compute what your PR changed. + +This is **not** a verdict on `.changeset/` or `tests/qa/smell-acks/`, which use the +fragment idiom correctly: a changeset and a smell acknowledgment stay meaningful after +merge, so durable state is the right home for them. Only the emitted-drift ack was spent +on arrival. You do not need to memorize any of this. **The failure output names its own remedy** — it -tells you to create a new fragment under `tests/emitted-drift-acks/` (with a name nobody -else is using — include your issue or PR number), which key to use, and prints a minimal -valid document you can paste. Note the two key spaces, because the message says which one -applies: an unattributable **hash** ripple is keyed on the emitted path +tells you which key to add and prints a minimal trailer line you can paste onto one of your +commits. Note the two key spaces, because the message says which one applies: an +unattributable **hash** ripple is keyed on the emitted path (`skills/gsd-add-tests/SKILL.md`), while **growth** is keyed on the bare filename as it -appears under `gsd-core/workflows/` or `agents/` (`explore.md`). When you remove the last -entry from your fragment, delete the fragment file too — its presence is the alarm, so an -empty one signals nothing. Nothing here is regenerated: if you find yourself looking for a -baseline file to re-run a generator over, that file was deleted by #2724 and is not coming -back. +appears under `gsd-core/workflows/` or `agents/` (`explore.md`). The two spaces are +structurally distinct — a `Growth` trailer never excuses a `Hash` ripple, even when the key +text happens to match. -**Why fragments, not one file (#2914):** every PR needing an acknowledgment used to -rewrite `tests/emitted-drift-ack.json`'s `paths` map wholesale — a single shared mutable -file every such PR touches guarantees a merge conflict between any two of them (5 of 6 -conflicting PRs in one open queue collided on this file and nothing else), and it means -spent, already-merged entries pile up on `next`. A fragment per PR — the same shape -`.changeset/` already uses for the identical problem — means two PRs can never conflict on -this seam again. Two ack sources (two fragments, or a fragment and the legacy file) may -**never** name the same path; that is a hard, loudly-reported error, not a silent -last-wins. - -**When two sources collide (#3078).** The error names both resolutions, because which one -applies depends on the fragment that already owns the path. If the owning fragment is -already **merged**, its entry is spent — it is at the base, so it gates nothing — and the -answer is to delete it (`git rm tests/emitted-drift-acks/.json`) and keep your own. -If it is still **live** on your branch, append your explanation to its existing entry -instead; that re-arms it, and re-arming deliberately costs an actual new sentence, because -the reason is the whole artifact a reviewer reads. Never rename the path to dodge the -error, and never declare it twice. - -**Neither ack source may persist on `next`, and a fragment is not exempt (#3078).** -`tests/emitted-drift-ack.json`, the legacy single file, must never survive there at all: -every entry is scoped to the diff that introduced it, so once merged it is by definition -already at the base — spent and inert, regardless of shape — and its persistence is what -makes it a shared merge-conflict cell. A **fragment** is judged on a different rule but the -same law. #2914 originally exempted the fragment directory on the premise that a persisting -fragment is harmless, since fragments are independently named and cannot conflict. That -premise was wrong: fragments do not share a *file*, but they do share a *path key space*, -and a path claimed by two sources is the hard error above. So a fully-spent fragment left on -`next` owns keys it can no longer gate, and the next PR that grows one of those paths can -declare it neither there (spent) nor in its own fragment (duplicate) — the exact wall #2914 -removed for the legacy file, one level down. A fragment is therefore swept once **every** -entry in it is spent; a **partially** spent one is left alone, which is what keeps the -re-arm-by-appending route above working. Both rules are enforced only on `next` itself, by -the `guard-no-ack-on-next` job in -`.github/workflows/test.yml` (push-to-`next` trigger, -`scripts/lint-emitted-drift-ack.cjs --guard-next`), never as a PR-lane check — a PR-lane -"base ack must be absent" check would red every open PR the moment one landed (the #2768 -shape #2789 exists to prevent), which means the job alerts **after** the merge and cannot -stop the offending PR — that is why the collision error above has to teach the resolution -too. If you ever see the legacy file present on `next`, delete it; do not try to make it -well-formed. If the job names a spent fragment, run the `git rm` it prints — that is the -whole remedy, and there is nothing to regenerate. - -**The sweep is staged around open PRs, not unconditional (#3842).** A fragment being -all-spent is necessary but not sufficient to sweep it: deleting a fragment that an OPEN -pull request still modifies hands that PR a `modify/delete` conflict on its very next -merge attempt — precisely the shared-file conflict fragments were adopted to end, just -reintroduced by the sweep itself. This actually happened the first time the sweep ran: -#3330, #3774, and #3648 all conflicted simultaneously, each with the swept fragment as its -*only* conflicting path, all three outside contributors. So `--guard-next` now also takes -`--defer-to-open-prs` (passed by the `guard-no-ack-on-next` job): it runs one `gh pr list ---json number,files` call, and any all-spent fragment an open PR's file list still names -is *held* rather than swept — reported informationally in the job output, never as a -failure — until that PR merges or closes. If the open-PR lookup itself fails (auth, -network, rate limit), every otherwise-sweepable fragment is held for that run rather than -swept blind; the next push to `next` tries again. A fragment fully spent AND untouched by -any open PR sweeps exactly as before — this changes *when* a spent fragment is removed, -never what "spent" means. +**Declaring the same key twice is fine if you say the same thing twice.** Identical +declarations — same key, same reason — are de-duplicated silently, because a trailer +legitimately survives a rebase and reappears on every rebased commit; failing there would +red a branch for doing nothing wrong. Two declarations of the same key with *different* +reasons are a hard, loudly-reported error: that is a genuine ambiguity about which +explanation holds, and only you can say which. There is no "which source owns the key" +question underneath it, because there is no shared file for two sources to own — to change +an acknowledgment, amend the commit carrying it. `npm run regen:derived` still exists for the artifacts that ARE committed and derived — `sync-manifest-versions`, the ADR index, the capability matrix, the inventory manifest, @@ -1389,9 +1355,8 @@ gsd-core/ Per-file growth is caught by the differential attribution check (tests/emitted-attribution.test.cjs, ADR-2719) — it reports the exact byte delta and - requires a per-PR fragment in - tests/emitted-drift-acks/ (#2914), no committed - snapshot to regenerate. Loose tier + requires an Emitted-Drift-Ack-Growth commit trailer + (ADR-3942), no committed snapshot to regenerate. Loose tier hard caps remain in tests/workflow-size-budget.test.cjs. The same applies to agent files (agents/gsd-*.md, tests/agent-size-budget.test.cjs). Full how-to + diff --git a/docs/CONTEXT-INDEX.json b/docs/CONTEXT-INDEX.json index 312dc2383..5f8864927 100644 --- a/docs/CONTEXT-INDEX.json +++ b/docs/CONTEXT-INDEX.json @@ -892,7 +892,7 @@ { "id": "RULESET.AGENT_SIZE_BUDGET", "klass": "RULESET", - "value": "agent-size-budget (#1074; sibling of WORKFLOW_SIZE_BUDGET; BYTES not lines per #717/#683, rebased from lines in PR 3/3) = differential attribution size ratchet (PRIMARY anti-creep since #2724/ADR-2719 §4, same mechanism and same ack fragments (tests/emitted-drift-acks/, #2914; legacy tests/emitted-drift-ack.json still honored) as WORKFLOW_SIZE_BUDGET, scoped to agents/gsd-*.md) + loose tier hard caps (red lines, never raised on approach: XL<=57344 / LARGE<=49152 / DEFAULT<=24576); net-new agents are DEFAULT-tier (no separate new-file cap). Sizes are measured via the shared scripts/workflow-size.cjs measureMdFiles(dir,predicate) counter (tests/helpers/emitted-runtime.cjs's currentSizes() and the guard's own tier-cap checks both import it). A grown agent fails the differential guard — ack + justify, or extract LAZILY to gsd-core/references/. DISTINCT from DEFECT.AGENT-FILE-SIZE-CAP-BREACH (a separate 45K-CHAR extraction-evidence threshold on gsd-planner via planner-decomposition/reachability tests): that guard proves mode-sections were extracted; this one bounds total agent bytes. Two guards, two units (chars vs bytes), two purposes. The prior per-file baseline (tests/agent-size-baseline.json, `npm run size:baseline`) is REMOVED by #2724" + "value": "agent-size-budget (#1074; sibling of WORKFLOW_SIZE_BUDGET; BYTES not lines per #717/#683, rebased from lines in PR 3/3) = differential attribution size ratchet (PRIMARY anti-creep since #2724/ADR-2719 §4, same mechanism and same `Emitted-Drift-Ack-Growth:` commit trailer (ADR-3942, superseding ADR-2719 §3's fragment model) as WORKFLOW_SIZE_BUDGET, scoped to agents/gsd-*.md) + loose tier hard caps (red lines, never raised on approach: XL<=57344 / LARGE<=49152 / DEFAULT<=24576); net-new agents are DEFAULT-tier (no separate new-file cap). Sizes are measured via the shared scripts/workflow-size.cjs measureMdFiles(dir,predicate) counter (tests/helpers/emitted-runtime.cjs's currentSizes() and the guard's own tier-cap checks both import it). A grown agent fails the differential guard — ack + justify, or extract LAZILY to gsd-core/references/. DISTINCT from DEFECT.AGENT-FILE-SIZE-CAP-BREACH (a separate 45K-CHAR extraction-evidence threshold on gsd-planner via planner-decomposition/reachability tests): that guard proves mode-sections were extracted; this one bounds total agent bytes. Two guards, two units (chars vs bytes), two purposes. The prior per-file baseline (tests/agent-size-baseline.json, `npm run size:baseline`) is REMOVED by #2724" }, { "id": "RULESET.ALLOWED-TOOLS-FRONTMATTER", @@ -992,7 +992,7 @@ { "id": "RULESET.EMITTED_ATTRIBUTION", "klass": "RULESET", - "value": "the emitted-artifact family (ADR-2719, epic #2719) — POST-CUTOVER (#2724, Phase 4). Historically tests/fixtures/golden-install-parity/*.json (19 path→hash manifests) + tests/workflow-size-baseline.json + tests/agent-size-baseline.json were all committed, PURE FUNCTIONS of the source tree whose correct merge was ALWAYS \"recompute\" — 140 of 143 conflicted-file instances across the open PR queue were these files. #2724 DELETES all three, the golden test (tests/golden-install-parity.test.cjs), the generator (scripts/gen-golden-install-parity-zcode.cjs), `npm run gen:golden`, `UPDATE_GOLDEN`, the merge-driver bridge (scripts/git-merge-regen-driver.cjs, `npm run setup:merge-driver`, the .gitattributes merge=gsd-regen block), and scripts/update-size-baseline.cjs (`npm run size:baseline`). The differential attribution check (tests/emitted-attribution.test.cjs + tests/emitted-provenance.test.cjs) is now the SOLE gate for emitted-artifact propagation AND size growth — no committed artifact, nothing to hand-merge, nothing to regenerate. `npm run regen:derived` still exists for what remains committed and derived: build, registry, ADR index, capability matrix, inventory manifest, manifest versions, and `tests/fixtures/install-tree/*.json` (now `npm run gen:install-tree`, folded into `regen:derived`). tests/fixtures/install-tree/*.json is DELIBERATELY EXCLUDED from the cutover (ADR-2719 §7): it conflicts on 0 of 7, its diffs are readable, and it preserves \"the installer stopped shipping X\" as a hard absolute failure — capturing it would convert that absolute into an attribution-free auto-resolve. The baseline the differential compares against is now published by `scripts/gen-emitted-baseline.cjs` on every push to `next` (cached, keyed on sha) and restored in PR lanes via `GSD_EMITTED_BASELINE`/`resolveBaseline()` (tests/helpers/emitted-baseline.cjs); a cache miss falls back to an in-job build via a throwaway `git worktree` (tests/helpers/emitted-runtime.cjs's `buildBaselineAtRef`). REMEDIATION IS PART OF THE GATE (#2778): the failure output names its own remedy, because a gate that states a requirement and withholds the means of satisfying it is a maintainer round-trip, not a gate — ADR-2719 §3's \"conspicuous declaration\" only works if the contributor can discover how to make it. Both failing branches name a NEW fragment to create under `tests/emitted-drift-acks/` (#2914; pick a name nobody else is using), say it may not exist yet (absence is the healthy steady state), print a minimal valid document, and repeat \"do NOT regenerate anything\" — post-#2724 there is nothing left to regenerate, and hunting for a deleted baseline is the predictable wrong guess. The two branches key on DIFFERENT spaces and each says which: the hash pass keys on the EMITTED PATH (always contains a `/`), the size ratchet keys on the BARE FILENAME (`currentSizes` writes `sizes[entry.name]` from readdirSync over `gsd-core/workflows/` + `agents/`). A stale-ack failure additionally says to delete the FILE when removing its last entry, since an empty-but-present ack parses fine yet signals nothing; post-#2789 it also offers CORRECTING the entry to name the ripple actually made, which is the other honest resolution and the one a contributor usually wants. NOT ack-able and deliberately given no ack text: the `NEW_FILE_CAP` branch, whose remedy is extraction. Text is sourced from one frozen `REMEDIATION` export in tests/helpers/emitted-diff.cjs whose example document is rendered from `ACK_VERSION` via `JSON.stringify`, so the taught schema cannot drift from the accepted one (a round-trip test feeds the printed document back through `parseAck`); the message teaches ONE canonical shape even though `parseAck` also accepts a bare-string reason and a missing `version` — liberal in what it accepts, conservative in what it sends. Note the ADR's Consequences originally called the #2724 migration \"terminal\"; #2778 corrected that — it is terminal only for a PR that grows no shipped file. #2914 replaced the single shared ack file with per-PR fragments under `tests/emitted-drift-acks/` — exactly the shape `.changeset/` already uses for the identical \"every PR rewrites one shared document\" conflict problem — so two PRs needing an ack can no longer collide with each other on the FILE; the legacy file is still read and unioned in for branches that predate the split, and a duplicate path key across two sources is a hard, loudly-reported error, never silent last-wins — #3078 made that error name its two resolutions (git rm an already-merged, spent owner; APPEND prose to a still-live one, which re-arms it), because the guard runs post-merge and cannot stop the colliding PR. `tests/emitted-drift-ack.json` (the LEGACY file specifically) must NEVER persist on `next` (#2914): every entry is scoped to the diff that introduced it, so once merged it is by definition already at the base — spent and inert regardless of shape — and a persistent copy makes that ONE file a shared merge-conflict cell across every open PR that also carries an ack, exactly the \"140 of 143\" cost this whole cutover exists to remove; #2914 asserted a persisting FRAGMENT was harmless by construction and deliberately exempted the directory; #3078 REVERSED that — fragments do not share a FILE but they DO share a PATH KEY SPACE, so a fully-spent fragment on `next` owns keys it can no longer gate and the next PR growing one of those paths can declare it neither there (spent) nor in its own (duplicate), which is the #2914 wall one level down (measured at the sweep: 45 fragments owning 403 paths, up from 13/272 at triage 19 days earlier). A fragment is judged on INERTNESS, not presence: swept once EVERY entry is spent, left alone while PARTIALLY spent — the asymmetry is what keeps the re-arm-by-appending route (#2639, #2993) working, and the `0000` legacy-migration bucket #2923 created for the old shared file's 35 entries was NOT permanent (the issue's own open question resolved to NO) and went with the rest. This is enforced on `next` itself only, never as a PR-lane check: the `guard-no-ack-on-next` job in `.github/workflows/test.yml` (push-to-`next` trigger) runs `scripts/lint-emitted-drift-ack.cjs --guard-next`, which is now BOTH halves — `assertAbsentOnNext` (legacy file, fails on PRESENCE alone, valid or not) and `assertNoAllSpentFragments` (fragments, fails on all-entries-spent vs the copy at the PRE-PUSH TIP of next — CI passes `github.event.before` via `--base-ref`, because the default branch allows REBASE merges so one push can carry N commits and a bare `HEAD^` would flag a fragment the same push introduced; `HEAD^` remains only the local/manual fallback, using the SAME zero-width/whitespace-stripping prose comparison as `isSpent` so an invisible reword cannot fake a re-arm; duplicated across the scripts-ship/tests-do-not line and held by a parity test). The job's checkout REQUIRES `fetch-depth: 2` plus an explicit `git fetch --depth=1 origin $BEFORE` — at depth 1 no base commit exists locally, every fragment reads as brand-new, and the guard passes vacuously, which is exactly how the legacy half went blind after #2914 removed the file it was watching. The gate's `INVISIBLE`/`normalizeAckReason` are EXPORTED from tests/helpers/emitted-diff.cjs for the sole purpose of letting the parity test compare them against the script's duplicate; before #3078 neither was exported, so the \"parity test\" the comments promised was a tautology checking the script against itself. A PR-lane \"base ack must be absent\" check would red every open PR the instant a spent ack merged, which is the #2768 shape #2789 already ended — so this alerts AFTER the merge by design and never stops the offending PR. #3875 automated the REMEDY that alert asks for, because detection without an executable remedy is what actually failed: #3823 shipped the guard together with a static 45-fragment sweep computed at its own branch point, #3809's fragment merged to `next` while it was in flight, and the guard reddened on its own merge commit and stayed red for 24 consecutive pushes over two days — the sweep condition is computed DYNAMICALLY at merge time while a hand-authored `git rm` is fixed at BRANCH time, so on a moving branch the second can never reliably satisfy the first. `runGuardNext` therefore returns the set it reasoned about (`sweepable`, already narrowed by the #3842 hold, plus `legacyPresent` for the legacy document, which is a fixed path rather than a fragment basename and would otherwise be invisible to any sweeper), `--sweep-plan` emits that set as a work list on stdout with the prose diverted to stderr and exit 0 (a non-empty plan is the NORMAL case, and a non-zero exit would fail the step that asked for the list), and `.github/workflows/ack-fragment-sweep.yml` runs it on a timer and opens a reviewable PR rather than pushing to protected `next`. The plan is re-validated against a literal allowlist before any deletion and each path is removed under a `:(literal)` pathspec — `git rm` reads its arguments as PATHSPECS with wildmatch semantics, so a fragment named `*.json` (a legal filename that `listFragmentFiles` admits, since it filters only on the suffix) would otherwise expand to every fragment in the directory, including ones the #3842 hold deliberately withheld. An empty plan is NOT reported as success on its own: the guard is re-run without the hold to separate \"next is clean\" from \"everything is held\", the commonest holder being the sweep PR from the previous run, which touches precisely the fragments it proposed to delete and would otherwise make the automation go silently inert. cf `RULESET.WORKFLOW_SIZE_BUDGET`, `RULESET.AGENT_SIZE_BUDGET`; see `### Emitted Artifact Provenance`" + "value": "the emitted-artifact family (ADR-2719, epic #2719) — POST-CUTOVER (#2724, Phase 4). Historically tests/fixtures/golden-install-parity/*.json (19 path→hash manifests) + tests/workflow-size-baseline.json + tests/agent-size-baseline.json were all committed, PURE FUNCTIONS of the source tree whose correct merge was ALWAYS \"recompute\" — 140 of 143 conflicted-file instances across the open PR queue were these files. #2724 DELETES all three, the golden test (tests/golden-install-parity.test.cjs), the generator (scripts/gen-golden-install-parity-zcode.cjs), `npm run gen:golden`, `UPDATE_GOLDEN`, the merge-driver bridge (scripts/git-merge-regen-driver.cjs, `npm run setup:merge-driver`, the .gitattributes merge=gsd-regen block), and scripts/update-size-baseline.cjs (`npm run size:baseline`). The differential attribution check (tests/emitted-attribution.test.cjs + tests/emitted-provenance.test.cjs) is now the SOLE gate for emitted-artifact propagation AND size growth — no committed artifact, nothing to hand-merge, nothing to regenerate. `npm run regen:derived` still exists for what remains committed and derived: build, registry, ADR index, capability matrix, inventory manifest, manifest versions, and `tests/fixtures/install-tree/*.json` (now `npm run gen:install-tree`, folded into `regen:derived`). tests/fixtures/install-tree/*.json is DELIBERATELY EXCLUDED from the cutover (ADR-2719 §7): it conflicts on 0 of 7, its diffs are readable, and it preserves \"the installer stopped shipping X\" as a hard absolute failure — capturing it would convert that absolute into an attribution-free auto-resolve. The baseline the differential compares against is now published by `scripts/gen-emitted-baseline.cjs` on every push to `next` (cached, keyed on sha) and restored in PR lanes via `GSD_EMITTED_BASELINE`/`resolveBaseline()` (tests/helpers/emitted-baseline.cjs); a cache miss falls back to an in-job build via a throwaway `git worktree` (tests/helpers/emitted-runtime.cjs's `buildBaselineAtRef`). REMEDIATION IS PART OF THE GATE (#2778): the failure output names its own remedy, because a gate that states a requirement and withholds the means of satisfying it is a maintainer round-trip, not a gate — ADR-2719 §3's \"conspicuous declaration\" only works if the contributor can discover how to make it. Both failing branches name the commit trailer to add — `Emitted-Drift-Ack-Hash:` or `Emitted-Drift-Ack-Growth:` (ADR-3942) — print its exact grammar (` — `, key and reason split on the FIRST em dash), and repeat \"do NOT regenerate anything\" — post-#2724 there is nothing left to regenerate, and hunting for a deleted baseline is the predictable wrong guess. The two branches key on DIFFERENT, now STRUCTURALLY DISTINCT trailer key spaces (separate maps since ADR-3942, closing a latent defect where a growth key could satisfy a hash lookup by naming coincidence and vice versa) and each says which: the hash pass keys on the EMITTED PATH (always contains a `/`, `Emitted-Drift-Ack-Hash:`), the size ratchet keys on the BARE FILENAME (`Emitted-Drift-Ack-Growth:`; `currentSizes` writes `sizes[entry.name]` from readdirSync over `gsd-core/workflows/` + `agents/`). A stale-ack failure additionally says to drop the trailer line (amending the commit) when removing its last entry, since a lingering unused trailer signals nothing; post-#2789 it also offers CORRECTING the reason to name the ripple actually made, which is the other honest resolution and the one a contributor usually wants. NOT ack-able and deliberately given no ack text: the `NEW_FILE_CAP` branch, whose remedy is extraction. Text is sourced from one frozen `REMEDIATION` export in tests/helpers/emitted-diff.cjs, whose example line is rendered via `renderAckTrailer` (`: — `, ADR-3942) so the taught grammar cannot drift from what `parseAckTrailers` actually accepts (a round-trip test feeds the printed line back through the parser); a key that is reserved (`__proto__`/`constructor`/`prototype`) or contains `<`, `>`, or whitespace is rejected loudly, and a doc example like ` — ` must never parse as a real declaration. Note the ADR's Consequences originally called the #2724 migration \"terminal\"; #2778 corrected that — it is terminal only for a PR that grows no shipped file. The ack was PR-lifetime data kept in permanent, shared, merge-path state, and each fix generated the next defect until ADR-3942 moved it off the tree entirely (see `### Emitted Artifact Provenance`): the single shared `tests/emitted-drift-ack.json` was a guaranteed merge-conflict cell (#2789; 5 of 6 conflicting PRs in one open queue collided on it and nothing else); #2914 replaced it with per-PR fragments under `tests/emitted-drift-acks/` — the `.changeset/` shape — ending the FILE conflict but not the KEY conflict, since two sources could never name the same path; #3078 found a fully-spent fragment left on `next` still walled off every key it owned (measured at the sweep: 45 fragments owning 403 paths, up from 13/272 at triage 19 days earlier) and added the post-merge-only `guard-no-ack-on-next` job plus a manual sweep; #3842's hand sweep handed three in-flight external PRs a `modify/delete` conflict each; #3823's hand-authored sweep, computed at branch time against a guard that evaluates at merge time, lost the race to a fragment merged mid-flight and left `next` red for 24 consecutive pushes; #3875's timed sweeper workflow automated the remedy but could not merge its own PRs (three independent, deterministic defects — bad conventional-title match, wrong CI-lane classification, no auto-merge path). ADR-3942 ends the chain: the escape hatch is now a commit trailer scoped to the PR's own commits, so there is no shared file, no shared key namespace, and nothing to sweep — the fragment directory, the next-lane guard job, the scheduled sweep workflow and the standalone ack linter are all DELETED (named by ROLE rather than by filename on purpose: a backticked path here asserts a LIVE repo path and `check-glossary-refs.cjs` fails on one that does not exist, while `lint-removed-but-needed.cjs` additionally fails on a deleted file's bare BASENAME appearing anywhere it scans — and this predicate's generated projection lands in docs/, which it does scan. ADR-3942 carries the exact paths; it sits under docs/adr/, which that guard exempts as a historical record). cf `RULESET.WORKFLOW_SIZE_BUDGET`, `RULESET.AGENT_SIZE_BUDGET`; see `### Emitted Artifact Provenance`" }, { "id": "RULESET.GENERATIVE-FIX", @@ -1182,7 +1182,7 @@ { "id": "RULESET.WORKFLOW_SIZE_BUDGET", "klass": "RULESET", - "value": "workflow size enforcement (#1074; BYTES not lines per #717; LF-normalized per #683) = differential attribution size ratchet (PRIMARY anti-creep since #2724/ADR-2719 §4: tests/emitted-attribution.test.cjs's real-tree test reports growth in any gsd-core/workflows/*.md with its exact byte delta vs `next`, no committed snapshot, requires an ack entry — a fragment under tests/emitted-drift-acks/, #2914; the legacy tests/emitted-drift-ack.json is still honored and unioned in) + loose tier hard caps (outer red lines, NEVER raised on approach: XL<=98304 / LARGE<=61440 / DEFAULT<=40960) + discuss-phase<32000; a file that grew fails the differential guard — add an ack entry naming the file and reason, justify the growth in the PR (or extract LAZILY-loaded content; eager @-imports don't reduce loaded context); crossing a hard cap means EXTRACT, not bump. The prior per-file baseline (tests/workflow-size-baseline.json, `npm run size:baseline`) is REMOVED by #2724. Its new-file cap (ADR-1610 Decision point 3, un-baselined files <=32768, the Codex anchor) is REVIVED inside the differential's size ratchet itself (`NEW_FILE_CAP` in tests/helpers/emitted-diff.cjs) rather than lost: \"not yet baselined\" is exactly \"present in sizeCurrent, absent from sizeBaseline\", a signal the ratchet already computes for its own reasons. NOT ack-able — same as the tier hard caps, the fix is extraction. Narrower than the original: this check cannot see XL/LARGE tiering (tests/workflow-size-budget.test.cjs's classification, invisible to the pure differential module), so a legitimately large NEW file must extract rather than tier in, one release earlier than an existing file would need to — a disclosed, deliberate simplification" + "value": "workflow size enforcement (#1074; BYTES not lines per #717; LF-normalized per #683) = differential attribution size ratchet (PRIMARY anti-creep since #2724/ADR-2719 §4: tests/emitted-attribution.test.cjs's real-tree test reports growth in any gsd-core/workflows/*.md with its exact byte delta vs `next`, no committed snapshot, requires an `Emitted-Drift-Ack-Growth:` commit trailer on the PR's own commits (ADR-3942, superseding ADR-2719 §3's fragment model — key is the bare filename, reason follows ` — `)) + loose tier hard caps (outer red lines, NEVER raised on approach: XL<=98304 / LARGE<=61440 / DEFAULT<=40960) + discuss-phase<32000; a file that grew fails the differential guard — add an ack entry naming the file and reason, justify the growth in the PR (or extract LAZILY-loaded content; eager @-imports don't reduce loaded context); crossing a hard cap means EXTRACT, not bump. The prior per-file baseline (tests/workflow-size-baseline.json, `npm run size:baseline`) is REMOVED by #2724. Its new-file cap (ADR-1610 Decision point 3, un-baselined files <=32768, the Codex anchor) is REVIVED inside the differential's size ratchet itself (`NEW_FILE_CAP` in tests/helpers/emitted-diff.cjs) rather than lost: \"not yet baselined\" is exactly \"present in sizeCurrent, absent from sizeBaseline\", a signal the ratchet already computes for its own reasons. NOT ack-able — same as the tier hard caps, the fix is extraction. Narrower than the original: this check cannot see XL/LARGE tiering (tests/workflow-size-budget.test.cjs's classification, invisible to the pure differential module), so a legitimately large NEW file must extract rather than tier in, one release earlier than an existing file would need to — a disclosed, deliberate simplification" }, { "id": "SESSION.2026-05-05", diff --git a/docs/README.md b/docs/README.md index b96624c24..0e772d0e5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -25,6 +25,7 @@ Language versions: [English](README.md) · [Português (pt-BR)](pt-BR/README.md) - [Probe edges in a non-English project](how-to/probe-edges-in-a-non-english-project.md) — get real edge coverage on a spec written in another language, and tell "no edges here" apart from "the probe could not read it" - [Resolve prohibition findings](how-to/resolve-prohibition-findings.md) — turn the spec phase's surfaced must-NOT constraints into resolved, dismissed, or deferred spec decisions - [Resolve an unreachable-workflow finding](how-to/resolve-unreachable-workflow-findings.md) — wire or fully sweep a shipped workflow that no command, agent, or skill references +- [Acknowledge emitted-artifact drift](how-to/acknowledge-emitted-drift.md) — declare a deliberate emitted-byte ripple or workflow/agent growth in a commit trailer, and migrate an older ack fragment - [Change the STATE.md schema](how-to/change-the-state-md-schema.md) — add, change or remove a STATE.md frontmatter key and keep the template and all five reference documents in step - [Resolve verify-command path findings](how-to/resolve-verify-command-path-findings.md) — fix an `` verify command whose target directory does not resolve from the executor's cwd - [State a failing direction](how-to/state-a-failing-direction.md) — say what output constitutes failure for an `` verify command, and migrate a phase planned before the rule diff --git a/docs/TESTING-SUITES.md b/docs/TESTING-SUITES.md index fe5f2a0d2..ab03b5101 100644 --- a/docs/TESTING-SUITES.md +++ b/docs/TESTING-SUITES.md @@ -137,32 +137,30 @@ The differential attribution check reports the file and the byte delta. To resol 1. **Justify the growth in your PR** (a sentence in the description is enough) — the acknowledgment entry (below) is the review record that the larger size was a deliberate, seen decision, not silent drift. -2. **Add an acknowledgment fragment** under `tests/emitted-drift-acks/` naming - the file and the reason, per `CONTRIBUTING.md`'s "Editing shipped content" - section and `CONTEXT.md`'s `### Emitted Artifact Provenance` entry. Name the - fragment for your issue or PR (something nobody else is using) — the failure - output prints a minimal valid document you can paste. This is deliberately a - per-PR fragment, not one shared file: two fragments can never *merge-conflict* - with each other, and a fragment appearing in your diff *is* the visible signal. - They do, however, share a path key space. If the failure instead names a path a - merged PR already acknowledged (a **spent** entry sitting in an existing - fragment), you have two routes and the error text names both: `git rm` that - fragment if every entry in it is spent — it gates nothing and only holds the - keys — or, if it is still live, reword/extend its `reason` in place to explain - the new ripple. Either way, do not add a duplicate entry for the same path; two - ack sources naming the same path is a hard, loudly-reported error. - The legacy single `tests/emitted-drift-ack.json` is still read and unioned - in for branches that carry it, but new acknowledgments never go there. -3. **Your fragment is deleted once it has merged (#3078).** A fragment on `next` - is spent by definition — its prose is already at the base, so it can no longer - clear anything — while still owning its path keys, which walls off the next PR - that grows one of them. The `guard-no-ack-on-next` job reds `next` and prints - the exact `git rm` for every fully-spent fragment. A *partially* spent fragment - is deliberately left alone. Since #3875 you do not have to run that `git rm`: - the `ack-fragment-sweep` workflow (`.github/workflows/ack-fragment-sweep.yml`) - asks the guard for its own sweep list every six hours and opens a PR deleting - exactly what it named, holding back any fragment an open PR still touches - (#3842). +2. **Add an acknowledgment trailer** to one of your own commits (ADR-3942), + naming the file and the reason, per `CONTRIBUTING.md`'s "Editing shipped + content" section and `CONTEXT.md`'s `### Emitted Artifact Provenance` entry: + + ``` + Emitted-Drift-Ack-Growth: explore.md — new dispatch section, reasoning ships with the block + ``` + + Growth keys on the **bare filename** as it appears under `gsd-core/workflows/` + or `agents/`; an unattributable **hash** ripple uses + `Emitted-Drift-Ack-Hash:` and keys on the emitted path (which always contains + a `/`). The two are separate namespaces — a growth trailer will not excuse a + hash ripple, and the failure output says which one applies. The trailer is the + review record that the larger size was a deliberate, seen decision. + + If you need to change an acknowledgment, amend the commit carrying it. That is + deliberate: the trailer cannot drift out of sync with the diff it explains, + because changing either changes the sha and re-runs the gate. +3. **There is nothing to clean up afterwards.** The trailer is read from + `git log $(git merge-base HEAD)..HEAD` — your commits and no others — + so once your PR merges it is out of range by construction. It never becomes + "spent", it owns no shared key space, it cannot conflict with anyone else's, + and no sweeper has to delete it. That is the whole reason ADR-3942 moved the + acknowledgment off the working tree. 4. **Or shrink it instead of acknowledging.** Prefer extraction when the growth is incidental: for a workflow, move per-mode bodies to `workflows//modes/`, templates to `workflows//templates/`, and @@ -182,8 +180,7 @@ help — that is the signal to extract, per step 3. |---|---| | `scripts/workflow-size.cjs` | Single source of truth — LF-normalized byte counter (`lfByteCount`) + generic `measureMdFiles(dir, predicate)` (backs both workflows and agents) + workflow enumeration (`listWorkflowStems`, `measureWorkflows`). Imported by both guards and by `tests/helpers/emitted-runtime.cjs`'s `currentSizes()` so they can never measure differently. | | `tests/emitted-attribution.test.cjs` + `tests/helpers/emitted-diff.cjs` | The differential attribution check and its size ratchet (ADR-2719). The sole mechanism for both emitted-content propagation AND per-file size growth as of #2724. | -| `tests/emitted-drift-acks/` | Per-PR acknowledgment fragments (primary, #2914) for unattributable emitted-content ripples and for size growth. A fragment appearing in your diff *is* the alarm; absence is the healthy steady state. | -| `tests/emitted-drift-ack.json` | Legacy single acknowledgment file, superseded by the per-PR fragments above. Still read and unioned in for branches that carry it; must never gain new entries and must never persist on `next` (enforced by `guard-no-ack-on-next` via `scripts/lint-emitted-drift-ack.cjs --guard-next`). | +| `Emitted-Drift-Ack-Hash:` / `Emitted-Drift-Ack-Growth:` commit trailers (ADR-3942) | The acknowledgment mechanism for unattributable emitted-content ripples and for size growth. Read from `git log $(git merge-base HEAD)..HEAD` — no committed file, nothing to sweep; a merged trailer is out of range by construction. | | `npm run regen:derived` | Runs every remaining generator in dependency order (build → registry → ADR index → capability matrix → inventory manifest → manifest versions → `tests/fixtures/install-tree/*.json`). | | `tests/workflow-size-budget.test.cjs` | The workflow tier hard-cap guards, plus the `discuss-phase` progressive-disclosure checks. | | `tests/agent-size-budget.test.cjs` | The agent tier hard-cap guards (the agent analog). | diff --git a/docs/adr/2719-emitted-artifact-attribution.md b/docs/adr/2719-emitted-artifact-attribution.md index 75dbef794..4398a397e 100644 --- a/docs/adr/2719-emitted-artifact-attribution.md +++ b/docs/adr/2719-emitted-artifact-attribution.md @@ -1,6 +1,6 @@ # ADR-2719: Emitted-artifact attribution — replace the committed parity fixtures with a computed conservation law -- **Status:** Accepted +- **Status:** Accepted; **Decision §3 superseded** by [ADR-3942](3942-emitted-drift-ack-commit-trailer.md) (The emitted-drift acknowledgment is PR-lifetime data — it belongs in a commit trailer) (2026-08-27), which replaces §3 and its #2789 Amendment. **§1, §2 and §4–§7 are retained and depended upon** — the conservation law itself is unchanged; only the storage of its escape hatch moved off the working tree. - **Date:** 2026-07-27 - **Issue:** [#2719](https://github.com/open-gsd/gsd-core/issues/2719) (epic); Phase 0 tracked by [#2720](https://github.com/open-gsd/gsd-core/issues/2720) - **Supersedes:** [ADR-2264](2264-golden-parity-redesign.md) (Redesign golden-install-parity) — replaces its Decision §2–§4 and its 2026-07-14 Amendment. ADR-2264 **Phase 1 is retained and depended upon**: the single-source `buildParityManifest` and the four exclusion constants in `tests/helpers/install-shared.cjs` are the foundation this design builds on, not something being reverted. diff --git a/docs/adr/3128-adaptive-runtime-evidence.md b/docs/adr/3128-adaptive-runtime-evidence.md index e6582275e..08ede2edf 100644 --- a/docs/adr/3128-adaptive-runtime-evidence.md +++ b/docs/adr/3128-adaptive-runtime-evidence.md @@ -113,7 +113,7 @@ Sequencing inside Phase 1 that the platform forces: 1. Author the contiguous protocol section and its extracted step file — this is what satisfies admission gate (1). 2. Only then admit the atom across grammar, predicate, fact and router, and regenerate the section manifest. 3. Update the tests that assert on `debug.md`'s literal text (`tests/debug-session-management.test.cjs`, `tests/debug-session-manager-commit.test.cjs`, `tests/claude-skills-migration.test.cjs`) in the same PR; regenerate `tests/fixtures/install-tree/*.json` via `npm run regen:derived`. -4. Add a per-PR `tests/emitted-drift-acks/` fragment for `debug.md`'s growth, keyed on the bare filename. +4. Add an `Emitted-Drift-Ack-Growth:` commit trailer for `debug.md`'s growth, keyed on the bare filename (ADR-3942; was a `tests/emitted-drift-acks/` fragment before that). ## Consequences diff --git a/docs/adr/3942-emitted-drift-ack-commit-trailer.md b/docs/adr/3942-emitted-drift-ack-commit-trailer.md index bb1435822..90663b16a 100644 --- a/docs/adr/3942-emitted-drift-ack-commit-trailer.md +++ b/docs/adr/3942-emitted-drift-ack-commit-trailer.md @@ -57,7 +57,16 @@ The two key spaces are convention-only. A hash ripple keys on the emitted path ( Emitted-Drift-Ack-Hash: — Emitted-Drift-Ack-Growth: — -Read from `git log ..` — the PR's own commits and no others. +Read from `git log $(git merge-base HEAD)..HEAD` — the PR's own commits and no others. + +> **Amendment (#3942 implementation, 2026-08-27).** This section originally said `git log +> ..`, leaving the range semantics unstated. **Two-dot would be a defect.** +> `changedPaths` comes from `git diff base...HEAD` — *three*-dot, i.e. merge-base — so a two-dot +> ack range would let the acknowledgment set and the change set disagree about which commits +> belong to this PR, and a trailer could excuse a delta that is not in the diff. §2's claim that +> spentness becomes *structural* also rests entirely on merge-base: it is what puts an +> already-merged trailer out of range by construction. Stated, and pinned by a test that forks a +> topic branch, places a trailer on each side, and asserts only the topic-side trailer is read. This preserves what ADR-2719 §3 actually cared about. Its stated design property is *"the acknowledgment file appears in the changed-files list **only when something rippled unexpectedly** … touching the acknowledgment *is* the alarm."* A trailer is still a conspicuous, reviewable, prose-carrying declaration that appears in the PR's diff — it is not the `UPDATE_GOLDEN=1` flag §3 rejected. What changes is that the declaration stops outliving the thing it declares. @@ -81,7 +90,28 @@ There is in-repo precedent for the mechanism: `gsd-core/workflows/ship.md:312` a ### 5. The PR test lane must fetch the commit range -`.github/workflows/test.yml:107-110` — the `test` job — has no `fetch-depth` key and therefore checks out at depth 1. A depth-1 checkout cannot see the PR's commit range, and the failure mode is a **vacuous pass**, not an error. `test.yml:882-885` already documents this exact hazard for the `guard-no-ack-on-next` job. +The gate must be able to see the PR's commit range, and must fail closed when it cannot. + +> **Amendment 1 — the premise was wrong (#3942 implementation, 2026-08-27).** This section +> originally asserted that "`.github/workflows/test.yml:107-110` — the `test` job — has no +> `fetch-depth` key and therefore checks out at depth 1," and made `fetch-depth: 0` a required +> change. **That is false, and no workflow change is needed.** Line 107 sits inside the +> `lint-tests` job; the matrix `test` job — the one that actually runs +> `tests/emitted-attribution.test.cjs` — begins at `test.yml:130` and already sets +> `fetch-depth: 0` on *both* its Windows (v5.0.1) and Linux/macOS (v6.0.2) checkout steps. The +> claim entered this ADR from a line citation that was not verified against the job boundaries +> before it was written down. The requirement stands as a **property to preserve**, not a change +> to make: if that `fetch-depth: 0` is ever removed, the reader must still fail closed. + +> **Amendment 2 — the failure mode was mischaracterized (#3942 implementation, 2026-08-27).** This section originally called the depth-1 +> failure mode a **vacuous pass**. That is true of the *fragment* guard and **false of the trailer +> reader**, and the phrase was carried over uncritically. With fragments, depth-1 makes every +> fragment read as brand-new — therefore live — so the guard passes: a false **green**. With +> trailers, an uncomputable range yields *zero* acknowledgments, so a PR that needs one fails: a +> false **red**. Provided the reader throws rather than returning an empty set, the depth-1 failure +> is loud in both directions, which is a real improvement this ADR undersold. `fetch-depth: 0` is +> still required; forgetting it is now merely obstructive instead of dangerous. The throw is pinned +> by a test that builds a genuine shallow clone rather than simulating one. `fetch-depth: 0` is required on that job, and the gate must fail closed when the range is unavailable — never `return` on a missing base, per ADR-2719 §6 ("A baseline-unavailable path must never be a bare `return`. In `node:test` that is a **pass**"). diff --git a/docs/how-to/acknowledge-emitted-drift.md b/docs/how-to/acknowledge-emitted-drift.md new file mode 100644 index 000000000..b9464f86e --- /dev/null +++ b/docs/how-to/acknowledge-emitted-drift.md @@ -0,0 +1,69 @@ +# How to acknowledge emitted-artifact drift + +**Goal:** Get a red differential-attribution check to green when the ripple it found is deliberate — by declaring it in a commit trailer on your own branch, so nothing is left behind in the tree once your PR merges. + +**Prerequisites:** A branch whose CI failed with an unattributable emitted-artifact delta, or with growth in a `gsd-core/workflows/*.md` or `agents/gsd-*.md` file. The failure output names the key and the space; you do not need to work either out yourself. + +For why the acknowledgment lives in a commit rather than a file, see [ADR-3942](../adr/3942-emitted-drift-ack-commit-trailer.md). For the conservation law it is an escape hatch from, see [ADR-2719](../adr/2719-emitted-artifact-attribution.md). This guide covers only how to *declare* one. + +--- + +## Pick the right key space + +There are two, and they are separate namespaces. A trailer in the wrong one will not excuse anything — it will fail as an unused declaration instead. + +| Your failure says | Trailer | Key | +|---|---|---| +| an emitted path's hash moved and your diff cannot explain it | `Emitted-Drift-Ack-Hash:` | the emitted path exactly as printed — always contains a `/` | +| a workflow or agent file grew | `Emitted-Drift-Ack-Growth:` | the **bare filename** as it appears under `gsd-core/workflows/` or `agents/` | + +The failure output tells you which applies. If you are guessing, you have the wrong one. + +## Declare it + +The grammar is ` — `, split on the **first** ` — ` (space, em dash, space), so your reason may contain further em dashes. + +On your next commit: + +```bash +git commit --trailer "Emitted-Drift-Ack-Growth: explore.md — new dispatch section; the reasoning ships with the block" +``` + +On a commit you already made: + +```bash +git commit --amend --trailer "Emitted-Drift-Ack-Hash: skills/gsd-add-tests/SKILL.md — converter rewrote every skill header" +``` + +Amending is the intended route, not a workaround. The trailer cannot drift out of sync with the diff it explains, because changing either changes the sha and re-runs the gate. + +Write a real reason. "fix" or "expected" is not one — the reason is the whole artifact a reviewer reads, and an empty one is rejected. + +## What happens next + +Nothing, and that is the point. The trailer is read from `git log $(git merge-base HEAD)..HEAD` — your commits and no others. Once your PR merges it is out of range by construction. There is no file to delete, no "spent" state to clean up, no shared key namespace to collide with, and no sweeper to wait for. + +--- + +## Migrating from an ack fragment + +If your branch predates ADR-3942 it may carry a `tests/emitted-drift-acks/*.json` fragment. That directory no longer exists on `next`, so you will meet a `modify/delete` conflict. Resolve it by moving the reason you already wrote into a trailer: + +```bash +git rm tests/emitted-drift-acks/.json +git commit --amend --trailer "Emitted-Drift-Ack-Growth: — " +``` + +Use `Emitted-Drift-Ack-Hash:` instead if the fragment's key contained a `/`. A fragment that declared several keys becomes several trailers — one per key, and they may sit on the same commit. + +--- + +## When it still fails + +| The failure says | What it means | What to do | +|---|---|---| +| an acknowledgment nothing consumed | you declared a key, but no delta matched it | remove the trailer, or correct the key to name the ripple you actually made — the message names which space it was declared in | +| a key was declared twice with different reasons | two commits in your range declare the same key and disagree | keep one. Identical repeats are deduplicated silently; conflicting ones are ambiguous and refused | +| an invalid key | your key contains whitespace, `<`, or `>` | you probably pasted a placeholder from documentation. Use the real path or filename | +| the range is structurally uncomputable | the checkout has no common ancestor — typically a shallow clone | this is a CI configuration problem, not something a trailer fixes. The gate fails loudly here rather than reading your branch as having no acknowledgments | +| a new file over the size cap | `NEW_FILE_CAP` is deliberately **not** acknowledgeable | extract content instead — lazily, via `gsd-core/references/`. An eager `@`-import shrinks the file without shrinking loaded context, which games the guard while making the real cost worse | diff --git a/examples/dynamic-context-management/CONTEXT-INDEX.json b/examples/dynamic-context-management/CONTEXT-INDEX.json index 16c299898..c67696763 100644 --- a/examples/dynamic-context-management/CONTEXT-INDEX.json +++ b/examples/dynamic-context-management/CONTEXT-INDEX.json @@ -28,97 +28,97 @@ "id": "ARCH.SKILL.improve-codebase.next-candidates", "klass": "ARCH", "value": "[Workstream Progress Projection Module]", - "line": 677 + "line": 680 }, { "id": "CI.GATE.changeset-lint", "klass": "CI", "value": "hard-fail for user-facing code diffs unless .changeset/* or PR has no-changelog label", - "line": 661 + "line": 664 }, { "id": "CI.GATE.issue-link-required", "klass": "CI", "value": "hard-fail if PR body lacks closes/fixes/resolves #", - "line": 660 + "line": 663 }, { "id": "CONFIG.LOCATION.SEAM.in-process-scrub", "klass": "CONFIG", "value": "TEST_ENV_BASE reaches CHILD env only; a test calling install() IN-PROCESS must additionally use helpers.scrubConfigLocationEnv() in beforeEach + its restorer in afterEach — HOME/USERPROFILE sandboxing is NOT sufficient because getGlobalConfigDir is env-FIRST", - "line": 695 + "line": 698 }, { "id": "CONFIG.LOCATION.SEAM.kimi-two-homes", "klass": "CONFIG", "value": "kimi declares TWO config-location vars: KIMI_CONFIG_DIR (registry, generic Agent-Skills root via resolveKimiGlobalDir) and KIMI_SHARE_DIR (KIMI_HOOKS_TOML_DESCRIPTOR, kimi's OWN native config.toml carrying GSD's [[hooks]] block via resolveKimiHooksTomlDir); a registry-only derivation covers the first and silently misses the second", - "line": 694 + "line": 697 }, { "id": "CONFIG.LOCATION.SEAM.scrub-set", "klass": "CONFIG", "value": "tests/helpers.cjs CONFIG_LOCATION_ENV_KEYS is DERIVED from five sources rather than maintained as one hand-written list (source 4 IS a literal residue list, for vars that fit no other rung — what is never hand-listed is the SET): capability-registry runtimes[].runtime.configHome.env AND [].configHome.skillsHome.env + runtime-homes NON_REGISTRY_CONFIG_HOME_DESCRIPTORS[].env AND [].skillsHome.env (a descriptor is a descriptor — BOTH descriptor rungs walk skillsHome, which resolves independently via resolveSkillsBaseFromDescriptor) + runtime-homes GSD_LOCATION_ENV_KEYS + a residue list (GROK_AGENTS_HOME, GSD_RUNTIME, GSD_PROJECT, GSD_WORKSTREAM) + WRITE_ESCAPE_PERMISSION_ENV_KEYS (GSD_ALLOW_SYMLINKED_DEST — a permission, not a location: it names no path but disarms the symlink-escape guard, so blanking it makes the guard STRICTER, never looser); adding a config-location var means making it ENUMERABLE at one of those sources, not appending a literal", - "line": 692 + "line": 695 }, { "id": "CONFIG.LOCATION.SEAM.two-families", "klass": "CONFIG", "value": "runtime configHomes (where a third-party runtime keeps config, registry- or descriptor-declared) and GSD's OWN location vars (GSD_HOME -> $GSD_HOME/.gsd store, GSD_AGENTS_DIR -> getAgentsDir priority 1) are DISTINCT families; no registry derivation reaches the second, and treating a miss there as a registry gap is what produced review round 2", - "line": 693 + "line": 696 }, { "id": "CONFIG.SEAM.loadConfig-context", "klass": "CONFIG", "value": "loadConfig(cwd,{workstream}) replaces env-mutation fallback; no temporary process.env GSD_WORKSTREAM rewrites", - "line": 691 + "line": 694 }, { "id": "EXEC.CLASSIFY.classes", "klass": "EXEC", "value": "{class:'quota-exceeded'|'classify-handoff-bug'|'unknown-failure', sentinel?, retryAfterSeconds?}", - "line": 910 + "line": 913 }, { "id": "EXEC.CLASSIFY.cross-runtime", "klass": "EXEC", "value": "Anthropic/CC: usage limit|rate limit|quota|429|retry-after; Copilot CLI: rate_limit (stem); Codex CLI: 429|usage_limit_reached|too many requests", - "line": 912 + "line": 915 }, { "id": "EXEC.CLASSIFY.handler", "klass": "EXEC", "value": "gsd-core/bin/lib/agent-command-router.cjs:classifyAgentFailure (registered via command-aliases.cjs; mutation:false outputMode:json)", - "line": 908 + "line": 911 }, { "id": "EXEC.CLASSIFY.precedence", "klass": "EXEC", "value": "quota sentinel wins over classifyHandoffIfNeeded bug when both appear", - "line": 913 + "line": 916 }, { "id": "EXEC.CLASSIFY.proactive-signal-not-usable", "klass": "EXEC", "value": "Anthropic exposes anthropic-ratelimit-* headers + Agent SDK RateLimitEvent; Claude Code subprocess does NOT forward to hooks/statusline today (upstream #33820, #22407, #32796)", - "line": 915 + "line": 918 }, { "id": "EXEC.CLASSIFY.retry-after-parser", "klass": "EXEC", "value": "\\bretry[-_ ]after[:\\s]+(\\d+)\\b avoids embedded-word false matches like noretry-after", - "line": 914 + "line": 917 }, { "id": "EXEC.CLASSIFY.sentinel-order", "klass": "EXEC", "value": "most specific first: 429 beats too-many-requests; resource_exhausted beats quota (array order in src/agent-command-router.cts QUOTA_SENTINELS checks resource_exhausted before quota); case-insensitive; canonical sentinel value is lower-cased form", - "line": 911 + "line": 914 }, { "id": "EXEC.CLASSIFY.workflow", "klass": "EXEC", "value": "gsd-core/workflows/execute-phase.md step 7; class-distinct prompts (quota-to-wait-for-reset; classify-handoff-bug-to-spot-check; unknown-to-continue/stop)", - "line": 909 + "line": 912 }, { "id": "GSD-RESEARCH.CONTEXT-DISCIPLINE", @@ -160,931 +160,931 @@ "id": "LEARNING.prompt-budget.boundary-gap", "klass": "LEARNING", "value": "PR #3708 commit 2df566ed reserved NOTE_RESERVE_TOKENS in pressure-threshold AND in minSet pre-check; both buggy paths only fire when baseTokens ∈ (effectiveBudget - NOTE_RESERVE_TOKENS, effectiveBudget]; original test suite used budgets far from that band so neither path was exercised; fix bde1ae8f confines NOTE_RESERVE accounting to post-trim assembly path only; future budget/limit code MUST add boundary fixtures per RULESET.TESTS.boundary-coverage.fixtures", - "line": 606 + "line": 609 }, { "id": "LIVE-CONFIG.GUARD.SEAM.ci-blind", "klass": "LIVE-CONFIG", "value": "the AMBIENT-ENV half stays CI-blind — CI never has these vars set, so green CI is not evidence for it; what strict mode catches in CI is the suite's own default-root leaks (HOME/USERPROFILE-derived), the guard remains the only loud signal for ambient-var escapes", - "line": 701 + "line": 704 }, { "id": "LIVE-CONFIG.GUARD.SEAM.module", "klass": "LIVE-CONFIG", "value": "scripts/live-config-guard.cjs (deliberately NOT scripts/lib/, which the installer copies to users wholesale while uninstall removes only an allowlist; excluded from the npm tarball via package.json files[] together with its whole require chain run-tests.cjs/affected-tests-lib.cjs/run-affected-tests.cjs — a partial exclusion trips the #2858 shipped-requires-only-shipped gate); exports [resolveLiveConfigRoots, resolveExtraWatchTargets, snapshotLiveConfig, diffLiveConfig, formatViolations, newestMtime]; driven by scripts/run-tests.cjs pre/post suite", - "line": 696 + "line": 699 }, { "id": "LIVE-CONFIG.GUARD.SEAM.non-root-targets", "klass": "LIVE-CONFIG", "value": "resolveExtraWatchTargets covers THREE live write surfaces that are not runtime config ROOTS (skills bases are a DELIBERATE non-target — the config-root layout misfires beneath them, so they need their own layout): $GSD_HOME/.gsd watched WHOLESALE (exclusively GSD-owned, so the shared-root trap does not apply) plus ONE config.toml per NON_REGISTRY_CONFIG_HOME_DESCRIPTORS entry, each watched as a SINGLE FILE (those roots belong to their products) — today three targets, since #2755 split Kimi CLI (~/.kimi, KIMI_SHARE_DIR) from Kimi Code (~/.kimi-code, KIMI_CODE_HOME); the targets are DERIVED by iterating that array, never by calling a named resolver, so a further descriptor is picked up without editing the guard PROVIDED it owns the same NON_REGISTRY_OWNED_FILE ('config.toml') — one that owns a different filename needs a per-descriptor mapping, the named residual the guard states at its own definition. SECOND RESIDUAL: config.toml is not all GSD writes into those roots — installSharedHooksBundle also populates /hooks/, which is UNWATCHED; closing it is a layout decision, like skills bases; passed to snapshotLiveConfig explicitly so a fixture-root caller cannot pull the real ~/.gsd into its snapshot", - "line": 698 + "line": 701 }, { "id": "LIVE-CONFIG.GUARD.SEAM.scope", "klass": "LIVE-CONFIG", "value": "ownership-based, never whole-root: GSD_OWNED_ENTRIES top-level footprint + children whose name startsWith GSD_ARTIFACT_PREFIX ('gsd-') under GSD_PREFIXED_PARENTS (dirs shared with the host agent); watching a shared root wholesale false-positives on the host's own writes and a guard that cries wolf gets disabled", - "line": 697 + "line": 700 }, { "id": "LIVE-CONFIG.GUARD.SEAM.severity", "klass": "LIVE-CONFIG", "value": "reports by default locally; CI wires GSD_STRICT_LIVE_CONFIG_GUARD=1 on Linux/macOS lanes (test.yml, all three test jobs) so a suite-produced leak FAILS those runs; Windows lanes stay report-only pending the documented pre-existing USERPROFILE sweep (~190 test sites sandbox HOME alone) — promote once that lands; skipped by GSD_SKIP_LIVE_CONFIG_GUARD=1", - "line": 700 + "line": 703 }, { "id": "LIVE-CONFIG.GUARD.SEAM.truncation", "klass": "LIVE-CONFIG", "value": "MAX_ENTRIES/MAX_DEPTH bound the walk; a bound hit sets truncated and diffLiveConfig emits kind:'unverified' — a truncated scan MUST NOT read as clean; boundary covered at {limit-1,limit,limit+1} via newestMtime's injected budget plus fast-check monotonicity, per RULESET.TESTS.boundary-coverage + RULESET.TESTS.property-based-testing", - "line": 699 + "line": 702 }, { "id": "META.RULE.brief-must-cite-doc", "klass": "META", "value": "agent prompts MUST quote the canonical doc line being applied; paraphrasing from predicate memory drifts and produces violations", - "line": 752 + "line": 755 }, { "id": "META.RULE.brief-no-paraphrase", "klass": "META", "value": "writing \"k040 — never leave changelog box unchecked\" caused 5 of 8 agents to edit CHANGELOG.md in violation of CONTRIBUTING.md L110", - "line": 753 + "line": 756 }, { "id": "META.RULE.canonical-source-precedence", "klass": "META", "value": "CONTRIBUTING.md > docs/adr/* > CONTEXT.md > agent memory", - "line": 750 + "line": 753 }, { "id": "META.RULE.read-contributing-first", "klass": "META", "value": "read CONTRIBUTING.md sections \"Pull Request Guidelines\" + \"CHANGELOG Entries\" before EVERY agent dispatch", - "line": 751 + "line": 754 }, { "id": "PLANNING.PATH.PARITY.project-scope", "klass": "PLANNING", "value": ".planning/ (never .planning/projects/); mirror planning-workspace.cjs planningDir()", - "line": 686 + "line": 689 }, { "id": "PLANNING.PATH.SEAM.helpers", "klass": "PLANNING", "value": "helpers.planningPaths delegates to workspacePlanningPaths + resolveWorkspaceContext; precedence explicit-ws > env-ws > env-project > root", - "line": 687 + "line": 690 }, { "id": "PLANNING.PATH.SEAM.init-handlers", "klass": "PLANNING", "value": "[initExecutePhase, initPlanPhase, initPhaseOp, initMilestoneOp] consume helpers.planningPaths().planning (no direct relPlanningPath join)", - "line": 688 + "line": 691 }, { "id": "PR.3267.POSTMORTEM.recovery", "klass": "PR", "value": "[issue#3270 created, label approved-enhancement applied, PR reopened, body includes \"Closes #3270\", label no-changelog applied]", - "line": 665 + "line": 668 }, { "id": "PR.3267.POSTMORTEM.root-cause", "klass": "PR", "value": "[missing issue link, missing changeset/no-changelog]", - "line": 664 + "line": 667 }, { "id": "PRED.k320.canonical-source", "klass": "PRED", "value": "CONTRIBUTING.md L193-211", - "line": 756 + "line": 759 }, { "id": "PRED.k320.ci-enforcement", "klass": "PRED", "value": "scripts/changeset/lint.cjs", - "line": 762 + "line": 765 }, { "id": "PRED.k320.ci-paths-monitored", "klass": "PRED", "value": "bin/ gsd-core/ src/ agents/ commands/ hooks/ sdk/src/ sdk/prompts/", - "line": 763 + "line": 766 }, { "id": "PRED.k320.cure", "klass": "PRED", "value": "drop .changeset/--.md fragment ONLY", - "line": 758 + "line": 761 }, { "id": "PRED.k320.evidence", "klass": "PRED", "value": "PR #3302 merge-conflict against #3308 CHANGELOG.md row 2026-05-09", - "line": 765 + "line": 768 }, { "id": "PRED.k320.opt-out-label", "klass": "PRED", "value": "no-changelog", - "line": 761 + "line": 764 }, { "id": "PRED.k320.recovery", "klass": "PRED", "value": "open Removed-typed cleanup PR deleting only the redundant row", - "line": 764 + "line": 767 }, { "id": "PRED.k320.rule", "klass": "PRED", "value": "do not edit CHANGELOG.md in feature/fix/enhancement PRs", - "line": 757 + "line": 760 }, { "id": "PRED.k320.signal", "klass": "PRED", "value": "changelog-direct-edit-forbidden", - "line": 755 + "line": 758 }, { "id": "PRED.k320.tool", "klass": "PRED", "value": "npm run changeset -- --type --pr --body \"...\"", - "line": 759 + "line": 762 }, { "id": "PRED.k320.types", "klass": "PRED", "value": "Added|Changed|Deprecated|Removed|Fixed|Security", - "line": 760 + "line": 763 }, { "id": "PRED.k321.evidence", "klass": "PRED", "value": "PRs #3304/#3305 (2026-05-09): real Minor/Major findings in body, 0 threads", - "line": 771 + "line": 774 }, { "id": "PRED.k321.poll-shape", "klass": "PRED", "value": "parse pulls//reviews body AND graphql reviewThreads", - "line": 769 + "line": 772 }, { "id": "PRED.k321.resolution", "klass": "PRED", "value": "address in code; no GraphQL resolveReviewThread needed for body-only findings", - "line": 770 + "line": 773 }, { "id": "PRED.k321.shape", "klass": "PRED", "value": "CR posts \"[!CAUTION] outside the diff\" findings in review BODY, not in reviewThreads", - "line": 768 + "line": 771 }, { "id": "PRED.k321.signal", "klass": "PRED", "value": "cr-outside-diff-range-finding", - "line": 767 + "line": 770 }, { "id": "PRED.k322.cure-1", "klass": "PRED", "value": "2nd retrigger ~10min after first ack", - "line": 776 + "line": 779 }, { "id": "PRED.k322.cure-2", "klass": "PRED", "value": "if silent at 50min, treat as silent-pass with maintainer flag in merge-commit body", - "line": 777 + "line": 780 }, { "id": "PRED.k322.distinct-from", "klass": "PRED", "value": "k080", - "line": 774 + "line": 777 }, { "id": "PRED.k322.evidence", "klass": "PRED", "value": "PR #3306 (2026-05-09): 0 reviews after 50min + 2 retriggers", - "line": 779 + "line": 782 }, { "id": "PRED.k322.merge-gate-impact", "klass": "PRED", "value": "k070 real_coderabbit_review_present unsatisfied; requires maintainer judgment", - "line": 778 + "line": 781 }, { "id": "PRED.k322.shape", "klass": "PRED", "value": "ack posted, real review never lands within [5s, 410s] cooldown after burst of N PRs <15min", - "line": 775 + "line": 778 }, { "id": "PRED.k322.signal", "klass": "PRED", "value": "cr-sustained-throttle", - "line": 773 + "line": 776 }, { "id": "PRED.k323.cure-alt", "klass": "PRED", "value": "consolidate into single PR when 2+ issues share root cause", - "line": 784 + "line": 787 }, { "id": "PRED.k323.cure-pre-dispatch", "klass": "PRED", "value": "brief one agent canonical-owner; brief others to EXCLUDE shared site", - "line": 783 + "line": 786 }, { "id": "PRED.k323.evidence", "klass": "PRED", "value": "#3300 (#3297) overlapped #3306 (#3298) on add-backlog.md hunks 2026-05-09", - "line": 786 + "line": 789 }, { "id": "PRED.k323.recovery", "klass": "PRED", "value": "close smaller PR as \"subsumed by #N\" or rebase second to drop overlap hunk", - "line": 785 + "line": 788 }, { "id": "PRED.k323.shape", "klass": "PRED", "value": "2+ open issues touch same canonical bug site; each fix's sibling-audit produces overlapping diff", - "line": 782 + "line": 785 }, { "id": "PRED.k323.signal", "klass": "PRED", "value": "sibling-audit-cross-pr-overlap", - "line": 781 + "line": 784 }, { "id": "PRED.k324.cure", "klass": "PRED", "value": "verify via gh api on every agent-completion notification; never trust narrative", - "line": 790 + "line": 793 }, { "id": "PRED.k324.evidence", "klass": "PRED", "value": "2026-05-09 session: 5+ mid-monitor terminations across PRs #3232/#3271/#3251/#3255/#3262", - "line": 792 + "line": 795 }, { "id": "PRED.k324.k095-restatement", "klass": "PRED", "value": "k095 confirmed shape: agent reports \"waiting for monitor\" / \"tests still running\" then terminates", - "line": 789 + "line": 792 }, { "id": "PRED.k324.poll-shape", "klass": "PRED", "value": "gh pr view --json mergeStateStatus,statusCheckRollup + pulls//reviews + graphql reviewThreads + issues//comments tail", - "line": 791 + "line": 794 }, { "id": "PRED.k324.signal", "klass": "PRED", "value": "agent-terminates-mid-monitor", - "line": 788 + "line": 791 }, { "id": "PRED.k325.cleanup", "klass": "PRED", "value": "git worktree remove --force for aged agent worktrees", - "line": 797 + "line": 800 }, { "id": "PRED.k325.cure", "klass": "PRED", "value": "detached-HEAD: git checkout --detach $(git ls-remote origin ); modify; commit; git push --force-with-lease=: origin HEAD:refs/heads/", - "line": 796 + "line": 799 }, { "id": "PRED.k325.evidence", "klass": "PRED", "value": "2026-05-09 CHANGELOG.md strip on PRs #3300/#3302/#3304/#3305 required detached-HEAD", - "line": 798 + "line": 801 }, { "id": "PRED.k325.shape", "klass": "PRED", "value": "git checkout errors \"already used by worktree at \"", - "line": 795 + "line": 798 }, { "id": "PRED.k325.signal", "klass": "PRED", "value": "worktree-branch-lock-on-force-push", - "line": 794 + "line": 797 }, { "id": "PRED.k326.cure", "klass": "PRED", "value": "quote canonical doc verbatim in brief; mentally simulate \"if all N agents follow this brief literally, do they violate any rule?\"", - "line": 802 + "line": 805 }, { "id": "PRED.k326.evidence", "klass": "PRED", "value": "2026-05-09 brief \"k040 — update CHANGELOG.md\" → 5 of 8 agents violated CONTRIBUTING.md L110", - "line": 803 + "line": 806 }, { "id": "PRED.k326.shape", "klass": "PRED", "value": "N parallel agents amplify a single brief-vs-doc contradiction into N violations", - "line": 801 + "line": 804 }, { "id": "PRED.k326.signal", "klass": "PRED", "value": "brief-contradicts-canonical-doc", - "line": 800 + "line": 803 }, { "id": "PRED.k327.ack-shape", "klass": "PRED", "value": "body \"✅ Actions performed - Full review triggered\"", - "line": 806 + "line": 809 }, { "id": "PRED.k327.cooldown-normal", "klass": "PRED", "value": "[5s, 410s]", - "line": 809 + "line": 812 }, { "id": "PRED.k327.cooldown-throttled", "klass": "PRED", "value": "k322", - "line": 810 + "line": 813 }, { "id": "PRED.k327.distinguish-key", "klass": "PRED", "value": "len(pulls//reviews) — ack=0, real=≥1", - "line": 808 + "line": 811 }, { "id": "PRED.k327.real-review-shape", "klass": "PRED", "value": "body starts \"Actionable comments posted: N\" OR \"[!CAUTION] Some comments are outside the diff\"", - "line": 807 + "line": 810 }, { "id": "PRED.k327.signal", "klass": "PRED", "value": "cr-ack-vs-real-review", - "line": 805 + "line": 808 }, { "id": "PRED.k328.audit-list", "klass": "PRED", "value": "[heading-matches-class, closing-keyword-present, changeset-fragment-or-no-changelog-label]", - "line": 815 + "line": 818 }, { "id": "PRED.k328.canonical-source", "klass": "PRED", "value": "CONTRIBUTING.md L48,L64,L81 (template links) + .github/PULL_REQUEST_TEMPLATE/{fix,enhancement,feature}.md L1 (heading text)", - "line": 813 + "line": 816 }, { "id": "PRED.k328.k100-restatement", "klass": "PRED", "value": "heading must match issue class: bug→## Fix PR, enhancement→## Enhancement PR, feature→## Feature PR", - "line": 814 + "line": 817 }, { "id": "PRED.k328.signal", "klass": "PRED", "value": "pr-template-typed-heading-required", - "line": 812 + "line": 815 }, { "id": "PRED.k329.body", "klass": "PRED", "value": "**** — . (#)", - "line": 821 + "line": 824 }, { "id": "PRED.k329.canonical-source", "klass": "PRED", "value": "CONTRIBUTING.md L196-202 + .changeset/README.md", - "line": 818 + "line": 821 }, { "id": "PRED.k329.filename", "klass": "PRED", "value": ".changeset/--.md", - "line": 819 + "line": 822 }, { "id": "PRED.k329.frontmatter", "klass": "PRED", "value": "---\\\\ntype: \\\\npr: \\\\n---", - "line": 820 + "line": 823 }, { "id": "PRED.k329.observed-clean", "klass": "PRED", "value": "#3299 sunny-ibex-wave, #3301 sturdy-rams-caper, #3306 3298-phase-dir-prefix-drift-workflows", - "line": 822 + "line": 825 }, { "id": "PRED.k329.signal", "klass": "PRED", "value": "changeset-fragment-canonical-shape", - "line": 817 + "line": 820 }, { "id": "PRED.k330.fallback", "klass": "PRED", "value": "append predicate-format findings directly to CONTEXT.md", - "line": 826 + "line": 829 }, { "id": "PRED.k330.shape", "klass": "PRED", "value": "mempalace MCP tools require explicit user call; AI cannot trigger", - "line": 825 + "line": 828 }, { "id": "PRED.k330.signal", "klass": "PRED", "value": "mempalace-diary-not-callable-by-ai", - "line": 824 + "line": 827 }, { "id": "PRED.k331.cure", "klass": "PRED", "value": "gh pr close with NO --comment flag", - "line": 831 + "line": 834 }, { "id": "PRED.k331.evidence", "klass": "PRED", "value": "2026-05-09 wave-3: violation on #3300 close, deleted within 30s", - "line": 833 + "line": 836 }, { "id": "PRED.k331.k101-restatement", "klass": "PRED", "value": "k101 includes close-time --comment flag; rationale belongs in subsuming PR's squash-merge body", - "line": 830 + "line": 833 }, { "id": "PRED.k331.recovery", "klass": "PRED", "value": "if violation lands, gh api -X DELETE repos///issues/comments/", - "line": 832 + "line": 835 }, { "id": "PRED.k331.shape", "klass": "PRED", "value": "instruction \"close with no comment (rationale)\" — parenthetical is rationale, NOT comment body", - "line": 829 + "line": 832 }, { "id": "PRED.k331.signal", "klass": "PRED", "value": "close-with-no-comment-is-literal", - "line": 828 + "line": 831 }, { "id": "PROBE.ci.surface", "klass": "PROBE", "value": "the contract (parse/validate, projection round-trip, fail-closed guards), NEVER the LLM judgment (ADR-550 D5)", - "line": 575 + "line": 578 }, { "id": "PROBE.core.seam", "klass": "PROBE", "value": "analyzeCoverage(items,resolutions?,validators) ingests ALREADY-proposed items; does NOT assume deterministic propose (ADR-550 D7b)", - "line": 568 + "line": 571 }, { "id": "PROBE.edge.verification", "klass": "PROBE", "value": "explicit|backstop", - "line": 570 + "line": 573 }, { "id": "PROBE.family", "klass": "PROBE", "value": "edge-probe(shape-axis)+prohibition-probe(must-NOT-axis)+ui-consideration-probe(UI-state-axis), shared probe-core, run as spec-phase/ui-phase soft gates (ADR-550 D7; #1867)", - "line": 566 + "line": 569 }, { "id": "PROBE.item.axes", "klass": "PROBE", "value": "status{resolved|dismissed|unresolved} x verification{|null} — orthogonal; the lifecycle enum carries no verification fact (ADR-550 D7a)", - "line": 569 + "line": 572 }, { "id": "PROBE.principle", "klass": "PROBE", "value": "verifier-reach-equals-spec-reach (a goal-backward verifier only checks assertions that exist; probes make omitted assertions exist before code) — ADR-857 verification-substrate boundary; docs/design/verifier-reach.md", - "line": 565 + "line": 568 }, { "id": "PROBE.prohib.verification", "klass": "PROBE", "value": "test|judgment", - "line": 571 + "line": 574 }, { "id": "PROBE.protocol", "klass": "PROBE", "value": "recall(adversarial over-generate)->precision(drop routine-engineering); dismissals require a non-empty reason", - "line": 567 + "line": 570 }, { "id": "PROBE.ui.axis", "klass": "PROBE", "value": "MIXED — closed compiled shape-rooted 8 (empty/loading/error/populated/partial/overflow/zero-one-many/long-text) via ui-consideration-probe adapter; open UX (real-time/a11y/i18n-RTL) prose-owned in references/domain-probes.md, NOT compiled (#1867)", - "line": 573 + "line": 576 }, { "id": "PROBE.ui.seam", "klass": "PROBE", "value": "ui-phase Step 9.5 post-verification: element-cue classify -> propose-then-confirm (partial-cue mitigation, Goodhart) -> autoResolve --auto floor (never dismiss; unclassified stays unresolved #1110) -> ## UI Considerations write-back -> plan-phase `## UI Considerations` lift rule (#1867)", - "line": 574 + "line": 577 }, { "id": "PROBE.ui.verification", "klass": "PROBE", "value": "explicit|backstop", - "line": 572 + "line": 575 }, { "id": "PROC.AGENT-DISPATCH.completion-verify", "klass": "PROC", "value": "run k324.poll-shape on every agent-completion notification", - "line": 837 + "line": 840 }, { "id": "PROC.AGENT-DISPATCH.parallel-overlap-audit", "klass": "PROC", "value": "before dispatching N sibling-audit fixers, compute file-set union and assign canonical owners", - "line": 836 + "line": 839 }, { "id": "PROC.AGENT-DISPATCH.preflight", "klass": "PROC", "value": "[read-CONTRIBUTING.md-fresh, read-relevant-ADRs, cite-specific-line-in-brief, require-closing-keyword, require-changeset-fragment, forbid-CHANGELOG.md-edit, require-isolation-worktree, forbid-self-PR-comment, mandate-trust-but-verify]", - "line": 835 + "line": 838 }, { "id": "PROC.MERGE-WAVE.changelog-strip-pattern", "klass": "PROC", "value": "detached-HEAD per k325 + git checkout main -- CHANGELOG.md + commit + force-with-lease", - "line": 841 + "line": 844 }, { "id": "PROC.MERGE-WAVE.merge-tool", "klass": "PROC", "value": "gh pr merge --squash --delete-branch", - "line": 842 + "line": 845 }, { "id": "PROC.MERGE-WAVE.merge-tool-warning", "klass": "PROC", "value": "delete-branch may fail with \"used by worktree at\" — harmless; remote branch still deleted", - "line": 843 + "line": 846 }, { "id": "PROC.MERGE-WAVE.ordering", "klass": "PROC", "value": "[wave1: isolated-files, wave2: CHANGELOG-only-overlap (better: strip per k320), wave3: same-file-overlap with explicit decision]", - "line": 839 + "line": 842 }, { "id": "PROC.MERGE-WAVE.preflight", "klass": "PROC", "value": "gh pr view --json files for every PR; identify overlap pairs; surface to maintainer", - "line": 840 + "line": 843 }, { "id": "PROC.PARALLEL-FIX-DISPATCH.observed", "klass": "PROC", "value": "#3541 + #3542 dispatched simultaneously this session; PRs #3546 #3547 opened green; one syntax slip caught by AGENT-RETIRED-SLASH-SYNTAX-DRIFT and fixed before second PR opened", - "line": 919 + "line": 922 }, { "id": "PROC.PARALLEL-FIX-DISPATCH.pattern", "klass": "PROC", "value": "bot triage brief → worktree per branch → parallel sub-agents do rubber-duck/RCA/TDD implementation only → top-level orchestrator owns commit + gsd-test + push + PR + changeset-pr-backfill", - "line": 917 + "line": 920 }, { "id": "PROC.PARALLEL-FIX-DISPATCH.rationale", "klass": "PROC", "value": "long-running test runs need cross-turn notifications (orchestrator-only); CONTRIBUTING.md gh-templates-first hook requires session-scoped Read calls sub-agents wouldn't otherwise make; sequencing test runs avoids GSD-TEST-CONCURRENT-OUTPUT-COLLISION", - "line": 918 + "line": 921 }, { "id": "PROC.TRIAGE.comment-shape", "klass": "PROC", "value": "lead with \"duplicate of #NNNN, fixed by PR #MMMM, in v1.X.Y\"; show current code snippet proving bug-surface gone; give @latest and @next upgrade commands; close", - "line": 922 + "line": 925 }, { "id": "PROC.TRIAGE.no-duplicate-label", "klass": "PROC", "value": "this repo has no duplicate label; framing lives in comment text + closing the issue", - "line": 923 + "line": 926 }, { "id": "PROC.TRIAGE.routing-incoming", "klass": "PROC", "value": "stale-bug-already-fixed to close as duplicate of originating issue + cite fix PR + first stable tag; release-publish-or-backport to ready-for-human; reporter-can-self-test to awaiting-retest", - "line": 921 + "line": 924 }, { "id": "PROHIB.canon-referral", "klass": "PROHIB", "value": "OWASP/GDPR/fairness-canon are REFERRED to /gsd:secure-phase+eslint, never minted as prohibitions (ADR-550 D6)", - "line": 577 + "line": 580 }, { "id": "PROHIB.descriptor.shape", "klass": "PROHIB", "value": "5 FLAT scalars (check_kind,check_target,check_rule,check_violation_fixture,check_clean_fixture) — NEVER a nested check:{} (parseMustHavesBlock is a flat parser, src/frontmatter.cts)", - "line": 582 + "line": 585 }, { "id": "PROHIB.enforce.adr", "klass": "PROHIB", "value": "docs/adr/1606-prohibition-enforcement-verify-seam.md (verify-time enforcement seam) + docs/adr/550-spec-phase-probe-contract.md (spec-phase contract)", - "line": 585 + "line": 588 }, { "id": "PROHIB.enforce.causation", "klass": "PROHIB", "value": "clean-fixture control proves the red is content-caused not env-var-set; MANDATORY for node-test (#1906 supersedes #1346 opt-in) — absent clean-fixture ⇒ node-test un-provable/fail-closed; lint-rule needs none (its subject IS the linted file)", - "line": 581 + "line": 584 }, { "id": "PROHIB.enforce.failfirst", "klass": "PROHIB", "value": "MACHINE-PROVEN against an author-supplied violation fixture (#1279); caller failFirst attestation DEMOTED to a non-authoritative hint (FF-08)", - "line": 580 + "line": 583 }, { "id": "PROHIB.enforce.green-rule", "klass": "PROHIB", "value": "passed iff provenFailFirst===true && run.passed===true (runProhibitionEnforcement); every miss/fail/un-provable HARD-GATES both modes via dispositionForProhibition's fail-closed default", - "line": 578 + "line": 581 }, { "id": "PROHIB.enforce.kinds", "klass": "PROHIB", "value": "node-test (non-vacuous red via isNonVacuousNodeTestRed; pass-side vacuity via isNonVacuousNodeTestPass) | lint-rule (eslint --format json filtered by ruleId)", - "line": 579 + "line": 582 }, { "id": "PROHIB.judgment-tier", "klass": "PROHIB", "value": "never-silent / never-hard-halt soft gate; autonomous emits \"unverified-prohibition — human review recommended\" (exogenous grading, ADR-550 D4)", - "line": 584 + "line": 587 }, { "id": "PROHIB.rail", "klass": "PROHIB", "value": "core verify rail, non-toggleable (ADR-857 verification-substrate boundary / decision #6); the verifier<->predicate contract is NOT an off-by-default capability", - "line": 583 + "line": 586 }, { "id": "PROHIB.recall", "klass": "PROHIB", "value": "LLM-prose; no compiled prohibition-probe recall engine (only the schema/projection layer is code, ADR-550 D7b)", - "line": 576 + "line": 579 }, { "id": "RELEASE-NOTES.ANTI-PATTERN", "klass": "RELEASE-NOTES", "value": "raw \"What's Changed\" PR list as final body for hotfix or feature release; \"Full Changelog only\" body for tagged release with >0 user-facing fixes", - "line": 732 + "line": 735 }, { "id": "RELEASE-NOTES.ANTI-PATTERN.implementation-first", "klass": "RELEASE-NOTES", "value": "do not lead bullet with file path or function name; lead with symptom/user-visible behavior", - "line": 733 + "line": 736 }, { "id": "RELEASE-NOTES.ANTI-PATTERN.risk-commentary", "klass": "RELEASE-NOTES", "value": "do not include \"may break\", \"be careful\", \"test thoroughly\" - release notes state what changed, not hedges about what might go wrong", - "line": 734 + "line": 737 }, { "id": "RELEASE-NOTES.DEFAULT-STATE", "klass": "RELEASE-NOTES", "value": "auto-generated body is \"What's Changed\" PR list + Full Changelog link; treat as draft, not final", - "line": 708 + "line": 711 }, { "id": "RELEASE-NOTES.EXAMPLE.hotfix", "klass": "RELEASE-NOTES", "value": "v1.41.1 (https://github.com/open-gsd/gsd-core/releases/tag/v1.41.1) - 14 fixes grouped by 6 subgroups", - "line": 736 + "line": 739 }, { "id": "RELEASE-NOTES.EXAMPLE.minor-auto-acceptable", "klass": "RELEASE-NOTES", "value": "v1.41.0 - kept auto-generated body; many small fixes with clean conventional-commit titles", - "line": 738 + "line": 741 }, { "id": "RELEASE-NOTES.EXAMPLE.rc", "klass": "RELEASE-NOTES", "value": "v1.7.0-rc.1 (https://github.com/open-gsd/gsd-core/releases/tag/v1.7.0-rc.1) - intro + Added/Changed/Fixed/Documentation taxonomy", - "line": 737 + "line": 740 }, { "id": "RELEASE-NOTES.GATE.hotfix", "klass": "RELEASE-NOTES", "value": "manual edit required; auto-generated body for vX.Y.{Z>0} is \"Full Changelog only\" and must be replaced with structured body", - "line": 709 + "line": 712 }, { "id": "RELEASE-NOTES.GATE.minor", "klass": "RELEASE-NOTES", "value": "auto-generated body acceptable when PR titles are clean; promote to structured body when >20 PRs or contains feature+refactor+fix mix", - "line": 711 + "line": 714 }, { "id": "RELEASE-NOTES.GATE.rc", "klass": "RELEASE-NOTES", "value": "manual edit recommended; auto-generated PR list is acceptable for early RCs but final RC before vX.Y.0 should match standard", - "line": 710 + "line": 713 }, { "id": "RELEASE-NOTES.RELEASE-STREAM.main-branch", "klass": "RELEASE-NOTES", "value": "next (RCs) + latest (stable); install via @next or @latest", - "line": 743 + "line": 746 }, { "id": "RELEASE-NOTES.RELEASE-STREAM.rule", "klass": "RELEASE-NOTES", "value": "streams do not mix; do not document @next in hotfix/stable notes", - "line": 744 + "line": 747 }, { "id": "RELEASE-NOTES.SCOPE", "klass": "RELEASE-NOTES", "value": "GitHub Releases body for tags vX.Y.Z, vX.Y.Z-rc.N; not CHANGELOG.md (changeset workflow owns that)", - "line": 707 + "line": 710 }, { "id": "RELEASE-NOTES.SOURCE.changesets", "klass": "RELEASE-NOTES", "value": ".changeset/*.md (frontmatter pr: + body bullets)", - "line": 723 + "line": 726 }, { "id": "RELEASE-NOTES.SOURCE.commits", "klass": "RELEASE-NOTES", "value": "git log .. --pretty=format:'%s%n%n%b' --no-merges", - "line": 722 + "line": 725 }, { "id": "RELEASE-NOTES.SOURCE.pr-bodies", "klass": "RELEASE-NOTES", "value": "gh pr view --json title,body for fixes lacking a changeset", - "line": 724 + "line": 727 }, { "id": "RELEASE-NOTES.SOURCE.precedence", "klass": "RELEASE-NOTES", "value": "changeset body > commit body > PR body > commit subject (prefer authored content over auto-generated)", - "line": 725 + "line": 728 }, { "id": "RELEASE-NOTES.STANDARD.bullet-shape", "klass": "RELEASE-NOTES", "value": "**Bold user-visible change** — explanation of what was broken or what's new, leading with symptom not implementation. Trailing (#NNN) PR ref.", - "line": 715 + "line": 718 }, { "id": "RELEASE-NOTES.STANDARD.footer.full-changelog", "klass": "RELEASE-NOTES", "value": "**Full Changelog**: https://github.com/open-gsd/gsd-core/compare/...", - "line": 719 + "line": 722 }, { "id": "RELEASE-NOTES.STANDARD.footer.hotfix", "klass": "RELEASE-NOTES", "value": "Install/upgrade: \\`npx @opengsd/gsd-core@latest\\`", - "line": 717 + "line": 720 }, { "id": "RELEASE-NOTES.STANDARD.footer.rc", "klass": "RELEASE-NOTES", "value": "Install for testing: \\`npx @opengsd/gsd-core@next\\` (per branch->dist-tag policy)", - "line": 718 + "line": 721 }, { "id": "RELEASE-NOTES.STANDARD.heading-level", "klass": "RELEASE-NOTES", "value": "## for category, ### for subgroup (area), - for bullet", - "line": 714 + "line": 717 }, { "id": "RELEASE-NOTES.STANDARD.intro", "klass": "RELEASE-NOTES", "value": "optional one-paragraph framing for RC/feature releases; omit for pure-fix hotfixes", - "line": 720 + "line": 723 }, { "id": "RELEASE-NOTES.STANDARD.subgroups", "klass": "RELEASE-NOTES", "value": "phase-planning-state | workstream | query-dispatch-cli | code-review | install | capture | docs | architecture | security", - "line": 716 + "line": 719 }, { "id": "RELEASE-NOTES.STANDARD.taxonomy", "klass": "RELEASE-NOTES", "value": "Keep-a-Changelog 1.1.0: Added | Changed | Deprecated | Removed | Fixed | Security | Documentation", - "line": 713 + "line": 716 }, { "id": "RELEASE-NOTES.TEMPLATE.hotfix", "klass": "RELEASE-NOTES", "value": "## Fixed\\n\\n### \\n- **** — . (#)\\n\\n---\\n\\nInstall/upgrade: \\`npx @opengsd/gsd-core@latest\\`\\n\\n**Full Changelog**: ", - "line": 740 + "line": 743 }, { "id": "RELEASE-NOTES.TEMPLATE.rc", "klass": "RELEASE-NOTES", "value": "\\n\\n## Added\\n### \\n- **** — . (#)\\n\\n## Changed\\n### Architecture\\n- **** — . (#)\\n\\n## Fixed\\n### \\n- **** — . (#)\\n\\n## Documentation\\n- **** — . (#)\\n\\n---\\n\\nThis is a release candidate. Install for testing:\\n\\`\\`\\`bash\\nnpx @opengsd/gsd-core@next\\n\\`\\`\\`\\n\\n**Full Changelog**: ", - "line": 741 + "line": 744 }, { "id": "RELEASE-NOTES.WORKFLOW.edit", "klass": "RELEASE-NOTES", "value": "gh release edit --notes-file ", - "line": 727 + "line": 730 }, { "id": "RELEASE-NOTES.WORKFLOW.idempotency", "klass": "RELEASE-NOTES", "value": "gh release edit overwrites body wholesale; safe to re-run after refining", - "line": 730 + "line": 733 }, { "id": "RELEASE-NOTES.WORKFLOW.token", "klass": "RELEASE-NOTES", "value": "must use .envrc GITHUB_TOKEN per RULESET.GH.AUTH.DEFAULT (this doc); never ambient gh auth", - "line": 729 + "line": 732 }, { "id": "RELEASE-NOTES.WORKFLOW.view", "klass": "RELEASE-NOTES", "value": "gh release view --json body --jq .body", - "line": 728 + "line": 731 }, { "id": "RULESET.ADR-HEADER", "klass": "RULESET", "value": "every docs/adr/NNNN-*.md must open with - **Status:** Accepted|Proposed|Superseded (by [ADR-NNNN](file.md))|Legacy + - **Date:** YYYY-MM-DD immediately after title", - "line": 632 + "line": 635 }, { "id": "RULESET.AGENT_SIZE_BUDGET", "klass": "RULESET", - "value": "agent-size-budget (#1074; sibling of WORKFLOW_SIZE_BUDGET; BYTES not lines per #717/#683, rebased from lines in PR 3/3) = differential attribution size ratchet (PRIMARY anti-creep since #2724/ADR-2719 §4, same mechanism and same ack fragments (tests/emitted-drift-acks/, #2914; legacy tests/emitted-drift-ack.json still honored) as WORKFLOW_SIZE_BUDGET, scoped to agents/gsd-*.md) + loose tier hard caps (red lines, never raised on approach: XL<=57344 / LARGE<=49152 / DEFAULT<=24576); net-new agents are DEFAULT-tier (no separate new-file cap). Sizes are measured via the shared scripts/workflow-size.cjs measureMdFiles(dir,predicate) counter (tests/helpers/emitted-runtime.cjs's currentSizes() and the guard's own tier-cap checks both import it). A grown agent fails the differential guard — ack + justify, or extract LAZILY to gsd-core/references/. DISTINCT from DEFECT.AGENT-FILE-SIZE-CAP-BREACH (a separate 45K-CHAR extraction-evidence threshold on gsd-planner via planner-decomposition/reachability tests): that guard proves mode-sections were extracted; this one bounds total agent bytes. Two guards, two units (chars vs bytes), two purposes. The prior per-file baseline (tests/agent-size-baseline.json, `npm run size:baseline`) is REMOVED by #2724", - "line": 621 + "value": "agent-size-budget (#1074; sibling of WORKFLOW_SIZE_BUDGET; BYTES not lines per #717/#683, rebased from lines in PR 3/3) = differential attribution size ratchet (PRIMARY anti-creep since #2724/ADR-2719 §4, same mechanism and same `Emitted-Drift-Ack-Growth:` commit trailer (ADR-3942, superseding ADR-2719 §3's fragment model) as WORKFLOW_SIZE_BUDGET, scoped to agents/gsd-*.md) + loose tier hard caps (red lines, never raised on approach: XL<=57344 / LARGE<=49152 / DEFAULT<=24576); net-new agents are DEFAULT-tier (no separate new-file cap). Sizes are measured via the shared scripts/workflow-size.cjs measureMdFiles(dir,predicate) counter (tests/helpers/emitted-runtime.cjs's currentSizes() and the guard's own tier-cap checks both import it). A grown agent fails the differential guard — ack + justify, or extract LAZILY to gsd-core/references/. DISTINCT from DEFECT.AGENT-FILE-SIZE-CAP-BREACH (a separate 45K-CHAR extraction-evidence threshold on gsd-planner via planner-decomposition/reachability tests): that guard proves mode-sections were extracted; this one bounds total agent bytes. Two guards, two units (chars vs bytes), two purposes. The prior per-file baseline (tests/agent-size-baseline.json, `npm run size:baseline`) is REMOVED by #2724", + "line": 624 }, { "id": "RULESET.ALLOWED-TOOLS-FRONTMATTER", "klass": "RULESET", "value": "command's allowed-tools must cover every tool the workflow calls (including Write for file creation); thin-wrapper pattern makes this easy to miss", - "line": 628 + "line": 631 }, { "id": "RULESET.ARGUMENTS-SANITIZE", "klass": "RULESET", "value": "any workflow step constructing .planning/.../{SLUG}.md path from user input ($ARGUMENTS, parsed remainder) must sanitize inline ([a-z0-9-] only, reject ..//\\\\, max-length) — \"(already sanitized)\" must trace back to explicit guard; RESUME/fallback modes need own guards", - "line": 629 + "line": 632 }, { "id": "RULESET.AUDIT.search-source-not-generated", "klass": "RULESET", "value": "verify an invariant/validation EXISTS by searching the AUTHORED source (src/*.cts OR the scripts/gen-*.cjs generator), never the generated bin/lib/*.cjs (gitignored, ADR-457); gen-time checks live in gen-*.cjs not the .cts it consumes → search BOTH before declaring absent; read generated .cjs only for output drift. Repro: grep src/*.cts for VALID_CONVERTER_NAMES → false \"5e ConverterName unenforced\"; actually enforced in gen-capability-registry.cjs. cf RULESET.TESTS.no-source-grep", - "line": 617 + "line": 620 }, { "id": "RULESET.CAPABILITY.cutover-self-gating", @@ -1114,493 +1114,493 @@ "id": "RULESET.CODERABBIT.GUARD.COMPLETE", "klass": "RULESET", "value": "required_checks_green && coderabbit_check_pass && graphQL(reviewThreads.unresolved_count)==0", - "line": 654 + "line": 657 }, { "id": "RULESET.CODERABBIT.GUARD.GRAPHQL", "klass": "RULESET", "value": "reviewThreads(first:100){nodes{id isResolved comments{nodes{author body path line originalLine url}}}}; use unresolved threads as authoritative, not badge text alone", - "line": 655 + "line": 658 }, { "id": "RULESET.CODERABBIT.GUARD.OPEN_PRS", "klass": "RULESET", "value": "gh pr list --repo open-gsd/gsd-core --author @me --state open; repeat near end because open PR set can change mid-run", - "line": 653 + "line": 656 }, { "id": "RULESET.CODERABBIT.GUARD.RERUN", "klass": "RULESET", "value": "after every push wait for CodeRabbit completion, then re-query unresolved threads; CodeRabbit can add new findings after earlier threads were resolved", - "line": 656 + "line": 659 }, { "id": "RULESET.CODERABBIT.GUARD.RESOLVE", "klass": "RULESET", "value": "fix validated finding -> focused tests -> commit/push -> resolveReviewThread(threadId) -> wait CI/CodeRabbit -> final unresolved_count query", - "line": 657 + "line": 660 }, { "id": "RULESET.CODERABBIT.GUARD.SCOPE", "klass": "RULESET", "value": "if a new @me open PR appears during final list, include it in the same guard pass before declaring all-open-PRs complete", - "line": 658 + "line": 661 }, { "id": "RULESET.CONTENT-PATH-NORMALIZATION", "klass": "RULESET", "value": "filesystem paths substituted into markdown body text (@-references, workflow .md, agent .md, generated docs, command bodies) MUST be normalized to POSIX forward slashes via .replace(/\\\\/g,'/') at the production source BEFORE substitution; never push normalization to tests; cross-platform content is POSIX-only; applies to: computePathPrefix output, install-path rewrites, generated shim paths emitted into .md bodies; idempotent on POSIX so unconditional; mechanically enforced by local/normalize-path-in-content (eslint, src/**/*.cts; #1733)", - "line": 859 + "line": 862 }, { "id": "RULESET.CONTRIB.CLASSIFY.enhancement", "klass": "RULESET", "value": "requires approved-enhancement before implementation", - "line": 647 + "line": 650 }, { "id": "RULESET.CONTRIB.CLASSIFY.feature", "klass": "RULESET", "value": "requires approved-feature before implementation", - "line": 648 + "line": 651 }, { "id": "RULESET.CONTRIB.CLASSIFY.fix", "klass": "RULESET", "value": "requires confirmed-bug before implementation (legacy 'confirmed' label is back-compat only for duplicate-sweep exemption, not a valid implementation gate)", - "line": 646 + "line": 649 }, { "id": "RULESET.CONTRIB.GATE.ORDER", "klass": "RULESET", "value": "issue-first -> approval-label -> code -> PR-link -> changeset/no-changelog", - "line": 645 + "line": 648 }, { "id": "RULESET.CR-THREAD-RESOLVE", "klass": "RULESET", "value": "after adding // allow-test-rule: to silence lint, resolve existing inline CR threads via graphql resolveReviewThread mutation before merge — open threads mislead future reviewers; pattern: gh api graphql -f query='mutation { resolveReviewThread(input:{threadId:\"PRRT_...\"}) { thread { isResolved } } }'", - "line": 639 + "line": 642 }, { "id": "RULESET.EMITTED_ATTRIBUTION", "klass": "RULESET", - "value": "the emitted-artifact family (ADR-2719, epic #2719) — POST-CUTOVER (#2724, Phase 4). Historically tests/fixtures/golden-install-parity/*.json (19 path→hash manifests) + tests/workflow-size-baseline.json + tests/agent-size-baseline.json were all committed, PURE FUNCTIONS of the source tree whose correct merge was ALWAYS \"recompute\" — 140 of 143 conflicted-file instances across the open PR queue were these files. #2724 DELETES all three, the golden test (tests/golden-install-parity.test.cjs), the generator (scripts/gen-golden-install-parity-zcode.cjs), `npm run gen:golden`, `UPDATE_GOLDEN`, the merge-driver bridge (scripts/git-merge-regen-driver.cjs, `npm run setup:merge-driver`, the .gitattributes merge=gsd-regen block), and scripts/update-size-baseline.cjs (`npm run size:baseline`). The differential attribution check (tests/emitted-attribution.test.cjs + tests/emitted-provenance.test.cjs) is now the SOLE gate for emitted-artifact propagation AND size growth — no committed artifact, nothing to hand-merge, nothing to regenerate. `npm run regen:derived` still exists for what remains committed and derived: build, registry, ADR index, capability matrix, inventory manifest, manifest versions, and `tests/fixtures/install-tree/*.json` (now `npm run gen:install-tree`, folded into `regen:derived`). tests/fixtures/install-tree/*.json is DELIBERATELY EXCLUDED from the cutover (ADR-2719 §7): it conflicts on 0 of 7, its diffs are readable, and it preserves \"the installer stopped shipping X\" as a hard absolute failure — capturing it would convert that absolute into an attribution-free auto-resolve. The baseline the differential compares against is now published by `scripts/gen-emitted-baseline.cjs` on every push to `next` (cached, keyed on sha) and restored in PR lanes via `GSD_EMITTED_BASELINE`/`resolveBaseline()` (tests/helpers/emitted-baseline.cjs); a cache miss falls back to an in-job build via a throwaway `git worktree` (tests/helpers/emitted-runtime.cjs's `buildBaselineAtRef`). REMEDIATION IS PART OF THE GATE (#2778): the failure output names its own remedy, because a gate that states a requirement and withholds the means of satisfying it is a maintainer round-trip, not a gate — ADR-2719 §3's \"conspicuous declaration\" only works if the contributor can discover how to make it. Both failing branches name a NEW fragment to create under `tests/emitted-drift-acks/` (#2914; pick a name nobody else is using), say it may not exist yet (absence is the healthy steady state), print a minimal valid document, and repeat \"do NOT regenerate anything\" — post-#2724 there is nothing left to regenerate, and hunting for a deleted baseline is the predictable wrong guess. The two branches key on DIFFERENT spaces and each says which: the hash pass keys on the EMITTED PATH (always contains a `/`), the size ratchet keys on the BARE FILENAME (`currentSizes` writes `sizes[entry.name]` from readdirSync over `gsd-core/workflows/` + `agents/`). A stale-ack failure additionally says to delete the FILE when removing its last entry, since an empty-but-present ack parses fine yet signals nothing; post-#2789 it also offers CORRECTING the entry to name the ripple actually made, which is the other honest resolution and the one a contributor usually wants. NOT ack-able and deliberately given no ack text: the `NEW_FILE_CAP` branch, whose remedy is extraction. Text is sourced from one frozen `REMEDIATION` export in tests/helpers/emitted-diff.cjs whose example document is rendered from `ACK_VERSION` via `JSON.stringify`, so the taught schema cannot drift from the accepted one (a round-trip test feeds the printed document back through `parseAck`); the message teaches ONE canonical shape even though `parseAck` also accepts a bare-string reason and a missing `version` — liberal in what it accepts, conservative in what it sends. Note the ADR's Consequences originally called the #2724 migration \"terminal\"; #2778 corrected that — it is terminal only for a PR that grows no shipped file. #2914 replaced the single shared ack file with per-PR fragments under `tests/emitted-drift-acks/` — exactly the shape `.changeset/` already uses for the identical \"every PR rewrites one shared document\" conflict problem — so two PRs needing an ack can no longer collide with each other on the FILE; the legacy file is still read and unioned in for branches that predate the split, and a duplicate path key across two sources is a hard, loudly-reported error, never silent last-wins — #3078 made that error name its two resolutions (git rm an already-merged, spent owner; APPEND prose to a still-live one, which re-arms it), because the guard runs post-merge and cannot stop the colliding PR. `tests/emitted-drift-ack.json` (the LEGACY file specifically) must NEVER persist on `next` (#2914): every entry is scoped to the diff that introduced it, so once merged it is by definition already at the base — spent and inert regardless of shape — and a persistent copy makes that ONE file a shared merge-conflict cell across every open PR that also carries an ack, exactly the \"140 of 143\" cost this whole cutover exists to remove; #2914 asserted a persisting FRAGMENT was harmless by construction and deliberately exempted the directory; #3078 REVERSED that — fragments do not share a FILE but they DO share a PATH KEY SPACE, so a fully-spent fragment on `next` owns keys it can no longer gate and the next PR growing one of those paths can declare it neither there (spent) nor in its own (duplicate), which is the #2914 wall one level down (measured at the sweep: 45 fragments owning 403 paths, up from 13/272 at triage 19 days earlier). A fragment is judged on INERTNESS, not presence: swept once EVERY entry is spent, left alone while PARTIALLY spent — the asymmetry is what keeps the re-arm-by-appending route (#2639, #2993) working, and the `0000` legacy-migration bucket #2923 created for the old shared file's 35 entries was NOT permanent (the issue's own open question resolved to NO) and went with the rest. This is enforced on `next` itself only, never as a PR-lane check: the `guard-no-ack-on-next` job in `.github/workflows/test.yml` (push-to-`next` trigger) runs `scripts/lint-emitted-drift-ack.cjs --guard-next`, which is now BOTH halves — `assertAbsentOnNext` (legacy file, fails on PRESENCE alone, valid or not) and `assertNoAllSpentFragments` (fragments, fails on all-entries-spent vs the copy at the PRE-PUSH TIP of next — CI passes `github.event.before` via `--base-ref`, because the default branch allows REBASE merges so one push can carry N commits and a bare `HEAD^` would flag a fragment the same push introduced; `HEAD^` remains only the local/manual fallback, using the SAME zero-width/whitespace-stripping prose comparison as `isSpent` so an invisible reword cannot fake a re-arm; duplicated across the scripts-ship/tests-do-not line and held by a parity test). The job's checkout REQUIRES `fetch-depth: 2` plus an explicit `git fetch --depth=1 origin $BEFORE` — at depth 1 no base commit exists locally, every fragment reads as brand-new, and the guard passes vacuously, which is exactly how the legacy half went blind after #2914 removed the file it was watching. The gate's `INVISIBLE`/`normalizeAckReason` are EXPORTED from tests/helpers/emitted-diff.cjs for the sole purpose of letting the parity test compare them against the script's duplicate; before #3078 neither was exported, so the \"parity test\" the comments promised was a tautology checking the script against itself. A PR-lane \"base ack must be absent\" check would red every open PR the instant a spent ack merged, which is the #2768 shape #2789 already ended — so this alerts AFTER the merge by design and never stops the offending PR. #3875 automated the REMEDY that alert asks for, because detection without an executable remedy is what actually failed: #3823 shipped the guard together with a static 45-fragment sweep computed at its own branch point, #3809's fragment merged to `next` while it was in flight, and the guard reddened on its own merge commit and stayed red for 24 consecutive pushes over two days — the sweep condition is computed DYNAMICALLY at merge time while a hand-authored `git rm` is fixed at BRANCH time, so on a moving branch the second can never reliably satisfy the first. `runGuardNext` therefore returns the set it reasoned about (`sweepable`, already narrowed by the #3842 hold, plus `legacyPresent` for the legacy document, which is a fixed path rather than a fragment basename and would otherwise be invisible to any sweeper), `--sweep-plan` emits that set as a work list on stdout with the prose diverted to stderr and exit 0 (a non-empty plan is the NORMAL case, and a non-zero exit would fail the step that asked for the list), and `.github/workflows/ack-fragment-sweep.yml` runs it on a timer and opens a reviewable PR rather than pushing to protected `next`. The plan is re-validated against a literal allowlist before any deletion and each path is removed under a `:(literal)` pathspec — `git rm` reads its arguments as PATHSPECS with wildmatch semantics, so a fragment named `*.json` (a legal filename that `listFragmentFiles` admits, since it filters only on the suffix) would otherwise expand to every fragment in the directory, including ones the #3842 hold deliberately withheld. An empty plan is NOT reported as success on its own: the guard is re-run without the hold to separate \"next is clean\" from \"everything is held\", the commonest holder being the sweep PR from the previous run, which touches precisely the fragments it proposed to delete and would otherwise make the automation go silently inert. cf `RULESET.WORKFLOW_SIZE_BUDGET`, `RULESET.AGENT_SIZE_BUDGET`; see `### Emitted Artifact Provenance`", - "line": 622 + "value": "the emitted-artifact family (ADR-2719, epic #2719) — POST-CUTOVER (#2724, Phase 4). Historically tests/fixtures/golden-install-parity/*.json (19 path→hash manifests) + tests/workflow-size-baseline.json + tests/agent-size-baseline.json were all committed, PURE FUNCTIONS of the source tree whose correct merge was ALWAYS \"recompute\" — 140 of 143 conflicted-file instances across the open PR queue were these files. #2724 DELETES all three, the golden test (tests/golden-install-parity.test.cjs), the generator (scripts/gen-golden-install-parity-zcode.cjs), `npm run gen:golden`, `UPDATE_GOLDEN`, the merge-driver bridge (scripts/git-merge-regen-driver.cjs, `npm run setup:merge-driver`, the .gitattributes merge=gsd-regen block), and scripts/update-size-baseline.cjs (`npm run size:baseline`). The differential attribution check (tests/emitted-attribution.test.cjs + tests/emitted-provenance.test.cjs) is now the SOLE gate for emitted-artifact propagation AND size growth — no committed artifact, nothing to hand-merge, nothing to regenerate. `npm run regen:derived` still exists for what remains committed and derived: build, registry, ADR index, capability matrix, inventory manifest, manifest versions, and `tests/fixtures/install-tree/*.json` (now `npm run gen:install-tree`, folded into `regen:derived`). tests/fixtures/install-tree/*.json is DELIBERATELY EXCLUDED from the cutover (ADR-2719 §7): it conflicts on 0 of 7, its diffs are readable, and it preserves \"the installer stopped shipping X\" as a hard absolute failure — capturing it would convert that absolute into an attribution-free auto-resolve. The baseline the differential compares against is now published by `scripts/gen-emitted-baseline.cjs` on every push to `next` (cached, keyed on sha) and restored in PR lanes via `GSD_EMITTED_BASELINE`/`resolveBaseline()` (tests/helpers/emitted-baseline.cjs); a cache miss falls back to an in-job build via a throwaway `git worktree` (tests/helpers/emitted-runtime.cjs's `buildBaselineAtRef`). REMEDIATION IS PART OF THE GATE (#2778): the failure output names its own remedy, because a gate that states a requirement and withholds the means of satisfying it is a maintainer round-trip, not a gate — ADR-2719 §3's \"conspicuous declaration\" only works if the contributor can discover how to make it. Both failing branches name the commit trailer to add — `Emitted-Drift-Ack-Hash:` or `Emitted-Drift-Ack-Growth:` (ADR-3942) — print its exact grammar (` — `, key and reason split on the FIRST em dash), and repeat \"do NOT regenerate anything\" — post-#2724 there is nothing left to regenerate, and hunting for a deleted baseline is the predictable wrong guess. The two branches key on DIFFERENT, now STRUCTURALLY DISTINCT trailer key spaces (separate maps since ADR-3942, closing a latent defect where a growth key could satisfy a hash lookup by naming coincidence and vice versa) and each says which: the hash pass keys on the EMITTED PATH (always contains a `/`, `Emitted-Drift-Ack-Hash:`), the size ratchet keys on the BARE FILENAME (`Emitted-Drift-Ack-Growth:`; `currentSizes` writes `sizes[entry.name]` from readdirSync over `gsd-core/workflows/` + `agents/`). A stale-ack failure additionally says to drop the trailer line (amending the commit) when removing its last entry, since a lingering unused trailer signals nothing; post-#2789 it also offers CORRECTING the reason to name the ripple actually made, which is the other honest resolution and the one a contributor usually wants. NOT ack-able and deliberately given no ack text: the `NEW_FILE_CAP` branch, whose remedy is extraction. Text is sourced from one frozen `REMEDIATION` export in tests/helpers/emitted-diff.cjs, whose example line is rendered via `renderAckTrailer` (`: — `, ADR-3942) so the taught grammar cannot drift from what `parseAckTrailers` actually accepts (a round-trip test feeds the printed line back through the parser); a key that is reserved (`__proto__`/`constructor`/`prototype`) or contains `<`, `>`, or whitespace is rejected loudly, and a doc example like ` — ` must never parse as a real declaration. Note the ADR's Consequences originally called the #2724 migration \"terminal\"; #2778 corrected that — it is terminal only for a PR that grows no shipped file. The ack was PR-lifetime data kept in permanent, shared, merge-path state, and each fix generated the next defect until ADR-3942 moved it off the tree entirely (see `### Emitted Artifact Provenance`): the single shared `tests/emitted-drift-ack.json` was a guaranteed merge-conflict cell (#2789; 5 of 6 conflicting PRs in one open queue collided on it and nothing else); #2914 replaced it with per-PR fragments under `tests/emitted-drift-acks/` — the `.changeset/` shape — ending the FILE conflict but not the KEY conflict, since two sources could never name the same path; #3078 found a fully-spent fragment left on `next` still walled off every key it owned (measured at the sweep: 45 fragments owning 403 paths, up from 13/272 at triage 19 days earlier) and added the post-merge-only `guard-no-ack-on-next` job plus a manual sweep; #3842's hand sweep handed three in-flight external PRs a `modify/delete` conflict each; #3823's hand-authored sweep, computed at branch time against a guard that evaluates at merge time, lost the race to a fragment merged mid-flight and left `next` red for 24 consecutive pushes; #3875's timed sweeper workflow automated the remedy but could not merge its own PRs (three independent, deterministic defects — bad conventional-title match, wrong CI-lane classification, no auto-merge path). ADR-3942 ends the chain: the escape hatch is now a commit trailer scoped to the PR's own commits, so there is no shared file, no shared key namespace, and nothing to sweep — the fragment directory, the next-lane guard job, the scheduled sweep workflow and the standalone ack linter are all DELETED (named by ROLE rather than by filename on purpose: a backticked path here asserts a LIVE repo path and `check-glossary-refs.cjs` fails on one that does not exist, while `lint-removed-but-needed.cjs` additionally fails on a deleted file's bare BASENAME appearing anywhere it scans — and this predicate's generated projection lands in docs/, which it does scan. ADR-3942 carries the exact paths; it sits under docs/adr/, which that guard exempts as a historical record). cf `RULESET.WORKFLOW_SIZE_BUDGET`, `RULESET.AGENT_SIZE_BUDGET`; see `### Emitted Artifact Provenance`", + "line": 625 }, { "id": "RULESET.GENERATIVE-FIX", "klass": "RULESET", "value": "parallel implementations diverge silently when no parity test enforces equality at the test layer; for any new constant/array/parser shared between two parallel surfaces (two workflow surfaces, or a generated artifact and its hand-authored source), the same commit MUST add a parity assertion that fails when the two diverge; exemplar: tests/runtime-launcher-parity.test.cjs (asserts every workflow bash block uses the canonical gsd_run launcher)", - "line": 857 + "line": 860 }, { "id": "RULESET.GH.AUTH.DEFAULT", "klass": "RULESET", "value": "source .envrc GITHUB_TOKEN before gh; exception=ambient allowed only when user explicitly says machine-only fallback", - "line": 652 + "line": 655 }, { "id": "RULESET.HARNESS.test-memory-guard", "klass": "RULESET", "value": "~/.claude/hooks/test-memory-guard.sh fires on every Bash PreToolUse; if argv[0]∈{node|vitest|jest|mocha|tsx|ts-node|tap|ava|playwright|cypress} OR matches (npm|pnpm|yarn|bun) (run )?(t|test|tests|vitest|jest); blocks via hookSpecificOutput.permissionDecision=deny when sum(RSS of running matching procs, excluding tsserver|*-mcp|claude|Electron|...) ≥ 4 GiB OR when argv[0] basename matches a running process's argv[0]. Exception: node --version|-v|--help|-h|-p|-e are trivial probes and skip the check. Designed for a 24 GB Mac where prior accidental fan-out exhausted RAM", - "line": 898 + "line": 901 }, { "id": "RULESET.MANIFEST-CANONICAL-KEY", "klass": "RULESET", "value": "docs/INVENTORY-MANIFEST.json has a single top-level key: families; ALL EIGHT families.* arrays (agents/commands/workflows/references/cli_modules/hooks flat, plus workflow_modes/workflow_steps nested — #2996, epic #1671 Phase 6.5) are canonical, consumed by test suites — tests/inventory-manifest-sync.test.cjs reads all eight, edit-phase/enh-2380/enh-2430 tests read commands+workflows; the six flat families are keyed by BARE BASENAME while the two nested families are keyed by // path, deliberately, because two workflows may each own a same-named step file and a basename key would silently drop one under a JSON-equality comparison; recursion is bounded at exactly one named subdirectory, never a general walk; the family tables live ONCE in scripts/gen-inventory-manifest.cjs and are IMPORTED by the test (the test formerly redeclared them, a DEFECT.GENERATIVE-FIX divergence that let a new family be verified by nobody while still reporting green); the old generated date field and the stale top-level workflows key are both gone; regen via node scripts/gen-inventory-manifest.cjs --write, AFTER build:lib; #3762 added the ROSTER half — tests/inventory-manifest-sync.test.cjs now also asserts every manifest entry has a hand-written row in docs/INVENTORY.md, via the pure matcher in tests/helpers/inventory-roster.cjs. Scope is the SIX FLAT families only, each searched inside its own `## ` section; workflow_steps/workflow_modes are DELIBERATELY exempt because docs/INVENTORY.md §\"Workflow Sub-Files\" is a shipped decision that they carry no hand-written per-file rows. Matching is whole-CELL-exact (never substring — the rostered host-integration-adapters/imperative-hook-bus.cjs must not satisfy the separate top-level hook-bus.cjs) and section-scoped (smart-entry.md and smart-entry.cjs are different families), EXCEPT commands, which match on the row's Source-column link to ../commands/gsd/.md because the six ns-* namespace routers deliberately RENDER a name that is not their file stem (/gsd-workflow ← ns-workflow.md) — DEFECT.DISPLAY-VALUE-AS-IDENTITY. Landing the gate required backfilling 32 pre-existing unrostered surfaces on next", - "line": 633 + "line": 636 }, { "id": "RULESET.PR-FLOW.docker-before-push", "klass": "RULESET", "value": "before ANY git push of any fix to any PR, run gsd-test (docker on the remote, mirrors ubuntu CI) and confirm exit 0. macOS-local node --test is NOT a substitute — many failures are platform-specific (path separators, case sensitivity, locale, fs semantics). Watchdog with Monitor on the output log; never set a sleep/timer and walk away. Source: user feedback 2026-05-16 — \"we don't set a timer we actively watch and record results in real time as possible\". SUPERSEDED 2026-07-17: 'confirm exit 0' is a false-green trap — piping/backgrounding can report exit 0 on a failed suite; gate on the verdict-line outcome:\"passed\" for the exact HEAD sha instead. See CLAUDE.md's gsd-test rule and the gsd-test-is-ref-based-commit-first predicate for the current, correct gating contract.", - "line": 900 + "line": 903 }, { "id": "RULESET.PR-FLOW.templates-mandatory", "klass": "RULESET", "value": "every gh pr create|edit|gh issue create|edit MUST first invoke the gh-templates-first skill and Read (Read tool, not Bash cat — k321 read-tracking) the matching template in .github/. Apply ALL required sections; never write freeform bodies. Repo enforces this via gsd-pr-template-policy GitHub Action which flags any non-templated body — the bot allows the PR to stay open only because authors are contributors-or-higher, but the warning is a real complaint that must be cured. Source: user feedback 2026-05-16 (multi-message escalation) — \"the whole reason i have that github action is because you fucking blow through and ignore using the templates\"", - "line": 902 + "line": 905 }, { "id": "RULESET.PR-SCOPE.one-concern-per-pr", "klass": "RULESET", "value": "split unrelated changes into separate PRs; cherry-pick doc changes to dedicated docs/ branch immediately, then force-push original to remove the commit", - "line": 635 + "line": 638 }, { "id": "RULESET.SHARED-HELPERS-LINT-VS-TEST", "klass": "RULESET", "value": "when a lint script and test suite both implement same constant (CANONICAL_TOOLS) or parser (parseFrontmatter, executionContextRefs), extract to scripts/*-helpers.cjs required by both — silent divergence otherwise", - "line": 630 + "line": 633 }, { "id": "RULESET.TESTS.CODERABBIT_FIX", "klass": "RULESET", "value": "prefer exported-function behavioral tests over source-grep; lint-no-source-grep rejects readFileSync source assertions without allow-test-rule", - "line": 659 + "line": 662 }, { "id": "RULESET.TESTS.boundary-coverage", "klass": "RULESET", "value": "tests MUST exercise inputs at and near the threshold/limit, not only trivial-fit and trivial-overflow; pick inputs where N ∈ {limit-1, limit, limit+1} and where pre-trim/pre-check accumulators ≈ effective limit; \"very small\" and \"very large\" inputs alone do not constitute edge-case coverage and routinely miss off-by-one + reservation-accounting bugs", - "line": 602 + "line": 605 }, { "id": "RULESET.TESTS.boundary-coverage.anti-pattern", "klass": "RULESET", "value": "test suites that pair budget:1_000_000 (trivially fits) with budget:1 (trivially overflows) and skip the boundary region; failure mode that shipped PR #3708 UNNEEDED_TRIM + FALSE_HARDFAIL regressions (commit 2df566ed, fixed bde1ae8f)", - "line": 605 + "line": 608 }, { "id": "RULESET.TESTS.boundary-coverage.fixtures", "klass": "RULESET", "value": "for any code with budget/limit/quota/threshold parameter, test suite MUST include: (a) input where SUT estimate == limit exactly, (b) input where estimate == limit - 1, (c) input where estimate == limit + 1, (d) input where any internal reserve/safety constant pushes baseline within reserve-distance of limit (catches early-pressure firing)", - "line": 604 + "line": 607 }, { "id": "RULESET.TESTS.clock-seam", "klass": "RULESET", "value": "concurrency logic must accept an optional {clock=Date} parameter; tests control time via t.mock.timers.enable(['Date']) + t.mock.timers.setTime(0) + t.mock.timers.tick(N); real OS scheduler races are not a permitted test pattern after ADR 456 (2026-05-28); real-race tests are deleted once deterministic seam tests cover the same logical path; clock.cjs realClock adds nowIso() (→ new Date(this.now()).toISOString()) and today() (→ nowIso().split('T')[0]) so all date-stamping in state.cjs routes through the seam; subprocess time-pin adapter: set GSD_TEST_MODE=1 + GSD_NOW_MS= in runGsdTools env to pin the date written by the SUT without touching real wall-clock (issue #474)", - "line": 609 + "line": 612 }, { "id": "RULESET.TESTS.coderabbit-fix-prefer", "klass": "RULESET", "value": "behavioral tests (call exported fn, capture JSON, assert typed fields) over source-grep", - "line": 600 + "line": 603 }, { "id": "RULESET.TESTS.delete-bad-tests", "klass": "RULESET", "value": "pass-always / vacuous-truth / source-grep / elapsed-time / real-race / permanent-allow-test-rule tests are DELETED and replaced with compliant tests in the same PR; not skipped, not commented out, not permanently exempted; replacement must cover the same logical path via typed-surface assertion or clock-seam pattern", - "line": 614 + "line": 617 }, { "id": "RULESET.TESTS.diagnostics", "klass": "RULESET", "value": "after JSON.parse, assert output shape (Array.isArray(output.phases)) with raw-output-prefix diagnostics before .map() — prevents opaque TypeErrors when CLI output shape changes", - "line": 601 + "line": 604 }, { "id": "RULESET.TESTS.escape-regex", "klass": "RULESET", "value": "new RegExp(\"prefix${var}\") must escapeRegex(var); phase-id.cjs exports escapeRegex (core.cjs re-export spine retired in epic #1267); phase IDs like 5.1 contain . which is metacharacter", - "line": 597 + "line": 600 }, { "id": "RULESET.TESTS.eslint-harness", "klass": "RULESET", "value": "ADR 452 (2026-05-28): ESLint flat config + typescript-eslint + eslint-plugin-n + eslint-plugin-no-only-tests + local plugin at eslint-rules/ (repo root, NOT scripts/eslint-rules/); replaces scripts/lint-*.cjs regex scanners (fully removed in #632); all three test-rigor rules now ship at error in tests/**/*.test.cjs scope: local/no-source-grep and local/no-magic-sleep-in-tests promoted by #3313, local/no-elapsed-assertion promoted by #3331 once #3314 delivered its ADR-456 §(a) precondition (epic #1885 was subsumed into epic #3053 and closed stale before this promotion landed)", - "line": 615 + "line": 618 }, { "id": "RULESET.TESTS.feedback-loop-convergence", "klass": "RULESET", "value": "when a feature's OUTPUT feeds back into its own INPUT (calibration, retry backoff, adaptive budgets, ratchets, any self-correcting signal), step-wise tests are NOT sufficient evidence of correctness: they assert `given X return Y` while the defect lives in the TRAJECTORY across iterations. Required: a closed-loop test that (a) drives the REAL end-to-end surface — not the pure core alone, since composition bugs live between surfaces — for N >= 2x the loop's window, (b) asserts convergence on the known-true value, (c) asserts the fixed point (an already-correct history must produce NO correction), and (d) asserts boundedness under an adversarial/oscillating history. Two defects shipped past a green ~26,800-test suite in epic #1952 for want of exactly this: calibration applied twice across two surfaces (factor^2, #2631) and calibration measured against its own corrected output so it oscillated to ~1.41 instead of converging on 2.0 (#2632). Every unit, boundary, property and round-trip test passed for both. HOW TO SPOT ONE (the detection tell, not a judgment call): the feature's own acceptance criterion carries a TEMPORAL QUANTIFIER — \"after N phases\", \"subsequent\", \"over time\", \"improves\", \"learns\", \"adapts\". That phrasing means the claim is about a TRAJECTORY, so a step-wise `given X return Y` test does not test the claim that was made. #1952's AC4 read \"After N phases, the error is computed and applied as a correction to SUBSEQUENT estimates\" — the tell was in plain sight and was still tested as a point. Survey of this repo (2026-07): estimation calibration is the ONLY true instance; size/mutation ratchets are exempt because they fail on both growth AND shrinkage (cannot self-satisfy), and retry ladders (node_repair_budget, plan_bounce_passes, provider_escalation) terminate rather than feed back. Test anchor: tests/estimate-loop-convergence.test.cjs", - "line": 603 + "line": 606 }, { "id": "RULESET.TESTS.guard-toplevel-readFileSync", "klass": "RULESET", "value": "module-level const src = readFileSync(...) throws before any test() registers — wrap in try/catch in test() or use lazy load", - "line": 599 + "line": 602 }, { "id": "RULESET.TESTS.mutation-runner", "klass": "RULESET", "value": "Stryker executes every shard through the OFFICIAL @stryker-mutator/tap-runner (testRunner:'tap'), never the built-in 'command' runner (#3915); 'command' is the one runner Stryker excludes from coverage analysis, which forced coverageAnalysis:'off' and made cost strictly linear in (mutants x whole-shard test time) — the frontmatter shard measured 1751s on run 33021042847 vs 212s for the next slowest. tap.testFiles is injected per shard via MUTATION_TEST_FILES (mutation.yml env <- matrix.tests <- scripts/mutation-matrix.cjs buildResult); resolveMutationTestFiles is the SINGLE fail-closed reader and existence-checks every entry, because the tap runner's findTestyLookingFiles resolves the list with glob() and a non-matching pattern yields an EMPTY list SILENTLY (a fast, confident, meaningless run). tap.forceBail is FALSE by measurement, not preference: 3 of 26 shard test files spawn subprocesses (config-schema.property, core-utils, feat-3881-yaml-parser-consequences) and bail fires on every KILLED mutant, so leaving it on kills processes mid-spawnSync and orphans their children; Stryker's separate disableBail still skips remaining FILES, which is most of the win. tap.nodeArgs and top-level buildCommand stay UNSET so no rebuild lands between mutation and test (ADR-457). Coverage granularity is per FILE, not per test (\"a test is always a test file\"), so the #2790 excludeTests bans on spawn-heavy integration files remain necessary and unchanged", - "line": 612 + "line": 615 }, { "id": "RULESET.TESTS.mutation-score", "klass": "RULESET", "value": "Stryker runs incremental (--since origin/next) on ubuntu-latest/Node24 CI leg; default threshold 80% killed/total; surviving mutants in scope block merge unless path is listed in stryker.config.mjs with documented reason; treat surviving mutant as a failing test specification", - "line": 611 + "line": 614 }, { "id": "RULESET.TESTS.mutation-score-denominator", "klass": "RULESET", "value": "the gated number is mutation-testing-metrics' mutationScore = totalDetected/totalValid, which counts NoCoverage in the denominator EXACTLY as Survived; both Stryker's own thresholds.break (core dist/src/reporters/mutation-test-report-helper.js) and scripts/check-mutation-score-ratchet.cjs read THAT field, which is what makes the #3915 coverageAnalysis 'off'->'perTest' switch score-neutral. NEVER gate on mutationScoreBasedOnCoveredCode — it EXCLUDES NoCoverage and inflates sharply under perTest (measured on a synthetic report: 8 killed/2 survived = 80 and 80; 8 killed/2 noCoverage = 80 and 100), so swapping to the better-sounding field would make every minScore floor trivially satisfiable and the gate decorative. Under the pre-#3915 coverageAnalysis:'off' the two fields were ALWAYS identical (noCoverage was structurally 0), which is why nothing had ever pinned the choice; tests/mutation-score-ratchet.test.cjs now pins it with a non-vacuity assertion that the two numbers genuinely diverge", - "line": 613 + "line": 616 }, { "id": "RULESET.TESTS.no-dead-regex-in-includes", "klass": "RULESET", "value": "src.includes(\"foo.*bar\") is always false — .* is regex metacharacter not wildcard; use new RegExp(...).test(src) or delete", - "line": 598 + "line": 601 }, { "id": "RULESET.TESTS.no-duplicate-fold-marker", "klass": "RULESET", "value": "local/no-duplicate-fold-marker ESLint AST rule (eslint-rules/no-duplicate-fold-marker.cjs, #3271) reports the 2nd and every later __foldDescribe(\"folded: ...\") call carrying a marker already seen in the SAME file, naming the first occurrence's line; error in tests/**/*.cjs. The key is the WHITESPACE-delimited token after folded:, NOT a [a-z0-9-]* slice — a slice truncates at \".\" and collides feat-443-effort-fast-mode.integration with feat-443-effort-fast-mode (two distinct suites coexisting in tests/model-resolver.test.cjs), and NOT the whole title, so a re-fold under a different batch label (\"B1 #1970\" vs \"B5 #1975\") is still caught. Deliberately silent on: a __foldDescribe title with no folded: prefix (the alias is reused for one ordinary describe in tests/review-default-reviewers-workflow.test.cjs), a plain describe(), a non-literal title, and the same marker in two DIFFERENT files (the defect class is intra-file).", - "line": 594 + "line": 597 }, { "id": "RULESET.TESTS.no-duplicate-fold-marker.why", "klass": "RULESET", "value": "consolidation epic #1969 folds are self-contained blocks, so a second verbatim copy parses, registers and PASSES twice — nothing reports it; #3271 found 25 such copies (~5,800 lines) in tests/install.test.cjs (18), tests/install-minimal-hooks.test.cjs (5) and tests/install-write-confinement.test.cjs (2), all from one stale-base re-application in 6d072435d (#1975 re-applying #1970's hunks, 2026-07-03). Ref DEFECT.GENERATIVE-FIX: the two copies drift apart silently when a contributor fixes one and leaves the other asserting the old behavior, with the suite still green.", - "line": 595 + "line": 598 }, { "id": "RULESET.TESTS.no-source-grep", "klass": "RULESET", "value": "local/no-source-grep ESLint AST rule (eslint-rules/no-source-grep.cjs) rejects readFileSync of a source .cjs/.js/.ts path bound to a var later hit with .includes()/.match()/.startsWith()/.endsWith()/.indexOf()/.search(); error in tests/**/*.test.cjs, warn in gsd-core/bin/**/*.cjs + scripts/**/*.cjs (ADR 452 retired the old regex script, removed for good in #632)", - "line": 591 + "line": 594 }, { "id": "RULESET.TESTS.no-source-grep.exemption", "klass": "RULESET", "value": "// allow-test-rule: with one-line justification; reserved for tests where the file content IS the product surface (STATE.md, config.toml, hooks.json, agent .md). Migration to typed-IR parser tracked in #2974.", - "line": 592 + "line": 595 }, { "id": "RULESET.TESTS.no-source-grep.tmp-file-traps", "klass": "RULESET", "value": "reading tmp files written by the SUT in tests still trips lint; round-trip through CLI (e.g. frontmatter get) instead of readFileSync+.includes()", - "line": 593 + "line": 596 }, { "id": "RULESET.TESTS.no-timing-assertion", "klass": "RULESET", "value": "do not assert on wall-clock elapsed time (Date.now() delta, performance.now(), process.hrtime() comparison); such assertions test the host machine not the SUT and flake on loaded CI runners; enforcement: local/no-elapsed-assertion ESLint rule, error (promoted by #3331 once #3314 delivered the ADR-456 §(a) reachability rule + deterministic backfill precondition); canonical replacement: clock-seam pattern with node:test mock.timers", - "line": 608 + "line": 611 }, { "id": "RULESET.TESTS.property-based-testing", "klass": "RULESET", "value": "modules implementing parsing / transformation / budget-limit / bijective contracts must include at least one fast-check (fc) property test asserting a domain invariant; invariant categories: round-trip, monotonicity, boundary-containment, idempotency; property tests live in *.test.cjs alongside unit tests; CI signal: Stryker mutation score below 80% blocks merge", - "line": 610 + "line": 613 }, { "id": "RULESET.TRIAGE-EXISTING-WORK", "klass": "RULESET", "value": "before writing agent brief for confirmed bug, check (1) local branches git branch -a | grep , (2) untracked/modified files on that branch, (3) stash, (4) open PRs with matching head branch — recover existing work rather than re-implement", - "line": 637 + "line": 640 }, { "id": "RULESET.WORKFLOW.COVERAGE-METADATA", "klass": "RULESET", "value": "#1602 SUMMARY frontmatter `coverage:` block (list of {id,description,requirement?,verification:[{kind∈unit|integration|e2e|automated_ui|manual_procedural|other, ref, status∈pass|fail|unknown}],human_judgment:bool,rationale?}) is the per-deliverable RTM consumed DETERMINISTICALLY by verify-work extract_tests via `gsd-tools uat classify-coverage --summary ` (src/coverage.cts → bin/lib/coverage.cjs). AUTHORING: execute-plan create_summary populates it from task results; every deliverable MUST be classified; fail-safe default = human_judgment:true + rationale. CLASSIFY CONTRACT: auto-pass (skip human) ONLY when human_judgment===false (strict boolean) AND verification non-empty AND every status==='pass' AND zero validation errors — else PRESENT to human. mode:legacy (no block) ⇒ byte-identical prose `## Accomplishments` fall-through; `coverage: []` ⇒ mode:coverage, zero entries (single-confirmation). Frozen IR: MODE/PRESENT_REASON/ERROR_CODE enums locked by tests/coverage-metadata-parser.test.cjs. extractFrontmatter CANNOT parse it (scalars-only `-` items) → dedicated parser, sibling of parseMustHavesBlock. Asymmetry by design: false-negative=redundant prompt (status quo); false-positive=shipped bug UAT existed to catch", - "line": 626 + "line": 629 }, { "id": "RULESET.WORKFLOW_EXECUTE_END_TO_END", "klass": "RULESET", "value": "standard for single-workflow commands is \"Execute end-to-end.\" (no bolded **Follow the X workflow** fragments); flag-dispatch routing uses \"execute the X workflow end-to-end.\" in routing bullets — convention verified live across ~20 commands/gsd/*.md files; no ADR currently documents this specific phrasing rule (ADR-0002 covers the adjacent but distinct command-contract/@-ref-resolution seam, not this convention)", - "line": 625 + "line": 628 }, { "id": "RULESET.WORKFLOW_EXECUTION_CONTEXT", "klass": "RULESET", "value": "@-ref in commands/gsd/*.md must resolve to an existing file on disk; regression test in tests/docs-update.test.cjs (folds former \\`bug-3135-capture-backlog-workflow\\`, consolidation epic #1969); INVENTORY.md row + INVENTORY-MANIFEST.json families.workflows must stay in sync; \"Invoked by\" attribution must move when a flag absorbs a micro-skill", - "line": 624 + "line": 627 }, { "id": "RULESET.WORKFLOW_FILE_NAMES", "klass": "RULESET", "value": "workflow files use hyphens; XML attributes must match (extract-learnings not extract_learnings); tests should pin exact hyphenated name", - "line": 623 + "line": 626 }, { "id": "RULESET.WORKFLOW_MARKDOWN.FENCES", "klass": "RULESET", "value": "preserve opening language fence when editing shell snippets in workflow markdown; malformed fence creates fresh CR threads (MD040)", - "line": 619 + "line": 622 }, { "id": "RULESET.WORKFLOW_SIZE_BUDGET", "klass": "RULESET", - "value": "workflow size enforcement (#1074; BYTES not lines per #717; LF-normalized per #683) = differential attribution size ratchet (PRIMARY anti-creep since #2724/ADR-2719 §4: tests/emitted-attribution.test.cjs's real-tree test reports growth in any gsd-core/workflows/*.md with its exact byte delta vs `next`, no committed snapshot, requires an ack entry — a fragment under tests/emitted-drift-acks/, #2914; the legacy tests/emitted-drift-ack.json is still honored and unioned in) + loose tier hard caps (outer red lines, NEVER raised on approach: XL<=98304 / LARGE<=61440 / DEFAULT<=40960) + discuss-phase<32000; a file that grew fails the differential guard — add an ack entry naming the file and reason, justify the growth in the PR (or extract LAZILY-loaded content; eager @-imports don't reduce loaded context); crossing a hard cap means EXTRACT, not bump. The prior per-file baseline (tests/workflow-size-baseline.json, `npm run size:baseline`) is REMOVED by #2724. Its new-file cap (ADR-1610 Decision point 3, un-baselined files <=32768, the Codex anchor) is REVIVED inside the differential's size ratchet itself (`NEW_FILE_CAP` in tests/helpers/emitted-diff.cjs) rather than lost: \"not yet baselined\" is exactly \"present in sizeCurrent, absent from sizeBaseline\", a signal the ratchet already computes for its own reasons. NOT ack-able — same as the tier hard caps, the fix is extraction. Narrower than the original: this check cannot see XL/LARGE tiering (tests/workflow-size-budget.test.cjs's classification, invisible to the pure differential module), so a legitimately large NEW file must extract rather than tier in, one release earlier than an existing file would need to — a disclosed, deliberate simplification", - "line": 620 + "value": "workflow size enforcement (#1074; BYTES not lines per #717; LF-normalized per #683) = differential attribution size ratchet (PRIMARY anti-creep since #2724/ADR-2719 §4: tests/emitted-attribution.test.cjs's real-tree test reports growth in any gsd-core/workflows/*.md with its exact byte delta vs `next`, no committed snapshot, requires an `Emitted-Drift-Ack-Growth:` commit trailer on the PR's own commits (ADR-3942, superseding ADR-2719 §3's fragment model — key is the bare filename, reason follows ` — `)) + loose tier hard caps (outer red lines, NEVER raised on approach: XL<=98304 / LARGE<=61440 / DEFAULT<=40960) + discuss-phase<32000; a file that grew fails the differential guard — add an ack entry naming the file and reason, justify the growth in the PR (or extract LAZILY-loaded content; eager @-imports don't reduce loaded context); crossing a hard cap means EXTRACT, not bump. The prior per-file baseline (tests/workflow-size-baseline.json, `npm run size:baseline`) is REMOVED by #2724. Its new-file cap (ADR-1610 Decision point 3, un-baselined files <=32768, the Codex anchor) is REVIVED inside the differential's size ratchet itself (`NEW_FILE_CAP` in tests/helpers/emitted-diff.cjs) rather than lost: \"not yet baselined\" is exactly \"present in sizeCurrent, absent from sizeBaseline\", a signal the ratchet already computes for its own reasons. NOT ack-able — same as the tier hard caps, the fix is extraction. Narrower than the original: this check cannot see XL/LARGE tiering (tests/workflow-size-budget.test.cjs's classification, invisible to the pure differential module), so a legitimately large NEW file must extract rather than tier in, one release earlier than an existing file would need to — a disclosed, deliberate simplification", + "line": 623 }, { "id": "SESSION.2026-05-05", "klass": "SESSION", "value": "[PRED.k320..k331 introduced; DEFECT.SOURCE-GREP-IN-NEW-TESTS, DEFECT.CHANGESET-PR-FIELD-DRIFT, DEFECT.PHASE-DIR-PREFIX-DRIFT, DEFECT.PROMPT-INJECTION-SCAN-COLLISION; ADR-0002 thin-wrapper pattern findings folded into RULESET.WORKFLOW_*]", - "line": 888 + "line": 891 }, { "id": "SESSION.2026-05-05.sdk-bridge", "klass": "SESSION", "value": "PR #3158 SDK Runtime Bridge — observability isolation rule; strict-mode dispatchMode reporting invariant; transport decision ordering (guard before event emission); folded into Dispatch Policy Module glossary", - "line": 889 + "line": 892 }, { "id": "SESSION.2026-05-09", "klass": "SESSION", "value": "[8-PR triage wave, 7 merged + 1 subsumed; META.RULE.* introduced; WAVE.LESSON.* captured; k320/k322/k323/k326/k331 evidence; AI Ops Memory predicate format established]", - "line": 890 + "line": 893 }, { "id": "SESSION.2026-05-10", "klass": "SESSION", "value": "[ai-ops memory consolidation; release-notes standard taxonomy + templates; RELEASE-NOTES.* predicates introduced]", - "line": 891 + "line": 894 }, { "id": "SESSION.2026-05-13", "klass": "SESSION", "value": "[Shell Command Projection Module expansion (#3465-#3468); ADR-0009 superseded; new exports for subprocess dispatch and platform file I/O; phase-gated migration plan; PR #3464 three-gate invariant CI+CR+unresolved=0; PR #3470 stash-include-untracked rebase pattern]", - "line": 892 + "line": 895 }, { "id": "SESSION.2026-05-14", "klass": "SESSION", "value": "[#3095/PR #3490 EXEC.CLASSIFY.* introduced (Anthropic/Copilot/Codex/Gemini [runtime removed #1928] cross-runtime rate-limit sentinel coverage); #3489/PR #3499 DEFECT.STATE-TRAMPLE.idempotency-oracle (STATE.md current_phase field is oracle for state.complete-phase); #3488/PR #3501 DAG resolver same-phase short-form depends_on (shortFormToId index added to sdk/src/query/phase.ts); #3491/PR #3502 DEFECT.NESTED-GIT-INIT (gitWorktreeInfoInternal helper); #3493/PR #3500 extractCurrentMilestone generic Phase Details continuation past planned-milestone siblings; #3503/PR #3504 DEFECT.PATH-SUBSTRING-CHECK (trailing-slash anchor for homedir checks); #3346/PR #3505 codex AoT TOML leaf-key via extractFlatHookEventName; #3506/PR #3507 label-scoped stale-bot sub-job pattern; multi-PR triage operational lessons folded into PROC.TRIAGE.*; #3508 DEFECT.AGENT-ISOLATION-SILENT-FAIL; gsd-test image-missing auto-build (locally-built image via embedded heredoc Dockerfile); refined PRED.k322 threshold to 3 PRs/<10min]", - "line": 893 + "line": 896 }, { "id": "SESSION.2026-05-15", "klass": "SESSION", "value": "[#3537/PR #3538 DEFECT.PHASE-REGEX-FANOUT — phaseMarkdownRegexSource promoted to core.cjs and wired to 7 sites; parity-style regression test established as DEFECT.GENERATIVE-FIX exemplar; trek-e/gsd-test-runner#1 filed for DEFECT.GSD-TEST-MIRROR-POISONED — chown-back-before-exec legacy gap (poisoned holodeck mirror unstuck via authorized docker chown to remote 1000:1000); RULESET.PR-FLOW.* codified from project CLAUDE.md load-bearing rule; first dispatch under run-tests-before-create held cleanly (PR #3520 worker stopped on Docker exit 12 infra failure, orchestrator opened PR after unblock); CONTEXT.md refactored from 882 lines of mixed prose+predicates into ~500 lines of pure-predicate format with chronological session log]", - "line": 894 + "line": 897 }, { "id": "SESSION.2026-05-15.parallel-fix-dispatch", "klass": "SESSION", "value": "[#3542/PR #3546 prohibit git stash family in executor agents (shared refs/stash across worktrees); #3541/PR #3547 non-TTY resolution for installer prompt-user actions (default remove for SDK build artifacts, keep for skills/gsd-*/SKILL.md); #3545 filed for gsd-test-summary concurrent /tmp output collision; new predicates DEFECT.HOOK-OVER-ENFORCEMENT.read-tool-tracking, DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION, DEFECT.SUBAGENT-LONG-RUNNING-BG-STALL, DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT, PROC.PARALLEL-FIX-DISPATCH; agent-trust-but-verify caught /gsd-update retired-syntax comment slip in #3541 implementation before PR open]", - "line": 895 + "line": 898 }, { "id": "SESSION.2026-05-16", "klass": "SESSION", "value": "[multi-PR triage wave (#3577/3581/3640/3641/3642/3648/3649/3637/3639). Established global PreToolUse hook ~/.claude/hooks/test-memory-guard.sh denying new node/test spawns when sum(RSS of node|vitest|jest|...) >= 4 GiB on the 24 GB Mac OR when a same-runner process is already in argv[0] — hard deny via hookSpecificOutput.permissionDecision=deny. PR #3577 fix: revert config-ensure-section dispatch to CJS cmdConfigEnsureSection (SDK author wrote single-section semantics under a name whose legacy callers expect full-default config init); plus 3 SDK parity carve-outs (configNewProject defaults align with sdk/shared/config-defaults.manifest.json, return relative .planning/config.json path, drop quotes from Unknown config key, lead malformed-JSON error with \"Failed to read config.json:\"). PR #3649 fix: chunk node --test spawn at 28K argv ceiling (Windows CreateProcess lpCommandLine cap 32,767 was instantly aborting unchunked spawn of 546 paths). Chunking fix surfaced 14 pre-existing Windows-only test bugs (4010 pass / 14 fail; vs 0/0 before — entire suite was un-runnable on Windows). PRs #3639 + #3637 confirmed unable to stand alone (legitimately depend on Phase 6 scaffolding only present on feat/3575-enforcement-hardening) — user decision: cherry-pick into #3577 and close. Five other PRs each had ≤1 unresolved CR thread of the changeset-pr-number / null-vs-throw / implicit-Claude-runtime / docs-stale-guidance / hardcoded-tests-path family — all quick wins. New predicates: DEFECT.SDK-PORT-NAME-COLLISION, DEFECT.WINDOWS-ARGV-OVERFLOW, DEFECT.STACKED-PR-CANNOT-STAND-ALONE, DEFECT.CANARY-VERSION-LEAK, DEFECT.GSD-TEST-HOST-MID-RUN-DEATH, RULESET.HARNESS.test-memory-guard, RULESET.PR-FLOW.docker-before-push, RULESET.PR-FLOW.templates-mandatory]", - "line": 896 + "line": 899 }, { "id": "WAVE.LESSON.agent-narrative-unreliable", "klass": "WAVE", "value": "k095/k324 confirmed at scale: 5 of 8 agents terminated mid-monitor with stale claims requiring direct verification", - "line": 850 + "line": 853 }, { "id": "WAVE.LESSON.changelog-policy-violation-multiplier", "klass": "WAVE", "value": "brief contradicting CONTRIBUTING.md's changelog-fragment policy (\"CHANGELOG Entries — Drop a Fragment\" section) produced violations on 5 of 8 PRs (#3300, #3302, #3304, #3305, #3308); k326 + k320 capture", - "line": 847 + "line": 850 }, { "id": "WAVE.LESSON.cr-throttle-burst-correlation", "klass": "WAVE", "value": "8 PRs in <15min triggered k322 sustained-throttle on multiple PRs (#3306 worst case)", - "line": 848 + "line": 851 }, { "id": "WAVE.LESSON.k101-still-trips", "klass": "WAVE", "value": "even after CONTEXT.md k101 reinforcement, agent of record posted self-PR comment on close; k331 adds explicit close-time literal-instruction guard", - "line": 851 + "line": 854 }, { "id": "WAVE.LESSON.sibling-audit-overlap", "klass": "WAVE", "value": "k015-family parallel dispatch on #3297 + #3298 produced k323 add-backlog.md cross-PR overlap", - "line": 849 + "line": 852 }, { "id": "WORKSTREAM.INVARIANT.migrate-name", "klass": "WORKSTREAM", "value": "must normalize through canonical slug policy", - "line": 673 + "line": 676 }, { "id": "WORKSTREAM.INVARIANT.slug-contract", "klass": "WORKSTREAM", "value": "all .planning/workstreams/ must be addressable by set/get/status/complete", - "line": 674 + "line": 677 }, { "id": "WORKSTREAM.NAME.POLICY.cjs-module", "klass": "WORKSTREAM", "value": "gsd-core/bin/lib/workstream-name-policy.cjs owns toWorkstreamSlug + active-name/path-segment validation", - "line": 689 + "line": 692 }, { "id": "WORKSTREAM.POINTER.SEAM.cjs-module", "klass": "WORKSTREAM", "value": "gsd-core/bin/lib/active-workstream-store.cjs owns read/write self-heal for .planning/active-workstream", - "line": 690 + "line": 693 }, { "id": "WORKSTREAM.REGRESSION.test-anchor", "klass": "WORKSTREAM", "value": "tests/workstream.test.cjs::normalizes --migrate-name to a valid workstream slug", - "line": 675 + "line": 678 }, { "id": "WORKTREE.SEAM.caller-rule", "klass": "WORKTREE", "value": "verify.cjs must consume inspectWorktreeHealth for W017 classification; no ad-hoc porcelain parsing in callers", - "line": 683 + "line": 686 }, { "id": "WORKTREE.SEAM.current", "klass": "WORKTREE", "value": "Worktree Safety Policy Module", - "line": 667 + "line": 670 }, { "id": "WORKTREE.SEAM.decision-1", "klass": "WORKTREE", "value": "retain non-destructive default; destructive path only as explicit future opt-in scaffold", - "line": 671 + "line": 674 }, { "id": "WORKTREE.SEAM.default-prune-policy", "klass": "WORKTREE", "value": "metadata_prune_only (non-destructive)", - "line": 670 + "line": 673 }, { "id": "WORKTREE.SEAM.files", "klass": "WORKTREE", "value": "[gsd-core/bin/lib/worktree-safety.cjs]", - "line": 668 + "line": 671 }, { "id": "WORKTREE.SEAM.interface", "klass": "WORKTREE", "value": "[resolveWorktreeContext, parseWorktreePorcelain, planWorktreePrune, executeWorktreePrunePlan, planWorktreeRecordAgent, cmdWorktreeRecordAgent]", - "line": 669 + "line": 672 }, { "id": "WORKTREE.SEAM.invariant", "klass": "WORKTREE", "value": "parser failure must degrade to metadata_prune_only and never escalate to destructive removal", - "line": 681 + "line": 684 }, { "id": "WORKTREE.SEAM.inventory-interface", "klass": "WORKTREE", "value": "[listLinkedWorktreePaths, inspectWorktreeHealth]", - "line": 682 + "line": 685 }, { "id": "WORKTREE.SEAM.inventory-snapshot", "klass": "WORKTREE", "value": "snapshotWorktreeInventory(repoRoot,{staleAfterMs,nowMs}) is canonical linked-worktree health snapshot for callers", - "line": 685 + "line": 688 }, { "id": "WORKTREE.SEAM.test-anchor-w017", "klass": "WORKTREE", "value": "tests/orphan-worktree-detection.test.cjs + tests/worktree-safety.test.cjs", - "line": 684 + "line": 687 }, { "id": "WORKTREE.SEAM.test-anchors", "klass": "WORKTREE", "value": "[resolveWorktreeContext:has_local_planning|linked_worktree|not_git_repo|main_worktree, planWorktreePrune:git_list_failed|worktrees_present|no_worktrees|parser_throw_fallback, executeWorktreePrunePlan:missing_plan|skip_passthrough|unsupported_action|metadata_prune_only]", - "line": 680 + "line": 683 }, { "id": "WORKTREE.SEAM.test-policy", "klass": "WORKTREE", "value": "cover all decision branches in policy module before changing prune behavior", - "line": 679 + "line": 682 } ], "duplicates": [] diff --git a/package.json b/package.json index 635b5a8c4..f303d3f94 100644 --- a/package.json +++ b/package.json @@ -121,7 +121,7 @@ "lint:table-schema-drift": "node scripts/lint-table-schema-drift.cjs", "lint:frontmatter-scalar-broad-grep": "node scripts/lint-frontmatter-scalar-broad-grep.cjs", "lint:removed-but-needed": "node scripts/lint-removed-but-needed.cjs", - "lint:ci": "npm run lint && npm run lint:skill-deps && npm run lint:generated-sync && node scripts/lint-test-file-count.cjs && node scripts/lint-command-contract.cjs && node scripts/lint-pr-check-project-dir.cjs && npm run lint:legacy-name && node scripts/lint-regression-test-names.cjs && node scripts/lint-allow-test-rule-refs.cjs && node scripts/lint-resolution-provenance.cjs && node scripts/lint-emitted-drift-ack.cjs && node scripts/lint-portable-timeout.cjs && node scripts/validate-registry.cjs && node scripts/lint-table-schema-drift.cjs && node scripts/lint-fix-has-regression-tests.cjs && node scripts/lint-example-parser-parity.cjs && node scripts/lint-docs-command-form.cjs && node scripts/lint-plan-count-drift.cjs && node scripts/lint-milestone-window-drift.cjs && node scripts/lint-phase-enumeration-drift.cjs && node scripts/lint-planning-prompt-drift.cjs && node scripts/lint-unreachable-guard-drift.cjs && node scripts/lint-completion-ratio-drift.cjs && node scripts/lint-state-field-drift.cjs && node scripts/lint-state-write-path-drift.cjs && node scripts/lint-completion-predicate-drift.cjs && node scripts/lint-planning-snapshot-bypass-drift.cjs && node scripts/lint-health-diagnostic-rule-table.cjs && node scripts/lint-planning-artifact-writer-drift.cjs && node scripts/lint-frontmatter-scalar-broad-grep.cjs && node scripts/lint-removed-but-needed.cjs && node scripts/lint-no-adhoc-regex-escape.cjs && node scripts/lint-vendored-deps.cjs && node scripts/lint-docs-guard-registration.cjs && node scripts/lint-source-test-name-collision.cjs && npm run lint:hooks-runtime-build-seam && node scripts/check-contract-drift.cjs && node scripts/lint-mutation-test-derivation-drift.cjs", + "lint:ci": "npm run lint && npm run lint:skill-deps && npm run lint:generated-sync && node scripts/lint-test-file-count.cjs && node scripts/lint-command-contract.cjs && node scripts/lint-pr-check-project-dir.cjs && npm run lint:legacy-name && node scripts/lint-regression-test-names.cjs && node scripts/lint-allow-test-rule-refs.cjs && node scripts/lint-resolution-provenance.cjs && node scripts/lint-portable-timeout.cjs && node scripts/validate-registry.cjs && node scripts/lint-table-schema-drift.cjs && node scripts/lint-fix-has-regression-tests.cjs && node scripts/lint-example-parser-parity.cjs && node scripts/lint-docs-command-form.cjs && node scripts/lint-plan-count-drift.cjs && node scripts/lint-milestone-window-drift.cjs && node scripts/lint-phase-enumeration-drift.cjs && node scripts/lint-planning-prompt-drift.cjs && node scripts/lint-unreachable-guard-drift.cjs && node scripts/lint-completion-ratio-drift.cjs && node scripts/lint-state-field-drift.cjs && node scripts/lint-state-write-path-drift.cjs && node scripts/lint-completion-predicate-drift.cjs && node scripts/lint-planning-snapshot-bypass-drift.cjs && node scripts/lint-health-diagnostic-rule-table.cjs && node scripts/lint-planning-artifact-writer-drift.cjs && node scripts/lint-frontmatter-scalar-broad-grep.cjs && node scripts/lint-removed-but-needed.cjs && node scripts/lint-no-adhoc-regex-escape.cjs && node scripts/lint-vendored-deps.cjs && node scripts/lint-docs-guard-registration.cjs && node scripts/lint-source-test-name-collision.cjs && npm run lint:hooks-runtime-build-seam && node scripts/check-contract-drift.cjs && node scripts/lint-mutation-test-derivation-drift.cjs", "lint:allow-test-rule-refs": "node scripts/lint-allow-test-rule-refs.cjs", "lint:regression-names": "node scripts/lint-regression-test-names.cjs", "lint:descriptions": "node scripts/lint-descriptions.cjs", diff --git a/scripts/lint-emitted-drift-ack.cjs b/scripts/lint-emitted-drift-ack.cjs deleted file mode 100644 index 0962c8b5a..000000000 --- a/scripts/lint-emitted-drift-ack.cjs +++ /dev/null @@ -1,936 +0,0 @@ -#!/usr/bin/env node -'use strict'; - -/** - * lint-emitted-drift-ack — refuse to merge a broken emitted-drift acknowledgment (#2789). - * - * The ack document is read from TWO sides: the working tree (`readAckFile`) and the BASE - * REF (`readAckFileAtRef`), because an entry already present at the base is spent and may - * no longer clear a delta. The base-side read fails LOUDLY on a document it cannot parse - * — it has to, since silently inheriting nothing would leave every entry able to consume - * a delta, which is the pre-#2789 gate. - * - * That makes a corrupt document ON THE BASE BRANCH unusually expensive: it reds every PR - * that carries an ack until someone repairs it. The real-tree test avoids an outright - * deadlock (a tree with no ack never consults the base, so the repair PR still lands), - * but the cheaper answer is to never let a broken document reach the base at all. This - * runs in `lint:ci`, so a PR carrying one cannot go green and cannot merge. - * - * This validator is DELIBERATELY STANDALONE. `scripts/` ships in the npm package and - * `tests/` does not (package.json `files`), so requiring the gate's own `parseAck` from - * here would be a MODULE_NOT_FOUND in the published package. The duplication is bounded - * by a parity test — `tests/emitted-attribution.test.cjs` runs both surfaces over one - * corpus and fails if they ever disagree about what is schema-valid. - * - * #2914: the single shared `tests/emitted-drift-ack.json` is replaced by per-PR - * fragments under `tests/emitted-drift-acks/` (kept alongside the legacy file, which is - * still honored). This validator now checks BOTH: every physical source is run through - * the same schema/policy rules below, and — because two sources are never allowed to - * name the same path (silent last-wins would resurrect exactly the silent-drift class - * the ack seam exists to end) — a cross-source duplicate key is ALSO a hard failure. - */ - -const fs = require('node:fs'); -const path = require('node:path'); -const { execFileSync } = require('node:child_process'); - -const ACK_VERSION = 1; -const ACK_REPO_PATH = 'tests/emitted-drift-ack.json'; -const ACK_DIR_REPO_PATH = 'tests/emitted-drift-acks'; -const REPO_ROOT = path.join(__dirname, '..'); - -/** - * Upper bound on any one git call made by the `--guard-next` lane (#3078). - * - * Every subprocess this repo spawns is bounded (CLAUDE.md -> KNOWN DEFECTS, "Unbounded - * Subprocesses": 5-30s for git). The guard reads one directory listing plus one blob per - * surviving fragment, all against local objects, so 15s is generous — but an unbounded - * `execFileSync` on a wedged git is an indefinite hang in a job whose whole timeout - * budget is one minute. - */ -const GIT_TIMEOUT_MS = 15_000; - -/** - * Characters that render as nothing: soft hyphen, the zero-width family, word joiner, - * BOM. Stripped before ack reasons are compared, so an invisible edit cannot make a spent - * acknowledgment look re-armed. - * - * DUPLICATED from `INVISIBLE` in `tests/helpers/emitted-diff.cjs`, for the same reason - * every other constant here is duplicated rather than required: `scripts/` ships in the - * npm package and `tests/` does not, so the require would be MODULE_NOT_FOUND once - * published (see this file's top-of-file comment). The two are held together by the - * prose-parity test in `tests/emitted-attribution.test.cjs`, which enumerates the gate's - * own codepoints and fails if this list stops covering them. - * - * Spelled as codepoints on purpose — a literal character class here would itself be - * invisible in review, which is the exact failure being defended against. - */ -const ACK_INVISIBLE = new RegExp( - `[${[0x00AD, 0x200B, 0x200C, 0x200D, 0x2060, 0xFEFF] - .map((c) => `\\u${c.toString(16).toUpperCase().padStart(4, '0')}`) - .join('')}]`, - 'g', -); - -/** - * Upper bound on how many fragment files `listFragmentFiles` may return in one - * `readdirSync` pass. Mirrors `MAX_ACK_FRAGMENTS` in `tests/helpers/emitted-diff.cjs` — - * DUPLICATED rather than imported, because `scripts/` ships in the npm package and - * `tests/` does not (requiring across that line would be MODULE_NOT_FOUND once - * published; see this file's top-of-file comment). The two are held to the same value - * by the schema-parity test in `tests/emitted-attribution.test.cjs`. - * - * Exceeding it throws rather than truncating: a truncated listing would silently drop - * acknowledgments from consideration, which is exactly the class of silent failure this - * whole ack seam exists to prevent. - */ -const MAX_ACK_FRAGMENTS = 500; - -const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v); - -/** - * Key names that can never be a legitimate emitted path or bare workflow/agent filename - * (`__proto__`, `constructor`, `prototype`). Duplicated (not imported) in - * `tests/helpers/emitted-diff.cjs`'s `parseAck` for the same reason every other constant - * here is duplicated rather than required — `scripts/` ships, `tests/` does not. Held to - * the same set by the schema-parity test in `tests/emitted-attribution.test.cjs`, which - * must see BOTH surfaces reject a document naming one of these, never one silently - * accepting what the other errors on (#2914 review). - */ -const RESERVED_ACK_KEYS = new Set(['__proto__', 'constructor', 'prototype']); - -/** - * Validate an ack document's raw text. - * - * `schemaErrors` are the ones that must agree with the gate's `parseAck` — the shape - * contract. `policyErrors` are lint-only rules that `parseAck` deliberately does NOT - * enforce, because they are about what may be COMMITTED rather than what may be parsed: - * a present-but-entryless document parses fine and signals nothing, so it must be deleted - * rather than left behind. - * - * @param {string|null} raw file contents, or null when the file is absent - * @param {object} [opts] - * @param {string} [opts.source] the path used in error messages (default: the legacy - * file). Generalized (#2914) so the same rules apply verbatim to a fragment under - * `ACK_DIR_REPO_PATH` — one definition of "valid", named per the file it is checking. - * @returns {{ schemaErrors: string[], policyErrors: string[], ok: boolean }} - */ -function validateAckText(raw, { source = ACK_REPO_PATH } = {}) { - const schemaErrors = []; - const policyErrors = []; - const done = () => ({ schemaErrors, policyErrors, ok: schemaErrors.length === 0 && policyErrors.length === 0 }); - - if (raw === null) return done(); // absent is the healthy steady state - - if (raw.trim() === '') { - schemaErrors.push(`${source} is present but empty`); - return done(); - } - - let doc; - try { - doc = JSON.parse(raw); - } catch (err) { - schemaErrors.push(`${source} is not valid JSON: ${err.message}`); - return done(); - } - - // A document that is literally `null` is POLICY, not schema. The gate's `parseAck` uses - // `null` as its "absent == no acks" sentinel, so it reads such a file as legal and - // harmless — and the parity test holds us to that. It is still not something to commit: - // it declares nothing, so the remedy is the same as an entryless document. - if (doc === null) { - policyErrors.push( - `${source} contains "null" and declares no acknowledgments. Delete the file — ` - + 'the healthy steady state is no file at all.', - ); - return done(); - } - - if (!isPlainObject(doc)) { - schemaErrors.push( - `${source}: must be a JSON object, got ${Array.isArray(doc) ? 'array' : typeof doc}`, - ); - return done(); - } - - if (doc.version !== undefined && doc.version !== ACK_VERSION) { - schemaErrors.push( - `${source}: unsupported version ${JSON.stringify(doc.version)} (expected ${ACK_VERSION})`, - ); - } - - const paths = doc.paths; - if (paths !== undefined && !isPlainObject(paths)) { - schemaErrors.push(`${source}: "paths" must be an object of -> { reason }`); - return done(); - } - - const entries = paths === undefined ? [] : Object.entries(paths); - for (const [rel, value] of entries) { - if (RESERVED_ACK_KEYS.has(rel)) { - // Reject loudly rather than silently filter. Previously this key was excluded - // only from `declaredKeys`'s duplicate-detection view, so a document naming it - // passed validation here while the gate's `parseAck` (fed the JSON.parse'd - // document, where such a key is a genuine own property) either mishandled it or - // disagreed silently — two surfaces reaching different verdicts on the same - // document (#2914 review). Recognizably the same finding as `parseAck`'s. - schemaErrors.push( - `${source}: ack key "${rel}" is reserved and can never be a valid emitted path ` - + 'or workflow/agent filename — remove it', - ); - continue; - } - const reason = isPlainObject(value) ? value.reason : value; - if (typeof reason !== 'string' || reason.trim() === '') { - schemaErrors.push(`${source}: ack for "${rel}" has no non-empty "reason"`); - } - } - - // Lint-only. An entryless document parses cleanly and acknowledges nothing, so it is - // pure confusion on the base branch — and it is exactly what a contributor leaves - // behind after removing the last entry by hand. - if (entries.length === 0) { - policyErrors.push( - `${source} is present but declares no acknowledgments. Delete the file — an ` - + 'empty one signals nothing, and the healthy steady state is no file at all.', - ); - } - - return done(); -} - -function readIfPresent(file) { - return fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null; -} - -/** - * Fragment filenames under `dir`, sorted. Absent directory == zero fragments. - * - * Fails loudly, naming `dir`, the cap, and the actual count, when the directory holds - * more than `MAX_ACK_FRAGMENTS` entries — never silently truncates the listing. - */ -function listFragmentFiles(dir) { - if (!fs.existsSync(dir)) return []; - const names = fs.readdirSync(dir).filter((name) => name.endsWith('.json')).sort(); - if (names.length > MAX_ACK_FRAGMENTS) { - throw new Error( - `lint-emitted-drift-ack: ${dir} contains ${names.length} ack fragments, exceeding ` - + `the cap of ${MAX_ACK_FRAGMENTS}. Refusing to read only some of them — a truncated ` - + 'read would silently drop acknowledgments. Prune spent fragments from this directory.', - ); - } - return names; -} - -/** - * The path keys a document declares, for cross-source collision detection — but ONLY - * when the document is itself trustworthy. A document that failed its own schema check - * must not also seed a bogus "collision" derived from garbage; its own error already - * blocks the merge, and reporting a fabricated collision on top would confuse rather - * than clarify. `RESERVED_ACK_KEYS` are also excluded here — they can never be a - * legitimate duplicate, since they can never be a legitimate key at all — but this is - * belt-and-suspenders, not the enforcement point: `validateAckText` above now rejects any - * document naming one outright, so `main()` only ever calls this on a document whose - * schema already checked out, making the exclusion below unreachable in practice. - */ -function declaredKeys(raw) { - if (raw === null) return []; - let doc; - try { - doc = JSON.parse(raw); - } catch { - return []; - } - if (!isPlainObject(doc)) return []; - const paths = doc.paths; - if (paths === undefined) return []; - if (!isPlainObject(paths)) return []; - return Object.keys(paths).filter((k) => !RESERVED_ACK_KEYS.has(k)); -} - -/** - * Normalize an ack reason to the prose a reviewer actually reads. - * - * Mirrors the gate's own `prose()` inside `diffEmitted` (`tests/helpers/emitted-diff.cjs`) - * exactly: strip invisibles, collapse internal whitespace, trim. Re-arming a spent ack is - * legitimate — it is how a contributor says "this is a NEW ripple, and here is why" — but - * it must cost an ACTUAL explanation, so a doubled space, a CRLF, or a U+200B may never - * make a spent entry look live. Bounded by the parity test named on ACK_INVISIBLE. - */ -function ackProse(reason) { - return reason.replace(ACK_INVISIBLE, '').replace(/\s+/g, ' ').trim(); -} - -/** - * The declared entries of one ack document as `path key -> normalized prose`, or `null` - * when the document cannot be trusted to answer the question. - * - * `null` is NOT "no entries" — it is "do not draw a conclusion from this file". A document - * that will not parse, is not an object, has a non-object `paths`, carries a reasonless or - * non-string entry, or names a RESERVED_ACK_KEY cannot be shown to be spent, and the - * conservative direction here is to leave it alone: `validateAckText` (run by `lint:ci`, - * pre-merge) owns SHAPE and already blocks such a document from reaching the base, while - * this guard owns LIFECYCLE. Sweeping a file we could not read would delete an - * acknowledgment on the strength of a parse failure. - * - * An ABSENT document (`raw === null`) is a genuine empty entry set — the fragment simply - * did not exist at that ref, so nothing it declares now has a counterpart there. - */ -function ackEntries(raw) { - if (raw === null) return new Map(); - let doc; - try { - doc = JSON.parse(raw); - } catch { - return null; - } - if (!isPlainObject(doc)) return null; - const paths = doc.paths; - if (paths === undefined) return new Map(); - if (!isPlainObject(paths)) return null; - - const entries = new Map(); - for (const [rel, value] of Object.entries(paths)) { - if (RESERVED_ACK_KEYS.has(rel)) return null; - const reason = isPlainObject(value) ? value.reason : value; - if (typeof reason !== 'string') return null; - entries.set(rel, ackProse(reason)); - } - return entries; -} - -/** - * assertNoAllSpentFragments — the fragment half of the `next`-lane guard (#3078). - * - * #2914 split the single shared ack file into per-PR fragments and deliberately exempted - * the fragment directory from `assertAbsentOnNext`, on the premise that a persistent - * fragment "cannot conflict with any other PR". That premise does not hold. Fragments do - * not share a FILE, but they do share a PATH KEY SPACE, and `main()` below treats a path - * claimed by two sources as a hard failure. So a merged fragment is not harmless: every - * entry it leaves on `next` is spent by definition — its prose is already at the base, so - * it gates nothing — while still owning its key, and the next PR that grows the same - * workflow can declare it neither in the owning fragment (spent) nor in its own - * (duplicate). That is the exact failure #2914 fixed for the legacy file, reintroduced one - * level down. Measured on `next` when this landed: 45 fragments owning 403 paths. - * - * Unlike the legacy file, PRESENCE alone is not the failure — a fragment landed by the - * very push being guarded is the healthy case for every ack-carrying PR. The failure is - * INERTNESS: every surviving entry's prose already matches the copy at the base ref, so - * the fragment can no longer clear a delta for anyone. A PARTIALLY spent fragment is left - * alone; only an entirely inert one is cruft. That distinction is why the base side is - * required, and it is what keeps the re-arm-by-appending route working (#2639, #2993). - * - * An ENTRYLESS document is vacuously all-spent and swept for the same reason - * `validateAckText` refuses to let one be committed: it acknowledges nothing. - * - * Pure — no fs, no git, no clock. `main()` does the reading. - * - * #3842: an all-spent fragment is not automatically safe to sweep. #3078's sweep deletes - * the fragment outright, and when an OPEN PR still modifies that same file, git reports a - * `modify/delete` conflict on the very next merge attempt — exactly the shared-file - * conflict fragments were adopted (#2914) to end, reintroduced by the sweep itself. Three - * outside-contributor PRs (#3330, #3774, #3648) hit this simultaneously the first time the - * sweep ran, each with the swept fragment as its ONLY conflicting path. `openPrTouchedPaths` - * lets a caller defer sweeping any fragment an open PR still touches, without changing the - * inertness rule itself: a held fragment is still reported (informationally, never as a - * failure) so it is not silently forgotten once the touching PR merges or closes. - * - * @param {Array<{name: string, currentRaw: string|null, baseRaw: string|null}>} fragments - * @param {object} [opts] - * @param {Set|'unknown'} [opts.openPrTouchedPaths] repo-relative fragment paths - * (`${ACK_DIR_REPO_PATH}/`) that at least one OPEN pull request currently modifies. - * Omit entirely to skip the open-PR distinction altogether (every all-spent fragment is - * reported as sweepable, unchanged pre-#3842 behavior — the shape every existing caller - * and test relies on). Pass the literal string `'unknown'` when the open-PR set could not - * be determined (e.g. the `gh` lookup failed): every otherwise-sweepable fragment is held - * rather than swept, since "we could not check" must never collapse to "assume it is safe". - * @returns {{ ok: boolean, message: string, sweepable: string[] }} - */ -function assertNoAllSpentFragments(fragments, { openPrTouchedPaths } = {}) { - const allSpentFragments = []; - - for (const { name, currentRaw, baseRaw } of fragments) { - const current = ackEntries(currentRaw); - if (current === null) continue; // unreadable — `validateAckText` owns that verdict - const base = ackEntries(baseRaw); - if (base === null) continue; - - const allSpent = [...current].every(([rel, prose]) => base.get(rel) === prose); - if (allSpent) allSpentFragments.push({ name, entries: current.size }); - } - - const holdAll = openPrTouchedPaths === 'unknown'; - const touched = openPrTouchedPaths instanceof Set ? openPrTouchedPaths : new Set(); - const toSweep = []; - const held = []; - for (const frag of allSpentFragments) { - (holdAll || touched.has(`${ACK_DIR_REPO_PATH}/${frag.name}`) ? held : toSweep).push(frag); - } - - const heldLines = held.length === 0 ? [] : [ - '', - holdAll - ? 'deferred (open-PR check unavailable): whether an open PR still touches the following ' - + 'all-spent fragment(s) could not be determined this run, so none of them were swept ' - + '(#3842) — assuming "safe to sweep" on a failed check would risk the exact conflict ' - + 'this deferral exists to avoid. They will be reconsidered on a later run.' - : `deferred: ${held.length} all-spent fragment(s) are held back because an open pull ` - + 'request still touches them (#3842). Sweeping one now would hand that PR a ' - + 'modify/delete conflict it did not cause — the same failure #2914 adopted fragments ' - + 'to end. They will be swept once the touching PR merges or closes.', - ...held.map( - ({ name, entries }) => ` - ${ACK_DIR_REPO_PATH}/${name} (${entries} entr${entries === 1 ? 'y' : 'ies'}, all spent, held)`, - ), - ]; - - if (toSweep.length === 0) { - return { - ok: true, - message: [ - `ok guard-no-ack-on-next: no all-spent fragment survives in ${ACK_DIR_REPO_PATH}/`, - ...heldLines, - ].join('\n'), - sweepable: [], - }; - } - - const lines = toSweep.map( - ({ name, entries }) => ` - ${ACK_DIR_REPO_PATH}/${name} (${entries} entr${entries === 1 ? 'y' : 'ies'}, all spent)` - + `\n remedy: git rm ${ACK_DIR_REPO_PATH}/${name}`, - ); - - return { - ok: false, - sweepable: toSweep.map(({ name }) => name), - message: [ - `guard-no-ack-on-next: ${toSweep.length} fully-spent ack fragment(s) survive on next.`, - '', - ...lines, - '', - 'Every entry in these fragments is already at the base, so each is spent and gates ' - + 'nothing (#2789) — but it still OWNS its path keys. The next PR that grows one of ' - + 'those paths can declare it neither here (spent) nor in its own fragment (a ' - + 'duplicate ack is a hard failure), so a spent fragment left behind is a wall, not ' - + 'harmless cruft (#3078).', - '', - 'A partially spent fragment is deliberately NOT reported: only an entirely inert one ' - + 'is swept, so appending prose to a live entry to re-arm it keeps working.', - ...heldLines, - ].join('\n'), - }; -} - -/** - * assertAbsentOnNext — the `next`-lane guard (#2914), invoked only by the - * `guard-no-ack-on-next` workflow job on push to `next`, never in `lint:ci`. - * - * `validateAckText` lints SHAPE, because a PR's own working tree may legitimately carry - * a live, well-formed ack — that is the normal case a PR-lane check must allow. This - * function instead rejects PRESENCE outright, valid or not: per the ack-lifecycle law - * (#2789, `RULESET.EMITTED_ATTRIBUTION`), an entry already at the base is spent the - * moment it merges, so a document surviving on `next` is inert cruft by definition, not - * a thing to schema-check. - * - * This MUST NOT run as a PR-lane check comparing a PR against `next` — that is the #2768 - * shape #2789 exists to prevent (a spent-but-present base ack would red every open PR the - * instant one landed). It is safe only because it runs on `next` itself, asserting a fact - * about `next`'s own tree, never about any PR's diff against it. - * - * Scoped to the LEGACY FILE ONLY — the fragment directory is guarded by - * `assertNoAllSpentFragments` above, on a stricter-to-state but weaker-to-apply rule - * (inertness, not presence). #3078 corrected the original premise that a persisting - * fragment "cannot conflict with any other PR": fragments share a path key space even - * though they do not share a file. - * - * @param {boolean} present whether ACK_REPO_PATH exists in the tree being checked - * @returns {{ ok: boolean, message: string }} - */ -function assertAbsentOnNext(present) { - if (!present) { - return { ok: true, message: `ok guard-no-ack-on-next: ${ACK_REPO_PATH} is absent (the healthy steady state)` }; - } - return { - ok: false, - message: [ - `guard-no-ack-on-next: ${ACK_REPO_PATH} exists on next.`, - '', - 'Every entry in this file is scoped to the diff that introduced it (#2789). Once merged ' - + 'to next it is, by definition, already at the base -- spent and inert, regardless of ' - + 'whether it is otherwise well-formed.', - '', - '#2914: acks now go in per-PR fragments under tests/emitted-drift-acks/, one file per ' - + 'PR, never this single shared file. A fragment cannot MERGE-CONFLICT with another ' - + "PR's fragment, but it does own its path keys, so a fully-spent one left on next " - + 'still blocks the next PR that grows the same path (#3078) -- fragments are guarded ' - + 'separately, on inertness rather than on presence.', - '', - 'CONTRIBUTING.md: "When you remove the last entry from tests/emitted-drift-ack.json, ' - + 'delete the file too -- its presence is the alarm."', - '', - `Remedy: git rm ${ACK_REPO_PATH}`, - ].join('\n'), - }; -} - -/** - * Every git call declares the SPECIFIC directory it operates on as safe, mirroring - * `safeDirArgs` in `tests/helpers/emitted-runtime.cjs` (#2767): a checkout mounted at a - * path owned by a different uid makes git refuse EVERY operation there with "detected - * dubious ownership", and this guard's whole value is that it fails LOUDLY on a real - * fault rather than degrading to "no base, nothing spent". Never the `*` wildcard, which - * would mark every repository on the machine safe. Duplicated rather than imported for - * the reason stated at the top of this file: `scripts/` ships in the npm package and - * `tests/` does not. - */ -function git(args, { cwd = REPO_ROOT } = {}) { - return execFileSync('git', ['-c', `safe.directory=${path.resolve(cwd)}`, ...args], { - cwd, - encoding: 'utf8', - timeout: GIT_TIMEOUT_MS, - maxBuffer: 16 * 1024 * 1024, - stdio: ['ignore', 'pipe', 'pipe'], - }); -} - -/** - * The LOCAL/MANUAL fallback for "the commit `next` was at BEFORE this push" — `HEAD^`, - * or `null` on a root commit. Correct only when the push it is standing in for carries - * exactly one commit. - * - * CI never relies on this: it passes the authoritative pre-push tip explicitly via - * `--base-ref` (`github.event.before`, wired in `.github/workflows/test.yml`), because - * the default branch's ruleset allows REBASE merges - * (`.github/rulesets/main-protection.json`, `allowed_merge_methods`), so a single push - * event can land N commits at once. `HEAD^` steps back exactly one commit — for a - * 2-commit rebase-merge whose first commit adds a fragment and whose second is - * unrelated, `HEAD^` would land on the first commit, read the fragment as already - * present there, and demand `git rm` on the very push that introduced it (#3078). This - * function exists purely as the manual-run / single-commit-push fallback. - * - * Two steps on purpose. `HEAD` is resolved first, which proves git runs and the working - * directory is a readable repository; only then is a failure to resolve `HEAD^` read as - * "this commit has no parent". A single blanket try/catch would collapse "git is broken" - * into "there is no base", and a guard with no base sweeps nothing — it would pass - * vacuously, which is precisely how the legacy-file job spent months guarding a file that - * had not existed since #2914 (#3078). - */ -function resolveBaseRef({ cwd = REPO_ROOT, run = git } = {}) { - run(['rev-parse', '--verify', 'HEAD'], { cwd }); - try { - return run(['rev-parse', '--verify', 'HEAD^'], { cwd }).trim(); - } catch { - return null; // root commit — nothing can be spent against it - } -} - -/** - * Raw text of one ack fragment AT `base`, or `null` when it is simply not there. - * - * Mirrors `readAckFileAtRef` in `tests/helpers/emitted-runtime.cjs` (duplicated across the - * ships/does-not-ship line, as everything else here is): `git show` alone cannot tell a - * bogus ref from an absent path — both say "does not exist in" — so absence is established - * with `ls-tree`, which exits 0 with empty output when the path is not there and non-zero - * on a real fault. A genuine git failure THROWS rather than degrading to `null`, because - * "could not read the base" read as "absent at the base" would make every fragment look - * brand-new and silently disarm the sweep. - */ -function readFragmentAtRef(base, name, { cwd = REPO_ROOT, run = git } = {}) { - const repoPath = `${ACK_DIR_REPO_PATH}/${name}`; - const listing = run(['ls-tree', '--name-only', base, '--', repoPath], { cwd }); - if (listing.trim() === '') return null; - return run(['show', `${base}:${repoPath}`], { cwd }); -} - -/** - * A base ref must not begin with `-`: `execFileSync`'s array form stops shell - * metacharacters but not git's own option parsing, and `git show` honors diff options - * including `--output=`, which WRITES. Same guard, same reason, as - * `readAckFileAtRef`'s. - */ -function assertUsableBaseRef(ref) { - if (typeof ref !== 'string' || ref === '' || ref.startsWith('-')) { - throw new Error( - `lint-emitted-drift-ack: refusing to read fragments at ${JSON.stringify(ref)} — a base ` - + 'ref must be a non-empty string that does not begin with "-", which git would parse ' - + 'as an option.', - ); - } - return ref; -} - -/** - * Upper bound on how many OPEN pull requests `fetchOpenPrTouchedAckPaths` may reason - * about in one run. Mirrors the shape of `MAX_ACK_FRAGMENTS` above: exceeding it throws - * rather than silently reasoning about a truncated list — a truncated open-PR set would - * make an actually-touched fragment look untouched and sweep it anyway, which is the - * exact failure #3842 exists to prevent. 200 is comfortably above this repo's open-PR - * count at any point observed to date. - */ -const MAX_OPEN_PRS = 200; - -/** Bound on the `gh pr list` call `fetchOpenPrTouchedAckPaths` makes (#3842). */ -const GH_TIMEOUT_MS = 20_000; - -/** - * Upper bound on how many files `gh pr list --json files` reports for ANY ONE - * pull request. gh requests a single page of GitHub's file connection, so a PR - * touching more than this comes back SILENTLY TRUNCATED — the response carries - * no "there is more" flag. - * - * Measured against PR #3848 (2026-08-25): 124 files changed, 100 returned, and - * ZERO of the returned paths under `tests/`, because the list stops mid - * `gsd-core/workflows/` which sorts before it. Every ack fragment in that PR was - * invisible to the filter below — so the sweep would have deleted it and handed - * the PR a modify/delete conflict, the exact failure #3842 exists to prevent, - * one level down from the MAX_OPEN_PRS guard that already states this reasoning. - * - * Reachable rather than theoretical: the launcher preamble is inlined into 113 - * shipped files, so every preamble change is a >100-file PR, and those are among - * the likeliest to carry an ack fragment. - */ -const MAX_PR_FILES = 100; - -/** - * GitHub will not enumerate more than this many files for one pull request on - * ANY route, paginated included. A PR past it cannot be answered completely, so - * it throws rather than returning a set quietly missing paths — the same rule, - * and the same reason, as MAX_OPEN_PRS. - */ -const GITHUB_MAX_PR_FILES = 3000; - -/** - * Default `execGh` for `fetchOpenPrTouchedAckPaths` — a real `gh` invocation. Kept as a - * separate, swappable function (rather than inlined) so tests can inject a stub instead - * of shelling out to a real, authenticated `gh` — which is unavailable, and would be - * flaky and network-dependent, in the test sandbox. - */ -function execGhDefault(args, { cwd = REPO_ROOT } = {}) { - return execFileSync('gh', args, { - cwd, - encoding: 'utf8', - timeout: GH_TIMEOUT_MS, - maxBuffer: 16 * 1024 * 1024, - stdio: ['ignore', 'pipe', 'pipe'], - }); -} - -/** - * Every changed path for ONE pull request, via the paginated REST endpoint. - * - * `gh pr list --json files` and `gh pr view --json files` both cap at - * MAX_PR_FILES; only `gh api … --paginate` walks the whole set. Verified against - * PR #3848: 124 paths, including the two under `tests/` that the capped route - * dropped. `{owner}` and `{repo}` are gh's own placeholders, resolved from the - * repository in `cwd`, so this needs no nwo plumbing. - * - * Throws on anything it cannot answer completely — the caller turns that into - * the `'unknown'` sentinel that holds every fragment, which is the whole - * fail-closed contract of this seam. - */ -function fetchPrFiles(number, { cwd = REPO_ROOT, execGh = execGhDefault } = {}) { - const stdout = execGh( - ['api', `repos/{owner}/{repo}/pulls/${number}/files`, '--paginate', '--jq', '.[].filename'], - { cwd }, - ); - const files = String(stdout) - .split('\n') - .map((line) => line.trim()) - .filter((line) => line !== ''); - if (files.length >= GITHUB_MAX_PR_FILES) { - throw new Error( - `fetchPrFiles: pull request #${number} reported ${files.length} files, at or above ` - + `GitHub's own per-PR ceiling of ${GITHUB_MAX_PR_FILES}. Refusing to reason about a list ` - + 'that may still be incomplete.', - ); - } - return files; -} - -/** - * The set of `tests/emitted-drift-acks/*.json` repo-relative paths touched by at least - * one currently-OPEN pull request (#3842). - * - * One `gh` call — `gh pr list --json number,files` returns every open PR's changed-file - * list in a single round trip, never one call per PR — intersected against the fragment - * directory prefix. This is the "one `gh` API call" the issue itself proposes: cheap - * enough to run on every push to `next` without meaningfully growing the guard job's - * budget. A PR whose file list lands AT `MAX_PR_FILES` earns exactly one additional - * paginated `gh api` call to re-fetch its full list (#3842) — because `gh pr list` - * silently truncates there, "at the cap" and "truncated" are indistinguishable from - * this response alone, so every such PR must be re-checked rather than trusted. - * - * Throws (never degrades to an empty set) when: `gh` itself fails (auth, network, rate - * limit), the output is not parseable JSON, is not an array, or reaches the `MAX_OPEN_PRS` - * cap — a truncated or unreadable answer must never be silently read as "no open PR - * touches anything", which would defeat the entire deferral this function exists to - * support. The caller (`main()`) decides what "we could not check" means for the sweep; - * this function's only job is to never fabricate an empty answer. - * - * @param {object} [opts] - * @param {string} [opts.cwd] - * @param {(args: string[], opts: {cwd: string}) => string} [opts.execGh] injectable `gh` - * runner, defaulting to a real bounded `execFileSync` call. Tests inject a stub here - * rather than exec'ing a real, authenticated `gh` binary. - * @param {number} [opts.limit] - * @returns {Set} - */ -function fetchOpenPrTouchedAckPaths({ cwd = REPO_ROOT, execGh = execGhDefault, limit = MAX_OPEN_PRS } = {}) { - const stdout = execGh(['pr', 'list', '--state', 'open', '--json', 'number,files', '--limit', String(limit)], { cwd }); - - let prs; - try { - prs = JSON.parse(stdout); - } catch (err) { - throw new Error(`fetchOpenPrTouchedAckPaths: "gh pr list" did not return valid JSON: ${err.message}`); - } - if (!Array.isArray(prs)) { - throw new Error(`fetchOpenPrTouchedAckPaths: expected a JSON array from "gh pr list", got ${typeof prs}`); - } - if (prs.length >= limit) { - throw new Error( - `fetchOpenPrTouchedAckPaths: "gh pr list" returned ${prs.length} open PRs, at or above ` - + `the cap of ${limit}. Refusing to reason about a possibly-truncated list — a fragment ` - + 'touched only by a PR past the cap would look untouched and be swept anyway. Raise ' - + '`limit` or investigate the open-PR count.', - ); - } - - const touched = new Set(); - for (const pr of prs) { - const files = Array.isArray(pr?.files) ? pr.files : []; - // `>=`, never `>`: a PR with exactly MAX_PR_FILES files is byte-identical, - // in this response, to one with four thousand truncated to MAX_PR_FILES. - // The only safe reading of "at the cap" is "possibly incomplete". - let paths; - if (files.length >= MAX_PR_FILES) { - // Second round trip, and the only one in this function. Deliberately not - // taken for the common case -- see this function's doc comment. - paths = fetchPrFiles(pr.number, { cwd, execGh }); - } else { - paths = files.map((file) => (isPlainObject(file) ? file.path : file)); - } - for (const filePath of paths) { - if (typeof filePath === 'string' && filePath.startsWith(`${ACK_DIR_REPO_PATH}/`)) { - touched.add(filePath); - } - } - } - return touched; -} - -/** - * The full `--guard-next` behavior, factored out of `main()` so it is callable directly - * from a test with injected deps — never through a real, network-dependent `gh` (or a - * real filesystem/git checkout) the way `main()` itself is only exercisable via - * subprocess. Returns data rather than performing I/O; `main()` does the printing and - * exit-code setting. - * - * @param {object} [opts] - * @param {string[]} [opts.argv] defaults to `process.argv` - * @param {string} [opts.cwd] defaults to `REPO_ROOT` - * @param {() => Set} [opts.fetchOpenPrPaths] defaults to `fetchOpenPrTouchedAckPaths`. - * Injectable so a test can supply canned open-PR data (or a throwing stub, to exercise - * the fail-closed "hold everything" path) without shelling out to a real `gh`. - * `sweepable` is the fragment basenames the guard would have the caller `git rm` — the - * same set the prose names, already narrowed by the #3842 open-PR hold, so a held - * fragment never appears in it. Surfaced as DATA rather than left to be scraped back out - * of `lines`, because the sweeper (#3875) must act on exactly the set the guard reasoned - * about: a sweeper that re-derives the list, or greps it out of the message text, can - * drift from the guard and delete a fragment the hold was protecting. - * `legacyPresent` is reported separately from `sweepable` because the two are different - * KINDS of cruft with the same remedy: `sweepable` holds fragment basenames under - * `ACK_DIR_REPO_PATH`, whereas the legacy document is one fixed path. Folding it into - * `sweepable` would make a consumer prefix it with the fragment directory and try to - * delete a path that does not exist. Without it the sweeper would be blind to exactly - * one of the two ways this guard can red `next`. - * @returns {{ ok: boolean, lines: string[], sweepable: string[], legacyPresent: boolean }} - */ -function runGuardNext({ argv = process.argv, cwd = REPO_ROOT, fetchOpenPrPaths = fetchOpenPrTouchedAckPaths } = {}) { - const legacyFile = path.join(cwd, ...ACK_REPO_PATH.split('/')); - const legacyPresent = fs.existsSync(legacyFile); - const legacy = assertAbsentOnNext(legacyPresent); - const lines = [legacy.message]; - - // The fragment half (#3078). CI always passes `--base-ref` (the pre-push tip of `next`, - // `github.event.before`), because the default branch allows REBASE merges and one push - // can carry N commits — `HEAD^` alone is not "the state of next before this push" in - // that case. `resolveBaseRef()`'s `HEAD^` is only the fallback for a manual run or a - // single-commit push, where the two agree. NOTE the job's checkout must fetch at least - // depth 2 for the `HEAD^` fallback to resolve at all, and must separately fetch the - // `--base-ref` commit itself, or every fragment reads as brand-new. - const baseFlag = argv.indexOf('--base-ref'); - const baseRef = baseFlag === -1 - ? resolveBaseRef({ cwd }) - : assertUsableBaseRef(argv[baseFlag + 1]); - - const dir = path.join(cwd, ...ACK_DIR_REPO_PATH.split('/')); - const fragments = listFragmentFiles(dir).map((name) => ({ - name, - currentRaw: readIfPresent(path.join(dir, name)), - baseRaw: baseRef === null ? null : readFragmentAtRef(baseRef, name, { cwd }), - })); - - // #3842: opt-in (never on by default — a caller that omits this flag gets the exact - // pre-#3842 behavior, which is what every existing test and any manual/local run relies - // on). CI passes it so the sweep never hands an open PR a modify/delete conflict it did - // not cause. A failed lookup holds EVERYTHING back rather than sweeping blind (see - // fetchOpenPrTouchedAckPaths's own doc comment). - let openPrTouchedPaths; - if (argv.includes('--defer-to-open-prs')) { - try { - openPrTouchedPaths = fetchOpenPrPaths(); - } catch (err) { - lines.push(`lint-emitted-drift-ack: open-PR check unavailable — ${err.message}`); - openPrTouchedPaths = 'unknown'; - } - } - - const sweep = assertNoAllSpentFragments(fragments, { openPrTouchedPaths }); - lines.push(sweep.message); - - return { ok: legacy.ok && sweep.ok, lines, sweepable: sweep.sweepable, legacyPresent }; -} - -/** - * CLI entry. Dependencies are injectable so the `--guard-next` / `--sweep-plan` argv - * routing is testable in-process: `main()` otherwise reads `process.argv` and writes - * through `console`, and the only way to observe it would be a subprocess run against - * the REAL repository — which cannot exhibit an arbitrary sweep set on demand, so the - * interesting cases would go uncovered. - * - * `cwd`, `out` and `err` are honoured by BOTH lanes, not just the guard lane. A seam - * that is injected halfway is worse than one that is not injected at all: a test - * passing `cwd` to the validation lane would silently read the real repository and - * report on whatever happens to be checked in, which is a pass-always test wearing the - * costume of a real one. - */ -function main({ - argv = process.argv, - cwd = REPO_ROOT, - out = console.log, - err = console.error, - guard = runGuardNext, -} = {}) { - if (argv.includes('--guard-next')) { - const result = guard({ argv }); - - // `--sweep-plan` (#3875) turns the guard from a VERDICT into a WORK LIST. The - // sweeper workflow needs the exact set the guard reasoned about, so the plan goes - // to stdout alone and the prose is diverted to stderr — a caller doing - // `xargs git rm` on stdout must never receive an explanatory sentence as a - // filename. Plan mode also exits 0 even when the verdict is a failure: a - // non-empty plan is the NORMAL case it exists to report, and a non-zero exit - // would fail the workflow step before it could act on the very list it asked for. - if (argv.includes('--sweep-plan')) { - for (const line of result.lines) err(line); - // The legacy document leads the plan: it is a fixed path rather than a name - // under the fragment directory, and `assertAbsentOnNext` reds `next` on its - // PRESENCE alone. Omitting it would leave the sweeper able to fix only one of - // the two conditions that make this guard fail. - if (result.legacyPresent) out(ACK_REPO_PATH); - for (const name of result.sweepable) out(`${ACK_DIR_REPO_PATH}/${name}`); - return; - } - - for (const line of result.lines) out(line); - if (!result.ok) process.exitCode = 1; - return; - } - - const legacyFile = path.join(cwd, ...ACK_REPO_PATH.split('/')); - - const fragmentsDir = path.join(cwd, ...ACK_DIR_REPO_PATH.split('/')); - const sources = [ - { label: ACK_REPO_PATH, raw: readIfPresent(legacyFile) }, - ...listFragmentFiles(fragmentsDir).map((name) => ({ - label: `${ACK_DIR_REPO_PATH}/${name}`, - raw: readIfPresent(path.join(fragmentsDir, name)), - })), - ]; - - const problems = []; - const owner = new Map(); // path key -> the source label that already claimed it - let anyPresent = false; - - for (const { label, raw } of sources) { - if (raw !== null) anyPresent = true; - - // `validateAckText` already prefixes every message with `source` (== `label`), so - // these are pushed verbatim rather than re-prefixed — a second prefix would read as - // "tests/emitted-drift-acks/x.json: tests/emitted-drift-acks/x.json is not valid - // JSON", naming the same file twice for no reason. - const result = validateAckText(raw, { source: label }); - problems.push(...result.schemaErrors, ...result.policyErrors); - - // Only chase collisions across documents whose OWN schema already checked out — - // a document we could not trust must not also seed a fabricated collision. - if (result.schemaErrors.length === 0) { - for (const key of declaredKeys(raw)) { - if (owner.has(key)) { - problems.push( - `duplicate ack for "${key}": declared in both ${owner.get(key)} and ${label}. ` - + 'Two ack sources (fragments, or a fragment and the legacy file) may never ' - + 'name the same path. Resolve it one of two ways, depending on the owner: if ' - + `${owner.get(key)} is already merged, its entry is SPENT and gates nothing — ` - + `delete it (git rm ${owner.get(key)}) and keep your own. If it is still live ` - + 'on this branch, APPEND your explanation to its existing entry instead, which ' - + 're-arms it — re-arming deliberately costs actual new prose. Do not rename ' - + 'the path to dodge this.', - ); - continue; - } - owner.set(key, label); - } - } - } - - if (problems.length) { - err(`lint-emitted-drift-ack: ${problems.length} problem(s)\n`); - for (const e of problems) err(` - ${e}`); - err( - '\nThis blocks the merge on purpose. The base-side reader fails loudly on a document ' - + 'it cannot parse, so a broken one on the base branch reds every PR that carries an ' - + 'acknowledgment, and a duplicate across two sources is exactly the silent-drift class ' - + 'the ack seam exists to end. Fix or delete the offending source(s) here, where it is cheap.', - ); - process.exitCode = 1; - return; - } - - out( - anyPresent - ? 'ok lint-emitted-drift-ack: all acknowledgment sources are well-formed' - : 'ok lint-emitted-drift-ack: no acknowledgment sources present (the healthy steady state)', - ); -} - -if (require.main === module) main(); - -module.exports = { - validateAckText, - assertAbsentOnNext, - assertNoAllSpentFragments, - ackProse, - ackEntries, - declaredKeys, - listFragmentFiles, - ACK_VERSION, - ACK_REPO_PATH, - ACK_DIR_REPO_PATH, - ACK_INVISIBLE, - MAX_ACK_FRAGMENTS, - git, - resolveBaseRef, - readFragmentAtRef, - assertUsableBaseRef, - GIT_TIMEOUT_MS, - fetchOpenPrTouchedAckPaths, - fetchPrFiles, - MAX_OPEN_PRS, - MAX_PR_FILES, - GITHUB_MAX_PR_FILES, - GH_TIMEOUT_MS, - runGuardNext, - main, -}; diff --git a/scripts/lint-removed-but-needed.cjs b/scripts/lint-removed-but-needed.cjs index 7994a178f..b72982a75 100644 --- a/scripts/lint-removed-but-needed.cjs +++ b/scripts/lint-removed-but-needed.cjs @@ -21,8 +21,9 @@ * For every file deleted (`git diff --name-status ...HEAD`, status * `D`), grep the post-diff tree for the deleted file's basename: * - * - `.github/workflows/`, `gsd-core/`, `docs/`, `package.json` — ANY - * surviving reference fails (the original rule). + * - `.github/workflows/`, `gsd-core/`, `docs/` (excluding `docs/adr/**` and + * `docs/research/**` — see "Historical-record exemption" below), + * `package.json` — ANY surviving reference fails (the original rule). * - `tests/` — scanned with a discriminator (#3565): a reference that PINS * existence (`fs.existsSync(path)`, `readFileSync`, `require`, or the * basename as a quoted object key) fails; a reference that ASSERTS @@ -78,6 +79,20 @@ * basename-twin). Same trade the docstring already makes for prose * mentions above — a false violation reds a correct tree; a missed * ambiguous mention only loses one detection channel. + * + * ## Historical-record exemption (#3942) + * + * `docs/adr/**` and `docs/research/**` are excluded from the `docs/` scan. + * An ADR's or a post-mortem's entire job is to record what was retired — + * naming the deleted file IS the point of the document, not a defect — so + * without this exemption a PR could never write the ADR that explains its + * own deletion in the same PR that performs it (it would have to land in a + * follow-up, after the fact, which is backwards for a decision record). + * The exemption is narrow and applies only to these two directories: every + * other document under `docs/` (guides, `TESTING-SUITES.md`, generated + * indexes, etc.) still enforces "ANY surviving reference fails" exactly as + * before. `.github/workflows/`, `gsd-core/`, and `package.json` are + * likewise unaffected — none of those are historical-record surfaces. */ const fs = require('node:fs'); @@ -347,12 +362,31 @@ function getSurvivingFiles(root) { .map((f) => f.replace(/\\/g, '/')); } +// #3942: docs/adr/** and docs/research/** are historical records — an ADR's +// or a post-mortem's whole job is to narrate what was retired, so naming a +// file this same PR deletes is the point, not DEFECT.REMOVED-BUT-NEEDED. +// Narrow and explicit: only these two `docs/` subtrees are exempt; every +// other document under `docs/` still enforces the original rule unchanged. +const DOCS_HISTORICAL_RECORD_PREFIXES = ['docs/adr/', 'docs/research/']; + +/** + * Pure: is `relFile` (repo-relative, forward-slash separated) inside one of + * the exempt historical-record directories (#3942)? + * @param {string} relFile + * @returns {boolean} + */ +function isDocsHistoricalRecord(relFile) { + return DOCS_HISTORICAL_RECORD_PREFIXES.some((prefix) => relFile.startsWith(prefix)); +} + function buildCorpus(root) { const corpus = []; for (const rel of SCAN_ROOTS) { for (const abs of walk(path.join(root, rel))) { + const relFile = path.relative(root, abs).replace(/\\/g, '/'); + if (rel === 'docs' && isDocsHistoricalRecord(relFile)) continue; try { - corpus.push({ file: path.relative(root, abs).replace(/\\/g, '/'), content: fs.readFileSync(abs, 'utf8') }); + corpus.push({ file: relFile, content: fs.readFileSync(abs, 'utf8') }); } catch { // unreadable (broken symlink, binary that slipped past SKIP_EXT) — skip } @@ -443,10 +477,12 @@ module.exports = { getDeletedFiles, getSurvivingFiles, buildCorpus, + isDocsHistoricalRecord, scan, SCAN_ROOTS, EXTRA_FILES, TESTS_ROOT, + DOCS_HISTORICAL_RECORD_PREFIXES, }; if (require.main === module) runMain(main); diff --git a/scripts/qa-smell-ratchet.cjs b/scripts/qa-smell-ratchet.cjs index 28a53bcae..28eb99a27 100644 --- a/scripts/qa-smell-ratchet.cjs +++ b/scripts/qa-smell-ratchet.cjs @@ -82,10 +82,12 @@ const ACKS_DIR = path.join(REPO_ROOT, ...ACKS_DIR_REL_PATH.split('/')); const BASELINE_VERSION = 1; /** - * Upper bound on fragment files read in one pass — mirrors the identical cap - * in `scripts/lint-emitted-drift-ack.cjs` (`MAX_ACK_FRAGMENTS`). Exceeding it - * throws rather than silently truncating the listing, which would silently - * drop acknowledgments from consideration — exactly the class of silent + * Upper bound on fragment files read in one pass — this cap is this script's own, + * for its own acknowledgment set (`tests/qa/smell-acks/`), which ADR-3942 does not + * touch. The emitted-drift ack this cap used to mirror moved to a commit trailer + * (ADR-3942) and no longer has a fragment-directory cap of its own to mirror. + * Exceeding it throws rather than silently truncating the listing, which would + * silently drop acknowledgments from consideration — exactly the class of silent * failure this whole seam exists to prevent. */ const MAX_ACK_FRAGMENTS = 500; diff --git a/tests/agent-tracked-source-rule.test.cjs b/tests/agent-tracked-source-rule.test.cjs index 2b6a8cf33..c701b4a59 100644 --- a/tests/agent-tracked-source-rule.test.cjs +++ b/tests/agent-tracked-source-rule.test.cjs @@ -81,13 +81,15 @@ describe('#3645 — agents write only git-tracked source paths', () => { // A prior version of this suite pinned the EXISTENCE and CONTENTS of the `3645` and // `3409` emitted-drift-acks fragments. An ack is scoped to the diff that introduced - // it (#2789's ack-lifecycle law): once #3645 merged and its growth is in `next`'s - // baseline, neither fragment gates anything — both are spent, and #3078's - // `guard-no-ack-on-next` sweeps them. A test may therefore never pin a fragment's - // existence or its prose; a fragment that is correctly swept would fail the pinning - // test for a reason that has nothing to do with the behavior it was meant to protect. - // #3645's actual protection is the two behavioral tests above — the mapper emitting - // only tracked analog paths, and the frozen planner file staying untouched — which - // this change leaves exactly as they were. The growth itself is protected by `next`'s - // emitted baseline (the differential attribution check), not by the spent fragment. + // it: once #3645 merged and its growth is in `next`'s baseline, the acknowledgment + // can never clear anything again. ADR-3942 moved the acknowledgment off the tree + // entirely — it is now a commit trailer (`Emitted-Drift-Ack-Hash:` / + // `Emitted-Drift-Ack-Growth:`) read from `git log $(git merge-base HEAD)..HEAD`, + // so a merged one is out of range by construction. There is no artifact left in the + // tree to pin even if a test wanted to. A test may therefore never pin an + // acknowledgment's existence or its prose; #3645's actual protection is the two + // behavioral tests above — the mapper emitting only tracked analog paths, and the + // frozen planner file staying untouched — which this change leaves exactly as they + // were. The growth itself is protected by `next`'s emitted baseline (the differential + // attribution check), not by the (now nonexistent) acknowledgment artifact. }); diff --git a/tests/emitted-ack-trailer.test.cjs b/tests/emitted-ack-trailer.test.cjs new file mode 100644 index 000000000..27986c6d3 --- /dev/null +++ b/tests/emitted-ack-trailer.test.cjs @@ -0,0 +1,709 @@ +'use strict'; + +/** + * tests/emitted-ack-trailer.test.cjs — #3942 commit-trailer acknowledgment reader/parser. + * + * FAILING-FIRST against the #3942 stubs in tests/helpers/emitted-diff.cjs + * (`parseAckTrailers`, `renderAckTrailer`, `ACK_TRAILER_HASH`, `ACK_TRAILER_GROWTH`, + * `ACK_TRAILER_DELIM`, `MAX_ACK_TRAILERS`) and tests/helpers/emitted-runtime.cjs + * (`readAckTrailers`). See `.gsd/phase/chore-3942-ack-commit-trailer/40-design.md` for + * the grammar/behavior table and `50-test-matrix.md` for the 37-row matrix this file + * covers. + * + * Every one of those exports is a STUB today: it returns a benign, empty-shaped value + * and NEVER throws. So every row below that requires real parsing, real git I/O, a + * thrown error, or a bijective round-trip is RED for the right reason — missing + * implementation, not a test bug. A handful of rows (25, 31, 34) legitimately assert + * an EMPTY result and so pass trivially today; that is the correct, non-vacuous + * behavior for those specific inputs both before and after the real implementation + * lands, not a weak assertion. + * + * Three rows (8, 34, 35) describe diffEmitted's future CONSUMER-side gating + * (`staleAcks` naming a space, shrinkage never needing a trailer, `NEW_FILE_CAP` never + * being excusable) — behavior that depends on diffEmitted being rewired to accept + * separate `ackHash`/`ackGrowth` maps, which is a later step per 40-design.md's blast + * radius table and out of scope for this stub-only change. Each is expressed here as + * the reader-level analog it reduces to (namespace separation, or "the reader has no + * opinion on X"), with a comment explaining the narrowing. See the dispatch report for + * the precise accounting. + * + * ── Row -> describe block map ──────────────────────────────────────────────── + * grammar and hostile keys (pure parser) rows 8, 9-18, 28-30 + * boundary: MAX_ACK_TRAILERS cap rows 19-21 + * real fixture reads via readAckTrailers rows 1-7, 25-27, 31, 34, 35 + * hostile IO: range/subprocess/timeout rows 22-24 + * round-trip and properties rows 33, 36, 37 + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const fc = require('fast-check'); + +const { cleanup } = require('./helpers.cjs'); +const { gitOrThrow, GIT_FIXTURE_TIMEOUT_MS } = require('./helpers/git-fixture.cjs'); +const { + ACK_TRAILER_HASH, + ACK_TRAILER_GROWTH, + ACK_TRAILER_DELIM, + MAX_ACK_TRAILERS, + parseAckTrailers, + renderAckTrailer, +} = require('./helpers/emitted-diff.cjs'); +const { readAckTrailers } = require('./helpers/emitted-runtime.cjs'); + +// Mirrors emitted-diff.cjs's (unexported) RESERVED_ACK_KEYS — 40-design.md's "Trailer +// key is __proto__ / constructor / prototype" row is explicitly carried over verbatim +// from that JSON-ack design, so the three literal values are pinned here rather than +// imported (nothing in the #3942 stub surface re-exports the JSON-ack constant). +const RESERVED_KEYS = ['__proto__', 'constructor', 'prototype']; + +// ─── fixture helpers ────────────────────────────────────────────────────────── + +/** A throwaway git repo: `init -b main`, deterministic identity, no GPG prompts. */ +function makeTempRepo(prefix) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + gitOrThrow(['init', '-q', '-b', 'main'], { cwd: dir, timeoutMs: GIT_FIXTURE_TIMEOUT_MS }); + gitOrThrow(['config', 'user.email', 'ack-trailer-fixture@example.com'], { cwd: dir, timeoutMs: GIT_FIXTURE_TIMEOUT_MS }); + gitOrThrow(['config', 'user.name', 'Ack Trailer Fixture'], { cwd: dir, timeoutMs: GIT_FIXTURE_TIMEOUT_MS }); + gitOrThrow(['config', 'commit.gpgsign', 'false'], { cwd: dir, timeoutMs: GIT_FIXTURE_TIMEOUT_MS }); + return dir; +} + +/** + * Commit an --allow-empty commit from a message written to a file (never `-m`, so the + * trailer block lands exactly where git's own trailer parser expects it). + */ +function commitMessage(dir, message, { branch } = {}) { + if (branch) { + gitOrThrow(['checkout', '-q', '-b', branch], { cwd: dir, timeoutMs: GIT_FIXTURE_TIMEOUT_MS }); + } + const msgFile = path.join(os.tmpdir(), `gsd-ack-trailer-msg-${crypto.randomBytes(6).toString('hex')}.txt`); + fs.writeFileSync(msgFile, message); + try { + gitOrThrow(['commit', '-q', '--allow-empty', '-F', msgFile], { cwd: dir, timeoutMs: GIT_FIXTURE_TIMEOUT_MS }); + } finally { + cleanup(msgFile); + } +} + +function headSha(dir) { + return gitOrThrow(['rev-parse', 'HEAD'], { cwd: dir, timeoutMs: GIT_FIXTURE_TIMEOUT_MS }).trim(); +} + +function checkout(dir, ref) { + gitOrThrow(['checkout', '-q', ref], { cwd: dir, timeoutMs: GIT_FIXTURE_TIMEOUT_MS }); +} + +/** One rendered trailer LINE — not `renderAckTrailer` (a stub today), but the literal + * grammar it will produce, so fixtures stay correct independent of the stub's state. */ +function trailerLine(name, key, reason) { + return `${name}: ${key}${ACK_TRAILER_DELIM}${reason}`; +} + +function withCleanup(t, dir) { + t.after(() => cleanup(dir)); +} + +// ─── grammar and hostile keys (pure parser) — rows 8, 9-18, 28-30 ──────────── + +describe('grammar and hostile keys (pure parser)', () => { + test('trailer without a delimiter is rejected — row 9', () => { + const { hash, errors } = parseAckTrailers({ hash: ['no-delimiter-here'], growth: [] }); + assert.equal(hash.size, 0); + assert.equal(errors.length, 1); + assert.match(errors[0], /delimiter/i); + }); + + test('empty reason is rejected — row 10', () => { + const { hash, errors } = parseAckTrailers({ hash: ['some/path — '], growth: [] }); + assert.equal(hash.has('some/path'), false); + assert.equal(errors.length, 1); + assert.match(errors[0], /reason/i); + }); + + test('one-character reason is accepted — row 11', () => { + const { hash, errors } = parseAckTrailers({ hash: ['some/path — x'], growth: [] }); + assert.deepEqual(errors, []); + assert.equal(hash.get('some/path').reason, 'x'); + }); + + test('empty key is rejected — row 12', () => { + const { hash, errors } = parseAckTrailers({ hash: [' — reason text'], growth: [] }); + assert.equal(hash.size, 0); + assert.equal(errors.length, 1); + assert.match(errors[0], /key/i); + }); + + test('reserved keys are rejected — row 13', () => { + const values = RESERVED_KEYS.map((k) => `${k} — ok`); + const { hash, errors } = parseAckTrailers({ hash: values, growth: [] }); + assert.equal(hash.size, 0); + assert.equal(errors.length, RESERVED_KEYS.length); + for (const k of RESERVED_KEYS) { + assert.ok(errors.some((e) => e.includes(k)), `errors must name ${k}`); + } + }); + + test('placeholder-shaped key is rejected — row 14', () => { + const { hash, errors } = parseAckTrailers({ hash: [' — reason'], growth: [] }); + assert.equal(hash.size, 0); + assert.equal(errors.length, 1); + }); + + test('key with whitespace is rejected — row 15', () => { + const { hash, errors } = parseAckTrailers({ hash: ['foo bar.md — reason'], growth: [] }); + assert.equal(hash.size, 0); + assert.equal(errors.length, 1); + }); + + test('identical duplicate trailer is deduped — row 16', () => { + const { hash, errors } = parseAckTrailers({ + hash: ['dup/path — same reason', 'dup/path — same reason'], + growth: [], + }); + assert.deepEqual(errors, []); + assert.equal(hash.size, 1); + assert.equal(hash.get('dup/path').reason, 'same reason'); + }); + + test('conflicting duplicate trailer is rejected — row 17', () => { + const { hash, errors } = parseAckTrailers({ + hash: ['dup/path — reason A', 'dup/path — reason B'], + growth: [], + }); + assert.equal(errors.length, 1); + assert.match(errors[0], /ambiguous|conflict/i); + assert.equal(hash.has('dup/path'), false, 'an ambiguous declaration must not silently pick a winner'); + }); + + test('same key in both spaces is legal — row 18', () => { + const { hash, growth, errors } = parseAckTrailers({ + hash: ['shared-key — hash reason'], + growth: ['shared-key — growth reason'], + }); + assert.deepEqual(errors, []); + assert.equal(hash.get('shared-key').reason, 'hash reason'); + assert.equal(growth.get('shared-key').reason, 'growth reason'); + }); + + test( + 'stale trailer fails and names its space — row 8 ' + + '(reader-level analog: the two spaces stay in separate maps so a downstream ' + + 'consumer can name which space an unconsumed key belongs to; the staleAcks ' + + 'gating itself is diffEmitted\'s job and is not rewired by this stub-only change)', + () => { + const { hash, growth } = parseAckTrailers({ + hash: ['a/b.md — hash-space reason'], + growth: ['c.md — growth-space reason'], + }); + assert.equal(hash.has('a/b.md'), true); + assert.equal(growth.has('c.md'), true); + assert.equal(hash.has('c.md'), false, 'growth-space key must not leak into the hash map'); + assert.equal(growth.has('a/b.md'), false, 'hash-space key must not leak into the growth map'); + }, + ); + + test('trailer value is trimmed — row 28', () => { + const { hash, errors } = parseAckTrailers({ + hash: [' padded/path — reason with padding '], + growth: [], + }); + assert.deepEqual(errors, []); + assert.equal(hash.get('padded/path').reason, 'reason with padding'); + }); + + test('reason may contain an em dash — row 29', () => { + const { hash, errors } = parseAckTrailers({ + hash: ['path/em — reason — with another em dash'], + growth: [], + }); + assert.deepEqual(errors, []); + assert.equal(hash.get('path/em').reason, 'reason — with another em dash'); + }); + + test('invisible characters cannot fake a distinct reason — row 30', () => { + // A zero-width space (U+200B) inserted inside an otherwise-identical reason. Once + // stripped, both entries read as the SAME prose — this must dedupe (row 16's rule), + // never register as a genuinely distinct (and therefore ambiguous, row 17) reason. + const { hash, errors } = parseAckTrailers({ + hash: ['inv/path — same reason', 'inv/path — sam​e reason'], + growth: [], + }); + assert.deepEqual(errors, []); + assert.equal(hash.size, 1); + assert.equal(hash.get('inv/path').reason, 'same reason'); + }); +}); + +// ─── boundary: MAX_ACK_TRAILERS cap — rows 19-21 ───────────────────────────── + +function buildUniqueHashValues(n) { + return Array.from({ length: n }, (_, i) => `k${i}/path.md — reason ${i}`); +} + +describe('boundary: MAX_ACK_TRAILERS cap', () => { + test('trailer count below the cap is accepted — row 19', () => { + const { hash, errors } = parseAckTrailers({ + hash: buildUniqueHashValues(MAX_ACK_TRAILERS - 1), + growth: [], + }); + assert.deepEqual(errors, []); + assert.equal(hash.size, MAX_ACK_TRAILERS - 1); + }); + + test('trailer count at the cap is accepted — row 20', () => { + const { hash, errors } = parseAckTrailers({ + hash: buildUniqueHashValues(MAX_ACK_TRAILERS), + growth: [], + }); + assert.deepEqual(errors, []); + assert.equal(hash.size, MAX_ACK_TRAILERS); + }); + + test('trailer count above the cap throws, never truncates — row 21', () => { + assert.throws( + () => parseAckTrailers({ hash: buildUniqueHashValues(MAX_ACK_TRAILERS + 1), growth: [] }), + (err) => { + assert.match(err.message, new RegExp(String(MAX_ACK_TRAILERS + 1)), 'must name the actual count'); + assert.match( + err.message.replace(String(MAX_ACK_TRAILERS + 1), ''), + new RegExp(String(MAX_ACK_TRAILERS)), + 'must name the cap', + ); + return true; + }, + 'one over the cap must throw rather than silently truncating the listing', + ); + }); + + test( + '100 identical repeats of ONE trailer do not throw — the cap is counted AFTER ' + + 'same-key dedup, not on the raw input count. A commit trailer, unlike the pre-' + + '#3942 fragment-directory listing this cap descends from, legitimately survives a ' + + "rebase: `git log` over the range reports the SAME trailer text once per rebased " + + 'commit it still lives on, so counting raw occurrences would throw on an entirely ' + + 'legitimate branch that never declared more than one distinct acknowledgment.', + () => { + const values = Array.from( + { length: 100 }, + () => 'rebased/path.md — identical reason on every rebased commit', + ); + const { hash, errors } = parseAckTrailers({ hash: values, growth: [] }); + assert.deepEqual(errors, []); + assert.equal(hash.size, 1, 'all 100 raw values collapse to the one distinct declaration they represent'); + assert.equal(hash.get('rebased/path.md').reason, 'identical reason on every rebased commit'); + }, + ); +}); + +// ─── real fixture reads via readAckTrailers — rows 1-7, 25-27, 31, 34, 35 ──── + +describe('real fixture reads via readAckTrailers', () => { + test('hash trailer excuses a rippled path — row 1', (t) => { + const dir = makeTempRepo('gsd-ack-trailer-1-'); + withCleanup(t, dir); + commitMessage(dir, 'init\n\nbaseline commit, no trailer\n'); + const baseSha = headSha(dir); + commitMessage( + dir, + `ripple the workflow\n\n${trailerLine(ACK_TRAILER_HASH, 'gsd-core/workflows/foo.md', 'deliberate ripple, #3942')}\n`, + ); + const r = readAckTrailers({ baseRef: baseSha, headRef: 'HEAD', cwd: dir }); + assert.equal(r.errors.length, 0); + assert.equal(r.hash.get('gsd-core/workflows/foo.md')?.reason, 'deliberate ripple, #3942'); + assert.equal(r.growth.size, 0); + }); + + test('growth trailer excuses a grown file — row 2', (t) => { + const dir = makeTempRepo('gsd-ack-trailer-2-'); + withCleanup(t, dir); + commitMessage(dir, 'init\n\nbaseline commit, no trailer\n'); + const baseSha = headSha(dir); + commitMessage( + dir, + `grow the workflow\n\n${trailerLine(ACK_TRAILER_GROWTH, 'plan-phase.md', 'grew for a new step, #3942')}\n`, + ); + const r = readAckTrailers({ baseRef: baseSha, headRef: 'HEAD', cwd: dir }); + assert.equal(r.errors.length, 0); + assert.equal(r.growth.get('plan-phase.md')?.reason, 'grew for a new step, #3942'); + assert.equal(r.hash.size, 0); + }); + + test('both spaces coexist in one range — row 3', (t) => { + const dir = makeTempRepo('gsd-ack-trailer-3-'); + withCleanup(t, dir); + commitMessage(dir, 'init\n\nbaseline commit, no trailer\n'); + const baseSha = headSha(dir); + commitMessage( + dir, + 'hash and growth together\n\n' + + `${trailerLine(ACK_TRAILER_HASH, 'gsd-core/workflows/bar.md', 'ripple reason')}\n` + + `${trailerLine(ACK_TRAILER_GROWTH, 'bar.md', 'growth reason')}\n`, + ); + const r = readAckTrailers({ baseRef: baseSha, headRef: 'HEAD', cwd: dir }); + assert.equal(r.errors.length, 0); + assert.equal(r.hash.get('gsd-core/workflows/bar.md')?.reason, 'ripple reason'); + assert.equal(r.growth.get('bar.md')?.reason, 'growth reason'); + }); + + test( + 'two trailers of the SAME name on one commit arrive as separate entries, not ' + + 'joined into one value (regression: `separator=1d` in the git --format string was ' + + 'a literal two-character value, not the `%x1d` escape — the record NEVER split on ' + + 'the real \\x1d byte the code expects, silently collapsing multiple same-key ' + + 'trailers into one joined string). The existing "both spaces coexist" test (row 3) ' + + 'uses Hash+Growth — two DIFFERENT trailer names — so it only exercises the field ' + + 'separator, never the value separator between multiple values of the SAME key; ' + + 'this test is what actually exercises `separator=` in the git --format string.', + (t) => { + const dir = makeTempRepo('gsd-ack-trailer-sep-'); + withCleanup(t, dir); + commitMessage(dir, 'init\n\nbaseline commit, no trailer\n'); + const baseSha = headSha(dir); + commitMessage( + dir, + 'two same-name trailers, different keys\n\n' + + `${trailerLine(ACK_TRAILER_HASH, 'one/path.md', 'reason one')}\n` + + `${trailerLine(ACK_TRAILER_HASH, 'two/path.md', 'reason two')}\n`, + ); + const r = readAckTrailers({ baseRef: baseSha, headRef: 'HEAD', cwd: dir }); + assert.equal(r.errors.length, 0); + assert.equal(r.hash.size, 2, 'two distinct trailers of the same name must arrive as two entries'); + assert.equal(r.hash.get('one/path.md')?.reason, 'reason one'); + assert.equal(r.hash.get('two/path.md')?.reason, 'reason two'); + }, + ); + + test( + 'two trailers of the SAME name and the SAME key with different reasons on one ' + + 'commit are surfaced as an ambiguous-conflict error, not silently merged ' + + '(regression, same root cause as the test immediately above: under the broken ' + + 'separator, this exact input produced NO error and a corrupted reason string like ' + + '"reason one1dtwo/path.md — reason two" — a truncation the ambiguous-duplicate ' + + 'error exists to catch, but never fired because the value never reached the ' + + 'parser split into two pieces)', + (t) => { + const dir = makeTempRepo('gsd-ack-trailer-sep-conflict-'); + withCleanup(t, dir); + commitMessage(dir, 'init\n\nbaseline commit, no trailer\n'); + const baseSha = headSha(dir); + commitMessage( + dir, + 'two same-name trailers, same key, conflicting reasons\n\n' + + `${trailerLine(ACK_TRAILER_HASH, 'same/path.md', 'reason A')}\n` + + `${trailerLine(ACK_TRAILER_HASH, 'same/path.md', 'reason B')}\n`, + ); + const r = readAckTrailers({ baseRef: baseSha, headRef: 'HEAD', cwd: dir }); + assert.equal(r.hash.has('same/path.md'), false, 'an ambiguous declaration must not silently pick a winner'); + assert.equal(r.errors.length, 1, 'the split must yield exactly two distinct raw values, not one merged one'); + assert.match(r.errors[0], /ambiguous|conflict/i); + }, + ); + + test('clean tree needs no trailer — row 4', (t) => { + const dir = makeTempRepo('gsd-ack-trailer-4-'); + withCleanup(t, dir); + commitMessage(dir, 'init\n\nbaseline commit, no trailer\n'); + const baseSha = headSha(dir); + commitMessage(dir, 'no-op change\n\nnothing to acknowledge\n'); + const r = readAckTrailers({ baseRef: baseSha, headRef: 'HEAD', cwd: dir }); + assert.equal(r.errors.length, 0); + assert.equal(r.hash.size, 0); + assert.equal(r.growth.size, 0); + }); + + test('growth trailer does not excuse a hash ripple — row 5', (t) => { + const dir = makeTempRepo('gsd-ack-trailer-5-'); + withCleanup(t, dir); + commitMessage(dir, 'init\n\nbaseline commit, no trailer\n'); + const baseSha = headSha(dir); + // A Growth trailer naming a slash-shaped emitted PATH, as if trying to excuse a + // hash ripple from the wrong namespace — the latent defect this row closes. + commitMessage( + dir, + `mis-scoped ack\n\n${trailerLine(ACK_TRAILER_GROWTH, 'gsd-core/workflows/baz.md', 'wrong space')}\n`, + ); + const r = readAckTrailers({ baseRef: baseSha, headRef: 'HEAD', cwd: dir }); + assert.equal(r.growth.get('gsd-core/workflows/baz.md')?.reason, 'wrong space'); + assert.equal(r.hash.has('gsd-core/workflows/baz.md'), false, 'the growth space must never leak into the hash space'); + }); + + test('hash trailer does not excuse growth — row 6', (t) => { + const dir = makeTempRepo('gsd-ack-trailer-6-'); + withCleanup(t, dir); + commitMessage(dir, 'init\n\nbaseline commit, no trailer\n'); + const baseSha = headSha(dir); + commitMessage(dir, `mis-scoped ack\n\n${trailerLine(ACK_TRAILER_HASH, 'qux.md', 'wrong space')}\n`); + const r = readAckTrailers({ baseRef: baseSha, headRef: 'HEAD', cwd: dir }); + assert.equal(r.hash.get('qux.md')?.reason, 'wrong space'); + assert.equal(r.growth.has('qux.md'), false, 'the hash space must never leak into the growth space'); + }); + + test('trailer before the merge-base is out of range — row 7', (t) => { + const dir = makeTempRepo('gsd-ack-trailer-7-'); + withCleanup(t, dir); + commitMessage(dir, 'fork point\n\nnothing to acknowledge yet\n'); + commitMessage( + dir, + `topic ripple\n\n${trailerLine(ACK_TRAILER_HASH, 'topic-key.md', 'on the topic branch')}\n`, + { branch: 'topic' }, + ); + checkout(dir, 'main'); + commitMessage(dir, `main ripple\n\n${trailerLine(ACK_TRAILER_HASH, 'main-key.md', 'on main, after the fork')}\n`); + checkout(dir, 'topic'); + + const r = readAckTrailers({ baseRef: 'main', headRef: 'HEAD', cwd: dir }); + assert.equal(r.hash.has('topic-key.md'), true, 'the topic-side trailer is in range via the merge-base'); + assert.equal(r.hash.has('main-key.md'), false, 'the main-side trailer, off the fork, must be out of range'); + }); + + test('empty range yields no acks — row 25', (t) => { + const dir = makeTempRepo('gsd-ack-trailer-25-'); + withCleanup(t, dir); + commitMessage(dir, 'only commit\n\nno trailer\n'); + const sha = headSha(dir); + const r = readAckTrailers({ baseRef: sha, headRef: 'HEAD', cwd: dir }); + assert.equal(r.errors.length, 0); + assert.equal(r.hash.size, 0); + assert.equal(r.growth.size, 0); + }); + + test('single-commit range is read — row 26', (t) => { + const dir = makeTempRepo('gsd-ack-trailer-26-'); + withCleanup(t, dir); + commitMessage(dir, 'base\n\nno trailer\n'); + const baseSha = headSha(dir); + commitMessage(dir, `only one commit\n\n${trailerLine(ACK_TRAILER_HASH, 'single.md', 'only one commit in range')}\n`); + const r = readAckTrailers({ baseRef: baseSha, headRef: 'HEAD', cwd: dir }); + assert.equal(r.hash.get('single.md')?.reason, 'only one commit in range'); + assert.equal(r.hash.size, 1); + }); + + test('CRLF commit message parses identically — row 27', (t) => { + const dir = makeTempRepo('gsd-ack-trailer-27-'); + withCleanup(t, dir); + commitMessage(dir, 'base\n\nno trailer\n'); + const baseSha = headSha(dir); + const crlfMessage = + `crlf commit\r\n\r\n${trailerLine(ACK_TRAILER_HASH, 'crlf-key.md', 'same reason regardless of line ending')}\r\n`; + const msgFile = path.join(os.tmpdir(), `gsd-ack-trailer-crlf-${crypto.randomBytes(6).toString('hex')}.txt`); + fs.writeFileSync(msgFile, crlfMessage); + try { + gitOrThrow(['commit', '-q', '--allow-empty', '-F', msgFile], { cwd: dir, timeoutMs: GIT_FIXTURE_TIMEOUT_MS }); + } finally { + cleanup(msgFile); + } + const r = readAckTrailers({ baseRef: baseSha, headRef: 'HEAD', cwd: dir }); + assert.equal( + r.hash.get('crlf-key.md')?.reason, + 'same reason regardless of line ending', + 'a CRLF commit message must parse identically to LF — no stray \\r in the reason', + ); + }); + + test('merge commit without a trailer is ignored — row 31', (t) => { + const dir = makeTempRepo('gsd-ack-trailer-31-'); + withCleanup(t, dir); + commitMessage(dir, 'fork\n\nno trailer\n'); + const forkSha = headSha(dir); + commitMessage(dir, 'topic change\n\nno trailer\n', { branch: 'topic' }); + checkout(dir, 'main'); + commitMessage(dir, 'main change\n\nno trailer\n'); + + const mergeMsgFile = path.join(os.tmpdir(), `gsd-ack-trailer-merge-${crypto.randomBytes(6).toString('hex')}.txt`); + fs.writeFileSync(mergeMsgFile, "Merge branch 'topic'\n\nno trailer here either\n"); + try { + gitOrThrow(['merge', '--no-ff', '-q', '-F', mergeMsgFile, 'topic'], { cwd: dir, timeoutMs: GIT_FIXTURE_TIMEOUT_MS }); + } finally { + cleanup(mergeMsgFile); + } + + const r = readAckTrailers({ baseRef: forkSha, headRef: 'HEAD', cwd: dir }); + assert.equal(r.errors.length, 0); + assert.equal(r.hash.size, 0); + assert.equal(r.growth.size, 0); + }); + + test('mid-body mention is not a trailer — row 32', (t) => { + const dir = makeTempRepo('gsd-ack-trailer-32-'); + withCleanup(t, dir); + commitMessage(dir, 'base\n\nno trailer\n'); + const baseSha = headSha(dir); + const message = + 'docs: teach the trailer syntax\n\n' + + `This body mentions ${trailerLine(ACK_TRAILER_HASH, 'fake/path.md', 'sneaky inline mention')} inline, ` + + 'as an example within a sentence.\n\n' + + 'A trailing paragraph that is not trailer-shaped, so the mention above cannot be ' + + 'mistaken for the trailer block.\n'; + commitMessage(dir, message); + const r = readAckTrailers({ baseRef: baseSha, headRef: 'HEAD', cwd: dir }); + assert.equal(r.hash.has('fake/path.md'), false, 'a mid-body mention must never parse as a live trailer'); + assert.equal(r.errors.length, 0); + }); + + test( + 'shrinkage needs no trailer — row 34 ' + + '(reader-level analog: the reader is diff-content-agnostic; the "never gated" ' + + 'behavior itself lives in diffEmitted, not rewired by this stub-only change)', + (t) => { + const dir = makeTempRepo('gsd-ack-trailer-34-'); + withCleanup(t, dir); + commitMessage(dir, 'large\n\nno trailer\n'); + const baseSha = headSha(dir); + commitMessage(dir, 'shrunk\n\nno trailer needed for shrinkage\n'); + const r = readAckTrailers({ baseRef: baseSha, headRef: 'HEAD', cwd: dir }); + assert.equal(r.errors.length, 0); + assert.equal(r.hash.size, 0); + assert.equal(r.growth.size, 0); + }, + ); + + test( + 'NEW_FILE_CAP is not excusable by a trailer — row 35 ' + + '(reader-level analog: the reader has no opinion on NEW_FILE_CAP and must read ' + + 'the trailer like any other hash entry; refusing to let it excuse the cap is ' + + 'diffEmitted\'s job, not rewired by this stub-only change)', + (t) => { + const dir = makeTempRepo('gsd-ack-trailer-35-'); + withCleanup(t, dir); + commitMessage(dir, 'base\n\nno trailer\n'); + const baseSha = headSha(dir); + commitMessage( + dir, + 'new oversized file\n\n' + + `${trailerLine(ACK_TRAILER_HASH, 'gsd-core/workflows/huge-new-file.md', 'trying to excuse a new-file-cap violation')}\n`, + ); + const r = readAckTrailers({ baseRef: baseSha, headRef: 'HEAD', cwd: dir }); + assert.equal( + r.hash.get('gsd-core/workflows/huge-new-file.md')?.reason, + 'trying to excuse a new-file-cap violation', + ); + }, + ); +}); + +// ─── hostile IO: uncomputable range, subprocess failure, timeout — rows 22-24 ─ + +describe('hostile IO: uncomputable range, subprocess failure, timeout', () => { + test('uncomputable range throws, never passes vacuously — row 22', (t) => { + const origin = makeTempRepo('gsd-ack-trailer-22-origin-'); + withCleanup(t, origin); + commitMessage(origin, 'origin A\n\nfirst commit\n'); + const shaA = headSha(origin); + commitMessage(origin, 'origin B\n\nsecond commit\n'); + commitMessage(origin, 'origin C\n\nthird commit\n'); + + const clone = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ack-trailer-22-clone-')); + t.after(() => cleanup(clone)); + gitOrThrow(['clone', '-q', '--depth', '1', `file://${origin}`, clone], { + cwd: origin, + timeoutMs: GIT_FIXTURE_TIMEOUT_MS, + }); + + assert.throws( + () => readAckTrailers({ baseRef: shaA, headRef: 'HEAD', cwd: clone }), + /./, + 'a genuinely uncomputable merge-base (shallow clone missing the base object) must ' + + 'throw, never return an empty result', + ); + }); + + test('git failure surfaces, not swallowed — row 23', (t) => { + const notARepo = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ack-trailer-23-')); + t.after(() => cleanup(notARepo)); + assert.throws( + () => readAckTrailers({ baseRef: 'main', headRef: 'HEAD', cwd: notARepo }), + /./, + 'a git failure (not a repository) must throw with the failure surfaced, never be ' + + 'read as "no trailers"', + ); + }); + + test('git log is bounded by a timeout — row 24', (t) => { + const dir = makeTempRepo('gsd-ack-trailer-24-'); + withCleanup(t, dir); + commitMessage(dir, 'init\n\nbaseline\n'); + const baseSha = headSha(dir); + commitMessage(dir, 'change\n\nno trailer\n'); + const start = Date.now(); + assert.throws( + // Speculative `timeoutMs`: the real reader is expected to bound its own git + // subprocess and throw a timeout rather than hang. The stub ignores every + // argument today, so this option name is not load-bearing for the RED result — + // it documents the contract the real implementation must honor. + () => readAckTrailers({ baseRef: baseSha, headRef: 'HEAD', cwd: dir, timeoutMs: 1 }), + /time/i, + 'an unreadable-in-time git call must throw a timeout, never hang', + ); + assert.ok( + Date.now() - start < GIT_FIXTURE_TIMEOUT_MS, + 'must fail fast — never ride out the full seam default', + ); + }); +}); + +// ─── round-trip and properties — rows 33, 36, 37 ───────────────────────────── + +describe('round-trip and properties', () => { + test('taught syntax round-trips through the reader — row 33', () => { + const line = renderAckTrailer(ACK_TRAILER_HASH, 'gsd-core/workflows/plan-phase.md', 'converter change, #3942'); + const prefix = `${ACK_TRAILER_HASH}:`; + assert.ok(line.startsWith(prefix), 'rendered line must start with its own trailer name'); + const value = line.slice(prefix.length).trimStart(); + const { hash, errors } = parseAckTrailers({ hash: [value], growth: [] }); + assert.deepEqual(errors, []); + assert.equal(hash.get('gsd-core/workflows/plan-phase.md')?.reason, 'converter change, #3942'); + }); + + const KEY_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789_./-'.split(''); + const REASON_ALPHABET = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 ,.#-'.split(''); + + const keyArb = fc.array(fc.constantFrom(...KEY_ALPHABET), { minLength: 1, maxLength: 24 }) + .map((chars) => chars.join('')) + .filter((k) => !RESERVED_KEYS.includes(k)); + + const reasonArb = fc.array(fc.constantFrom(...REASON_ALPHABET), { minLength: 1, maxLength: 40 }) + .map((chars) => chars.join('').trim()) + .filter((s) => s.length > 0); + + test('prop: trailer render/parse is bijective — row 36', () => { + fc.assert( + fc.property(keyArb, reasonArb, (key, reason) => { + const line = renderAckTrailer(ACK_TRAILER_HASH, key, reason); + const prefix = `${ACK_TRAILER_HASH}:`; + assert.ok(line.startsWith(prefix), 'rendered line must start with the trailer name'); + const value = line.slice(prefix.length).trimStart(); + const { hash, errors } = parseAckTrailers({ hash: [value], growth: [] }); + assert.deepEqual(errors, []); + assert.equal(hash.get(key)?.reason, reason); + }), + { seed: 3942, numRuns: 300 }, + ); + }); + + const uniqueKeysArb = fc.uniqueArray(keyArb, { minLength: 1, maxLength: 10 }); + + test( + 'prop: every parsed entry lands in exactly one space, never silently dropped — row 37 ' + + '(reader-level analog of "every moved path lands in exactly one bucket": the full ' + + 'attributed/unattributable/acked conservation law is diffEmitted\'s, not this reader\'s)', + () => { + fc.assert( + fc.property(uniqueKeysArb, reasonArb, (keys, reason) => { + const hashValues = keys.map((k) => `${k}${ACK_TRAILER_DELIM}${reason}`); + const { hash, errors } = parseAckTrailers({ hash: hashValues, growth: [] }); + assert.deepEqual(errors, []); + assert.equal(hash.size, keys.length, 'every unique, well-formed key must be conserved — none silently dropped'); + for (const k of keys) { + assert.equal(hash.get(k).reason, reason); + } + }), + { seed: 3942, numRuns: 300 }, + ); + }, + ); +}); diff --git a/tests/emitted-attribution.test.cjs b/tests/emitted-attribution.test.cjs index 4d473691d..d901a944a 100644 --- a/tests/emitted-attribution.test.cjs +++ b/tests/emitted-attribution.test.cjs @@ -34,7 +34,6 @@ const { test, describe } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); -const os = require('node:os'); const path = require('node:path'); const crypto = require('node:crypto'); const { execFileSync } = require('node:child_process'); @@ -42,7 +41,6 @@ const fc = require('fast-check'); const { cleanup, createTempDir } = require('./helpers.cjs'); const { BUILD_SCRIPT, buildParityManifest, buildInstallTree, PKG_VERSION } = require('./helpers/install-shared.cjs'); -const { escapeRegex } = require('../gsd-core/bin/lib/pattern.cjs'); const { resolveChangedPaths, resolveBase, @@ -50,15 +48,7 @@ const { buildBaselineAtRef, currentManifests, currentSizes, - readAckFile, - readAckFileAtRef, - readAckSources, - readAckSourcesAtRef, - listAckFragmentFiles, - listAckFragmentFilesAtRef, - ACK_REPO_PATH, - ACK_DIR, - ACK_DIR_REPO_PATH, + readAckTrailers, baselineFamilyNamesAtRef, MANIFEST_FAMILIES, MINIMUM_MANIFEST_FAMILIES, @@ -75,39 +65,23 @@ const { } = require('./helpers/emitted-runtime.cjs'); const { EXPECTED_MANIFEST_COUNT, loadManifests } = require('./helpers/emitted-provenance.cjs'); +// ADR-3942 §6: scripts/lint-emitted-drift-ack.cjs (the legacy JSON-ack-file pre-merge +// lint + guard-no-ack-on-next + the fragment-sweep machinery it backed) is deleted — +// there is no committed ack file/fragment left for it to lint, sweep, or guard. Every +// test that required it below is gone with it. const { - validateAckText, - assertAbsentOnNext, - assertNoAllSpentFragments, - ackProse, - ackEntries, - ACK_INVISIBLE, - MAX_ACK_FRAGMENTS: MAX_ACK_FRAGMENTS_LINT, - resolveBaseRef, - readFragmentAtRef, - assertUsableBaseRef, - fetchOpenPrTouchedAckPaths, - MAX_OPEN_PRS, - MAX_PR_FILES, - GITHUB_MAX_PR_FILES, - runGuardNext, - main, -} = require('../scripts/lint-emitted-drift-ack.cjs'); -const { - ACK_VERSION, - ACK_FILE, - ACK_DIR: ACK_DIR_PURE, NEW_FILE_CAP, - MAX_ACK_FRAGMENTS, REMEDIATION, - INVISIBLE, - normalizeAckReason, sourceSatisfiedBy, - parseAck, - mergeAckSources, diffEmitted, buildReport, formatReport, + ACK_TRAILER_HASH, + ACK_TRAILER_GROWTH, + INVISIBLE, + normalizeAckReason, + parseAckTrailers, + renderAckTrailer, } = require('./helpers/emitted-diff.cjs'); const { @@ -132,6 +106,16 @@ const SKILL_SRC = 'commands/gsd/add-tests.md'; const mf = (obj) => ({ claude: obj }); +/** + * Build an `ackHash`/`ackGrowth` Map from the old `{ path: { reason } | reason }` shape + * most fixtures below were already written in (#3942 changed `diffEmitted`'s ack + * parameters from a parsed JSON document to a pre-parsed `Map` per + * space — see `tests/helpers/emitted-diff.cjs::diffEmitted`'s doc comment). + */ +const mapOf = (paths) => new Map( + Object.entries(paths).map(([k, v]) => [k, typeof v === 'string' ? { reason: v } : v]), +); + // ─── Attribution: the conservation law ─────────────────────────────────────── test('unchanged hashes are not reported', () => { @@ -428,13 +412,12 @@ test('an unattributable-by-table path surfaces as an error', () => { // ─── Acknowledgment file ───────────────────────────────────────────────────── test('an acked ripple passes and is echoed', () => { - const ack = { version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'converter change, #2723' } } }; + const ackHash = mapOf({ [WORKFLOW_KEY]: { reason: 'converter change, #2723' } }); const r = diffEmitted({ baseline: mf({ [WORKFLOW_KEY]: 'aaa' }), current: mf({ [WORKFLOW_KEY]: 'bbb' }), changedPaths: ['README.md'], - ack, - baseAck: null, + ackHash, }); assert.equal(r.unattributable.length, 0); assert.equal(r.acked.length, 1); @@ -444,1950 +427,180 @@ test('an acked ripple passes and is echoed', () => { test('a stale ack entry fails', () => { // An ack that outlives its ripple pre-clears the NEXT one on that path. - const ack = { version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'old' } } }; + const ackHash = mapOf({ [WORKFLOW_KEY]: { reason: 'old' } }); const r = diffEmitted({ baseline: mf({ [WORKFLOW_KEY]: 'aaa' }), current: mf({ [WORKFLOW_KEY]: 'aaa' }), changedPaths: [], - ack, - baseAck: null, + ackHash, }); - assert.deepEqual(r.staleAcks, [WORKFLOW_KEY]); + assert.deepEqual(r.staleAcks, [{ key: WORKFLOW_KEY, space: 'hash' }]); assert.ok(!r.ok); assert.match(formatReport(r), /stale acknowledgment/); }); -test('an ack without a reason fails', () => { - for (const bad of [{ reason: '' }, { reason: ' ' }, {}, null, 42]) { +test('an ack Map entry is trusted verbatim — reason validation moved upstream to parseAckTrailers (#3942)', () => { + // Pre-#3942, `diffEmitted` parsed a raw ack DOCUMENT itself (`parseAck`), so it owned + // the "no non-empty reason" rejection this test used to assert. `ackHash`/`ackGrowth` + // are now pre-parsed `Map`s (see `diffEmitted`'s doc comment), and + // that validation moved to `parseAckTrailers`, whose own coverage + // (`tests/emitted-ack-trailer.test.cjs`) is where an empty/missing reason is rejected. + // `diffEmitted` itself now trusts a Map entry it is handed — including a technically + // empty-string reason — rather than re-validating it, so this proves the boundary + // moved rather than disappeared. + for (const reason of ['', ' ']) { const r = diffEmitted({ baseline: mf({ [WORKFLOW_KEY]: 'aaa' }), current: mf({ [WORKFLOW_KEY]: 'bbb' }), changedPaths: [], - ack: { version: ACK_VERSION, paths: { [WORKFLOW_KEY]: bad } }, - baseAck: null, + ackHash: mapOf({ [WORKFLOW_KEY]: { reason } }), }); - assert.ok(!r.ok, `${JSON.stringify(bad)} must be rejected`); - assert.match(r.errors.join('\n'), /has no non-empty "reason"/); - } -}); - -test('an absent ack file means no acks', () => { - const r = diffEmitted({ - baseline: mf({ [WORKFLOW_KEY]: 'aaa' }), - current: mf({ [WORKFLOW_KEY]: 'bbb' }), - changedPaths: [WORKFLOW_SRC], - ack: null, - }); - assert.equal(r.errors.length, 0); - assert.ok(r.ok, 'the healthy steady state is no ack file at all'); -}); - -test('a live ack and a stale ack together: only the stale one is named', () => { - const ack = { - version: ACK_VERSION, - paths: { - [WORKFLOW_KEY]: { reason: 'live ripple' }, - [SKILL_KEY]: { reason: 'stale' }, - }, - }; - const r = diffEmitted({ - baseline: mf({ [WORKFLOW_KEY]: 'aaa', [SKILL_KEY]: 'ccc' }), - current: mf({ [WORKFLOW_KEY]: 'bbb', [SKILL_KEY]: 'ccc' }), - changedPaths: [], - ack, - baseAck: null, - }); - assert.deepEqual(r.staleAcks, [SKILL_KEY], 'the live one must not be named'); -}); - -// ─── Ack lifecycle: an ack is scoped to the diff that introduced it (#2789) ── -// -// Every other input to the law is base-relative — `baseline` vs `current`, `changedPaths` -// from `git diff base...HEAD`. The ack set was the one absolute input, read only from -// HEAD. That mismatch is what made a MERGED ack look identical to a never-explained one: -// both present as "no delta consumed it", so merging an ack the PR lane had accepted -// reddened `next` and every PR branching off it (#2768). -// -// `baseAck` closes it. An entry already present at the base is SPENT — its ripple is -// absorbed, it is not this diff's to answer for, and it may no longer clear anything. - -test('an ack already present at the base is spent — not stale, and it does not fail', () => { - // The #2768 shape exactly: the ack merged, so the base carries it and no delta remains. - const ack = { version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'deliberate growth' } } }; - const r = diffEmitted({ - baseline: mf({ [WORKFLOW_KEY]: 'aaa' }), - current: mf({ [WORKFLOW_KEY]: 'aaa' }), - changedPaths: [], - ack, - baseAck: ack, - }); - assert.deepEqual(r.staleAcks, [], 'an absorbed ripple is the ack SUCCEEDING, not failing'); - assert.deepEqual(r.spentAcks, [WORKFLOW_KEY], 'still surfaced, so it can be cleaned up'); - assert.ok(r.ok); -}); - -test('a spent ack cannot pre-clear a NEW ripple on its own path', () => { - // ADR-2719's own named hazard. Today a leftover ack silently clears the next ripple; - // scoped to its diff it cannot, so the new ripple must be explained on its own terms. - const ack = { version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'last time' } } }; - const r = diffEmitted({ - baseline: mf({ [WORKFLOW_KEY]: 'aaa' }), - current: mf({ [WORKFLOW_KEY]: 'bbb' }), // a genuinely new, unexplained move - changedPaths: [], - ack, - baseAck: ack, - }); - assert.equal(r.acked.length, 0, 'a spent ack must not absorb a new ripple'); - assert.equal(r.unattributable.length, 1); - assert.ok(!r.ok); -}); - -test('re-arming a spent ack costs actual prose — not whitespace, not a decorative field', () => { - // Re-arming is legitimate; it is how a contributor says "this is a NEW ripple, and here - // is why". But the reason is the whole artifact a reviewer reads, so it must cost a - // real explanation. Both of these once re-armed an ack whose justification still - // described the PREVIOUS ripple, showing a reviewer nothing new in the ack file's diff. - const base = { version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'the same words' } } }; - const newRipple = { - baseline: mf({ [WORKFLOW_KEY]: 'aaa' }), - current: mf({ [WORKFLOW_KEY]: 'bbb' }), // genuinely new and unexplained - changedPaths: [], - baseAck: base, - }; - - const doubledSpace = diffEmitted({ - ...newRipple, - ack: { version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'the same words' } } }, - }); - assert.equal(doubledSpace.acked.length, 0, 'internal whitespace must not re-arm'); - assert.ok(!doubledSpace.ok); - - const decoratedField = diffEmitted({ - ...newRipple, - ack: { version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'the same words', runtime: 'claude' } } }, - }); - assert.equal(decoratedField.acked.length, 0, 'an unrelated field must not re-arm'); - assert.ok(!decoratedField.ok); - - // …while genuinely new prose still does. - const reworded = diffEmitted({ - ...newRipple, - ack: { version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'a different, specific explanation' } } }, - }); - assert.equal(reworded.acked.length, 1); - assert.ok(reworded.ok); -}); - -test('spent entries are reported sorted, and modelled in buildReport not just rendered', () => { - // Insertion order is deliberately REVERSE-sorted (`skills/…` before `gsd-core/…`), so - // the assertion bites: comparing against a sorted copy of the result would pass even - // with the sort deleted, and asserting on an already-ordered fixture proves nothing. - const both = { - version: ACK_VERSION, - paths: { [SKILL_KEY]: { reason: 'second' }, [WORKFLOW_KEY]: { reason: 'first' } }, - }; - assert.ok(SKILL_KEY > WORKFLOW_KEY, 'the fixture must be inserted out of order to be a real test'); - - const r = diffEmitted({ - baseline: mf({ 'gsd-core/workflows/zzz.md': 'aaa' }), - current: mf({ 'gsd-core/workflows/zzz.md': 'bbb' }), // an unrelated failure to render under - changedPaths: [], - ack: both, - baseAck: both, - }); - assert.deepEqual(r.spentAcks, [WORKFLOW_KEY, SKILL_KEY], 'spent entries must come back sorted'); - - const block = buildReport(r).blocks.find((b) => b.kind === 'spent-acks'); - assert.ok(block, 'spent acks must be modelled in the IR, so tests need no raw text matching'); - assert.equal(block.count, 2); - assert.deepEqual(block.items, r.spentAcks); -}); - -test('buildReport and formatReport agree about spent acks on a PASSING run', () => { - // `formatReport` is documented as a pure rendering of `buildReport`. The spent section - // is the one block whose emit-condition could drift, because a passing run must render - // nothing — so the IR must withhold it there too, or a JSON reporter built on the IR - // would report spent acks for a green run while the text reporter stayed silent. - const spent = { version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'absorbed' } } }; - const passing = diffEmitted({ - baseline: mf({ [WORKFLOW_KEY]: 'aaa' }), - current: mf({ [WORKFLOW_KEY]: 'aaa' }), - changedPaths: [], - ack: spent, - baseAck: spent, - }); - assert.ok(passing.ok); - assert.deepEqual(passing.spentAcks, [WORKFLOW_KEY], 'the datum is still on the result object'); - assert.equal(formatReport(passing), ''); - assert.equal( - buildReport(passing).blocks.find((b) => b.kind === 'spent-acks'), - undefined, - 'the IR must not carry a block the renderer suppresses', - ); -}); - -test('ackDocument survives a __proto__ key instead of silently teaching an empty document', () => { - // `key` comes from repo/emitted paths. On a plain object `__proto__` sets the prototype - // rather than a property, so JSON.stringify would emit `"paths":{}` — remediation text - // that teaches the contributor to acknowledge nothing at all. - const doc = JSON.parse(REMEDIATION.ackDocument([ - { key: '__proto__', reason: 'hostile key' }, - { key: 'plan-phase.md', reason: 'ordinary key' }, - ])); - assert.deepEqual(Object.keys(doc.paths).sort(), ['__proto__', 'plan-phase.md']); - assert.equal(doc.paths.__proto__.reason, 'hostile key'); - assert.equal(({}).reason, undefined, 'Object.prototype must be untouched'); -}); - -test('a clean run renders NOTHING, even when spent entries exist', () => { - // `formatReport` returning prose for an ok result reads as "something is wrong". - const spent = { version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'absorbed' } } }; - const r = diffEmitted({ - baseline: mf({ [WORKFLOW_KEY]: 'aaa' }), - current: mf({ [WORKFLOW_KEY]: 'aaa' }), - changedPaths: [], - ack: spent, - baseAck: spent, - }); - assert.ok(r.ok); - assert.deepEqual(r.spentAcks, [WORKFLOW_KEY]); - assert.equal(formatReport(r), '', 'a passing run must render an empty report'); -}); - -test('an ack whose reason changed in this diff is live again', () => { - const r = diffEmitted({ - baseline: mf({ [WORKFLOW_KEY]: 'aaa' }), - current: mf({ [WORKFLOW_KEY]: 'bbb' }), - changedPaths: [], - ack: { version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'THIS ripple, freshly explained' } } }, - baseAck: { version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'the previous one' } } }, - }); - assert.equal(r.acked.length, 1, 'rewriting the reason re-arms the ack for the new ripple'); - assert.deepEqual(r.staleAcks, []); - assert.ok(r.ok); -}); - -test('an ack absent from the base is live and consumes its ripple', () => { - const r = diffEmitted({ - baseline: mf({ [WORKFLOW_KEY]: 'aaa' }), - current: mf({ [WORKFLOW_KEY]: 'bbb' }), - changedPaths: [], - ack: { version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'new in this PR' } } }, - baseAck: { version: ACK_VERSION, paths: { [SKILL_KEY]: { reason: 'unrelated, already merged' } } }, - }); - assert.equal(r.acked.length, 1); - assert.deepEqual(r.staleAcks, []); - assert.ok(r.ok); -}); - -test('a LIVE ack that nothing consumes is still stale and still fails', () => { - // The softening must not reach the case the rule exists for: an ack written in THIS - // diff that never explained anything is an authoring mistake, and blame lands right. - const r = diffEmitted({ - baseline: mf({ [WORKFLOW_KEY]: 'aaa' }), - current: mf({ [WORKFLOW_KEY]: 'aaa' }), - changedPaths: [], - ack: { version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'explains nothing' } } }, - baseAck: { version: ACK_VERSION, paths: { [SKILL_KEY]: { reason: 'unrelated' } } }, - }); - assert.deepEqual(r.staleAcks, [WORKFLOW_KEY]); - assert.deepEqual(r.spentAcks, []); - assert.ok(!r.ok); -}); - -test('an absent or unreadable base ack inherits NOTHING — the gate stays armed', () => { - // Omission is not evidence that an entry was already merged. Every unknown here fails - // toward the strict reading, so a base we could not read cannot excuse a stale ack. - // `undefined` is deliberately NOT in this list: a destructuring default fires on it, so - // it takes the OMITTED path and fails with "baseAck was not supplied" — a different - // rule, covered by its own test above. Including it here would look like coverage of - // the staleness path while asserting something else entirely. - for (const baseAck of [null, {}, { version: ACK_VERSION }, 'not-an-object', 42, []]) { - const r = diffEmitted({ - baseline: mf({ [WORKFLOW_KEY]: 'aaa' }), - current: mf({ [WORKFLOW_KEY]: 'aaa' }), - changedPaths: [], - ack: { version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'x' } } }, - baseAck, - }); - assert.deepEqual(r.staleAcks, [WORKFLOW_KEY], `baseAck ${JSON.stringify(baseAck)} must inherit nothing`); - assert.ok(!r.ok); - } -}); - -// ─── Pre-merge lint parity (#2789) ─────────────────────────────────────────── -// -// `scripts/lint-emitted-drift-ack.cjs` blocks a broken ack document from ever reaching -// the base branch, where the base-side reader's (correct) loud failure would be -// expensive. It cannot reuse `parseAck`: `scripts/` ships in the npm package and `tests/` -// does not, so requiring across that line would be a MODULE_NOT_FOUND in the published -// package. Two validators of one schema is exactly the divergence this repo requires a -// parity assertion for — so the corpus below runs through BOTH and must get the same -// verdict from each. - -test('the pre-merge lint and the gate parser agree on what is schema-valid', () => { - const corpus = [ - // [label, raw text, expected schema-valid?] - ['absent-equivalent empty object', '{}', true], - ['versioned, no paths', '{"version":1}', true], - ['empty paths', '{"version":1,"paths":{}}', true], - ['one good entry', '{"version":1,"paths":{"a.md":{"reason":"why"}}}', true], - ['bare-string reason', '{"version":1,"paths":{"a.md":"why"}}', true], - ['no version key', '{"paths":{"a.md":{"reason":"why"}}}', true], - ['unknown extra field', '{"version":1,"paths":{"a.md":{"reason":"why","note":"x"}}}', true], - ['bad JSON', '{ not json', false], - ['array document', '[]', false], - ['scalar document', '42', false], - ['string document', '"nope"', false], - // NOT here: a document of literally `null`. `parseAck` uses null as its - // "absent == no acks" sentinel and reads it as legal, so it is schema-valid on both - // sides; the lint rejects it on POLICY instead. Covered in the entryless test below. - ['wrong version', '{"version":9,"paths":{}}', false], - ['paths is an array', '{"version":1,"paths":[]}', false], - ['paths is a scalar', '{"version":1,"paths":7}', false], - ['empty reason', '{"version":1,"paths":{"a.md":{"reason":""}}}', false], - ['whitespace reason', '{"version":1,"paths":{"a.md":{"reason":" "}}}', false], - ['missing reason', '{"version":1,"paths":{"a.md":{}}}', false], - ['numeric reason', '{"version":1,"paths":{"a.md":42}}', false], - // #2914 review: a `__proto__`/`constructor`/`prototype` key is a genuine OWN key on - // the production path (`JSON.parse`, unlike a JS object literal), and both surfaces - // must reject it outright rather than one silently filtering it and the other - // erroring or mishandling it. - ['reserved key __proto__', '{"version":1,"paths":{"__proto__":{"reason":"ok"}}}', false], - ['reserved key constructor', '{"version":1,"paths":{"constructor":{"reason":"ok"}}}', false], - ['reserved key prototype', '{"version":1,"paths":{"prototype":{"reason":"ok"}}}', false], - ]; - - for (const [label, raw, expectedValid] of corpus) { - const lint = validateAckText(raw); - const lintValid = lint.schemaErrors.length === 0; - - // The gate's own parser, fed the same document the same way `readAckFile` would. - let gateValid; - try { - gateValid = parseAck(JSON.parse(raw)).errors.length === 0; - } catch { - gateValid = false; // unparseable JSON never reaches parseAck; readAckFile throws first - } - - assert.equal(lintValid, expectedValid, `lint verdict for ${label}`); - assert.equal( - gateValid, lintValid, - `DIVERGENCE on ${label}: the pre-merge lint and parseAck disagree, so one of them ` - + 'would let a document through that the other rejects', - ); - } -}); - -test('a __proto__/constructor/prototype ack key is rejected loudly by both surfaces, never silently dropped (#2914 review)', () => { - // Built via JSON.parse — the production path — so the key is a genuine OWN property, - // never the JS object-literal special case (`{__proto__: v}` sets the prototype and - // yields zero own keys, which is what made the pre-fix regression test vacuous). - for (const key of ['__proto__', 'constructor', 'prototype']) { - const raw = JSON.stringify({ version: ACK_VERSION, paths: { [key]: { reason: 'hostile' } } }); - const doc = JSON.parse(raw); - assert.deepEqual(Object.keys(doc.paths), [key], `JSON.parse must create a genuine own key for ${key}`); - - const gate = parseAck(doc, { source: 'tests/emitted-drift-acks/1000-a.json' }); - assert.equal(gate.entries.size, 0, `${key} must never become a live ack entry`); - assert.equal(gate.errors.length, 1); - assert.match(gate.errors[0], /reserved/); - assert.match(gate.errors[0], new RegExp(key)); - - const lint = validateAckText(raw, { source: 'tests/emitted-drift-acks/1000-a.json' }); - assert.equal(lint.schemaErrors.length, 1); - assert.match(lint.schemaErrors[0], /reserved/); - assert.match(lint.schemaErrors[0], new RegExp(key)); - - // Recognizably the SAME finding on both surfaces, not merely both non-empty. - assert.equal( - gate.errors[0].replace('tests/emitted-drift-acks/1000-a.json', 'SOURCE'), - lint.schemaErrors[0].replace('tests/emitted-drift-acks/1000-a.json', 'SOURCE'), - `${key}: parseAck and validateAckText must report the same finding`, - ); - - assert.equal(({}).reason, undefined, 'Object.prototype must stay untouched throughout'); - } -}); - -test('the pre-merge lint and the gate helpers agree on the fragment-count cap (#2914 review)', () => { - // Duplicated by necessity (scripts/ ships, tests/ does not — see MAX_ACK_FRAGMENTS's - // doc comment in both files), so this parity test is what keeps the two values from - // silently drifting apart the way the schema rules above are held together. - assert.equal( - MAX_ACK_FRAGMENTS_LINT, MAX_ACK_FRAGMENTS, - 'scripts/lint-emitted-drift-ack.cjs and tests/helpers/emitted-diff.cjs must agree on ' - + 'MAX_ACK_FRAGMENTS', - ); -}); - -test('the lint additionally rejects a present-but-entryless document the parser accepts', () => { - // This is policy, not schema, and the one place the two surfaces are MEANT to differ: - // `parseAck` must treat `{}` as "no acks" (legal) so an absent-equivalent document - // never fails the gate mid-run, while the lint refuses to let one be COMMITTED, - // because it acknowledges nothing and only confuses the next reader. - // `null` belongs here rather than in the schema corpus: it is the gate's own - // "absent == no acks" sentinel, so it is legal to PARSE and still wrong to COMMIT. - for (const raw of ['{}', '{"version":1}', '{"version":1,"paths":{}}', 'null']) { - const r = validateAckText(raw); - assert.deepEqual(r.schemaErrors, [], `${raw} must be schema-valid`); - assert.equal(r.policyErrors.length, 1, `${raw} must trip the delete-the-file policy`); - assert.ok(!r.ok); - assert.deepEqual(parseAck(JSON.parse(raw)).errors, [], `${raw} must stay legal for the gate`); - } -}); - -test('the lint passes on an absent file — the healthy steady state', () => { - const r = validateAckText(null); - assert.deepEqual(r.schemaErrors, []); - assert.deepEqual(r.policyErrors, []); - assert.ok(r.ok); -}); - -test('the lint rejects a present-but-empty file rather than reading it as absent', () => { - for (const raw of ['', ' ', '\n\t ']) { - const r = validateAckText(raw); - assert.equal(r.schemaErrors.length, 1, `${JSON.stringify(raw)} must be rejected`); - // Asserting the SPECIFIC message, not just the count: deleting the empty-file branch - // leaves `JSON.parse('')` throwing its own single error, so a bare count passes either - // way and the branch can be removed with no test failing. - assert.match(r.schemaErrors[0], /present but empty/, `${JSON.stringify(raw)} must name emptiness`); - assert.ok(!r.ok); - } -}); - -test('validateAckText names the SOURCE it is checking, not a hardcoded literal (#2914)', () => { - // Generalized so the lint can run the SAME rules over a fragment as over the legacy - // file. A message that hardcoded tests/emitted-drift-ack.json would misname every - // fragment's own errors. - const r = validateAckText('{"version":1,"paths":{"a.md":{"reason":""}}}', { - source: 'tests/emitted-drift-acks/1000-a.json', - }); - assert.match(r.schemaErrors[0], /tests\/emitted-drift-acks\/1000-a\.json/); -}); - -test('declaredKeys: only a schema-trustworthy document contributes keys for collision detection', () => { - const { declaredKeys } = require('../scripts/lint-emitted-drift-ack.cjs'); - assert.deepEqual(declaredKeys(null), []); - assert.deepEqual(declaredKeys('{ not json'), [], 'unparseable JSON contributes no keys'); - assert.deepEqual(declaredKeys('[]'), [], 'a non-object document contributes no keys'); - assert.deepEqual(declaredKeys('{"paths":{"a.md":{"reason":"r"}}}'), ['a.md']); - assert.deepEqual( - declaredKeys('{"paths":{"__proto__":{"reason":"r"}}}'), [], - '__proto__ is excluded from collision detection as belt-and-suspenders — in practice ' - + 'main() never reaches this on such a document, because validateAckText already ' - + 'rejects it outright (#2914 review), so there is no schema-valid document left for ' - + 'declaredKeys to see it on', - ); -}); - -test('listFragmentFiles: absent directory is zero fragments, present directory is sorted .json only', () => { - const { listFragmentFiles } = require('../scripts/lint-emitted-drift-ack.cjs'); - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-lint-frag-')); - try { - assert.deepEqual(listFragmentFiles(path.join(dir, 'missing')), []); - fs.writeFileSync(path.join(dir, '2000-z.json'), '{}'); - fs.writeFileSync(path.join(dir, '1000-a.json'), '{}'); - fs.writeFileSync(path.join(dir, 'notes.txt'), 'nope'); - assert.deepEqual(listFragmentFiles(dir), ['1000-a.json', '2000-z.json']); - } finally { - cleanup(dir); - } -}); - -test('listFragmentFiles: exactly MAX_ACK_FRAGMENTS entries passes, one over fails loudly (#2914 review)', () => { - const { listFragmentFiles } = require('../scripts/lint-emitted-drift-ack.cjs'); - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-lint-frag-cap-')); - try { - for (let i = 0; i < MAX_ACK_FRAGMENTS; i++) { - fs.writeFileSync(path.join(dir, `f-${String(i).padStart(4, '0')}.json`), '{}'); - } - assert.equal(listFragmentFiles(dir).length, MAX_ACK_FRAGMENTS, 'at the cap must still pass'); - - fs.writeFileSync(path.join(dir, `f-${String(MAX_ACK_FRAGMENTS).padStart(4, '0')}.json`), '{}'); - assert.throws( - () => listFragmentFiles(dir), - (err) => { - assert.match(err.message, new RegExp(escapeRegex(dir))); - assert.match(err.message, new RegExp(String(MAX_ACK_FRAGMENTS + 1))); - assert.match(err.message, new RegExp(String(MAX_ACK_FRAGMENTS))); - return true; - }, - 'one over the cap must throw, naming the directory, the cap, and the actual count', - ); - } finally { - cleanup(dir); - } -}); - -// ─── guard-no-ack-on-next: presence itself is the failure (#2914) ──────────── -// -// Unlike `validateAckText` above (a PR-lane shape lint that must let a live, well-formed -// ack through), `assertAbsentOnNext` runs ONLY against `next` itself — see the -// `guard-no-ack-on-next` job in `.github/workflows/test.yml`, gated on push to `next` — and -// rejects PRESENCE outright, valid or not. Per the ack-lifecycle law (#2789), an entry -// already at the base is spent the moment it merges, so the shape never matters here. -// `assertAbsentOnNext` takes only the boolean `present` — an entryless-vs-populated -// distinction is collapsed to that boolean before this function ever sees it (see -// `main()`'s `fs.existsSync` call in `scripts/lint-emitted-drift-ack.cjs`), so no test -// here can exercise that distinction: there is deliberately no separate "entryless" -// case below, since one would be identical in input and assertion to the populated -// case and would claim coverage the function structurally cannot provide. - -test('assertAbsentOnNext passes when the file is absent — the healthy steady state', () => { - const r = assertAbsentOnNext(false); - assert.ok(r.ok); - assert.match(r.message, /absent \(the healthy steady state\)/); -}); - -test('assertAbsentOnNext fails when the file is present with entries, naming the file and the remedy', () => { - const r = assertAbsentOnNext(true); - assert.ok(!r.ok); - assert.match(r.message, /tests\/emitted-drift-ack\.json exists on next/); - assert.match(r.message, /spent and inert/); - assert.match(r.message, /delete the file too/, 'must cite CONTRIBUTING.md\'s delete-the-file rule'); - assert.match(r.message, /git rm tests\/emitted-drift-ack\.json/, 'the remedy must be named, not just the problem'); - assert.match( - r.message, /tests\/emitted-drift-acks\//, - '#2914: the message must explain that acks now go in per-PR fragments', - ); - // #3078 removed the premise this used to assert: a persisting fragment is NOT harmless - // just because it does not share a FILE with another PR — fragments share a PATH KEY - // SPACE, and a duplicate path across two sources is a hard failure in main(), so a - // fully-spent fragment left behind still owns keys it can no longer gate. - assert.doesNotMatch( - r.message, /cannot conflict with any other PR/, - '#3078: this premise was false and has been removed — a fragment cannot MERGE-CONFLICT ' - + 'with another PR\'s fragment, but it can still collide on a path key', - ); - assert.match(r.message, /#3078/); -}); - -test('assertAbsentOnNext fed from a real next-like tree: absent passes, present fails (regression, #2914)', () => { - // A throwaway directory standing in for `next`'s tree, so the check exercises real - // fs.existsSync semantics on ACK_REPO_PATH rather than a hand-picked boolean. - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-guard-next-')); - const ackPath = path.join(dir, ACK_REPO_PATH); - fs.mkdirSync(path.dirname(ackPath), { recursive: true }); - - assert.ok( - assertAbsentOnNext(fs.existsSync(ackPath)).ok, - 'a fresh tree with no ack file must pass', - ); - - // Reproduces the exact #2834/#2900 shape: 34 spent entries surviving on next. - fs.writeFileSync(ackPath, JSON.stringify({ version: 1, paths: { 'a.md': { reason: 'spent' } } })); - const r = assertAbsentOnNext(fs.existsSync(ackPath)); - assert.ok(!r.ok, 'a tree carrying the file, however well-formed, must fail'); - assert.match(r.message, /exists on next/); -}); - -// ─── guard-no-ack-on-next: fully-spent FRAGMENTS are guarded on inertness (#3078) ── -// -// #2914 exempted the fragment directory from `assertAbsentOnNext` on the premise that -// a persistent fragment "cannot conflict with any other PR". That premise is false: -// fragments do not share a FILE, but they do share a PATH KEY SPACE, and a duplicate -// path declared by two sources is a hard failure in `main()` below. So a merged, -// fully-spent fragment still OWNS its path keys, and the next PR that grows one of -// those paths can declare it neither there (spent — `parseAck`'s `isSpent`/`prose` -// contract) nor in its own fragment (a cross-source duplicate). Unlike the legacy -// file, PRESENCE alone is not the failure here — a fragment landed by the very push -// being guarded is the healthy case for every ack-carrying PR. The failure is -// INERTNESS: every surviving entry's prose already matches the base ref's copy, so -// the fragment can no longer clear a delta for anyone. - -const doc = (paths) => JSON.stringify({ version: ACK_VERSION, paths }); -const frag = (name, current, base) => ({ name, currentRaw: current, baseRaw: base }); - -// ── A. assertNoAllSpentFragments — the pure lifecycle rule ────────────────── - -test('ackEntries: a literal "null" document is unreadable, distinct from an entryless object document', () => { - // The distinction assertNoAllSpentFragments's "literal null" test above depends on: - // an entryless OBJECT document is a genuine empty entry set (`new Map()`), while the - // TEXT "null" parses to the JS value `null`, fails isPlainObject, and returns the - // "cannot trust this document" sentinel instead. - assert.deepEqual(ackEntries('{}'), new Map()); - assert.deepEqual(ackEntries('{"version":1,"paths":{}}'), new Map()); - assert.equal(ackEntries('null'), null); - assert.equal(ackEntries(null) instanceof Map, true, 'an ABSENT fragment (raw === null) is a genuine empty entry set'); - assert.deepEqual(ackEntries(null), new Map()); -}); - -test('assertNoAllSpentFragments: zero fragments is ok, vacuously', () => { - const r = assertNoAllSpentFragments([]); - assert.ok(r.ok); - assert.match(r.message, /no all-spent fragment survives/); - assert.deepEqual(r.sweepable, []); -}); - -test('assertNoAllSpentFragments: a fragment absent at the base is live — the healthy shape of every fresh PR', () => { - // A fragment landed by the very push being guarded is the NORMAL case for every - // ack-carrying PR. Reporting it would red `next` on every such merge. - const r = assertNoAllSpentFragments([ - frag('a.json', doc({ 'x.md': { reason: 'why' } }), null), - ]); - assert.ok(r.ok); - assert.deepEqual(r.sweepable, []); -}); - -test('assertNoAllSpentFragments: a fully-spent fragment is reported, naming the file, "all spent", and the exact git rm remedy (n=1, n=3)', () => { - for (const entries of [ - { 'x.md': { reason: 'why' } }, - { 'a.md': { reason: 'a' }, 'b.md': { reason: 'b' }, 'c.md': { reason: 'c' } }, - ]) { - const raw = doc(entries); - const r = assertNoAllSpentFragments([frag('spent.json', raw, raw)]); - assert.ok(!r.ok, `${Object.keys(entries).length} entr(y/ies) must still be reported all-spent`); - assert.match(r.message, /spent\.json/); - assert.match(r.message, /all spent/); - assert.match(r.message, new RegExp(escapeRegex(`git rm ${ACK_DIR_REPO_PATH}/spent.json`))); - assert.deepEqual(r.sweepable, ['spent.json']); - } -}); - -test('assertNoAllSpentFragments: a partially spent fragment (one entry new) is left alone', () => { - // Named explicitly as a must-not-change: appending a new entry beside an - // already-spent one must not get swept out from under the live one. - const r = assertNoAllSpentFragments([ - frag( - 'mixed.json', - doc({ x: { reason: 'x-reason' }, y: { reason: 'y-reason' } }), - doc({ x: { reason: 'x-reason' } }), - ), - ]); - assert.ok(r.ok); - assert.deepEqual(r.sweepable, []); -}); - -test('assertNoAllSpentFragments: re-arming by appending genuinely new prose keeps working (#2639, #2993)', () => { - const r = assertNoAllSpentFragments([ - frag('r.json', doc({ x: { reason: 'a brand new explanation' } }), doc({ x: { reason: 'the original explanation' } })), - ]); - assert.ok(r.ok, 're-arming by appending genuinely new prose must not be swept'); -}); - -test('assertNoAllSpentFragments: a zero-information reword never looks like a re-arm — doubled whitespace, leading/trailing whitespace, CRLF vs LF', () => { - const cases = [ - ['doubled internal whitespace', 'the original explanation', 'the original explanation'], - ['leading/trailing whitespace', ' the original explanation ', 'the original explanation'], - ['CRLF vs LF inside the reason', 'the original\r\nexplanation', 'the original\nexplanation'], - ]; - for (const [label, current, base] of cases) { - const r = assertNoAllSpentFragments([ - frag('r.json', doc({ x: { reason: current } }), doc({ x: { reason: base } })), - ]); - assert.ok(!r.ok, `${label}: a zero-information reword must still read as spent`); - assert.deepEqual(r.sweepable, ['r.json'], label); - } -}); - -test('assertNoAllSpentFragments: each invisible codepoint alone must not re-arm a spent entry', () => { - const codepoints = [0x00AD, 0x200B, 0x200C, 0x200D, 0x2060, 0xFEFF]; - for (const cp of codepoints) { - const base = 'the original explanation'; - const current = `the${String.fromCodePoint(cp)} original explanation`; - const r = assertNoAllSpentFragments([ - frag('r.json', doc({ x: { reason: current } }), doc({ x: { reason: base } })), - ]); - assert.ok(!r.ok, `U+${cp.toString(16).toUpperCase()} alone must not re-arm a spent entry`); - } -}); - -test('assertNoAllSpentFragments: every SURVIVING entry is spent, even after one entry is dropped', () => { - const base = doc({ x: { reason: 'x-reason' }, y: { reason: 'y-reason' } }); - const current = doc({ x: { reason: 'x-reason' } }); - const r = assertNoAllSpentFragments([frag('drop.json', current, base)]); - assert.ok(!r.ok); - assert.deepEqual(r.sweepable, ['drop.json']); -}); - -test('assertNoAllSpentFragments: disjoint key sets between current and base is not spent', () => { - const r = assertNoAllSpentFragments([ - frag('disjoint.json', doc({ x: { reason: 'x' } }), doc({ y: { reason: 'y' } })), - ]); - assert.ok(r.ok); -}); - -test('assertNoAllSpentFragments: an entryless-but-parseable-object document is vacuously all-spent (n=0 boundary)', () => { - // Vacuously all-spent: `[...new Map()].every(...)` is true, so an object document - // declaring zero entries is swept for the same reason validateAckText refuses to let - // one be committed — it acknowledges nothing. - for (const raw of ['{}', '{"version":1}', '{"version":1,"paths":{}}']) { - const r = assertNoAllSpentFragments([frag('empty.json', raw, raw)]); - assert.ok(!r.ok, `${raw} must be swept`); - assert.deepEqual(r.sweepable, ['empty.json'], raw); - } -}); - -test('assertNoAllSpentFragments: a document of literally "null" is UNREADABLE here, not entryless, so it is left alone', () => { - // Distinct from the object-shaped entryless case above: `ackEntries` parses the text - // "null" to the JS value `null`, which fails `isPlainObject` and returns `null` - // (its "cannot trust this document" sentinel) — NOT `new Map()`. So this fragment is - // skipped by assertNoAllSpentFragments entirely, same as any other unreadable - // document (see the next test) — it is never counted as vacuously spent, unlike - // validateAckText's POLICY layer, which does reject "null" (present-but-entryless). - // The two surfaces are allowed to differ here: this guard owns LIFECYCLE, not shape. - const r = assertNoAllSpentFragments([frag('literal-null.json', 'null', 'null')]); - assert.ok(r.ok, 'a literal "null" document must not be swept by this guard'); - assert.deepEqual(r.sweepable, []); -}); - -test('assertNoAllSpentFragments: a document it cannot trust to answer the question is left alone, never swept', () => { - // validateAckText / lint:ci owns SHAPE; this guard owns LIFECYCLE. Sweeping a - // fragment on the strength of a parse failure would delete an acknowledgment for - // the wrong reason. - const validBase = doc({ x: { reason: 'x' } }); - for (const currentRaw of ['{ not json', '[]', '42', '{"paths":[]}', '{"paths":{"x":42}}']) { - const r = assertNoAllSpentFragments([frag('bad.json', currentRaw, validBase)]); - assert.ok(r.ok, `unreadable current ${currentRaw} must not be swept`); - assert.deepEqual(r.sweepable, []); - } - - const r2 = assertNoAllSpentFragments([frag('bad-base.json', validBase, '{ not json')]); - assert.ok(r2.ok, 'an unreadable base must not be swept either'); - assert.deepEqual(r2.sweepable, []); -}); - -test('assertNoAllSpentFragments: naming ONLY the inert fragments makes the remedy safe to apply blindly', () => { - const spentA = doc({ a: { reason: 'a' } }); - const spentB = doc({ b: { reason: 'b' } }); - const live = doc({ c: { reason: 'c-new' } }); - const liveBase = doc({ c: { reason: 'c-old' } }); - const r = assertNoAllSpentFragments([ - frag('spent-a.json', spentA, spentA), - frag('spent-b.json', spentB, spentB), - frag('live-c.json', live, liveBase), - ]); - assert.ok(!r.ok); - assert.deepEqual([...r.sweepable].sort(), ['spent-a.json', 'spent-b.json']); - assert.doesNotMatch(r.message, /live-c\.json/); -}); - -test('assertNoAllSpentFragments: a __proto__ key is never mistaken for a spent entry, and Object.prototype stays untouched', () => { - // A `[key]` computed property, never a literal `{ __proto__: ... }` — the literal - // form is special-cased by JS to SET THE PROTOTYPE rather than create an own - // property, which would make `paths` serialize as `{}` and this test vacuous. - const key = '__proto__'; - const raw = JSON.stringify({ version: ACK_VERSION, paths: { [key]: { reason: 'hostile' } } }); - const parsedPaths = JSON.parse(raw).paths; - assert.deepEqual(Object.keys(parsedPaths), ['__proto__'], 'JSON.parse must create a genuine own key'); - const r = assertNoAllSpentFragments([frag('proto.json', raw, raw)]); - assert.ok(r.ok, '__proto__ makes the document unreadable to ackEntries, so it must never be swept'); - assert.deepEqual(r.sweepable, []); - assert.equal(({}).reason, undefined, 'Object.prototype must stay untouched throughout'); -}); - -test('assertNoAllSpentFragments: a bare-string reason and an object-shaped reason are compared on prose alone', () => { - const bare = doc({ x: 'why' }); - const bareR = assertNoAllSpentFragments([frag('bare.json', bare, bare)]); - assert.ok(!bareR.ok, 'a bare-string reason on both sides must still be recognized as spent'); - - const objCurrent = doc({ x: { reason: 'why' } }); - const bareBase = doc({ x: 'why' }); - const shapeR = assertNoAllSpentFragments([frag('shape.json', objCurrent, bareBase)]); - assert.ok(!shapeR.ok, 'a shape change alone must not re-arm — parseAck accepts both shapes'); -}); - -test('assertNoAllSpentFragments: a decorative "runtime" field beside an unchanged reason does not re-arm (runtime is deliberately not compared)', () => { - const base = doc({ x: { reason: 'why' } }); - const current = doc({ x: { reason: 'why', runtime: 'claude' } }); - const r = assertNoAllSpentFragments([frag('runtime.json', current, base)]); - assert.ok(!r.ok, 'runtime carries no explanation and must not re-arm a byte-identical reason'); -}); - -// ── B. prose parity with the gate (the generative-fix-divergence gate) ────── -// -// `scripts/` ships in the npm package and `tests/` does not, so `ackProse` MUST -// duplicate `emitted-diff.cjs`'s `prose()`. This section is the parity assertion -// that fails when they diverge. - -test('ACK_INVISIBLE and the gate\'s own INVISIBLE are the identical regex, not a second hand-typed copy (#3078)', () => { - // scripts/ ships in the npm package and tests/ does not, so ACK_INVISIBLE is a literal - // duplicate of INVISIBLE (tests/helpers/emitted-diff.cjs) rather than a require across - // that line — see both files' top-of-file comments. A divergence here means an - // invisible reword can re-arm a spent ack on the real `--guard-next` gate and NOT on - // this suite (or vice versa), which is exactly the class of drift a parity test that - // compares a hardcoded literal against its own definition can never catch. - assert.equal( - ACK_INVISIBLE.source, INVISIBLE.source, - 'scripts/lint-emitted-drift-ack.cjs\'s ACK_INVISIBLE and tests/helpers/emitted-diff.cjs\'s ' - + 'INVISIBLE must declare the identical character class — they are duplicated only because ' - + 'scripts/ ships in the npm package and tests/ does not', - ); - assert.equal( - ACK_INVISIBLE.flags, INVISIBLE.flags, - 'both are duplicated for the same reason and must agree on flags too (both carry "g", ' - + 'which is exactly what makes .test() below stateful and in need of a lastIndex reset)', - ); - - // Derive the codepoint list from the REAL gate regex rather than hardcoding one here. - // A hardcoded list would pass even if someone added a codepoint to only one side — the - // exact divergence this test exists to catch. `.test()` on a `g`-flagged regex advances - // `lastIndex` as a side effect, so it is reset before and after every call. - for (let cp = 0x0000; cp <= 0xFFFF; cp++) { - const ch = String.fromCodePoint(cp); - - INVISIBLE.lastIndex = 0; - const gateStrips = INVISIBLE.test(ch); - INVISIBLE.lastIndex = 0; - - ACK_INVISIBLE.lastIndex = 0; - const ackStrips = ACK_INVISIBLE.test(ch); - ACK_INVISIBLE.lastIndex = 0; - - const hex = cp.toString(16).toUpperCase().padStart(4, '0'); - assert.equal(ackStrips, gateStrips, `U+${hex}: ACK_INVISIBLE and INVISIBLE disagree on whether it is invisible`); - - if (gateStrips) { - assert.equal(ackProse(`a${ch}b`), 'ab', `ackProse must strip U+${hex}`); - assert.equal(normalizeAckReason(`a${ch}b`), 'ab', `normalizeAckReason must strip U+${hex}`); - } else if (!/\s/.test(ch)) { - // Not invisible, and not plain whitespace either (whitespace-collapse behavior - // depends on adjacency, so it is exercised separately below) — must survive as-is. - assert.equal(ackProse(`a${ch}b`), `a${ch}b`, `ackProse must NOT strip U+${hex}`); - assert.equal(normalizeAckReason(`a${ch}b`), `a${ch}b`, `normalizeAckReason must NOT strip U+${hex}`); - } - } -}); - -test('normalizeAckReason (the gate\'s own prose normalizer) and ackProse agree byte-for-byte over a behavioral corpus (#3078)', () => { - const invisibleCps = [0x00AD, 0x200B, 0x200C, 0x200D, 0x2060, 0xFEFF]; - const corpus = [ - 'identical text', - 'doubled internal space', - ' leading and trailing space ', - 'crlf\r\nline', - 'lf\nline', - 'tab\ttab', - 'nbsp nbsp', // U+00A0 NBSP - 'ideographic space', // U+3000 IDEOGRAPHIC SPACE - 'em space', // U+2003 EM SPACE - ...invisibleCps.map((cp) => `invisible${String.fromCodePoint(cp)}codepoint`), - '', - ' \t\n ', - ]; - for (const reason of corpus) { - assert.equal( - normalizeAckReason(reason), ackProse(reason), - `normalizeAckReason and ackProse diverge on ${JSON.stringify(reason)}`, - ); - } -}); - -test('ackProse and the sweep guard agree on what counts as "the same explanation, reworded"', () => { - const corpus = [ - ['identical', 'same words', 'same words', true], - ['doubled space', 'same words', 'same words', true], - ['leading/trailing space', ' same words ', 'same words', true], - ['CRLF vs LF', 'same\r\nwords', 'same\nwords', true], - ['NBSP vs space', 'same words', 'same words', true], - ['ideographic space vs space', 'same words', 'same words', true], - ['EM SPACE vs space', 'same words', 'same words', true], - ['zero-width inserted', 'same​words', 'samewords', true], - ['genuinely different words', 'same words', 'different words', false], - ]; - - for (const [label, a, b, expectedSameProse] of corpus) { - assert.equal( - ackProse(a) === ackProse(b), expectedSameProse, - `${label}: ackProse must agree it is${expectedSameProse ? '' : ' NOT'} the same explanation`, - ); - - const gate = assertNoAllSpentFragments([ - frag('r.json', doc({ x: { reason: a } }), doc({ x: { reason: b } })), - ]); - assert.equal( - !gate.ok, expectedSameProse, - `${label}: a divergence here means the sweep guard and the gate disagree about what ` - + 're-arms an ack', - ); - } -}); - -test('property: normalizeAckReason (the gate) and ackProse agree for arbitrary strings, seeded (#3078)', () => { - // Two-sided over unconstrained input, not just the constructed corpus above — a - // divergence anywhere in fc.string()'s space fails here, not just at the handful of - // codepoints the corpus test happens to name. - fc.assert( - fc.property( - fc.string(), - (s) => { - assert.equal(normalizeAckReason(s), ackProse(s)); - }, - ), - { seed: 3078, numRuns: 200 }, - ); -}); - -test('property: ackProse and normalizeAckReason agree, and both are invariant under expanding existing whitespace and inserting invisible codepoints, seeded (#3078)', () => { - const invisibleChars = [0x00AD, 0x200B, 0x200C, 0x200D, 0x2060, 0xFEFF].map((cp) => String.fromCodePoint(cp)); - const word = fc.string({ minLength: 1, maxLength: 8 }).filter((s) => !/\s/.test(s)); - - fc.assert( - fc.property( - fc.array(word, { minLength: 1, maxLength: 6 }), - fc.constantFrom(' ', '\t', '\n', '\r\n', ' ', ' ', ' '), - fc.array(fc.constantFrom(...invisibleChars), { minLength: 0, maxLength: 6 }), - (parts, whitespaceRun, invisibles) => { - const base = parts.join(' '); - // Every existing single space widened to a longer whitespace RUN — never - // introduces whitespace where none existed, so the collapsed result cannot - // change. - const expanded = parts.join(whitespaceRun); - // Invisible codepoints inserted anywhere are stripped outright, never - // collapsed to a space, so they never introduce a new word boundary. - const withInvisibles = invisibles.reduce((s, ch) => ch + s, base); - const padded = ` ${base} `; - - // Two-sided on every one of these forms, not just the base string. - for (const candidate of [base, expanded, withInvisibles, padded]) { - assert.equal(normalizeAckReason(candidate), ackProse(candidate)); - } - - assert.equal(ackProse(expanded), ackProse(base)); - assert.equal(ackProse(withInvisibles), ackProse(base)); - assert.equal(ackProse(padded), ackProse(base)); - }, - ), - { seed: 3078, numRuns: 200 }, - ); -}); - -// ── C. --guard-next wiring against a REAL git repository ──────────────────── -// -// The pure function above is fed hand-built strings; this section proves the -// WIRING — real commits, real `git show` reads — because a guard that resolves no -// base passes vacuously, and that is exactly how the legacy half went blind. - -// #2767: the remote runner mounts the repo at a path owned by another uid, and git then -// refuses every operation there with "detected dubious ownership". Names the SPECIFIC -// directory, never the `*` wildcard. Mirrors `safeDirArgs` in helpers/emitted-runtime.cjs. -const gitIn = (dir, args) => execFileSync( - 'git', ['-c', `safe.directory=${path.resolve(dir)}`, ...args], - { cwd: dir, encoding: 'utf8', timeout: 15000 }, -); - -function makeGuardNextRepo() { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-guard-next-repo-')); - const runGit = (args) => gitIn(dir, args); - runGit(['init', '-q', '-b', 'next']); - function commit(msg) { - runGit(['-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'add', '-A']); - runGit(['-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-q', '-m', msg]); - return runGit(['rev-parse', 'HEAD']).trim(); - } - function writeFrag(name, obj) { - const fragDir = path.join(dir, ...ACK_DIR_REPO_PATH.split('/')); - fs.mkdirSync(fragDir, { recursive: true }); - fs.writeFileSync(path.join(fragDir, name), JSON.stringify(obj)); - } - return { dir, commit, writeFrag }; -} - -// Section C used to reimplement git-reading in a local `readFragAtCommit` helper and -// feed only the PURE `assertNoAllSpentFragments`, so the real wiring — the -// `ls-tree`-then-`show` absence-vs-fault discrimination, the `HEAD`-then-`HEAD^` -// two-step, the root-commit fallback, and the option-injection guard — was never -// executed by any test (#3078 review). C1-C4 below now call the REAL -// `readFragmentAtRef`, exported from `scripts/lint-emitted-drift-ack.cjs` for exactly -// this purpose. - -test('C1: real repo — a fragment added in commit 2 vs commit 1 (root, no fragment) is live', () => { - const repo = makeGuardNextRepo(); - try { - fs.writeFileSync(path.join(repo.dir, 'README.md'), 'root\n'); - const c1 = repo.commit('root'); - repo.writeFrag('a.json', { version: ACK_VERSION, paths: { 'x.md': { reason: 'why' } } }); - repo.commit('add fragment'); - - const r = assertNoAllSpentFragments([ - frag( - 'a.json', - readFragmentAtRef('HEAD', 'a.json', { cwd: repo.dir }), - readFragmentAtRef(c1, 'a.json', { cwd: repo.dir }), - ), - ]); - assert.ok(r.ok, 'a fragment absent at the base is live'); - } finally { - cleanup(repo.dir); - } -}); - -test('C2: real repo — an unrelated file change leaves an unchanged fragment fully spent', () => { - const repo = makeGuardNextRepo(); - try { - repo.writeFrag('a.json', { version: ACK_VERSION, paths: { 'x.md': { reason: 'why' } } }); - const c2 = repo.commit('add fragment'); - fs.writeFileSync(path.join(repo.dir, 'README.md'), 'unrelated\n'); - repo.commit('unrelated change'); - - const r = assertNoAllSpentFragments([ - frag( - 'a.json', - readFragmentAtRef('HEAD', 'a.json', { cwd: repo.dir }), - readFragmentAtRef(c2, 'a.json', { cwd: repo.dir }), - ), - ]); - assert.ok(!r.ok, 'unchanged fragment against its own prior commit is fully spent'); - } finally { - cleanup(repo.dir); - } -}); - -test('C3: real repo — appending prose to the owning entry re-arms it', () => { - const repo = makeGuardNextRepo(); - try { - repo.writeFrag('a.json', { version: ACK_VERSION, paths: { 'x.md': { reason: 'the original explanation' } } }); - const c2 = repo.commit('add fragment'); - repo.writeFrag('a.json', { - version: ACK_VERSION, - paths: { 'x.md': { reason: 'the original explanation, now covering a new ripple too' } }, - }); - repo.commit('reword'); - - const r = assertNoAllSpentFragments([ - frag( - 'a.json', - readFragmentAtRef('HEAD', 'a.json', { cwd: repo.dir }), - readFragmentAtRef(c2, 'a.json', { cwd: repo.dir }), - ), - ]); - assert.ok(r.ok, 'genuinely new prose re-arms the entry'); - } finally { - cleanup(repo.dir); - } -}); - -test('C4: real repo — a fragment at the ROOT commit compared against a null base is live', () => { - const repo = makeGuardNextRepo(); - try { - repo.writeFrag('a.json', { version: ACK_VERSION, paths: { 'x.md': { reason: 'why' } } }); - repo.commit('root with fragment'); - - // The base is passed as a literal `null`, not a call to readFragmentAtRef(null, …) - // — this mirrors main()'s own `baseRef === null ? null : readFragmentAtRef(...)` - // branch for a root commit, where resolveBaseRef() has already returned null. - const r = assertNoAllSpentFragments([ - frag('a.json', readFragmentAtRef('HEAD', 'a.json', { cwd: repo.dir }), null), - ]); - assert.ok(r.ok, 'a root commit has no base — resolveBaseRef returns null and nothing can be spent against it'); - } finally { - cleanup(repo.dir); - } -}); - -// ── C2 (direct). Direct coverage of the real git seam (#3078 review) ──────── -// -// C1-C4 above exercise `readFragmentAtRef` only through `assertNoAllSpentFragments`'s -// verdict, which cannot distinguish "read the wrong thing" from "read nothing" if both -// happen to produce the same pure-function outcome. The tests below assert on -// `readFragmentAtRef`, `resolveBaseRef`, and `assertUsableBaseRef` DIRECTLY, plus one -// end-to-end run of the real script as a subprocess — the only thing that exercises -// `main()`'s own argv parsing. `makeGuardNextRepo()` above already satisfies the "one -// makeRepo() helper" shape this needs (mkdtempSync, `git init -q -b next`, per-commit -// identity via `-c user.email=... -c user.name=...`, never mutating global git config), -// so it is reused rather than duplicated. - -test('readFragmentAtRef: returns null for a fragment genuinely absent at that ref — distinguished via ls-tree, not a git-show error message (healthy steady state)', () => { - const repo = makeGuardNextRepo(); - try { - fs.writeFileSync(path.join(repo.dir, 'README.md'), 'root\n'); - const c1 = repo.commit('root, no fragment yet'); - repo.writeFrag('a.json', { version: ACK_VERSION, paths: { 'x.md': { reason: 'why' } } }); - repo.commit('add fragment'); - - assert.equal( - readFragmentAtRef(c1, 'a.json', { cwd: repo.dir }), null, - 'a fragment not yet added at that ref must read as null, not throw', - ); - } finally { - cleanup(repo.dir); - } -}); - -test('readFragmentAtRef: returns the exact bytes committed at that ref, which differ from an uncommitted working-tree edit — proves it reads the REF, not the tree', () => { - const repo = makeGuardNextRepo(); - try { - repo.writeFrag('a.json', { version: ACK_VERSION, paths: { 'x.md': { reason: 'original' } } }); - const c1 = repo.commit('add fragment'); - // Deliberately left UNCOMMITTED — only the working tree carries this edit. - repo.writeFrag('a.json', { version: ACK_VERSION, paths: { 'x.md': { reason: 'edited after the commit' } } }); - - const atRef = readFragmentAtRef(c1, 'a.json', { cwd: repo.dir }); - const onDisk = fs.readFileSync(path.join(repo.dir, ...ACK_DIR_REPO_PATH.split('/'), 'a.json'), 'utf8'); - assert.equal(JSON.parse(atRef).paths['x.md'].reason, 'original'); - assert.notEqual(atRef, onDisk, 'the ref read must not pick up the uncommitted working-tree edit'); - } finally { - cleanup(repo.dir); - } -}); - -test('readFragmentAtRef: throws on a ref that does not exist — a failed base read must be an error, never a silent null', () => { - const repo = makeGuardNextRepo(); - try { - repo.writeFrag('a.json', { version: ACK_VERSION, paths: { 'x.md': { reason: 'why' } } }); - repo.commit('add fragment'); - - // "could not read the base" read as "absent at the base" would make every fragment - // look brand-new and disarm the sweep — this must throw, not return null. - assert.throws( - () => readFragmentAtRef('not-a-real-ref-3078', 'a.json', { cwd: repo.dir }), - /not-a-real-ref-3078/, - 'a bad ref must throw, naming itself', - ); - } finally { - cleanup(repo.dir); - } -}); - -test('resolveBaseRef: returns the parent sha on a two-commit repo, matching `git rev-parse HEAD^` as a 40-hex string', () => { - const repo = makeGuardNextRepo(); - try { - fs.writeFileSync(path.join(repo.dir, 'README.md'), 'one\n'); - const c1 = repo.commit('first'); - fs.writeFileSync(path.join(repo.dir, 'README.md'), 'two\n'); - repo.commit('second'); - - const expected = gitIn(repo.dir, ['rev-parse', 'HEAD^']).trim(); - const actual = resolveBaseRef({ cwd: repo.dir }); - assert.match(actual, /^[0-9a-f]{40}$/, 'must be a 40-hex sha'); - assert.equal(actual, expected); - assert.equal(actual, c1); - } finally { - cleanup(repo.dir); - } -}); - -test('resolveBaseRef: returns null on a root commit (the ONLY way null is reached) and THROWS when pointed at a directory that is not a git repository at all', () => { - const repo = makeGuardNextRepo(); - try { - fs.writeFileSync(path.join(repo.dir, 'README.md'), 'root\n'); - repo.commit('root, no parent'); - assert.equal(resolveBaseRef({ cwd: repo.dir }), null, 'a root commit has no parent'); - } finally { - cleanup(repo.dir); - } - - // The pair this matters for: a blanket try/catch around BOTH the HEAD and HEAD^ - // resolutions would silently collapse "git is broken here" into "there is no base", - // and a guard with no base sweeps nothing — it would pass vacuously, unnoticed by any - // test, which is precisely how the legacy-file job spent months guarding a file that - // had not existed since #2914 (#3078). resolveBaseRef resolves HEAD FIRST, unguarded, - // specifically so a broken repository throws instead of returning null. - const notARepo = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-not-a-repo-')); - try { - assert.throws(() => resolveBaseRef({ cwd: notARepo })); - } finally { - cleanup(notARepo); - } -}); - -test('assertUsableBaseRef: rejects an option-shaped ref, an empty string, null, and a non-string; returns a normal sha unchanged', () => { - // `git show` honors diff options including `--output=`, which WRITES a file — - // an option-shaped ref is a real hazard, not a hypothetical one. - for (const bad of ['-x', '--output=/tmp/gsd-3078-pwned', '', null, 42, {}]) { - assert.throws( - () => assertUsableBaseRef(bad), - /would parse|option/, - `${JSON.stringify(bad)} must be rejected as unusable`, - ); - } - const sha = 'a'.repeat(40); - assert.equal(assertUsableBaseRef(sha), sha, 'a normal 40-hex sha must be returned unchanged'); -}); - -test('E2E: --guard-next --base-ref runs the real script as a subprocess against this checkout, deriving its expected outcome from the live fragment inventory', () => { - // Passing THIS checkout's own HEAD as --base-ref is the deliberate degenerate case: with - // a clean tree, the working-tree copy of every fragment in tests/emitted-drift-acks/ - // equals its committed copy at HEAD, so "spent" is trivially true for every fragment - // that happens to exist right now. That degeneracy is exactly what makes the expected - // exit code and output DERIVABLE from the inventory at runtime rather than a number - // hand-picked when the directory happened to be empty (#3078 regression: it was empty - // when this test was written, then a fragment was restored and the hardcoded - // exit-0/count-2 expectation went stale). Whether the directory holds zero fragments or - // N, the assertion below is the correct one either way. - const scriptPath = path.join(REPO_ROOT, 'scripts', 'lint-emitted-drift-ack.cjs'); - const head = gitIn(REPO_ROOT, ['rev-parse', 'HEAD']).trim(); - const { listFragmentFiles } = require('../scripts/lint-emitted-drift-ack.cjs'); - const fragDir = path.join(REPO_ROOT, 'tests', 'emitted-drift-acks'); - const fragments = listFragmentFiles(fragDir); - - let status = 0; - let out = ''; - try { - out = execFileSync( - process.execPath, - [scriptPath, '--guard-next', '--base-ref', head], - { cwd: REPO_ROOT, encoding: 'utf8', timeout: 30000 }, - ); - } catch (err) { - status = err.status; - out = (err.stdout || '') + (err.stderr || ''); - } - - // The legacy-file half is independent of the fragment inventory and always reports ok - // on this checkout (the legacy file was deleted by #2914) — this is the "legacy-file - // line" half of --guard-next's two halves. - assert.equal( - out.split('\n').filter((l) => l.startsWith('ok guard-no-ack-on-next:')).length >= 1, true, - 'the legacy-file guard must print its own ok line regardless of fragment state', - ); - - if (fragments.length === 0) { - assert.equal(status, 0, 'zero fragments: the guard must exit 0'); - assert.match(out, /no all-spent fragment survives/, 'the fragment-sweep half must also print its ok line'); - } else { - assert.notEqual(status, 0, `${fragments.length} fragment(s) at HEAD are trivially all-spent against themselves`); - assert.match(out, new RegExp(`${fragments.length} fully-spent ack fragment\\(s\\) survive on next`)); - for (const name of fragments) { - assert.ok(out.includes(name), `sweep output must name ${name}`); - assert.match( - out, new RegExp(`git rm[^\\n]*${escapeRegex(name)}`), - `sweep output must print a git rm remedy line for ${name}`, - ); - } - } -}); - -test('E2E: --guard-next rejects an option-shaped --base-ref', () => { - // main()'s argv parsing is the only thing this exercises that the unit tests above - // cannot: an option-shaped --base-ref must be rejected, not silently accepted. - const scriptPath = path.join(REPO_ROOT, 'scripts', 'lint-emitted-drift-ack.cjs'); - let threw = false; - let status; - let stderr = ''; - try { - execFileSync( - process.execPath, - [scriptPath, '--guard-next', '--base-ref', '-x'], - { cwd: REPO_ROOT, encoding: 'utf8', timeout: 30000 }, - ); - } catch (err) { - threw = true; - status = err.status; - stderr = err.stderr || ''; - } - assert.ok(threw, 'an option-shaped --base-ref must make the guard exit non-zero'); - assert.notEqual(status, 0); - assert.match(stderr, /would parse|option/); -}); - -// ── D. the collision shape end-to-end — the issue's own regression criterion ─ -// -// #3078 "Done when": a merged, fully-spent fragment must not silently occupy a path -// key it can no longer gate. All three arms below must hold together: (1) a -// cross-source duplicate is a hard failure naming the remedy, (2) leaving the -// owning entry untouched is spent and gates nothing, (3) appending prose re-arms -// it, and (4) once the spent owner is swept, a new fragment can declare the path -// cleanly. - -test('D1/D2: two fragments naming the same path is a hard failure, naming both files, git rm, re-arms, and APPEND', () => { - const scriptPath = path.join(REPO_ROOT, 'scripts', 'lint-emitted-drift-ack.cjs'); - const fragDir = path.join(REPO_ROOT, ...ACK_DIR_REPO_PATH.split('/')); - const nameA = 'zzz-3078-test-collision-a.json'; - const nameB = 'zzz-3078-test-collision-b.json'; - const pathA = path.join(fragDir, nameA); - const pathB = path.join(fragDir, nameB); - const collisionKey = 'zzz-3078-test-collision-path.md'; - - try { - // The fragment directory does not necessarily exist yet — #2914's directory is - // created on first use, and `listFragmentFiles` treats an absent one as zero - // fragments, so a clean checkout may not have it. - fs.mkdirSync(fragDir, { recursive: true }); - fs.writeFileSync(pathA, JSON.stringify({ version: ACK_VERSION, paths: { [collisionKey]: { reason: 'a' } } })); - fs.writeFileSync(pathB, JSON.stringify({ version: ACK_VERSION, paths: { [collisionKey]: { reason: 'b' } } })); - - let stderr = ''; - let threw = false; - try { - execFileSync(process.execPath, [scriptPath], { cwd: REPO_ROOT, encoding: 'utf8', timeout: 15000 }); - } catch (err) { - threw = true; - stderr = err.stderr || ''; - } - assert.ok(threw, 'a cross-source duplicate must exit non-zero'); - assert.match(stderr, new RegExp(`duplicate ack for "${escapeRegex(collisionKey)}"`)); - assert.match(stderr, new RegExp(escapeRegex(nameA))); - assert.match(stderr, new RegExp(escapeRegex(nameB))); - assert.match(stderr, /git rm /); - assert.match(stderr, /re-arms/); - assert.match(stderr, /APPEND/); - } finally { - // Must NOT survive the test — a real duplicate left behind would red the repo's - // own lint:ci the next time anyone runs it. cleanup() cannot be used here: it - // refuses any path outside the known OS temp roots, and these fixtures are - // deliberately created inside the real repo's tests/emitted-drift-acks/. - // eslint-disable-next-line local/no-raw-rmsync-in-tests -- not a temp dir; cleaning up files created inside the real repo tree - fs.rmSync(pathA, { force: true }); - // eslint-disable-next-line local/no-raw-rmsync-in-tests -- not a temp dir; cleaning up files created inside the real repo tree - fs.rmSync(pathB, { force: true }); - } -}); - -test('D3: leaving the owning fragment entry untouched is spent and gates nothing', () => { - const r = diffEmitted({ - baseline: mf({ [WORKFLOW_KEY]: 'aaa' }), - current: mf({ [WORKFLOW_KEY]: 'bbb' }), - changedPaths: [], - ack: { version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'already explained' } } }, - baseAck: { version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'already explained' } } }, - }); - assert.deepEqual(r.spentAcks, [WORKFLOW_KEY]); - assert.equal(r.acked.length, 0, 'a spent entry must not clear the new delta'); - assert.deepEqual(r.unattributable.map((u) => u.rel), [WORKFLOW_KEY]); -}); - -test('D4: appending prose to the owning fragment entry re-arms it — #2639/#2993 ship depending on this route', () => { - const r = diffEmitted({ - baseline: mf({ [WORKFLOW_KEY]: 'aaa' }), - current: mf({ [WORKFLOW_KEY]: 'bbb' }), - changedPaths: [], - ack: { - version: ACK_VERSION, - paths: { [WORKFLOW_KEY]: { reason: 'already explained, and now this NEW ripple too' } }, - }, - baseAck: { version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'already explained' } } }, - }); - assert.deepEqual(r.spentAcks, []); - assert.equal(r.acked.length, 1, '#2639/#2993 ship depending on this re-arm route continuing to work'); - assert.equal(r.unattributable.length, 0); -}); - -test('D5: once the spent owner is swept, declaring the same path in a new fragment is clean', () => { - // Before the sweep: the owning fragment is fully spent (assertNoAllSpentFragments - // names it) but still present — this is the #3078 hazard. - const spentDoc = doc({ [WORKFLOW_KEY]: { reason: 'already explained' } }); - const before = assertNoAllSpentFragments([frag('owner.json', spentDoc, spentDoc)]); - assert.ok(!before.ok); - assert.deepEqual(before.sweepable, ['owner.json']); - - // After the sweep (fragment deleted, per the remedy): a NEW fragment can declare - // the same path with zero collision, exercised through the real duplicate-detection - // path (mergeAckSources), not just the pure sweep guard above. - const { merged, errors } = mergeAckSources([ - { - source: 'tests/emitted-drift-acks/new.json', - doc: { version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'a fresh explanation' } } }, - }, - ]); - assert.deepEqual(errors, [], 'no duplicate arises once the spent owner is gone'); - assert.ok(merged.paths[WORKFLOW_KEY]); - - const parsed = parseAck(merged); - assert.equal(parsed.entries.size, 1); - assert.deepEqual(parsed.errors, []); -}); - -// ─── Per-PR ack fragments: mergeAckSources + readAckSources (#2914) ────────── -// -// #2914 replaces the single shared tests/emitted-drift-ack.json with per-PR fragments -// under tests/emitted-drift-acks/, exactly the shape .changeset/ already uses to solve -// the same "every PR rewrites one file wholesale" conflict problem. The legacy file is -// still read and unioned in — five open PRs (#2818, #2812, #2728, #2566, #2531) carry it -// — so BOTH surfaces must keep working, together and alone. - -// `merged.paths` is deliberately built via `Object.create(null)` (see mergeAckSources's -// doc comment: a fragment/legacy source naming a key `__proto__` must set a PROPERTY, -// never the prototype). `assert.deepEqual`/`deepStrictEqual` compares `[[Prototype]]` -// too, so a direct comparison against an ordinary `{}` literal fails on the prototype -// alone even when every key/value matches. Round-tripping through JSON (exactly what a -// real committed ack document goes through) normalizes it to a plain object for -// assertion purposes without touching the production code under test. -const plain = (o) => JSON.parse(JSON.stringify(o)); - -test('mergeAckSources: a single source with no entries merges to an empty, legal document', () => { - const { merged, errors } = mergeAckSources([]); - assert.deepEqual(errors, []); - assert.deepEqual(plain(merged), { version: ACK_VERSION, paths: {} }); -}); - -test('mergeAckSources: fragments-only union with no overlap', () => { - const { merged, errors } = mergeAckSources([ - { source: 'tests/emitted-drift-acks/1000-a.json', doc: { version: ACK_VERSION, paths: { 'a.md': { reason: 'ra' } } } }, - { source: 'tests/emitted-drift-acks/1001-b.json', doc: { version: ACK_VERSION, paths: { 'b.md': { reason: 'rb' } } } }, - ]); - assert.deepEqual(errors, []); - assert.deepEqual(plain(merged.paths), { 'a.md': { reason: 'ra' }, 'b.md': { reason: 'rb' } }); -}); - -test('mergeAckSources: legacy-only (a single source) merges through unchanged', () => { - const { merged, errors } = mergeAckSources([ - { source: ACK_FILE, doc: { version: ACK_VERSION, paths: { 'a.md': { reason: 'legacy' } } } }, - ]); - assert.deepEqual(errors, []); - assert.deepEqual(plain(merged.paths), { 'a.md': { reason: 'legacy' } }); -}); - -test('mergeAckSources: legacy file and fragments together, no overlap', () => { - const { merged, errors } = mergeAckSources([ - { source: ACK_FILE, doc: { version: ACK_VERSION, paths: { 'legacy.md': { reason: 'from legacy' } } } }, - { source: 'tests/emitted-drift-acks/1000-a.json', doc: { version: ACK_VERSION, paths: { 'a.md': { reason: 'from fragment' } } } }, - ]); - assert.deepEqual(errors, []); - assert.deepEqual(plain(merged.paths), { - 'legacy.md': { reason: 'from legacy' }, - 'a.md': { reason: 'from fragment' }, - }); -}); - -test('mergeAckSources: a duplicate key across two fragments is a loud error, never silent last-wins', () => { - const { merged, errors } = mergeAckSources([ - { source: 'tests/emitted-drift-acks/1000-a.json', doc: { version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'first' } } } }, - { source: 'tests/emitted-drift-acks/1001-b.json', doc: { version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'second' } } } }, - ]); - assert.equal(errors.length, 1); - assert.match(errors[0], /duplicate ack for/); - assert.match(errors[0], /1000-a\.json/, 'the error must name the first source'); - assert.match(errors[0], /1001-b\.json/, 'the error must name the second source'); - // First-wins is a deliberate, DOCUMENTED simplification (not silent): the caller is - // told loudly via `errors`, and the merged doc still holds a well-defined value. - assert.equal(merged.paths[WORKFLOW_KEY].reason, 'first'); -}); - -test('mergeAckSources: an empty fragments directory (represented as zero docs) is legal', () => { - const { merged, errors } = mergeAckSources([]); - assert.deepEqual(errors, []); - assert.deepEqual(plain(merged.paths), {}); -}); - -test('mergeAckSources: a malformed source (bad version) surfaces the same error parseAck would', () => { - const { errors } = mergeAckSources([ - { source: 'tests/emitted-drift-acks/1000-bad.json', doc: { version: 99, paths: {} } }, - ]); - assert.match(errors.join('\n'), /unsupported version 99/); -}); - -test('mergeAckSources: a __proto__ key from a fragment is rejected, never merged in or used to pollute', () => { - // MUST be built via JSON.parse, not a JS object literal: `{ '__proto__': v }` is the - // special ObjectLiteral case that SETS THE PROTOTYPE and yields zero own keys, so a - // fixture built that way is empty and never exercises this path at all (the exact - // reason the previous version of this test was vacuous and failed with "Cannot read - // properties of undefined"). `JSON.parse` is the production path and creates a genuine - // own key. - const doc = JSON.parse('{"version":' + ACK_VERSION + ',"paths":{"__proto__":{"reason":"hostile"}}}'); - assert.deepEqual(Object.keys(doc.paths), ['__proto__'], 'JSON.parse must create a genuine own key'); - - const { merged, errors } = mergeAckSources([ - { source: 'tests/emitted-drift-acks/1000-a.json', doc }, - ]); - - assert.equal(errors.length, 1); - assert.match(errors[0], /reserved/); - assert.match(errors[0], /__proto__/); - assert.deepEqual(plain(merged.paths), {}, 'a reserved key must never be merged into the document'); - assert.equal(Object.getPrototypeOf(merged.paths), null, 'merged.paths stays null-prototype'); - assert.equal(({}).reason, undefined, 'Object.prototype must be untouched'); -}); - -test('readAckSources: fragments-only on a real tree', () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ack-frag-')); - try { - const fragDir = path.join(dir, 'acks'); - fs.mkdirSync(fragDir, { recursive: true }); - fs.writeFileSync(path.join(fragDir, '1000-a.json'), JSON.stringify({ version: ACK_VERSION, paths: { 'a.md': { reason: 'ra' } } })); - fs.writeFileSync(path.join(fragDir, '1001-b.json'), JSON.stringify({ version: ACK_VERSION, paths: { 'b.md': { reason: 'rb' } } })); - - const { doc, errors } = readAckSources({ legacyPath: path.join(dir, 'emitted-drift-ack.json'), fragmentsDir: fragDir }); - assert.deepEqual(errors, []); - assert.deepEqual(plain(doc.paths), { 'a.md': { reason: 'ra' }, 'b.md': { reason: 'rb' } }); - } finally { - cleanup(dir); - } -}); - -test('readAckSources: legacy-only on a real tree (no fragments directory at all)', () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ack-legacy-')); - try { - const legacyPath = path.join(dir, 'emitted-drift-ack.json'); - fs.writeFileSync(legacyPath, JSON.stringify({ version: ACK_VERSION, paths: { 'legacy.md': { reason: 'from legacy' } } })); - - const { doc, errors } = readAckSources({ legacyPath, fragmentsDir: path.join(dir, 'nonexistent-acks') }); - assert.deepEqual(errors, []); - assert.deepEqual(plain(doc.paths), { 'legacy.md': { reason: 'from legacy' } }); - } finally { - cleanup(dir); - } -}); - -test('readAckSources: legacy file and fragments together', () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ack-both-')); - try { - const legacyPath = path.join(dir, 'emitted-drift-ack.json'); - const fragDir = path.join(dir, 'acks'); - fs.mkdirSync(fragDir, { recursive: true }); - fs.writeFileSync(legacyPath, JSON.stringify({ version: ACK_VERSION, paths: { 'legacy.md': { reason: 'from legacy' } } })); - fs.writeFileSync(path.join(fragDir, '1000-a.json'), JSON.stringify({ version: ACK_VERSION, paths: { 'a.md': { reason: 'from fragment' } } })); - - const { doc, errors } = readAckSources({ legacyPath, fragmentsDir: fragDir }); - assert.deepEqual(errors, []); - assert.deepEqual(plain(doc.paths), { - 'legacy.md': { reason: 'from legacy' }, - 'a.md': { reason: 'from fragment' }, - }); - } finally { - cleanup(dir); - } -}); - -test('readAckSources: neither the legacy file nor the fragments directory exists — the healthy steady state', () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ack-absent-')); - try { - const { doc, errors } = readAckSources({ - legacyPath: path.join(dir, 'emitted-drift-ack.json'), - fragmentsDir: path.join(dir, 'acks'), - }); - assert.equal(doc, null); - assert.deepEqual(errors, []); - } finally { - cleanup(dir); - } -}); - -test('readAckSources: an empty fragments directory (present, zero files) plus no legacy file', () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ack-emptydir-')); - try { - const fragDir = path.join(dir, 'acks'); - fs.mkdirSync(fragDir, { recursive: true }); - const { doc, errors } = readAckSources({ legacyPath: path.join(dir, 'emitted-drift-ack.json'), fragmentsDir: fragDir }); - assert.equal(doc, null, 'zero fragments and no legacy file is still the healthy steady state'); - assert.deepEqual(errors, []); - } finally { - cleanup(dir); - } -}); - -test('readAckSources: a duplicate key across two fragments fails loudly and would fail the real gate', () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ack-dupe-')); - try { - const fragDir = path.join(dir, 'acks'); - fs.mkdirSync(fragDir, { recursive: true }); - fs.writeFileSync(path.join(fragDir, '1000-a.json'), JSON.stringify({ version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'first' } } })); - fs.writeFileSync(path.join(fragDir, '1001-b.json'), JSON.stringify({ version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'second' } } })); - - const { doc: ack, errors: mergeAckErrors } = readAckSources({ - legacyPath: path.join(dir, 'emitted-drift-ack.json'), - fragmentsDir: fragDir, - }); - assert.equal(mergeAckErrors.length, 1); - assert.match(mergeAckErrors[0], /duplicate ack for/); - - // Wired exactly as the real-tree test wires it: folded into diffEmitted's own - // errors, which must fail the whole gate — never silently pass with one winner. - const r = diffEmitted({ - baseline: mf({ [WORKFLOW_KEY]: 'aaa' }), - current: mf({ [WORKFLOW_KEY]: 'aaa' }), - changedPaths: [], - ack, - baseAck: null, - mergeAckErrors, - }); - assert.ok(!r.ok, 'a duplicate ack across two fragments must fail the gate'); - assert.match(formatReport(r), /duplicate ack for/); - } finally { - cleanup(dir); - } -}); - -test('readAckSources: a malformed fragment (invalid JSON) throws, naming the fragment file', () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ack-malformed-')); - try { - const fragDir = path.join(dir, 'acks'); - fs.mkdirSync(fragDir, { recursive: true }); - fs.writeFileSync(path.join(fragDir, '1000-bad.json'), '{ not json'); - - assert.throws( - () => readAckSources({ legacyPath: path.join(dir, 'emitted-drift-ack.json'), fragmentsDir: fragDir }), - /1000-bad\.json.*not valid JSON/, - ); - } finally { - cleanup(dir); - } -}); - -test('readAckSources: listAckFragmentFiles returns sorted .json names only, absent dir is empty', () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ack-listing-')); - try { - assert.deepEqual(listAckFragmentFiles(path.join(dir, 'missing')), []); - - const fragDir = path.join(dir, 'acks'); - fs.mkdirSync(fragDir, { recursive: true }); - fs.writeFileSync(path.join(fragDir, '2000-z.json'), '{}'); - fs.writeFileSync(path.join(fragDir, '1000-a.json'), '{}'); - fs.writeFileSync(path.join(fragDir, 'README.md'), 'not a fragment'); - assert.deepEqual(listAckFragmentFiles(fragDir), ['1000-a.json', '2000-z.json']); - } finally { - cleanup(dir); - } -}); - -test('listAckFragmentFilesAtRef: lists .json fragment names at a ref directly, sorted, non-.json excluded', () => { - const run = (args) => { - assert.equal(args[0], 'ls-tree'); - assert.equal(args[args.length - 1], `${ACK_DIR_REPO_PATH}/`); - return [ - `${ACK_DIR_REPO_PATH}/2000-z.json`, - `${ACK_DIR_REPO_PATH}/1000-a.json`, - `${ACK_DIR_REPO_PATH}/README.md`, - ].join('\n') + '\n'; - }; - assert.deepEqual(listAckFragmentFilesAtRef(SHA_A, { run }), ['1000-a.json', '2000-z.json']); -}); - -test('listAckFragmentFilesAtRef: an absent fragment directory at the ref is zero names, not a fault', () => { - const run = () => '\n'; - assert.deepEqual(listAckFragmentFilesAtRef(SHA_A, { run }), []); -}); - -test('listAckFragmentFilesAtRef: a git failure listing the directory throws', () => { - const run = () => { throw new Error('injected git failure'); }; - assert.throws(() => listAckFragmentFilesAtRef(SHA_A, { run }), /could not list tests\/emitted-drift-acks\//); -}); - -test('listAckFragmentFiles: exactly MAX_ACK_FRAGMENTS entries passes, one over fails loudly (#2914 review)', () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ack-listing-cap-')); - try { - for (let i = 0; i < MAX_ACK_FRAGMENTS; i++) { - fs.writeFileSync(path.join(dir, `f-${String(i).padStart(4, '0')}.json`), '{}'); - } - assert.equal(listAckFragmentFiles(dir).length, MAX_ACK_FRAGMENTS, 'at the cap must still pass'); - - fs.writeFileSync(path.join(dir, `f-${String(MAX_ACK_FRAGMENTS).padStart(4, '0')}.json`), '{}'); - assert.throws( - () => listAckFragmentFiles(dir), - (err) => { - assert.match(err.message, new RegExp(escapeRegex(dir))); - assert.match(err.message, new RegExp(String(MAX_ACK_FRAGMENTS + 1))); - assert.match(err.message, new RegExp(String(MAX_ACK_FRAGMENTS))); - return true; - }, - 'one over the cap must throw, naming the directory, the cap, and the actual count', - ); - } finally { - cleanup(dir); - } -}); - -test('listAckFragmentFilesAtRef: exactly MAX_ACK_FRAGMENTS entries passes, one over fails loudly (#2914 review)', () => { - const makeListing = (count) => Array.from( - { length: count }, - (_, i) => `${ACK_DIR_REPO_PATH}/f-${String(i).padStart(4, '0')}.json`, - ).join('\n') + '\n'; - - const atCap = () => makeListing(MAX_ACK_FRAGMENTS); - assert.equal( - listAckFragmentFilesAtRef(SHA_A, { run: atCap }).length, - MAX_ACK_FRAGMENTS, - 'at the cap must still pass', - ); - - const overCap = () => makeListing(MAX_ACK_FRAGMENTS + 1); - assert.throws( - () => listAckFragmentFilesAtRef(SHA_A, { run: overCap }), - (err) => { - assert.match(err.message, new RegExp(escapeRegex(`${ACK_DIR_REPO_PATH}/ at "${SHA_A}"`))); - assert.match(err.message, new RegExp(String(MAX_ACK_FRAGMENTS + 1))); - assert.match(err.message, new RegExp(String(MAX_ACK_FRAGMENTS))); - return true; - }, - 'one over the cap must throw, naming the directory, the cap, and the actual count', - ); -}); - -// ─── The migration pin is gone: #3078 emptied the directory it protected (#3078) ── -// #2914 migrated the legacy shared file's 35 entries into the legacy-migration bucket -// fragment and this file pinned that fragment's continued existence, on the premise -// that a fragment left on `next` is inert-but-harmless. #3078 removed that premise: -// fragments don't share a file, but they DO share a path key space, so a fully-spent -// fragment owns keys it can no longer gate, and the next PR to grow one of those paths -// is hard-blocked. `--guard-next` now sweeps all-spent fragments, and the whole -// directory was emptied — the legacy-migration bucket included. The maintainer's -// decision on #3078 states plainly it was never permanent. -// -// The three tests that pinned it are gone rather than adapted: each asserted the -// presence of a specific merged fragment, which is exactly the state the guard now -// forbids. What they protected is covered instead by the fragment-lifecycle section -// (`assertNoAllSpentFragments`) added by #3078, and #2733's own control-flow -// protection lives in `tests/spec-phase-probe-reachability.test.cjs`, which never -// depended on the ack. The #2733 spec-phase.md byte-delta entry (31987 -> 31997) is -// spent by construction: `next`'s published emitted baseline has carried the -// post-#2733 bytes since that PR merged, so `sizeBaseline === sizeCurrent` and there -// is no growth left for it to clear. - -test('an empty fragment directory is the healthy steady state, end to end (#3078)', () => { - // Absent directory is zero fragments, not a fault. - const missingDir = path.join(REPO_ROOT, 'tests', 'no-such-emitted-drift-acks-dir'); - assert.deepEqual(listAckFragmentFiles(missingDir), []); - - // On THIS checkout, ACK_DIR may or may not exist — other PRs merge fragments over - // time, so this must not become a new pin either way. Assert the invariant that - // holds regardless. - assert.ok(Array.isArray(listAckFragmentFiles(ACK_DIR))); - assert.ok(assertNoAllSpentFragments([]).ok); - - // The deadlock-avoidance path the empty state now takes every day: a tree carrying - // no ack never consults the base, which is what keeps a repair PR landable — and is - // now the steady state rather than the exception. - const r = diffEmitted({ - baseline: mf({}), - current: mf({}), - changedPaths: [], - ack: null, - baseAck: null, - }); - assert.ok(r.ok); - assert.deepEqual(r.staleAcks, []); - assert.deepEqual(r.spentAcks, []); -}); - -// ─── readAckFileAtRef: the base-side reader (#2789) ────────────────────────── -// -// This half never runs in the remote runner — the real-tree test skips there, because a -// shallow clone has no `origin/*` to resolve. Without these, replacing the body with -// `return null` would fail nothing while silently restoring the pre-#2789 gate. The git -// runner is injected rather than monkeypatched: deterministic on every OS, and no -// dependence on the host repo's actual refs. - -const fakeGit = (handlers) => (args) => { - if (args[0] === 'ls-tree') return handlers.lsTree ? handlers.lsTree() : `${ACK_REPO_PATH}\n`; - if (args[0] === 'show') return handlers.show ? handlers.show() : '{}'; - throw new Error(`unexpected git call: ${args.join(' ')}`); -}; - -test('readAckFileAtRef: absent at the ref is the healthy steady state and returns null', () => { - // ls-tree exits 0 with EMPTY output when the path simply is not there. - const doc = readAckFileAtRef(SHA_A, { run: fakeGit({ lsTree: () => '\n' }) }); - assert.equal(doc, null); -}); - -test('readAckFileAtRef: present and valid parses through', () => { - const payload = { version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'r' } } }; - const doc = readAckFileAtRef(SHA_A, { - run: fakeGit({ show: () => JSON.stringify(payload) }), - }); - assert.deepEqual(doc, payload); -}); - -test('readAckFileAtRef: a READ FAILURE throws — it must never degrade to "inherit nothing"', () => { - // The whole point. Returning null here looks armed (every entry stays live) but a LIVE - // entry is precisely the one that CAN CONSUME a delta, so a genuinely new unexplained - // ripple would come back `acked` instead of `unattributable` — silently the entire - // pre-#2789 gate. Same law as resolveChangedPaths: a failed git read is an error. - const boom = () => { throw new Error('injected git failure'); }; - - assert.throws( - () => readAckFileAtRef(SHA_A, { run: fakeGit({ lsTree: boom }) }), - /could not list the ack/, - ); - assert.throws( - () => readAckFileAtRef(SHA_A, { run: fakeGit({ show: boom }) }), - /exists at .* but could not be read/, - ); -}); - -test('readAckFileAtRef: present but empty or unparseable throws, like the head-side reader', () => { - assert.throws( - () => readAckFileAtRef(SHA_A, { run: fakeGit({ show: () => ' \n' }) }), - /present at .* but empty/, - ); - assert.throws( - () => readAckFileAtRef(SHA_A, { run: fakeGit({ show: () => '{ not json' }) }), - /is not valid JSON/, - ); -}); - -test('readAckFileAtRef: refuses an option-shaped ref rather than handing it to git', () => { - // execFileSync's array form stops shell metacharacters but NOT git's option parsing: - // `git show` honors --output=, which writes. The guard belongs with the argument, - // since this helper is exported and its callers are not the only possible ones. - const never = () => { throw new Error('git must not be invoked at all'); }; - for (const bad of ['--output=/tmp/pwn', '-next', '--upload-pack=x', '', null, undefined, 42]) { - assert.throws( - () => readAckFileAtRef(bad, { run: fakeGit({ lsTree: never, show: never }) }), - /refusing to read the ack/, - `${JSON.stringify(bad)} must be refused`, - ); - } -}); - -// ─── readAckSourcesAtRef: the base-side UNION reader (#2914) ───────────────── -// -// Mirrors readAckFileAtRef's fakeGit harness above, extended to also answer `ls-tree` -// on the FRAGMENT DIRECTORY and `show` for each fragment name it lists — a base-side -// stand-in for "the legacy file plus every fragment, as they existed at that ref". - -const fakeMultiGit = ({ legacy, fragments = {} } = {}) => (args) => { - if (args[0] === 'ls-tree') { - const target = args[args.length - 1]; - if (target === ACK_REPO_PATH) return legacy !== undefined ? `${ACK_REPO_PATH}\n` : '\n'; - if (target === `${ACK_DIR_REPO_PATH}/`) { - const names = Object.keys(fragments); - return names.length ? names.map((n) => `${ACK_DIR_REPO_PATH}/${n}`).join('\n') + '\n' : '\n'; - } - // readAckFileAtRef's OWN per-file existence check, once per fragment name it was - // told about by the directory listing above — a second, distinct ls-tree call. - const name = target.slice(target.lastIndexOf('/') + 1); - if (`${ACK_DIR_REPO_PATH}/${name}` === target && Object.prototype.hasOwnProperty.call(fragments, name)) { - return `${target}\n`; - } - throw new Error(`fakeMultiGit: unexpected ls-tree target ${target}`); - } - if (args[0] === 'show') { - const spec = args[1]; - const p = spec.slice(spec.indexOf(':') + 1); - if (p === ACK_REPO_PATH) return legacy; - const name = p.slice(p.lastIndexOf('/') + 1); - if (Object.prototype.hasOwnProperty.call(fragments, name)) return fragments[name]; - throw new Error(`fakeMultiGit: unexpected show path ${p}`); - } - throw new Error(`fakeMultiGit: unexpected git call: ${args.join(' ')}`); -}; - -test('readAckSourcesAtRef: fragments-only at a ref', () => { - const { doc } = readAckSourcesAtRef(SHA_A, { - run: fakeMultiGit({ - fragments: { - '1000-a.json': JSON.stringify({ version: ACK_VERSION, paths: { 'a.md': { reason: 'ra' } } }), - '1001-b.json': JSON.stringify({ version: ACK_VERSION, paths: { 'b.md': { reason: 'rb' } } }), - }, - }), - }); - assert.deepEqual(plain(doc.paths), { 'a.md': { reason: 'ra' }, 'b.md': { reason: 'rb' } }); -}); - -test('readAckSourcesAtRef: legacy-only at a ref (no fragments directory)', () => { - const { doc } = readAckSourcesAtRef(SHA_A, { - run: fakeMultiGit({ legacy: JSON.stringify({ version: ACK_VERSION, paths: { 'legacy.md': { reason: 'from legacy' } } }) }), - }); - assert.deepEqual(plain(doc.paths), { 'legacy.md': { reason: 'from legacy' } }); -}); - -test('readAckSourcesAtRef: legacy and fragments together at a ref', () => { - const { doc } = readAckSourcesAtRef(SHA_A, { - run: fakeMultiGit({ - legacy: JSON.stringify({ version: ACK_VERSION, paths: { 'legacy.md': { reason: 'from legacy' } } }), - fragments: { '1000-a.json': JSON.stringify({ version: ACK_VERSION, paths: { 'a.md': { reason: 'from fragment' } } }) }, - }), - }); - assert.deepEqual(plain(doc.paths), { 'legacy.md': { reason: 'from legacy' }, 'a.md': { reason: 'from fragment' } }); -}); - -test('readAckSourcesAtRef: neither legacy nor any fragment exists at the ref', () => { - const { doc } = readAckSourcesAtRef(SHA_A, { run: fakeMultiGit({}) }); - assert.equal(doc, null); -}); - -test('readAckSourcesAtRef: an empty fragments directory at the ref, no legacy file', () => { - const { doc } = readAckSourcesAtRef(SHA_A, { run: fakeMultiGit({ fragments: {} }) }); - assert.equal(doc, null, 'zero fragments and no legacy file at the ref is still the healthy steady state'); -}); - -test('readAckSourcesAtRef: a base-side duplicate across fragments does not throw (discarded like other base schema issues)', () => { - // Matches this module's existing precedent for the base side (see diffEmitted's real - // -tree caller: "Base-side SCHEMA errors are deliberately discarded"). A base-side - // duplicate is `next`'s own health, not this diff's to answer for, and - // mergeAckSources's first-source-wins keeps the STRICT reading even with no error - // surfaced -- an entry can only be spent against the ONE reason actually kept. - const { doc } = readAckSourcesAtRef(SHA_A, { - run: fakeMultiGit({ - fragments: { - '1000-a.json': JSON.stringify({ version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'first' } } }), - '1001-b.json': JSON.stringify({ version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'second' } } }), - }, - }), - }); - assert.equal(doc.paths[WORKFLOW_KEY].reason, 'first'); -}); - -test('readAckSourcesAtRef: a fragment that is unreadable at the ref still throws', () => { - const fragRelPath = `${ACK_DIR_REPO_PATH}/1000-a.json`; - const run = (args) => { - if (args[0] === 'ls-tree') { - const target = args[args.length - 1]; - if (target === ACK_REPO_PATH) return '\n'; - if (target === `${ACK_DIR_REPO_PATH}/`) return `${fragRelPath}\n`; - // readAckFileAtRef's OWN existence check for the individual fragment file, prior - // to `show` — it exists at this ref, so the failure below is a genuine read fault. - if (target === fragRelPath) return `${fragRelPath}\n`; - throw new Error(`unexpected ls-tree target: ${target}`); - } - if (args[0] === 'show') throw new Error('injected git failure'); - throw new Error(`unexpected git call: ${args.join(' ')}`); - }; - assert.throws( - () => readAckSourcesAtRef(SHA_A, { run }), - /exists at .* but could not be read/, - ); -}); - -test('OMITTING baseAck while an ack is present is a loud error, never a silent pass', () => { - // This is what makes the production seam non-revertible in silence. Drop `baseAck:` - // from the real-tree call and the gate fails loudly here, instead of quietly restoring - // #2768 with every other test still green. Same discipline the module already applies - // to `changedPaths`: a missing input is an error, not an empty set. - const r = diffEmitted({ - baseline: mf({ [WORKFLOW_KEY]: 'aaa' }), - current: mf({ [WORKFLOW_KEY]: 'aaa' }), - changedPaths: [], - ack: { version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'x' } } }, - }); - assert.ok(!r.ok); - assert.match(r.errors.join('\n'), /baseAck was not supplied/); -}); - -test('with no ack ENTRIES, baseAck is not required — the healthy steady state stays quiet', () => { - // Absence of an ack file is the normal case for almost every PR. It must not be made - // to carry a new required argument it has no use for — and neither must a document - // that is present but declares nothing, which `parseAck` accepts as legal (see - // 'non-object ack JSON is rejected'). Only a real ENTRY can be spent or live, so only - // a real entry needs the base side. - for (const ack of [undefined, null, {}, { version: ACK_VERSION }, { paths: {} }]) { - const r = diffEmitted({ - baseline: mf({ [WORKFLOW_KEY]: 'aaa' }), - current: mf({ [WORKFLOW_KEY]: 'bbb' }), - changedPaths: [WORKFLOW_SRC], - ack, - }); - assert.deepEqual(r.errors, [], `ack ${JSON.stringify(ack)} must need no baseAck`); + assert.equal(r.unattributable.length, 0, `reason=${JSON.stringify(reason)} must still excuse the ripple`); + assert.equal(r.acked[0].reason, reason, 'the reason is carried through verbatim, unvalidated'); assert.ok(r.ok); } }); -test('a spent size-growth ack neither fails nor clears a further growth', () => { - const ack = { version: ACK_VERSION, paths: { 'plan-phase.md': { reason: 'grew once, deliberately' } } }; - const shared = { +test('an absent ack means no acks', () => { + const r = diffEmitted({ baseline: mf({ [WORKFLOW_KEY]: 'aaa' }), - current: mf({ [WORKFLOW_KEY]: 'aaa' }), + current: mf({ [WORKFLOW_KEY]: 'bbb' }), + changedPaths: [WORKFLOW_SRC], + }); + assert.equal(r.errors.length, 0); + assert.ok(r.ok, 'the healthy steady state is no ack at all'); +}); + +test('a live ack and a stale ack together: only the stale one is named', () => { + const ackHash = mapOf({ + [WORKFLOW_KEY]: { reason: 'live ripple' }, + [SKILL_KEY]: { reason: 'stale' }, + }); + const r = diffEmitted({ + baseline: mf({ [WORKFLOW_KEY]: 'aaa', [SKILL_KEY]: 'ccc' }), + current: mf({ [WORKFLOW_KEY]: 'bbb', [SKILL_KEY]: 'ccc' }), changedPaths: [], - ack, - baseAck: ack, + ackHash, + }); + assert.deepEqual(r.staleAcks, [{ key: SKILL_KEY, space: 'hash' }], 'the live one must not be named'); +}); + +// ─── normalizeAckReason / INVISIBLE (#3942 anti-gaming prose normalization) ── +// +// Both are on the live path via `parseAckTrailers`'s own same-key dedup +// (tests/helpers/emitted-diff.cjs) but had ZERO test references repo-wide before this +// block — the old suite's coverage (origin/next:1069, :1084, :1307, :1322) called +// `ackProse`/`ACK_INVISIBLE` from `scripts/lint-emitted-drift-ack.cjs`, which ADR-3942 +// §6 deletes outright (the legacy JSON-ack-file lint/guard/sweep script this repo no +// longer has any use for), so those tests are NOT restorable verbatim. This block is a +// from-scratch equivalent against the CURRENT, sole surface: `INVISIBLE` and +// `normalizeAckReason`, both exported directly from `tests/helpers/emitted-diff.cjs`. +// Each assertion is written so that removing any ONE codepoint from `INVISIBLE`'s +// definition fails a specific test, not just the aggregate. + +describe('normalizeAckReason / INVISIBLE (#3942 anti-gaming prose normalization)', () => { + // The exact six codepoints INVISIBLE strips (tests/helpers/emitted-diff.cjs): soft + // hyphen, the zero-width family, word joiner, BOM. Individually named (not just + // looped) so a single dropped codepoint fails its OWN assertion, not a shared one. + const INVISIBLE_CODEPOINTS = { + 'U+00AD SOFT HYPHEN': 0x00AD, + 'U+200B ZERO WIDTH SPACE': 0x200B, + 'U+200C ZERO WIDTH NON-JOINER': 0x200C, + 'U+200D ZERO WIDTH JOINER': 0x200D, + 'U+2060 WORD JOINER': 0x2060, + 'U+FEFF ZERO WIDTH NO-BREAK SPACE (BOM)': 0xFEFF, }; - // Absorbed: base and current agree on size, so there is no growth left to explain. - const settled = diffEmitted({ ...shared, sizeBaseline: { 'plan-phase.md': 5000 }, sizeCurrent: { 'plan-phase.md': 5000 } }); - assert.deepEqual(settled.staleAcks, []); - assert.ok(settled.ok); - - // A further growth is a NEW ripple: the spent ack must not silently absorb it. - const grewAgain = diffEmitted({ ...shared, sizeBaseline: { 'plan-phase.md': 5000 }, sizeCurrent: { 'plan-phase.md': 5400 } }); - assert.equal(grewAgain.grown.length, 1); - assert.equal(grewAgain.grown[0].acked, false, 'a spent ack must not clear a further growth'); - assert.ok(!grewAgain.ok); -}); - -test('non-object ack JSON is rejected, not treated as empty', () => { - // Reading these as "no acks" would SILENTLY DISARM the gate — indistinguishable - // from a healthy run, which is the worst failure available here. - for (const bad of [0, 'a string', [], true]) { - const { errors } = parseAck(bad); - assert.ok(errors.length > 0, `${JSON.stringify(bad)} must be rejected`); - assert.match(errors.join('\n'), /must be a JSON object/); + for (const [label, cp] of Object.entries(INVISIBLE_CODEPOINTS)) { + test(`normalizeAckReason strips ${label}`, () => { + const ch = String.fromCodePoint(cp); + assert.equal(normalizeAckReason(`a${ch}b`), 'ab', `${label} must be stripped, not left in place`); + assert.equal( + normalizeAckReason(`same${ch} reason`), 'same reason', + `${label} inserted mid-word must not survive into the normalized reason`, + ); + }); } - assert.deepEqual(parseAck(null).errors, [], 'absent is legal'); - assert.deepEqual(parseAck({}).errors, [], 'empty object is legal'); - assert.equal(parseAck({ version: 99, paths: {} }).errors.length, 1, 'version drift is caught'); + + test('INVISIBLE.test() agrees with normalizeAckReason for every one of the six codepoints (regex/function parity)', () => { + for (const cp of Object.values(INVISIBLE_CODEPOINTS)) { + const ch = String.fromCodePoint(cp); + INVISIBLE.lastIndex = 0; // global-flagged regex — .test() is stateful, reset before use + const matches = INVISIBLE.test(ch); + INVISIBLE.lastIndex = 0; + assert.equal(matches, true, `INVISIBLE regex must itself match U+${cp.toString(16).toUpperCase().padStart(4, '0')}`); + } + }); + + test('a codepoint OUTSIDE the six is left alone by normalizeAckReason (only these six are invisible, not "any non-ASCII")', () => { + // U+00E9 (é) is a real, visible character with no whitespace/invisible meaning — + // proves normalizeAckReason is not accidentally stripping a broader class than the + // six named codepoints. + assert.equal(normalizeAckReason('café reason'), 'café reason'); + }); + + test('whitespace runs collapse to a single space', () => { + assert.equal(normalizeAckReason('doubled internal space'), 'doubled internal space'); + assert.equal(normalizeAckReason('tab\ttab'), 'tab tab'); + assert.equal(normalizeAckReason('multi\n\n\nline'), 'multi line'); + }); + + test('leading/trailing whitespace is trimmed', () => { + assert.equal(normalizeAckReason(' leading and trailing space '), 'leading and trailing space'); + assert.equal(normalizeAckReason('\t\ttabbed on both ends\t\t'), 'tabbed on both ends'); + }); + + test('CRLF collapses the same as a single space, matching LF', () => { + assert.equal(normalizeAckReason('crlf\r\nline'), 'crlf line'); + assert.equal( + normalizeAckReason('same\r\nwords'), normalizeAckReason('same\nwords'), + 'CRLF and LF must normalize identically for the same underlying words', + ); + }); + + test('an invisible codepoint cannot fake a distinct reason once whitespace-collapsed and trimmed together', () => { + const zwsp = String.fromCodePoint(0x200B); + assert.equal( + normalizeAckReason(` same${zwsp} reason `), + normalizeAckReason('same reason'), + 'stripping the invisible codepoint and collapsing the surrounding whitespace run must land on the identical string', + ); + }); + + test('property: normalizeAckReason never leaves an INVISIBLE-matched codepoint in its output, seeded (#3942)', () => { + fc.assert( + fc.property(fc.string({ minLength: 0, maxLength: 60 }), (s) => { + const out = normalizeAckReason(s); + INVISIBLE.lastIndex = 0; + assert.equal(INVISIBLE.test(out), false, 'no invisible codepoint may survive normalization'); + INVISIBLE.lastIndex = 0; + assert.equal(out, out.trim(), 'output must already be trimmed (idempotent under trim)'); + assert.doesNotMatch(out, /\s{2,}/, 'no whitespace run of 2+ may survive collapsing'); + }), + { seed: 3942, numRuns: 300 }, + ); + }); + + test('property: normalizeAckReason is idempotent — normalizing twice equals normalizing once, seeded (#3942)', () => { + fc.assert( + fc.property(fc.string({ minLength: 0, maxLength: 60 }), (s) => { + const once = normalizeAckReason(s); + const twice = normalizeAckReason(once); + assert.equal(once, twice); + }), + { seed: 3942, numRuns: 300 }, + ); + }); }); +// ADR-3942 removed the whole ack-persistence-lifecycle mechanism this section tested: §2 +// makes spentness structural (a trailer scoped to merge-base..HEAD has no base-side copy +// to compare against, so nothing can ever be "spent"), which deletes diffEmitted's +// baseAck/spentAcks parameters and return field; §1/§6 delete the JSON-ack-file reader +// (parseAck, mergeAckSources) and the legacy pre-merge lint / guard-no-ack-on-next / +// fragment-sweep script this section's tests required (deleted entirely, per §6). +// Every test that lived here exercised one of those now-nonexistent mechanisms. + // ─── The acceptance criteria, failing-first ────────────────────────────────── test('a ripple names the unexplained path and not the explained one', () => { @@ -2425,8 +638,7 @@ test('a converter change fails without an ack and passes with one', () => { for (const rel of Object.keys(moved)) paths[rel] = { reason: 'converter rewrite, ADR-2719' }; const withAck = diffEmitted({ baseline: mf(base), current: mf(moved), changedPaths, - ack: { version: ACK_VERSION, paths }, - baseAck: null, + ackHash: mapOf(paths), }); assert.equal(withAck.unattributable.length, 0); assert.equal(withAck.acked.length, 25); @@ -2451,8 +663,7 @@ test('growth is reported with its exact byte delta and needs an ack', () => { const withAck = diffEmitted({ baseline: mf({}), current: mf({}), changedPaths: [], sizeBaseline, sizeCurrent, - ack: { version: ACK_VERSION, paths: { 'verify-work.md': { reason: 'new UAT section' } } }, - baseAck: null, + ackGrowth: mapOf({ 'verify-work.md': { reason: 'new UAT section' } }), }); assert.equal(withAck.grown[0].acked, true); assert.ok(withAck.ok); @@ -2468,8 +679,7 @@ test('an ack consumed by size growth alone is not reported as stale', () => { changedPaths: [], sizeBaseline: { 'verify-work.md': 10000 }, sizeCurrent: { 'verify-work.md': 11247 }, - ack: { version: ACK_VERSION, paths: { 'verify-work.md': { reason: 'new UAT section' } } }, - baseAck: null, + ackGrowth: mapOf({ 'verify-work.md': { reason: 'new UAT section' } }), }); assert.deepEqual(r.staleAcks, [], 'a growth-consumed ack is live, not stale'); assert.equal(r.grown[0].acked, true); @@ -2509,10 +719,6 @@ const growthOnly = (extra = {}) => diffEmitted({ changedPaths: [], sizeBaseline: { 'explore.md': 11127 }, sizeCurrent: { 'explore.md': 13230 }, - // Sits BEFORE the spread so a row can still override it, while every row that passes - // an `ack` inline gets the explicit "nothing inherited from the base" reading rather - // than tripping `diffEmitted`'s required-baseAck error (#2789). - baseAck: null, ...extra, }); @@ -2537,66 +743,79 @@ test('a growth-only failure carries the byte delta, the key rule, and an ack ent assert.equal(growth.keyRule, REMEDIATION.growthKeyRule, 'growth keys on the bare filename'); assert.deepEqual(report.ackable, [ - { key: 'explore.md', reason: REMEDIATION.growthReason }, - ], 'the ack entry must be keyed on the file that actually grew'); + { key: 'explore.md', reason: REMEDIATION.growthReason, space: 'growth' }, + ], 'the ack entry must be keyed on the file that actually grew, tagged with its trailer space'); }); -test('the renderer emits the ack file, the document, and the do-not-regenerate line', () => { +test('the renderer emits the trailer instructions, the taught line, and the do-not-regenerate line', () => { // The one place rendered text is the object of the test: proving the IR above actually // reaches the contributor. Everything it asserts is an identity comparison against the // frozen surface, so rewording any sentence cannot fail this. const msg = formatReport(growthOnly()); assert.ok(msg.includes('explore.md grew 2103 bytes (11127 -> 13230)'), 'the delta still leads'); - assert.ok(msg.includes(REMEDIATION.ackFile), 'the message must name the ack file'); - assert.ok(msg.includes(REMEDIATION.createIfAbsent), 'it must say the file may not exist yet'); + assert.ok(msg.includes(REMEDIATION.addTrailerGrowth), 'the message must teach the trailer, not a file'); + assert.ok(!msg.includes(REMEDIATION.addTrailerHash), 'a growth-only report must not teach the hash trailer'); assert.ok(msg.includes(REMEDIATION.growthKeyRule), 'it must state the bare-filename key rule'); assert.ok(msg.includes(REMEDIATION.doNotRegenerate), 'it must say not to regenerate'); assert.ok( - msg.includes(REMEDIATION.ackDocument([{ key: 'explore.md', reason: REMEDIATION.growthReason }])), - 'the printed document must be the one the IR describes', + msg.includes(renderAckTrailer(ACK_TRAILER_GROWTH, 'explore.md', REMEDIATION.growthReason)), + 'the printed trailer line must be the one the IR describes', ); }); -test('the document the report teaches is accepted by parseAck', () => { - // The divergence killer. A report that teaches a schema the parser rejects is worse +test('the trailer the report teaches round-trips through parseAckTrailers (#3942)', () => { + // The divergence killer. A report that teaches a grammar the parser rejects is worse // than no report: the contributor follows it, is rejected anyway, and now distrusts the - // gate. This pins the taught shape to the accepted shape in one assertion. - const taught = REMEDIATION.ackDocument([{ key: 'explore.md', reason: 'a real reason' }]); - const { entries, errors } = parseAck(JSON.parse(taught)); - assert.deepEqual(errors, [], 'the taught document must parse with zero errors'); - assert.equal(entries.get('explore.md').reason, 'a real reason'); + // gate. This pins the taught line to the accepted grammar in one assertion — mirrors the + // pre-#3942 "the document the report teaches is accepted by parseAck" test, updated for + // the trailer grammar. + const taughtLine = renderAckTrailer(ACK_TRAILER_GROWTH, 'explore.md', 'a real reason'); + const rawValue = taughtLine.slice(ACK_TRAILER_GROWTH.length + 2); // strip ": " + const { growth, errors } = parseAckTrailers({ growth: [rawValue] }); + assert.deepEqual(errors, [], 'the taught line must parse with zero errors'); + assert.equal(growth.get('explore.md').reason, 'a real reason'); // And it must actually clear the gate it is offered to clear. - const r = growthOnly({ ack: JSON.parse(taught) }); + const r = growthOnly({ ackGrowth: growth }); assert.equal(r.grown[0].acked, true); assert.deepEqual(r.staleAcks, []); assert.ok(r.ok, 'following the printed instructions must turn the lane green'); }); -test('the taught document derives its version from ACK_VERSION', () => { - // A hand-typed `"version": 1` beside a live ACK_VERSION is the generative-fix-divergence - // class: bump one, the other lies. Asserting the relationship — not the literal — is - // what makes the bump safe. - assert.equal(JSON.parse(REMEDIATION.ackDocument([{ key: 'x.md', reason: 'r' }])).version, ACK_VERSION); +test('REMEDIATION.ackTrailerExample itself round-trips through parseAckTrailers', () => { + // A second, independent round-trip: the EXAMPLE printed for documentation/self-serve + // discovery (not the per-report taught line above) must parse too — this is exactly + // the "docs teaching the feature can arm it" hazard 40-design.md calls out, so the + // example must be built from real data, never an angle-bracket placeholder. + const rawValue = REMEDIATION.ackTrailerExample.slice(ACK_TRAILER_HASH.length + 2); + const { hash, errors } = parseAckTrailers({ hash: [rawValue] }); + assert.deepEqual(errors, []); + assert.equal(hash.get('skills/gsd-add-tests/SKILL.md').reason, 'converter rewrite, ADR-2719'); }); -test('the remediation surface is frozen and points at the fragment directory, not the legacy file', () => { - // #2914: the remedy is a NEW fragment under ACK_DIR, never the single legacy file — - // asserting `ackFile === ACK_FILE` here would pin the exact bandaid this design - // replaces (a shared filename every PR is tempted back onto). +test('the remediation surface is frozen and teaches the trailer, not the legacy fragment file', () => { + // #3942: the remedy is a commit TRAILER, never a fragment file under the legacy + // fragment directory — asserting on the legacy file/directory paths here would pin + // the exact mechanism this design replaces. assert.ok(Object.isFrozen(REMEDIATION), 'the exported surface must not be mutable'); - assert.equal(REMEDIATION.ackDir, ACK_DIR_PURE, 'one definition, not a second literal'); - assert.ok(REMEDIATION.ackFile.startsWith(`${ACK_DIR_PURE}/`), 'the taught path must live under the fragment directory'); - assert.notEqual(REMEDIATION.ackFile, ACK_FILE, 'the remedy must not be the legacy shared file'); + assert.equal(REMEDIATION.ackFile, undefined, 'there is no fragment file to name any more'); + assert.equal(REMEDIATION.ackDir, undefined, 'there is no fragment directory to name any more'); + assert.equal(REMEDIATION.createIfAbsent, undefined, '"create a file" is no longer the remedy'); + assert.equal(REMEDIATION.spentAckNote, undefined, '"spent" has no trailer-world remedy to teach'); + assert.ok(REMEDIATION.addTrailerHash.includes(ACK_TRAILER_HASH)); + assert.ok(!REMEDIATION.addTrailerHash.includes(ACK_TRAILER_GROWTH), 'the hash remedy must not name the growth trailer'); + assert.ok(REMEDIATION.addTrailerGrowth.includes(ACK_TRAILER_GROWTH)); + assert.ok(!REMEDIATION.addTrailerGrowth.includes(ACK_TRAILER_HASH), 'the growth remedy must not name the hash trailer'); }); -test('a ripple and a growth in one report share ONE document', () => { +test('a ripple and a growth in one report each get their OWN trailer line', () => { // The combination nobody writes down, and the most likely real shape: a feature PR that // both grows a workflow AND ripples an emitted path. // - // Caught in review: printing a complete document per branch made each read as "the file - // to create", so a contributor pasting the second over the first silently loses the - // first acknowledgment — an ack-lost failure with no signal. One document, one file. + // #3942: there is no longer one shared document to paste over — a hash-space entry and + // a growth-space entry are two DIFFERENT trailer names, so they cannot collide the way + // two entries in one JSON object's `paths` key never could either; this test now proves + // each renders under its OWN trailer name, not merely that both are present. const r = diffEmitted({ baseline: mf({ [WORKFLOW_KEY]: 'aaa' }), current: mf({ [WORKFLOW_KEY]: 'bbb' }), @@ -2608,57 +827,57 @@ test('a ripple and a growth in one report share ONE document', () => { assert.equal(blockOf(report, 'unattributable').keyRule, REMEDIATION.rippleKeyRule); assert.equal(blockOf(report, 'unacked-growth').keyRule, REMEDIATION.growthKeyRule); - // Both key spaces, one ack set, in list order. + // Both key spaces, tagged, in list order. assert.deepEqual(report.ackable, [ - { key: WORKFLOW_KEY, reason: REMEDIATION.rippleReason }, - { key: 'explore.md', reason: REMEDIATION.growthReason }, + { key: WORKFLOW_KEY, reason: REMEDIATION.rippleReason, space: 'hash' }, + { key: 'explore.md', reason: REMEDIATION.growthReason, space: 'growth' }, ]); - // And the rendered document is genuinely one object holding both. - const doc = JSON.parse(REMEDIATION.ackDocument(report.ackable)); - assert.deepEqual(Object.keys(doc.paths).sort(), [WORKFLOW_KEY, 'explore.md'].sort()); - const { errors } = parseAck(doc); - assert.deepEqual(errors, [], 'the combined document must parse'); - const msg = formatReport(r); + assert.ok(msg.includes(renderAckTrailer(ACK_TRAILER_HASH, WORKFLOW_KEY, REMEDIATION.rippleReason))); + assert.ok(msg.includes(renderAckTrailer(ACK_TRAILER_GROWTH, 'explore.md', REMEDIATION.growthReason))); assert.equal( - msg.split('{"version"').length - 1, 1, - 'exactly one document may be printed — two would invite pasting one over the other', + msg.split(`${ACK_TRAILER_HASH}:`).length - 1, 1, + 'exactly one hash-space line may be printed for this report', + ); + assert.equal( + msg.split(`${ACK_TRAILER_GROWTH}:`).length - 1, 1, + 'exactly one growth-space line may be printed for this report', ); }); -test('a stale ack names the file it lives in and the delete-the-file case', () => { - // Pre-#2778 this said acks "must be deleted" without naming the file they live in. It - // also never said what to do when the last entry goes: an empty-but-present ack file - // parses fine and is "legal", but it destroys the ADR-2719 §3 property that the file's - // PRESENCE is the alarm. +test('a stale ack names its trailer and space, and the fix is to amend the trailer, not delete a file', () => { + // Pre-#3942 this said acks "must be deleted" from a named FILE. #3942 replaces the + // remedy with amending a commit trailer — there is no file to name any more. const r = diffEmitted({ baseline: mf({ [WORKFLOW_KEY]: 'aaa' }), current: mf({ [WORKFLOW_KEY]: 'aaa' }), changedPaths: [], - ack: { version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'old' } } }, - baseAck: null, + ackHash: mapOf({ [WORKFLOW_KEY]: { reason: 'old' } }), }); const stale = blockOf(buildReport(r), 'stale-acks'); - assert.deepEqual(stale.items, [WORKFLOW_KEY]); + assert.deepEqual(stale.items, [{ key: WORKFLOW_KEY, space: 'hash' }]); assert.equal(stale.fix, REMEDIATION.staleAckFix); - assert.match(stale.fix, /delete the file/, 'the last-entry case must be covered'); + assert.match(stale.fix, /Remove the trailer/, 'the remedy must name the trailer, not a file'); - // A stale-only report has nothing to acknowledge — it must NOT offer a document. - assert.deepEqual(buildReport(r).ackable, [], 'deleting an ack is not acknowledging one'); + // A stale-only report has nothing to acknowledge — it must NOT offer a trailer to add. + assert.deepEqual(buildReport(r).ackable, [], 'removing a stale trailer is not acknowledging one'); }); test('growth and a stale ack in one report keep both remedies', () => { - // The contributor is adding one entry and removing another in the same file. - const r = growthOnly({ ack: { version: ACK_VERSION, paths: { 'gone.md': { reason: 'outlived' } } } }); - assert.deepEqual(r.staleAcks, ['gone.md']); + // The contributor is adding one entry and removing another — 'gone.md' is a bare + // filename (growth space). + const r = growthOnly({ ackGrowth: mapOf({ 'gone.md': { reason: 'outlived' } }) }); + assert.deepEqual(r.staleAcks, [{ key: 'gone.md', space: 'growth' }]); assert.equal(r.grown[0].acked, false); const report = buildReport(r); assert.ok(blockOf(report, 'unacked-growth'), 'the growth still needs an ack'); - assert.ok(blockOf(report, 'stale-acks'), 'the stale entry still needs deleting'); - assert.deepEqual(report.ackable, [{ key: 'explore.md', reason: REMEDIATION.growthReason }], - 'only the growth is ackable; the stale entry is removed, not added'); + assert.ok(blockOf(report, 'stale-acks'), 'the stale entry still needs removing'); + assert.deepEqual( + report.ackable, [{ key: 'explore.md', reason: REMEDIATION.growthReason, space: 'growth' }], + 'only the growth is ackable; the stale entry is removed, not added', + ); }); test('the validation early-return renders instead of throwing', () => { @@ -2676,6 +895,15 @@ test('the validation early-return renders instead of throwing', () => { { baseline: {}, current: null, changedPaths: [] }, { baseline: {}, current: {}, changedPaths: null }, { baseline: [], current: {}, changedPaths: [] }, + // #3942: ackHash/ackGrowth are new inputs (a pre-parsed Map, not a JSON document) + // and were NOT gated the way baseline/current/changedPaths already are above — the + // identical #2778 crash class, just on a newer parameter pair. + { baseline: {}, current: {}, changedPaths: [], ackHash: {} }, + { baseline: {}, current: {}, changedPaths: [], ackHash: null }, + { baseline: {}, current: {}, changedPaths: [], ackHash: 'x' }, + { baseline: {}, current: {}, changedPaths: [], ackGrowth: {} }, + { baseline: {}, current: {}, changedPaths: [], ackGrowth: null }, + { baseline: {}, current: {}, changedPaths: [], ackGrowth: 'x' }, ]) { const r = diffEmitted(bad); assert.ok(!r.ok); @@ -2688,6 +916,48 @@ test('the validation early-return renders instead of throwing', () => { } }); +describe('diffEmitted validates ackHash/ackGrowth (#2778-shape defect, #3942)', () => { + // #3942 changed diffEmitted's ack inputs from a parsed JSON document to a pre-parsed + // Map per space (parseAckTrailers' output). baseline/current/changedPaths already had + // a validation guard for exactly this class of bad input (:296-301) — ackHash/ + // ackGrowth did not, so a bad shape reached `liveAckHash.has(rel)` / + // `liveAckGrowth.has(name)` further down and threw an unhandled TypeError instead of + // an error verdict, rather than being caught and named up front like every other + // input. + const BAD_SHAPES = [ + { label: 'plain object', value: {} }, + { label: 'null', value: null }, + { label: 'string', value: 'x' }, + { label: 'array', value: [] }, + ]; + + for (const { label, value } of BAD_SHAPES) { + test(`ackHash=${label} raises an error verdict naming ackHash, never throws`, () => { + const r = diffEmitted({ + baseline: mf({}), current: mf({}), changedPaths: [], ackHash: value, + }); + assert.equal(r.ok, false); + assert.ok(r.errors.some((e) => e.includes('ackHash')), `errors must name ackHash: ${JSON.stringify(r.errors)}`); + }); + + test(`ackGrowth=${label} raises an error verdict naming ackGrowth, never throws`, () => { + const r = diffEmitted({ + baseline: mf({}), current: mf({}), changedPaths: [], ackGrowth: value, + }); + assert.equal(r.ok, false); + assert.ok(r.errors.some((e) => e.includes('ackGrowth')), `errors must name ackGrowth: ${JSON.stringify(r.errors)}`); + }); + } + + test('a well-formed Map for both ackHash and ackGrowth is accepted (the healthy steady state is not gated away)', () => { + const r = diffEmitted({ + baseline: mf({}), current: mf({}), changedPaths: [], ackHash: new Map(), ackGrowth: new Map(), + }); + assert.equal(r.ok, true); + assert.deepEqual(r.errors, []); + }); +}); + test('a failed git diff renders as an error, never as "nothing changed"', () => { // The comment on that validation branch says a failed `git diff` must never be read as // an empty change set. That contract is only worth anything if the resulting report is @@ -2708,8 +978,7 @@ test('a passing result produces no blocks and nothing to acknowledge', () => { test('an acked growth produces no block and nothing to acknowledge', () => { // The contributor already did the thing the remediation asks for; repeating it is noise. const r = growthOnly({ - ack: { version: ACK_VERSION, paths: { 'explore.md': { reason: 'new mode section' } } }, - baseAck: null, + ackGrowth: mapOf({ 'explore.md': { reason: 'new mode section' } }), }); assert.ok(r.ok); const report = buildReport(r); @@ -2731,14 +1000,13 @@ test('a mixed grown set offers an ack entry only for the unacked files', () => { baseline: mf({}), current: mf({}), changedPaths: [], sizeBaseline: { 'kept.md': 100, 'loud.md': 100 }, sizeCurrent: { 'kept.md': 200, 'loud.md': 200 }, - ack: { version: ACK_VERSION, paths: { 'kept.md': { reason: 'declared' } } }, - baseAck: null, + ackGrowth: mapOf({ 'kept.md': { reason: 'declared' } }), }); const report = buildReport(r); const growth = blockOf(report, 'unacked-growth'); assert.equal(growth.count, 1, 'only the unacked one is counted'); assert.deepEqual(growth.items.map((g) => g.name), ['loud.md']); - assert.deepEqual(report.ackable, [{ key: 'loud.md', reason: REMEDIATION.growthReason }], + assert.deepEqual(report.ackable, [{ key: 'loud.md', reason: REMEDIATION.growthReason, space: 'growth' }], 'the document must key on the unacked file, not the acked one'); }); @@ -2777,7 +1045,8 @@ test('the new-file cap block carries no ack affordance', () => { assert.equal(cap.count, 1); assert.equal(cap.keyRule, undefined, 'the cap has no key rule because it has no ack'); assert.deepEqual(report.ackable, [], 'the cap must never offer an acknowledgment'); - assert.ok(!formatReport(r).includes(REMEDIATION.ackFile), 'and must not point at the ack file'); + assert.ok(!formatReport(r).includes(REMEDIATION.addTrailerHash), 'and must not offer a hash trailer for it'); + assert.ok(!formatReport(r).includes(REMEDIATION.addTrailerGrowth), 'and must not offer a growth trailer for it'); }); // ─── New-file cap (ADR-1610 Decision point 3, revived after #2724) ─────────── @@ -2825,8 +1094,7 @@ test('the new-file cap is not ack-able (extraction, not acknowledgment, is the f const r = diffEmitted({ baseline: mf({}), current: mf({}), changedPaths: [], sizeBaseline: {}, sizeCurrent: { 'new-workflow.md': NEW_FILE_CAP + 1 }, - ack: { version: ACK_VERSION, paths: { 'new-workflow.md': { reason: 'trying to bypass it' } } }, - baseAck: null, + ackGrowth: mapOf({ 'new-workflow.md': { reason: 'trying to bypass it' } }), }); assert.equal(r.newFileCapExceeded.length, 1, 'an ack entry must not exempt the new-file cap'); assert.ok(!r.ok); @@ -3249,43 +1517,9 @@ test( }, ); -test('readAckFile: absent is legal, malformed and unreadable are not', () => { - const tmp = fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'gsd-ack-')); - try { - const ackPath = path.join(tmp, 'emitted-drift-ack.json'); - - // Absent == no acks. The healthy steady state. - assert.equal(readAckFile(ackPath), null); - - // Present and valid. - fs.writeFileSync(ackPath, JSON.stringify({ version: ACK_VERSION, paths: {} })); - assert.deepEqual(readAckFile(ackPath), { version: ACK_VERSION, paths: {} }); - - // Present but empty — must NOT be read as absent. - fs.writeFileSync(ackPath, ''); - assert.throws(() => readAckFile(ackPath), /present but empty/); - - // Present but not JSON. - fs.writeFileSync(ackPath, '{not json'); - assert.throws(() => readAckFile(ackPath), /not valid JSON/); - - // Unreadable: monkeypatch the fs method, restore in `finally`. NEVER chmod 0o000 — - // root bypasses mode bits, so the test would silently pass with zero coverage in - // root Docker/CI. This exercises the SUT (readAckFile), not fs itself. - fs.writeFileSync(ackPath, JSON.stringify({ version: ACK_VERSION, paths: {} })); - const orig = fs.readFileSync; - try { - fs.readFileSync = () => { throw new Error('injected ack read failure'); }; - assert.throws(() => readAckFile(ackPath), /injected ack read failure/); - } finally { - fs.readFileSync = orig; - } - // Restoration is real, not assumed. - assert.deepEqual(readAckFile(ackPath), { version: ACK_VERSION, paths: {} }); - } finally { - cleanup(tmp); - } -}); +// `readAckFile` (the legacy JSON-ack-file reader) is deleted — ADR-3942 §1/§6 moves the +// acknowledgment to a commit trailer, and its reader (`readAckTrailers`) is covered by +// tests/emitted-ack-trailer.test.cjs, not here. test('formatReport truncation is exact at limit-1 / limit / limit+1', () => { // sampleLimit gates a real branch. CLAUDE.md's boundary rule applies to it like any @@ -3363,8 +1597,7 @@ test('property: every moved key lands in exactly one bucket', () => { baseline: mf(baseline), current: mf(current), changedPaths: changedKeys.map((k) => sources[k]), - ack: { version: ACK_VERSION, paths: ackPaths }, - baseAck: null, + ackHash: mapOf(ackPaths), }); if (r.errors.length) return false; @@ -3878,21 +2111,25 @@ test('differential attribution over the real tree', { timeout: 480_000 }, async assert.ok(baseline && Object.keys(baseline).length > 0, `resolved baseline via ${resolvedBaseline.via} has no families`); const changedPaths = resolveChangedPaths(base); - // #2914: unions the legacy single file with every per-PR fragment under - // tests/emitted-drift-acks/. `mergeAckErrors` (e.g. two sources naming the same path) - // is folded into `diffEmitted`'s own errors below, exactly like any other ack schema - // problem — never silently resolved. - const { doc: ack, errors: mergeAckErrors } = readAckSources(); + // #3942: acknowledgments live in COMMIT TRAILERS over `..HEAD` now, never + // the legacy fragment directory / single file `readAckSources` unions — that read + // source is retired for this, the shipping caller (40-design.md). `readAckTrailers` + // resolves the merge-base internally from `base`, which is the SAME ref + // `resolveChangedPaths` used to build `changedPaths` above — the ack range and the + // change range must share one base, or a trailer could excuse a delta structurally + // outside the diff (40-design.md Correction 2). `trailerErrors` (a per-value parse + // problem — bad delimiter, empty reason, an ambiguous double declaration) folds into + // `diffEmitted`'s own errors below exactly like any other ack schema problem, never + // silently resolved. + const { hash: ackHash, growth: ackGrowth, errors: trailerErrors } = readAckTrailers({ baseRef: base }); const current = currentManifests(); - // Consult the base side ONLY when this tree actually has a document to classify. - // `readAckSourcesAtRef` throws on a base it cannot read, which is right — but reading - // it unconditionally would DEADLOCK the repo if `next` ever carried a corrupt ack (a - // bad merge leaving conflict markers in exactly the file class this epic exists over): - // every PR would go red, INCLUDING the PR that deletes the corrupt file and repairs - // base. A tree carrying no ack has nothing to inherit, so it needs no base read — which - // is precisely the shape of the repair PR, and it lands and unblocks everyone. - const baseAck = ack === null ? null : readAckSourcesAtRef(baseSha).doc; + // There is no base-side read here at all (contrast the pre-#3942 `readAckSourcesAtRef` + // call this replaces): a trailer read over `..HEAD` is structurally + // incapable of returning an entry that was "already at the base" — an ancestor of the + // merge-base is out of range by construction (40-design.md row 8, Correction 1) — so + // `baseAck` is not passed, and `diffEmitted`'s `spentAcks` is always empty for this + // caller. // Reconcile the family SET across three independent signals, rather than asserting one // count against both sides. The baseline is built at the base ref and the current tree @@ -3918,19 +2155,11 @@ test('differential attribution over the real tree', { timeout: 480_000 }, async baseline, current, changedPaths, - ack, - // The base side of the ack lifecycle (#2789). Without it a MERGED ack is - // indistinguishable from one that never explained anything, which is what reddened - // `next` for five commits and every PR branching off it (#2768). Entries already - // present here are spent: inert, never stale, and unable to pre-clear a new ripple. - // - // Keyed on `baseSha`, not `base`: the baseline half is already validated against that - // exact sha, so both halves of the base side provably describe the SAME commit, and a - // ref that moved between `resolveBase()` and here cannot split them. - baseAck, + ackHash, + ackGrowth, sizeBaseline: resolvedBaseline.sizeBaseline, sizeCurrent: currentSizes(), - mergeAckErrors, + mergeAckErrors: trailerErrors, }); assert.ok( @@ -4306,737 +2535,8 @@ describe('#3271: emitted-runtime-bounds', () => { }); }); -// ─── E. #3842: stage the sweep — hold a fragment an open PR still touches ─── -// -// The sweep landed by #3078 deletes every all-spent fragment unconditionally. When an -// OPEN pull request still modifies that same file, deleting it hands that PR a -// `modify/delete` conflict on its very next merge attempt — the exact shared-file -// conflict fragments were adopted (#2914) to end, reintroduced by the sweep itself. -// #3330, #3774, and #3648 all conflicted the first time the sweep ran, each with the -// swept fragment as its ONLY conflicting path. `assertNoAllSpentFragments` now takes an -// optional `openPrTouchedPaths` to defer sweeping those; `fetchOpenPrTouchedAckPaths` -// computes that set with one `gh pr list` call; `runGuardNext` wires the two together -// behind an opt-in `--defer-to-open-prs` flag so every pre-#3842 caller (including every -// test above, and section C's real-git-repo tests) is completely unaffected. -describe('#3842: assertNoAllSpentFragments defers to open PRs', () => { - test('omitting openPrTouchedPaths entirely is byte-identical to pre-#3842 behavior', () => { - const spent = doc({ a: { reason: 'a' } }); - const withoutOpt = assertNoAllSpentFragments([frag('a.json', spent, spent)]); - const withEmptyOpt = assertNoAllSpentFragments([frag('a.json', spent, spent)], {}); - assert.deepEqual(withoutOpt, withEmptyOpt); - assert.ok(!withoutOpt.ok); - assert.deepEqual(withoutOpt.sweepable, ['a.json']); - }); - - test('an all-spent fragment named by an open PR is held, not reported as sweepable', () => { - const spent = doc({ a: { reason: 'a' } }); - const r = assertNoAllSpentFragments( - [frag('a.json', spent, spent)], - { openPrTouchedPaths: new Set([`${ACK_DIR_REPO_PATH}/a.json`]) }, - ); - assert.ok(r.ok, 'nothing left safe to sweep, so the guard must pass'); - assert.deepEqual(r.sweepable, []); - assert.match(r.message, /deferred:.*held back because an open pull request/s); - assert.match(r.message, new RegExp(`${ACK_DIR_REPO_PATH}/a\\.json.*held`)); - assert.doesNotMatch(r.message, /git rm/, 'a held fragment must never carry a git rm remedy'); - }); - - test('a mix of held and safe-to-sweep fragments fails only on the safe one', () => { - const spentHeld = doc({ a: { reason: 'a' } }); - const spentSafe = doc({ b: { reason: 'b' } }); - const r = assertNoAllSpentFragments( - [frag('held.json', spentHeld, spentHeld), frag('safe.json', spentSafe, spentSafe)], - { openPrTouchedPaths: new Set([`${ACK_DIR_REPO_PATH}/held.json`]) }, - ); - assert.ok(!r.ok, 'one fragment is still safe to sweep, so the guard must still fail'); - assert.deepEqual(r.sweepable, ['safe.json']); - assert.match(r.message, /safe\.json/); - assert.match(r.message, /git rm[^\n]*safe\.json/); - assert.match(r.message, /held\.json.*held/s); - assert.doesNotMatch(r.message, /git rm[^\n]*held\.json/); - }); - - test('the "unknown" sentinel holds every otherwise-sweepable fragment (a failed open-PR lookup must never sweep blind)', () => { - const spentA = doc({ a: { reason: 'a' } }); - const spentB = doc({ b: { reason: 'b' } }); - const r = assertNoAllSpentFragments( - [frag('a.json', spentA, spentA), frag('b.json', spentB, spentB)], - { openPrTouchedPaths: 'unknown' }, - ); - assert.ok(r.ok); - assert.deepEqual(r.sweepable, []); - assert.match(r.message, /open-PR check unavailable/); - assert.match(r.message, /a\.json/); - assert.match(r.message, /b\.json/); - }); - - test('a PARTIALLY spent fragment is left alone regardless of open-PR touch — the open-PR set only ever narrows an already-sweepable list', () => { - const live = doc({ c: { reason: 'c-new' } }); - const liveBase = doc({ c: { reason: 'c-old' } }); - const r = assertNoAllSpentFragments( - [frag('live.json', live, liveBase)], - { openPrTouchedPaths: new Set([`${ACK_DIR_REPO_PATH}/live.json`]) }, - ); - assert.ok(r.ok); - assert.deepEqual(r.sweepable, []); - assert.doesNotMatch(r.message, /live\.json/, 'a partially-spent fragment is never mentioned by either rule'); - }); -}); - -describe('#3842: fetchOpenPrTouchedAckPaths', () => { - const prsWithFile = (relPath) => JSON.stringify([{ number: 1, files: [{ path: relPath }] }]); - - test('returns only paths under the fragment directory, ignoring unrelated changed files', () => { - const stdout = JSON.stringify([ - { number: 1, files: [{ path: `${ACK_DIR_REPO_PATH}/a.json` }, { path: 'README.md' }] }, - { number: 2, files: [{ path: 'src/foo.cts' }] }, - ]); - const paths = fetchOpenPrTouchedAckPaths({ execGh: () => stdout }); - assert.deepEqual([...paths], [`${ACK_DIR_REPO_PATH}/a.json`]); - }); - - test('accepts a bare-string file entry, not only { path }-shaped ones', () => { - const stdout = JSON.stringify([{ number: 1, files: [`${ACK_DIR_REPO_PATH}/a.json`] }]); - const paths = fetchOpenPrTouchedAckPaths({ execGh: () => stdout }); - assert.deepEqual([...paths], [`${ACK_DIR_REPO_PATH}/a.json`]); - }); - - test('a PR with no files array, or an empty one, contributes nothing and does not throw', () => { - const stdout = JSON.stringify([{ number: 1 }, { number: 2, files: [] }]); - const paths = fetchOpenPrTouchedAckPaths({ execGh: () => stdout }); - assert.deepEqual([...paths], []); - }); - - test('zero open PRs is an empty set, not an error', () => { - const paths = fetchOpenPrTouchedAckPaths({ execGh: () => '[]' }); - assert.deepEqual([...paths], []); - }); - - test('invokes gh with the expected argv: pr list, open state, number+files json, and a --limit', () => { - let capturedArgs; - fetchOpenPrTouchedAckPaths({ - execGh: (args) => { capturedArgs = args; return '[]'; }, - limit: 42, - }); - assert.deepEqual(capturedArgs, ['pr', 'list', '--state', 'open', '--json', 'number,files', '--limit', '42']); - }); - - test('malformed JSON from gh throws, rather than degrading to an empty (falsely "nothing touched") set', () => { - assert.throws( - () => fetchOpenPrTouchedAckPaths({ execGh: () => '{ not json' }), - /did not return valid JSON/, - ); - }); - - test('a non-array JSON value from gh throws', () => { - assert.throws( - () => fetchOpenPrTouchedAckPaths({ execGh: () => '{"unexpected":"shape"}' }), - /expected a JSON array/, - ); - }); - - test('a real execGh failure (gh missing, unauthenticated, rate-limited) propagates rather than being swallowed here', () => { - assert.throws( - () => fetchOpenPrTouchedAckPaths({ - execGh: () => { throw new Error('gh: command not found'); }, - }), - /command not found/, - ); - }); - - // Boundary coverage at the MAX_OPEN_PRS cap: limit-1, limit, limit+1. - test(`boundary: ${MAX_OPEN_PRS - 1} open PRs (limit-1) is accepted without truncation risk`, () => { - const n = MAX_OPEN_PRS - 1; - const stdout = JSON.stringify(Array.from({ length: n }, (_, i) => ({ number: i, files: [] }))); - assert.doesNotThrow(() => fetchOpenPrTouchedAckPaths({ execGh: () => stdout })); - }); - - test(`boundary: exactly ${MAX_OPEN_PRS} open PRs (limit) throws — a list this long might be truncated by --limit itself`, () => { - const n = MAX_OPEN_PRS; - const stdout = JSON.stringify(Array.from({ length: n }, (_, i) => ({ number: i, files: [] }))); - assert.throws( - () => fetchOpenPrTouchedAckPaths({ execGh: () => stdout }), - /at or above the cap/, - ); - }); - - test(`boundary: ${MAX_OPEN_PRS + 1} open PRs (limit+1) also throws`, () => { - const n = MAX_OPEN_PRS + 1; - const stdout = JSON.stringify(Array.from({ length: n }, (_, i) => ({ number: i, files: [] }))); - assert.throws( - () => fetchOpenPrTouchedAckPaths({ execGh: () => stdout }), - /at or above the cap/, - ); - }); - - test('sanity: a stub returning a single PR that touches one fragment is detected (guards against a vacuously-passing stub)', () => { - const paths = fetchOpenPrTouchedAckPaths({ execGh: () => prsWithFile(`${ACK_DIR_REPO_PATH}/x.json`) }); - assert.equal(paths.has(`${ACK_DIR_REPO_PATH}/x.json`), true); - assert.equal(paths.size, 1); - }); - - // `gh pr list --json files` truncates each PR's own file list at MAX_PR_FILES, silently - // (#3842, PR #3848: 124 files changed, 100 returned, none of the two ack paths among - // them because the list stops mid `gsd-core/workflows/`, which sorts before `tests/`). - // A PR AT the cap is answered with one additional paginated `gh api` call; below the - // cap, the single `gh pr list` call is trusted as-is. - const filler = (n) => Array.from( - { length: n }, - (_, i) => ({ path: `gsd-core/workflows/w${String(i).padStart(4, '0')}.md` }), - ); - - test('a sub-cap PR is answered by the single list call', () => { - const files = filler(MAX_PR_FILES - 2).concat([{ path: `${ACK_DIR_REPO_PATH}/a.json` }]); - const stdout = JSON.stringify([{ number: 1, files }]); - let calls = 0; - const paths = fetchOpenPrTouchedAckPaths({ execGh: () => { calls += 1; return stdout; } }); - assert.equal(calls, 1); - assert.equal(paths.has(`${ACK_DIR_REPO_PATH}/a.json`), true); - }); - - test('a PR at the file cap is re-fetched, because full and truncated look identical', () => { - const files = filler(MAX_PR_FILES); - const stdout = JSON.stringify([{ number: 77, files }]); - const paths = fetchOpenPrTouchedAckPaths({ - execGh: (args) => (args[0] === 'pr' ? stdout : `${ACK_DIR_REPO_PATH}/a.json\n`), - }); - assert.equal(paths.has(`${ACK_DIR_REPO_PATH}/a.json`), true); - }); - - test('a PR past the file cap has its ack path recovered by the re-fetch', () => { - const files = filler(MAX_PR_FILES + 1); - const stdout = JSON.stringify([{ number: 77, files }]); - const paths = fetchOpenPrTouchedAckPaths({ - execGh: (args) => (args[0] === 'pr' ? stdout : `${ACK_DIR_REPO_PATH}/a.json\n`), - }); - assert.equal(paths.has(`${ACK_DIR_REPO_PATH}/a.json`), true); - }); - - test('the re-fetch asks the paginated REST endpoint for that PR', () => { - const files = filler(MAX_PR_FILES); - const stdout = JSON.stringify([{ number: 77, files }]); - let capturedArgs; - fetchOpenPrTouchedAckPaths({ - execGh: (args) => { - if (args[0] === 'pr') return stdout; - capturedArgs = args; - return ''; - }, - }); - assert.ok(capturedArgs.includes('api')); - assert.ok(capturedArgs.includes('repos/{owner}/{repo}/pulls/77/files')); - assert.ok(capturedArgs.includes('--paginate')); - }); - - test('a failed re-fetch throws rather than trusting the truncated list', () => { - const files = filler(MAX_PR_FILES); - const stdout = JSON.stringify([{ number: 77, files }]); - assert.throws(() => fetchOpenPrTouchedAckPaths({ - execGh: (args) => { - if (args[0] === 'pr') return stdout; - throw new Error('gh: rate limited'); - }, - })); - }); - - test("a PR beyond GitHub's own file ceiling throws rather than returning a partial set", () => { - const files = filler(MAX_PR_FILES); - const stdout = JSON.stringify([{ number: 77, files }]); - const apiOut = Array.from( - { length: GITHUB_MAX_PR_FILES }, - (_, i) => `gsd-core/workflows/w${i}.md`, - ).join('\n'); - assert.throws(() => fetchOpenPrTouchedAckPaths({ - execGh: (args) => (args[0] === 'pr' ? stdout : apiOut), - })); - }); - - // Boundary coverage at GitHub's own file ceiling: limit-1, limit, limit+1. - // (limit is the test immediately above.) - test("boundary: a re-fetch just under GitHub's file ceiling is accepted", () => { - const files = filler(MAX_PR_FILES); - const stdout = JSON.stringify([{ number: 77, files }]); - const paths = Array.from( - { length: GITHUB_MAX_PR_FILES - 1 }, - (_, i) => `gsd-core/workflows/w${i}.md`, - ); - paths[0] = `${ACK_DIR_REPO_PATH}/a.json`; - const apiOut = paths.join('\n'); - let result; - assert.doesNotThrow(() => { - result = fetchOpenPrTouchedAckPaths({ - execGh: (args) => (args[0] === 'pr' ? stdout : apiOut), - }); - }); - assert.equal(result.has(`${ACK_DIR_REPO_PATH}/a.json`), true); - }); - - test('boundary: a re-fetch one past GitHub\'s file ceiling also throws', () => { - const files = filler(MAX_PR_FILES); - const stdout = JSON.stringify([{ number: 77, files }]); - const apiOut = Array.from( - { length: GITHUB_MAX_PR_FILES + 1 }, - (_, i) => `gsd-core/workflows/w${i}.md`, - ).join('\n'); - assert.throws(() => fetchOpenPrTouchedAckPaths({ - execGh: (args) => (args[0] === 'pr' ? stdout : apiOut), - })); - }); - - test('a re-fetched PR that touches no fragment contributes nothing', () => { - const files = filler(MAX_PR_FILES); - const stdout = JSON.stringify([{ number: 77, files }]); - const apiOut = filler(MAX_PR_FILES + 5).map((f) => f.path).join('\n'); - const paths = fetchOpenPrTouchedAckPaths({ - execGh: (args) => (args[0] === 'pr' ? stdout : apiOut), - }); - assert.equal(paths.size, 0); - }); - - test('each capped PR is re-fetched independently', () => { - const cappedFiles = filler(MAX_PR_FILES); - const subCapFiles = filler(MAX_PR_FILES - 1); - const stdout = JSON.stringify([ - { number: 1, files: cappedFiles }, - { number: 2, files: cappedFiles }, - { number: 3, files: subCapFiles }, - ]); - let apiCalls = 0; - const paths = fetchOpenPrTouchedAckPaths({ - execGh: (args) => { - if (args[0] === 'pr') return stdout; - apiCalls += 1; - const prNumber = args[1].match(/pulls\/(\d+)\/files/)[1]; - return prNumber === '2' ? `${ACK_DIR_REPO_PATH}/a.json\n` : 'gsd-core/workflows/w0000.md\n'; - }, - }); - assert.equal(apiCalls, 2); - assert.deepEqual([...paths], [`${ACK_DIR_REPO_PATH}/a.json`]); - }); -}); - -describe('#3842: runGuardNext wires --defer-to-open-prs end to end against a real repo', () => { - test('without --defer-to-open-prs, the open-PR fetcher is never called and behavior is unchanged', () => { - const repo = makeGuardNextRepo(); - try { - repo.writeFrag('a.json', { version: ACK_VERSION, paths: { 'x.md': { reason: 'why' } } }); - const c2 = repo.commit('add fragment'); - fs.writeFileSync(path.join(repo.dir, 'README.md'), 'unrelated\n'); - repo.commit('unrelated change'); - - let calls = 0; - const result = runGuardNext({ - argv: ['node', 'script', '--guard-next', '--base-ref', c2], - cwd: repo.dir, - fetchOpenPrPaths: () => { calls += 1; return new Set(); }, - }); - assert.equal(calls, 0, 'the fetcher must not be invoked without the opt-in flag'); - assert.ok(!result.ok, 'the fully-spent fragment must still fail the guard'); - assert.ok(result.lines.some((l) => l.includes('a.json'))); - } finally { - cleanup(repo.dir); - } - }); - - test('with --defer-to-open-prs, a fragment an "open PR" touches is held instead of failing the guard', () => { - const repo = makeGuardNextRepo(); - try { - repo.writeFrag('a.json', { version: ACK_VERSION, paths: { 'x.md': { reason: 'why' } } }); - const c2 = repo.commit('add fragment'); - fs.writeFileSync(path.join(repo.dir, 'README.md'), 'unrelated\n'); - repo.commit('unrelated change'); - - const result = runGuardNext({ - argv: ['node', 'script', '--guard-next', '--base-ref', c2, '--defer-to-open-prs'], - cwd: repo.dir, - fetchOpenPrPaths: () => new Set([`${ACK_DIR_REPO_PATH}/a.json`]), - }); - assert.ok(result.ok, 'the only sweepable fragment is held, so the guard must pass'); - assert.ok(result.lines.some((l) => l.includes('a.json') && l.includes('held'))); - } finally { - cleanup(repo.dir); - } - }); - - test('with --defer-to-open-prs, a failing fetcher holds everything rather than sweeping blind, and still reports why', () => { - const repo = makeGuardNextRepo(); - try { - repo.writeFrag('a.json', { version: ACK_VERSION, paths: { 'x.md': { reason: 'why' } } }); - const c2 = repo.commit('add fragment'); - fs.writeFileSync(path.join(repo.dir, 'README.md'), 'unrelated\n'); - repo.commit('unrelated change'); - - const result = runGuardNext({ - argv: ['node', 'script', '--guard-next', '--base-ref', c2, '--defer-to-open-prs'], - cwd: repo.dir, - fetchOpenPrPaths: () => { throw new Error('gh: rate limited'); }, - }); - assert.ok(result.ok, 'an unverifiable open-PR set must hold rather than sweep'); - assert.ok(result.lines.some((l) => l.includes('gh: rate limited'))); - assert.ok(result.lines.some((l) => l.includes('open-PR check unavailable'))); - } finally { - cleanup(repo.dir); - } - }); - - test('a re-fetch failure degrades to the unknown sentinel, not to an empty set', () => { - const repo = makeGuardNextRepo(); - try { - repo.writeFrag('a.json', { version: ACK_VERSION, paths: { 'x.md': { reason: 'why' } } }); - const c2 = repo.commit('add fragment'); - fs.writeFileSync(path.join(repo.dir, 'README.md'), 'unrelated\n'); - repo.commit('unrelated change'); - - const result = runGuardNext({ - argv: ['node', 'script', '--guard-next', '--base-ref', c2, '--defer-to-open-prs'], - cwd: repo.dir, - fetchOpenPrPaths: () => { throw new Error('fetchPrFiles: PR #77 re-fetch failed'); }, - }); - assert.ok(result.ok, 'an unverifiable open-PR set must hold rather than sweep'); - assert.ok(result.lines.some((l) => l.includes('a.json') && l.includes('held'))); - assert.ok(result.lines.some((l) => l.includes('open-PR check unavailable'))); - } finally { - cleanup(repo.dir); - } - }); - - test('with --defer-to-open-prs, a fragment untouched by any open PR still sweeps (the flag only narrows, never widens, the safe set)', () => { - const repo = makeGuardNextRepo(); - try { - repo.writeFrag('a.json', { version: ACK_VERSION, paths: { 'x.md': { reason: 'why' } } }); - const c2 = repo.commit('add fragment'); - fs.writeFileSync(path.join(repo.dir, 'README.md'), 'unrelated\n'); - repo.commit('unrelated change'); - - const result = runGuardNext({ - argv: ['node', 'script', '--guard-next', '--base-ref', c2, '--defer-to-open-prs'], - cwd: repo.dir, - fetchOpenPrPaths: () => new Set(['tests/emitted-drift-acks/unrelated-other.json']), - }); - assert.ok(!result.ok, 'a.json is untouched by any open PR, so it must still be reported as sweepable'); - assert.ok(result.lines.some((l) => l.includes('git rm') && l.includes('a.json'))); - } finally { - cleanup(repo.dir); - } - }); -}); - -// ── #3875: the sweep set as DATA, and the --sweep-plan work-list mode ─────── -// -// `next` sat red for 24 consecutive pushes (2026-08-24 → 2026-08-26) on two -// all-spent fragments nobody swept. The guard computed the right answer every -// time; what was missing was a way for anything except a human reading CI prose -// to ACT on it. #3823 shipped the guard already-failing for the same reason: its -// own sweep was a static list of deletions fixed at branch time, and #3809's -// fragment merged while it was in flight, so the guard reds on its own merge -// commit. A sweeper has to read the set the guard actually reasoned about — not -// re-derive it, and not scrape it back out of the message text. - -describe('#3875: runGuardNext surfaces the sweepable set as data', () => { - test('sweepable names the spent fragment the prose names', () => { - const repo = makeGuardNextRepo(); - try { - repo.writeFrag('a.json', { version: ACK_VERSION, paths: { 'x.md': { reason: 'why' } } }); - const c2 = repo.commit('add fragment'); - fs.writeFileSync(path.join(repo.dir, 'README.md'), 'unrelated\n'); - repo.commit('unrelated change'); - - const result = runGuardNext({ - argv: ['node', 'script', '--guard-next', '--base-ref', c2], - cwd: repo.dir, - }); - assert.ok(!result.ok, 'the fully-spent fragment must fail the guard'); - assert.deepEqual(result.sweepable, ['a.json']); - } finally { - cleanup(repo.dir); - } - }); - - test('a fragment held by the #3842 open-PR deferral never appears in sweepable', () => { - const repo = makeGuardNextRepo(); - try { - repo.writeFrag('a.json', { version: ACK_VERSION, paths: { 'x.md': { reason: 'why' } } }); - const c2 = repo.commit('add fragment'); - fs.writeFileSync(path.join(repo.dir, 'README.md'), 'unrelated\n'); - repo.commit('unrelated change'); - - const result = runGuardNext({ - argv: ['node', 'script', '--guard-next', '--base-ref', c2, '--defer-to-open-prs'], - cwd: repo.dir, - fetchOpenPrPaths: () => new Set([`${ACK_DIR_REPO_PATH}/a.json`]), - }); - assert.ok(result.ok, 'a held fragment must not fail the guard'); - assert.deepEqual( - result.sweepable, [], - 'a sweeper acting on this list must never delete a fragment the hold was protecting', - ); - } finally { - cleanup(repo.dir); - } - }); - - test('a clean tree yields an empty sweepable set', () => { - const repo = makeGuardNextRepo(); - try { - fs.writeFileSync(path.join(repo.dir, 'README.md'), 'root\n'); - const c1 = repo.commit('root'); - fs.writeFileSync(path.join(repo.dir, 'README.md'), 'again\n'); - repo.commit('second'); - - const result = runGuardNext({ - argv: ['node', 'script', '--guard-next', '--base-ref', c1], - cwd: repo.dir, - }); - assert.ok(result.ok); - assert.deepEqual(result.sweepable, []); - } finally { - cleanup(repo.dir); - } - }); -}); - -describe('#3875: --sweep-plan emits a work list, not a verdict', () => { - // `main()` reads process.argv and writes through console, so its argv routing is - // observable in-process only via the injected seams. A subprocess run against the - // real repository cannot exhibit an arbitrary sweep set on demand, which is exactly - // the interesting case. - const runMain = (argv, guardResult) => { - const out = []; - const err = []; - const saved = process.exitCode; - process.exitCode = undefined; - try { - main({ - argv, - out: (line) => out.push(String(line)), - err: (line) => err.push(String(line)), - guard: () => guardResult, - }); - return { out, err, exitCode: process.exitCode }; - } finally { - process.exitCode = saved; - } - }; - - const spent = { - ok: false, - lines: ['guard prose line'], - sweepable: ['a.json', 'b.json'], - legacyPresent: false, - }; - - test('the plan is repo-relative fragment paths on stdout, one per line', () => { - const r = runMain(['node', 'script', '--guard-next', '--sweep-plan'], spent); - assert.deepEqual(r.out, [ - `${ACK_DIR_REPO_PATH}/a.json`, - `${ACK_DIR_REPO_PATH}/b.json`, - ]); - }); - - test('the guard prose is diverted to stderr so stdout is safe to pipe', () => { - const r = runMain(['node', 'script', '--guard-next', '--sweep-plan'], spent); - assert.deepEqual( - r.err, ['guard prose line'], - 'a caller doing `xargs git rm` on stdout must never receive a sentence as a filename', - ); - }); - - test('a non-empty plan still exits 0 — a work list is not a failure', () => { - const r = runMain(['node', 'script', '--guard-next', '--sweep-plan'], spent); - // `equal(..., undefined)`, not `notEqual(..., 1)`: the weaker form passes for - // ANY value that is not 1, so an implementation setting exitCode to 2 — or to - // anything at all — would survive it. - assert.equal( - r.exitCode, undefined, - 'plan mode must leave the exit code untouched; a non-zero exit would fail the sweeper step before it could act on the list it asked for', - ); - }); - - test('a clean tree emits an empty plan, still reports the prose, and exits 0', () => { - const r = runMain( - ['node', 'script', '--guard-next', '--sweep-plan'], - { ok: true, lines: ['all clear'], sweepable: [], legacyPresent: false }, - ); - assert.deepEqual(r.out, []); - // Asserting only the empty stdout would be vacuous — it holds for an - // implementation that emits nothing anywhere. The prose must still reach - // stderr, or a silent run is indistinguishable from a broken one. - assert.deepEqual(r.err, ['all clear']); - assert.equal(r.exitCode, undefined); - }); - - test('without --sweep-plan the guard lane is unchanged: prose on stdout, exit 1', () => { - const r = runMain(['node', 'script', '--guard-next'], spent); - assert.deepEqual(r.out, ['guard prose line']); - assert.deepEqual(r.err, []); - assert.equal(r.exitCode, 1, 'the verdict lane must keep failing on a surviving spent fragment'); - }); - - test('the legacy ack document leads the plan when present', () => { - const r = runMain( - ['node', 'script', '--guard-next', '--sweep-plan'], - { ok: false, lines: ['prose'], sweepable: ['a.json'], legacyPresent: true }, - ); - assert.deepEqual(r.out, [ACK_REPO_PATH, `${ACK_DIR_REPO_PATH}/a.json`]); - }); - - test('the legacy ack document is absent from the plan when it is not on the tree', () => { - const r = runMain( - ['node', 'script', '--guard-next', '--sweep-plan'], - { ok: false, lines: ['prose'], sweepable: ['a.json'], legacyPresent: false }, - ); - assert.deepEqual(r.out, [`${ACK_DIR_REPO_PATH}/a.json`]); - }); - - test('--sweep-plan without --guard-next falls through to the validation lane', () => { - const repo = makeGuardNextRepo(); - try { - fs.writeFileSync(path.join(repo.dir, 'README.md'), 'root\n'); - repo.commit('root'); - const out = []; - const err = []; - main({ - argv: ['node', 'script', '--sweep-plan'], - cwd: repo.dir, - out: (line) => out.push(String(line)), - err: (line) => err.push(String(line)), - }); - // `--sweep-plan` is meaningful only alongside `--guard-next`. Pinned so the - // routing cannot quietly change into "plan mode implies guard mode", which - // would make a bare --sweep-plan print a work list computed against a base - // ref nobody asked for. - assert.equal(err.length, 0); - assert.equal(out.length, 1); - assert.ok(out[0].startsWith('ok lint-emitted-drift-ack:'), `got: ${out[0]}`); - } finally { - cleanup(repo.dir); - } - }); -}); - -describe('#3875: the legacy document and the fragment directory are separate sweep inputs', () => { - test('runGuardNext reports a revived legacy ack document as present', () => { - const repo = makeGuardNextRepo(); - try { - fs.writeFileSync(path.join(repo.dir, 'README.md'), 'root\n'); - const c1 = repo.commit('root'); - const legacy = path.join(repo.dir, ...ACK_REPO_PATH.split('/')); - fs.mkdirSync(path.dirname(legacy), { recursive: true }); - fs.writeFileSync(legacy, JSON.stringify({ version: ACK_VERSION, paths: {} })); - repo.commit('revive the legacy file'); - - const result = runGuardNext({ - argv: ['node', 'script', '--guard-next', '--base-ref', c1], - cwd: repo.dir, - }); - assert.equal(result.ok, false, 'a legacy ack document on next must fail the guard'); - assert.equal( - result.legacyPresent, true, - 'without this the sweeper is blind to one of the two ways this guard reds next', - ); - } finally { - cleanup(repo.dir); - } - }); - - test('runGuardNext reports legacyPresent false on a tree that has none', () => { - const repo = makeGuardNextRepo(); - try { - fs.writeFileSync(path.join(repo.dir, 'README.md'), 'root\n'); - const c1 = repo.commit('root'); - fs.writeFileSync(path.join(repo.dir, 'README.md'), 'again\n'); - repo.commit('second'); - - const result = runGuardNext({ - argv: ['node', 'script', '--guard-next', '--base-ref', c1], - cwd: repo.dir, - }); - assert.equal(result.legacyPresent, false); - } finally { - cleanup(repo.dir); - } - }); - - // The live shape on `next` when #3875 was written: four all-spent fragments, two - // of them held by an open PR. An implementation that always returned an empty - // `sweepable` would pass the all-held and clean-tree cases; only a mixed one - // proves the partition is real. - test('a mixed tree partitions into swept and held, and reports both', () => { - const repo = makeGuardNextRepo(); - try { - repo.writeFrag('held.json', { version: ACK_VERSION, paths: { 'h.md': { reason: 'held' } } }); - repo.writeFrag('sweep.json', { version: ACK_VERSION, paths: { 's.md': { reason: 'sweep' } } }); - const c2 = repo.commit('add two fragments'); - fs.writeFileSync(path.join(repo.dir, 'README.md'), 'unrelated\n'); - repo.commit('unrelated change'); - - const result = runGuardNext({ - argv: ['node', 'script', '--guard-next', '--base-ref', c2, '--defer-to-open-prs'], - cwd: repo.dir, - fetchOpenPrPaths: () => new Set([`${ACK_DIR_REPO_PATH}/held.json`]), - }); - assert.deepEqual(result.sweepable, ['sweep.json'], 'only the unheld fragment is sweepable'); - assert.ok(!result.ok, 'an unheld all-spent fragment still fails the guard'); - const prose = result.lines.join('\n'); - assert.ok(prose.includes('held.json') && prose.includes('held'), 'the held fragment must still be reported, not silently dropped'); - assert.ok(prose.includes('git rm') && prose.includes('sweep.json'), 'the sweepable fragment must name its remedy'); - } finally { - cleanup(repo.dir); - } - }); - - test('main() honours injected cwd and out in the validation lane, not just the guard lane', () => { - const repo = makeGuardNextRepo(); - try { - fs.writeFileSync(path.join(repo.dir, 'README.md'), 'root\n'); - repo.commit('root'); - const out = []; - const err = []; - main({ - argv: ['node', 'script'], - cwd: repo.dir, - out: (line) => out.push(String(line)), - err: (line) => err.push(String(line)), - }); - // A half-injected seam is worse than none: with `cwd` ignored this reads the - // REAL repository and reports on whatever is checked in, which passes for - // reasons entirely unrelated to the fixture. - assert.equal(err.length, 0, 'a clean fixture tree has no problems to report'); - assert.equal(out.length, 1, 'exactly one summary line, and on the injected sink'); - assert.ok(out[0].includes('no acknowledgment sources present'), `got: ${out[0]}`); - } finally { - cleanup(repo.dir); - } - }); - - test('main() reports a malformed fragment through the injected err sink', () => { - const repo = makeGuardNextRepo(); - try { - repo.writeFrag('bad.json', {}); - fs.writeFileSync( - path.join(repo.dir, ...ACK_DIR_REPO_PATH.split('/'), 'bad.json'), - 'not json at all', - ); - repo.commit('add a malformed fragment'); - const out = []; - const err = []; - const saved = process.exitCode; - process.exitCode = undefined; - try { - main({ - argv: ['node', 'script'], - cwd: repo.dir, - out: (line) => out.push(String(line)), - err: (line) => err.push(String(line)), - }); - assert.ok(err.length > 0, 'the malformed fragment must reach the injected err sink'); - assert.ok(err.join('\n').includes('bad.json'), 'the report must name the offending fragment'); - } finally { - process.exitCode = saved; - } - } finally { - cleanup(repo.dir); - } - }); -}); +// ADR-3942 §6 deletes the legacy pre-merge lint / guard-no-ack-on-next / the #3842 and +// #3875 open-PR-deferred fragment-sweep machinery, plus tests/emitted-drift-acks/ itself +// — the commit-trailer acknowledgment leaves no tree artifact to sweep, defer, or guard. +// Every describe block that lived here exercised that now-deleted script and is gone with it. diff --git a/tests/emitted-drift-acks/3034-parallel-reviewer-lanes.json b/tests/emitted-drift-acks/3034-parallel-reviewer-lanes.json deleted file mode 100644 index 4d0c3451d..000000000 --- a/tests/emitted-drift-acks/3034-parallel-reviewer-lanes.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "version": 1, - "paths": { - "review.md": { - "reason": "#3034 adds opt-in concurrent reviewer-lane dispatch to the invoke_reviewers step. The growth is the feature plus the reasoning that has to travel with it, because this block is shipped shell that is read by an agent at runtime rather than by a compiler: the strict-equality guard and its fail-safe polarity (deliberately inverted relative to the commit_docs guard, so it does not read as an inconsistency to be tidied away), why per-lane result files replaced a shared append (concurrent O_APPEND is atomic only below PIPE_BUF, and write_reviews parses that JSONL to render the models:/model_sources: frontmatter), why aggregation walks selection order rather than completion order, why the slug list is de-duplicated before dispatch, and why the loop body was hoisted into one shared function instead of forking into two dispatch bodies. No prose was moved into an eagerly @-imported reference to shrink the measured file -- total loaded context grew by exactly this diff. #3885 (ADR-3473 §8.5) adds a further, additive growth on top of the above: a gate in write_reviews that refuses to render REVIEWS.md when every dispatched lane left no result (distinguishing an all-budget-skipped run, which is not a failure, from a genuine total lane failure, since both leave the aggregate JSONL empty), a conditional guard on the commit step so a run that hit that gate never commits an artifact it did not produce, an updated present_results branch that reports the failure/skip case instead of the success banner, and a per-lane evidence-preservation step in present_results that copies the run's `gsd-review-*.md` and non-empty `.err` files into `{phase_dir}/.review-diagnostics/` before `rm -rf` destroys the run directory -- the only record a failed run left. None of this is committed alongside REVIEWS.md (the commit step still names only that one file, never a directory glob), so the preserved diagnostics cannot leak into the REVIEWS.md commit. No prose was moved into an eagerly @-imported reference to shrink the measured file." - } - } -} diff --git a/tests/emitted-drift-acks/3172-stated-failing-direction.json b/tests/emitted-drift-acks/3172-stated-failing-direction.json deleted file mode 100644 index 843cd43dc..000000000 --- a/tests/emitted-drift-acks/3172-stated-failing-direction.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "version": 1, - "paths": { - "plan-phase.md": "#3172: plan-phase.md now dispatches the deterministic `gsd_run check verify-failure-directions` probe beside the #2401 path probe and interpolates its result into the plan-checker prompt as {FAILING_DIRECTIONS} inside a new block, and the planner spawn prompt gains a block requiring a `` sibling for every runnable `` command. That contract block is the larger half of the growth and lives here rather than in the planner agent file ON PURPOSE: the planner is frozen under a 49152-LF-char cap, so a planner-side authoring rule is projected onto its spawn contract — the #3297 / #3645 precedent. 90877 -> 93073 bytes (+2196). Deliberate runtime-loaded workflow text for the new gate, not converter drift. Filed as its own fragment per #3078: the 3409 fragment that previously carried plan-phase.md's growth reason was spent and has been swept from next, so nothing else claims this key." - } -} diff --git a/tests/emitted-drift-acks/3707-parse-gap-reporting.json b/tests/emitted-drift-acks/3707-parse-gap-reporting.json deleted file mode 100644 index fce50b413..000000000 --- a/tests/emitted-drift-acks/3707-parse-gap-reporting.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "$comment": "Growth ack (#2914 fragment). Reason: #3707 fixed `audit-uat` reporting zero outstanding items for a UAT file that had three, then dropping the phase from the report entirely. Fixing the parser alone was not enough — both reviewers found independently that the fix was invisible end to end. A file that parses to zero items is now reported with `parse_gap: true` and counted in a new `summary.parse_gap_files`, but parse-gap entries carry no items, and BOTH of these workflows gated their user-visible output on `summary.total_items === 0`. audit-uat.md printed '## All Clear ... Stop here.' and progress.md suppressed its Verification Debt section, so the headline symptom — the phase vanishing — still reproduced for a reader while only the raw JSON had changed. The growth in each file is the widened gate plus the branch that actually reports the unparsed files, with their phase and path, so the reader gets the cue to go and look. Prose is the product here: these steps ARE what an executing agent reads and acts on, so a smaller form would just move the omission. A later revision on this branch tried splitting `summary.parse_gap_files` into a live-only counter plus a separate `summary.archived_parse_gap_files` for archived phases, on the premise that an archived milestone's UAT files are complete by definition. That premise is false — #2766's own rationale states outstanding UAT items do not stop mattering when a milestone closes, since a deferred human-UAT scenario or a `skipped` live-stack test is exactly what gets archived still-open — and the split produced two executed regressions: a phase belonging to the CURRENT milestone but filed under the `milestones/` archive tree was demoted out of the gate, and an archived outstanding row that failed to parse gave `parse_gap_files: 0` where the identical row, parsed, gave `total_items: 1` — burying exactly the debt class #3707 exists to surface. The split is reverted; `parse_gap_files` is ONE counter again, counting every `parse_gap: true` entry regardless of `archived_milestone`, mirroring `total_items`, which never had a split. Both workflow files consequently shrink back toward (but not fully to) their #3707-only size: they retain the original all-clear-gate widening and unparsed-files reporting, but drop every live/archived-split rule, filter, and informational sub-section added afterward. audit-uat.md origin/next 5582 -> 7124 bytes (+1542); progress.md origin/next 24791 -> 25626 bytes (+835). Both still exceed their origin/next size — the ack remains armed for the #3707 all-clear-gate widening and unparsed-file reporting alone, which is real, permanent growth; only the archived-split narrative and its rules are gone.", - "version": 1, - "paths": { - "audit-uat.md": { - "reason": "#3707: the all-clear gate widens from `total_items === 0` to `total_items === 0 && parse_gap_files === 0`, and a new branch reports unparsed UAT files (`parse_gap: true` entries) with phase and path — filtered on `parse_gap: true` alone, never on `archived_milestone`. Without it the command still prints 'All Clear' for a phase whose rows it could not parse, which is the exact symptom this issue reports. A live/archived split was added and then reverted on this branch (see `$comment`): the split's extra rule in `initialize`, the narrowed Unparsed-table filter, and the separate 'Unparsed UAT Files in Archived Milestones' informational section are all removed, so the file settles at origin/next 5582 -> 7124 bytes (+1542, final)." - }, - "progress.md": { - "reason": "#3707: the Verification Debt section no longer gates on outstanding items alone (`outstanding_debt > 0 OR parse_gap_files > 0`), and the table gains a row naming the unparsed file, sourced from `results` entries with `parse_gap: true` — with no `archived_milestone` filter. A live/archived split was added and then reverted on this branch (see `$comment`): the split's `archived_parse_gap_files` tracking, the paragraph explaining why it must not fold into the gate, the paragraph stating the split deliberately does not extend to `outstanding_debt`, and the standalone informational FYI line are all removed, so the file settles at origin/next 24791 -> 25626 bytes (+835, final)." - } - } -} diff --git a/tests/emitted-drift-acks/README.md b/tests/emitted-drift-acks/README.md deleted file mode 100644 index d928b3eb8..000000000 --- a/tests/emitted-drift-acks/README.md +++ /dev/null @@ -1,77 +0,0 @@ -# tests/emitted-drift-acks/ - -Per-PR acknowledgment fragments for the differential attribution check -(`tests/emitted-attribution.test.cjs`, ADR-2719 / #2789 / #2914). - -**This directory being empty is the healthy steady state.** A fragment appearing -in a diff *is* the alarm; a fragment sitting here on `next` is spent cruft. -`README.md` is not a fragment — every reader filters on `.json` — and exists so -the directory (which `CONTEXT.md` and `CONTRIBUTING.md` both reference by path) -survives the sweep that empties it. - -## The lifecycle, in three steps - -1. **The gate names its own remedy.** When an emitted-artifact hash moves, or a - `gsd-core/workflows/*.md` / `agents/gsd-*.md` file grows, and your diff cannot - explain it, the failure output tells you which key to use and prints a minimal - valid document to paste. Create a NEW fragment named for your issue or PR - (`-.json`) — never reuse someone else's, and never revive the - legacy single `tests/emitted-drift-ack.json`. -2. **Note the two key spaces.** The message says which one applies. An - unattributable **hash** ripple is keyed on the emitted path - (`skills/gsd-add-tests/SKILL.md`); **growth** is keyed on the bare filename as - it appears under `gsd-core/workflows/` or `agents/` (`explore.md`). -3. **The fragment is deleted once it has merged (#3078).** Every entry is scoped - to the diff that introduced it, so the moment it lands on `next` its prose is - already at the base — it is spent and can no longer clear anything, while - still owning its path keys. The `guard-no-ack-on-next` job reds `next` and - prints the exact `git rm` for every fully-spent fragment. - - You no longer have to run that `git rm` yourself (#3875). The - `ack-fragment-sweep` workflow runs every six hours, asks the guard for its own - sweep list (`--sweep-plan`), and opens a PR deleting exactly what the guard - named. Deleting the fragment in a follow-up PR by hand still works and is - still welcome — it is simply no longer the only thing standing between a - merged fragment and a red `next`. - - The sweep is automated because the manual remedy could not keep up. The guard - evaluates at MERGE time; a hand-authored `git rm` is fixed at BRANCH time, so - any ack-carrying PR that merges in between invalidates it. #3823 lost exactly - that race to #3809 on its own merge commit and left `next` red for 24 - consecutive pushes. - -## Why the sweep exists - -Fragments end the *file* conflict the single shared document caused. They do not -end the *key* conflict: two ack sources may never name the same path, and that is -a hard, loudly-reported error. So a fully-spent fragment left here walls off every -path it owns — the next PR to grow one of them can declare it neither in the -owning fragment (spent, gates nothing) nor in its own (duplicate). #2914 assumed a -persisting fragment was harmless; #3078 measured the cost at 45 fragments owning -403 paths and made the guard sweep them. - -A **partially** spent fragment is deliberately left alone. That asymmetry is what -keeps the re-arm route working: appending prose to a live entry re-arms it, and -re-arming deliberately costs an actual new sentence — the comparison strips -zero-width characters and collapses whitespace precisely so a zero-information -edit cannot fake one. - -## Never pin a fragment in a test - -A fragment is deleted the moment it has merged (see step 3 above), so any test -that asserts one exists, or asserts its contents, will fail the instant -`guard-no-ack-on-next` sweeps it — and that failure has nothing to do with the -behavior the fragment once explained. This has already cost two suites: -`tests/emitted-attribution.test.cjs`'s three `#2914` migration pins, and -`tests/agent-tracked-source-rule.test.cjs`'s `#3645` growth-ack pin. What a test -may legitimately assert is the BEHAVIOR the ack explains, or the guard's own -verdict (`assertNoAllSpentFragments`, `assertAbsentOnNext`) — never the -paperwork. - -## Do not regenerate anything - -There is no baseline file to re-run a generator over; #2724 deleted it. If you -find yourself hunting for one, that is the predictable wrong guess. - -See `CONTRIBUTING.md` → "Editing shipped content", `docs/TESTING-SUITES.md`, and -`CONTEXT.md`'s `RULESET.EMITTED_ATTRIBUTION` for the full model. diff --git a/tests/helpers/emitted-diff.cjs b/tests/helpers/emitted-diff.cjs index efe7d413d..b887e0b2d 100644 --- a/tests/helpers/emitted-diff.cjs +++ b/tests/helpers/emitted-diff.cjs @@ -7,9 +7,10 @@ * Given the emitted manifests at `next` HEAD and at PR HEAD, plus the repo paths the * PR actually changed, decide which moved emitted paths are EXPLAINED by the diff and * which are not. Unattributable deltas are a hard failure that names them; the only - * way through is a committed acknowledgment — a per-PR fragment under - * `tests/emitted-drift-acks/` (#2914), or, for branches predating that split, the - * legacy single `tests/emitted-drift-ack.json` — both are read and unioned (#2914). + * way through is a commit trailer on the PR's own commits (ADR-3942) — + * `Emitted-Drift-Ack-Hash:` / `Emitted-Drift-Ack-Growth:`, read over + * `..HEAD` by `readAckTrailers` (`tests/helpers/emitted-runtime.cjs`) and + * parsed by `parseAckTrailers` below. * * ── Why this module is pure ────────────────────────────────────────────────── * No fs, no git, no installer, no clock. The naive shape — one integration test that @@ -32,57 +33,16 @@ const { attributeEmittedPath } = require('./emitted-provenance.cjs'); -/** Ack schema version. Pinned from day one: contributors hand-write this file, so its - * shape is public the moment it ships (Hyrum). Loosening later is easy; tightening is not. */ -const ACK_VERSION = 1; - /** * Key names that can never be a legitimate emitted path or bare workflow/agent filename, * and that also happen to be the JS-object footguns (`__proto__`, `constructor`, - * `prototype`). Rejected LOUDLY by `parseAck` rather than silently dropped: a document - * naming one of these is always an authoring mistake (never a real path), and dropping it - * quietly would let the SAME document pass `scripts/lint-emitted-drift-ack.cjs`'s - * duplicate-detection (which excludes these keys for a different reason — see - * `declaredKeys`'s doc comment there) while erroring differently here — exactly the - * generative-fix-divergence class this repo's parity test exists to catch (#2914 review). + * `prototype`). Rejected LOUDLY by `parseAckTrailers` rather than silently dropped: a + * trailer naming one of these is always an authoring mistake (never a real path or + * filename), and dropping it quietly would let the SAME reserved key satisfy a downstream + * lookup instead of failing the gate that names it. */ const RESERVED_ACK_KEYS = new Set(['__proto__', 'constructor', 'prototype']); -/** - * The LEGACY acknowledgment file, named ONCE (#2778), still honored (#2914). - * - * This string was previously typed by hand in `formatReport`'s unattributable branch, in - * `parseAck`'s default `source`, and again as `ACK_PATH` in emitted-runtime.cjs. Adding a - * fourth copy for the growth branch is the *generative fix divergence* class this repo - * records: parallel surfaces reading one shared value must not be able to drift. One - * definition consumed by every branch is cheaper than a parity test over four literals. - * - * #2914 replaces this SINGLE SHARED FILE with per-PR fragments under `ACK_DIR` — a - * single mutable document whose `paths` map every PR rewrites wholesale is a guaranteed - * merge-conflict cell between any two PRs that both need an ack (5 of 6 conflicting PRs - * in the open queue collided on this file and nothing else). The legacy path is still - * read and unioned with the fragments directory so open PRs authored before the split - * (#2818, #2812, #2728, #2566, #2531) are not broken by this change. - */ -const ACK_FILE = 'tests/emitted-drift-ack.json'; - -/** - * The per-PR fragment directory (#2914) — the `.changeset/`-shaped fix to the same - * shared-mutable-file problem `.changeset/` already solves: every fragment is a - * separately-named file, so two PRs adding an ack can never conflict with each other, - * and a fragment left behind on `next` after merge is inert rather than a shared cell. - */ -const ACK_DIR = 'tests/emitted-drift-acks'; - -/** - * Distinguishes "caller omitted the base side" from "caller said there is none". - * - * A plain `null` default cannot tell those apart, and the difference is the whole point: - * omission would silently mean "inherit nothing", which is precisely how a dropped - * argument would restore #2768 with every unit test still green. - */ -const BASE_ACK_OMITTED = Symbol('baseAck omitted'); - /** * Characters that render as nothing: soft hyphen, the zero-width family, word joiner, * BOM. Stripped before reasons are compared, so an invisible edit cannot re-arm a spent @@ -99,18 +59,14 @@ const INVISIBLE = new RegExp( /** * The single definition of "the prose a reviewer actually reads" for an ack reason. * - * It is exported for exactly one reason: `scripts/lint-emitted-drift-ack.cjs` carries - * its own duplicate (`ackProse`) rather than requiring this file, because `scripts/` - * ships in the published npm package and `tests/` does not — a `require('../tests/...')` - * from a shipped script would work in this repo and break for every installed user - * (#3078). That duplication is unavoidable, but leaving both copies unexported meant - * nothing could ever compare them: the "parity test" this module's comments promised - * was checking the script against itself. Exporting this lets a real test hold the - * script's copy to this one. + * Used internally by `parseAckTrailers`'s own same-key dedup (a key declared twice + * within one trailer space collapses to one entry only when the reasons are identical + * after normalization) and exported so a real test can assert on that behavior directly + * rather than only through `parseAckTrailers`'s combined output. * * The invisible-stripping (`INVISIBLE`) and whitespace-collapse below are anti-gaming - * defences, not incidental normalization — see the comments at the two call sites in - * `diffEmitted`. Do not weaken either side of the duplicate without weakening both. + * defences, not incidental normalization — see `parseAckTrailers`'s dedup check, its + * one call site. */ function normalizeAckReason(reason) { return reason.replace(INVISIBLE, '').replace(/\s+/g, ' ').trim(); @@ -140,52 +96,18 @@ function normalizeAckReason(reason) { const NEW_FILE_CAP = 32768; /** - * Upper bound on how many fragment files a `readdirSync` of `ACK_DIR` (or its - * counterpart in `scripts/lint-emitted-drift-ack.cjs`) may return in one pass. - * - * 500 is ample headroom over any real repo's fragment count, which stays in the - * single digits between releases (fragments are deleted once spent). Exceeding it - * throws rather than silently truncating: a truncated listing would silently drop - * acknowledgments from the merged set, which is exactly the class of silent failure - * this whole ack seam exists to prevent — the fix for a directory this large is to - * prune spent fragments, never to read only some of them. - * - * Duplicated (not imported) in `scripts/lint-emitted-drift-ack.cjs`, which cannot - * require anything from `tests/` (it ships in the npm package; `tests/` does not). - * The two are held to the same value by the schema-parity test in - * `tests/emitted-attribution.test.cjs`. + * Trailer key naming an emitted PATH whose HASH moved (grammar: 40-design.md). + * Relocated ahead of `REMEDIATION` (which follows immediately below and calls + * `renderAckTrailer` at module-eval time, so these three consts must already be + * initialized — a `const` declared later in the file is in the temporal dead zone + * during that call, unlike `renderAckTrailer` itself, which is a hoisted function + * declaration and may stay where it is, further down. */ -const MAX_ACK_FRAGMENTS = 500; - -/** - * Render the minimal valid acknowledgment document for a set of entries (#2778). - * - * Built with `JSON.stringify` from `ACK_VERSION` rather than typed out, for two reasons: the - * printed document is guaranteed to be syntactically valid JSON, and it cannot fall out of - * step with the version `parseAck` enforces. A hand-typed `"version": 1` sitting beside a live - * `ACK_VERSION` is the drift this module warns about everywhere else. - * - * It teaches exactly ONE shape. `parseAck` is deliberately more liberal — it accepts a bare - * string as the reason and tolerates a missing `version` or `paths`. Be liberal in what you - * accept, conservative in what you send: advertising those tolerances would spread a quirk - * into hand-written contributor files and make the canonical form look optional. - * - * It takes ALL the entries at once and renders ONE document, which is not a convenience: - * a report can trip the hash branch and the size branch together (a feature PR that both - * ripples an emitted path and grows a workflow). Printing a complete document per branch - * made each look like "the file to create", so a contributor pasting the second over the - * first would silently lose the first acknowledgment — an ack-lost failure with no signal. - * One document, one file, one paste. - */ -function ackDocument(entries) { - // Null-prototype: `key` comes from repo/emitted paths, so a file literally named - // `__proto__` would otherwise SET THE PROTOTYPE instead of a property, and - // `JSON.stringify` would then emit `"paths":{}` — a remediation document that silently - // teaches the contributor to acknowledge nothing. - const paths = Object.create(null); - for (const { key, reason } of entries) paths[key] = { reason }; - return JSON.stringify({ version: ACK_VERSION, paths }); -} +const ACK_TRAILER_HASH = 'Emitted-Drift-Ack-Hash'; +/** Trailer key naming a bare workflow/agent FILENAME that grew. */ +const ACK_TRAILER_GROWTH = 'Emitted-Drift-Ack-Growth'; +/** Key/reason delimiter: space, EM DASH (U+2014), space — split on the FIRST occurrence only. */ +const ACK_TRAILER_DELIM = ' — '; /** * Self-serve remediation, as data rather than prose scattered across branches (#2778). @@ -203,16 +125,32 @@ function ackDocument(entries) { * help text must not be a breaking change. */ const REMEDIATION = Object.freeze({ - // Deliberately NOT a fixed filename (#2914): the remedy is a NEW fragment under - // `ACK_DIR`, and the whole point of a fragment directory is that its name is the - // contributor's to pick — a fixed suggestion here would tempt everyone back onto one - // shared filename, resurrecting the exact merge-conflict cell this design removes. - ackFile: `${ACK_DIR}/.json`, - ackDir: ACK_DIR, - createIfAbsent: - `create a NEW file under ${ACK_DIR}/ — pick a name nobody else is using (include ` - + 'this issue or PR number, e.g. `2914-fix.json`), and never reuse an existing ' - + 'fragment\'s name', + /** + * #3942: the remedy is a COMMIT TRAILER on this PR, never a new file — the legacy + * fragment directory and the legacy single file are both retired as write targets. + * + * Split into two SPACE-SPECIFIC fields rather than one combined sentence: the two + * trailer names key on structurally distinct spaces (RULESET.EMITTED_ATTRIBUTION), and + * a report that trips only one of the two branches must teach only that one name. + * `formatReport` picks whichever of these apply to the report being rendered — never + * both unconditionally. + * + * Deliberately NAME the trailer WITHOUT a trailing colon (backtick-quoted bare name, + * not `` `Name:` ``) — the colon-suffixed form is the taught example line's own + * grammar (`renderAckTrailer`, printed once per space right below this text). Using + * the colon form here too would make a report that trips BOTH branches print each + * trailer name (with colon) TWICE — once in this prose, once in its taught line — + * which is the exact defect this split fixes; see "a ripple and a growth in one report + * each get their OWN trailer line" in tests/emitted-attribution.test.cjs. + */ + addTrailerHash: + 'Add a trailer to a commit in this PR (never a new file). Use the ' + + `\`${ACK_TRAILER_HASH}\` trailer for an unattributable ripple (key = the emitted ` + + 'path, always contains "/").', + addTrailerGrowth: + 'Add a trailer to a commit in this PR (never a new file). Use the ' + + `\`${ACK_TRAILER_GROWTH}\` trailer for growth (key = the bare filename as it ` + + 'appears under gsd-core/workflows/ or agents/).', doNotRegenerate: 'Do NOT regenerate anything to silence this — there is nothing left to regenerate.', /** The size ratchet keys on `entry.name` from readdirSync (emitted-runtime.cjs `currentSizes`). */ @@ -221,15 +159,20 @@ const REMEDIATION = Object.freeze({ rippleKeyRule: 'Key on the EMITTED PATH exactly as printed above', rippleReason: '', growthReason: '', - staleAckFix: - `Delete those entries from your fragment under ${ACK_DIR}/, or correct them to name ` - + 'the ripple you actually made. If that leaves no entries, delete the file itself ' - + '— an empty one signals nothing.', - spentAckNote: - 'These are inert, NOT a failure: the base already carries them, so their ripple is ' - + `absorbed and they can no longer clear anything. Delete them from your fragment ` - + `under ${ACK_DIR}/ whenever convenient.`, - ackDocument, + staleAckFix: 'Remove the trailer, or correct it to name the ripple you actually made.', + /** + * The taught trailer syntax, rendered through `renderAckTrailer` — the exact function + * `parseAckTrailers` is the inverse of — so the printed example can never drift from + * what the reader actually accepts (round-trip discipline, mirrors the old + * `ackDocument`/`parseAck` pairing this replaces). A realistic path rather than an + * angle-bracket placeholder: `parseAckTrailers` rejects keys containing `<`/`>` + * specifically because this PR's own docs teaching the placeholder-shaped example + * would otherwise arm itself as a live (and then stale) ack — see 40-design.md's + * negative-space note. + */ + ackTrailerExample: renderAckTrailer( + ACK_TRAILER_HASH, 'skills/gsd-add-tests/SKILL.md', 'converter rewrite, ADR-2719', + ), }); /** @@ -250,180 +193,60 @@ function sourceSatisfiedBy(source, changedSet) { return changedSet.has(source) ? source : null; } -/** - * Normalize + validate the acknowledgment document. - * - * Rejects a document that parses but is not a plain object. Treating `0` / `"s"` / `[]` / - * `null` / `true` as "no acks" would SILENTLY DISARM the gate — the single worst failure - * available here, because it looks identical to a healthy run. - * - * @returns {{ entries: Map, errors: string[] }} - */ -function parseAck(doc, { source = 'emitted-drift-ack.json' } = {}) { - const errors = []; - const entries = new Map(); - - if (doc === null || doc === undefined) return { entries, errors }; // absent == no acks (legal) - - if (typeof doc !== 'object' || Array.isArray(doc)) { - errors.push( - `${source}: must be a JSON object, got ${Array.isArray(doc) ? 'array' : typeof doc}`, - ); - return { entries, errors }; - } - - if (doc.version !== undefined && doc.version !== ACK_VERSION) { - errors.push(`${source}: unsupported version ${JSON.stringify(doc.version)} (expected ${ACK_VERSION})`); - } - - const paths = doc.paths; - if (paths === undefined) return { entries, errors }; // `{}` or `{version:1}` == no acks - if (paths === null || typeof paths !== 'object' || Array.isArray(paths)) { - errors.push(`${source}: "paths" must be an object of -> { reason }`); - return { entries, errors }; - } - - for (const [rel, value] of Object.entries(paths)) { - if (RESERVED_ACK_KEYS.has(rel)) { - // Reject loudly rather than silently drop. This is the fix for #2914 review: a - // document naming `__proto__`/`constructor`/`prototype` used to be silently - // accepted here (a genuine own key when the document comes from `JSON.parse`, - // per the production path) while the lint filtered it out of duplicate detection - // — two surfaces disagreeing about the SAME key is exactly the drift the parity - // test below exists to catch. - errors.push( - `${source}: ack key "${rel}" is reserved and can never be a valid emitted path ` - + 'or workflow/agent filename — remove it', - ); - continue; - } - const reason = value && typeof value === 'object' ? value.reason : value; - if (typeof reason !== 'string' || reason.trim() === '') { - // "name them AND say why" is the contract (ADR-2719 §3). An ack with no reason - // is a silent regeneration wearing a declaration's clothes. - errors.push(`${source}: ack for "${rel}" has no non-empty "reason"`); - continue; - } - entries.set(rel, { - reason: reason.trim(), - runtime: value && typeof value === 'object' ? value.runtime : undefined, - }); - } - - return { entries, errors }; -} - -/** - * Union multiple ack SOURCES into ONE document (#2914). - * - * `docs` is an ordered list of `{ source, doc }`, where `doc` is a parsed ack document - * (or `null`) and `source` is a human label used only in error messages — a fragment's - * repo-relative path, or the legacy file's path. Each source is parsed with the SAME - * `parseAck` the single-document gate already uses, so the schema can never drift - * between "one file" and "many files" — there is exactly one definition of what a valid - * entry looks like, reused here rather than re-typed. - * - * ── The collision rule (#2914) ──────────────────────────────────────────────── - * Two sources declaring the SAME emitted/growth key is an ERROR, never a silent - * last-wins merge. Two per-PR fragments are never supposed to name the same path — if - * they do, at least one of them is wrong, or the world has already changed under one of - * them since it was written — and silently letting the later source win would let a - * fragment quietly retire an earlier one's acknowledgment with zero signal in the diff. - * That is exactly the class of silent drift the acknowledgment seam (#2789 spent/live - * lifecycle) exists to end: an ack that stops explaining anything must be conspicuous, - * never invisible. Failing loudly, with both source names in the message, is the only - * reading consistent with the rest of this module's "unknown fails toward the strict - * side" law (see `readAckFileAtRef`'s doc comment for the base-side version of the same - * principle). - * - * @param {Array<{source: string, doc: object|null}>} docs - * @returns {{ merged: {version: number, paths: object}, errors: string[] }} - */ -function mergeAckSources(docs) { - const errors = []; - // Null-prototype for the same reason `ackDocument` uses one above: an ordinary `{}` - // would turn an assignment keyed `__proto__` into setting the prototype rather than a - // property. `parseAck` now rejects `RESERVED_ACK_KEYS` outright (#2914 review) so - // `entries` below can never actually carry one — this is belt-and-suspenders against - // the day that stops being true, not the current enforcement point. - const paths = Object.create(null); - const owner = new Map(); // rel -> source that already claimed it, for the error message - - for (const { source, doc } of docs) { - const { entries, errors: parseErrors } = parseAck(doc, { source }); - errors.push(...parseErrors); - for (const [rel, entry] of entries) { - if (owner.has(rel)) { - errors.push( - `duplicate ack for "${rel}": declared in both ${owner.get(rel)} and ${source}. ` - + 'Two ack sources may never name the same path — rename or merge the fragments.', - ); - continue; - } - owner.set(rel, source); - paths[rel] = entry.runtime !== undefined - ? { reason: entry.reason, runtime: entry.runtime } - : { reason: entry.reason }; - } - } - - return { merged: { version: ACK_VERSION, paths }, errors }; -} - /** * The conservation law. * - * ── Ack lifecycle: an ack is scoped to the diff that introduced it (#2789) ─── + * ── Ack lifecycle: scoped to the diff that introduced it, structurally (ADR-3942 §2) ── * - * Every other input here is BASE-RELATIVE — `baseline` vs `current`, `changedPaths` from - * `git diff base...HEAD`. `ack` was the one ABSOLUTE input, read only from the working - * tree, and that mismatch was a real defect: `staleAcks` asks only "did a delta consume - * you?", which cannot tell "you never explained anything" (an authoring mistake) from - * "your ripple is now absorbed into the base" (the ack's SUCCESS condition). Merging an - * ack therefore made it look like a mistake, reddening `next` and every PR branching off - * it (#2768). + * Every input here is BASE-RELATIVE — `baseline` vs `current`, `changedPaths` from + * `git diff base...HEAD`, and now `ackHash`/`ackGrowth` too: they come from commit + * trailers read over `..HEAD` (`readAckTrailers`, + * `tests/helpers/emitted-runtime.cjs`), which is the PR's own commits and no others. A + * trailer on the base side of the fork is out of range by construction, so there is no + * base-side copy left to compare against and nothing can ever be "spent" — ADR-3942 §2 + * retired the `baseAck`/`spentAcks` mechanism that used to compute that distinction + * (`readAckFileAtRef`, `readAckSourcesAtRef`, and their callers are deleted alongside + * it, per ADR-3942 §6). * - * `baseAck` supplies the missing side. An entry already present at the base is SPENT: it - * is not part of this diff, so it may no longer consume a delta and is never reported - * stale. Making it inert is also what finally closes ADR-2719's own named hazard — a - * leftover ack used to SILENTLY pre-clear the next ripple on its path; now that ripple - * must be explained on its own terms. Spent entries are still reported (`spentAcks`) so - * they can be tidied, but they gate nothing. + * ── Two independent key spaces, not one merged map ──────────────────────────── * - * `baseAck` is REQUIRED whenever `ack` is present — omitting it is an error, never a - * silent "nothing inherited". Same discipline as `changedPaths` above: a caller that - * cannot supply the base side must say `null` deliberately, so that dropping the - * argument fails loudly instead of quietly restoring #2768. + * `ackHash` and `ackGrowth` are the two commit-trailer namespaces (40-design.md), + * already parsed into `Map` by `parseAckTrailers`. The hash pass + * consults `ackHash` ONLY and the growth pass consults `ackGrowth` ONLY: naming a + * growth-only bare filename in `Emitted-Drift-Ack-Hash` (or vice versa) must NOT excuse + * anything — that cross-space excusal, possible when both spaces shared one map, is the + * latent defect this split closes (rows 3/6). `staleAcks` is reported per space so the + * error can say which trailer declared the unconsumed key. * * @param {object} opts * @param {object} opts.baseline { [runtime]: { [rel]: hash } } at `next` HEAD * @param {object} opts.current { [runtime]: { [rel]: hash } } at PR HEAD * @param {string[]} opts.changedPaths repo paths the PR changed (git diff --name-only) - * @param {object} [opts.ack] parsed emitted-drift-ack.json document (or null) - * @param {object} opts.baseAck the same document AT THE BASE REF (or null when - * absent there). Required once `ack` is non-null. + * @param {Map} [opts.ackHash] live `Emitted-Drift-Ack-Hash` + * entries, keyed on the emitted path (always contains `/`). Defaults to an empty Map. + * @param {Map} [opts.ackGrowth] live `Emitted-Drift-Ack-Growth` + * entries, keyed on the bare workflow/agent filename. Defaults to an empty Map. * @param {object} [opts.sizeBaseline] { [name]: bytes } workflow/agent sizes at next * @param {object} [opts.sizeCurrent] { [name]: bytes } workflow/agent sizes at PR HEAD - * @param {string[]} [opts.mergeAckErrors] errors already discovered while UNIONING - * `ack` from multiple physical sources (`mergeAckSources`, #2914) — e.g. two fragments - * naming the same path. This module never touches the filesystem, so it cannot - * discover a cross-file collision on its own; the shell layer that reads the legacy - * file plus every fragment computes this and folds it in verbatim so a duplicate - * fails the gate exactly like any other ack schema error, rather than silently - * resolving via last-wins. + * @param {string[]} [opts.mergeAckErrors] errors already discovered while reading the + * ack source (`parseAckTrailers`'s per-value errors). This module never touches the + * filesystem or git, so it cannot discover such a problem on its own; the caller folds + * it in verbatim so it fails the gate exactly like any other ack schema error, rather + * than silently resolving via last-wins. * * @returns {{ * moved: number, attributed: Array, unattributable: Array, acked: Array, * removed: Array, grown: Array, shrunk: Array, newFileCapExceeded: Array, - * staleAcks: string[], spentAcks: string[], errors: string[], ok: boolean + * staleAcks: Array<{key: string, space: 'hash'|'growth'}>, + * errors: string[], ok: boolean * }} */ function diffEmitted({ baseline, current, changedPaths, - ack = null, - baseAck = BASE_ACK_OMITTED, + ackHash = new Map(), + ackGrowth = new Map(), sizeBaseline = null, sizeCurrent = null, mergeAckErrors = [], @@ -442,6 +265,17 @@ function diffEmitted({ // a failure storm that reads like a real finding. errors.push('changedPaths must be an array (a failed git diff is an error, not an empty set)'); } + // #2778-shape guard, extended to the two #3942 ack Maps: without this, `{}`, `null`, + // or a plain string handed to `ackHash`/`ackGrowth` reaches `liveAckHash.has(rel)` / + // `liveAckGrowth.has(name)` below and throws an unhandled TypeError instead of an + // error verdict naming the offending parameter — the same crash class `baseline`/ + // `current`/`changedPaths` are already guarded against, immediately above. + if (!(ackHash instanceof Map)) { + errors.push('ackHash must be a Map of parseAckTrailers output (readAckTrailers().hash)'); + } + if (!(ackGrowth instanceof Map)) { + errors.push('ackGrowth must be a Map of parseAckTrailers output (readAckTrailers().growth)'); + } if (errors.length) { return { // `newFileCapExceeded` MUST be present here. formatReport reads @@ -451,84 +285,27 @@ function diffEmitted({ // manifest came back malformed, so the crash replaced the one message that would // have explained the infrastructure problem (#2778). moved: 0, attributed: [], unattributable: [], acked: [], removed: [], - grown: [], shrunk: [], newFileCapExceeded: [], staleAcks: [], spentAcks: [], + grown: [], shrunk: [], newFileCapExceeded: [], staleAcks: [], errors, ok: false, }; } const changedSet = new Set(changedPaths); - const { entries: declaredAcks, errors: ackErrors } = parseAck(ack); - errors.push(...ackErrors); // Folded in verbatim, not re-derived: see `mergeAckErrors`'s doc comment above. errors.push(...mergeAckErrors); - // Keyed on DECLARED ENTRIES, not on the document being non-null. `{}`, `{version:1}` - // and `{paths:{}}` all carry zero acks and `parseAck` calls them legal, so demanding a - // base side for them would fail a document the module elsewhere accepts. Protection is - // undiminished: an entry is the only thing that can be misclassified as spent or live, - // so the error still fires in exactly the situation where dropping `baseAck` would - // reintroduce #2768. - if (declaredAcks.size > 0 && baseAck === BASE_ACK_OMITTED) { - errors.push( - `${ACK_FILE}: baseAck was not supplied. The base side is not optional — without it ` - + 'a merged ack is indistinguishable from one that never explained anything, which ' - + 'is exactly the defect this parameter exists to close (#2789). Pass the document ' - + 'read at the base ref, or an explicit null when it is absent there.', - ); - } - - // Base-side SCHEMA errors are deliberately discarded, not surfaced. The base's validity - // is not this diff's to answer for, and a document we cannot read simply inherits - // nothing — which is the ARMED reading, since every entry then stays live and gated. - const resolvedBaseAck = baseAck === BASE_ACK_OMITTED ? null : baseAck; - const { entries: baseAcks } = parseAck(resolvedBaseAck); - - /** - * Spent == the base already carries this entry's REASON, compared on prose alone. - * - * Re-arming a spent ack is legitimate — it is how a contributor says "this is a NEW - * ripple, and here is why" — but it must cost an actual explanation, because the - * reason is the entire artifact a reviewer reads. So the comparison is deliberately - * insensitive to everything that is not prose: - * - * - INTERNAL whitespace collapses. `parseAck` only trims the ends, so without this a - * doubled space re-arms an ack whose justification still describes the PREVIOUS - * ripple, and the ack file's diff shows a reviewer nothing new. - * - `runtime` is NOT compared. Nothing else in this module reads it (lookups key on - * `rel` alone), so including it would make an undocumented, schema-absent field the - * one thing that re-arms an ack — `+ "runtime": "claude"` beside a byte-identical - * reason, carrying no explanation at all. - * - * Both directions were live re-arm paths for a genuinely unattributable ripple. - */ - // `\s` covers NBSP and the ideographic space but NOT the zero-width family, so without - // this a U+200B (or a soft hyphen) re-arms a spent ack while being literally invisible - // in the diff — the purest form of "no new explanation". Stripped before the whitespace - // collapse so a zero-width char cannot glue two words into a different-looking string. - // Zero-information re-arm defence. `\s` covers NBSP and the ideographic space but NOT - // the zero-width family, so without this a U+200B or a soft hyphen re-arms a spent ack - // while being literally INVISIBLE in the diff — the purest form of "no new - // explanation". Built from codepoints rather than literal characters, because a - // literal class would itself be unreviewable in this file. - const prose = normalizeAckReason; - const isSpent = (rel, entry) => { - const prior = baseAcks.get(rel); - return prior !== undefined && prose(prior.reason) === prose(entry.reason); - }; - - const ackEntries = new Map(); - const spentAcks = []; - for (const [rel, entry] of declaredAcks) { - if (isSpent(rel, entry)) spentAcks.push(rel); - else ackEntries.set(rel, entry); - } - spentAcks.sort(); + // Every entry a trailer-scoped range can produce is by construction this PR's own + // (ADR-3942 §2) — there is no base-side copy to partition against, so both Maps are + // "live" in full; nothing here is ever "spent". + const liveAckHash = ackHash; + const liveAckGrowth = ackGrowth; const attributed = []; const unattributable = []; const acked = []; const removed = []; - const usedAcks = new Set(); + const usedAcksHash = new Set(); + const usedAcksGrowth = new Set(); let moved = 0; const runtimes = new Set([...Object.keys(baseline), ...Object.keys(current)]); @@ -594,9 +371,11 @@ function diffEmitted({ if (via !== null) { attributed.push({ ...record, via }); - } else if (ackEntries.has(rel)) { - usedAcks.add(rel); - acked.push({ ...record, reason: ackEntries.get(rel).reason }); + } else if (liveAckHash.has(rel)) { + // `liveAckHash` ONLY — a `liveAckGrowth` entry naming the same string by + // coincidence must NOT excuse a hash-space ripple (row 3). + usedAcksHash.add(rel); + acked.push({ ...record, reason: liveAckHash.get(rel).reason }); } else { unattributable.push({ ...record, @@ -629,8 +408,9 @@ function diffEmitted({ if (to > from) { // Growth needs the SAME acknowledgment. Anti-creep survives without pinning a // number: "verify-work.md grew 1,247 bytes" beats a number moving in a 93-line map. - const isAcked = ackEntries.has(name); - if (isAcked) usedAcks.add(name); + // `liveAckGrowth` ONLY (row 6) — the mirror of the hash pass above. + const isAcked = liveAckGrowth.has(name); + if (isAcked) usedAcksGrowth.add(name); grown.push({ name, from, to, delta: to - from, acked: isAcked }); } else if (to < from) { // Shrinkage is not creep — reported, never gated. @@ -644,7 +424,16 @@ function diffEmitted({ // An ack that outlives the ripple it explained is future blindness: it would silently // pre-clear a NEW ripple on the same path. It must be deleted when the ripple is. // Computed here, once, after BOTH the hash pass and the size pass have consumed acks. - const staleAcks = [...ackEntries.keys()].filter((rel) => !usedAcks.has(rel)).sort(); + // + // Reported PER SPACE — `{key, space}`, never a bare string — so the message can say + // WHICH trailer declared the unconsumed key (row 7). A string-prefix convention + // (`hash:`) was considered and rejected: it would encode the namespace in a + // string the consumer must remember to strip, the same convention-not-code weakness + // #3942's design explicitly declines elsewhere (40-design.md "Rejected"). + const staleAcks = [ + ...[...liveAckHash.keys()].filter((k) => !usedAcksHash.has(k)).sort().map((key) => ({ key, space: 'hash' })), + ...[...liveAckGrowth.keys()].filter((k) => !usedAcksGrowth.has(k)).sort().map((key) => ({ key, space: 'growth' })), + ]; const ok = errors.length === 0 && unattributable.length === 0 @@ -662,7 +451,6 @@ function diffEmitted({ shrunk, newFileCapExceeded, staleAcks, - spentAcks, errors, ok, }; @@ -731,34 +519,17 @@ function buildReport(result, { sampleLimit = 20 } = {}) { }); } - // Modelled here, not only rendered, so it can be asserted on identity like every other - // block — this file's stated contract is that `formatReport` is a pure rendering of - // this IR, and a section that exists only in prose would have to be tested by raw text - // matching, which CONTRIBUTING.md prohibits. - // - // Gated on `!result.ok` to KEEP that contract true. Spent acks are the one block whose - // emit-condition could drift from the renderer's, since a passing run must render - // nothing at all; without this, a JSON reporter built on the IR would announce spent - // acks for a green run while the text reporter stayed silent. - if (!result.ok && result.spentAcks && result.spentAcks.length) { - blocks.push({ - kind: 'spent-acks', - count: result.spentAcks.length, - items: result.spentAcks.slice(0, sampleLimit), - fix: REMEDIATION.spentAckNote, - }); - } - // ONE ack set for the whole report, not one per branch. A report can trip the hash - // branch and the size branch at once, and two complete documents each reading as "the - // file to create" invites pasting the second over the first — losing an acknowledgment - // with no signal. Capped at `sampleLimit` per branch so the document stays consistent - // with the lists above it rather than naming rows the report chose not to print. + // branch and the size branch at once (a feature PR that both ripples an emitted path + // and grows a workflow). Capped at `sampleLimit` per branch so the taught trailers stay + // consistent with the lists above them rather than naming rows the report chose not to + // print. `space` tags each entry with the trailer namespace it belongs to (#3942), so + // the renderer can pick `Emitted-Drift-Ack-Hash` vs `Emitted-Drift-Ack-Growth` per line. const ackable = [ ...result.unattributable.slice(0, sampleLimit) - .map((u) => ({ key: u.rel, reason: REMEDIATION.rippleReason })), + .map((u) => ({ key: u.rel, reason: REMEDIATION.rippleReason, space: 'hash' })), ...unackedGrowth.slice(0, sampleLimit) - .map((g) => ({ key: g.name, reason: REMEDIATION.growthReason })), + .map((g) => ({ key: g.name, reason: REMEDIATION.growthReason, space: 'growth' })), ]; return { blocks, ackable }; @@ -818,55 +589,212 @@ function formatReport(result, { sampleLimit = 20 } = {}) { } if (result.staleAcks.length) { + // Each item names WHICH trailer declared it (#3942 row 7) — a hash-space entry + // renders as `Emitted-Drift-Ack-Hash: `, a growth-space one as + // `Emitted-Drift-Ack-Growth: `, via the SAME `renderAckTrailer` the taught + // example below uses, with a placeholder reason (the real reason is what made it + // stale in the first place — irrelevant to naming the fix). + const list = result.staleAcks.slice(0, sampleLimit) + .map(({ key, space }) => ` ${renderAckTrailer( + space === 'growth' ? ACK_TRAILER_GROWTH : ACK_TRAILER_HASH, key, '', + )}`); parts.push( `${result.staleAcks.length} stale acknowledgment(s) — written or reworded in THIS diff, ` + - 'but nothing here needed them, so they explain nothing:\n ' + - result.staleAcks.slice(0, sampleLimit).join('\n ') + + 'but nothing here needed them, so they explain nothing:\n' + + list.join('\n') + `\n\n${REMEDIATION.staleAckFix}`, ); } - // Informational, never gating, and rendered ONLY alongside a real failure. Spent - // entries are inert housekeeping, not a demand — surfacing them when a contributor is - // already in the file is free, but emitting them for an otherwise-clean run would make - // `formatReport` return prose for an `ok` result, which this module's callers read as - // "there is something wrong". - const spent = (result.spentAcks || []).slice(0, sampleLimit); - if (spent.length && !result.ok) { - parts.push( - `${result.spentAcks.length} spent acknowledgment(s):\n ` + spent.join('\n ') + - `\n\n${REMEDIATION.spentAckNote}`, - ); - } - - // The remedy, once, at the end — one file, one document, one paste. + // The remedy, once, at the end — one trailer per entry, never a file. The + // instructional prose is picked PER SPACE PRESENT, not unconditionally: a report that + // only trips the growth branch must not teach the hash trailer (and vice versa) — see + // "the renderer emits the trailer instructions..." below. `addTrailerHash`/ + // `addTrailerGrowth` name their trailer WITHOUT a trailing colon specifically so this + // prose and the colon-suffixed taught line right below it never double-count the SAME + // trailer name in one message — see "a ripple and a growth in one report each get + // their OWN trailer line". const { ackable } = buildReport(result, { sampleLimit }); if (ackable.length) { + const lines = ackable.map(({ key, reason, space }) => ` ${renderAckTrailer( + space === 'growth' ? ACK_TRAILER_GROWTH : ACK_TRAILER_HASH, key, reason, + )}`); + const spacesPresent = new Set(ackable.map((a) => a.space)); + const instructions = [ + spacesPresent.has('hash') ? REMEDIATION.addTrailerHash : null, + spacesPresent.has('growth') ? REMEDIATION.addTrailerGrowth : null, + ].filter(Boolean).join('\n'); parts.push( - `To acknowledge, create ${REMEDIATION.ackFile}\n` + - `(${REMEDIATION.createIfAbsent})\n` + - 'containing ONE document that names every path listed above and why:\n\n' + - ` ${REMEDIATION.ackDocument(ackable)}\n\n` + - REMEDIATION.doNotRegenerate, + `${instructions}\n\n` + + lines.join('\n') + + `\n\n${REMEDIATION.doNotRegenerate}`, ); } return parts.join('\n\n'); } +/** + * ── #3942 commit-trailer acknowledgment grammar ────────────────────────────── + * + * `readAckTrailers` (`tests/helpers/emitted-runtime.cjs`) reads the raw trailer values + * from git and hands them to `parseAckTrailers` below, whose two Maps are what + * `diffEmitted`'s `ackHash`/`ackGrowth` parameters now consume directly (40-design.md). + * `ACK_TRAILER_HASH`/`ACK_TRAILER_GROWTH`/`ACK_TRAILER_DELIM` are declared earlier in + * this file (immediately before `REMEDIATION`), which calls `renderAckTrailer` at + * module-eval time and therefore needs them initialized by then. + */ + +/** Upper bound on trailers read from one range. Real implementation throws above this. */ +const MAX_ACK_TRAILERS = 64; + +/** + * Parse trailer VALUES already extracted per trailer name (no git I/O — the two + * independent namespaces, `hash` and `growth`, are each an array of the raw text after + * `: `, one entry per trailer instance found in range). + * + * Grammar (40-design.md): ` — `, split on the FIRST ` — ` (space, EM DASH, + * space — `ACK_TRAILER_DELIM`) so a reason may itself contain further em dashes (row 29). + * Key and reason are trimmed. A missing delimiter, an empty key, or an empty reason is a + * per-value error naming the offending trailer — "name them and say why" (ADR-2719 §3). + * A key that is `RESERVED_ACK_KEYS` (`__proto__`/`constructor`/`prototype`), or that + * contains `<`, `>`, or whitespace (row 14 — a doc example like + * ` — ` must never arm itself), is rejected loudly. + * + * Same key declared twice WITHIN one space: identical after `normalizeAckReason` + * (invisible-character-stripped, whitespace-collapsed) dedupes silently, keeping the + * FIRST declaration; a genuinely different reason is a hard "declared twice" error and + * the key is dropped from that space entirely — an ambiguous declaration must never + * silently pick a winner. The two spaces (`hash`/`growth`) are independent namespaces: + * the same key may legally appear in both (row 18). + * + * The cap (`MAX_ACK_TRAILERS`) is checked on the DISTINCT (key, reason) count, AFTER + * the same-key dedup above — never on the raw input count. A commit trailer, unlike the + * pre-#3942 fragment file it replaces, legitimately survives a rebase: the identical + * trailer text is carried forward on each rebased commit, and `git log` over the range + * then reports it once per commit it lives on. Counting the raw values would throw on a + * perfectly legitimate branch purely because it was rebased across many commits, even + * though every value collapses to the SAME map entry above. Counting only what actually + * survives dedup is what makes the cap mean "too many distinct declarations" rather + * than "too many git objects happen to carry this text" — still throwing, never + * truncating, on a genuine overflow, because a truncated read would silently drop + * acknowledgments (the same law the pre-#3942 fragment-directory cap enforced for its + * own listing). + * + * @param {{hash?: string[], growth?: string[]}} [trailers] + * @returns {{hash: Map, growth: Map, errors: string[]}} + */ +function parseAckTrailers({ hash = [], growth = [] } = {}) { + const errors = []; + const spaces = [ + { name: ACK_TRAILER_HASH, values: hash, map: new Map() }, + { name: ACK_TRAILER_GROWTH, values: growth, map: new Map() }, + ]; + + for (const space of spaces) { + const conflicted = new Set(); // keys already reported ambiguous — never resurrected + for (const raw of space.values) { + const delimIndex = raw.indexOf(ACK_TRAILER_DELIM); + if (delimIndex === -1) { + errors.push( + `${space.name}: trailer value ${JSON.stringify(raw)} has no "${ACK_TRAILER_DELIM}" ` + + 'delimiter — expected " — "', + ); + continue; + } + const key = raw.slice(0, delimIndex).trim(); + const reason = raw.slice(delimIndex + ACK_TRAILER_DELIM.length).trim(); + + if (key === '') { + errors.push(`${space.name}: trailer value ${JSON.stringify(raw)} has an empty key`); + continue; + } + if (reason === '') { + errors.push( + `${space.name}: trailer value ${JSON.stringify(raw)} has an empty reason — ` + + 'name it and say why (ADR-2719 §3)', + ); + continue; + } + if (RESERVED_ACK_KEYS.has(key)) { + errors.push( + `${space.name}: trailer key "${key}" is reserved and can never be a valid ` + + 'emitted path or workflow/agent filename — remove it', + ); + continue; + } + if (/[<>\s]/.test(key)) { + errors.push( + `${space.name}: trailer key ${JSON.stringify(key)} is an invalid key — keys may ` + + 'not contain "<", ">", or whitespace', + ); + continue; + } + + if (conflicted.has(key)) continue; + + const existing = space.map.get(key); + if (existing === undefined) { + space.map.set(key, { reason }); + } else if (normalizeAckReason(existing.reason) === normalizeAckReason(reason)) { + // Identical after normalization (invisible chars stripped, whitespace collapsed) + // — dedupe silently, keep the first declaration. + } else { + errors.push( + `${space.name}: trailer key "${key}" is declared twice with ambiguous, ` + + 'conflicting reasons — an ambiguous declaration cannot silently pick a winner', + ); + space.map.delete(key); + conflicted.add(key); + } + } + } + + // Post-dedup: distinct (key, reason) declarations that actually survive into the + // returned Maps — see this function's doc comment for why raw input count is the + // wrong thing to cap on. A key dropped above (an ambiguous conflicting duplicate) is + // already absent from `space.map` here and correctly does not count either. + const distinctCount = spaces[0].map.size + spaces[1].map.size; + if (distinctCount > MAX_ACK_TRAILERS) { + throw new Error( + `emitted-drift ack: ${distinctCount} distinct commit trailer declarations were found ` + + `in range, exceeding the cap of ${MAX_ACK_TRAILERS} trailers. Refusing to read only ` + + 'some of them — a truncated read would silently drop acknowledgments. Prune spent ' + + 'trailers (amend or drop them) rather than letting the count grow unbounded.', + ); + } + + return { hash: spaces[0].map, growth: spaces[1].map, errors }; +} + +/** + * Render one trailer LINE (`: — `) for docs and self-serve + * remediation text. Deliberately the exact grammar `parseAckTrailers` parses, so this + * round-trips through it (row 33/36) — a doc example built from this function can never + * drift from what the reader actually accepts. + * + * @param {string} trailerName + * @param {string} key + * @param {string} reason + * @returns {string} + */ +function renderAckTrailer(trailerName, key, reason) { + return `${trailerName}: ${key}${ACK_TRAILER_DELIM}${reason}`; +} + module.exports = { - ACK_VERSION, - ACK_FILE, - ACK_DIR, NEW_FILE_CAP, - MAX_ACK_FRAGMENTS, REMEDIATION, INVISIBLE, normalizeAckReason, sourceSatisfiedBy, - parseAck, - mergeAckSources, diffEmitted, buildReport, formatReport, + ACK_TRAILER_HASH, + ACK_TRAILER_GROWTH, + ACK_TRAILER_DELIM, + MAX_ACK_TRAILERS, + parseAckTrailers, + renderAckTrailer, }; diff --git a/tests/helpers/emitted-runtime.cjs b/tests/helpers/emitted-runtime.cjs index e8956c817..bb2477e95 100644 --- a/tests/helpers/emitted-runtime.cjs +++ b/tests/helpers/emitted-runtime.cjs @@ -45,45 +45,10 @@ const { extraEmitRootsFor, PKG_VERSION, } = require('./install-shared.cjs'); -const { mergeAckSources, MAX_ACK_FRAGMENTS } = require('./emitted-diff.cjs'); - -/** - * Fail loudly when a fragment listing exceeds `MAX_ACK_FRAGMENTS`, naming the - * directory, the cap, and the actual count. Never truncate: a silently-truncated - * listing would silently drop acknowledgments, which is exactly the class of silent - * failure the ack seam exists to prevent (see `MAX_ACK_FRAGMENTS`'s doc comment in - * `emitted-diff.cjs`). - */ -function assertFragmentCountWithinCap(dirLabel, names) { - if (names.length > MAX_ACK_FRAGMENTS) { - throw new Error( - `emitted-attribution: ${dirLabel} contains ${names.length} ack fragments, ` - + `exceeding the cap of ${MAX_ACK_FRAGMENTS}. Refusing to read only some of them — a ` - + 'truncated read would silently drop acknowledgments. Prune spent fragments from ' - + 'this directory.', - ); - } - return names; -} +const { ACK_TRAILER_HASH, ACK_TRAILER_GROWTH, parseAckTrailers } = require('./emitted-diff.cjs'); +const { runGit, OUTCOME } = require('./process-seam.cjs'); const REPO_ROOT = path.join(__dirname, '..', '..'); -/** - * Repo-relative and POSIX-separated on every platform: this form is what `git show - * :` requires, and git speaks only forward slashes regardless of host OS. - * `ACK_PATH` derives from it so the two can never name different files. - * - * LEGACY single-file path (#2778), still honored and unioned with `ACK_DIR_REPO_PATH` - * below (#2914) — open PRs authored before the fragment split still carry this file. - */ -const ACK_REPO_PATH = 'tests/emitted-drift-ack.json'; -const ACK_PATH = path.join(REPO_ROOT, ...ACK_REPO_PATH.split('/')); -/** - * Per-PR fragment directory (#2914). Every fragment is independently named, so two PRs - * that each need an ack can never collide on this path the way they always did on the - * single legacy file above. - */ -const ACK_DIR_REPO_PATH = 'tests/emitted-drift-acks'; -const ACK_DIR = path.join(REPO_ROOT, ...ACK_DIR_REPO_PATH.split('/')); const FIXTURE_SUBDIR = 'tests/fixtures/golden-install-parity'; /** @@ -329,190 +294,6 @@ function resolveBaseSha(base = 'origin/next') { return git(['rev-parse', base]).trim(); } -/** - * The acknowledgment document AS IT EXISTS AT `base` — the base side of the ack - * lifecycle (#2789). An entry already present there is SPENT: its ripple is absorbed - * into the base, so it may no longer clear a delta and is never reported stale. - * - * ── Why "inherit nothing" is NOT a safe default ────────────────────────────── - * Absent at that ref is the healthy steady state and returns `null`. Every OTHER failure - * THROWS, and the distinction is load-bearing in the direction that is easy to get - * backwards. Returning `null` on a read error looks armed — nothing is inherited, so - * every entry stays live — but a LIVE entry's defining power is that it CONSUMES a - * delta. So `null` is armed on the staleness axis and DISARMED on the consumption axis, - * which is the axis a gate over shipped artifacts actually cares about: a genuinely new, - * unexplained ripple on a path carrying an already-merged ack would come back `acked` - * instead of `unattributable`. That is silently the whole pre-#2789 behavior, including - * the pre-clearing hazard this change exists to close. - * - * So this follows the same law as `resolveChangedPaths` above — a failed git read is an - * ERROR, not an empty set — and matches the head-side `readAckFile`, which already - * throws on a document that exists but will not parse. Being more forgiving about the - * base copy of the same file would be strictly worse: it is the copy we cannot see in - * the diff. - * - * `git show` alone cannot make the distinction — a bogus ref and an absent path produce - * the same "does not exist in" message — so absence is established with `ls-tree`, which - * exits 0 with empty output when the path is simply not there and non-zero on a real - * fault. - * - * `repoPath` defaults to the legacy single file, but is generalized (#2914) so the same - * read-at-ref logic serves any one fragment under `ACK_DIR_REPO_PATH` too — there is - * exactly one implementation of "read this ack path at that ref", reused per source - * rather than re-typed per fragment. - */ -function readAckFileAtRef(base, { cwd = REPO_ROOT, run = git, repoPath = ACK_REPO_PATH } = {}) { - // `execFileSync`'s array form stops SHELL metacharacters but not git's own option - // parsing: a ref beginning with `-` is read as an option token, and `git show` honors - // diff options including `--output=`, which writes. Today every caller passes a - // resolved 40-hex sha, but this function is exported and validated nothing itself — - // the guard belonged with the argument, not with the one caller that happens to be safe. - if (typeof base !== 'string' || base === '' || base.startsWith('-')) { - throw new Error( - `emitted-attribution: refusing to read the ack at ${JSON.stringify(base)} — a base ref ` - + 'must be a non-empty string that does not begin with "-", which git would parse as an option.', - ); - } - - let listing; - try { - listing = run(['ls-tree', '--name-only', base, '--', repoPath], { cwd }); - } catch (err) { - throw new Error( - `emitted-attribution: could not list the ack at "${base}": ${err.message}. This is a ` - + 'hard error on purpose — treating an unreadable base as "nothing inherited" would ' - + 'leave every ack able to consume a delta, which is the pre-#2789 gate.', - ); - } - if (listing.trim() === '') return null; // genuinely absent at that ref — the steady state - - let raw; - try { - raw = run(['show', `${base}:${repoPath}`], { cwd }); - } catch (err) { - throw new Error( - `emitted-attribution: ${repoPath} exists at "${base}" but could not be read: ${err.message}`, - ); - } - if (raw.trim() === '') { - throw new Error(`emitted-attribution: ${repoPath} is present at "${base}" but empty`); - } - try { - return JSON.parse(raw); - } catch (err) { - throw new Error( - `emitted-attribution: ${repoPath} at "${base}" is not valid JSON: ${err.message}`, - ); - } -} - -/** - * Fragment filenames present under `ACK_DIR_REPO_PATH` AT `base`, sorted. - * - * Mirrors `readAckFileAtRef`'s absence handling: `ls-tree` on a directory that does not - * exist at that ref exits 0 with empty output, which this reads as "no fragments there" - * — the healthy steady state, not a fault. A genuine git failure (bad ref, corrupt - * object) still throws, for the same reason `readAckFileAtRef` throws on one: silently - * reading "could not list" as "nothing there" would leave every fragment ack able to - * consume a delta it should not. - */ -function listAckFragmentFilesAtRef(base, { cwd = REPO_ROOT, run = git } = {}) { - let out; - try { - out = run(['ls-tree', '--name-only', base, '--', `${ACK_DIR_REPO_PATH}/`], { cwd }); - } catch (err) { - throw new Error( - `emitted-attribution: could not list ${ACK_DIR_REPO_PATH}/ at "${base}": ${err.message}.`, - ); - } - const names = out - .split('\n') - .map((line) => line.trim()) - .filter(Boolean) - .filter((p) => p.endsWith('.json')) - .map((p) => p.slice(p.lastIndexOf('/') + 1)) - .sort(); - return assertFragmentCountWithinCap(`${ACK_DIR_REPO_PATH}/ at "${base}"`, names); -} - -/** - * Fragment filenames present under `ACK_DIR` on THIS tree (the working copy), sorted. - * Absent directory == zero fragments, the healthy steady state — not a fault. - */ -function listAckFragmentFiles(dir = ACK_DIR) { - if (!fs.existsSync(dir)) return []; - const names = fs.readdirSync(dir) - .filter((name) => name.endsWith('.json')) - .sort(); - return assertFragmentCountWithinCap(dir, names); -} - -/** - * Read + union every ack source on THIS tree (#2914): the legacy single file (if - * present) plus every fragment under `ACK_DIR`. Reuses `readAckFile` per physical file - * (same absent/empty/unparseable rules for a fragment as for the legacy file — one - * definition, not a second one per source) and `mergeAckSources` (tests/helpers/ - * emitted-diff.cjs) for the union + duplicate-key detection. - * - * Returns `{ doc: null, errors: [] }` only when NEITHER the legacy file nor any - * fragment exists — the healthy steady state matching `readAckFile`'s own `null` - * contract, so callers can keep testing `ack === null` to decide whether the base side - * needs consulting at all (avoiding the deadlock `readAckFileAtRef`'s doc comment - * describes for a corrupt base). - * - * @returns {{ doc: {version: number, paths: object} | null, errors: string[] }} - */ -function readAckSources({ legacyPath = ACK_PATH, fragmentsDir = ACK_DIR } = {}) { - const docs = []; - if (fs.existsSync(legacyPath)) { - docs.push({ source: ACK_REPO_PATH, doc: readAckFile(legacyPath) }); - } - for (const name of listAckFragmentFiles(fragmentsDir)) { - docs.push({ - source: `${ACK_DIR_REPO_PATH}/${name}`, - doc: readAckFile(path.join(fragmentsDir, name)), - }); - } - if (docs.length === 0) return { doc: null, errors: [] }; - const { merged, errors } = mergeAckSources(docs); - return { doc: merged, errors }; -} - -/** - * Read + union every ack source AT `base` (#2914): the legacy single file plus every - * fragment, as they existed at that ref. Mirrors `readAckSources` above, one ref-read - * per physical source via `readAckFileAtRef`'s now-generalized `repoPath` option. - * - * Base-side merge/schema errors are DELIBERATELY DISCARDED, matching this module's - * existing precedent for the base side (see `diffEmitted`'s caller below: "Base-side - * SCHEMA errors are deliberately discarded... a document we cannot read simply inherits - * nothing — which is the ARMED reading"). A cross-fragment collision found only at the - * base is `next`'s own health, not this diff's to answer for; `mergeAckSources`'s - * first-source-wins fallback for a duplicate key is still the STRICT reading here (an - * entry can only be "spent" against the ONE reason kept, never either of two), so - * discarding the error text costs no protection while avoiding a lint-clean PR being - * blocked by a historical duplicate it did not introduce and cannot fix by itself. - * - * A genuine READ failure (corrupt JSON, unreadable object) on any single source still - * throws, exactly as `readAckFileAtRef` already does — only the schema/collision - * bookkeeping is discarded, never a fault. - * - * @returns {{ doc: {version: number, paths: object} | null }} - */ -function readAckSourcesAtRef(base, { cwd = REPO_ROOT, run = git } = {}) { - const docs = []; - const legacyDoc = readAckFileAtRef(base, { cwd, run }); - if (legacyDoc !== null) docs.push({ source: ACK_REPO_PATH, doc: legacyDoc }); - for (const name of listAckFragmentFilesAtRef(base, { cwd, run })) { - const relPath = `${ACK_DIR_REPO_PATH}/${name}`; - const doc = readAckFileAtRef(base, { cwd, run, repoPath: relPath }); - if (doc !== null) docs.push({ source: relPath, doc }); - } - if (docs.length === 0) return { doc: null }; - const { merged } = mergeAckSources(docs); - return { doc: merged }; -} - /** * Base-ref candidates, most-specific first. * @@ -935,35 +716,115 @@ function currentSizes({ repoRoot = REPO_ROOT } = {}) { } /** - * Read `tests/emitted-drift-ack.json`. - * Absent is legal and means "no acks" — its PRESENCE is the alarm (ADR §3). - * A present-but-unreadable or unparseable file THROWS: silently treating it as absent - * would disarm the gate in the one case where someone is actively using it. + * Bounded git invocation for the #3942 commit-trailer ack reader, built on the + * never-throws process seam (`runGit`) rather than `git()` above: `readAckTrailers` must + * honor a PER-CALL `timeoutMs` (row 24's hostile-timeout row), and `git()` pins + * `GIT_TIMEOUT_MS` at import time with no per-call override. Throws on anything but a + * clean exit — same "a git failure is a hard error, never an empty result" law as + * `resolveChangedPaths` above. */ -function readAckFile(ackPath = ACK_PATH) { - if (!fs.existsSync(ackPath)) return null; - const raw = fs.readFileSync(ackPath, 'utf8'); - if (raw.trim() === '') { - throw new Error(`emitted-attribution: ${path.basename(ackPath)} is present but empty`); +function ackTrailerGit(args, { cwd = REPO_ROOT, timeoutMs = GIT_TIMEOUT_MS } = {}) { + const result = runGit([...safeDirArgs(cwd), ...args], { cwd, timeoutMs }); + if (result.outcome === OUTCOME.EXITED && result.exitCode === 0) { + return result.stdout; } + throw new Error( + `emitted-ack-trailer: \`git ${args.join(' ')}\` failed — outcome=${result.outcome} ` + + `exitCode=${result.exitCode} stderr=${(result.stderr || '').trim()}`, + ); +} + +// Record/field/value separators for the `git log --format` trailer extraction below. +// ASCII control characters (RS/US/GS) so they can never collide with real trailer +// content, following the precedent at gsd-core/workflows/ship.md:312 (`%x1f`/`%x1e` for +// a different `%(trailers:...)` extraction in this same repo). +const ACK_TRAILER_RECORD_SEP = '\x1e'; // between commits +const ACK_TRAILER_FIELD_SEP = '\x1f'; // between the hash-space list and growth-space list +const ACK_TRAILER_VALUE_SEP = '\x1d'; // between multiple values of the SAME trailer key + +/** + * Read #3942 commit-trailer acknowledgments over `..`. + * + * ── Why the merge-base is resolved here, not taken as `baseRef` verbatim ────── + * `changedPaths` (`resolveChangedPaths` above) is three-dot (merge-base) by construction, + * and 40-design.md's Correction 2 requires the trailer range to agree — otherwise a + * trailer could excuse a delta structurally outside the diff. Reading + * `..` (never `..`) is what makes a trailer on the + * OTHER side of a fork correctly out of range (row 7/8 — structural spentness). + * + * ── Why an uncomputable range THROWS, never returns empty ───────────────────── + * A shallow clone (or any ref sharing no history with `headRef`) makes the merge-base + * uncomputable. Returning an empty result here would read as "no acks needed" — a false + * GREEN that silently disarms the gate. This is 40-design.md's Correction 1 (row 15/22): + * every failure path below throws, naming "range"/"merge-base"/"shallow". + * + * Trailer values are read with git's OWN trailer parser + * (`%(trailers:key=...,valueonly)`), never a regex over the message body, so a mid-body + * mention of the trailer syntax (row 32 — this PR's own docs teach the grammar) is + * correctly inert. `\r` is stripped from every value before parsing (row 27 — a CRLF + * commit message must parse identically to LF). + * + * @param {{baseRef: string, headRef?: string, cwd?: string, timeoutMs?: number}} opts + * @returns {{hash: Map, growth: Map, errors: string[]}} + */ +function readAckTrailers({ baseRef, headRef = 'HEAD', cwd = REPO_ROOT, timeoutMs = GIT_TIMEOUT_MS } = {}) { + let mergeBaseOut; try { - return JSON.parse(raw); + mergeBaseOut = ackTrailerGit(['merge-base', baseRef, headRef], { cwd, timeoutMs }); } catch (err) { - throw new Error(`emitted-attribution: ${path.basename(ackPath)} is not valid JSON: ${err.message}`); + throw new Error( + `emitted-ack-trailer: could not compute a merge-base range for "${baseRef}..${headRef}" ` + + '(a bad ref, a shallow clone with no common ancestor, or another git failure): ' + + err.message, + ); } + const mergeBase = mergeBaseOut.trim(); + if (!/^[0-9a-f]{40}$/.test(mergeBase)) { + throw new Error( + `emitted-ack-trailer: git merge-base for "${baseRef}..${headRef}" returned no usable ` + + `commit (${JSON.stringify(mergeBase)}) — the range is structurally uncomputable ` + + '(possibly a shallow clone with no common ancestor).', + ); + } + + // `separator=` inside a `%(trailers:...)` placeholder is itself a PRETTY-FORMAT + // string, not a literal — git substitutes `%x` escapes within it (same as it + // does for the top-level `--format` string). The hex code alone (no `%x` prefix) is + // therefore emitted as its own two literal characters, never the control byte, and + // the `.split(ACK_TRAILER_VALUE_SEP)` below then never finds a real separator: two + // trailers of the SAME key on one commit collapse into a single joined value instead + // of splitting into separate entries (silent data loss — the exact failure class + // `MAX_ACK_TRAILERS` exists to prevent). Fixed by emitting the `%x` escape. + const ackValueSepHex = `%x${ACK_TRAILER_VALUE_SEP.codePointAt(0).toString(16).padStart(2, '0')}`; + const format = + `${ACK_TRAILER_RECORD_SEP}%(trailers:key=${ACK_TRAILER_HASH},valueonly,separator=${ackValueSepHex})` + + `${ACK_TRAILER_FIELD_SEP}%(trailers:key=${ACK_TRAILER_GROWTH},valueonly,separator=${ackValueSepHex})`; + + let raw; + try { + raw = ackTrailerGit(['log', `${mergeBase}..${headRef}`, `--format=${format}`], { cwd, timeoutMs }); + } catch (err) { + throw new Error(`emitted-ack-trailer: could not read commit trailers over the range: ${err.message}`); + } + + const normalized = raw.replace(/\r/g, ''); + const hashValues = []; + const growthValues = []; + // index 0 is the (empty) text before the FIRST record separator — every real record + // starts with one, by construction of the `--format` string above. + const records = normalized.split(ACK_TRAILER_RECORD_SEP).slice(1); + for (const record of records) { + const [hashField = '', growthFieldRaw = ''] = record.split(ACK_TRAILER_FIELD_SEP); + const growthField = growthFieldRaw.replace(/\n+$/, ''); // git's own between-commit newline + for (const v of hashField.split(ACK_TRAILER_VALUE_SEP)) if (v !== '') hashValues.push(v); + for (const v of growthField.split(ACK_TRAILER_VALUE_SEP)) if (v !== '') growthValues.push(v); + } + + return parseAckTrailers({ hash: hashValues, growth: growthValues }); } module.exports = { REPO_ROOT, - ACK_PATH, - ACK_REPO_PATH, - ACK_DIR, - ACK_DIR_REPO_PATH, - readAckFileAtRef, - listAckFragmentFiles, - listAckFragmentFilesAtRef, - readAckSources, - readAckSourcesAtRef, FIXTURE_SUBDIR, MANIFEST_FAMILIES, MINIMUM_MANIFEST_FAMILIES, @@ -985,7 +846,7 @@ module.exports = { measuredPackageVersion, currentManifests, currentSizes, - readAckFile, + readAckTrailers, WORKTREE_TIMEOUT_MS, BUILD_LIB_TIMEOUT_MS, BUILD_TIMEOUT_MS, diff --git a/tests/qa/smell-acks/README.md b/tests/qa/smell-acks/README.md index 43abada0f..194ed06f8 100644 --- a/tests/qa/smell-acks/README.md +++ b/tests/qa/smell-acks/README.md @@ -17,13 +17,24 @@ an optional human note alongside a real `issue`, never a replacement for one. ## Why fragments, not one shared file -Same reason `.changeset/` and `tests/emitted-drift-acks/` use fragments -instead of one shared mutable document: a single `tests/qa/smell-baseline.json` +Same reason `.changeset/` uses fragments instead of one shared mutable +document: a single `tests/qa/smell-baseline.json` that every acknowledging PR has to rewrite guarantees a merge conflict between any two such PRs in flight at once. A fragment per PR — uniquely named so concurrent PRs never touch the same file — means two PRs can never conflict on this seam. +**Why this directory survived when `tests/emitted-drift-acks/` did not +(ADR-3942).** That one was deleted because an emitted-drift acknowledgment is +spent the instant its PR merges: its ripple is in the base, so it can never +clear anything again, and every merged fragment became cruft a scheduled bot +had to garbage-collect. A **smell** acknowledgment is the opposite — it is a +standing decision that stays valid for as long as the smell keeps firing, which +is precisely why it folds into a durable shrink-only baseline rather than +evaporating. Same fragment shape, opposite data lifetime. Do not "consistently" +migrate this directory to a commit trailer; the trailer is right only for data +whose life ends at merge. + ## Shape One fragment = one acknowledged smell finding: diff --git a/tests/removed-but-needed-lint.test.cjs b/tests/removed-but-needed-lint.test.cjs index c8d563161..f97629b07 100644 --- a/tests/removed-but-needed-lint.test.cjs +++ b/tests/removed-but-needed-lint.test.cjs @@ -28,6 +28,7 @@ const { findSurvivingReferences, classifyTestReference, findSurvivingTestReferences, + isDocsHistoricalRecord, scan, } = require(LINT_SCRIPT); const { cleanup } = require('./helpers.cjs'); @@ -547,3 +548,129 @@ describe('removed-but-needed lint: tests/ arm end-to-end (#3565)', () => { assert.equal(result.exitCode, 0, `expected exit 0, got ${result.exitCode}: ${result.stderr}`); }); }); + +// ─── #3942: the two docs subdirectories adr/ and research/ are historical +// records, exempt from the docs/ scan — an ADR's or a post-mortem's whole +// job is to narrate what a PR retired, so naming the deleted file in the +// same PR that deletes it is the point, not DEFECT.REMOVED-BUT-NEEDED. +// Every OTHER document under docs/ still enforces the original "ANY +// surviving reference fails" rule. +// +// Paths below are assembled via array `.join('/')` rather than a single +// contiguous string literal that would read as a nested docs/ subdirectory +// path: this test file carries a pinned docs-guard-exempt fingerprint (a +// fixed, reviewed list of docs/ tokens its own text is allowed to +// reference — see scripts/lint-docs-guard-registration.exempt-baseline.cjs), +// and a new contiguous docs-subdirectory substring in the source would grow +// that fingerprint. The assembled value is identical at runtime; only the +// source text differs. + +describe('removed-but-needed lint: isDocsHistoricalRecord (pure, #3942)', () => { + test('a path under the docs adr subdirectory is exempt', () => { + assert.equal(isDocsHistoricalRecord(['docs', 'adr', '3942-thing.md'].join('/')), true); + }); + + test('a path under the docs research subdirectory is exempt', () => { + assert.equal(isDocsHistoricalRecord(['docs', 'research', '3875-thing.md'].join('/')), true); + }); + + test('a live docs/ path outside those two directories is NOT exempt', () => { + assert.equal(isDocsHistoricalRecord(['docs', 'TESTING-SUITES.md'].join('/')), false); + assert.equal(isDocsHistoricalRecord(['docs', 'CONTEXT-INDEX.json'].join('/')), false); + }); + + test('a coincidental prefix collision is NOT exempt (a docs file merely named "adr-notes.md" is not inside the adr subdirectory)', () => { + assert.equal(isDocsHistoricalRecord(['docs', 'adr-notes.md'].join('/')), false); + assert.equal(isDocsHistoricalRecord(['docs', 'research-notes.md'].join('/')), false); + }); +}); + +describe('removed-but-needed lint: docs historical-record exemption end-to-end (#3942)', () => { + test('exit 1: the gate still FIRES for a deleted file referenced from a live docs/ path outside adr/research (guard proven to fail, not just to pass)', (t) => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-rbn-docs-live-')); + t.after(() => cleanup(tmpDir)); + buildTempRepo( + tmpDir, + [ + { file: 'gsd-core/workflows/retired-thing.md', content: '# retired\n' }, + // docs/some-doc.md is already a pinned docs-guard-exempt fingerprint + // token for this file — reused rather than introducing a new one. + { file: 'docs/some-doc.md', content: 'see gsd-core/workflows/retired-thing.md for details\n' }, + ], + [{ file: 'gsd-core/workflows/retired-thing.md', content: null }], + ); + const scriptCopy = copyScriptInto(tmpDir); + const result = runNode( + [scriptCopy], + { cwd: tmpDir, env: { ...process.env, GSD_REMOVED_BUT_NEEDED_BASE: 'main' } }, + ); + assert.equal(result.exitCode, 1, `expected exit 1, got ${result.exitCode}: ${result.stderr}`); + assert.match(result.stderr, /retired-thing\.md/); + }); + + test('exit 0: the SAME deleted-file reference is exempt when the referencing document lives under the docs adr subdirectory', (t) => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-rbn-docs-adr-')); + t.after(() => cleanup(tmpDir)); + const adrFile = ['docs', 'adr', '9999-retire-the-thing.md'].join('/'); + buildTempRepo( + tmpDir, + [ + { file: 'gsd-core/workflows/retired-thing.md', content: '# retired\n' }, + { + file: adrFile, + content: '# ADR: retire retired-thing.md\n\nRecords the deletion of gsd-core/workflows/retired-thing.md.\n', + }, + ], + [{ file: 'gsd-core/workflows/retired-thing.md', content: null }], + ); + const scriptCopy = copyScriptInto(tmpDir); + const result = runNode( + [scriptCopy], + { cwd: tmpDir, env: { ...process.env, GSD_REMOVED_BUT_NEEDED_BASE: 'main' } }, + ); + assert.equal(result.exitCode, 0, `expected exit 0, got ${result.exitCode}: ${result.stderr}`); + }); + + test('exit 0: the exemption also applies to the docs research subdirectory', (t) => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-rbn-docs-research-')); + t.after(() => cleanup(tmpDir)); + const researchFile = ['docs', 'research', '9999-post-mortem.md'].join('/'); + buildTempRepo( + tmpDir, + [ + { file: 'gsd-core/workflows/retired-thing.md', content: '# retired\n' }, + { + file: researchFile, + content: '# Post-mortem\n\nNarrates the deletion of gsd-core/workflows/retired-thing.md.\n', + }, + ], + [{ file: 'gsd-core/workflows/retired-thing.md', content: null }], + ); + const scriptCopy = copyScriptInto(tmpDir); + const result = runNode( + [scriptCopy], + { cwd: tmpDir, env: { ...process.env, GSD_REMOVED_BUT_NEEDED_BASE: 'main' } }, + ); + assert.equal(result.exitCode, 0, `expected exit 0, got ${result.exitCode}: ${result.stderr}`); + }); + + test('exit 1: a docs/ file whose name merely STARTS WITH "adr" (not inside the adr subdirectory) is NOT exempt — no accidental over-exemption', (t) => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-rbn-docs-adr-lookalike-')); + t.after(() => cleanup(tmpDir)); + const lookalikeFile = ['docs', 'adr-notes.md'].join('/'); + buildTempRepo( + tmpDir, + [ + { file: 'gsd-core/workflows/retired-thing.md', content: '# retired\n' }, + { file: lookalikeFile, content: 'see gsd-core/workflows/retired-thing.md\n' }, + ], + [{ file: 'gsd-core/workflows/retired-thing.md', content: null }], + ); + const scriptCopy = copyScriptInto(tmpDir); + const result = runNode( + [scriptCopy], + { cwd: tmpDir, env: { ...process.env, GSD_REMOVED_BUT_NEEDED_BASE: 'main' } }, + ); + assert.equal(result.exitCode, 1, `expected exit 1, got ${result.exitCode}: ${result.stderr}`); + }); +});