* test(#3942): failing-first suite for the emitted-drift ack commit trailer Binds 37 input classes from the phase test matrix to the behavior ADR-3942 specifies, before any of it exists. Stubs return benign empty values rather than throwing, deliberately: several rows assert that something DOES throw (cap overflow, uncomputable commit range), and a throwing stub would turn those green for the wrong reason and destroy the red. The two rows that carry the design's load: - merge-base semantics. The range is $(git merge-base base HEAD)..HEAD, not base..HEAD, because changedPaths comes from `git diff base...HEAD` (three dot). Two-dot would let the ack set and the change set disagree about which commits are this PR's. The fixture forks a topic branch, puts a trailer on each side, and asserts only the topic-side trailer is in range. - fail-closed on an uncomputable range. With fragments a depth-1 checkout passes VACUOUSLY, every fragment reading as brand-new. With trailers the range cannot be computed at all, and returning an empty set would silently disarm the gate, so it must throw. The fixture builds a genuine shallow clone rather than simulating one. Also covers the self-inflicted case: this change's own documentation quotes the trailer syntax, so an example landing at the end of a commit message would arm a live acknowledgment keyed on the literal placeholder text. Keys carrying angle brackets or whitespace are rejected. Authored per the phase artifacts 40-design.md and 50-test-matrix.md. Not yet run on the remote runner — this commit exists to be tested. Refs #3942 * chore(#3942): move the emitted-drift ack to a commit trailer Implements ADR-3942, superseding ADR-2719 section 3 and its #2789 amendment. Sections 1, 2 and 4-7 are retained: the conservation law is unchanged, only the storage of its escape hatch moved off the working tree. An acknowledgment explains one PR's ripple, and the moment that PR merges the ripple is in the base, so it can never clear anything again. It was stored in permanent shared state anyway, and every consequence of that mismatch had to be built and then maintained. The chain is #2789 -> #2914 -> #3078 -> #3842 -> #3823 -> #3875, each fix generating the next defect, ending in a scheduled sweeper whose own first PR could not merge itself. Added parseAckTrailers + renderAckTrailer (pure) and readAckTrailers (IO shell), reading Emitted-Drift-Ack-Hash: / Emitted-Drift-Ack-Growth: trailers over the merge-base range. tests/emitted-ack-trailer.test.cjs, 37 cases, written failing-first and confirmed red before any of this existed. Changed diffEmitted takes two structurally distinct key-space maps instead of one shared paths map. That closes a latent defect: the spaces were separated by convention only, so a growth key satisfied a hash lookup by naming coincidence. staleAcks now reports which space a key was declared in. REMEDIATION teaches the trailer, per space, with its example rendered through renderAckTrailer so the taught grammar cannot drift from what the parser accepts. Removed the sweep workflow, the guard-no-ack-on-next job, the standalone linter and its lint:ci entry, the fragment directory and its three spent fragments, the legacy single-file union, and the baseAck/spentAcks mechanism -- spentness is now structural, not computed. Two range properties carry the design and are pinned by tests rather than asserted: the range is merge-base scoped, matching git diff base...HEAD, so an already-merged trailer is out of range by construction; and an uncomputable range throws instead of reading as zero acknowledgments, which is the inverse of the fragment guard's vacuous pass. Three deliberate observable changes, each disclosed in the changeset: the unread runtime field is gone, the legacy file is no longer read, and cross-space excusal no longer works. Ten open PRs carry fragments and will meet a modify/delete conflict. Measured before landing and accepted deliberately; the one-line migration is in the PR body. Verified: lint:ci exit 0. Remote runner to follow on this exact sha. Refs #3942 * fix(#3942): silent trailer collapse, lost coverage, and an unbounded cap Six findings from the orthogonal review round, all fixed in place. BLOCKER -- two trailers of the same name on one commit collapsed silently. readAckTrailers built `separator=1d` where git needs `separator=%x1d`: the `separator=` value inside a %(trailers:...) placeholder is itself a pretty-format string, so the bare hex was emitted as two literal characters and the split on \x1d never matched. Two same-name trailers therefore joined into one value with errors empty -- the first reason absorbing the second entry's key. Silent truncation, the exact class MAX_ACK_TRAILERS throws to prevent. Confirmed with od -c against real git output before and after. The failing-first matrix did not catch it because its "both spaces coexist" row uses Hash plus Growth -- different trailer NAMES -- so the value separator was never exercised. Two regression tests now cover same-name trailers directly. Coverage recovered: normalizeAckReason and INVISIBLE stayed on the live path via parseAckTrailers but lost every test when the old suite was pruned. Back under test against the current surface -- all six invisible codepoints individually, whitespace collapse, trim, CRLF, and two seeded fast-check properties. Dropping any single codepoint now fails. MAX_ACK_TRAILERS counted raw trailers before de-duplication, so one trailer carried forward across rebased commits counted once per commit and could throw on a legitimate branch. Now counts distinct entries; 100 identical repeats dedupe to one. diffEmitted validated baseline, current and changedPaths but not the new ackHash/ackGrowth, so a bad shape raised an unhandled TypeError instead of an error verdict -- the same defect shape this file documents for #2778. Docs: CONTRIBUTING and TESTING-SUITES were rewritten only in their first sections; the later passages still taught fragments, git rm and the deleted guard, contradicting the new text directly above them. Finished. Also extends lint-removed-but-needed to exempt docs/adr and docs/research. That gate fails on any docs mention of a file deleted in the same diff, which makes it impossible to document a deletion in the PR performing it -- an ADR's whole job is naming what it retired. Exemption is narrow and comes with a test proving the gate still fires for a live consumer elsewhere under docs/. A guard that cannot fail is worse than no guard. Maintainer-approved. CONTEXT.md names the retired machinery by role rather than by filename: its generated projection lands in docs/, which that gate does scan. Adds docs/how-to/acknowledge-emitted-drift.md. The required docs set is Reference and Explanation, so the task quadrant can be empty with every gate green -- and this change has a real multi-step journey, including the fragment migration ten open PRs now need. lint:ci exit 0. Refs #3942 * docs(#3942): correct the duplicate-trailer rule in CONTRIBUTING Both axes of the code review independently flagged the same passage, without seeing each other's output. It claimed two declarations of the same key are always "a hard, loudly-reported error, not a silent last-wins". That is only half true, and the missing half is the one contributors hit: identical declarations -- same key, same reason -- dedupe silently, because a trailer legitimately survives a rebase and reappears on every rebased commit. Failing there would red a branch for doing nothing wrong, which is exactly why the dedup exists. Only a same-key/different-reason pair errors, and that one is a genuine ambiguity about which explanation holds. As written, the paragraph told a contributor that a rebase-carried trailer breaks the gate -- the opposite of the behavior. CONTEXT.md's parallel entry already stated it correctly; this brings CONTRIBUTING into line. Doc-only, root-level markdown. Refs #3942 * chore(#3942): backfill changeset PR number to 3954 --------- Co-authored-by: sim <sim@local>
This commit is contained in:
5
.changeset/serene-orcas-glide.md
Normal file
5
.changeset/serene-orcas-glide.md
Normal file
@@ -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)
|
||||
280
.github/workflows/ack-fragment-sweep.yml
vendored
280
.github/workflows/ack-fragment-sweep.yml
vendored
@@ -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 <<EOF
|
||||
Automated sweep of fully-spent emitted-drift-ack fragment(s) surviving on \`next\`.
|
||||
|
||||
Every entry in each fragment below is already at the base, so it is spent and
|
||||
gates nothing (#2789) — but it still OWNS its path keys, which walls off the
|
||||
next PR that grows one of them (#3078). Leaving them in place reds \`next\` on
|
||||
every push.
|
||||
|
||||
${LIST}
|
||||
|
||||
The list was produced by \`lint-emitted-drift-ack.cjs --guard-next --sweep-plan
|
||||
--defer-to-open-prs\` — the same computation the \`guard-no-ack-on-next\` job
|
||||
runs — so it reflects the verdict the guard itself produced at \`${SHORT_SHA}\`, not a
|
||||
re-derived or hand-maintained list. Any fragment an open PR still touches was
|
||||
held back rather than swept (#3842).
|
||||
|
||||
Refs #3875. Generated by \`.github/workflows/ack-fragment-sweep.yml\`.
|
||||
EOF
|
||||
)
|
||||
|
||||
PR_URL=$(gh pr create \
|
||||
--base next \
|
||||
--head "$BR" \
|
||||
--title "chore: sweep spent ack fragments from next (${SHORT_SHA})" \
|
||||
--body "$BODY")
|
||||
PR="${PR_URL##*/}"
|
||||
echo "opened ${PR_URL}"
|
||||
|
||||
# no-changelog: deleting spent acknowledgment paperwork is not a
|
||||
# user-facing change, so it carries no changeset fragment.
|
||||
#
|
||||
# Not `|| true`: `no-changelog` is what exempts this PR from the changeset
|
||||
# gate, so losing it leaves the PR red for a reason that has nothing to do
|
||||
# with its contents. Still non-fatal — the PR itself is already open and
|
||||
# useful — but it must be visible.
|
||||
if ! gh pr edit "$PR" --add-label automation --add-label no-changelog; then
|
||||
echo "::warning::could not label PR #${PR}; the changeset gate will need the no-changelog label applied by hand."
|
||||
fi
|
||||
76
.github/workflows/test.yml
vendored
76
.github/workflows/test.yml
vendored
@@ -838,79 +838,3 @@ jobs:
|
||||
with:
|
||||
path: .gsd-cache/emitted-baseline.json
|
||||
key: emitted-baseline-${{ github.sha }}
|
||||
|
||||
# #2914: tests/emitted-drift-ack.json (the LEGACY single ack file) must never persist
|
||||
# on `next`. Every entry is scoped to the diff that introduced it (#2789) — once
|
||||
# merged it is, by definition, already at the base, so it is spent and inert
|
||||
# regardless of shape. Acks now go in per-PR fragments under
|
||||
# tests/emitted-drift-acks/ instead — one independently-named file per PR, never a
|
||||
# single file every PR rewrites wholesale — so a fragment LEFT ON next is harmless and
|
||||
# is deliberately NOT what this guard checks; only the legacy shared file is a shared
|
||||
# merge-conflict cell worth guarding against. This is DELIBERATELY NOT a PR-lane check
|
||||
# comparing a PR's base ack against `next`: that is exactly the #2768 shape #2789 was
|
||||
# written to end (a spent-but-present base ack would red every open PR the moment one
|
||||
# landed). It runs only here, on push to `next`, asserting a fact about `next`'s own
|
||||
# tree; it needs no npm install, since the guard is pure fs.existsSync.
|
||||
#
|
||||
# Enforcement scope, stated honestly: this job is push-triggered and runs post-merge,
|
||||
# and is not wired into `required-tests` — it cannot BLOCK a merge. A red run here
|
||||
# only turns `next`'s own CI red for manual follow-up, same as publish-emitted-baseline
|
||||
# above. It fires reliably today because the path that reaches `next`
|
||||
# (`auto-backmerge.yml`'s admin-merge step) authenticates with `GSD_BOT_PR_TOKEN`, a
|
||||
# PAT, which DOES trigger this workflow on push. If that secret ever lapses, the
|
||||
# `|| secrets.GITHUB_TOKEN` fallback there would push with the default token instead,
|
||||
# which GitHub's anti-recursion rule keeps from triggering new workflow runs —
|
||||
# silently skipping this job and publish-emitted-baseline alike. That is a known,
|
||||
# shared limitation of every push-to-next job in this file, not specific to this guard.
|
||||
guard-no-ack-on-next:
|
||||
name: Guard no spent ack on next
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/next'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 1
|
||||
# `contents: read` restates the top-level default because job-level `permissions`
|
||||
# REPLACES it rather than adds to it. `pull-requests: read` is new (#3842): the sweep
|
||||
# step now runs `gh pr list` to defer any all-spent fragment an OPEN PR still touches,
|
||||
# rather than deleting it out from under that PR (#3330, #3774, #3648 all hit this the
|
||||
# first time the sweep ran, each with the swept fragment as its only conflicting path).
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# The guard compares each surviving fragment against its copy at the tip of
|
||||
# `next` BEFORE this push. Depth 2 covers the script's local `HEAD^` fallback;
|
||||
# at the default depth of 1 even that commit is absent, `git rev-parse HEAD^`
|
||||
# fails, every fragment reads as brand-new, and the job passes VACUOUSLY —
|
||||
# exactly how the legacy-file half spent months guarding a file that had not
|
||||
# existed since #2914 (#3078).
|
||||
fetch-depth: 2
|
||||
|
||||
# `github.event.before` is the authoritative pre-push tip, and `HEAD^` is NOT a
|
||||
# substitute for it: the default branch allows REBASE merges
|
||||
# (.github/rulesets/main-protection.json, allowed_merge_methods), so one push can
|
||||
# carry N commits. With `HEAD^` a 2-commit rebase-merge whose first commit adds a
|
||||
# fragment would read that fragment as already-present and demand `git rm` on the
|
||||
# very push that introduced it. Zeros mean the branch was just created: there is no
|
||||
# pre-push tip, so the guard is given none and falls back to resolving one locally.
|
||||
- name: Fetch the pre-push tip of next
|
||||
if: github.event.before != '0000000000000000000000000000000000000000'
|
||||
env:
|
||||
BEFORE: ${{ github.event.before }}
|
||||
run: git fetch --no-tags --depth=1 origin "$BEFORE"
|
||||
|
||||
- name: Assert no spent ack survives on next (legacy file and fragments)
|
||||
env:
|
||||
BEFORE: ${{ github.event.before }}
|
||||
# `gh pr list` needs auth; the default GITHUB_TOKEN plus this job's
|
||||
# `pull-requests: read` permission above is sufficient to read PR file lists in
|
||||
# this same repo (#3842).
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
# The sha goes through the environment rather than `${{ }}` interpolation into the
|
||||
# script body, so nothing from the event can ever be parsed as shell.
|
||||
run: |
|
||||
if [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then
|
||||
node scripts/lint-emitted-drift-ack.cjs --guard-next --defer-to-open-prs
|
||||
else
|
||||
node scripts/lint-emitted-drift-ack.cjs --guard-next --base-ref "$BEFORE" --defer-to-open-prs
|
||||
fi
|
||||
|
||||
File diff suppressed because one or more lines are too long
123
CONTRIBUTING.md
123
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 <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/<owner>.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 +
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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 `<automated>` 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 `<automated>` verify command, and migrate a phase planned before the rule
|
||||
|
||||
@@ -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 <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/<name>/modes/`, templates to `workflows/<name>/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 <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). |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -57,7 +57,16 @@ The two key spaces are convention-only. A hash ripple keys on the emitted path (
|
||||
Emitted-Drift-Ack-Hash: <emitted/path> — <reason>
|
||||
Emitted-Drift-Ack-Growth: <filename> — <reason>
|
||||
|
||||
Read from `git log <base>..<head>` — the PR's own commits and no others.
|
||||
Read from `git log $(git merge-base <base> HEAD)..HEAD` — the PR's own commits and no others.
|
||||
|
||||
> **Amendment (#3942 implementation, 2026-08-27).** This section originally said `git log
|
||||
> <base>..<head>`, 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**").
|
||||
|
||||
|
||||
69
docs/how-to/acknowledge-emitted-drift.md
Normal file
69
docs/how-to/acknowledge-emitted-drift.md
Normal file
@@ -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 `<key> — <reason>`, 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 <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/<yours>.json
|
||||
git commit --amend --trailer "Emitted-Drift-Ack-Growth: <filename> — <the reason from your fragment>"
|
||||
```
|
||||
|
||||
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 |
|
||||
File diff suppressed because one or more lines are too long
@@ -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",
|
||||
|
||||
@@ -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 <emitted path> -> { 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<string>|'unknown'} [opts.openPrTouchedPaths] repo-relative fragment paths
|
||||
* (`${ACK_DIR_REPO_PATH}/<name>`) 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=<file>`, 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<string>}
|
||||
*/
|
||||
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<string>} [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,
|
||||
};
|
||||
@@ -21,8 +21,9 @@
|
||||
* For every file deleted (`git diff --name-status <base>...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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 <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.
|
||||
});
|
||||
|
||||
709
tests/emitted-ack-trailer.test.cjs
Normal file
709
tests/emitted-ack-trailer.test.cjs
Normal file
@@ -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: ['<emitted/path> — 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 — same 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 },
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <failing_direction_probe> block, and the planner spawn prompt gains a <failing_direction_contract> block requiring a `<fails_when>` sibling for every runnable `<automated>` 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."
|
||||
}
|
||||
}
|
||||
@@ -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)."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
(`<NNNN>-<slug>.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.
|
||||
@@ -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
|
||||
* `<merge-base>..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}/<a-name-nobody-else-will-use>.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: '<why this ripple is deliberate>',
|
||||
growthReason: '<why this growth is deliberate>',
|
||||
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<string, {reason: string, runtime?: string}>, 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 <emitted path> -> { 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 `<merge-base>..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<string, {reason}>` 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<string,{reason:string}>} [opts.ackHash] live `Emitted-Drift-Ack-Hash`
|
||||
* entries, keyed on the emitted path (always contains `/`). Defaults to an empty Map.
|
||||
* @param {Map<string,{reason:string}>} [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:<key>`) 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: <key>`, a growth-space one as
|
||||
// `Emitted-Drift-Ack-Growth: <key>`, 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, '<its declared reason>',
|
||||
)}`);
|
||||
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
|
||||
* `<Trailer-Name>: `, one entry per trailer instance found in range).
|
||||
*
|
||||
* Grammar (40-design.md): `<key> — <reason>`, 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
|
||||
* `<emitted/path> — <reason>` 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<string, {reason: string}>, growth: Map<string, {reason: string}>, 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 "<key> — <reason>"',
|
||||
);
|
||||
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 (`<name>: <key> — <reason>`) 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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
* <ref>:<path>` 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=<file>`, 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 `<mergeBase>..<headRef>`.
|
||||
*
|
||||
* ── 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
|
||||
* `<mergeBase>..<headRef>` (never `<baseRef>..<headRef>`) 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<string, {reason: string}>, growth: Map<string, {reason: string}>, 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<hex>` 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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user