enhance(#2778): make the size-ratchet failure name its own remedy (#2780)

* fix(#2778): exempt intentionally-absent paths from the glossary gate

check-glossary-refs asserts that every backticked tests/ token in
CONTEXT.md resolves on disk. tests/emitted-drift-ack.json (ADR-2719
section 3) is absent on a healthy next BY DESIGN — it appears only
inside a PR that needs it, which is what makes touching it the alarm.

It passed before only by accident of backtick pairing: CONTEXT.md's
RULESET entries are themselves backtick-wrapped and contain backticks,
so the token happened to fall outside a code span. Any edit that
shifted the parity exposed it. A gate that passes by luck is not
passing.

The exemption is exact, not a prefix hole: a sibling missing tests/
path still fails, and a test locks that.

* feat(#2778): make the size-ratchet failure name its own remedy

The growth branch stated a requirement and withheld the means of
satisfying it: no ack file named, no schema, no key format, and no
do-not-regenerate line — so the likeliest guess was to hunt for a
baseline that #2724 deleted. Observed live on #2543.

All remediation now comes from one frozen REMEDIATION export whose
example document is rendered from ACK_VERSION, so the taught schema
cannot drift from the schema parseAck accepts. A round-trip test feeds
the printed document back through parseAck.

The report is now built as a typed IR (buildReport) that formatReport
renders, so tests assert on structure rather than prose, per
CONTRIBUTING.md's raw-text-matching rule.

Two defects found and fixed inline while building:
- diffEmitted's validation early-return omitted newFileCapExceeded
  while formatReport reads its length, so the branch that reports a
  failed git diff threw a TypeError instead of naming the problem.
- Printing one complete ack document per failing branch made each read
  as the whole file, so pasting the second over the first silently lost
  an acknowledgment. One document now covers the whole report.

Closes #2778

* chore(#2778): backfill changeset pr number to 2780
This commit is contained in:
Tom Boucher
2026-07-28 18:26:55 -04:00
committed by GitHub
parent 0997d4f443
commit e276cc7f00
8 changed files with 546 additions and 8 deletions

View File

@@ -0,0 +1,5 @@
---
type: Changed
pr: 2780
---
**The emitted-attribution size ratchet now tells you how to clear it** — a PR that only grew a workflow or agent file used to fail with a byte delta and the word "acknowledgment", without naming `tests/emitted-drift-ack.json`, saying it does not exist yet, giving its schema, or stating that the key is the bare filename. All three failing branches now print a minimal valid document and repeat that nothing is regenerated. (#2778)

View File

@@ -474,7 +474,7 @@ The prompt-level data/instruction isolation seam for untrusted web/document ingr
`RULESET.WORKFLOW_MARKDOWN.FENCES=preserve opening language fence when editing shell snippets in workflow markdown; malformed fence creates fresh CR threads (MD040)`
`RULESET.WORKFLOW_SIZE_BUDGET=workflow size enforcement (#1074; BYTES not lines per #717; LF-normalized per #683) = differential attribution size ratchet (PRIMARY anti-creep since #2724/ADR-2719 §4: tests/emitted-attribution.test.cjs's real-tree test reports growth in any gsd-core/workflows/*.md with its exact byte delta vs `next`, no committed snapshot, requires a tests/emitted-drift-ack.json entry) + loose tier hard caps (outer red lines, NEVER raised on approach: XL<=98304 / LARGE<=61440 / DEFAULT<=40960) + discuss-phase<32000; a file that grew fails the differential guard — add an ack entry naming the file and reason, justify the growth in the PR (or extract LAZILY-loaded content; eager @-imports don't reduce loaded context); crossing a hard cap means EXTRACT, not bump. The prior per-file baseline (tests/workflow-size-baseline.json, `npm run size:baseline`) is REMOVED by #2724. Its new-file cap (ADR-1610 Decision point 3, un-baselined files <=32768, the Codex anchor) is REVIVED inside the differential's size ratchet itself (`NEW_FILE_CAP` in tests/helpers/emitted-diff.cjs) rather than lost: "not yet baselined" is exactly "present in sizeCurrent, absent from sizeBaseline", a signal the ratchet already computes for its own reasons. NOT ack-able — same as the tier hard caps, the fix is extraction. Narrower than the original: this check cannot see XL/LARGE tiering (tests/workflow-size-budget.test.cjs's classification, invisible to the pure differential module), so a legitimately large NEW file must extract rather than tier in, one release earlier than an existing file would need to — a disclosed, deliberate simplification`
`RULESET.AGENT_SIZE_BUDGET=agent-size-budget (#1074; sibling of WORKFLOW_SIZE_BUDGET; BYTES not lines per #717/#683, rebased from lines in PR 3/3) = differential attribution size ratchet (PRIMARY anti-creep since #2724/ADR-2719 §4, same mechanism and same tests/emitted-drift-ack.json as WORKFLOW_SIZE_BUDGET, scoped to agents/gsd-*.md) + loose tier hard caps (red lines, never raised on approach: XL<=57344 / LARGE<=49152 / DEFAULT<=24576); net-new agents are DEFAULT-tier (no separate new-file cap). Sizes are measured via the shared scripts/workflow-size.cjs measureMdFiles(dir,predicate) counter (tests/helpers/emitted-runtime.cjs's currentSizes() and the guard's own tier-cap checks both import it). A grown agent fails the differential guard — ack + justify, or extract LAZILY to gsd-core/references/. DISTINCT from DEFECT.AGENT-FILE-SIZE-CAP-BREACH (a separate 45K-CHAR extraction-evidence threshold on gsd-planner via planner-decomposition/reachability tests): that guard proves mode-sections were extracted; this one bounds total agent bytes. Two guards, two units (chars vs bytes), two purposes. The prior per-file baseline (tests/agent-size-baseline.json, `npm run size:baseline`) is REMOVED by #2724`
`RULESET.EMITTED_ATTRIBUTION=the emitted-artifact family (ADR-2719, epic #2719) — POST-CUTOVER (#2724, Phase 4). Historically tests/fixtures/golden-install-parity/*.json (19 path→hash manifests) + tests/workflow-size-baseline.json + tests/agent-size-baseline.json were all committed, PURE FUNCTIONS of the source tree whose correct merge was ALWAYS "recompute" — 140 of 143 conflicted-file instances across the open PR queue were these files. #2724 DELETES all three, the golden test (tests/golden-install-parity.test.cjs), the generator (scripts/gen-golden-install-parity-zcode.cjs), `npm run gen:golden`, `UPDATE_GOLDEN`, the merge-driver bridge (scripts/git-merge-regen-driver.cjs, `npm run setup:merge-driver`, the .gitattributes merge=gsd-regen block), and scripts/update-size-baseline.cjs (`npm run size:baseline`). The differential attribution check (tests/emitted-attribution.test.cjs + tests/emitted-provenance.test.cjs) is now the SOLE gate for emitted-artifact propagation AND size growth — no committed artifact, nothing to hand-merge, nothing to regenerate. `npm run regen:derived` still exists for what remains committed and derived: build, registry, ADR index, capability matrix, inventory manifest, manifest versions, and `tests/fixtures/install-tree/*.json` (now `npm run gen:install-tree`, folded into `regen:derived`). tests/fixtures/install-tree/*.json is DELIBERATELY EXCLUDED from the cutover (ADR-2719 §7): it conflicts on 0 of 7, its diffs are readable, and it preserves "the installer stopped shipping X" as a hard absolute failure — capturing it would convert that absolute into an attribution-free auto-resolve. The baseline the differential compares against is now published by `scripts/gen-emitted-baseline.cjs` on every push to `next` (cached, keyed on sha) and restored in PR lanes via `GSD_EMITTED_BASELINE`/`resolveBaseline()` (tests/helpers/emitted-baseline.cjs); a cache miss falls back to an in-job build via a throwaway `git worktree` (tests/helpers/emitted-runtime.cjs's `buildBaselineAtRef`). cf `RULESET.WORKFLOW_SIZE_BUDGET`, `RULESET.AGENT_SIZE_BUDGET`; see `### Emitted Artifact Provenance``
`RULESET.EMITTED_ATTRIBUTION=the emitted-artifact family (ADR-2719, epic #2719) — POST-CUTOVER (#2724, Phase 4). Historically tests/fixtures/golden-install-parity/*.json (19 path→hash manifests) + tests/workflow-size-baseline.json + tests/agent-size-baseline.json were all committed, PURE FUNCTIONS of the source tree whose correct merge was ALWAYS "recompute" — 140 of 143 conflicted-file instances across the open PR queue were these files. #2724 DELETES all three, the golden test (tests/golden-install-parity.test.cjs), the generator (scripts/gen-golden-install-parity-zcode.cjs), `npm run gen:golden`, `UPDATE_GOLDEN`, the merge-driver bridge (scripts/git-merge-regen-driver.cjs, `npm run setup:merge-driver`, the .gitattributes merge=gsd-regen block), and scripts/update-size-baseline.cjs (`npm run size:baseline`). The differential attribution check (tests/emitted-attribution.test.cjs + tests/emitted-provenance.test.cjs) is now the SOLE gate for emitted-artifact propagation AND size growth — no committed artifact, nothing to hand-merge, nothing to regenerate. `npm run regen:derived` still exists for what remains committed and derived: build, registry, ADR index, capability matrix, inventory manifest, manifest versions, and `tests/fixtures/install-tree/*.json` (now `npm run gen:install-tree`, folded into `regen:derived`). tests/fixtures/install-tree/*.json is DELIBERATELY EXCLUDED from the cutover (ADR-2719 §7): it conflicts on 0 of 7, its diffs are readable, and it preserves "the installer stopped shipping X" as a hard absolute failure — capturing it would convert that absolute into an attribution-free auto-resolve. The baseline the differential compares against is now published by `scripts/gen-emitted-baseline.cjs` on every push to `next` (cached, keyed on sha) and restored in PR lanes via `GSD_EMITTED_BASELINE`/`resolveBaseline()` (tests/helpers/emitted-baseline.cjs); a cache miss falls back to an in-job build via a throwaway `git worktree` (tests/helpers/emitted-runtime.cjs's `buildBaselineAtRef`). REMEDIATION IS PART OF THE GATE (#2778): the failure output names its own remedy, because a gate that states a requirement and withholds the means of satisfying it is a maintainer round-trip, not a gate — ADR-2719 §3's "conspicuous declaration" only works if the contributor can discover how to make it. Both failing branches name `tests/emitted-drift-ack.json`, say it may not exist yet (absence is the healthy steady state), print a minimal valid document, and repeat "do NOT regenerate anything" — post-#2724 there is nothing left to regenerate, and hunting for a deleted baseline is the predictable wrong guess. The two branches key on DIFFERENT spaces and each says which: the hash pass keys on the EMITTED PATH (always contains a `/`), the size ratchet keys on the BARE FILENAME (`currentSizes` writes `sizes[entry.name]` from readdirSync over `gsd-core/workflows/` + `agents/`). A stale-ack failure additionally says to delete the FILE when removing its last entry, since an empty-but-present ack parses fine yet destroys the presence-is-the-alarm property. NOT ack-able and deliberately given no ack text: the `NEW_FILE_CAP` branch, whose remedy is extraction. Text is sourced from one frozen `REMEDIATION` export in tests/helpers/emitted-diff.cjs whose example document is rendered from `ACK_VERSION` via `JSON.stringify`, so the taught schema cannot drift from the accepted one (a round-trip test feeds the printed document back through `parseAck`); the message teaches ONE canonical shape even though `parseAck` also accepts a bare-string reason and a missing `version` — liberal in what it accepts, conservative in what it sends. Note the ADR's Consequences originally called the #2724 migration "terminal"; #2778 corrected that — it is terminal only for a PR that grows no shipped file. cf `RULESET.WORKFLOW_SIZE_BUDGET`, `RULESET.AGENT_SIZE_BUDGET`; see `### Emitted Artifact Provenance``
`RULESET.WORKFLOW_FILE_NAMES=workflow files use hyphens; <step name="..."> XML attributes must match (extract-learnings not extract_learnings); tests should pin exact hyphenated name`
`RULESET.WORKFLOW_EXECUTION_CONTEXT=@-ref in commands/gsd/*.md must resolve to an existing file on disk; regression test in tests/docs-update.test.cjs (folds former \`bug-3135-capture-backlog-workflow\`, consolidation epic #1969); INVENTORY.md row + INVENTORY-MANIFEST.json families.workflows must stay in sync; "Invoked by" attribution must move when a flag absorbs a micro-skill`
`RULESET.WORKFLOW_EXECUTE_END_TO_END=standard for single-workflow commands is "Execute end-to-end." (no bolded **Follow the X workflow** fragments); flag-dispatch routing uses "execute the X workflow end-to-end." in routing bullets — convention verified live across ~20 commands/gsd/*.md files; no ADR currently documents this specific phrasing rule (ADR-0002 covers the adjacent but distinct command-contract/@-ref-resolution seam, not this convention)`

View File

@@ -780,6 +780,17 @@ its exact byte delta and needs the same acknowledgment; the outer tier hard caps
`tests/workflow-size-budget.test.cjs` / `tests/agent-size-budget.test.cjs` are unaffected
and still apply.
You do not need to memorize any of this. **The failure output names its own remedy** — it
tells you the file to create, that it does not exist yet, 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
(`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 `tests/emitted-drift-ack.json`, delete the 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.
`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,
the registry, and `tests/fixtures/install-tree/*.json` (`npm run gen:install-tree`, the

View File

@@ -120,7 +120,7 @@ A baseline-unavailable path must never be a bare `return`. In `node:test` that i
## Consequences
- **Positive.** The conflict class ends rather than being automated around: 140 of 143 conflicted-file instances disappear. The propagation catch becomes a computed statement instead of a reviewer noticing an anomaly in hex. ~520 KB of committed derived state is deleted, along with the duplicate generator, `UPDATE_GOLDEN`, and `npm run gen:golden`. The artifact family finally has a name in `CONTEXT.md`.
- **Negative — one-time migration.** Deleting the fixtures converts existing conflicts into delete/modify conflicts on the same 20 files, affecting 16 PRs. Verified by simulating the deletion with `git commit-tree` and re-running `git merge-tree`. The resolution is one identical command per PR and it is terminal; today's is regenerate-rebase-repush, recurring on every merge to `next`.
- **Negative — one-time migration.** Deleting the fixtures converts existing conflicts into delete/modify conflicts on the same 20 files, affecting 16 PRs. Verified by simulating the deletion with `git commit-tree` and re-running `git merge-tree`. The resolution starts with one identical command per PR — `git rm tests/fixtures/golden-install-parity/*.json tests/workflow-size-baseline.json tests/agent-size-baseline.json` — and for a PR that changes no shipped file's size, that is the whole of it. **It is not terminal for a PR that grows a `gsd-core/workflows/*.md` or `agents/gsd-*.md` file**, which is the common case for a feature change: those need a second step, creating `tests/emitted-drift-ack.json` with an entry keyed on the **bare filename** (§3, §4). This ADR originally described the migration as terminal, full stop; that claim was corrected by #2778 after the Phase 4 cutover met it in the field on #2543. The failure output now states the second step itself, so the correction is discoverable where the contributor actually is rather than only here. Either way the cost is bounded and one-time; today's is regenerate-rebase-repush, recurring on every merge to `next`.
- **Negative — new residual risks, accepted.** A provenance rule can map to the *wrong* source and still pass the totality guard: false attribution, not a false alarm. The Phase 3 dual-run window exists to surface exactly this, and spot-check tests on known pairs reduce it further. Separately, the relative chain holds only while the check runs on every merging PR — that is CI configuration rather than design, and if the selection rules narrow later it breaks silently.
- **Neutral.** During Phase 1–3, github.com still reports `CONFLICTING`. Merge drivers live in `.git/config`, so forks lack them and GitHub's own merge never runs them. The driver removes the labour, not the label, and is retired in Phase 4.

View File

@@ -64,6 +64,29 @@ const TRACKED_PREFIXES = [
/** The only bare (no-prefix-match) tokens this gate checks by exact name. */
const TRACKED_EXACT = new Set(['bin/install.js', 'package.json']);
/**
* Paths CONTEXT.md documents whose ABSENCE is the healthy steady state (#2778).
*
* `TRACKED_PREFIXES` skips claims this gate *cannot* check. This is the narrower
* third case: a claim it must not check, because "does not exist" is the correct
* state rather than drift.
*
* `tests/emitted-drift-ack.json` is the acknowledgment file from ADR-2719 §3. Its
* whole design property is that it appears in the changed-files list ONLY when
* something rippled unexpectedly — "touching it IS the alarm". A permanently
* committed copy would signal nothing, which is exactly why the ADR rejected
* shipping an empty stub. So the file is absent on a healthy `next` and present
* only inside a PR that needs it, and asserting either way is wrong.
*
* Until #2778 this passed only by accident: CONTEXT.md's `RULESET.` entries are
* themselves backtick-wrapped and contain backticks, so the sequential pairing in
* `extractTrackedRefs` happened to leave this token outside a code span. Any edit
* that shifted the parity — such as #2778's own — exposed it. A gate that passes
* by luck is not passing; naming the exemption makes the intent explicit and
* survives the next edit.
*/
const INTENTIONALLY_ABSENT = new Set(['tests/emitted-drift-ack.json']);
/**
* Shape a backticked token must have to even be considered a path candidate:
* one or more `/`-separated segments of word/dot/dash characters, with an
@@ -75,6 +98,7 @@ const PATH_TOKEN_RE = /^[\w.-]+(?:\/[\w.-]+)*(?::\d+)?$/;
/** Whether `token` (line-suffix already stripped) is one this gate checks. */
function isTracked(token) {
if (INTENTIONALLY_ABSENT.has(token)) return false;
return TRACKED_EXACT.has(token) || TRACKED_PREFIXES.some((prefix) => token.startsWith(prefix));
}

View File

@@ -143,6 +143,48 @@ test('a reference to a nonexistent gsd-core/bin/lib/*.cjs path is skipped (gener
assert.equal(res.status, 0, `generated bin/lib path must be skipped, not asserted missing: ${res.stderr}`);
});
test('an intentionally-absent documented path is skipped, not asserted missing', (t) => {
// #2778. `tests/emitted-drift-ack.json` (ADR-2719 §3) is absent on a healthy
// `next` BY DESIGN — it appears only inside a PR that needs it, which is what
// makes touching it the alarm. Asserting its existence turns the correct state
// into a drift finding. Under `tests/` it would otherwise be tracked, so this
// is a real exemption rather than a prefix accident.
const context = [
'# Context',
'',
'The escape hatch is a committed acknowledgment (`tests/emitted-drift-ack.json`).',
'',
`${allRuntimesSentence(17, REAL_RUNTIMES)}.`,
'',
].join('\n');
const root = makeRepo(t, { contextBody: context });
const res = run(root, ['--check']);
assert.equal(
res.status, 0,
`an intentionally-absent path must be skipped, not asserted missing: ${res.stderr}`,
);
});
test('a sibling tests/ path that is merely missing is still caught', (t) => {
// The exemption must be exact, not a blanket hole under `tests/`. A different
// missing tests/ file must still fail, or #2778's fix would disarm the gate for
// the whole directory.
const context = [
'# Context',
'',
'See `tests/emitted-drift-ack-typo.json` for details.',
'',
`${allRuntimesSentence(17, REAL_RUNTIMES)}.`,
'',
].join('\n');
const root = makeRepo(t, { contextBody: context });
const res = run(root, ['--check']);
assert.equal(res.status, 1, 'a genuinely missing tests/ path must still be a finding');
assert.match(`${res.stdout}${res.stderr}`, /emitted-drift-ack-typo\.json/);
});
test('a ~/-rooted path and a bare filename are both skipped', (t) => {
const context = [
'# Context',

View File

@@ -60,10 +60,13 @@ const {
const { EXPECTED_MANIFEST_COUNT, loadManifests } = require('./helpers/emitted-provenance.cjs');
const {
ACK_VERSION,
ACK_FILE,
NEW_FILE_CAP,
REMEDIATION,
sourceSatisfiedBy,
parseAck,
diffEmitted,
buildReport,
formatReport,
} = require('./helpers/emitted-diff.cjs');
@@ -560,6 +563,289 @@ test('shrinkage is reported but needs no ack', () => {
assert.ok(r.ok, 'shrinkage is not creep — gating it would punish what the ratchet wants');
});
// ─── The failure must name its own remedy (#2778, ADR-2719 §3) ───────────────
//
// A gate that states a requirement and withholds the means of satisfying it is not a
// gate, it is a maintainer round-trip. ADR-2719 §3 makes the acknowledgment a
// *conspicuous declaration a contributor makes deliberately* — which only works if the
// contributor can discover how to make it. Observed live on #2543: real growth from a
// legitimate feature change, a red lane, and no self-serve path out of it.
//
// These assert on `buildReport`'s typed IR, not on rendered prose — CONTRIBUTING.md
// ("Prohibited: Raw Text Matching on Test Outputs") requires a human formatter to expose
// a structured surface so a reworded sentence is never a failing test. Exactly two tests
// below touch the rendered string, and only to prove the renderer emits the IR at all.
//
// They also use the bare `buildReport(r)` / `formatReport(r)` form, because that is what
// the real-tree test at the bottom of this file calls: a row that only ever passed an
// explicit `sampleLimit` would prove a property no shipping caller exercises.
/** The growth-only shape: a size ratchet trip with NO unattributable hash movement. */
const growthOnly = (extra = {}) => diffEmitted({
baseline: mf({}),
current: mf({}),
changedPaths: [],
sizeBaseline: { 'explore.md': 11127 },
sizeCurrent: { 'explore.md': 13230 },
...extra,
});
/** The one block of `kind`, or undefined. */
const blockOf = (report, kind) => report.blocks.find((b) => b.kind === kind);
test('a growth-only failure carries the byte delta, the key rule, and an ack entry', () => {
// The pre-#2778 report stopped after the byte delta. The suite's only coverage of the
// remediation reached it through the UNATTRIBUTABLE branch, so a growth-only regression
// was invisible — which is why this fixture carries no hash movement at all.
const r = growthOnly();
assert.equal(r.unattributable.length, 0, 'this fixture must isolate the growth branch');
assert.ok(!r.ok);
const report = buildReport(r);
const growth = blockOf(report, 'unacked-growth');
assert.ok(growth, 'the growth branch must produce a block');
assert.equal(growth.count, 1);
assert.deepEqual(growth.items[0], {
name: 'explore.md', from: 11127, to: 13230, delta: 2103, acked: false,
});
assert.equal(growth.keyRule, REMEDIATION.growthKeyRule, 'growth keys on the bare filename');
assert.deepEqual(report.ackable, [
{ key: 'explore.md', reason: REMEDIATION.growthReason },
], 'the ack entry must be keyed on the file that actually grew');
});
test('the renderer emits the ack file, the document, and the do-not-regenerate line', () => {
// The one place rendered text is the object of the test: proving the IR above actually
// reaches the contributor. Everything it asserts is an identity comparison against the
// frozen surface, so rewording any sentence cannot fail this.
const msg = formatReport(growthOnly());
assert.ok(msg.includes('explore.md grew 2103 bytes (11127 -> 13230)'), 'the delta still leads');
assert.ok(msg.includes(REMEDIATION.ackFile), 'the message must name the ack file');
assert.ok(msg.includes(REMEDIATION.createIfAbsent), 'it must say the file may not exist yet');
assert.ok(msg.includes(REMEDIATION.growthKeyRule), 'it must state the bare-filename key rule');
assert.ok(msg.includes(REMEDIATION.doNotRegenerate), 'it must say not to regenerate');
assert.ok(
msg.includes(REMEDIATION.ackDocument([{ key: 'explore.md', reason: REMEDIATION.growthReason }])),
'the printed document must be the one the IR describes',
);
});
test('the document the report teaches is accepted by parseAck', () => {
// The divergence killer. A report that teaches a schema the parser rejects is worse
// than no report: the contributor follows it, is rejected anyway, and now distrusts the
// gate. This pins the taught shape to the accepted shape in one assertion.
const taught = REMEDIATION.ackDocument([{ key: 'explore.md', reason: 'a real reason' }]);
const { entries, errors } = parseAck(JSON.parse(taught));
assert.deepEqual(errors, [], 'the taught document must parse with zero errors');
assert.equal(entries.get('explore.md').reason, 'a real reason');
// And it must actually clear the gate it is offered to clear.
const r = growthOnly({ ack: JSON.parse(taught) });
assert.equal(r.grown[0].acked, true);
assert.deepEqual(r.staleAcks, []);
assert.ok(r.ok, 'following the printed instructions must turn the lane green');
});
test('the taught document derives its version from ACK_VERSION', () => {
// A hand-typed `"version": 1` beside a live ACK_VERSION is the generative-fix-divergence
// class: bump one, the other lies. Asserting the relationship — not the literal — is
// what makes the bump safe.
assert.equal(JSON.parse(REMEDIATION.ackDocument([{ key: 'x.md', reason: 'r' }])).version, ACK_VERSION);
});
test('the remediation surface is frozen and names the ack file once', () => {
assert.ok(Object.isFrozen(REMEDIATION), 'the exported surface must not be mutable');
assert.equal(REMEDIATION.ackFile, ACK_FILE, 'one definition, not a second literal');
});
test('a ripple and a growth in one report share ONE document', () => {
// The combination nobody writes down, and the most likely real shape: a feature PR that
// both grows a workflow AND ripples an emitted path.
//
// Caught in review: printing a complete document per branch made each read as "the file
// to create", so a contributor pasting the second over the first silently loses the
// first acknowledgment — an ack-lost failure with no signal. One document, one file.
const r = diffEmitted({
baseline: mf({ [WORKFLOW_KEY]: 'aaa' }),
current: mf({ [WORKFLOW_KEY]: 'bbb' }),
changedPaths: ['README.md'],
sizeBaseline: { 'explore.md': 11127 },
sizeCurrent: { 'explore.md': 13230 },
});
const report = buildReport(r);
assert.equal(blockOf(report, 'unattributable').keyRule, REMEDIATION.rippleKeyRule);
assert.equal(blockOf(report, 'unacked-growth').keyRule, REMEDIATION.growthKeyRule);
// Both key spaces, one ack set, in list order.
assert.deepEqual(report.ackable, [
{ key: WORKFLOW_KEY, reason: REMEDIATION.rippleReason },
{ key: 'explore.md', reason: REMEDIATION.growthReason },
]);
// And the rendered document is genuinely one object holding both.
const doc = JSON.parse(REMEDIATION.ackDocument(report.ackable));
assert.deepEqual(Object.keys(doc.paths).sort(), [WORKFLOW_KEY, 'explore.md'].sort());
const { errors } = parseAck(doc);
assert.deepEqual(errors, [], 'the combined document must parse');
const msg = formatReport(r);
assert.equal(
msg.split('{"version"').length - 1, 1,
'exactly one document may be printed — two would invite pasting one over the other',
);
});
test('a stale ack names the file it lives in and the delete-the-file case', () => {
// Pre-#2778 this said acks "must be deleted" without naming the file they live in. It
// also never said what to do when the last entry goes: an empty-but-present ack file
// parses fine and is "legal", but it destroys the ADR-2719 §3 property that the file's
// PRESENCE is the alarm.
const r = diffEmitted({
baseline: mf({ [WORKFLOW_KEY]: 'aaa' }),
current: mf({ [WORKFLOW_KEY]: 'aaa' }),
changedPaths: [],
ack: { version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'old' } } },
});
const stale = blockOf(buildReport(r), 'stale-acks');
assert.deepEqual(stale.items, [WORKFLOW_KEY]);
assert.equal(stale.fix, REMEDIATION.staleAckFix);
assert.match(stale.fix, /delete the file/, 'the last-entry case must be covered');
// A stale-only report has nothing to acknowledge — it must NOT offer a document.
assert.deepEqual(buildReport(r).ackable, [], 'deleting an ack is not acknowledging one');
});
test('growth and a stale ack in one report keep both remedies', () => {
// The contributor is adding one entry and removing another in the same file.
const r = growthOnly({ ack: { version: ACK_VERSION, paths: { 'gone.md': { reason: 'outlived' } } } });
assert.deepEqual(r.staleAcks, ['gone.md']);
assert.equal(r.grown[0].acked, false);
const report = buildReport(r);
assert.ok(blockOf(report, 'unacked-growth'), 'the growth still needs an ack');
assert.ok(blockOf(report, 'stale-acks'), 'the stale entry still needs deleting');
assert.deepEqual(report.ackable, [{ key: 'explore.md', reason: REMEDIATION.growthReason }],
'only the growth is ackable; the stale entry is removed, not added');
});
test('the validation early-return renders instead of throwing', () => {
// Found while building #2778. diffEmitted's input-validation early return omitted
// `newFileCapExceeded`, and formatReport reads `result.newFileCapExceeded.length`
// unconditionally — so this path threw `TypeError: Cannot read properties of
// undefined` instead of printing its errors.
//
// Worst possible place for it: this branch is what runs when `git diff` failed or a
// manifest came back malformed. The crash replaced the only message that would have
// named the infrastructure problem, and a TypeError in a test helper reads like a
// broken test rather than a broken environment.
for (const bad of [
{ baseline: null, current: {}, changedPaths: [] },
{ baseline: {}, current: null, changedPaths: [] },
{ baseline: {}, current: {}, changedPaths: null },
{ baseline: [], current: {}, changedPaths: [] },
]) {
const r = diffEmitted(bad);
assert.ok(!r.ok);
assert.ok(r.errors.length > 0);
assert.deepEqual(r.newFileCapExceeded, [], 'every returned shape must carry every bucket');
const report = buildReport(r);
assert.equal(blockOf(report, 'errors').count, r.errors.length, 'the errors must render');
assert.deepEqual(report.ackable, [], 'a malformed input is not something to acknowledge');
}
});
test('a failed git diff renders as an error, never as "nothing changed"', () => {
// The comment on that validation branch says a failed `git diff` must never be read as
// an empty change set. That contract is only worth anything if the resulting report is
// renderable — which it was not until the bucket above was restored.
const r = diffEmitted({ baseline: mf({}), current: mf({}), changedPaths: null });
assert.match(r.errors.join('\n'), /changedPaths must be an array/);
assert.match(formatReport(r), /changedPaths must be an array/);
});
test('a passing result produces no blocks and nothing to acknowledge', () => {
// Remediation must never leak into a green run — it is failure text, not advice.
const report = buildReport(diffEmitted({ baseline: mf({}), current: mf({}), changedPaths: [] }));
assert.deepEqual(report.blocks, []);
assert.deepEqual(report.ackable, []);
assert.equal(formatReport(diffEmitted({ baseline: mf({}), current: mf({}), changedPaths: [] })), '');
});
test('an acked growth produces no block and nothing to acknowledge', () => {
// The contributor already did the thing the remediation asks for; repeating it is noise.
const r = growthOnly({
ack: { version: ACK_VERSION, paths: { 'explore.md': { reason: 'new mode section' } } },
});
assert.ok(r.ok);
const report = buildReport(r);
assert.equal(blockOf(report, 'unacked-growth'), undefined, 'an acknowledged growth is not a failure');
assert.deepEqual(report.ackable, []);
});
test('shrinkage produces no block and nothing to acknowledge', () => {
const r = diffEmitted({
baseline: mf({}), current: mf({}), changedPaths: [],
sizeBaseline: { 'a.md': 9000 }, sizeCurrent: { 'a.md': 8000 },
});
assert.deepEqual(buildReport(r).blocks, [], 'shrinkage is reported in the result, never as failure');
assert.deepEqual(buildReport(r).ackable, []);
});
test('a mixed grown set offers an ack entry only for the unacked files', () => {
const r = diffEmitted({
baseline: mf({}), current: mf({}), changedPaths: [],
sizeBaseline: { 'kept.md': 100, 'loud.md': 100 },
sizeCurrent: { 'kept.md': 200, 'loud.md': 200 },
ack: { version: ACK_VERSION, paths: { 'kept.md': { reason: 'declared' } } },
});
const report = buildReport(r);
const growth = blockOf(report, 'unacked-growth');
assert.equal(growth.count, 1, 'only the unacked one is counted');
assert.deepEqual(growth.items.map((g) => g.name), ['loud.md']);
assert.deepEqual(report.ackable, [{ key: 'loud.md', reason: REMEDIATION.growthReason }],
'the document must key on the unacked file, not the acked one');
});
test('the ack set is capped at the sample limit at limit-1 / limit / limit+1', () => {
// CLAUDE.md's boundary rule. The document must not name rows the report chose not to
// print — a contributor cannot acknowledge a path they were never shown.
const build = (n) => {
const sizeBaseline = {}; const sizeCurrent = {};
for (let i = 0; i < n; i++) {
const k = `g${String(i).padStart(3, '0')}.md`;
sizeBaseline[k] = 100; sizeCurrent[k] = 200;
}
return diffEmitted({ baseline: mf({}), current: mf({}), changedPaths: [], sizeBaseline, sizeCurrent });
};
for (const [n, expected] of [[19, 19], [20, 20], [21, 20]]) {
const report = buildReport(build(n), { sampleLimit: 20 });
assert.equal(blockOf(report, 'unacked-growth').count, n, `count reports all ${n}`);
assert.equal(report.ackable.length, expected, `the document names ${expected} at n=${n}`);
assert.ok(
formatReport(build(n), { sampleLimit: 20 }).includes(REMEDIATION.growthKeyRule),
`the key rule must survive n=${n}`,
);
}
});
test('the new-file cap block carries no ack affordance', () => {
// The cap is NOT ack-able — the fix is extraction. Offering a document here would teach
// a contributor to write an entry that cannot clear the gate, which is worse than the
// silence it replaced.
const r = diffEmitted({
baseline: mf({}), current: mf({}), changedPaths: [],
sizeBaseline: {}, sizeCurrent: { 'new-workflow.md': NEW_FILE_CAP + 1 },
});
const report = buildReport(r);
const cap = blockOf(report, 'new-file-cap');
assert.equal(cap.count, 1);
assert.equal(cap.keyRule, undefined, 'the cap has no key rule because it has no ack');
assert.deepEqual(report.ackable, [], 'the cap must never offer an acknowledgment');
assert.ok(!formatReport(r).includes(REMEDIATION.ackFile), 'and must not point at the ack file');
});
// ─── New-file cap (ADR-1610 Decision point 3, revived after #2724) ───────────
//
// tests/workflow-size-baseline.json used to double as the "has this file been

View File

@@ -34,6 +34,17 @@ const { attributeEmittedPath } = require('./emitted-provenance.cjs');
* shape is public the moment it ships (Hyrum). Loosening later is easy; tightening is not. */
const ACK_VERSION = 1;
/**
* The acknowledgment file, named ONCE (#2778).
*
* 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.
*/
const ACK_FILE = 'tests/emitted-drift-ack.json';
/**
* A brand-new workflow/agent file — absent from the baseline, present now — must
* still stay under the Codex `project_doc_max_bytes` anchor (ADR-1610 Decision
@@ -57,6 +68,64 @@ const ACK_VERSION = 1;
*/
const NEW_FILE_CAP = 32768;
/**
* 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) {
const paths = {};
for (const { key, reason } of entries) paths[key] = { reason };
return JSON.stringify({ version: ACK_VERSION, paths });
}
/**
* Self-serve remediation, as data rather than prose scattered across branches (#2778).
*
* ADR-2719 §3 makes the acknowledgment a *conspicuous declaration a contributor makes
* deliberately*. That only works if the contributor can discover how to make it. Before this,
* the growth branch stated a requirement and withheld the means: it said "without an
* acknowledgment" without naming the file, saying the file does not exist yet, giving the
* schema, or saying which of the two key spaces applies — and it omitted the "do not
* regenerate" instruction too, so the likeliest guess was to go hunting for a baseline file
* #2724 deleted. Observed live on #2543.
*
* Exported as one frozen object rather than loose strings so the observable surface is
* deliberate (Hyrum), and so tests can assert on identity instead of prose — rewording the
* help text must not be a breaking change.
*/
const REMEDIATION = Object.freeze({
ackFile: ACK_FILE,
createIfAbsent: 'create the file if absent — it exists only when something needs acknowledging',
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`). */
growthKeyRule: 'Key on the BARE FILENAME as it appears under gsd-core/workflows/ or agents/',
/** The hash pass keys on the emitted manifest path, which always carries a `/`. */
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 ${ACK_FILE}. If that leaves no entries, delete the file `
+ 'itself — its PRESENCE is the alarm, so an empty one signals nothing.',
ackDocument,
});
/**
* Does a changed repo path satisfy a provenance `sources` entry?
*
@@ -166,8 +235,14 @@ function diffEmitted({
}
if (errors.length) {
return {
// `newFileCapExceeded` MUST be present here. formatReport reads
// `result.newFileCapExceeded.length` unconditionally, so omitting it made this
// early return throw a TypeError instead of rendering the errors it was built to
// report — and this is precisely the path taken when `git diff` failed or a
// 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: [], staleAcks: [], errors, ok: false,
grown: [], shrunk: [], newFileCapExceeded: [], staleAcks: [], errors, ok: false,
};
}
@@ -318,9 +393,88 @@ function diffEmitted({
};
}
/**
* The report as a typed intermediate representation, before any rendering (#2778).
*
* `formatReport`'s output is the human-facing deliverable ADR-2719 §1 sells the design
* on — but CONTRIBUTING.md ("Prohibited: Raw Text Matching on Test Outputs") is explicit
* that a human formatter must expose a structured surface for tests to assert on, so a
* reworded sentence is never a failing test and a passing test never depends on prose.
* `formatReport` is a pure rendering of this; assert on this.
*
* @returns {{ blocks: Array<{kind: string} & object>, ackable: Array<{key: string, reason: string}> }}
*/
function buildReport(result, { sampleLimit = 20 } = {}) {
const blocks = [];
if (result.errors.length) {
blocks.push({
kind: 'errors',
count: result.errors.length,
items: result.errors.slice(0, sampleLimit),
});
}
const unackedGrowth = result.grown.filter((g) => !g.acked);
if (result.unattributable.length) {
blocks.push({
kind: 'unattributable',
count: result.unattributable.length,
items: result.unattributable.slice(0, sampleLimit),
truncated: Math.max(0, result.unattributable.length - sampleLimit),
keyRule: REMEDIATION.rippleKeyRule,
});
}
if (unackedGrowth.length) {
blocks.push({
kind: 'unacked-growth',
count: unackedGrowth.length,
items: unackedGrowth.slice(0, sampleLimit),
keyRule: REMEDIATION.growthKeyRule,
});
}
if (result.newFileCapExceeded.length) {
// Deliberately carries NO ack affordance: the new-file cap is not ack-able, and the
// fix is extraction. Offering a document here would teach an entry that cannot clear
// the gate — worse than the silence it replaced.
blocks.push({
kind: 'new-file-cap',
count: result.newFileCapExceeded.length,
items: result.newFileCapExceeded.slice(0, sampleLimit),
});
}
if (result.staleAcks.length) {
blocks.push({
kind: 'stale-acks',
count: result.staleAcks.length,
items: result.staleAcks.slice(0, sampleLimit),
fix: REMEDIATION.staleAckFix,
});
}
// 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.
const ackable = [
...result.unattributable.slice(0, sampleLimit)
.map((u) => ({ key: u.rel, reason: REMEDIATION.rippleReason })),
...unackedGrowth.slice(0, sampleLimit)
.map((g) => ({ key: g.name, reason: REMEDIATION.growthReason })),
];
return { blocks, ackable };
}
/**
* Render a report as the failure message ADR-2719 §1 specifies — it sells the whole
* design on this text, so it is a deliverable, not a detail.
* design on this text, so it is a deliverable, not a detail. Pure rendering of
* `buildReport`; tests assert on that IR, not on these sentences.
*/
function formatReport(result, { sampleLimit = 20 } = {}) {
const parts = [];
@@ -348,8 +502,7 @@ function formatReport(result, { sampleLimit = 20 } = {}) {
(result.unattributable.length > sampleLimit
? `\n …and ${result.unattributable.length - sampleLimit} more`
: '') +
'\n\nIf this ripple is intended, record it in tests/emitted-drift-ack.json naming each\n' +
'path and why. Do NOT regenerate anything to silence this.',
`\n\n${REMEDIATION.rippleKeyRule}.`,
);
}
@@ -358,7 +511,8 @@ function formatReport(result, { sampleLimit = 20 } = {}) {
const list = unackedGrowth.slice(0, sampleLimit)
.map((g) => ` ${g.name} grew ${g.delta} bytes (${g.from} -> ${g.to})`);
parts.push(
`${unackedGrowth.length} file(s) grew without an acknowledgment:\n${list.join('\n')}`,
`${unackedGrowth.length} file(s) grew without an acknowledgment:\n${list.join('\n')}\n\n` +
`${REMEDIATION.growthKeyRule}.`,
);
}
@@ -374,7 +528,20 @@ function formatReport(result, { sampleLimit = 20 } = {}) {
parts.push(
`${result.staleAcks.length} stale acknowledgment(s) — the ripple they explained is gone, ` +
'so they must be deleted (an ack that outlives its ripple pre-clears the next one):\n ' +
result.staleAcks.slice(0, sampleLimit).join('\n '),
result.staleAcks.slice(0, sampleLimit).join('\n ') +
`\n\n${REMEDIATION.staleAckFix}`,
);
}
// The remedy, once, at the end — one file, one document, one paste.
const { ackable } = buildReport(result, { sampleLimit });
if (ackable.length) {
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,
);
}
@@ -383,9 +550,12 @@ function formatReport(result, { sampleLimit = 20 } = {}) {
module.exports = {
ACK_VERSION,
ACK_FILE,
NEW_FILE_CAP,
REMEDIATION,
sourceSatisfiedBy,
parseAck,
diffEmitted,
buildReport,
formatReport,
};