Files
msd-core/tests/emitted-attribution.test.cjs
Tom Boucher fa41bfec5c enhance(#3942): the emitted-drift ack is PR-lifetime data — move it to a commit trailer (#3954)
* test(#3942): failing-first suite for the emitted-drift ack commit trailer

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

The two rows that carry the design's load:

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

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

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

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

Refs #3942

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

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

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

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

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

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

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

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

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

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

Refs #3942

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

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

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

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

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

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

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

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

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

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

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

lint:ci exit 0.

Refs #3942

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

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

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

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

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

Doc-only, root-level markdown.

Refs #3942

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

---------

Co-authored-by: sim <sim@local>
2026-08-27 17:28:39 -04:00

2543 lines
120 KiB
JavaScript

// docs-guard-exempt: 'docs/README.md' and 'docs/tests/...' are synthetic changedPaths/fixture-path strings, never read as content.
'use strict';
/**
* emitted-attribution.test.cjs — the differential attribution check (#2723/#2724,
* ADR-2719 §1/§3/§4/§5/§6, epic #2719 Phases 3-4).
*
* This is the SOLE gate for emitted-artifact propagation (#2724, Phase 4 cutover).
* `tests/golden-install-parity.test.cjs` — the committed path->hash fixtures it dual-ran
* beside during Phase 3 — is deleted. The dual-run window it ran on real PRs (#2412,
* #2566, #2728) surfaced two real defects (#2750, #2760), both now fixed and merged; no
* disagreement between the two checks was ever observed once both landed correctly.
*
* The law: every emitted path whose hash moved between `next` HEAD and PR HEAD must be
* attributable — through the Phase 2 table — to a path the PR actually changed.
* Unattributable deltas fail with the paths NAMED. The only way through is a committed
* acknowledgment, never a flag (a contributor facing a red gate sets a flag, which is
* what UPDATE_GOLDEN=1 used to be, before #2724 removed it).
*
* Structure: the pure law is exercised against synthetic manifests, which is what makes
* the four failing-first criteria practical to assert at all — and then the final test
* runs that same law against the REAL tree: 19 actual installer spawns for the current
* side, `resolveBaseline()` (env / cache / in-job build) for the baseline side, real
* `git diff` for the changed paths, and the real `tests/emitted-drift-ack.json`.
*
* That last test is load-bearing. Without it this file would be interface-only — every
* assertion true of hand-built inputs and none of the repo — which is the
* promised-but-not-built failure this epic keeps finding in its own predecessors.
* Verified by injecting an uncommitted edit to a shipped workflow: emitted output moves
* but the path never appears in `git diff origin/next...HEAD`, and the check names all
* 18 affected emitted paths.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const crypto = require('node:crypto');
const { execFileSync } = require('node:child_process');
const fc = require('fast-check');
const { cleanup, createTempDir } = require('./helpers.cjs');
const { BUILD_SCRIPT, buildParityManifest, buildInstallTree, PKG_VERSION } = require('./helpers/install-shared.cjs');
const {
resolveChangedPaths,
resolveBase,
baseRefCandidates,
buildBaselineAtRef,
currentManifests,
currentSizes,
readAckTrailers,
baselineFamilyNamesAtRef,
MANIFEST_FAMILIES,
MINIMUM_MANIFEST_FAMILIES,
REGISTRY_SIGNAL_PATHS,
FAMILY_REASON,
touchesRuntimeRegistry,
reconcileFamilies,
safeDirArgs,
measuredPackageVersion,
WORKTREE_TIMEOUT_MS,
BUILD_LIB_TIMEOUT_MS,
BUILD_TIMEOUT_MS,
CHUNK_TIMEOUT_CEILING_MS,
} = require('./helpers/emitted-runtime.cjs');
const { EXPECTED_MANIFEST_COUNT, loadManifests } = require('./helpers/emitted-provenance.cjs');
// ADR-3942 §6: scripts/lint-emitted-drift-ack.cjs (the legacy JSON-ack-file pre-merge
// lint + guard-no-ack-on-next + the fragment-sweep machinery it backed) is deleted —
// there is no committed ack file/fragment left for it to lint, sweep, or guard. Every
// test that required it below is gone with it.
const {
NEW_FILE_CAP,
REMEDIATION,
sourceSatisfiedBy,
diffEmitted,
buildReport,
formatReport,
ACK_TRAILER_HASH,
ACK_TRAILER_GROWTH,
INVISIBLE,
normalizeAckReason,
parseAckTrailers,
renderAckTrailer,
} = require('./helpers/emitted-diff.cjs');
const {
BASELINE_ENV,
BASELINE_VERSION,
resolveBaseline,
} = require('./helpers/emitted-baseline.cjs');
const SHA_A = 'a'.repeat(40);
const SHA_B = 'b'.repeat(40);
/** This checkout's own root — used to build a synthetic commit in-place (see
* `buildBaselineAtRef resolves a baseline via the in-job build...` below). */
const REPO_ROOT = path.join(__dirname, '..');
/** A real emitted key + its real source, so rows assert the shape production uses. */
const WORKFLOW_KEY = 'gsd-core/workflows/plan-phase.md';
const WORKFLOW_SRC = 'gsd-core/workflows/plan-phase.md';
const SKILL_KEY = 'skills/gsd-add-tests/SKILL.md';
const SKILL_SRC = 'commands/gsd/add-tests.md';
const mf = (obj) => ({ claude: obj });
/**
* Build an `ackHash`/`ackGrowth` Map from the old `{ path: { reason } | reason }` shape
* most fixtures below were already written in (#3942 changed `diffEmitted`'s ack
* parameters from a parsed JSON document to a pre-parsed `Map<string, {reason}>` per
* space — see `tests/helpers/emitted-diff.cjs::diffEmitted`'s doc comment).
*/
const mapOf = (paths) => new Map(
Object.entries(paths).map(([k, v]) => [k, typeof v === 'string' ? { reason: v } : v]),
);
// ─── Attribution: the conservation law ───────────────────────────────────────
test('unchanged hashes are not reported', () => {
const r = diffEmitted({
baseline: mf({ [WORKFLOW_KEY]: 'aaa' }),
current: mf({ [WORKFLOW_KEY]: 'aaa' }),
changedPaths: [],
});
assert.equal(r.moved, 0);
assert.equal(r.attributed.length, 0);
assert.equal(r.unattributable.length, 0);
assert.ok(r.ok);
});
test('a moved hash whose source changed is attributed', () => {
const r = diffEmitted({
baseline: mf({ [WORKFLOW_KEY]: 'aaa' }),
current: mf({ [WORKFLOW_KEY]: 'bbb' }),
changedPaths: [WORKFLOW_SRC],
});
assert.equal(r.moved, 1);
assert.equal(r.unattributable.length, 0);
assert.equal(r.attributed.length, 1);
assert.equal(r.attributed[0].via, WORKFLOW_SRC);
assert.ok(r.ok);
});
test('a trailing-slash source entry matches by prefix, segment-aware', () => {
// Kimi's root agent aggregates all of agents/ — a Phase 2 prefix source.
assert.equal(sourceSatisfiedBy('agents/', new Set(['agents/gsd-planner.md'])), 'agents/gsd-planner.md');
// Hostile: a bare startsWith would over-attribute here. It must NOT match.
assert.equal(sourceSatisfiedBy('agents/', new Set(['agentsfoo/x.md'])), null);
// Exact entries compare exactly.
assert.equal(sourceSatisfiedBy('a/b.md', new Set(['a/b.md'])), 'a/b.md');
assert.equal(sourceSatisfiedBy('a/b.md', new Set(['a/b.md.bak'])), null);
const r = diffEmitted({
baseline: { kimi: { 'agents/gsd.yaml': 'aaa' } },
current: { kimi: { 'agents/gsd.yaml': 'bbb' } },
changedPaths: ['agents/gsd-planner.md'],
});
assert.equal(r.unattributable.length, 0, 'prefix source should attribute');
assert.equal(r.attributed[0].via, 'agents/gsd-planner.md');
});
test('a moved hash nothing explains is unattributable and named', () => {
const r = diffEmitted({
baseline: mf({ [WORKFLOW_KEY]: 'aaa' }),
current: mf({ [WORKFLOW_KEY]: 'bbb' }),
changedPaths: ['README.md'],
});
assert.equal(r.unattributable.length, 1);
const u = r.unattributable[0];
assert.equal(u.rel, WORKFLOW_KEY);
assert.equal(u.runtime, 'claude');
assert.equal(u.ruleId, 'gsd-core-verbatim');
assert.deepEqual(u.expectedSources, [WORKFLOW_SRC]);
assert.ok(!r.ok);
// ADR-2719 §1 sells the design on this message — it is a deliverable.
const msg = formatReport(r);
assert.match(msg, /changed that nothing in this diff explains/);
assert.ok(msg.includes(WORKFLOW_KEY));
assert.ok(msg.includes(WORKFLOW_SRC), 'the message must say what WOULD have explained it');
});
test('synthesized paths are exempt from attribution', () => {
const r = diffEmitted({
baseline: mf({ 'gsd-core/VERSION': 'aaa' }),
current: mf({ 'gsd-core/VERSION': 'bbb' }),
changedPaths: [],
});
assert.equal(r.unattributable.length, 0, 'install-time state can never be unexplained');
assert.equal(r.attributed[0].via, '<synthesized: exempt>');
assert.ok(r.ok);
});
test('code-derived paths attribute to their emitting source file', () => {
// Phase 2 deliberately refused to mark these exempt; this is why.
const r = diffEmitted({
baseline: { cline: { '.clinerules/gsd.md': 'aaa' } },
current: { cline: { '.clinerules/gsd.md': 'bbb' } },
changedPaths: ['src/runtime-hooks-surface.cts'],
});
assert.equal(r.unattributable.length, 0);
assert.equal(r.attributed[0].via, 'src/runtime-hooks-surface.cts');
const blind = diffEmitted({
baseline: { cline: { '.clinerules/gsd.md': 'aaa' } },
current: { cline: { '.clinerules/gsd.md': 'bbb' } },
changedPaths: ['README.md'],
});
assert.equal(blind.unattributable.length, 1, 'had these been exempt, this ripple would be invisible forever');
});
test('an added emitted key is a ripple too', () => {
const r = diffEmitted({
baseline: mf({}),
current: mf({ [WORKFLOW_KEY]: 'bbb' }),
changedPaths: ['README.md'],
});
assert.equal(r.moved, 1);
assert.equal(r.unattributable.length, 1);
assert.equal(r.unattributable[0].change, 'added');
});
test('a removed emitted key is reported', () => {
const r = diffEmitted({
baseline: mf({ [WORKFLOW_KEY]: 'aaa' }),
current: mf({}),
changedPaths: [WORKFLOW_SRC],
});
assert.equal(r.removed.length, 1);
assert.equal(r.removed[0].change, 'removed');
assert.equal(r.unattributable.length, 0, 'the deletion is explained by the source change');
});
test('moved hashes with no changed paths are all unattributable', () => {
const r = diffEmitted({
baseline: mf({ [WORKFLOW_KEY]: 'aaa', [SKILL_KEY]: 'ccc' }),
current: mf({ [WORKFLOW_KEY]: 'bbb', [SKILL_KEY]: 'ddd' }),
changedPaths: [],
});
assert.equal(r.unattributable.length, 2, 'emitted output moving with zero source changes is a real finding');
assert.ok(!r.ok);
});
test('a failed git diff is an error, not an empty change set', () => {
// Treating a git failure as "nothing changed" would make everything unattributable —
// a failure storm that reads exactly like a real finding.
const r = diffEmitted({ baseline: mf({}), current: mf({}), changedPaths: null });
assert.ok(!r.ok);
assert.match(r.errors.join('\n'), /changedPaths must be an array/);
});
// ─── Transform attribution (#2757 defect 1) ──────────────────────────────────
//
// A `kind: 'derived'` rule's bytes can legitimately move for a second reason the
// `sources`-only design cannot express: the TRANSFORM code that generates the
// derived artifact changed, not the source it derives from. Replays the #2566
// shape verbatim: 16 emitted `agents/*.toml` moved, the diff touches
// `src/runtime-artifact-conversion.cts` and zero `agents/*.md`.
test('a derived path explained only by a transform change is attributed (#2566 shape)', () => {
const moved = {};
const base = {};
const agentNames = [
'gsd-planner', 'gsd-executor', 'gsd-verifier', 'gsd-code-reviewer',
'gsd-security-auditor', 'gsd-nyquist-auditor', 'gsd-doc-writer', 'gsd-roadmapper',
'gsd-phase-researcher', 'gsd-pattern-mapper', 'gsd-plan-checker', 'gsd-debugger',
'gsd-ui-checker', 'gsd-eval-planner', 'gsd-framework-selector', 'gsd-code-fixer',
];
assert.equal(agentNames.length, 16, 'the #2566 reproduction is 16 emitted .toml files');
for (const name of agentNames) {
base[`agents/${name}.toml`] = `before-${name}`;
moved[`agents/${name}.toml`] = `after-${name}`;
}
// Before the fix, `agents-toml-derived` has no `transforms` field: this must fail.
const withoutTransformChange = diffEmitted({
baseline: mf(base),
current: mf(moved),
// Deliberately NOT `src/runtime-artifact-conversion.cts` — proves the negative
// (an unrelated source change does not accidentally attribute).
changedPaths: ['README.md'],
});
assert.equal(withoutTransformChange.unattributable.length, 16);
assert.ok(!withoutTransformChange.ok);
// The actual #2566 shape: the diff touches the transform, not any agents/*.md.
const withTransformChange = diffEmitted({
baseline: mf(base),
current: mf(moved),
changedPaths: ['src/runtime-artifact-conversion.cts'],
});
assert.equal(
withTransformChange.unattributable.length, 0,
'a transform-only change must attribute every moved derived path',
);
assert.equal(withTransformChange.attributed.length, 16);
for (const rec of withTransformChange.attributed) {
assert.equal(rec.via, 'src/runtime-artifact-conversion.cts');
assert.equal(rec.ruleId, 'agents-toml-derived');
}
assert.ok(withTransformChange.ok);
});
test('an identity-classified agent .md moved by a transform-only change is unattributable (#2757 defect 2, pre-fix shape)', () => {
// Reproduces the maintainer's follow-up: codex's agents/*.md hash moves without any
// agents/*.md in the diff. Whether this attributes now depends entirely on whether
// `agents-verbatim` has been reclassified to `derived` with `transforms` declared —
// this test asserts the REAL, current behavior of the shipped table, so it doubles
// as the defect-2 regression once the fix lands (the id in the rule table has not
// changed, only `kind`/`transforms`, so this same test proves both "was broken" and
// "is fixed" depending on which commit runs it).
const r = diffEmitted({
baseline: { codex: { 'agents/gsd-nyquist-auditor.md': 'before' } },
current: { codex: { 'agents/gsd-nyquist-auditor.md': 'after' } },
changedPaths: ['src/runtime-artifact-conversion.cts'],
});
assert.equal(r.unattributable.length, 0, 'a declared transform must explain the moved identity-family path');
assert.equal(r.attributed[0].ruleId, 'agents-verbatim');
assert.equal(r.attributed[0].via, 'src/runtime-artifact-conversion.cts');
assert.ok(r.ok);
});
test('a moved derived path with neither source nor transform in the diff still fails, named', () => {
const r = diffEmitted({
baseline: mf({ 'agents/gsd-planner.toml': 'before' }),
current: mf({ 'agents/gsd-planner.toml': 'after' }),
changedPaths: ['docs/README.md'],
});
assert.equal(r.unattributable.length, 1);
assert.equal(r.unattributable[0].rel, 'agents/gsd-planner.toml');
assert.deepEqual(r.unattributable[0].expectedSources, ['agents/gsd-planner.md']);
assert.ok(
r.unattributable[0].expectedTransforms.includes('src/runtime-artifact-conversion.cts'),
'the message must be able to say what transform WOULD have explained it too',
);
const msg = formatReport(r);
assert.ok(msg.includes('agents/gsd-planner.toml'));
assert.ok(msg.includes('src/runtime-artifact-conversion.cts'), 'the transform hint must appear in the report');
});
test('an unrelated src file does not attribute a moved derived path (transforms list stays narrow)', () => {
// The review's own risk: a transform list that is too broad silently excuses real
// ripples. src/state-document.cts has nothing to do with agent conversion.
const r = diffEmitted({
baseline: mf({ 'agents/gsd-planner.toml': 'before' }),
current: mf({ 'agents/gsd-planner.toml': 'after' }),
changedPaths: ['src/state-document.cts'],
});
assert.equal(r.unattributable.length, 1, 'an unrelated src/*.cts file must NOT excuse the ripple');
assert.ok(!r.ok);
});
test('bin/install.js alone does not attribute a moved agent artifact (deliberate exclusion)', () => {
// bin/install.js implements the final splice (injectEffortFrontmatter,
// generateCodexAgentToml) but is deliberately excluded from AGENT_TRANSFORM_SRCS —
// at 13k+ lines spanning every installer concern, including it would be the blanket
// escape hatch ADR-2719 warns against. This proves the exclusion holds in the
// shipped table, not just in the design doc.
const r = diffEmitted({
baseline: mf({ 'agents/gsd-planner.toml': 'before' }),
current: mf({ 'agents/gsd-planner.toml': 'after' }),
changedPaths: ['bin/install.js'],
});
assert.equal(r.unattributable.length, 1, 'bin/install.js alone must not attribute — it is not a declared transform');
assert.ok(!r.ok);
});
test('a moved path with a source match wins over an also-present transform match (deterministic via)', () => {
const r = diffEmitted({
baseline: mf({ 'agents/gsd-planner.toml': 'before' }),
current: mf({ 'agents/gsd-planner.toml': 'after' }),
changedPaths: ['agents/gsd-planner.md', 'src/runtime-artifact-conversion.cts'],
});
assert.equal(r.unattributable.length, 0);
assert.equal(r.attributed[0].via, 'agents/gsd-planner.md', 'sources are checked before transforms');
});
test('a transform-explained converter ripple still needs no ack (transforms are a first-class attribution, not a workaround)', () => {
// Contrast with 'a converter change fails without an ack and passes with one' above:
// THAT test simulates a family with NO transforms declared, so it correctly still
// requires an ack. agents-toml-derived DOES declare a transform, so the equivalent
// ripple must attribute directly, with no ack needed at all.
const moved = {};
const base = {};
for (let i = 0; i < 5; i++) {
base[`agents/gsd-fixture-${i}.toml`] = `h${i}`;
moved[`agents/gsd-fixture-${i}.toml`] = `x${i}`;
}
const r = diffEmitted({
baseline: mf(base),
current: mf(moved),
changedPaths: ['src/runtime-artifact-conversion.cts'],
});
assert.equal(r.unattributable.length, 0);
assert.equal(r.acked.length, 0, 'no ack was needed — the transform explains it directly');
assert.ok(r.ok);
});
test('an unattributable-by-table path surfaces as an error', () => {
const r = diffEmitted({
baseline: mf({ 'totally/unknown/thing.md': 'aaa' }),
current: mf({ 'totally/unknown/thing.md': 'bbb' }),
changedPaths: [],
});
assert.ok(!r.ok);
assert.match(r.errors.join('\n'), /no rule matches/);
assert.equal(r.unattributable.length, 0, 'a table hole is an error, not a silent skip');
});
// ─── Acknowledgment file ─────────────────────────────────────────────────────
test('an acked ripple passes and is echoed', () => {
const ackHash = mapOf({ [WORKFLOW_KEY]: { reason: 'converter change, #2723' } });
const r = diffEmitted({
baseline: mf({ [WORKFLOW_KEY]: 'aaa' }),
current: mf({ [WORKFLOW_KEY]: 'bbb' }),
changedPaths: ['README.md'],
ackHash,
});
assert.equal(r.unattributable.length, 0);
assert.equal(r.acked.length, 1);
assert.equal(r.acked[0].reason, 'converter change, #2723');
assert.ok(r.ok);
});
test('a stale ack entry fails', () => {
// An ack that outlives its ripple pre-clears the NEXT one on that path.
const ackHash = mapOf({ [WORKFLOW_KEY]: { reason: 'old' } });
const r = diffEmitted({
baseline: mf({ [WORKFLOW_KEY]: 'aaa' }),
current: mf({ [WORKFLOW_KEY]: 'aaa' }),
changedPaths: [],
ackHash,
});
assert.deepEqual(r.staleAcks, [{ key: WORKFLOW_KEY, space: 'hash' }]);
assert.ok(!r.ok);
assert.match(formatReport(r), /stale acknowledgment/);
});
test('an ack Map entry is trusted verbatim — reason validation moved upstream to parseAckTrailers (#3942)', () => {
// Pre-#3942, `diffEmitted` parsed a raw ack DOCUMENT itself (`parseAck`), so it owned
// the "no non-empty reason" rejection this test used to assert. `ackHash`/`ackGrowth`
// are now pre-parsed `Map<string,{reason}>`s (see `diffEmitted`'s doc comment), and
// that validation moved to `parseAckTrailers`, whose own coverage
// (`tests/emitted-ack-trailer.test.cjs`) is where an empty/missing reason is rejected.
// `diffEmitted` itself now trusts a Map entry it is handed — including a technically
// empty-string reason — rather than re-validating it, so this proves the boundary
// moved rather than disappeared.
for (const reason of ['', ' ']) {
const r = diffEmitted({
baseline: mf({ [WORKFLOW_KEY]: 'aaa' }),
current: mf({ [WORKFLOW_KEY]: 'bbb' }),
changedPaths: [],
ackHash: mapOf({ [WORKFLOW_KEY]: { reason } }),
});
assert.equal(r.unattributable.length, 0, `reason=${JSON.stringify(reason)} must still excuse the ripple`);
assert.equal(r.acked[0].reason, reason, 'the reason is carried through verbatim, unvalidated');
assert.ok(r.ok);
}
});
test('an absent ack means no acks', () => {
const r = diffEmitted({
baseline: mf({ [WORKFLOW_KEY]: 'aaa' }),
current: mf({ [WORKFLOW_KEY]: 'bbb' }),
changedPaths: [WORKFLOW_SRC],
});
assert.equal(r.errors.length, 0);
assert.ok(r.ok, 'the healthy steady state is no ack at all');
});
test('a live ack and a stale ack together: only the stale one is named', () => {
const ackHash = mapOf({
[WORKFLOW_KEY]: { reason: 'live ripple' },
[SKILL_KEY]: { reason: 'stale' },
});
const r = diffEmitted({
baseline: mf({ [WORKFLOW_KEY]: 'aaa', [SKILL_KEY]: 'ccc' }),
current: mf({ [WORKFLOW_KEY]: 'bbb', [SKILL_KEY]: 'ccc' }),
changedPaths: [],
ackHash,
});
assert.deepEqual(r.staleAcks, [{ key: SKILL_KEY, space: 'hash' }], 'the live one must not be named');
});
// ─── normalizeAckReason / INVISIBLE (#3942 anti-gaming prose normalization) ──
//
// Both are on the live path via `parseAckTrailers`'s own same-key dedup
// (tests/helpers/emitted-diff.cjs) but had ZERO test references repo-wide before this
// block — the old suite's coverage (origin/next:1069, :1084, :1307, :1322) called
// `ackProse`/`ACK_INVISIBLE` from `scripts/lint-emitted-drift-ack.cjs`, which ADR-3942
// §6 deletes outright (the legacy JSON-ack-file lint/guard/sweep script this repo no
// longer has any use for), so those tests are NOT restorable verbatim. This block is a
// from-scratch equivalent against the CURRENT, sole surface: `INVISIBLE` and
// `normalizeAckReason`, both exported directly from `tests/helpers/emitted-diff.cjs`.
// Each assertion is written so that removing any ONE codepoint from `INVISIBLE`'s
// definition fails a specific test, not just the aggregate.
describe('normalizeAckReason / INVISIBLE (#3942 anti-gaming prose normalization)', () => {
// The exact six codepoints INVISIBLE strips (tests/helpers/emitted-diff.cjs): soft
// hyphen, the zero-width family, word joiner, BOM. Individually named (not just
// looped) so a single dropped codepoint fails its OWN assertion, not a shared one.
const INVISIBLE_CODEPOINTS = {
'U+00AD SOFT HYPHEN': 0x00AD,
'U+200B ZERO WIDTH SPACE': 0x200B,
'U+200C ZERO WIDTH NON-JOINER': 0x200C,
'U+200D ZERO WIDTH JOINER': 0x200D,
'U+2060 WORD JOINER': 0x2060,
'U+FEFF ZERO WIDTH NO-BREAK SPACE (BOM)': 0xFEFF,
};
for (const [label, cp] of Object.entries(INVISIBLE_CODEPOINTS)) {
test(`normalizeAckReason strips ${label}`, () => {
const ch = String.fromCodePoint(cp);
assert.equal(normalizeAckReason(`a${ch}b`), 'ab', `${label} must be stripped, not left in place`);
assert.equal(
normalizeAckReason(`same${ch} reason`), 'same reason',
`${label} inserted mid-word must not survive into the normalized reason`,
);
});
}
test('INVISIBLE.test() agrees with normalizeAckReason for every one of the six codepoints (regex/function parity)', () => {
for (const cp of Object.values(INVISIBLE_CODEPOINTS)) {
const ch = String.fromCodePoint(cp);
INVISIBLE.lastIndex = 0; // global-flagged regex — .test() is stateful, reset before use
const matches = INVISIBLE.test(ch);
INVISIBLE.lastIndex = 0;
assert.equal(matches, true, `INVISIBLE regex must itself match U+${cp.toString(16).toUpperCase().padStart(4, '0')}`);
}
});
test('a codepoint OUTSIDE the six is left alone by normalizeAckReason (only these six are invisible, not "any non-ASCII")', () => {
// U+00E9 (é) is a real, visible character with no whitespace/invisible meaning —
// proves normalizeAckReason is not accidentally stripping a broader class than the
// six named codepoints.
assert.equal(normalizeAckReason('café reason'), 'café reason');
});
test('whitespace runs collapse to a single space', () => {
assert.equal(normalizeAckReason('doubled internal space'), 'doubled internal space');
assert.equal(normalizeAckReason('tab\ttab'), 'tab tab');
assert.equal(normalizeAckReason('multi\n\n\nline'), 'multi line');
});
test('leading/trailing whitespace is trimmed', () => {
assert.equal(normalizeAckReason(' leading and trailing space '), 'leading and trailing space');
assert.equal(normalizeAckReason('\t\ttabbed on both ends\t\t'), 'tabbed on both ends');
});
test('CRLF collapses the same as a single space, matching LF', () => {
assert.equal(normalizeAckReason('crlf\r\nline'), 'crlf line');
assert.equal(
normalizeAckReason('same\r\nwords'), normalizeAckReason('same\nwords'),
'CRLF and LF must normalize identically for the same underlying words',
);
});
test('an invisible codepoint cannot fake a distinct reason once whitespace-collapsed and trimmed together', () => {
const zwsp = String.fromCodePoint(0x200B);
assert.equal(
normalizeAckReason(` same${zwsp} reason `),
normalizeAckReason('same reason'),
'stripping the invisible codepoint and collapsing the surrounding whitespace run must land on the identical string',
);
});
test('property: normalizeAckReason never leaves an INVISIBLE-matched codepoint in its output, seeded (#3942)', () => {
fc.assert(
fc.property(fc.string({ minLength: 0, maxLength: 60 }), (s) => {
const out = normalizeAckReason(s);
INVISIBLE.lastIndex = 0;
assert.equal(INVISIBLE.test(out), false, 'no invisible codepoint may survive normalization');
INVISIBLE.lastIndex = 0;
assert.equal(out, out.trim(), 'output must already be trimmed (idempotent under trim)');
assert.doesNotMatch(out, /\s{2,}/, 'no whitespace run of 2+ may survive collapsing');
}),
{ seed: 3942, numRuns: 300 },
);
});
test('property: normalizeAckReason is idempotent — normalizing twice equals normalizing once, seeded (#3942)', () => {
fc.assert(
fc.property(fc.string({ minLength: 0, maxLength: 60 }), (s) => {
const once = normalizeAckReason(s);
const twice = normalizeAckReason(once);
assert.equal(once, twice);
}),
{ seed: 3942, numRuns: 300 },
);
});
});
// ADR-3942 removed the whole ack-persistence-lifecycle mechanism this section tested: §2
// makes spentness structural (a trailer scoped to merge-base..HEAD has no base-side copy
// to compare against, so nothing can ever be "spent"), which deletes diffEmitted's
// baseAck/spentAcks parameters and return field; §1/§6 delete the JSON-ack-file reader
// (parseAck, mergeAckSources) and the legacy pre-merge lint / guard-no-ack-on-next /
// fragment-sweep script this section's tests required (deleted entirely, per §6).
// Every test that lived here exercised one of those now-nonexistent mechanisms.
// ─── The acceptance criteria, failing-first ──────────────────────────────────
test('a ripple names the unexplained path and not the explained one', () => {
// #2723 AC: "edit one source file, corrupt an unrelated emitted file, assert the
// check names the unattributable paths."
const r = diffEmitted({
baseline: mf({ [WORKFLOW_KEY]: 'aaa', [SKILL_KEY]: 'ccc' }),
current: mf({ [WORKFLOW_KEY]: 'bbb', [SKILL_KEY]: 'ddd' }),
changedPaths: [WORKFLOW_SRC], // only the workflow source was edited
});
assert.equal(r.unattributable.length, 1);
assert.equal(r.unattributable[0].rel, SKILL_KEY, 'the unrelated emitted file is the finding');
assert.equal(r.attributed.length, 1);
assert.equal(r.attributed[0].rel, WORKFLOW_KEY, 'the explained one must NOT be reported');
assert.ok(!r.ok);
});
test('a converter change fails without an ack and passes with one', () => {
// #2723 AC: "simulate a legitimate converter change: assert it fails without an ack
// entry and passes with one." A converter edit moves emitted bytes for files whose
// sources nobody touched — ADR-2264's "~5% git cannot review".
const moved = {};
const base = {};
for (let i = 0; i < 25; i++) {
base[`skills/gsd-cmd-${i}/SKILL.md`] = `h${i}`;
moved[`skills/gsd-cmd-${i}/SKILL.md`] = `x${i}`;
}
const changedPaths = ['src/runtime-artifact-conversion.cts'];
const without = diffEmitted({ baseline: mf(base), current: mf(moved), changedPaths });
assert.equal(without.unattributable.length, 25);
assert.ok(!without.ok, 'a converter change must not pass silently');
const paths = {};
for (const rel of Object.keys(moved)) paths[rel] = { reason: 'converter rewrite, ADR-2719' };
const withAck = diffEmitted({
baseline: mf(base), current: mf(moved), changedPaths,
ackHash: mapOf(paths),
});
assert.equal(withAck.unattributable.length, 0);
assert.equal(withAck.acked.length, 25);
assert.ok(withAck.ok);
});
test('growth is reported with its exact byte delta and needs an ack', () => {
// ADR-2719 must-have 6, added by an /adr-phase-coverage audit precisely because
// scope item 5 promised it and no criterion asserted it.
const sizeBaseline = { 'verify-work.md': 10000, 'plan-phase.md': 8000 };
const sizeCurrent = { 'verify-work.md': 11247, 'plan-phase.md': 8000 };
const without = diffEmitted({
baseline: mf({}), current: mf({}), changedPaths: [], sizeBaseline, sizeCurrent,
});
assert.equal(without.grown.length, 1);
assert.deepEqual(without.grown[0], {
name: 'verify-work.md', from: 10000, to: 11247, delta: 1247, acked: false,
});
assert.ok(!without.ok, 'unacked growth must block');
assert.match(formatReport(without), /verify-work\.md grew 1247 bytes/);
const withAck = diffEmitted({
baseline: mf({}), current: mf({}), changedPaths: [], sizeBaseline, sizeCurrent,
ackGrowth: mapOf({ 'verify-work.md': { reason: 'new UAT section' } }),
});
assert.equal(withAck.grown[0].acked, true);
assert.ok(withAck.ok);
});
test('an ack consumed by size growth alone is not reported as stale', () => {
// Ordering regression: stale-ack detection must run AFTER the size pass. Computing it
// between the hash pass and the size pass reports a legitimate growth ack as stale —
// a false failure that would push contributors to delete the very ack that is working.
const r = diffEmitted({
baseline: mf({}),
current: mf({}),
changedPaths: [],
sizeBaseline: { 'verify-work.md': 10000 },
sizeCurrent: { 'verify-work.md': 11247 },
ackGrowth: mapOf({ 'verify-work.md': { reason: 'new UAT section' } }),
});
assert.deepEqual(r.staleAcks, [], 'a growth-consumed ack is live, not stale');
assert.equal(r.grown[0].acked, true);
assert.ok(r.ok);
});
test('shrinkage is reported but needs no ack', () => {
const r = diffEmitted({
baseline: mf({}), current: mf({}), changedPaths: [],
sizeBaseline: { 'a.md': 9000 }, sizeCurrent: { 'a.md': 8000 },
});
assert.deepEqual(r.shrunk, [{ name: 'a.md', from: 9000, to: 8000, delta: 1000 }]);
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, space: 'growth' },
], 'the ack entry must be keyed on the file that actually grew, tagged with its trailer space');
});
test('the renderer emits the trailer instructions, the taught line, and the do-not-regenerate line', () => {
// The one place rendered text is the object of the test: proving the IR above actually
// reaches the contributor. Everything it asserts is an identity comparison against the
// frozen surface, so rewording any sentence cannot fail this.
const msg = formatReport(growthOnly());
assert.ok(msg.includes('explore.md grew 2103 bytes (11127 -> 13230)'), 'the delta still leads');
assert.ok(msg.includes(REMEDIATION.addTrailerGrowth), 'the message must teach the trailer, not a file');
assert.ok(!msg.includes(REMEDIATION.addTrailerHash), 'a growth-only report must not teach the hash trailer');
assert.ok(msg.includes(REMEDIATION.growthKeyRule), 'it must state the bare-filename key rule');
assert.ok(msg.includes(REMEDIATION.doNotRegenerate), 'it must say not to regenerate');
assert.ok(
msg.includes(renderAckTrailer(ACK_TRAILER_GROWTH, 'explore.md', REMEDIATION.growthReason)),
'the printed trailer line must be the one the IR describes',
);
});
test('the trailer the report teaches round-trips through parseAckTrailers (#3942)', () => {
// The divergence killer. A report that teaches a grammar the parser rejects is worse
// than no report: the contributor follows it, is rejected anyway, and now distrusts the
// gate. This pins the taught line to the accepted grammar in one assertion — mirrors the
// pre-#3942 "the document the report teaches is accepted by parseAck" test, updated for
// the trailer grammar.
const taughtLine = renderAckTrailer(ACK_TRAILER_GROWTH, 'explore.md', 'a real reason');
const rawValue = taughtLine.slice(ACK_TRAILER_GROWTH.length + 2); // strip "<name>: "
const { growth, errors } = parseAckTrailers({ growth: [rawValue] });
assert.deepEqual(errors, [], 'the taught line must parse with zero errors');
assert.equal(growth.get('explore.md').reason, 'a real reason');
// And it must actually clear the gate it is offered to clear.
const r = growthOnly({ ackGrowth: growth });
assert.equal(r.grown[0].acked, true);
assert.deepEqual(r.staleAcks, []);
assert.ok(r.ok, 'following the printed instructions must turn the lane green');
});
test('REMEDIATION.ackTrailerExample itself round-trips through parseAckTrailers', () => {
// A second, independent round-trip: the EXAMPLE printed for documentation/self-serve
// discovery (not the per-report taught line above) must parse too — this is exactly
// the "docs teaching the feature can arm it" hazard 40-design.md calls out, so the
// example must be built from real data, never an angle-bracket placeholder.
const rawValue = REMEDIATION.ackTrailerExample.slice(ACK_TRAILER_HASH.length + 2);
const { hash, errors } = parseAckTrailers({ hash: [rawValue] });
assert.deepEqual(errors, []);
assert.equal(hash.get('skills/gsd-add-tests/SKILL.md').reason, 'converter rewrite, ADR-2719');
});
test('the remediation surface is frozen and teaches the trailer, not the legacy fragment file', () => {
// #3942: the remedy is a commit TRAILER, never a fragment file under the legacy
// fragment directory — asserting on the legacy file/directory paths here would pin
// the exact mechanism this design replaces.
assert.ok(Object.isFrozen(REMEDIATION), 'the exported surface must not be mutable');
assert.equal(REMEDIATION.ackFile, undefined, 'there is no fragment file to name any more');
assert.equal(REMEDIATION.ackDir, undefined, 'there is no fragment directory to name any more');
assert.equal(REMEDIATION.createIfAbsent, undefined, '"create a file" is no longer the remedy');
assert.equal(REMEDIATION.spentAckNote, undefined, '"spent" has no trailer-world remedy to teach');
assert.ok(REMEDIATION.addTrailerHash.includes(ACK_TRAILER_HASH));
assert.ok(!REMEDIATION.addTrailerHash.includes(ACK_TRAILER_GROWTH), 'the hash remedy must not name the growth trailer');
assert.ok(REMEDIATION.addTrailerGrowth.includes(ACK_TRAILER_GROWTH));
assert.ok(!REMEDIATION.addTrailerGrowth.includes(ACK_TRAILER_HASH), 'the growth remedy must not name the hash trailer');
});
test('a ripple and a growth in one report each get their OWN trailer line', () => {
// The combination nobody writes down, and the most likely real shape: a feature PR that
// both grows a workflow AND ripples an emitted path.
//
// #3942: there is no longer one shared document to paste over — a hash-space entry and
// a growth-space entry are two DIFFERENT trailer names, so they cannot collide the way
// two entries in one JSON object's `paths` key never could either; this test now proves
// each renders under its OWN trailer name, not merely that both are present.
const r = diffEmitted({
baseline: mf({ [WORKFLOW_KEY]: 'aaa' }),
current: mf({ [WORKFLOW_KEY]: 'bbb' }),
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, tagged, in list order.
assert.deepEqual(report.ackable, [
{ key: WORKFLOW_KEY, reason: REMEDIATION.rippleReason, space: 'hash' },
{ key: 'explore.md', reason: REMEDIATION.growthReason, space: 'growth' },
]);
const msg = formatReport(r);
assert.ok(msg.includes(renderAckTrailer(ACK_TRAILER_HASH, WORKFLOW_KEY, REMEDIATION.rippleReason)));
assert.ok(msg.includes(renderAckTrailer(ACK_TRAILER_GROWTH, 'explore.md', REMEDIATION.growthReason)));
assert.equal(
msg.split(`${ACK_TRAILER_HASH}:`).length - 1, 1,
'exactly one hash-space line may be printed for this report',
);
assert.equal(
msg.split(`${ACK_TRAILER_GROWTH}:`).length - 1, 1,
'exactly one growth-space line may be printed for this report',
);
});
test('a stale ack names its trailer and space, and the fix is to amend the trailer, not delete a file', () => {
// Pre-#3942 this said acks "must be deleted" from a named FILE. #3942 replaces the
// remedy with amending a commit trailer — there is no file to name any more.
const r = diffEmitted({
baseline: mf({ [WORKFLOW_KEY]: 'aaa' }),
current: mf({ [WORKFLOW_KEY]: 'aaa' }),
changedPaths: [],
ackHash: mapOf({ [WORKFLOW_KEY]: { reason: 'old' } }),
});
const stale = blockOf(buildReport(r), 'stale-acks');
assert.deepEqual(stale.items, [{ key: WORKFLOW_KEY, space: 'hash' }]);
assert.equal(stale.fix, REMEDIATION.staleAckFix);
assert.match(stale.fix, /Remove the trailer/, 'the remedy must name the trailer, not a file');
// A stale-only report has nothing to acknowledge — it must NOT offer a trailer to add.
assert.deepEqual(buildReport(r).ackable, [], 'removing a stale trailer is not acknowledging one');
});
test('growth and a stale ack in one report keep both remedies', () => {
// The contributor is adding one entry and removing another — 'gone.md' is a bare
// filename (growth space).
const r = growthOnly({ ackGrowth: mapOf({ 'gone.md': { reason: 'outlived' } }) });
assert.deepEqual(r.staleAcks, [{ key: 'gone.md', space: 'growth' }]);
assert.equal(r.grown[0].acked, false);
const report = buildReport(r);
assert.ok(blockOf(report, 'unacked-growth'), 'the growth still needs an ack');
assert.ok(blockOf(report, 'stale-acks'), 'the stale entry still needs removing');
assert.deepEqual(
report.ackable, [{ key: 'explore.md', reason: REMEDIATION.growthReason, space: 'growth' }],
'only the growth is ackable; the stale entry is removed, not added',
);
});
test('the validation early-return renders instead of throwing', () => {
// 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: [] },
// #3942: ackHash/ackGrowth are new inputs (a pre-parsed Map, not a JSON document)
// and were NOT gated the way baseline/current/changedPaths already are above — the
// identical #2778 crash class, just on a newer parameter pair.
{ baseline: {}, current: {}, changedPaths: [], ackHash: {} },
{ baseline: {}, current: {}, changedPaths: [], ackHash: null },
{ baseline: {}, current: {}, changedPaths: [], ackHash: 'x' },
{ baseline: {}, current: {}, changedPaths: [], ackGrowth: {} },
{ baseline: {}, current: {}, changedPaths: [], ackGrowth: null },
{ baseline: {}, current: {}, changedPaths: [], ackGrowth: 'x' },
]) {
const r = diffEmitted(bad);
assert.ok(!r.ok);
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');
}
});
describe('diffEmitted validates ackHash/ackGrowth (#2778-shape defect, #3942)', () => {
// #3942 changed diffEmitted's ack inputs from a parsed JSON document to a pre-parsed
// Map per space (parseAckTrailers' output). baseline/current/changedPaths already had
// a validation guard for exactly this class of bad input (:296-301) — ackHash/
// ackGrowth did not, so a bad shape reached `liveAckHash.has(rel)` /
// `liveAckGrowth.has(name)` further down and threw an unhandled TypeError instead of
// an error verdict, rather than being caught and named up front like every other
// input.
const BAD_SHAPES = [
{ label: 'plain object', value: {} },
{ label: 'null', value: null },
{ label: 'string', value: 'x' },
{ label: 'array', value: [] },
];
for (const { label, value } of BAD_SHAPES) {
test(`ackHash=${label} raises an error verdict naming ackHash, never throws`, () => {
const r = diffEmitted({
baseline: mf({}), current: mf({}), changedPaths: [], ackHash: value,
});
assert.equal(r.ok, false);
assert.ok(r.errors.some((e) => e.includes('ackHash')), `errors must name ackHash: ${JSON.stringify(r.errors)}`);
});
test(`ackGrowth=${label} raises an error verdict naming ackGrowth, never throws`, () => {
const r = diffEmitted({
baseline: mf({}), current: mf({}), changedPaths: [], ackGrowth: value,
});
assert.equal(r.ok, false);
assert.ok(r.errors.some((e) => e.includes('ackGrowth')), `errors must name ackGrowth: ${JSON.stringify(r.errors)}`);
});
}
test('a well-formed Map for both ackHash and ackGrowth is accepted (the healthy steady state is not gated away)', () => {
const r = diffEmitted({
baseline: mf({}), current: mf({}), changedPaths: [], ackHash: new Map(), ackGrowth: new Map(),
});
assert.equal(r.ok, true);
assert.deepEqual(r.errors, []);
});
});
test('a failed git diff renders as an error, never as "nothing changed"', () => {
// The comment on that validation branch says a failed `git diff` must never be read as
// an empty change set. That contract is only worth anything if the resulting report is
// 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({
ackGrowth: mapOf({ '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 },
ackGrowth: mapOf({ 'kept.md': { reason: 'declared' } }),
});
const report = buildReport(r);
const growth = blockOf(report, 'unacked-growth');
assert.equal(growth.count, 1, 'only the unacked one is counted');
assert.deepEqual(growth.items.map((g) => g.name), ['loud.md']);
assert.deepEqual(report.ackable, [{ key: 'loud.md', reason: REMEDIATION.growthReason, space: 'growth' }],
'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.addTrailerHash), 'and must not offer a hash trailer for it');
assert.ok(!formatReport(r).includes(REMEDIATION.addTrailerGrowth), 'and must not offer a growth trailer for it');
});
// ─── New-file cap (ADR-1610 Decision point 3, revived after #2724) ───────────
//
// tests/workflow-size-baseline.json used to double as the "has this file been
// baselined before" signal a NEW_FILE_CAP check keyed off. #2724 deleted it without
// reviving that check anywhere — a brand-new workflow/agent file (present in
// sizeCurrent, absent from sizeBaseline) got zero size scrutiny at all, silently
// loosening the bound from 32768 (ADR-1610) to whichever tier cap it happened to
// fall under (DEFAULT_CAP = 40960, nearly 8 KiB looser) with nothing in CI to say so.
// A file in that gap risks silent truncation at the Codex `project_doc_max_bytes`
// anchor. Not ack-able — same as the tier hard caps, the fix is extraction.
test('a brand-new file at exactly the cap is accepted (limit)', () => {
const r = diffEmitted({
baseline: mf({}), current: mf({}), changedPaths: [],
sizeBaseline: {}, sizeCurrent: { 'new-workflow.md': NEW_FILE_CAP },
});
assert.deepEqual(r.newFileCapExceeded, []);
assert.ok(r.ok, `exactly ${NEW_FILE_CAP} bytes must be accepted`);
});
test('a brand-new file one byte over the cap is rejected (limit+1)', () => {
const r = diffEmitted({
baseline: mf({}), current: mf({}), changedPaths: [],
sizeBaseline: {}, sizeCurrent: { 'new-workflow.md': NEW_FILE_CAP + 1 },
});
assert.deepEqual(r.newFileCapExceeded, [
{ name: 'new-workflow.md', bytes: NEW_FILE_CAP + 1, cap: NEW_FILE_CAP },
]);
assert.ok(!r.ok, `${NEW_FILE_CAP + 1} bytes must be rejected`);
assert.match(formatReport(r), /new-workflow\.md is 32769 bytes/);
});
test('a brand-new file one byte under the cap is accepted (limit-1)', () => {
const r = diffEmitted({
baseline: mf({}), current: mf({}), changedPaths: [],
sizeBaseline: {}, sizeCurrent: { 'new-workflow.md': NEW_FILE_CAP - 1 },
});
assert.deepEqual(r.newFileCapExceeded, []);
assert.ok(r.ok, `${NEW_FILE_CAP - 1} bytes must be accepted`);
});
test('the new-file cap is not ack-able (extraction, not acknowledgment, is the fix)', () => {
const r = diffEmitted({
baseline: mf({}), current: mf({}), changedPaths: [],
sizeBaseline: {}, sizeCurrent: { 'new-workflow.md': NEW_FILE_CAP + 1 },
ackGrowth: mapOf({ 'new-workflow.md': { reason: 'trying to bypass it' } }),
});
assert.equal(r.newFileCapExceeded.length, 1, 'an ack entry must not exempt the new-file cap');
assert.ok(!r.ok);
});
test('an existing (baselined) file is governed by growth, not the new-file cap', () => {
// A file already IN sizeBaseline is not "new" even if it happens to sit above
// NEW_FILE_CAP — that is the tier hard cap's job, not this one's.
const r = diffEmitted({
baseline: mf({}), current: mf({}), changedPaths: [],
sizeBaseline: { 'old.md': NEW_FILE_CAP + 5000 },
sizeCurrent: { 'old.md': NEW_FILE_CAP + 5000 },
});
assert.deepEqual(r.newFileCapExceeded, []);
assert.deepEqual(r.grown, []);
assert.ok(r.ok);
});
// ─── Baseline resolution + staleness ─────────────────────────────────────────
const goodBaseline = (sha) => ({
version: BASELINE_VERSION,
sha,
manifests: { claude: { [WORKFLOW_KEY]: 'aaa' } },
sizes: { 'plan-phase.md': 100 },
});
test('a stale baseline cache key is detected, not used', () => {
// ADR-2719 §5: the one thing that has to be exactly right.
const r = resolveBaseline({
expectedSha: SHA_A,
env: {},
cachePath: 'cache.json',
readJson: () => goodBaseline(SHA_B),
});
assert.ok(!r.ok);
assert.match(r.errors.join('\n'), /STALE baseline/);
assert.ok(r.errors.join('\n').includes(SHA_B) && r.errors.join('\n').includes(SHA_A));
});
test('a matching baseline sha is accepted', () => {
const r = resolveBaseline({
expectedSha: SHA_A, env: {}, cachePath: 'cache.json',
readJson: () => goodBaseline(SHA_A),
});
assert.ok(r.ok);
assert.equal(r.sha, SHA_A);
assert.equal(r.via, 'cache:cache.json');
assert.deepEqual(r.sizeBaseline, { 'plan-phase.md': 100 });
});
test('an unavailable baseline fails explicitly rather than skipping', () => {
// ADR-2719 §6 — in node:test a bare `return` is a PASS, which would fail the gate open.
const r = resolveBaseline({
expectedSha: SHA_A, env: {}, cachePath: 'cache.json',
readJson: () => null,
});
assert.ok(!r.ok);
assert.equal(r.via, 'none');
assert.match(r.errors.join('\n'), /bare `return` is a PASS/);
});
test('a malformed baseline is rejected', () => {
for (const bad of [0, 'str', [], true]) {
const r = resolveBaseline({
expectedSha: SHA_A, env: {}, cachePath: 'c.json', readJson: () => bad,
});
assert.ok(!r.ok, `${JSON.stringify(bad)} must be rejected`);
}
const noSha = resolveBaseline({
expectedSha: SHA_A, env: {}, cachePath: 'c.json',
readJson: () => ({ version: BASELINE_VERSION, manifests: {} }),
});
assert.match(noSha.errors.join('\n'), /must be a 40-hex commit sha/);
});
test('baseline resolution precedence is explicit and reported', () => {
// env wins over cache…
const viaEnv = resolveBaseline({
expectedSha: SHA_A,
env: { [BASELINE_ENV]: '/tmp/from-cache-restore.json' },
cachePath: 'cache.json',
readJson: (p) => (p === '/tmp/from-cache-restore.json' ? goodBaseline(SHA_A) : goodBaseline(SHA_B)),
});
assert.ok(viaEnv.ok);
assert.equal(viaEnv.via, `env:${BASELINE_ENV}`);
// …and an explicitly-pointed-at stale baseline is a hard stop, not a fall-through:
// the operator said "use this one".
//
// #2854: this fixture used to be named '/tmp/from-cache-restore.json', which asserted
// the exact conflation that broke CI — a CI cache restore is NOT an operator pin, and
// naming it one here documented the defect as intended behavior. The hard stop is a
// real guarantee for a HAND-SET path and is preserved; what changed is that CI no
// longer routes its restore through this door at all.
const envStale = resolveBaseline({
expectedSha: SHA_A,
env: { [BASELINE_ENV]: '/tmp/operator-pinned-baseline.json' },
cachePath: 'cache.json',
readJson: () => goodBaseline(SHA_B),
buildFallback: () => goodBaseline(SHA_A),
});
assert.ok(!envStale.ok, 'an explicit stale baseline must not silently fall through');
// a stale CACHE, by contrast, falls through to the build fallback
const viaBuild = resolveBaseline({
expectedSha: SHA_A, env: {}, cachePath: 'cache.json',
readJson: () => goodBaseline(SHA_B),
buildFallback: () => goodBaseline(SHA_A),
});
assert.ok(viaBuild.ok);
assert.equal(viaBuild.via, 'build');
});
// ── #2854: a CI cache restore is not an operator pin ─────────────────────────────
//
// ADR-2719 §5: "Cache miss falls back to an in-job build at `origin/next`." The PR lane
// restores the baseline keyed on the PR's RECORDED base sha (`test.yml:198`) while the
// gate resolves the base ref LIVE (`resolveBase()`), so the two drift whenever `next`
// advances between a PR's last sync and its run. The restore was published straight to
// GSD_EMITTED_BASELINE, where a mismatch is fatal — turning a recoverable cache into a
// hard failure on diffs that touched nothing related. The export step is the boundary
// that must be conservative in what it sends.
const CI_CACHE_PATH = '.gsd-cache/emitted-baseline.json';
/** Resolve exactly as CI does: cache restored to the DEFAULT path, nothing announced. */
const resolveAsCI = (doc, expectedSha = SHA_A) => resolveBaseline({
expectedSha,
env: {}, // no operator pin — this is the whole point
cachePath: CI_CACHE_PATH,
readJson: typeof doc === 'function' ? doc : () => doc,
buildFallback: () => goodBaseline(expectedSha),
});
test('#2854: a drifted cache restore degrades to the in-job build', () => {
const r = resolveAsCI(goodBaseline(SHA_B)); // restored under a drifted key
assert.ok(r.ok, `must resolve via the in-job build; got: ${(r.errors || []).join('; ')}`);
assert.equal(r.via, 'build');
assert.equal(r.sha, SHA_A);
assert.deepEqual(r.attempted, [`cache:${CI_CACHE_PATH}`, 'build'],
'the cache must be tried and rejected before the build, and the trail must say so');
});
test('#2854: a current cache restore is used directly (the fast path survives)', () => {
const r = resolveAsCI(goodBaseline(SHA_A));
assert.ok(r.ok);
assert.equal(r.via, `cache:${CI_CACHE_PATH}`, 'a valid cache must not pay for a rebuild');
});
test('#2854: every recoverable malformation degrades rather than failing the run', () => {
// The blocker an isolated reviewer caught: an earlier revision gated only on sha
// equality, so a doc with the RIGHT sha but a wrong schema version or broken
// manifests was announced as an operator pin and hard-stopped downstream —
// reproducing this bug's own class, triggered by malformation instead of staleness.
// Routing through the cache path makes every one of these recoverable by construction.
const cases = {
'stale sha': goodBaseline(SHA_B),
'wrong schema version': { version: BASELINE_VERSION + 998, sha: SHA_A, manifests: { c: {} }, sizes: {} },
'manifests is an array': { version: BASELINE_VERSION, sha: SHA_A, manifests: [], sizes: {} },
'manifests absent': { version: BASELINE_VERSION, sha: SHA_A },
'sha absent': { version: BASELINE_VERSION, manifests: { c: {} }, sizes: {} },
'sha not 40-hex': { version: BASELINE_VERSION, sha: 'g'.repeat(40), manifests: { c: {} }, sizes: {} },
'absent file': null,
};
for (const [name, doc] of Object.entries(cases)) {
const r = resolveAsCI(doc);
assert.ok(r.ok, `${name}: must degrade to the build, not fail — got ${(r.errors || []).join('; ')}`);
assert.equal(r.via, 'build', `${name}: must reach the in-job build`);
}
});
test('#2854: sha length boundary — 39, 40, 41 hex', () => {
// limit-1 / limit / limit+1 on the 40-hex contract validateBaseline enforces.
for (const len of [39, 41]) {
const r = resolveAsCI({ version: BASELINE_VERSION, sha: 'a'.repeat(len), manifests: { c: {} }, sizes: {} });
assert.equal(r.via, 'build', `${len} hex is not a sha — must not be used as the baseline`);
}
const exact = resolveAsCI(goodBaseline(SHA_A));
assert.equal(exact.via, `cache:${CI_CACHE_PATH}`, '40 hex matching is the contract');
});
test('#2854: valid JSON that is not an object degrades rather than passing vacuously', () => {
for (const doc of [0, 'str', [], true]) {
const r = resolveAsCI(doc);
assert.ok(r.ok, `${JSON.stringify(doc)}: must degrade to the build`);
assert.equal(r.via, 'build', `${JSON.stringify(doc)} must never read as a usable baseline`);
}
});
test('#2854: an unreadable cache degrades and does not throw', () => {
// Deterministic IO failure by injection — never chmod 0o000, which root bypasses.
const r = resolveAsCI(() => { throw new Error('EACCES: permission denied'); });
assert.ok(r.ok);
assert.equal(r.via, 'build');
});
test('#2854: the cache is used exactly when it is valid for the sha under test', () => {
const hex40 = fc.string({
unit: fc.constantFrom(...'0123456789abcdef'), minLength: 40, maxLength: 40,
});
fc.assert(fc.property(hex40, hex40, (built, expected) => {
const r = resolveAsCI(goodBaseline(built), expected);
// Always resolves; the only question is whether it paid for a rebuild.
if (!r.ok) return false;
return (r.via === `cache:${CI_CACHE_PATH}`) === (built === expected);
}), { numRuns: 200 });
});
test('#2854: the gate is pinned to the SAME base the tree was merged with', () => {
// The deepest half of this bug. "Rebase check" merges `pull_request.base.sha`,
// pinned by #2472 so all 12 matrix jobs agree on one tree. But resolveBase()
// otherwise falls through to `origin/next`, which `fetch-depth: 0` leaves at the
// LIVE tip. When `next` advanced mid-flight the gate compared a tree built on
// base.sha against a baseline at a NEWER commit — so the correctly-keyed cache
// was rejected as "stale" and the run died. Worse than dying would be surviving:
// a baseline at the wrong commit attributes other people's merges to this PR.
//
// Two surfaces read one value, which is the generative-divergence shape this repo
// has been bitten by before, so the parity is asserted rather than assumed.
const yaml = require('js-yaml');
const wf = yaml.load(fs.readFileSync(path.join(REPO_ROOT, '.github/workflows/test.yml'), 'utf8'));
const jobsUnderTest = Object.entries(wf.jobs).filter(([, job]) =>
(job.steps || []).some((s) => typeof s.run === 'string' && s.run.includes('ci-rebase-check.cjs')));
assert.ok(jobsUnderTest.length >= 2,
`expected the rebase-pinned jobs to be found, got ${jobsUnderTest.length}`);
for (const [name, job] of jobsUnderTest) {
const rebaseStep = job.steps.find((s) => typeof s.run === 'string' && s.run.includes('ci-rebase-check.cjs'));
const mergedBase = (rebaseStep.env || {}).CI_REBASE_BASE_SHA;
const gateBase = (job.env || {}).GSD_EMITTED_BASE;
assert.ok(mergedBase, `job ${name}: rebase step must pin CI_REBASE_BASE_SHA`);
assert.equal(gateBase, mergedBase,
`job ${name}: the emitted gate's base (GSD_EMITTED_BASE=${JSON.stringify(gateBase)}) must equal ` +
`the commit the tree was merged with (CI_REBASE_BASE_SHA=${JSON.stringify(mergedBase)}). ` +
'Diverging them makes the differential compare a tree against a baseline from a ' +
'different commit, which mis-attributes unrelated merges to this PR.');
}
});
test('#2854: an explicit base pin outranks the live branch tip', () => {
// The mechanism the workflow pin relies on: GSD_EMITTED_BASE must win over
// origin/<base>, or setting it in CI would change nothing.
const pinned = 'c'.repeat(40);
assert.equal(baseRefCandidates({ GSD_EMITTED_BASE: pinned, GITHUB_BASE_REF: 'next' })[0], pinned,
'an explicit pin must be tried before origin/next');
});
test('#2854: an EMPTY base pin is ignored, not treated as a candidate', () => {
// The pin is job-level env, so on push/workflow_dispatch — where there is no
// pull_request — `${{ github.event.pull_request.base.sha }}` renders as an empty
// string rather than being unset. baseRefCandidates' truthy check already excludes
// it, but nothing asserted that, so narrowing the check to `!== undefined` would
// silently push '' as the first candidate and have `git rev-parse ''` decide the
// baseline. Pinned here because the workflow now guarantees this input shape.
assert.deepEqual(
baseRefCandidates({ GSD_EMITTED_BASE: '', GITHUB_BASE_REF: 'next' }),
baseRefCandidates({ GITHUB_BASE_REF: 'next' }),
'an empty pin must behave exactly as an absent one',
);
assert.ok(!baseRefCandidates({ GSD_EMITTED_BASE: '', GITHUB_BASE_REF: 'next' }).includes(''),
'the empty string must never become a base-ref candidate');
});
test('#2854: the resolution summary names only sources actually attempted', () => {
// The caller's assertion message hardcoded "(tried env, <cache>, and an in-job build)"
// on every failure, including early returns that reached none of them.
const envOnly = resolveBaseline({
expectedSha: SHA_A,
env: { [BASELINE_ENV]: '/tmp/operator-pinned-baseline.json' },
cachePath: 'cache.json',
readJson: () => goodBaseline(SHA_B),
buildFallback: () => goodBaseline(SHA_A),
});
assert.ok(!envOnly.ok);
assert.deepEqual(envOnly.attempted, [`env:${BASELINE_ENV}`],
'an env hard stop reaches neither the cache nor the build — the summary must say so');
const allThree = resolveBaseline({
expectedSha: SHA_A, env: {}, cachePath: 'cache.json',
readJson: () => goodBaseline(SHA_B),
buildFallback: () => goodBaseline(SHA_A),
});
assert.deepEqual(allThree.attempted, ['cache:cache.json', 'build']);
});
test('base-ref candidates are ordered most-specific first and de-duplicated', () => {
// The gate went red on its first matrix run because it hard-depended on
// `origin/next`, which cannot exist in the gsd-test container (shallow clone +
// base/head merge, no remote-tracking refs). Candidate order is the fix, so it is
// pinned rather than left implicit.
assert.deepEqual(
baseRefCandidates({ GSD_EMITTED_BASE: 'abc123', GITHUB_BASE_REF: 'next' }),
['abc123', 'origin/next', 'next'],
'an explicit override wins, then the Actions base ref, then the defaults',
);
assert.deepEqual(
baseRefCandidates({ GITHUB_BASE_REF: 'release/1.9' }),
['origin/release/1.9', 'release/1.9', 'origin/next', 'next'],
'a non-next base ref is honored before falling back',
);
assert.deepEqual(
baseRefCandidates({}),
['origin/next', 'next'],
'with no env, the repo defaults are the only candidates',
);
// De-duplication matters: GITHUB_BASE_REF=next must not produce origin/next twice.
const dupes = baseRefCandidates({ GITHUB_BASE_REF: 'next' });
assert.equal(new Set(dupes).size, dupes.length);
});
test('an unreadable baseline surfaces an error', () => {
const r = resolveBaseline({
expectedSha: SHA_A, env: {}, cachePath: 'c.json',
readJson: () => { throw new Error('injected read failure'); },
});
assert.ok(!r.ok);
assert.match(r.errors.join('\n'), /injected read failure/);
});
// ─── buildBaselineAtRef: the in-job build must bootstrap without its own generator ──
test(
'buildBaselineAtRef resolves a baseline via the in-job build even when the generator '
+ 'script is absent at the ref (#2767 regression)',
{ timeout: 480_000 },
(t) => {
// Mirrors "differential attribution over the real tree": install output is
// platform-specific on Windows, and this drives the same heavy worktree +
// build:lib + 19-installer pipeline.
if (process.platform === 'win32') {
t.skip('emitted parity is asserted on macOS + Linux; Windows install output is platform-specific');
return;
}
// Hermetic by construction (#2767 review finding B). This test used to resolve a
// real base ref (typically `origin/next`) and skip unless that ref, checked via
// `git cat-file -e`, still LACKED scripts/gen-emitted-baseline.cjs — the file THIS
// PR adds. That was true only until this PR merged: after merge every resolvable
// base ref carries the file, the precondition is permanently false, and the test
// would skip forever, losing all regression value silently (a skip reads as green).
// It also depended on `origin/next` being resolvable at all, which the gsd-test
// runner's shallow clone + base/head merge does not guarantee (no remote-tracking
// refs) — the same non-hermetic-history failure mode "baseline families are
// enumerated from the ref, not from the current registry" (above) was rewritten to
// avoid, by building its own throwaway git repo instead of reaching for this
// repo's history.
//
// That precedent doesn't directly transplant here: `buildBaselineAtRef` needs a
// REAL, buildable gsd-core tree (`npm run build:lib`, the compiled `bin/lib/*.cjs`,
// `node_modules`) to produce a real manifest — a minimal from-scratch repo has none
// of that. So instead of a from-scratch repo, this synthesizes the missing-generator
// condition IN-PLACE with git plumbing: read this checkout's own HEAD tree into a
// scratch index (a temp `GIT_INDEX_FILE`, never the real `.git/index`), remove just
// `scripts/gen-emitted-baseline.cjs` from that index, write the resulting tree, and
// commit it as a child of HEAD. The result is one loose commit object — a real,
// buildable tree identical to HEAD's except missing the one file under test — that
// is never referenced by any branch, tag, or ref, so it is not checked out, not
// pushed, and needs no cleanup beyond the scratch index directory itself. The real
// working tree, HEAD, and index of this checkout are never touched.
const tmpIndexDir = createTempDir('emitted-baseline-synth-index-');
t.after(() => cleanup(tmpIndexDir));
const tmpIndexFile = path.join(tmpIndexDir, 'index');
const gitEnv = {
...process.env,
GIT_INDEX_FILE: tmpIndexFile,
GIT_AUTHOR_NAME: 'GSD Test', GIT_AUTHOR_EMAIL: 'test@example.invalid',
GIT_COMMITTER_NAME: 'GSD Test', GIT_COMMITTER_EMAIL: 'test@example.invalid',
};
// `-c safe.directory=<REPO_ROOT>` via the shared `safeDirArgs` (emitted-runtime.cjs):
// the remote runner mounts this repo at a path owned by a different uid, and git's
// dubious-ownership protection refuses every operation there otherwise — this test
// proved that the hard way (#2767 review) when its first `git rev-parse HEAD` failed
// closed. Reusing the SAME helper `buildBaselineAtRef` now uses (below) rather than
// hand-rolling the flag here keeps the fix from silently diverging per call site.
const run = (...args) => execFileSync('git', [...safeDirArgs(REPO_ROOT), ...args], {
cwd: REPO_ROOT, encoding: 'utf8', timeout: 30_000, env: gitEnv, stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
const headSha = run('rev-parse', 'HEAD');
run('read-tree', 'HEAD');
run('update-index', '--force-remove', 'scripts/gen-emitted-baseline.cjs');
const syntheticTree = run('write-tree');
const syntheticSha = run(
'commit-tree', syntheticTree, '-p', headSha, '-m',
'synthetic: missing scripts/gen-emitted-baseline.cjs (#2767 test fixture — unreferenced, never pushed)',
);
// Precondition, ASSERTED not assumed: the synthetic commit truly lacks the file —
// otherwise this test would prove nothing.
assert.throws(
() => execFileSync('git', [...safeDirArgs(REPO_ROOT), 'cat-file', '-e', `${syntheticSha}:scripts/gen-emitted-baseline.cjs`], {
cwd: REPO_ROOT, encoding: 'utf8', timeout: 30_000, stdio: 'pipe',
}),
/./,
'the synthetic ref must genuinely lack the generator script for this test to prove anything',
);
// The actual regression assertion: this must NOT throw "Cannot find module", and
// must produce a well-formed baseline artifact measuring the SYNTHETIC ref, not the
// caller's own tree. Before the #2767 fix, `buildBaselineAtRef` unconditionally ran
// `<worktreeDir>/scripts/gen-emitted-baseline.cjs` — the checked-out WORKTREE'S OWN
// copy — which fails closed with `Cannot find module` for exactly this ref shape.
const artifact = buildBaselineAtRef(syntheticSha, { cwd: REPO_ROOT });
assert.equal(artifact.version, BASELINE_VERSION);
assert.equal(
artifact.sha, syntheticSha,
'the artifact must report the REF\'s sha, not the caller checkout\'s',
);
assert.ok(artifact.manifests && typeof artifact.manifests === 'object');
assert.ok(
Object.keys(artifact.manifests).length >= MINIMUM_MANIFEST_FAMILIES,
`expected at least ${MINIMUM_MANIFEST_FAMILIES} manifest families, got ${Object.keys(artifact.manifests).length}`,
);
assert.ok(artifact.sizes && Object.keys(artifact.sizes).length > 0, 'sizes must be non-empty');
},
);
// `readAckFile` (the legacy JSON-ack-file reader) is deleted — ADR-3942 §1/§6 moves the
// acknowledgment to a commit trailer, and its reader (`readAckTrailers`) is covered by
// tests/emitted-ack-trailer.test.cjs, not here.
test('formatReport truncation is exact at limit-1 / limit / limit+1', () => {
// sampleLimit gates a real branch. CLAUDE.md's boundary rule applies to it like any
// other limit; the earlier suite named a test "limit+1" that tested no numeric limit
// at all, which is worse than no coverage because it reads as covered.
const build = (n) => {
const baseline = {}; const current = {};
for (let i = 0; i < n; i++) {
const k = `gsd-core/workflows/w${String(i).padStart(3, '0')}.md`;
baseline[k] = 'a'; current[k] = 'b';
}
return diffEmitted({ baseline: mf(baseline), current: mf(current), changedPaths: [] });
};
const at19 = formatReport(build(19), { sampleLimit: 20 });
assert.ok(at19.includes('w018.md'), 'limit-1 lists every path');
assert.ok(!at19.includes('…and'), 'limit-1 must not truncate');
const at20 = formatReport(build(20), { sampleLimit: 20 });
assert.ok(at20.includes('w019.md'), 'at the limit the last path is listed');
assert.ok(!at20.includes('…and'), 'exactly at the limit must not truncate');
const at21 = formatReport(build(21), { sampleLimit: 20 });
assert.ok(at21.includes('…and 1 more'), 'limit+1 truncates and says how many were hidden');
assert.ok(!at21.includes('w020.md'), 'the 21st path is not listed');
});
// ─── Independence + purity ───────────────────────────────────────────────────
test('the differential covers every runtime present in either manifest', () => {
const baseline = { claude: { [WORKFLOW_KEY]: 'a' }, kimi: { [WORKFLOW_KEY]: 'a' } };
const current = { claude: { [WORKFLOW_KEY]: 'b' }, opencode: { [WORKFLOW_KEY]: 'c' } };
const r = diffEmitted({ baseline, current, changedPaths: [] });
const seen = new Set([...r.unattributable, ...r.attributed, ...r.removed].map((x) => x.runtime));
assert.deepEqual([...seen].sort(), ['claude', 'kimi', 'opencode'],
'a runtime present on only one side must still be evaluated');
});
test('diff is pure and repeatable', () => {
const args = {
baseline: mf({ [WORKFLOW_KEY]: 'aaa' }),
current: mf({ [WORKFLOW_KEY]: 'bbb' }),
// 3 elements in deliberately unsorted order, so an in-place sort would be visible.
changedPaths: ['zzz/last.md', WORKFLOW_SRC, 'aaa/first.md'],
};
const frozen = JSON.stringify(args);
const a = diffEmitted(args);
const b = diffEmitted(args);
assert.deepEqual(b, a);
assert.equal(JSON.stringify(args), frozen, 'inputs must not be mutated');
});
// ─── Property: conservation ──────────────────────────────────────────────────
test('property: every moved key lands in exactly one bucket', () => {
// The conservation law itself. A key silently dropped from all three buckets is a
// hole in the very invariant ADR-2719 asserts — and it is the failure a hand-written
// example set is least likely to find.
const keys = [WORKFLOW_KEY, SKILL_KEY, 'agents/gsd-planner.md', 'scripts/lib/cli-exit.cjs'];
const sources = { [WORKFLOW_KEY]: WORKFLOW_SRC, [SKILL_KEY]: SKILL_SRC,
'agents/gsd-planner.md': 'agents/gsd-planner.md', 'scripts/lib/cli-exit.cjs': 'scripts/lib/cli-exit.cjs' };
fc.assert(
fc.property(
fc.subarray(keys, { minLength: 1 }), // which keys move
fc.subarray(keys), // which sources the PR changed
fc.subarray(keys), // which keys are acked
(movedKeys, changedKeys, ackedKeys) => {
const baseline = {}; const current = {};
for (const k of keys) { baseline[k] = 'h'; current[k] = movedKeys.includes(k) ? 'x' : 'h'; }
const ackPaths = {};
for (const k of ackedKeys) ackPaths[k] = { reason: 'property' };
const r = diffEmitted({
baseline: mf(baseline),
current: mf(current),
changedPaths: changedKeys.map((k) => sources[k]),
ackHash: mapOf(ackPaths),
});
if (r.errors.length) return false;
const bucketed = [
...r.attributed.map((x) => x.rel),
...r.unattributable.map((x) => x.rel),
...r.acked.map((x) => x.rel),
];
// exactly-once, and exactly the moved set — no key invented, none dropped
return bucketed.length === movedKeys.length
&& new Set(bucketed).size === bucketed.length
&& movedKeys.every((k) => bucketed.includes(k));
},
),
{ numRuns: 400 },
);
});
// ─── Family reconciliation (#2723 correction) ────────────────────────────────
//
// #2723 shipped `EXPECTED_MANIFEST_COUNT = 19` asserted against BOTH the baseline (built
// at the base ref) and the current tree (built at PR HEAD). Those sides legitimately
// differ by one family whenever a PR adds or removes a runtime, so no value satisfied
// both: 19 rejected the current side, 20 rejected the baseline side. Every runtime-adding
// PR was hard-blocked — found by tracing #2005 (Qoder) through the gate.
//
// Driven at PURE-FUNCTION altitude on purpose. The real-tree test below skips wherever no
// base ref exists (the gsd-test runner shallow-clones, so `origin/*` is absent), so a
// regression written at that altitude would silently skip on the very runner that has to
// prove RED.
const ALL_FAMILIES = MANIFEST_FAMILIES.map((f) => f.name);
const REGISTRY_CHANGE = ['tests/helpers/install-shared.cjs'];
// The shape a shipping caller passes: repo-relative POSIX paths from `git diff --name-only`.
const CONTENT_ONLY_CHANGE = ['gsd-core/workflows/plan-phase.md'];
const derivedOf = (names) => names.map((name) => ({ name, runtime: name, scope: 'global' }));
const manifestsOf = (names) => Object.fromEntries(names.map((n) => [n, { 'some/emitted/path': 'hash' }]));
/** Build a fully-consistent reconciliation input, then override one facet per test. */
function reconcileWith({ derivedNames = ALL_FAMILIES, fixtureNames, baselineNames, currentNames, ...rest }) {
return reconcileFamilies({
derived: derivedOf(derivedNames),
fixtures: fixtureNames || derivedNames,
baseline: manifestsOf(baselineNames || derivedNames),
current: manifestsOf(currentNames || derivedNames),
changedPaths: CONTENT_ONLY_CHANGE,
...rest,
});
}
const codesOf = (r) => r.errors.map((e) => e.code);
test('reason codes are a frozen, locked set', () => {
assert.deepEqual(Object.keys(FAMILY_REASON).sort(), [
'ADDED_UNATTRIBUTED', 'BAD_CHANGED_PATHS', 'BASELINE_UNUSABLE', 'BELOW_FLOOR',
'CURRENT_UNUSABLE', 'DERIVED_UNUSABLE', 'DROPPED_UNATTRIBUTED',
'FIXTURES_UNUSABLE', 'FIXTURE_WITHOUT_RUNTIME', 'MISSING_CLAUDE_LOCAL',
'RUNTIME_WITHOUT_FIXTURE',
]);
assert.ok(Object.isFrozen(FAMILY_REASON));
});
test('passes when every family signal agrees', () => {
assert.deepEqual(reconcileWith({}), { ok: true, errors: [] });
});
test('the count export agrees with the derived family set (divergence guard)', () => {
// The #2723 defect was two surfaces carrying independent notions of this number.
assert.equal(EXPECTED_MANIFEST_COUNT, MANIFEST_FAMILIES.length);
assert.ok(EXPECTED_MANIFEST_COUNT >= MINIMUM_MANIFEST_FAMILIES);
});
// ── The deadlock itself ──────────────────────────────────────────────────────
test('permits an added family attributed to a runtime-registry change', () => {
const withQoder = [...ALL_FAMILIES, 'qoder'];
const r = reconcileWith({
derivedNames: withQoder,
baselineNames: ALL_FAMILIES, // base ref predates the new runtime
currentNames: withQoder,
changedPaths: REGISTRY_CHANGE,
});
assert.deepEqual(r, { ok: true, errors: [] });
});
test('rejects an added family with no runtime-registry change, naming it', () => {
const withQoder = [...ALL_FAMILIES, 'qoder'];
const r = reconcileWith({
derivedNames: withQoder,
baselineNames: ALL_FAMILIES,
currentNames: withQoder,
changedPaths: CONTENT_ONLY_CHANGE,
});
assert.equal(r.ok, false);
assert.deepEqual(r.errors, [{ code: FAMILY_REASON.ADDED_UNATTRIBUTED, family: 'qoder' }]);
});
test('permits a dropped family attributed to a runtime-registry change', () => {
const without = ALL_FAMILIES.filter((n) => n !== 'trae');
const r = reconcileWith({
derivedNames: without,
baselineNames: ALL_FAMILIES,
currentNames: without,
changedPaths: REGISTRY_CHANGE,
minimum: 18,
});
assert.deepEqual(r, { ok: true, errors: [] });
});
test('rejects a silently dropped family, naming it', () => {
const without = ALL_FAMILIES.filter((n) => n !== 'trae');
const r = reconcileWith({
derivedNames: without,
baselineNames: ALL_FAMILIES,
currentNames: without,
changedPaths: CONTENT_ONLY_CHANGE,
minimum: 18,
});
assert.equal(r.ok, false);
assert.deepEqual(r.errors, [{ code: FAMILY_REASON.DROPPED_UNATTRIBUTED, family: 'trae' }]);
});
test('attribution is the ONLY permission path, symmetrically', () => {
// No ack-style bypass on either side: a one-sided escape hatch would make removals
// easier to wave through than additions, and the drift-ack file covers unattributable
// emitted-PATH deltas, not family churn.
const without = ALL_FAMILIES.filter((n) => n !== 'trae');
const added = [...ALL_FAMILIES, 'qoder'];
for (const [names, baselineNames, code, family] of [
[without, ALL_FAMILIES, FAMILY_REASON.DROPPED_UNATTRIBUTED, 'trae'],
[added, ALL_FAMILIES, FAMILY_REASON.ADDED_UNATTRIBUTED, 'qoder'],
]) {
const r = reconcileWith({
derivedNames: names, baselineNames, currentNames: names,
changedPaths: CONTENT_ONLY_CHANGE, minimum: 18,
});
assert.equal(r.ok, false);
assert.deepEqual(r.errors, [{ code, family }]);
}
});
test('an add and a drop together are permitted when attributed', () => {
const swapped = [...ALL_FAMILIES.filter((n) => n !== 'trae'), 'qoder'];
const r = reconcileWith({
derivedNames: swapped,
baselineNames: ALL_FAMILIES,
currentNames: swapped,
changedPaths: REGISTRY_CHANGE,
});
assert.deepEqual(r, { ok: true, errors: [] });
});
test('an EQUAL-COUNT membership swap is caught in both directions', () => {
// 19 in, 19 out — invisible to any count-based check. This is why the contract is
// set-based rather than numeric.
const swapped = [...ALL_FAMILIES.filter((n) => n !== 'trae'), 'qoder'];
const r = reconcileWith({
derivedNames: swapped,
baselineNames: ALL_FAMILIES,
currentNames: swapped,
changedPaths: CONTENT_ONLY_CHANGE,
});
assert.equal(swapped.length, ALL_FAMILIES.length, 'the swap must leave the totals equal');
assert.equal(r.ok, false);
assert.deepEqual(r.errors.slice().sort((a, b) => a.code.localeCompare(b.code)), [
{ code: FAMILY_REASON.ADDED_UNATTRIBUTED, family: 'qoder' },
{ code: FAMILY_REASON.DROPPED_UNATTRIBUTED, family: 'trae' },
]);
});
// ── Single-tree drift ────────────────────────────────────────────────────────
test('rejects a fixture with no registered runtime, naming it', () => {
const r = reconcileWith({ fixtureNames: [...ALL_FAMILIES, 'ghost'] });
assert.equal(r.ok, false);
assert.deepEqual(r.errors, [{ code: FAMILY_REASON.FIXTURE_WITHOUT_RUNTIME, family: 'ghost' }]);
});
test('rejects a registered runtime with no fixture, naming it', () => {
const r = reconcileWith({
derivedNames: [...ALL_FAMILIES, 'qoder'],
fixtureNames: ALL_FAMILIES,
baselineNames: [...ALL_FAMILIES, 'qoder'],
currentNames: [...ALL_FAMILIES, 'qoder'],
changedPaths: REGISTRY_CHANGE,
});
assert.equal(r.ok, false);
assert.deepEqual(r.errors, [{ code: FAMILY_REASON.RUNTIME_WITHOUT_FIXTURE, family: 'qoder' }]);
});
// ── The absolute floor: limit-1 / limit / limit+1 ────────────────────────────
test('floor is enforced at limit-1 / limit / limit+1', () => {
const eighteen = ALL_FAMILIES.filter((n) => n !== 'trae'); // limit-1
const twenty = [...ALL_FAMILIES, 'qoder']; // limit+1
const below = reconcileWith({
derivedNames: eighteen, baselineNames: eighteen, currentNames: eighteen,
});
assert.equal(below.ok, false);
assert.ok(codesOf(below).includes(FAMILY_REASON.BELOW_FLOOR));
assert.deepEqual(reconcileWith({}), { ok: true, errors: [] }); // limit == 19
const above = reconcileWith({
derivedNames: twenty, baselineNames: twenty, currentNames: twenty,
});
assert.deepEqual(above, { ok: true, errors: [] });
});
test('a uniformly shrunken universe fails on the floor', () => {
// The Goodhart move the old literal permitted: drop a runtime AND its fixture together
// and lower the constant, and 18 === 18 passes over a smaller world.
const eighteen = ALL_FAMILIES.filter((n) => n !== 'trae');
const r = reconcileWith({
derivedNames: eighteen, fixtureNames: eighteen,
baselineNames: eighteen, currentNames: eighteen,
changedPaths: REGISTRY_CHANGE,
});
assert.equal(r.ok, false);
assert.deepEqual(codesOf(r), [FAMILY_REASON.BELOW_FLOOR]);
});
// ── #2086: claude-local is pinned by name on both sides ──────────────────────
test('a missing claude-local family is named on either side', () => {
const noLocal = ALL_FAMILIES.filter((n) => n !== 'claude-local');
const missingCurrent = reconcileWith({
currentNames: noLocal, changedPaths: REGISTRY_CHANGE,
});
assert.ok(codesOf(missingCurrent).includes(FAMILY_REASON.MISSING_CLAUDE_LOCAL));
const missingBaseline = reconcileWith({
baselineNames: noLocal, changedPaths: REGISTRY_CHANGE,
});
assert.ok(codesOf(missingBaseline).includes(FAMILY_REASON.MISSING_CLAUDE_LOCAL));
});
// ── Hostile / malformed input: explicit failure, never a quiet ok ────────────
test('unusable baseline and current are rejected explicitly, not read as empty', () => {
for (const bad of [null, undefined, [], 'nope', 0]) {
const r = reconcileFamilies({
derived: derivedOf(ALL_FAMILIES), fixtures: ALL_FAMILIES,
baseline: bad, current: manifestsOf(ALL_FAMILIES), changedPaths: [],
});
assert.deepEqual(r, { ok: false, errors: [{ code: FAMILY_REASON.BASELINE_UNUSABLE }] });
}
for (const bad of [null, undefined, [], 'nope', 0]) {
const r = reconcileFamilies({
derived: derivedOf(ALL_FAMILIES), fixtures: ALL_FAMILIES,
baseline: manifestsOf(ALL_FAMILIES), current: bad, changedPaths: [],
});
assert.deepEqual(r, { ok: false, errors: [{ code: FAMILY_REASON.CURRENT_UNUSABLE }] });
}
});
test('a non-array changedPaths is an explicit error, never a silent "no registry change"', () => {
for (const bad of [null, undefined, 'tests/helpers/install-shared.cjs', {}, 7]) {
const r = reconcileWith({ changedPaths: bad });
assert.deepEqual(r, { ok: false, errors: [{ code: FAMILY_REASON.BAD_CHANGED_PATHS }] });
}
});
test('malformed derived and fixtures inputs fail with a verdict, not a TypeError', () => {
// Every input is gated. An unhandled throw here would read as an infrastructure fault
// rather than a gate verdict, which is how a propagation check goes quiet.
for (const bad of [null, undefined, 'nope', {}, [{ nope: 1 }], [null]]) {
const r = reconcileFamilies({
derived: bad, fixtures: ALL_FAMILIES,
baseline: manifestsOf(ALL_FAMILIES), current: manifestsOf(ALL_FAMILIES),
changedPaths: [],
});
assert.deepEqual(r, { ok: false, errors: [{ code: FAMILY_REASON.DERIVED_UNUSABLE }] });
}
for (const bad of [null, undefined, 'nope', {}, [1], [null]]) {
const r = reconcileFamilies({
derived: derivedOf(ALL_FAMILIES), fixtures: bad,
baseline: manifestsOf(ALL_FAMILIES), current: manifestsOf(ALL_FAMILIES),
changedPaths: [],
});
assert.deepEqual(r, { ok: false, errors: [{ code: FAMILY_REASON.FIXTURES_UNUSABLE }] });
}
});
// ── Registry attribution ─────────────────────────────────────────────────────
test('each registry-signal path independently attributes a family change', () => {
for (const p of [...REGISTRY_SIGNAL_PATHS, 'capabilities/qoder/capability.json']) {
assert.equal(touchesRuntimeRegistry([p]), true, `${p} should attribute`);
}
// Narrow on purpose: surfaces that merely accompany a runtime addition must NOT
// excuse an unattributed family delta.
for (const p of ['src/runtime-name-policy.cts', 'gsd-core/bin/lib/capability-registry.cjs']) {
assert.equal(touchesRuntimeRegistry([p]), false, `${p} must NOT attribute on its own`);
}
});
test('backslash-separated registry paths normalize unconditionally', () => {
// Path separators normalize on every platform — backslash paths arrive on Linux too.
assert.equal(touchesRuntimeRegistry(['tests\\helpers\\install-shared.cjs']), true);
assert.equal(touchesRuntimeRegistry(['capabilities\\qoder\\capability.json']), true);
});
test('near-miss paths do not attribute a family change', () => {
for (const p of [
'capabilities/qoder/other.json',
'capabilities/capability.json',
'tests/helpers/install-shared.cjs.bak',
'docs/tests/helpers/install-shared.cjs',
'gsd-core/workflows/plan-phase.md',
]) {
assert.equal(touchesRuntimeRegistry([p]), false, `${p} should NOT attribute`);
}
assert.equal(touchesRuntimeRegistry([]), false);
});
// ── The baseline must come from the REF, not from HEAD's registry ────────────
test('baseline families are enumerated from the ref, not from the current registry', (t) => {
// Regression: enumerating the baseline from MANIFEST_FAMILIES (imported at module load,
// so it describes PR HEAD) makes a REMOVED runtime invisible — the name is already gone
// from the current registry, so the base ref is never asked for it, and the dropped-
// family check can never fire in production even though its unit tests pass.
//
// Built as its own git repo rather than reaching for this repo's history. The gsd-test
// runner shallow-clones base+head, so `rev-list --max-parents=0` there returns the
// GRAFTED boundary commit — a recent one carrying every fixture — not a true root. (This
// repo also has two root commits locally.) A history-dependent assertion passes on a full
// clone and fails in the runner, which is exactly what it did.
const repo = createTempDir('emitted-baseline-ref');
t.after(() => cleanup(repo));
// No `safeDirArgs` needed here (unlike the #2767 fix above): `repo` is a directory
// this same process just created with `mkdtempSync` + `git init`, so its owner is
// always the uid running the test regardless of container — it is never the
// externally-mounted repo path the dubious-ownership check reacts to.
const run = (...args) => execFileSync('git', args, { cwd: repo, encoding: 'utf8', timeout: 30_000 });
run('init', '--quiet', '-b', 'main');
run('config', 'user.email', 'test@example.invalid');
run('config', 'user.name', 'Test');
const fixtureDir = path.join(repo, ...'tests/fixtures/golden-install-parity'.split('/'));
fs.mkdirSync(fixtureDir, { recursive: true });
// Deliberately includes a family that is NOT in today's registry. This is the real
// discriminator: a registry-derived implementation can never report it, because the name
// does not exist in MANIFEST_FAMILIES — which is precisely how a REMOVED runtime went
// invisible and made the dropped-family check unreachable in production.
const atRefOnly = 'zzz-retired-runtime';
const committed = ['claude', 'claude-local', atRefOnly];
for (const name of committed) {
fs.writeFileSync(path.join(fixtureDir, `${name}.json`), JSON.stringify({ 'a/b': 'hash' }));
}
run('add', '-A');
run('commit', '--quiet', '-m', 'fixtures');
assert.ok(
!ALL_FAMILIES.includes(atRefOnly),
'the probe family must be absent from the current registry for this test to discriminate',
);
assert.deepEqual(
baselineFamilyNamesAtRef('HEAD', { cwd: repo }).slice().sort(),
committed.slice().sort(),
'the baseline must report what the REF carries, including a family the current registry lacks',
);
// A ref that cannot be resolved yields nothing rather than throwing, which is the
// post-cutover signal to fall back to resolveBaseline's cache path.
assert.deepEqual(baselineFamilyNamesAtRef('refs/heads/no-such-ref-2723', { cwd: repo }), []);
// Deliberately NOT asserted against the ambient checkout. Reading this repo's own HEAD is
// not guaranteed inside the runner container — it returned [] there, which is this
// function's documented behavior when git cannot read the ref, not a defect. Asserting on
// it tests the checkout rather than the code, and the temp repo above already proves the
// property that matters: the family set follows the REF. The ambient path is covered by
// the real-tree test, which skips explicitly when no base ref is resolvable.
//
// A git failure is never silently permissive downstream: baselineManifestsAtRef returns
// null on an empty family set, and the real-tree test asserts the baseline is non-empty.
});
// ── Independence / purity ────────────────────────────────────────────────────
test('reconciliation is pure across repeated calls', () => {
const args = {
derivedNames: [...ALL_FAMILIES, 'qoder'],
baselineNames: ALL_FAMILIES,
currentNames: [...ALL_FAMILIES, 'qoder'],
changedPaths: CONTENT_ONLY_CHANGE,
};
assert.deepEqual(reconcileWith(args), reconcileWith(args));
});
// ── Property: the reported delta is exactly the set difference ───────────────
test('property: reported added/dropped are exactly the set differences', () => {
fc.assert(
fc.property(
fc.uniqueArray(fc.string({ minLength: 1, maxLength: 6 }).filter((s) => !/^\s*$/.test(s)), { minLength: 0, maxLength: 5 }),
fc.uniqueArray(fc.integer({ min: 0, max: ALL_FAMILIES.length - 2 }), { minLength: 0, maxLength: 4 }),
(rawAdds, dropIdx) => {
const added = rawAdds.filter((s) => !ALL_FAMILIES.includes(s));
// never drop claude-local: it has its own dedicated assertion
const dropped = dropIdx
.map((i) => ALL_FAMILIES[i])
.filter((n) => n !== 'claude-local');
const current = [...ALL_FAMILIES.filter((n) => !dropped.includes(n)), ...added];
const r = reconcileFamilies({
derived: derivedOf(current),
fixtures: current,
baseline: manifestsOf(ALL_FAMILIES),
current: manifestsOf(current),
changedPaths: CONTENT_ONLY_CHANGE,
minimum: 0,
});
const reportedAdded = r.errors
.filter((e) => e.code === FAMILY_REASON.ADDED_UNATTRIBUTED).map((e) => e.family).sort();
const reportedDropped = r.errors
.filter((e) => e.code === FAMILY_REASON.DROPPED_UNATTRIBUTED).map((e) => e.family).sort();
assert.deepEqual(reportedAdded, [...new Set(added)].sort());
assert.deepEqual(reportedDropped, [...new Set(dropped)].sort());
return true;
},
),
{ numRuns: 200, seed: 2723 },
);
});
// ─── The real thing: the law, run against the actual tree ───────────────────
//
// Everything above exercises the pure law against synthetic input, which is what makes
// the acceptance criteria practical to assert at all. This block is what stops the
// phase from being interface-only: it builds the CURRENT emitted manifests for real
// (one installer spawn per runtime), resolves the BASELINE via `resolveBaseline()`,
// resolves the changed paths with real git, reads the real ack file, and runs the
// conservation law over all of it.
//
// Baseline source note (#2724, post-cutover): the committed golden fixtures this test
// used to read via `git show origin/next:<fixture>` are deleted. `resolveBaseline()`'s
// documented precedence takes over: `GSD_EMITTED_BASELINE` env (CI's PR-lane cache
// restore, keyed on the PR's base sha) -> the on-disk cache at
// `.gsd-cache/emitted-baseline.json` (populated by CI's push-to-next publish step,
// scripts/gen-emitted-baseline.cjs) -> an in-job build (a throwaway `git worktree`
// checked out at `base`, running the same script there — slow but never absent). Never
// the working-tree fixtures, which would be whatever this PR's author regenerated;
// comparing against those would be vacuous.
test('differential attribution over the real tree', { timeout: 480_000 }, async (t) => {
if (process.platform === 'win32') {
// Mirrors the golden harness: install output is platform-specific on Windows
// (backslash paths), so parity is asserted on macOS + Linux. An explicit t.skip,
// never a bare `return` — in node:test that would be a PASS (ADR-2719 §6).
t.skip('emitted parity is asserted on macOS + Linux; Windows install output is platform-specific');
return;
}
// hooks/dist is gitignored and built (DEFECT.HOOKS-DIST-SCOPED-CI): the scoped CI
// lane does not run build:hooks, so a real install there would emit no hooks/ dir.
// Build idempotently, exactly as the golden harness does.
execFileSync(process.execPath, [BUILD_SCRIPT], { encoding: 'utf-8', stdio: 'pipe', timeout: 120_000 });
// The base ref is not universally available. The gsd-test runner shallow-clones and
// merges base+head, so no `origin/*` remote-tracking ref exists in the container —
// this test went red on its first matrix run for exactly that reason, which is the
// resolver doing its job and the dependency being wrong.
//
// An explicit t.skip is the ADR-sanctioned response for a genuine environmental
// skip: it is REPORTED as skipped, unlike a bare `return`, which node:test scores as
// a PASS (ADR-2719 §6). Hard-failing instead would make the suite permanently red
// wherever a base ref cannot exist by construction, which is not a propagation
// finding — it is a statement about the checkout.
const resolved = resolveBase();
if (!resolved) {
t.skip(
'no base ref resolvable — tried ' + baseRefCandidates().join(', ') +
'. The differential gate did NOT run here. It binds in the CI test lanes, which ' +
'fetch the base ref explicitly; set GSD_EMITTED_BASE=<ref|sha> to run it elsewhere.',
);
return;
}
const { ref: base, sha: baseSha } = resolved;
assert.match(baseSha, /^[0-9a-f]{40}$/);
// Phase 4 (#2724): the golden fixtures this used to read via `baselineManifestsAtRef`
// (git show <base>:<fixture>) are deleted, so the baseline now comes through
// `resolveBaseline()`'s documented precedence: GSD_EMITTED_BASELINE env (CI's PR-lane
// cache restore) -> the on-disk cache (CI's push-to-next publish step) -> an in-job
// build at `base` (a throwaway git worktree + scripts/gen-emitted-baseline.cjs) ->
// explicit failure. The build fallback is deliberately the slow path — it exists so a
// cache miss degrades rather than fails outright (ADR-2719 §5).
const readBaselineJson = (p) => (fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, 'utf8')) : null);
const resolvedBaseline = resolveBaseline({
expectedSha: baseSha,
readJson: readBaselineJson,
buildFallback: () => buildBaselineAtRef(base),
});
assert.ok(
resolvedBaseline.ok,
// #2854: report the sources actually reached, not a hardcoded list of all three. An
// early return could claim it "tried an in-job build" it never called, which sent
// contributors hunting a rebuild that had not run.
`no usable emitted baseline for ${base}@${baseSha.slice(0, 12)} (tried ` +
`${(resolvedBaseline.attempted || []).join(', ') || 'nothing'}):` +
`\n ${(resolvedBaseline.errors || []).join('\n ')}`,
);
const baseline = resolvedBaseline.baseline;
assert.ok(baseline && Object.keys(baseline).length > 0, `resolved baseline via ${resolvedBaseline.via} has no families`);
const changedPaths = resolveChangedPaths(base);
// #3942: acknowledgments live in COMMIT TRAILERS over `<merge-base>..HEAD` now, never
// the legacy fragment directory / single file `readAckSources` unions — that read
// source is retired for this, the shipping caller (40-design.md). `readAckTrailers`
// resolves the merge-base internally from `base`, which is the SAME ref
// `resolveChangedPaths` used to build `changedPaths` above — the ack range and the
// change range must share one base, or a trailer could excuse a delta structurally
// outside the diff (40-design.md Correction 2). `trailerErrors` (a per-value parse
// problem — bad delimiter, empty reason, an ambiguous double declaration) folds into
// `diffEmitted`'s own errors below exactly like any other ack schema problem, never
// silently resolved.
const { hash: ackHash, growth: ackGrowth, errors: trailerErrors } = readAckTrailers({ baseRef: base });
const current = currentManifests();
// There is no base-side read here at all (contrast the pre-#3942 `readAckSourcesAtRef`
// call this replaces): a trailer read over `<merge-base>..HEAD` is structurally
// incapable of returning an entry that was "already at the base" — an ancestor of the
// merge-base is out of range by construction (40-design.md row 8, Correction 1) — so
// `baseAck` is not passed, and `diffEmitted`'s `spentAcks` is always empty for this
// caller.
// Reconcile the family SET across three independent signals, rather than asserting one
// count against both sides. The baseline is built at the base ref and the current tree
// at PR HEAD, so the two legitimately differ by a family whenever a PR adds or removes
// a runtime — a single shared literal could satisfy neither side at once (#2723), and
// a count cannot see a membership swap that leaves the total unchanged either way.
const familyVerdict = reconcileFamilies({
derived: MANIFEST_FAMILIES,
fixtures: loadManifests().map((m) => m.file.replace(/\.json$/, '')),
baseline,
current,
changedPaths,
});
assert.ok(
familyVerdict.ok,
'emitted manifest family set is not reconciled:\n ' +
familyVerdict.errors
.map((e) => (e.family ? `${e.code}: ${e.family}` : e.code))
.join('\n '),
);
const result = diffEmitted({
baseline,
current,
changedPaths,
ackHash,
ackGrowth,
sizeBaseline: resolvedBaseline.sizeBaseline,
sizeCurrent: currentSizes(),
mergeAckErrors: trailerErrors,
});
assert.ok(
result.ok,
`emitted-attribution failed against ${base}@${baseSha.slice(0, 12)}:\n\n${formatReport(result)}`,
);
});
// ─── Cross-tree version normalization (#2891) ──────────────────────────────────
//
// #2767's `currentManifests({ repoRoot })` spawns a DIFFERENT checkout's installer
// but, before this fix, still normalized the emitted content against THIS checkout's
// PKG_VERSION — so a version-bumped current tree compared against an older-version
// baseline worktree never collapsed the baseline's `// gsd-hook-version: <old>` stamp
// to '<VERSION>', every one of that baseline's emitted files spuriously "differed",
// and the differential attribution gate above (the real-tree test) failed with all
// 364 emitted hook paths unattributed. These tests pin the mechanism directly against
// `buildParityManifest`'s `pkgVersion` option and `measuredPackageVersion`, the two
// pieces `currentManifests` composes to fix it, rather than only against the
// expensive real-tree gate.
function makeVersionStampedTree(hookVersion) {
const root = createTempDir('gsd-test-ppm-version-');
const configDir = path.join(root, 'cfg');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(
path.join(configDir, 'hook.js'),
`// gsd-hook-version: ${hookVersion}\nconsole.log('hook body unchanged across versions');\n`,
);
return { root, configDir };
}
test('buildParityManifest: same content at two different pkgVersions hashes identically when each is normalized against its OWN version (#2891)', () => {
const a = makeVersionStampedTree('1.8.0');
const b = makeVersionStampedTree('1.9.0');
try {
const manifestA = buildParityManifest(a.configDir, a.root, { pkgVersion: '1.8.0' });
const manifestB = buildParityManifest(b.configDir, b.root, { pkgVersion: '1.9.0' });
assert.equal(
manifestA['hook.js'],
manifestB['hook.js'],
'byte-identical-apart-from-version-stamp files must hash identically once each side ' +
'is normalized against the version that actually produced it'
);
} finally {
cleanup(a.root);
cleanup(b.root);
}
});
test('buildParityManifest: hash of a measured tree does not depend on the MEASURING repo\'s own version (#2891)', () => {
// Reproduces the real cross-tree shape: content stamped with version X, normalized
// with the EXPLICIT pkgVersion of the tree that produced it (X) — never with this
// checkout's own PKG_VERSION (Y), which is what the pre-fix bug silently defaulted to.
const measuredVersion = '7.7.7';
assert.notEqual(
measuredVersion,
PKG_VERSION,
'test fixture must use a version distinct from this checkout\'s own PKG_VERSION for the assertion below to be meaningful'
);
const tree = makeVersionStampedTree(measuredVersion);
try {
const manifest = buildParityManifest(tree.configDir, tree.root, { pkgVersion: measuredVersion });
// The stamp must have collapsed to '<VERSION>' — if it hadn't (e.g. because the
// measuring repo's own PKG_VERSION had been used instead), the raw '7.7.7' would
// still be present pre-hash and this hash would differ from a control manifest
// built directly against the sentinel-substituted content.
const controlContent = `// gsd-hook-version: <VERSION>\nconsole.log('hook body unchanged across versions');\n`;
const controlHash = crypto.createHash('sha256').update(controlContent).digest('hex').slice(0, 16);
assert.equal(
manifest['hook.js'],
controlHash,
'hash must reflect the version-stamp collapsing to <VERSION> using the MEASURED tree\'s ' +
'own version, independent of whatever PKG_VERSION the measuring repo happens to be at'
);
} finally {
cleanup(tree.root);
}
});
test('buildParityManifest: pkgVersion guard rejects empty/undefined/null/non-string/non-semver-shaped and never corrupts the manifest (#2891)', () => {
const tree = makeVersionStampedTree('1.8.0');
try {
// Omitting pkgVersion entirely is legitimate (defaults to this checkout's own
// PKG_VERSION) and must NOT throw.
assert.doesNotThrow(() => buildParityManifest(tree.configDir, tree.root));
// Explicitly passing a bad value must throw — including an EXPLICIT `undefined`,
// which is deliberately NOT treated the same as omitting the key (see the `in`
// guard in install-shared.cjs: a caller-side bug that resolves a version to
// `undefined` must fail loudly, never silently fall back to this checkout's own
// version). '1' and '12' are shape failures (#2891 review FINDING 2): a
// non-semver-shaped string like '1' must be rejected, not silently accepted and
// later matched as a substring of unrelated numeric content (e.g. 'v 1.8.0 x').
for (const bad of ['', undefined, null, 42, '1', '12']) {
assert.throws(
() => buildParityManifest(tree.configDir, tree.root, { pkgVersion: bad }),
/pkgVersion must be a non-empty semver-ish string/,
`expected pkgVersion=${JSON.stringify(bad)} to throw`
);
}
// Corruption check: an empty pkgVersion, if it ever reached blind substring
// replacement, would corrupt content that merely contains matching characters.
// Confirm the guard fires BEFORE that — a file whose entire content is 'abc' must
// never make it into a manifest via a '' pkgVersion.
const corruptibleRoot = createTempDir('gsd-test-ppm-corrupt-');
const corruptibleDir = path.join(corruptibleRoot, 'cfg');
fs.mkdirSync(corruptibleDir, { recursive: true });
fs.writeFileSync(path.join(corruptibleDir, 'f.txt'), 'abc');
try {
assert.throws(
() => buildParityManifest(corruptibleDir, corruptibleRoot, { pkgVersion: '' }),
/pkgVersion must be a non-empty semver-ish string/
);
} finally {
cleanup(corruptibleRoot);
}
} finally {
cleanup(tree.root);
}
});
test('buildParityManifest: pkgVersion shape guard boundary — limit-1/limit/limit+1 by length (#2891 review FINDING 2)', () => {
const tree = makeVersionStampedTree('1.8.0');
try {
// '0.0.0' is the shortest string SEMVER_ISH_RE accepts (5 chars: MAJOR.MINOR.PATCH,
// all single-digit, no prerelease/build) — the "limit" case.
assert.doesNotThrow(
() => buildParityManifest(tree.configDir, tree.root, { pkgVersion: '0.0.0' }),
'a minimal valid MAJOR.MINOR.PATCH string must be accepted (limit)'
);
// '12' (limit+1 relative to the 1-char failure below, and still nowhere near
// semver-shaped) must still be rejected.
assert.throws(
() => buildParityManifest(tree.configDir, tree.root, { pkgVersion: '12' }),
/pkgVersion must be a non-empty semver-ish string/,
'a 2-char non-semver-shaped string must be rejected (limit+1 by length from \'1\')'
);
// '1' (limit-1 relative to '12') must be rejected — the concrete regression this
// guard exists to close: {pkgVersion:'1'} was previously ACCEPTED and rewrote
// 'v 1.8.0 x' to 'v <VERSION>.8.0 x' via a bare-substring match.
assert.throws(
() => buildParityManifest(tree.configDir, tree.root, { pkgVersion: '1' }),
/pkgVersion must be a non-empty semver-ish string/,
'a 1-char string must be rejected (limit-1)'
);
} finally {
cleanup(tree.root);
}
});
test('buildParityManifest: pkgVersion is honored when reachable via the PROTOTYPE CHAIN, not only as an own key (#2891 review FINDING 4)', () => {
const tree = makeVersionStampedTree('7.7.7');
try {
// Object.create({pkgVersion:'7.7.7'}) has NO own 'pkgVersion' key, but the key IS
// reachable via `in` — before the fix this silently fell through to this
// checkout's own PKG_VERSION (the exact silent-fallback the guard exists to
// prevent), reached via a different vector than an explicit own-key bad value.
const opts = Object.create({ pkgVersion: '7.7.7' });
const manifest = buildParityManifest(tree.configDir, tree.root, opts);
const controlContent = `// gsd-hook-version: <VERSION>\nconsole.log('hook body unchanged across versions');\n`;
const controlHash = crypto.createHash('sha256').update(controlContent).digest('hex').slice(0, 16);
assert.equal(
manifest['hook.js'],
controlHash,
'an inherited pkgVersion must be read and normalized against, not silently ignored in favor of this checkout\'s own PKG_VERSION'
);
} finally {
cleanup(tree.root);
}
});
test('buildParityManifest: opts guard rejects null/non-object (#2891 review FINDING 5)', () => {
const tree = makeVersionStampedTree('1.8.0');
try {
for (const bad of [null, 'x', 42, true, []]) {
assert.throws(
() => buildParityManifest(tree.configDir, tree.root, bad),
/opts must be a plain object or omitted/,
`expected opts=${JSON.stringify(bad)} to throw a clear message, not a raw TypeError`
);
}
} finally {
cleanup(tree.root);
}
});
test('buildInstallTree: no longer accepts/forwards an opts argument, so a bad third argument is silently ignored rather than reaching buildParityManifest\'s guard (#2891 review FINDINGS 5+6)', () => {
// FINDING 6 removed buildInstallTree's dead opts-forwarding parameter (pkgVersion
// never affects the emitted FILE SET, only content hashes, and no caller ever passed
// a third argument). A consequence: buildInstallTree(cd, root, null) — the exact
// FINDING 5 repro against the OLD forwarding code — no longer reaches
// buildParityManifest's opts guard at all; the extra argument is simply unused,
// consistent with ordinary JS call semantics, and buildParityManifest gets its
// default `{}`. This must NOT throw.
const tree = makeVersionStampedTree('1.8.0');
try {
assert.doesNotThrow(() => buildInstallTree(tree.configDir, tree.root, null));
assert.deepEqual(
buildInstallTree(tree.configDir, tree.root, null),
buildInstallTree(tree.configDir, tree.root),
'a discarded third argument must not change the result'
);
} finally {
cleanup(tree.root);
}
});
test('measuredPackageVersion: resolves this checkout\'s version with no repoRoot, the measured tree\'s version with one, and fails closed (#2891)', () => {
// No repoRoot at all (key genuinely absent): this checkout's own PKG_VERSION, no
// filesystem I/O.
assert.equal(measuredPackageVersion(), PKG_VERSION);
// Explicit `undefined` is the ONLY falsy value treated as "this checkout" — every
// OTHER falsy value ('', 0, false) is a caller-side bug and must fail closed rather
// than silently defaulting, consistent with `currentManifests`' installScript gate
// and with `buildParityManifest`'s pkgVersion guard (#2891 review FINDING 7).
assert.equal(measuredPackageVersion(undefined), PKG_VERSION);
for (const bad of ['', 0, false]) {
assert.throws(
() => measuredPackageVersion(bad),
/repoRoot must be a non-empty path or omitted entirely/,
`expected repoRoot=${JSON.stringify(bad)} to throw`
);
}
// A different tree's package.json: its OWN version, not this checkout's.
const measuredRoot = createTempDir('gsd-test-mpv-ok-');
try {
fs.writeFileSync(
path.join(measuredRoot, 'package.json'),
JSON.stringify({ name: 'measured-tree', version: '9.9.9' }),
);
assert.equal(measuredPackageVersion(measuredRoot), '9.9.9');
} finally {
cleanup(measuredRoot);
}
// Missing package.json: fails closed, never falls back to this checkout's version.
const missingRoot = createTempDir('gsd-test-mpv-missing-');
try {
assert.throws(() => measuredPackageVersion(missingRoot), /cannot read/);
} finally {
cleanup(missingRoot);
}
// Unparseable package.json.
const badJsonRoot = createTempDir('gsd-test-mpv-badjson-');
try {
fs.writeFileSync(path.join(badJsonRoot, 'package.json'), '{not json');
assert.throws(() => measuredPackageVersion(badJsonRoot), /not valid JSON/);
} finally {
cleanup(badJsonRoot);
}
// Version-less package.json (key ABSENT entirely) — the only branch the pre-review
// test suite drove.
const noVersionRoot = createTempDir('gsd-test-mpv-noversion-');
try {
fs.writeFileSync(path.join(noVersionRoot, 'package.json'), JSON.stringify({ name: 'no-version' }));
assert.throws(() => measuredPackageVersion(noVersionRoot), /no non-empty string "version" field/);
} finally {
cleanup(noVersionRoot);
}
// "version" key PRESENT but an empty string — a distinct branch from "absent"
// (`typeof '' === 'string'` but `''.length === 0`); the pre-review suite never drove
// it and both surviving mutants collapse this into the absent-key case (#2891 review
// FINDING 3).
const emptyVersionRoot = createTempDir('gsd-test-mpv-emptyversion-');
try {
fs.writeFileSync(path.join(emptyVersionRoot, 'package.json'), JSON.stringify({ version: '' }));
assert.throws(() => measuredPackageVersion(emptyVersionRoot), /no non-empty string "version" field/);
} finally {
cleanup(emptyVersionRoot);
}
// "version" key PRESENT but non-string (e.g. a bare JSON number) — the other branch
// `typeof version !== 'string'` guards, distinct from both "absent" and "empty
// string" (#2891 review FINDING 3).
const numericVersionRoot = createTempDir('gsd-test-mpv-numericversion-');
try {
fs.writeFileSync(path.join(numericVersionRoot, 'package.json'), JSON.stringify({ version: 123 }));
assert.throws(() => measuredPackageVersion(numericVersionRoot), /no non-empty string "version" field/);
} finally {
cleanup(numericVersionRoot);
}
// Unreadable package.json: monkeypatch fs.readFileSync (NEVER chmod 0o000 — root
// bypasses mode bits and the test would silently pass with zero coverage in root
// Docker/CI). Save original, override to throw, assert.throws, restore in `finally`.
const unreadableRoot = createTempDir('gsd-test-mpv-unreadable-');
try {
fs.writeFileSync(path.join(unreadableRoot, 'package.json'), JSON.stringify({ version: '1.0.0' }));
const orig = fs.readFileSync;
try {
fs.readFileSync = () => { throw new Error('injected package.json read failure'); };
assert.throws(() => measuredPackageVersion(unreadableRoot), /injected package\.json read failure/);
} finally {
fs.readFileSync = orig;
}
// Restoration is real, not assumed.
assert.equal(measuredPackageVersion(unreadableRoot), '1.0.0');
} finally {
cleanup(unreadableRoot);
}
});
// ── #3271: the timeout ladder must escalate inward-out ──────────────────────────
//
// Three nested bounds govern this file's two heavy tests: the per-STEP bound inside
// buildBaselineAtRef, the per-TEST timeout node:test enforces, and the whole-CHUNK
// timeout in scripts/run-tests.cjs. They only produce a useful failure if they fire
// in that order. When the step bound was raised to the chunk ceiling, the chunk won
// the race and the failure arrived as an opaque "no failed step" kill — the clean
// per-step message was built and then made unreachable in the same change.
describe('#3271: emitted-runtime-bounds', () => {
const PER_TEST_TIMEOUT_MS = 480_000;
test('the step bound fires before the per-test timeout', () => {
assert.ok(
BUILD_TIMEOUT_MS < PER_TEST_TIMEOUT_MS,
`step bound ${BUILD_TIMEOUT_MS}ms must be under the per-test timeout ${PER_TEST_TIMEOUT_MS}ms, ` +
'or node:test kills the test before buildBaselineAtRef can say which step stalled',
);
});
test('the per-test timeout fires before the whole-chunk timeout', () => {
assert.ok(
PER_TEST_TIMEOUT_MS < CHUNK_TIMEOUT_CEILING_MS,
`per-test timeout ${PER_TEST_TIMEOUT_MS}ms must be under the chunk ceiling ` +
`${CHUNK_TIMEOUT_CEILING_MS}ms (scripts/run-tests.cjs:973), or the chunk is killed first ` +
'and the failure is reported with no failing step at all',
);
});
test('a realistic full build still fits inside the per-test timeout', () => {
// Steps 1 and 2 measured at 15.1s and 19.8s on the remote runner. Their own
// bounds (60s + 180s) are worst-case ceilings, not expected cost; asserting on
// the SUM of all three ceilings would demand a per-test timeout larger than the
// chunk allows and lock in an impossible ladder.
const realisticPreamble = 60_000;
assert.ok(
BUILD_TIMEOUT_MS + realisticPreamble < PER_TEST_TIMEOUT_MS,
'the generator bound plus a realistic worktree+build preamble must fit inside the per-test timeout',
);
});
test('the declared bounds are the ones this file actually uses', () => {
// Guards the drift this ladder depends on: if a call site's literal timeout is
// edited without updating PER_TEST_TIMEOUT_MS, the two tests above keep passing
// while the real ladder is inverted. Asserted behaviorally against the helper's
// exported values rather than by scanning source text.
assert.equal(WORKTREE_TIMEOUT_MS, 60_000);
assert.equal(BUILD_LIB_TIMEOUT_MS, 180_000);
assert.equal(BUILD_TIMEOUT_MS, 360_000);
assert.equal(CHUNK_TIMEOUT_CEILING_MS, 600_000);
});
test('a failing step names itself and its elapsed time', () => {
// The message is the only channel that survives into the remote runner's
// failures.json — its captured `output` field comes back empty. A bare
// "spawnSync ETIMEDOUT" cost four separate experiments to re-derive what the
// throw already had.
let thrown;
assert.throws(
() => buildBaselineAtRef('refs/heads/definitely-not-a-real-ref-3271'),
(err) => { thrown = err; return true; },
);
assert.match(thrown.message, /git-worktree-add failed after [\d.]+s/);
assert.match(thrown.message, /Step timings: git-worktree-add=FAILED@[\d.]+s/);
assert.match(thrown.message, /bounds: worktree 60000ms, build:lib 180000ms, generator 360000ms/);
});
});
// ADR-3942 §6 deletes the legacy pre-merge lint / guard-no-ack-on-next / the #3842 and
// #3875 open-PR-deferred fragment-sweep machinery, plus tests/emitted-drift-acks/ itself
// — the commit-trailer acknowledgment leaves no tree artifact to sweep, defer, or guard.
// Every describe block that lived here exercised that now-deleted script and is gone with it.