4723 lines
220 KiB
JavaScript
4723 lines
220 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 os = require('node:os');
|
||
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 { escapeRegex } = require('../gsd-core/bin/lib/pattern.cjs');
|
||
const {
|
||
resolveChangedPaths,
|
||
resolveBase,
|
||
baseRefCandidates,
|
||
buildBaselineAtRef,
|
||
currentManifests,
|
||
currentSizes,
|
||
readAckFile,
|
||
readAckFileAtRef,
|
||
readAckSources,
|
||
readAckSourcesAtRef,
|
||
listAckFragmentFiles,
|
||
listAckFragmentFilesAtRef,
|
||
ACK_REPO_PATH,
|
||
ACK_DIR,
|
||
ACK_DIR_REPO_PATH,
|
||
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');
|
||
const {
|
||
validateAckText,
|
||
assertAbsentOnNext,
|
||
assertNoAllSpentFragments,
|
||
ackProse,
|
||
ackEntries,
|
||
ACK_INVISIBLE,
|
||
MAX_ACK_FRAGMENTS: MAX_ACK_FRAGMENTS_LINT,
|
||
resolveBaseRef,
|
||
readFragmentAtRef,
|
||
assertUsableBaseRef,
|
||
fetchOpenPrTouchedAckPaths,
|
||
MAX_OPEN_PRS,
|
||
MAX_PR_FILES,
|
||
GITHUB_MAX_PR_FILES,
|
||
runGuardNext,
|
||
} = require('../scripts/lint-emitted-drift-ack.cjs');
|
||
const {
|
||
ACK_VERSION,
|
||
ACK_FILE,
|
||
ACK_DIR: ACK_DIR_PURE,
|
||
NEW_FILE_CAP,
|
||
MAX_ACK_FRAGMENTS,
|
||
REMEDIATION,
|
||
INVISIBLE,
|
||
normalizeAckReason,
|
||
sourceSatisfiedBy,
|
||
parseAck,
|
||
mergeAckSources,
|
||
diffEmitted,
|
||
buildReport,
|
||
formatReport,
|
||
} = 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 });
|
||
|
||
// ─── 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 ack = { version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'converter change, #2723' } } };
|
||
const r = diffEmitted({
|
||
baseline: mf({ [WORKFLOW_KEY]: 'aaa' }),
|
||
current: mf({ [WORKFLOW_KEY]: 'bbb' }),
|
||
changedPaths: ['README.md'],
|
||
ack,
|
||
baseAck: null,
|
||
});
|
||
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 ack = { version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'old' } } };
|
||
const r = diffEmitted({
|
||
baseline: mf({ [WORKFLOW_KEY]: 'aaa' }),
|
||
current: mf({ [WORKFLOW_KEY]: 'aaa' }),
|
||
changedPaths: [],
|
||
ack,
|
||
baseAck: null,
|
||
});
|
||
assert.deepEqual(r.staleAcks, [WORKFLOW_KEY]);
|
||
assert.ok(!r.ok);
|
||
assert.match(formatReport(r), /stale acknowledgment/);
|
||
});
|
||
|
||
test('an ack without a reason fails', () => {
|
||
for (const bad of [{ reason: '' }, { reason: ' ' }, {}, null, 42]) {
|
||
const r = diffEmitted({
|
||
baseline: mf({ [WORKFLOW_KEY]: 'aaa' }),
|
||
current: mf({ [WORKFLOW_KEY]: 'bbb' }),
|
||
changedPaths: [],
|
||
ack: { version: ACK_VERSION, paths: { [WORKFLOW_KEY]: bad } },
|
||
baseAck: null,
|
||
});
|
||
assert.ok(!r.ok, `${JSON.stringify(bad)} must be rejected`);
|
||
assert.match(r.errors.join('\n'), /has no non-empty "reason"/);
|
||
}
|
||
});
|
||
|
||
test('an absent ack file means no acks', () => {
|
||
const r = diffEmitted({
|
||
baseline: mf({ [WORKFLOW_KEY]: 'aaa' }),
|
||
current: mf({ [WORKFLOW_KEY]: 'bbb' }),
|
||
changedPaths: [WORKFLOW_SRC],
|
||
ack: null,
|
||
});
|
||
assert.equal(r.errors.length, 0);
|
||
assert.ok(r.ok, 'the healthy steady state is no ack file at all');
|
||
});
|
||
|
||
test('a live ack and a stale ack together: only the stale one is named', () => {
|
||
const ack = {
|
||
version: ACK_VERSION,
|
||
paths: {
|
||
[WORKFLOW_KEY]: { reason: 'live ripple' },
|
||
[SKILL_KEY]: { reason: 'stale' },
|
||
},
|
||
};
|
||
const r = diffEmitted({
|
||
baseline: mf({ [WORKFLOW_KEY]: 'aaa', [SKILL_KEY]: 'ccc' }),
|
||
current: mf({ [WORKFLOW_KEY]: 'bbb', [SKILL_KEY]: 'ccc' }),
|
||
changedPaths: [],
|
||
ack,
|
||
baseAck: null,
|
||
});
|
||
assert.deepEqual(r.staleAcks, [SKILL_KEY], 'the live one must not be named');
|
||
});
|
||
|
||
// ─── Ack lifecycle: an ack is scoped to the diff that introduced it (#2789) ──
|
||
//
|
||
// Every other input to the law is base-relative — `baseline` vs `current`, `changedPaths`
|
||
// from `git diff base...HEAD`. The ack set was the one absolute input, read only from
|
||
// HEAD. That mismatch is what made a MERGED ack look identical to a never-explained one:
|
||
// both present as "no delta consumed it", so merging an ack the PR lane had accepted
|
||
// reddened `next` and every PR branching off it (#2768).
|
||
//
|
||
// `baseAck` closes it. An entry already present at the base is SPENT — its ripple is
|
||
// absorbed, it is not this diff's to answer for, and it may no longer clear anything.
|
||
|
||
test('an ack already present at the base is spent — not stale, and it does not fail', () => {
|
||
// The #2768 shape exactly: the ack merged, so the base carries it and no delta remains.
|
||
const ack = { version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'deliberate growth' } } };
|
||
const r = diffEmitted({
|
||
baseline: mf({ [WORKFLOW_KEY]: 'aaa' }),
|
||
current: mf({ [WORKFLOW_KEY]: 'aaa' }),
|
||
changedPaths: [],
|
||
ack,
|
||
baseAck: ack,
|
||
});
|
||
assert.deepEqual(r.staleAcks, [], 'an absorbed ripple is the ack SUCCEEDING, not failing');
|
||
assert.deepEqual(r.spentAcks, [WORKFLOW_KEY], 'still surfaced, so it can be cleaned up');
|
||
assert.ok(r.ok);
|
||
});
|
||
|
||
test('a spent ack cannot pre-clear a NEW ripple on its own path', () => {
|
||
// ADR-2719's own named hazard. Today a leftover ack silently clears the next ripple;
|
||
// scoped to its diff it cannot, so the new ripple must be explained on its own terms.
|
||
const ack = { version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'last time' } } };
|
||
const r = diffEmitted({
|
||
baseline: mf({ [WORKFLOW_KEY]: 'aaa' }),
|
||
current: mf({ [WORKFLOW_KEY]: 'bbb' }), // a genuinely new, unexplained move
|
||
changedPaths: [],
|
||
ack,
|
||
baseAck: ack,
|
||
});
|
||
assert.equal(r.acked.length, 0, 'a spent ack must not absorb a new ripple');
|
||
assert.equal(r.unattributable.length, 1);
|
||
assert.ok(!r.ok);
|
||
});
|
||
|
||
test('re-arming a spent ack costs actual prose — not whitespace, not a decorative field', () => {
|
||
// Re-arming is legitimate; it is how a contributor says "this is a NEW ripple, and here
|
||
// is why". But the reason is the whole artifact a reviewer reads, so it must cost a
|
||
// real explanation. Both of these once re-armed an ack whose justification still
|
||
// described the PREVIOUS ripple, showing a reviewer nothing new in the ack file's diff.
|
||
const base = { version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'the same words' } } };
|
||
const newRipple = {
|
||
baseline: mf({ [WORKFLOW_KEY]: 'aaa' }),
|
||
current: mf({ [WORKFLOW_KEY]: 'bbb' }), // genuinely new and unexplained
|
||
changedPaths: [],
|
||
baseAck: base,
|
||
};
|
||
|
||
const doubledSpace = diffEmitted({
|
||
...newRipple,
|
||
ack: { version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'the same words' } } },
|
||
});
|
||
assert.equal(doubledSpace.acked.length, 0, 'internal whitespace must not re-arm');
|
||
assert.ok(!doubledSpace.ok);
|
||
|
||
const decoratedField = diffEmitted({
|
||
...newRipple,
|
||
ack: { version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'the same words', runtime: 'claude' } } },
|
||
});
|
||
assert.equal(decoratedField.acked.length, 0, 'an unrelated field must not re-arm');
|
||
assert.ok(!decoratedField.ok);
|
||
|
||
// …while genuinely new prose still does.
|
||
const reworded = diffEmitted({
|
||
...newRipple,
|
||
ack: { version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'a different, specific explanation' } } },
|
||
});
|
||
assert.equal(reworded.acked.length, 1);
|
||
assert.ok(reworded.ok);
|
||
});
|
||
|
||
test('spent entries are reported sorted, and modelled in buildReport not just rendered', () => {
|
||
// Insertion order is deliberately REVERSE-sorted (`skills/…` before `gsd-core/…`), so
|
||
// the assertion bites: comparing against a sorted copy of the result would pass even
|
||
// with the sort deleted, and asserting on an already-ordered fixture proves nothing.
|
||
const both = {
|
||
version: ACK_VERSION,
|
||
paths: { [SKILL_KEY]: { reason: 'second' }, [WORKFLOW_KEY]: { reason: 'first' } },
|
||
};
|
||
assert.ok(SKILL_KEY > WORKFLOW_KEY, 'the fixture must be inserted out of order to be a real test');
|
||
|
||
const r = diffEmitted({
|
||
baseline: mf({ 'gsd-core/workflows/zzz.md': 'aaa' }),
|
||
current: mf({ 'gsd-core/workflows/zzz.md': 'bbb' }), // an unrelated failure to render under
|
||
changedPaths: [],
|
||
ack: both,
|
||
baseAck: both,
|
||
});
|
||
assert.deepEqual(r.spentAcks, [WORKFLOW_KEY, SKILL_KEY], 'spent entries must come back sorted');
|
||
|
||
const block = buildReport(r).blocks.find((b) => b.kind === 'spent-acks');
|
||
assert.ok(block, 'spent acks must be modelled in the IR, so tests need no raw text matching');
|
||
assert.equal(block.count, 2);
|
||
assert.deepEqual(block.items, r.spentAcks);
|
||
});
|
||
|
||
test('buildReport and formatReport agree about spent acks on a PASSING run', () => {
|
||
// `formatReport` is documented as a pure rendering of `buildReport`. The spent section
|
||
// is the one block whose emit-condition could drift, because a passing run must render
|
||
// nothing — so the IR must withhold it there too, or a JSON reporter built on the IR
|
||
// would report spent acks for a green run while the text reporter stayed silent.
|
||
const spent = { version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'absorbed' } } };
|
||
const passing = diffEmitted({
|
||
baseline: mf({ [WORKFLOW_KEY]: 'aaa' }),
|
||
current: mf({ [WORKFLOW_KEY]: 'aaa' }),
|
||
changedPaths: [],
|
||
ack: spent,
|
||
baseAck: spent,
|
||
});
|
||
assert.ok(passing.ok);
|
||
assert.deepEqual(passing.spentAcks, [WORKFLOW_KEY], 'the datum is still on the result object');
|
||
assert.equal(formatReport(passing), '');
|
||
assert.equal(
|
||
buildReport(passing).blocks.find((b) => b.kind === 'spent-acks'),
|
||
undefined,
|
||
'the IR must not carry a block the renderer suppresses',
|
||
);
|
||
});
|
||
|
||
test('ackDocument survives a __proto__ key instead of silently teaching an empty document', () => {
|
||
// `key` comes from repo/emitted paths. On a plain object `__proto__` sets the prototype
|
||
// rather than a property, so JSON.stringify would emit `"paths":{}` — remediation text
|
||
// that teaches the contributor to acknowledge nothing at all.
|
||
const doc = JSON.parse(REMEDIATION.ackDocument([
|
||
{ key: '__proto__', reason: 'hostile key' },
|
||
{ key: 'plan-phase.md', reason: 'ordinary key' },
|
||
]));
|
||
assert.deepEqual(Object.keys(doc.paths).sort(), ['__proto__', 'plan-phase.md']);
|
||
assert.equal(doc.paths.__proto__.reason, 'hostile key');
|
||
assert.equal(({}).reason, undefined, 'Object.prototype must be untouched');
|
||
});
|
||
|
||
test('a clean run renders NOTHING, even when spent entries exist', () => {
|
||
// `formatReport` returning prose for an ok result reads as "something is wrong".
|
||
const spent = { version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'absorbed' } } };
|
||
const r = diffEmitted({
|
||
baseline: mf({ [WORKFLOW_KEY]: 'aaa' }),
|
||
current: mf({ [WORKFLOW_KEY]: 'aaa' }),
|
||
changedPaths: [],
|
||
ack: spent,
|
||
baseAck: spent,
|
||
});
|
||
assert.ok(r.ok);
|
||
assert.deepEqual(r.spentAcks, [WORKFLOW_KEY]);
|
||
assert.equal(formatReport(r), '', 'a passing run must render an empty report');
|
||
});
|
||
|
||
test('an ack whose reason changed in this diff is live again', () => {
|
||
const r = diffEmitted({
|
||
baseline: mf({ [WORKFLOW_KEY]: 'aaa' }),
|
||
current: mf({ [WORKFLOW_KEY]: 'bbb' }),
|
||
changedPaths: [],
|
||
ack: { version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'THIS ripple, freshly explained' } } },
|
||
baseAck: { version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'the previous one' } } },
|
||
});
|
||
assert.equal(r.acked.length, 1, 'rewriting the reason re-arms the ack for the new ripple');
|
||
assert.deepEqual(r.staleAcks, []);
|
||
assert.ok(r.ok);
|
||
});
|
||
|
||
test('an ack absent from the base is live and consumes its ripple', () => {
|
||
const r = diffEmitted({
|
||
baseline: mf({ [WORKFLOW_KEY]: 'aaa' }),
|
||
current: mf({ [WORKFLOW_KEY]: 'bbb' }),
|
||
changedPaths: [],
|
||
ack: { version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'new in this PR' } } },
|
||
baseAck: { version: ACK_VERSION, paths: { [SKILL_KEY]: { reason: 'unrelated, already merged' } } },
|
||
});
|
||
assert.equal(r.acked.length, 1);
|
||
assert.deepEqual(r.staleAcks, []);
|
||
assert.ok(r.ok);
|
||
});
|
||
|
||
test('a LIVE ack that nothing consumes is still stale and still fails', () => {
|
||
// The softening must not reach the case the rule exists for: an ack written in THIS
|
||
// diff that never explained anything is an authoring mistake, and blame lands right.
|
||
const r = diffEmitted({
|
||
baseline: mf({ [WORKFLOW_KEY]: 'aaa' }),
|
||
current: mf({ [WORKFLOW_KEY]: 'aaa' }),
|
||
changedPaths: [],
|
||
ack: { version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'explains nothing' } } },
|
||
baseAck: { version: ACK_VERSION, paths: { [SKILL_KEY]: { reason: 'unrelated' } } },
|
||
});
|
||
assert.deepEqual(r.staleAcks, [WORKFLOW_KEY]);
|
||
assert.deepEqual(r.spentAcks, []);
|
||
assert.ok(!r.ok);
|
||
});
|
||
|
||
test('an absent or unreadable base ack inherits NOTHING — the gate stays armed', () => {
|
||
// Omission is not evidence that an entry was already merged. Every unknown here fails
|
||
// toward the strict reading, so a base we could not read cannot excuse a stale ack.
|
||
// `undefined` is deliberately NOT in this list: a destructuring default fires on it, so
|
||
// it takes the OMITTED path and fails with "baseAck was not supplied" — a different
|
||
// rule, covered by its own test above. Including it here would look like coverage of
|
||
// the staleness path while asserting something else entirely.
|
||
for (const baseAck of [null, {}, { version: ACK_VERSION }, 'not-an-object', 42, []]) {
|
||
const r = diffEmitted({
|
||
baseline: mf({ [WORKFLOW_KEY]: 'aaa' }),
|
||
current: mf({ [WORKFLOW_KEY]: 'aaa' }),
|
||
changedPaths: [],
|
||
ack: { version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'x' } } },
|
||
baseAck,
|
||
});
|
||
assert.deepEqual(r.staleAcks, [WORKFLOW_KEY], `baseAck ${JSON.stringify(baseAck)} must inherit nothing`);
|
||
assert.ok(!r.ok);
|
||
}
|
||
});
|
||
|
||
// ─── Pre-merge lint parity (#2789) ───────────────────────────────────────────
|
||
//
|
||
// `scripts/lint-emitted-drift-ack.cjs` blocks a broken ack document from ever reaching
|
||
// the base branch, where the base-side reader's (correct) loud failure would be
|
||
// expensive. It cannot reuse `parseAck`: `scripts/` ships in the npm package and `tests/`
|
||
// does not, so requiring across that line would be a MODULE_NOT_FOUND in the published
|
||
// package. Two validators of one schema is exactly the divergence this repo requires a
|
||
// parity assertion for — so the corpus below runs through BOTH and must get the same
|
||
// verdict from each.
|
||
|
||
test('the pre-merge lint and the gate parser agree on what is schema-valid', () => {
|
||
const corpus = [
|
||
// [label, raw text, expected schema-valid?]
|
||
['absent-equivalent empty object', '{}', true],
|
||
['versioned, no paths', '{"version":1}', true],
|
||
['empty paths', '{"version":1,"paths":{}}', true],
|
||
['one good entry', '{"version":1,"paths":{"a.md":{"reason":"why"}}}', true],
|
||
['bare-string reason', '{"version":1,"paths":{"a.md":"why"}}', true],
|
||
['no version key', '{"paths":{"a.md":{"reason":"why"}}}', true],
|
||
['unknown extra field', '{"version":1,"paths":{"a.md":{"reason":"why","note":"x"}}}', true],
|
||
['bad JSON', '{ not json', false],
|
||
['array document', '[]', false],
|
||
['scalar document', '42', false],
|
||
['string document', '"nope"', false],
|
||
// NOT here: a document of literally `null`. `parseAck` uses null as its
|
||
// "absent == no acks" sentinel and reads it as legal, so it is schema-valid on both
|
||
// sides; the lint rejects it on POLICY instead. Covered in the entryless test below.
|
||
['wrong version', '{"version":9,"paths":{}}', false],
|
||
['paths is an array', '{"version":1,"paths":[]}', false],
|
||
['paths is a scalar', '{"version":1,"paths":7}', false],
|
||
['empty reason', '{"version":1,"paths":{"a.md":{"reason":""}}}', false],
|
||
['whitespace reason', '{"version":1,"paths":{"a.md":{"reason":" "}}}', false],
|
||
['missing reason', '{"version":1,"paths":{"a.md":{}}}', false],
|
||
['numeric reason', '{"version":1,"paths":{"a.md":42}}', false],
|
||
// #2914 review: a `__proto__`/`constructor`/`prototype` key is a genuine OWN key on
|
||
// the production path (`JSON.parse`, unlike a JS object literal), and both surfaces
|
||
// must reject it outright rather than one silently filtering it and the other
|
||
// erroring or mishandling it.
|
||
['reserved key __proto__', '{"version":1,"paths":{"__proto__":{"reason":"ok"}}}', false],
|
||
['reserved key constructor', '{"version":1,"paths":{"constructor":{"reason":"ok"}}}', false],
|
||
['reserved key prototype', '{"version":1,"paths":{"prototype":{"reason":"ok"}}}', false],
|
||
];
|
||
|
||
for (const [label, raw, expectedValid] of corpus) {
|
||
const lint = validateAckText(raw);
|
||
const lintValid = lint.schemaErrors.length === 0;
|
||
|
||
// The gate's own parser, fed the same document the same way `readAckFile` would.
|
||
let gateValid;
|
||
try {
|
||
gateValid = parseAck(JSON.parse(raw)).errors.length === 0;
|
||
} catch {
|
||
gateValid = false; // unparseable JSON never reaches parseAck; readAckFile throws first
|
||
}
|
||
|
||
assert.equal(lintValid, expectedValid, `lint verdict for ${label}`);
|
||
assert.equal(
|
||
gateValid, lintValid,
|
||
`DIVERGENCE on ${label}: the pre-merge lint and parseAck disagree, so one of them `
|
||
+ 'would let a document through that the other rejects',
|
||
);
|
||
}
|
||
});
|
||
|
||
test('a __proto__/constructor/prototype ack key is rejected loudly by both surfaces, never silently dropped (#2914 review)', () => {
|
||
// Built via JSON.parse — the production path — so the key is a genuine OWN property,
|
||
// never the JS object-literal special case (`{__proto__: v}` sets the prototype and
|
||
// yields zero own keys, which is what made the pre-fix regression test vacuous).
|
||
for (const key of ['__proto__', 'constructor', 'prototype']) {
|
||
const raw = JSON.stringify({ version: ACK_VERSION, paths: { [key]: { reason: 'hostile' } } });
|
||
const doc = JSON.parse(raw);
|
||
assert.deepEqual(Object.keys(doc.paths), [key], `JSON.parse must create a genuine own key for ${key}`);
|
||
|
||
const gate = parseAck(doc, { source: 'tests/emitted-drift-acks/1000-a.json' });
|
||
assert.equal(gate.entries.size, 0, `${key} must never become a live ack entry`);
|
||
assert.equal(gate.errors.length, 1);
|
||
assert.match(gate.errors[0], /reserved/);
|
||
assert.match(gate.errors[0], new RegExp(key));
|
||
|
||
const lint = validateAckText(raw, { source: 'tests/emitted-drift-acks/1000-a.json' });
|
||
assert.equal(lint.schemaErrors.length, 1);
|
||
assert.match(lint.schemaErrors[0], /reserved/);
|
||
assert.match(lint.schemaErrors[0], new RegExp(key));
|
||
|
||
// Recognizably the SAME finding on both surfaces, not merely both non-empty.
|
||
assert.equal(
|
||
gate.errors[0].replace('tests/emitted-drift-acks/1000-a.json', 'SOURCE'),
|
||
lint.schemaErrors[0].replace('tests/emitted-drift-acks/1000-a.json', 'SOURCE'),
|
||
`${key}: parseAck and validateAckText must report the same finding`,
|
||
);
|
||
|
||
assert.equal(({}).reason, undefined, 'Object.prototype must stay untouched throughout');
|
||
}
|
||
});
|
||
|
||
test('the pre-merge lint and the gate helpers agree on the fragment-count cap (#2914 review)', () => {
|
||
// Duplicated by necessity (scripts/ ships, tests/ does not — see MAX_ACK_FRAGMENTS's
|
||
// doc comment in both files), so this parity test is what keeps the two values from
|
||
// silently drifting apart the way the schema rules above are held together.
|
||
assert.equal(
|
||
MAX_ACK_FRAGMENTS_LINT, MAX_ACK_FRAGMENTS,
|
||
'scripts/lint-emitted-drift-ack.cjs and tests/helpers/emitted-diff.cjs must agree on '
|
||
+ 'MAX_ACK_FRAGMENTS',
|
||
);
|
||
});
|
||
|
||
test('the lint additionally rejects a present-but-entryless document the parser accepts', () => {
|
||
// This is policy, not schema, and the one place the two surfaces are MEANT to differ:
|
||
// `parseAck` must treat `{}` as "no acks" (legal) so an absent-equivalent document
|
||
// never fails the gate mid-run, while the lint refuses to let one be COMMITTED,
|
||
// because it acknowledges nothing and only confuses the next reader.
|
||
// `null` belongs here rather than in the schema corpus: it is the gate's own
|
||
// "absent == no acks" sentinel, so it is legal to PARSE and still wrong to COMMIT.
|
||
for (const raw of ['{}', '{"version":1}', '{"version":1,"paths":{}}', 'null']) {
|
||
const r = validateAckText(raw);
|
||
assert.deepEqual(r.schemaErrors, [], `${raw} must be schema-valid`);
|
||
assert.equal(r.policyErrors.length, 1, `${raw} must trip the delete-the-file policy`);
|
||
assert.ok(!r.ok);
|
||
assert.deepEqual(parseAck(JSON.parse(raw)).errors, [], `${raw} must stay legal for the gate`);
|
||
}
|
||
});
|
||
|
||
test('the lint passes on an absent file — the healthy steady state', () => {
|
||
const r = validateAckText(null);
|
||
assert.deepEqual(r.schemaErrors, []);
|
||
assert.deepEqual(r.policyErrors, []);
|
||
assert.ok(r.ok);
|
||
});
|
||
|
||
test('the lint rejects a present-but-empty file rather than reading it as absent', () => {
|
||
for (const raw of ['', ' ', '\n\t ']) {
|
||
const r = validateAckText(raw);
|
||
assert.equal(r.schemaErrors.length, 1, `${JSON.stringify(raw)} must be rejected`);
|
||
// Asserting the SPECIFIC message, not just the count: deleting the empty-file branch
|
||
// leaves `JSON.parse('')` throwing its own single error, so a bare count passes either
|
||
// way and the branch can be removed with no test failing.
|
||
assert.match(r.schemaErrors[0], /present but empty/, `${JSON.stringify(raw)} must name emptiness`);
|
||
assert.ok(!r.ok);
|
||
}
|
||
});
|
||
|
||
test('validateAckText names the SOURCE it is checking, not a hardcoded literal (#2914)', () => {
|
||
// Generalized so the lint can run the SAME rules over a fragment as over the legacy
|
||
// file. A message that hardcoded tests/emitted-drift-ack.json would misname every
|
||
// fragment's own errors.
|
||
const r = validateAckText('{"version":1,"paths":{"a.md":{"reason":""}}}', {
|
||
source: 'tests/emitted-drift-acks/1000-a.json',
|
||
});
|
||
assert.match(r.schemaErrors[0], /tests\/emitted-drift-acks\/1000-a\.json/);
|
||
});
|
||
|
||
test('declaredKeys: only a schema-trustworthy document contributes keys for collision detection', () => {
|
||
const { declaredKeys } = require('../scripts/lint-emitted-drift-ack.cjs');
|
||
assert.deepEqual(declaredKeys(null), []);
|
||
assert.deepEqual(declaredKeys('{ not json'), [], 'unparseable JSON contributes no keys');
|
||
assert.deepEqual(declaredKeys('[]'), [], 'a non-object document contributes no keys');
|
||
assert.deepEqual(declaredKeys('{"paths":{"a.md":{"reason":"r"}}}'), ['a.md']);
|
||
assert.deepEqual(
|
||
declaredKeys('{"paths":{"__proto__":{"reason":"r"}}}'), [],
|
||
'__proto__ is excluded from collision detection as belt-and-suspenders — in practice '
|
||
+ 'main() never reaches this on such a document, because validateAckText already '
|
||
+ 'rejects it outright (#2914 review), so there is no schema-valid document left for '
|
||
+ 'declaredKeys to see it on',
|
||
);
|
||
});
|
||
|
||
test('listFragmentFiles: absent directory is zero fragments, present directory is sorted .json only', () => {
|
||
const { listFragmentFiles } = require('../scripts/lint-emitted-drift-ack.cjs');
|
||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-lint-frag-'));
|
||
try {
|
||
assert.deepEqual(listFragmentFiles(path.join(dir, 'missing')), []);
|
||
fs.writeFileSync(path.join(dir, '2000-z.json'), '{}');
|
||
fs.writeFileSync(path.join(dir, '1000-a.json'), '{}');
|
||
fs.writeFileSync(path.join(dir, 'notes.txt'), 'nope');
|
||
assert.deepEqual(listFragmentFiles(dir), ['1000-a.json', '2000-z.json']);
|
||
} finally {
|
||
cleanup(dir);
|
||
}
|
||
});
|
||
|
||
test('listFragmentFiles: exactly MAX_ACK_FRAGMENTS entries passes, one over fails loudly (#2914 review)', () => {
|
||
const { listFragmentFiles } = require('../scripts/lint-emitted-drift-ack.cjs');
|
||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-lint-frag-cap-'));
|
||
try {
|
||
for (let i = 0; i < MAX_ACK_FRAGMENTS; i++) {
|
||
fs.writeFileSync(path.join(dir, `f-${String(i).padStart(4, '0')}.json`), '{}');
|
||
}
|
||
assert.equal(listFragmentFiles(dir).length, MAX_ACK_FRAGMENTS, 'at the cap must still pass');
|
||
|
||
fs.writeFileSync(path.join(dir, `f-${String(MAX_ACK_FRAGMENTS).padStart(4, '0')}.json`), '{}');
|
||
assert.throws(
|
||
() => listFragmentFiles(dir),
|
||
(err) => {
|
||
assert.match(err.message, new RegExp(escapeRegex(dir)));
|
||
assert.match(err.message, new RegExp(String(MAX_ACK_FRAGMENTS + 1)));
|
||
assert.match(err.message, new RegExp(String(MAX_ACK_FRAGMENTS)));
|
||
return true;
|
||
},
|
||
'one over the cap must throw, naming the directory, the cap, and the actual count',
|
||
);
|
||
} finally {
|
||
cleanup(dir);
|
||
}
|
||
});
|
||
|
||
// ─── guard-no-ack-on-next: presence itself is the failure (#2914) ────────────
|
||
//
|
||
// Unlike `validateAckText` above (a PR-lane shape lint that must let a live, well-formed
|
||
// ack through), `assertAbsentOnNext` runs ONLY against `next` itself — see the
|
||
// `guard-no-ack-on-next` job in `.github/workflows/test.yml`, gated on push to `next` — and
|
||
// rejects PRESENCE outright, valid or not. Per the ack-lifecycle law (#2789), an entry
|
||
// already at the base is spent the moment it merges, so the shape never matters here.
|
||
// `assertAbsentOnNext` takes only the boolean `present` — an entryless-vs-populated
|
||
// distinction is collapsed to that boolean before this function ever sees it (see
|
||
// `main()`'s `fs.existsSync` call in `scripts/lint-emitted-drift-ack.cjs`), so no test
|
||
// here can exercise that distinction: there is deliberately no separate "entryless"
|
||
// case below, since one would be identical in input and assertion to the populated
|
||
// case and would claim coverage the function structurally cannot provide.
|
||
|
||
test('assertAbsentOnNext passes when the file is absent — the healthy steady state', () => {
|
||
const r = assertAbsentOnNext(false);
|
||
assert.ok(r.ok);
|
||
assert.match(r.message, /absent \(the healthy steady state\)/);
|
||
});
|
||
|
||
test('assertAbsentOnNext fails when the file is present with entries, naming the file and the remedy', () => {
|
||
const r = assertAbsentOnNext(true);
|
||
assert.ok(!r.ok);
|
||
assert.match(r.message, /tests\/emitted-drift-ack\.json exists on next/);
|
||
assert.match(r.message, /spent and inert/);
|
||
assert.match(r.message, /delete the file too/, 'must cite CONTRIBUTING.md\'s delete-the-file rule');
|
||
assert.match(r.message, /git rm tests\/emitted-drift-ack\.json/, 'the remedy must be named, not just the problem');
|
||
assert.match(
|
||
r.message, /tests\/emitted-drift-acks\//,
|
||
'#2914: the message must explain that acks now go in per-PR fragments',
|
||
);
|
||
// #3078 removed the premise this used to assert: a persisting fragment is NOT harmless
|
||
// just because it does not share a FILE with another PR — fragments share a PATH KEY
|
||
// SPACE, and a duplicate path across two sources is a hard failure in main(), so a
|
||
// fully-spent fragment left behind still owns keys it can no longer gate.
|
||
assert.doesNotMatch(
|
||
r.message, /cannot conflict with any other PR/,
|
||
'#3078: this premise was false and has been removed — a fragment cannot MERGE-CONFLICT '
|
||
+ 'with another PR\'s fragment, but it can still collide on a path key',
|
||
);
|
||
assert.match(r.message, /#3078/);
|
||
});
|
||
|
||
test('assertAbsentOnNext fed from a real next-like tree: absent passes, present fails (regression, #2914)', () => {
|
||
// A throwaway directory standing in for `next`'s tree, so the check exercises real
|
||
// fs.existsSync semantics on ACK_REPO_PATH rather than a hand-picked boolean.
|
||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-guard-next-'));
|
||
const ackPath = path.join(dir, ACK_REPO_PATH);
|
||
fs.mkdirSync(path.dirname(ackPath), { recursive: true });
|
||
|
||
assert.ok(
|
||
assertAbsentOnNext(fs.existsSync(ackPath)).ok,
|
||
'a fresh tree with no ack file must pass',
|
||
);
|
||
|
||
// Reproduces the exact #2834/#2900 shape: 34 spent entries surviving on next.
|
||
fs.writeFileSync(ackPath, JSON.stringify({ version: 1, paths: { 'a.md': { reason: 'spent' } } }));
|
||
const r = assertAbsentOnNext(fs.existsSync(ackPath));
|
||
assert.ok(!r.ok, 'a tree carrying the file, however well-formed, must fail');
|
||
assert.match(r.message, /exists on next/);
|
||
});
|
||
|
||
// ─── guard-no-ack-on-next: fully-spent FRAGMENTS are guarded on inertness (#3078) ──
|
||
//
|
||
// #2914 exempted the fragment directory from `assertAbsentOnNext` on the premise that
|
||
// a persistent fragment "cannot conflict with any other PR". That premise is false:
|
||
// fragments do not share a FILE, but they do share a PATH KEY SPACE, and a duplicate
|
||
// path declared by two sources is a hard failure in `main()` below. So a merged,
|
||
// fully-spent fragment still OWNS its path keys, and the next PR that grows one of
|
||
// those paths can declare it neither there (spent — `parseAck`'s `isSpent`/`prose`
|
||
// contract) nor in its own fragment (a cross-source duplicate). Unlike the legacy
|
||
// file, PRESENCE alone is not the failure here — a fragment landed by the very push
|
||
// being guarded is the healthy case for every ack-carrying PR. The failure is
|
||
// INERTNESS: every surviving entry's prose already matches the base ref's copy, so
|
||
// the fragment can no longer clear a delta for anyone.
|
||
|
||
const doc = (paths) => JSON.stringify({ version: ACK_VERSION, paths });
|
||
const frag = (name, current, base) => ({ name, currentRaw: current, baseRaw: base });
|
||
|
||
// ── A. assertNoAllSpentFragments — the pure lifecycle rule ──────────────────
|
||
|
||
test('ackEntries: a literal "null" document is unreadable, distinct from an entryless object document', () => {
|
||
// The distinction assertNoAllSpentFragments's "literal null" test above depends on:
|
||
// an entryless OBJECT document is a genuine empty entry set (`new Map()`), while the
|
||
// TEXT "null" parses to the JS value `null`, fails isPlainObject, and returns the
|
||
// "cannot trust this document" sentinel instead.
|
||
assert.deepEqual(ackEntries('{}'), new Map());
|
||
assert.deepEqual(ackEntries('{"version":1,"paths":{}}'), new Map());
|
||
assert.equal(ackEntries('null'), null);
|
||
assert.equal(ackEntries(null) instanceof Map, true, 'an ABSENT fragment (raw === null) is a genuine empty entry set');
|
||
assert.deepEqual(ackEntries(null), new Map());
|
||
});
|
||
|
||
test('assertNoAllSpentFragments: zero fragments is ok, vacuously', () => {
|
||
const r = assertNoAllSpentFragments([]);
|
||
assert.ok(r.ok);
|
||
assert.match(r.message, /no all-spent fragment survives/);
|
||
assert.deepEqual(r.sweepable, []);
|
||
});
|
||
|
||
test('assertNoAllSpentFragments: a fragment absent at the base is live — the healthy shape of every fresh PR', () => {
|
||
// A fragment landed by the very push being guarded is the NORMAL case for every
|
||
// ack-carrying PR. Reporting it would red `next` on every such merge.
|
||
const r = assertNoAllSpentFragments([
|
||
frag('a.json', doc({ 'x.md': { reason: 'why' } }), null),
|
||
]);
|
||
assert.ok(r.ok);
|
||
assert.deepEqual(r.sweepable, []);
|
||
});
|
||
|
||
test('assertNoAllSpentFragments: a fully-spent fragment is reported, naming the file, "all spent", and the exact git rm remedy (n=1, n=3)', () => {
|
||
for (const entries of [
|
||
{ 'x.md': { reason: 'why' } },
|
||
{ 'a.md': { reason: 'a' }, 'b.md': { reason: 'b' }, 'c.md': { reason: 'c' } },
|
||
]) {
|
||
const raw = doc(entries);
|
||
const r = assertNoAllSpentFragments([frag('spent.json', raw, raw)]);
|
||
assert.ok(!r.ok, `${Object.keys(entries).length} entr(y/ies) must still be reported all-spent`);
|
||
assert.match(r.message, /spent\.json/);
|
||
assert.match(r.message, /all spent/);
|
||
assert.match(r.message, new RegExp(escapeRegex(`git rm ${ACK_DIR_REPO_PATH}/spent.json`)));
|
||
assert.deepEqual(r.sweepable, ['spent.json']);
|
||
}
|
||
});
|
||
|
||
test('assertNoAllSpentFragments: a partially spent fragment (one entry new) is left alone', () => {
|
||
// Named explicitly as a must-not-change: appending a new entry beside an
|
||
// already-spent one must not get swept out from under the live one.
|
||
const r = assertNoAllSpentFragments([
|
||
frag(
|
||
'mixed.json',
|
||
doc({ x: { reason: 'x-reason' }, y: { reason: 'y-reason' } }),
|
||
doc({ x: { reason: 'x-reason' } }),
|
||
),
|
||
]);
|
||
assert.ok(r.ok);
|
||
assert.deepEqual(r.sweepable, []);
|
||
});
|
||
|
||
test('assertNoAllSpentFragments: re-arming by appending genuinely new prose keeps working (#2639, #2993)', () => {
|
||
const r = assertNoAllSpentFragments([
|
||
frag('r.json', doc({ x: { reason: 'a brand new explanation' } }), doc({ x: { reason: 'the original explanation' } })),
|
||
]);
|
||
assert.ok(r.ok, 're-arming by appending genuinely new prose must not be swept');
|
||
});
|
||
|
||
test('assertNoAllSpentFragments: a zero-information reword never looks like a re-arm — doubled whitespace, leading/trailing whitespace, CRLF vs LF', () => {
|
||
const cases = [
|
||
['doubled internal whitespace', 'the original explanation', 'the original explanation'],
|
||
['leading/trailing whitespace', ' the original explanation ', 'the original explanation'],
|
||
['CRLF vs LF inside the reason', 'the original\r\nexplanation', 'the original\nexplanation'],
|
||
];
|
||
for (const [label, current, base] of cases) {
|
||
const r = assertNoAllSpentFragments([
|
||
frag('r.json', doc({ x: { reason: current } }), doc({ x: { reason: base } })),
|
||
]);
|
||
assert.ok(!r.ok, `${label}: a zero-information reword must still read as spent`);
|
||
assert.deepEqual(r.sweepable, ['r.json'], label);
|
||
}
|
||
});
|
||
|
||
test('assertNoAllSpentFragments: each invisible codepoint alone must not re-arm a spent entry', () => {
|
||
const codepoints = [0x00AD, 0x200B, 0x200C, 0x200D, 0x2060, 0xFEFF];
|
||
for (const cp of codepoints) {
|
||
const base = 'the original explanation';
|
||
const current = `the${String.fromCodePoint(cp)} original explanation`;
|
||
const r = assertNoAllSpentFragments([
|
||
frag('r.json', doc({ x: { reason: current } }), doc({ x: { reason: base } })),
|
||
]);
|
||
assert.ok(!r.ok, `U+${cp.toString(16).toUpperCase()} alone must not re-arm a spent entry`);
|
||
}
|
||
});
|
||
|
||
test('assertNoAllSpentFragments: every SURVIVING entry is spent, even after one entry is dropped', () => {
|
||
const base = doc({ x: { reason: 'x-reason' }, y: { reason: 'y-reason' } });
|
||
const current = doc({ x: { reason: 'x-reason' } });
|
||
const r = assertNoAllSpentFragments([frag('drop.json', current, base)]);
|
||
assert.ok(!r.ok);
|
||
assert.deepEqual(r.sweepable, ['drop.json']);
|
||
});
|
||
|
||
test('assertNoAllSpentFragments: disjoint key sets between current and base is not spent', () => {
|
||
const r = assertNoAllSpentFragments([
|
||
frag('disjoint.json', doc({ x: { reason: 'x' } }), doc({ y: { reason: 'y' } })),
|
||
]);
|
||
assert.ok(r.ok);
|
||
});
|
||
|
||
test('assertNoAllSpentFragments: an entryless-but-parseable-object document is vacuously all-spent (n=0 boundary)', () => {
|
||
// Vacuously all-spent: `[...new Map()].every(...)` is true, so an object document
|
||
// declaring zero entries is swept for the same reason validateAckText refuses to let
|
||
// one be committed — it acknowledges nothing.
|
||
for (const raw of ['{}', '{"version":1}', '{"version":1,"paths":{}}']) {
|
||
const r = assertNoAllSpentFragments([frag('empty.json', raw, raw)]);
|
||
assert.ok(!r.ok, `${raw} must be swept`);
|
||
assert.deepEqual(r.sweepable, ['empty.json'], raw);
|
||
}
|
||
});
|
||
|
||
test('assertNoAllSpentFragments: a document of literally "null" is UNREADABLE here, not entryless, so it is left alone', () => {
|
||
// Distinct from the object-shaped entryless case above: `ackEntries` parses the text
|
||
// "null" to the JS value `null`, which fails `isPlainObject` and returns `null`
|
||
// (its "cannot trust this document" sentinel) — NOT `new Map()`. So this fragment is
|
||
// skipped by assertNoAllSpentFragments entirely, same as any other unreadable
|
||
// document (see the next test) — it is never counted as vacuously spent, unlike
|
||
// validateAckText's POLICY layer, which does reject "null" (present-but-entryless).
|
||
// The two surfaces are allowed to differ here: this guard owns LIFECYCLE, not shape.
|
||
const r = assertNoAllSpentFragments([frag('literal-null.json', 'null', 'null')]);
|
||
assert.ok(r.ok, 'a literal "null" document must not be swept by this guard');
|
||
assert.deepEqual(r.sweepable, []);
|
||
});
|
||
|
||
test('assertNoAllSpentFragments: a document it cannot trust to answer the question is left alone, never swept', () => {
|
||
// validateAckText / lint:ci owns SHAPE; this guard owns LIFECYCLE. Sweeping a
|
||
// fragment on the strength of a parse failure would delete an acknowledgment for
|
||
// the wrong reason.
|
||
const validBase = doc({ x: { reason: 'x' } });
|
||
for (const currentRaw of ['{ not json', '[]', '42', '{"paths":[]}', '{"paths":{"x":42}}']) {
|
||
const r = assertNoAllSpentFragments([frag('bad.json', currentRaw, validBase)]);
|
||
assert.ok(r.ok, `unreadable current ${currentRaw} must not be swept`);
|
||
assert.deepEqual(r.sweepable, []);
|
||
}
|
||
|
||
const r2 = assertNoAllSpentFragments([frag('bad-base.json', validBase, '{ not json')]);
|
||
assert.ok(r2.ok, 'an unreadable base must not be swept either');
|
||
assert.deepEqual(r2.sweepable, []);
|
||
});
|
||
|
||
test('assertNoAllSpentFragments: naming ONLY the inert fragments makes the remedy safe to apply blindly', () => {
|
||
const spentA = doc({ a: { reason: 'a' } });
|
||
const spentB = doc({ b: { reason: 'b' } });
|
||
const live = doc({ c: { reason: 'c-new' } });
|
||
const liveBase = doc({ c: { reason: 'c-old' } });
|
||
const r = assertNoAllSpentFragments([
|
||
frag('spent-a.json', spentA, spentA),
|
||
frag('spent-b.json', spentB, spentB),
|
||
frag('live-c.json', live, liveBase),
|
||
]);
|
||
assert.ok(!r.ok);
|
||
assert.deepEqual([...r.sweepable].sort(), ['spent-a.json', 'spent-b.json']);
|
||
assert.doesNotMatch(r.message, /live-c\.json/);
|
||
});
|
||
|
||
test('assertNoAllSpentFragments: a __proto__ key is never mistaken for a spent entry, and Object.prototype stays untouched', () => {
|
||
// A `[key]` computed property, never a literal `{ __proto__: ... }` — the literal
|
||
// form is special-cased by JS to SET THE PROTOTYPE rather than create an own
|
||
// property, which would make `paths` serialize as `{}` and this test vacuous.
|
||
const key = '__proto__';
|
||
const raw = JSON.stringify({ version: ACK_VERSION, paths: { [key]: { reason: 'hostile' } } });
|
||
const parsedPaths = JSON.parse(raw).paths;
|
||
assert.deepEqual(Object.keys(parsedPaths), ['__proto__'], 'JSON.parse must create a genuine own key');
|
||
const r = assertNoAllSpentFragments([frag('proto.json', raw, raw)]);
|
||
assert.ok(r.ok, '__proto__ makes the document unreadable to ackEntries, so it must never be swept');
|
||
assert.deepEqual(r.sweepable, []);
|
||
assert.equal(({}).reason, undefined, 'Object.prototype must stay untouched throughout');
|
||
});
|
||
|
||
test('assertNoAllSpentFragments: a bare-string reason and an object-shaped reason are compared on prose alone', () => {
|
||
const bare = doc({ x: 'why' });
|
||
const bareR = assertNoAllSpentFragments([frag('bare.json', bare, bare)]);
|
||
assert.ok(!bareR.ok, 'a bare-string reason on both sides must still be recognized as spent');
|
||
|
||
const objCurrent = doc({ x: { reason: 'why' } });
|
||
const bareBase = doc({ x: 'why' });
|
||
const shapeR = assertNoAllSpentFragments([frag('shape.json', objCurrent, bareBase)]);
|
||
assert.ok(!shapeR.ok, 'a shape change alone must not re-arm — parseAck accepts both shapes');
|
||
});
|
||
|
||
test('assertNoAllSpentFragments: a decorative "runtime" field beside an unchanged reason does not re-arm (runtime is deliberately not compared)', () => {
|
||
const base = doc({ x: { reason: 'why' } });
|
||
const current = doc({ x: { reason: 'why', runtime: 'claude' } });
|
||
const r = assertNoAllSpentFragments([frag('runtime.json', current, base)]);
|
||
assert.ok(!r.ok, 'runtime carries no explanation and must not re-arm a byte-identical reason');
|
||
});
|
||
|
||
// ── B. prose parity with the gate (the generative-fix-divergence gate) ──────
|
||
//
|
||
// `scripts/` ships in the npm package and `tests/` does not, so `ackProse` MUST
|
||
// duplicate `emitted-diff.cjs`'s `prose()`. This section is the parity assertion
|
||
// that fails when they diverge.
|
||
|
||
test('ACK_INVISIBLE and the gate\'s own INVISIBLE are the identical regex, not a second hand-typed copy (#3078)', () => {
|
||
// scripts/ ships in the npm package and tests/ does not, so ACK_INVISIBLE is a literal
|
||
// duplicate of INVISIBLE (tests/helpers/emitted-diff.cjs) rather than a require across
|
||
// that line — see both files' top-of-file comments. A divergence here means an
|
||
// invisible reword can re-arm a spent ack on the real `--guard-next` gate and NOT on
|
||
// this suite (or vice versa), which is exactly the class of drift a parity test that
|
||
// compares a hardcoded literal against its own definition can never catch.
|
||
assert.equal(
|
||
ACK_INVISIBLE.source, INVISIBLE.source,
|
||
'scripts/lint-emitted-drift-ack.cjs\'s ACK_INVISIBLE and tests/helpers/emitted-diff.cjs\'s '
|
||
+ 'INVISIBLE must declare the identical character class — they are duplicated only because '
|
||
+ 'scripts/ ships in the npm package and tests/ does not',
|
||
);
|
||
assert.equal(
|
||
ACK_INVISIBLE.flags, INVISIBLE.flags,
|
||
'both are duplicated for the same reason and must agree on flags too (both carry "g", '
|
||
+ 'which is exactly what makes .test() below stateful and in need of a lastIndex reset)',
|
||
);
|
||
|
||
// Derive the codepoint list from the REAL gate regex rather than hardcoding one here.
|
||
// A hardcoded list would pass even if someone added a codepoint to only one side — the
|
||
// exact divergence this test exists to catch. `.test()` on a `g`-flagged regex advances
|
||
// `lastIndex` as a side effect, so it is reset before and after every call.
|
||
for (let cp = 0x0000; cp <= 0xFFFF; cp++) {
|
||
const ch = String.fromCodePoint(cp);
|
||
|
||
INVISIBLE.lastIndex = 0;
|
||
const gateStrips = INVISIBLE.test(ch);
|
||
INVISIBLE.lastIndex = 0;
|
||
|
||
ACK_INVISIBLE.lastIndex = 0;
|
||
const ackStrips = ACK_INVISIBLE.test(ch);
|
||
ACK_INVISIBLE.lastIndex = 0;
|
||
|
||
const hex = cp.toString(16).toUpperCase().padStart(4, '0');
|
||
assert.equal(ackStrips, gateStrips, `U+${hex}: ACK_INVISIBLE and INVISIBLE disagree on whether it is invisible`);
|
||
|
||
if (gateStrips) {
|
||
assert.equal(ackProse(`a${ch}b`), 'ab', `ackProse must strip U+${hex}`);
|
||
assert.equal(normalizeAckReason(`a${ch}b`), 'ab', `normalizeAckReason must strip U+${hex}`);
|
||
} else if (!/\s/.test(ch)) {
|
||
// Not invisible, and not plain whitespace either (whitespace-collapse behavior
|
||
// depends on adjacency, so it is exercised separately below) — must survive as-is.
|
||
assert.equal(ackProse(`a${ch}b`), `a${ch}b`, `ackProse must NOT strip U+${hex}`);
|
||
assert.equal(normalizeAckReason(`a${ch}b`), `a${ch}b`, `normalizeAckReason must NOT strip U+${hex}`);
|
||
}
|
||
}
|
||
});
|
||
|
||
test('normalizeAckReason (the gate\'s own prose normalizer) and ackProse agree byte-for-byte over a behavioral corpus (#3078)', () => {
|
||
const invisibleCps = [0x00AD, 0x200B, 0x200C, 0x200D, 0x2060, 0xFEFF];
|
||
const corpus = [
|
||
'identical text',
|
||
'doubled internal space',
|
||
' leading and trailing space ',
|
||
'crlf\r\nline',
|
||
'lf\nline',
|
||
'tab\ttab',
|
||
'nbsp nbsp', // U+00A0 NBSP
|
||
'ideographic space', // U+3000 IDEOGRAPHIC SPACE
|
||
'em space', // U+2003 EM SPACE
|
||
...invisibleCps.map((cp) => `invisible${String.fromCodePoint(cp)}codepoint`),
|
||
'',
|
||
' \t\n ',
|
||
];
|
||
for (const reason of corpus) {
|
||
assert.equal(
|
||
normalizeAckReason(reason), ackProse(reason),
|
||
`normalizeAckReason and ackProse diverge on ${JSON.stringify(reason)}`,
|
||
);
|
||
}
|
||
});
|
||
|
||
test('ackProse and the sweep guard agree on what counts as "the same explanation, reworded"', () => {
|
||
const corpus = [
|
||
['identical', 'same words', 'same words', true],
|
||
['doubled space', 'same words', 'same words', true],
|
||
['leading/trailing space', ' same words ', 'same words', true],
|
||
['CRLF vs LF', 'same\r\nwords', 'same\nwords', true],
|
||
['NBSP vs space', 'same words', 'same words', true],
|
||
['ideographic space vs space', 'same words', 'same words', true],
|
||
['EM SPACE vs space', 'same words', 'same words', true],
|
||
['zero-width inserted', 'samewords', 'samewords', true],
|
||
['genuinely different words', 'same words', 'different words', false],
|
||
];
|
||
|
||
for (const [label, a, b, expectedSameProse] of corpus) {
|
||
assert.equal(
|
||
ackProse(a) === ackProse(b), expectedSameProse,
|
||
`${label}: ackProse must agree it is${expectedSameProse ? '' : ' NOT'} the same explanation`,
|
||
);
|
||
|
||
const gate = assertNoAllSpentFragments([
|
||
frag('r.json', doc({ x: { reason: a } }), doc({ x: { reason: b } })),
|
||
]);
|
||
assert.equal(
|
||
!gate.ok, expectedSameProse,
|
||
`${label}: a divergence here means the sweep guard and the gate disagree about what `
|
||
+ 're-arms an ack',
|
||
);
|
||
}
|
||
});
|
||
|
||
test('property: normalizeAckReason (the gate) and ackProse agree for arbitrary strings, seeded (#3078)', () => {
|
||
// Two-sided over unconstrained input, not just the constructed corpus above — a
|
||
// divergence anywhere in fc.string()'s space fails here, not just at the handful of
|
||
// codepoints the corpus test happens to name.
|
||
fc.assert(
|
||
fc.property(
|
||
fc.string(),
|
||
(s) => {
|
||
assert.equal(normalizeAckReason(s), ackProse(s));
|
||
},
|
||
),
|
||
{ seed: 3078, numRuns: 200 },
|
||
);
|
||
});
|
||
|
||
test('property: ackProse and normalizeAckReason agree, and both are invariant under expanding existing whitespace and inserting invisible codepoints, seeded (#3078)', () => {
|
||
const invisibleChars = [0x00AD, 0x200B, 0x200C, 0x200D, 0x2060, 0xFEFF].map((cp) => String.fromCodePoint(cp));
|
||
const word = fc.string({ minLength: 1, maxLength: 8 }).filter((s) => !/\s/.test(s));
|
||
|
||
fc.assert(
|
||
fc.property(
|
||
fc.array(word, { minLength: 1, maxLength: 6 }),
|
||
fc.constantFrom(' ', '\t', '\n', '\r\n', ' ', ' ', ' '),
|
||
fc.array(fc.constantFrom(...invisibleChars), { minLength: 0, maxLength: 6 }),
|
||
(parts, whitespaceRun, invisibles) => {
|
||
const base = parts.join(' ');
|
||
// Every existing single space widened to a longer whitespace RUN — never
|
||
// introduces whitespace where none existed, so the collapsed result cannot
|
||
// change.
|
||
const expanded = parts.join(whitespaceRun);
|
||
// Invisible codepoints inserted anywhere are stripped outright, never
|
||
// collapsed to a space, so they never introduce a new word boundary.
|
||
const withInvisibles = invisibles.reduce((s, ch) => ch + s, base);
|
||
const padded = ` ${base} `;
|
||
|
||
// Two-sided on every one of these forms, not just the base string.
|
||
for (const candidate of [base, expanded, withInvisibles, padded]) {
|
||
assert.equal(normalizeAckReason(candidate), ackProse(candidate));
|
||
}
|
||
|
||
assert.equal(ackProse(expanded), ackProse(base));
|
||
assert.equal(ackProse(withInvisibles), ackProse(base));
|
||
assert.equal(ackProse(padded), ackProse(base));
|
||
},
|
||
),
|
||
{ seed: 3078, numRuns: 200 },
|
||
);
|
||
});
|
||
|
||
// ── C. --guard-next wiring against a REAL git repository ────────────────────
|
||
//
|
||
// The pure function above is fed hand-built strings; this section proves the
|
||
// WIRING — real commits, real `git show` reads — because a guard that resolves no
|
||
// base passes vacuously, and that is exactly how the legacy half went blind.
|
||
|
||
// #2767: the remote runner mounts the repo at a path owned by another uid, and git then
|
||
// refuses every operation there with "detected dubious ownership". Names the SPECIFIC
|
||
// directory, never the `*` wildcard. Mirrors `safeDirArgs` in helpers/emitted-runtime.cjs.
|
||
const gitIn = (dir, args) => execFileSync(
|
||
'git', ['-c', `safe.directory=${path.resolve(dir)}`, ...args],
|
||
{ cwd: dir, encoding: 'utf8', timeout: 15000 },
|
||
);
|
||
|
||
function makeGuardNextRepo() {
|
||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-guard-next-repo-'));
|
||
const runGit = (args) => gitIn(dir, args);
|
||
runGit(['init', '-q', '-b', 'next']);
|
||
function commit(msg) {
|
||
runGit(['-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'add', '-A']);
|
||
runGit(['-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-q', '-m', msg]);
|
||
return runGit(['rev-parse', 'HEAD']).trim();
|
||
}
|
||
function writeFrag(name, obj) {
|
||
const fragDir = path.join(dir, ...ACK_DIR_REPO_PATH.split('/'));
|
||
fs.mkdirSync(fragDir, { recursive: true });
|
||
fs.writeFileSync(path.join(fragDir, name), JSON.stringify(obj));
|
||
}
|
||
return { dir, commit, writeFrag };
|
||
}
|
||
|
||
// Section C used to reimplement git-reading in a local `readFragAtCommit` helper and
|
||
// feed only the PURE `assertNoAllSpentFragments`, so the real wiring — the
|
||
// `ls-tree`-then-`show` absence-vs-fault discrimination, the `HEAD`-then-`HEAD^`
|
||
// two-step, the root-commit fallback, and the option-injection guard — was never
|
||
// executed by any test (#3078 review). C1-C4 below now call the REAL
|
||
// `readFragmentAtRef`, exported from `scripts/lint-emitted-drift-ack.cjs` for exactly
|
||
// this purpose.
|
||
|
||
test('C1: real repo — a fragment added in commit 2 vs commit 1 (root, no fragment) is live', () => {
|
||
const repo = makeGuardNextRepo();
|
||
try {
|
||
fs.writeFileSync(path.join(repo.dir, 'README.md'), 'root\n');
|
||
const c1 = repo.commit('root');
|
||
repo.writeFrag('a.json', { version: ACK_VERSION, paths: { 'x.md': { reason: 'why' } } });
|
||
repo.commit('add fragment');
|
||
|
||
const r = assertNoAllSpentFragments([
|
||
frag(
|
||
'a.json',
|
||
readFragmentAtRef('HEAD', 'a.json', { cwd: repo.dir }),
|
||
readFragmentAtRef(c1, 'a.json', { cwd: repo.dir }),
|
||
),
|
||
]);
|
||
assert.ok(r.ok, 'a fragment absent at the base is live');
|
||
} finally {
|
||
cleanup(repo.dir);
|
||
}
|
||
});
|
||
|
||
test('C2: real repo — an unrelated file change leaves an unchanged fragment fully spent', () => {
|
||
const repo = makeGuardNextRepo();
|
||
try {
|
||
repo.writeFrag('a.json', { version: ACK_VERSION, paths: { 'x.md': { reason: 'why' } } });
|
||
const c2 = repo.commit('add fragment');
|
||
fs.writeFileSync(path.join(repo.dir, 'README.md'), 'unrelated\n');
|
||
repo.commit('unrelated change');
|
||
|
||
const r = assertNoAllSpentFragments([
|
||
frag(
|
||
'a.json',
|
||
readFragmentAtRef('HEAD', 'a.json', { cwd: repo.dir }),
|
||
readFragmentAtRef(c2, 'a.json', { cwd: repo.dir }),
|
||
),
|
||
]);
|
||
assert.ok(!r.ok, 'unchanged fragment against its own prior commit is fully spent');
|
||
} finally {
|
||
cleanup(repo.dir);
|
||
}
|
||
});
|
||
|
||
test('C3: real repo — appending prose to the owning entry re-arms it', () => {
|
||
const repo = makeGuardNextRepo();
|
||
try {
|
||
repo.writeFrag('a.json', { version: ACK_VERSION, paths: { 'x.md': { reason: 'the original explanation' } } });
|
||
const c2 = repo.commit('add fragment');
|
||
repo.writeFrag('a.json', {
|
||
version: ACK_VERSION,
|
||
paths: { 'x.md': { reason: 'the original explanation, now covering a new ripple too' } },
|
||
});
|
||
repo.commit('reword');
|
||
|
||
const r = assertNoAllSpentFragments([
|
||
frag(
|
||
'a.json',
|
||
readFragmentAtRef('HEAD', 'a.json', { cwd: repo.dir }),
|
||
readFragmentAtRef(c2, 'a.json', { cwd: repo.dir }),
|
||
),
|
||
]);
|
||
assert.ok(r.ok, 'genuinely new prose re-arms the entry');
|
||
} finally {
|
||
cleanup(repo.dir);
|
||
}
|
||
});
|
||
|
||
test('C4: real repo — a fragment at the ROOT commit compared against a null base is live', () => {
|
||
const repo = makeGuardNextRepo();
|
||
try {
|
||
repo.writeFrag('a.json', { version: ACK_VERSION, paths: { 'x.md': { reason: 'why' } } });
|
||
repo.commit('root with fragment');
|
||
|
||
// The base is passed as a literal `null`, not a call to readFragmentAtRef(null, …)
|
||
// — this mirrors main()'s own `baseRef === null ? null : readFragmentAtRef(...)`
|
||
// branch for a root commit, where resolveBaseRef() has already returned null.
|
||
const r = assertNoAllSpentFragments([
|
||
frag('a.json', readFragmentAtRef('HEAD', 'a.json', { cwd: repo.dir }), null),
|
||
]);
|
||
assert.ok(r.ok, 'a root commit has no base — resolveBaseRef returns null and nothing can be spent against it');
|
||
} finally {
|
||
cleanup(repo.dir);
|
||
}
|
||
});
|
||
|
||
// ── C2 (direct). Direct coverage of the real git seam (#3078 review) ────────
|
||
//
|
||
// C1-C4 above exercise `readFragmentAtRef` only through `assertNoAllSpentFragments`'s
|
||
// verdict, which cannot distinguish "read the wrong thing" from "read nothing" if both
|
||
// happen to produce the same pure-function outcome. The tests below assert on
|
||
// `readFragmentAtRef`, `resolveBaseRef`, and `assertUsableBaseRef` DIRECTLY, plus one
|
||
// end-to-end run of the real script as a subprocess — the only thing that exercises
|
||
// `main()`'s own argv parsing. `makeGuardNextRepo()` above already satisfies the "one
|
||
// makeRepo() helper" shape this needs (mkdtempSync, `git init -q -b next`, per-commit
|
||
// identity via `-c user.email=... -c user.name=...`, never mutating global git config),
|
||
// so it is reused rather than duplicated.
|
||
|
||
test('readFragmentAtRef: returns null for a fragment genuinely absent at that ref — distinguished via ls-tree, not a git-show error message (healthy steady state)', () => {
|
||
const repo = makeGuardNextRepo();
|
||
try {
|
||
fs.writeFileSync(path.join(repo.dir, 'README.md'), 'root\n');
|
||
const c1 = repo.commit('root, no fragment yet');
|
||
repo.writeFrag('a.json', { version: ACK_VERSION, paths: { 'x.md': { reason: 'why' } } });
|
||
repo.commit('add fragment');
|
||
|
||
assert.equal(
|
||
readFragmentAtRef(c1, 'a.json', { cwd: repo.dir }), null,
|
||
'a fragment not yet added at that ref must read as null, not throw',
|
||
);
|
||
} finally {
|
||
cleanup(repo.dir);
|
||
}
|
||
});
|
||
|
||
test('readFragmentAtRef: returns the exact bytes committed at that ref, which differ from an uncommitted working-tree edit — proves it reads the REF, not the tree', () => {
|
||
const repo = makeGuardNextRepo();
|
||
try {
|
||
repo.writeFrag('a.json', { version: ACK_VERSION, paths: { 'x.md': { reason: 'original' } } });
|
||
const c1 = repo.commit('add fragment');
|
||
// Deliberately left UNCOMMITTED — only the working tree carries this edit.
|
||
repo.writeFrag('a.json', { version: ACK_VERSION, paths: { 'x.md': { reason: 'edited after the commit' } } });
|
||
|
||
const atRef = readFragmentAtRef(c1, 'a.json', { cwd: repo.dir });
|
||
const onDisk = fs.readFileSync(path.join(repo.dir, ...ACK_DIR_REPO_PATH.split('/'), 'a.json'), 'utf8');
|
||
assert.equal(JSON.parse(atRef).paths['x.md'].reason, 'original');
|
||
assert.notEqual(atRef, onDisk, 'the ref read must not pick up the uncommitted working-tree edit');
|
||
} finally {
|
||
cleanup(repo.dir);
|
||
}
|
||
});
|
||
|
||
test('readFragmentAtRef: throws on a ref that does not exist — a failed base read must be an error, never a silent null', () => {
|
||
const repo = makeGuardNextRepo();
|
||
try {
|
||
repo.writeFrag('a.json', { version: ACK_VERSION, paths: { 'x.md': { reason: 'why' } } });
|
||
repo.commit('add fragment');
|
||
|
||
// "could not read the base" read as "absent at the base" would make every fragment
|
||
// look brand-new and disarm the sweep — this must throw, not return null.
|
||
assert.throws(
|
||
() => readFragmentAtRef('not-a-real-ref-3078', 'a.json', { cwd: repo.dir }),
|
||
/not-a-real-ref-3078/,
|
||
'a bad ref must throw, naming itself',
|
||
);
|
||
} finally {
|
||
cleanup(repo.dir);
|
||
}
|
||
});
|
||
|
||
test('resolveBaseRef: returns the parent sha on a two-commit repo, matching `git rev-parse HEAD^` as a 40-hex string', () => {
|
||
const repo = makeGuardNextRepo();
|
||
try {
|
||
fs.writeFileSync(path.join(repo.dir, 'README.md'), 'one\n');
|
||
const c1 = repo.commit('first');
|
||
fs.writeFileSync(path.join(repo.dir, 'README.md'), 'two\n');
|
||
repo.commit('second');
|
||
|
||
const expected = gitIn(repo.dir, ['rev-parse', 'HEAD^']).trim();
|
||
const actual = resolveBaseRef({ cwd: repo.dir });
|
||
assert.match(actual, /^[0-9a-f]{40}$/, 'must be a 40-hex sha');
|
||
assert.equal(actual, expected);
|
||
assert.equal(actual, c1);
|
||
} finally {
|
||
cleanup(repo.dir);
|
||
}
|
||
});
|
||
|
||
test('resolveBaseRef: returns null on a root commit (the ONLY way null is reached) and THROWS when pointed at a directory that is not a git repository at all', () => {
|
||
const repo = makeGuardNextRepo();
|
||
try {
|
||
fs.writeFileSync(path.join(repo.dir, 'README.md'), 'root\n');
|
||
repo.commit('root, no parent');
|
||
assert.equal(resolveBaseRef({ cwd: repo.dir }), null, 'a root commit has no parent');
|
||
} finally {
|
||
cleanup(repo.dir);
|
||
}
|
||
|
||
// The pair this matters for: a blanket try/catch around BOTH the HEAD and HEAD^
|
||
// resolutions would silently collapse "git is broken here" into "there is no base",
|
||
// and a guard with no base sweeps nothing — it would pass vacuously, unnoticed by any
|
||
// test, which is precisely how the legacy-file job spent months guarding a file that
|
||
// had not existed since #2914 (#3078). resolveBaseRef resolves HEAD FIRST, unguarded,
|
||
// specifically so a broken repository throws instead of returning null.
|
||
const notARepo = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-not-a-repo-'));
|
||
try {
|
||
assert.throws(() => resolveBaseRef({ cwd: notARepo }));
|
||
} finally {
|
||
cleanup(notARepo);
|
||
}
|
||
});
|
||
|
||
test('assertUsableBaseRef: rejects an option-shaped ref, an empty string, null, and a non-string; returns a normal sha unchanged', () => {
|
||
// `git show` honors diff options including `--output=<file>`, which WRITES a file —
|
||
// an option-shaped ref is a real hazard, not a hypothetical one.
|
||
for (const bad of ['-x', '--output=/tmp/gsd-3078-pwned', '', null, 42, {}]) {
|
||
assert.throws(
|
||
() => assertUsableBaseRef(bad),
|
||
/would parse|option/,
|
||
`${JSON.stringify(bad)} must be rejected as unusable`,
|
||
);
|
||
}
|
||
const sha = 'a'.repeat(40);
|
||
assert.equal(assertUsableBaseRef(sha), sha, 'a normal 40-hex sha must be returned unchanged');
|
||
});
|
||
|
||
test('E2E: --guard-next --base-ref runs the real script as a subprocess against this checkout, deriving its expected outcome from the live fragment inventory', () => {
|
||
// Passing THIS checkout's own HEAD as --base-ref is the deliberate degenerate case: with
|
||
// a clean tree, the working-tree copy of every fragment in tests/emitted-drift-acks/
|
||
// equals its committed copy at HEAD, so "spent" is trivially true for every fragment
|
||
// that happens to exist right now. That degeneracy is exactly what makes the expected
|
||
// exit code and output DERIVABLE from the inventory at runtime rather than a number
|
||
// hand-picked when the directory happened to be empty (#3078 regression: it was empty
|
||
// when this test was written, then a fragment was restored and the hardcoded
|
||
// exit-0/count-2 expectation went stale). Whether the directory holds zero fragments or
|
||
// N, the assertion below is the correct one either way.
|
||
const scriptPath = path.join(REPO_ROOT, 'scripts', 'lint-emitted-drift-ack.cjs');
|
||
const head = gitIn(REPO_ROOT, ['rev-parse', 'HEAD']).trim();
|
||
const { listFragmentFiles } = require('../scripts/lint-emitted-drift-ack.cjs');
|
||
const fragDir = path.join(REPO_ROOT, 'tests', 'emitted-drift-acks');
|
||
const fragments = listFragmentFiles(fragDir);
|
||
|
||
let status = 0;
|
||
let out = '';
|
||
try {
|
||
out = execFileSync(
|
||
process.execPath,
|
||
[scriptPath, '--guard-next', '--base-ref', head],
|
||
{ cwd: REPO_ROOT, encoding: 'utf8', timeout: 30000 },
|
||
);
|
||
} catch (err) {
|
||
status = err.status;
|
||
out = (err.stdout || '') + (err.stderr || '');
|
||
}
|
||
|
||
// The legacy-file half is independent of the fragment inventory and always reports ok
|
||
// on this checkout (the legacy file was deleted by #2914) — this is the "legacy-file
|
||
// line" half of --guard-next's two halves.
|
||
assert.equal(
|
||
out.split('\n').filter((l) => l.startsWith('ok guard-no-ack-on-next:')).length >= 1, true,
|
||
'the legacy-file guard must print its own ok line regardless of fragment state',
|
||
);
|
||
|
||
if (fragments.length === 0) {
|
||
assert.equal(status, 0, 'zero fragments: the guard must exit 0');
|
||
assert.match(out, /no all-spent fragment survives/, 'the fragment-sweep half must also print its ok line');
|
||
} else {
|
||
assert.notEqual(status, 0, `${fragments.length} fragment(s) at HEAD are trivially all-spent against themselves`);
|
||
assert.match(out, new RegExp(`${fragments.length} fully-spent ack fragment\\(s\\) survive on next`));
|
||
for (const name of fragments) {
|
||
assert.ok(out.includes(name), `sweep output must name ${name}`);
|
||
assert.match(
|
||
out, new RegExp(`git rm[^\\n]*${escapeRegex(name)}`),
|
||
`sweep output must print a git rm remedy line for ${name}`,
|
||
);
|
||
}
|
||
}
|
||
});
|
||
|
||
test('E2E: --guard-next rejects an option-shaped --base-ref', () => {
|
||
// main()'s argv parsing is the only thing this exercises that the unit tests above
|
||
// cannot: an option-shaped --base-ref must be rejected, not silently accepted.
|
||
const scriptPath = path.join(REPO_ROOT, 'scripts', 'lint-emitted-drift-ack.cjs');
|
||
let threw = false;
|
||
let status;
|
||
let stderr = '';
|
||
try {
|
||
execFileSync(
|
||
process.execPath,
|
||
[scriptPath, '--guard-next', '--base-ref', '-x'],
|
||
{ cwd: REPO_ROOT, encoding: 'utf8', timeout: 30000 },
|
||
);
|
||
} catch (err) {
|
||
threw = true;
|
||
status = err.status;
|
||
stderr = err.stderr || '';
|
||
}
|
||
assert.ok(threw, 'an option-shaped --base-ref must make the guard exit non-zero');
|
||
assert.notEqual(status, 0);
|
||
assert.match(stderr, /would parse|option/);
|
||
});
|
||
|
||
// ── D. the collision shape end-to-end — the issue's own regression criterion ─
|
||
//
|
||
// #3078 "Done when": a merged, fully-spent fragment must not silently occupy a path
|
||
// key it can no longer gate. All three arms below must hold together: (1) a
|
||
// cross-source duplicate is a hard failure naming the remedy, (2) leaving the
|
||
// owning entry untouched is spent and gates nothing, (3) appending prose re-arms
|
||
// it, and (4) once the spent owner is swept, a new fragment can declare the path
|
||
// cleanly.
|
||
|
||
test('D1/D2: two fragments naming the same path is a hard failure, naming both files, git rm, re-arms, and APPEND', () => {
|
||
const scriptPath = path.join(REPO_ROOT, 'scripts', 'lint-emitted-drift-ack.cjs');
|
||
const fragDir = path.join(REPO_ROOT, ...ACK_DIR_REPO_PATH.split('/'));
|
||
const nameA = 'zzz-3078-test-collision-a.json';
|
||
const nameB = 'zzz-3078-test-collision-b.json';
|
||
const pathA = path.join(fragDir, nameA);
|
||
const pathB = path.join(fragDir, nameB);
|
||
const collisionKey = 'zzz-3078-test-collision-path.md';
|
||
|
||
try {
|
||
// The fragment directory does not necessarily exist yet — #2914's directory is
|
||
// created on first use, and `listFragmentFiles` treats an absent one as zero
|
||
// fragments, so a clean checkout may not have it.
|
||
fs.mkdirSync(fragDir, { recursive: true });
|
||
fs.writeFileSync(pathA, JSON.stringify({ version: ACK_VERSION, paths: { [collisionKey]: { reason: 'a' } } }));
|
||
fs.writeFileSync(pathB, JSON.stringify({ version: ACK_VERSION, paths: { [collisionKey]: { reason: 'b' } } }));
|
||
|
||
let stderr = '';
|
||
let threw = false;
|
||
try {
|
||
execFileSync(process.execPath, [scriptPath], { cwd: REPO_ROOT, encoding: 'utf8', timeout: 15000 });
|
||
} catch (err) {
|
||
threw = true;
|
||
stderr = err.stderr || '';
|
||
}
|
||
assert.ok(threw, 'a cross-source duplicate must exit non-zero');
|
||
assert.match(stderr, new RegExp(`duplicate ack for "${escapeRegex(collisionKey)}"`));
|
||
assert.match(stderr, new RegExp(escapeRegex(nameA)));
|
||
assert.match(stderr, new RegExp(escapeRegex(nameB)));
|
||
assert.match(stderr, /git rm /);
|
||
assert.match(stderr, /re-arms/);
|
||
assert.match(stderr, /APPEND/);
|
||
} finally {
|
||
// Must NOT survive the test — a real duplicate left behind would red the repo's
|
||
// own lint:ci the next time anyone runs it. cleanup() cannot be used here: it
|
||
// refuses any path outside the known OS temp roots, and these fixtures are
|
||
// deliberately created inside the real repo's tests/emitted-drift-acks/.
|
||
// eslint-disable-next-line local/no-raw-rmsync-in-tests -- not a temp dir; cleaning up files created inside the real repo tree
|
||
fs.rmSync(pathA, { force: true });
|
||
// eslint-disable-next-line local/no-raw-rmsync-in-tests -- not a temp dir; cleaning up files created inside the real repo tree
|
||
fs.rmSync(pathB, { force: true });
|
||
}
|
||
});
|
||
|
||
test('D3: leaving the owning fragment entry untouched is spent and gates nothing', () => {
|
||
const r = diffEmitted({
|
||
baseline: mf({ [WORKFLOW_KEY]: 'aaa' }),
|
||
current: mf({ [WORKFLOW_KEY]: 'bbb' }),
|
||
changedPaths: [],
|
||
ack: { version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'already explained' } } },
|
||
baseAck: { version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'already explained' } } },
|
||
});
|
||
assert.deepEqual(r.spentAcks, [WORKFLOW_KEY]);
|
||
assert.equal(r.acked.length, 0, 'a spent entry must not clear the new delta');
|
||
assert.deepEqual(r.unattributable.map((u) => u.rel), [WORKFLOW_KEY]);
|
||
});
|
||
|
||
test('D4: appending prose to the owning fragment entry re-arms it — #2639/#2993 ship depending on this route', () => {
|
||
const r = diffEmitted({
|
||
baseline: mf({ [WORKFLOW_KEY]: 'aaa' }),
|
||
current: mf({ [WORKFLOW_KEY]: 'bbb' }),
|
||
changedPaths: [],
|
||
ack: {
|
||
version: ACK_VERSION,
|
||
paths: { [WORKFLOW_KEY]: { reason: 'already explained, and now this NEW ripple too' } },
|
||
},
|
||
baseAck: { version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'already explained' } } },
|
||
});
|
||
assert.deepEqual(r.spentAcks, []);
|
||
assert.equal(r.acked.length, 1, '#2639/#2993 ship depending on this re-arm route continuing to work');
|
||
assert.equal(r.unattributable.length, 0);
|
||
});
|
||
|
||
test('D5: once the spent owner is swept, declaring the same path in a new fragment is clean', () => {
|
||
// Before the sweep: the owning fragment is fully spent (assertNoAllSpentFragments
|
||
// names it) but still present — this is the #3078 hazard.
|
||
const spentDoc = doc({ [WORKFLOW_KEY]: { reason: 'already explained' } });
|
||
const before = assertNoAllSpentFragments([frag('owner.json', spentDoc, spentDoc)]);
|
||
assert.ok(!before.ok);
|
||
assert.deepEqual(before.sweepable, ['owner.json']);
|
||
|
||
// After the sweep (fragment deleted, per the remedy): a NEW fragment can declare
|
||
// the same path with zero collision, exercised through the real duplicate-detection
|
||
// path (mergeAckSources), not just the pure sweep guard above.
|
||
const { merged, errors } = mergeAckSources([
|
||
{
|
||
source: 'tests/emitted-drift-acks/new.json',
|
||
doc: { version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'a fresh explanation' } } },
|
||
},
|
||
]);
|
||
assert.deepEqual(errors, [], 'no duplicate arises once the spent owner is gone');
|
||
assert.ok(merged.paths[WORKFLOW_KEY]);
|
||
|
||
const parsed = parseAck(merged);
|
||
assert.equal(parsed.entries.size, 1);
|
||
assert.deepEqual(parsed.errors, []);
|
||
});
|
||
|
||
// ─── Per-PR ack fragments: mergeAckSources + readAckSources (#2914) ──────────
|
||
//
|
||
// #2914 replaces the single shared tests/emitted-drift-ack.json with per-PR fragments
|
||
// under tests/emitted-drift-acks/, exactly the shape .changeset/ already uses to solve
|
||
// the same "every PR rewrites one file wholesale" conflict problem. The legacy file is
|
||
// still read and unioned in — five open PRs (#2818, #2812, #2728, #2566, #2531) carry it
|
||
// — so BOTH surfaces must keep working, together and alone.
|
||
|
||
// `merged.paths` is deliberately built via `Object.create(null)` (see mergeAckSources's
|
||
// doc comment: a fragment/legacy source naming a key `__proto__` must set a PROPERTY,
|
||
// never the prototype). `assert.deepEqual`/`deepStrictEqual` compares `[[Prototype]]`
|
||
// too, so a direct comparison against an ordinary `{}` literal fails on the prototype
|
||
// alone even when every key/value matches. Round-tripping through JSON (exactly what a
|
||
// real committed ack document goes through) normalizes it to a plain object for
|
||
// assertion purposes without touching the production code under test.
|
||
const plain = (o) => JSON.parse(JSON.stringify(o));
|
||
|
||
test('mergeAckSources: a single source with no entries merges to an empty, legal document', () => {
|
||
const { merged, errors } = mergeAckSources([]);
|
||
assert.deepEqual(errors, []);
|
||
assert.deepEqual(plain(merged), { version: ACK_VERSION, paths: {} });
|
||
});
|
||
|
||
test('mergeAckSources: fragments-only union with no overlap', () => {
|
||
const { merged, errors } = mergeAckSources([
|
||
{ source: 'tests/emitted-drift-acks/1000-a.json', doc: { version: ACK_VERSION, paths: { 'a.md': { reason: 'ra' } } } },
|
||
{ source: 'tests/emitted-drift-acks/1001-b.json', doc: { version: ACK_VERSION, paths: { 'b.md': { reason: 'rb' } } } },
|
||
]);
|
||
assert.deepEqual(errors, []);
|
||
assert.deepEqual(plain(merged.paths), { 'a.md': { reason: 'ra' }, 'b.md': { reason: 'rb' } });
|
||
});
|
||
|
||
test('mergeAckSources: legacy-only (a single source) merges through unchanged', () => {
|
||
const { merged, errors } = mergeAckSources([
|
||
{ source: ACK_FILE, doc: { version: ACK_VERSION, paths: { 'a.md': { reason: 'legacy' } } } },
|
||
]);
|
||
assert.deepEqual(errors, []);
|
||
assert.deepEqual(plain(merged.paths), { 'a.md': { reason: 'legacy' } });
|
||
});
|
||
|
||
test('mergeAckSources: legacy file and fragments together, no overlap', () => {
|
||
const { merged, errors } = mergeAckSources([
|
||
{ source: ACK_FILE, doc: { version: ACK_VERSION, paths: { 'legacy.md': { reason: 'from legacy' } } } },
|
||
{ source: 'tests/emitted-drift-acks/1000-a.json', doc: { version: ACK_VERSION, paths: { 'a.md': { reason: 'from fragment' } } } },
|
||
]);
|
||
assert.deepEqual(errors, []);
|
||
assert.deepEqual(plain(merged.paths), {
|
||
'legacy.md': { reason: 'from legacy' },
|
||
'a.md': { reason: 'from fragment' },
|
||
});
|
||
});
|
||
|
||
test('mergeAckSources: a duplicate key across two fragments is a loud error, never silent last-wins', () => {
|
||
const { merged, errors } = mergeAckSources([
|
||
{ source: 'tests/emitted-drift-acks/1000-a.json', doc: { version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'first' } } } },
|
||
{ source: 'tests/emitted-drift-acks/1001-b.json', doc: { version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'second' } } } },
|
||
]);
|
||
assert.equal(errors.length, 1);
|
||
assert.match(errors[0], /duplicate ack for/);
|
||
assert.match(errors[0], /1000-a\.json/, 'the error must name the first source');
|
||
assert.match(errors[0], /1001-b\.json/, 'the error must name the second source');
|
||
// First-wins is a deliberate, DOCUMENTED simplification (not silent): the caller is
|
||
// told loudly via `errors`, and the merged doc still holds a well-defined value.
|
||
assert.equal(merged.paths[WORKFLOW_KEY].reason, 'first');
|
||
});
|
||
|
||
test('mergeAckSources: an empty fragments directory (represented as zero docs) is legal', () => {
|
||
const { merged, errors } = mergeAckSources([]);
|
||
assert.deepEqual(errors, []);
|
||
assert.deepEqual(plain(merged.paths), {});
|
||
});
|
||
|
||
test('mergeAckSources: a malformed source (bad version) surfaces the same error parseAck would', () => {
|
||
const { errors } = mergeAckSources([
|
||
{ source: 'tests/emitted-drift-acks/1000-bad.json', doc: { version: 99, paths: {} } },
|
||
]);
|
||
assert.match(errors.join('\n'), /unsupported version 99/);
|
||
});
|
||
|
||
test('mergeAckSources: a __proto__ key from a fragment is rejected, never merged in or used to pollute', () => {
|
||
// MUST be built via JSON.parse, not a JS object literal: `{ '__proto__': v }` is the
|
||
// special ObjectLiteral case that SETS THE PROTOTYPE and yields zero own keys, so a
|
||
// fixture built that way is empty and never exercises this path at all (the exact
|
||
// reason the previous version of this test was vacuous and failed with "Cannot read
|
||
// properties of undefined"). `JSON.parse` is the production path and creates a genuine
|
||
// own key.
|
||
const doc = JSON.parse('{"version":' + ACK_VERSION + ',"paths":{"__proto__":{"reason":"hostile"}}}');
|
||
assert.deepEqual(Object.keys(doc.paths), ['__proto__'], 'JSON.parse must create a genuine own key');
|
||
|
||
const { merged, errors } = mergeAckSources([
|
||
{ source: 'tests/emitted-drift-acks/1000-a.json', doc },
|
||
]);
|
||
|
||
assert.equal(errors.length, 1);
|
||
assert.match(errors[0], /reserved/);
|
||
assert.match(errors[0], /__proto__/);
|
||
assert.deepEqual(plain(merged.paths), {}, 'a reserved key must never be merged into the document');
|
||
assert.equal(Object.getPrototypeOf(merged.paths), null, 'merged.paths stays null-prototype');
|
||
assert.equal(({}).reason, undefined, 'Object.prototype must be untouched');
|
||
});
|
||
|
||
test('readAckSources: fragments-only on a real tree', () => {
|
||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ack-frag-'));
|
||
try {
|
||
const fragDir = path.join(dir, 'acks');
|
||
fs.mkdirSync(fragDir, { recursive: true });
|
||
fs.writeFileSync(path.join(fragDir, '1000-a.json'), JSON.stringify({ version: ACK_VERSION, paths: { 'a.md': { reason: 'ra' } } }));
|
||
fs.writeFileSync(path.join(fragDir, '1001-b.json'), JSON.stringify({ version: ACK_VERSION, paths: { 'b.md': { reason: 'rb' } } }));
|
||
|
||
const { doc, errors } = readAckSources({ legacyPath: path.join(dir, 'emitted-drift-ack.json'), fragmentsDir: fragDir });
|
||
assert.deepEqual(errors, []);
|
||
assert.deepEqual(plain(doc.paths), { 'a.md': { reason: 'ra' }, 'b.md': { reason: 'rb' } });
|
||
} finally {
|
||
cleanup(dir);
|
||
}
|
||
});
|
||
|
||
test('readAckSources: legacy-only on a real tree (no fragments directory at all)', () => {
|
||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ack-legacy-'));
|
||
try {
|
||
const legacyPath = path.join(dir, 'emitted-drift-ack.json');
|
||
fs.writeFileSync(legacyPath, JSON.stringify({ version: ACK_VERSION, paths: { 'legacy.md': { reason: 'from legacy' } } }));
|
||
|
||
const { doc, errors } = readAckSources({ legacyPath, fragmentsDir: path.join(dir, 'nonexistent-acks') });
|
||
assert.deepEqual(errors, []);
|
||
assert.deepEqual(plain(doc.paths), { 'legacy.md': { reason: 'from legacy' } });
|
||
} finally {
|
||
cleanup(dir);
|
||
}
|
||
});
|
||
|
||
test('readAckSources: legacy file and fragments together', () => {
|
||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ack-both-'));
|
||
try {
|
||
const legacyPath = path.join(dir, 'emitted-drift-ack.json');
|
||
const fragDir = path.join(dir, 'acks');
|
||
fs.mkdirSync(fragDir, { recursive: true });
|
||
fs.writeFileSync(legacyPath, JSON.stringify({ version: ACK_VERSION, paths: { 'legacy.md': { reason: 'from legacy' } } }));
|
||
fs.writeFileSync(path.join(fragDir, '1000-a.json'), JSON.stringify({ version: ACK_VERSION, paths: { 'a.md': { reason: 'from fragment' } } }));
|
||
|
||
const { doc, errors } = readAckSources({ legacyPath, fragmentsDir: fragDir });
|
||
assert.deepEqual(errors, []);
|
||
assert.deepEqual(plain(doc.paths), {
|
||
'legacy.md': { reason: 'from legacy' },
|
||
'a.md': { reason: 'from fragment' },
|
||
});
|
||
} finally {
|
||
cleanup(dir);
|
||
}
|
||
});
|
||
|
||
test('readAckSources: neither the legacy file nor the fragments directory exists — the healthy steady state', () => {
|
||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ack-absent-'));
|
||
try {
|
||
const { doc, errors } = readAckSources({
|
||
legacyPath: path.join(dir, 'emitted-drift-ack.json'),
|
||
fragmentsDir: path.join(dir, 'acks'),
|
||
});
|
||
assert.equal(doc, null);
|
||
assert.deepEqual(errors, []);
|
||
} finally {
|
||
cleanup(dir);
|
||
}
|
||
});
|
||
|
||
test('readAckSources: an empty fragments directory (present, zero files) plus no legacy file', () => {
|
||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ack-emptydir-'));
|
||
try {
|
||
const fragDir = path.join(dir, 'acks');
|
||
fs.mkdirSync(fragDir, { recursive: true });
|
||
const { doc, errors } = readAckSources({ legacyPath: path.join(dir, 'emitted-drift-ack.json'), fragmentsDir: fragDir });
|
||
assert.equal(doc, null, 'zero fragments and no legacy file is still the healthy steady state');
|
||
assert.deepEqual(errors, []);
|
||
} finally {
|
||
cleanup(dir);
|
||
}
|
||
});
|
||
|
||
test('readAckSources: a duplicate key across two fragments fails loudly and would fail the real gate', () => {
|
||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ack-dupe-'));
|
||
try {
|
||
const fragDir = path.join(dir, 'acks');
|
||
fs.mkdirSync(fragDir, { recursive: true });
|
||
fs.writeFileSync(path.join(fragDir, '1000-a.json'), JSON.stringify({ version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'first' } } }));
|
||
fs.writeFileSync(path.join(fragDir, '1001-b.json'), JSON.stringify({ version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'second' } } }));
|
||
|
||
const { doc: ack, errors: mergeAckErrors } = readAckSources({
|
||
legacyPath: path.join(dir, 'emitted-drift-ack.json'),
|
||
fragmentsDir: fragDir,
|
||
});
|
||
assert.equal(mergeAckErrors.length, 1);
|
||
assert.match(mergeAckErrors[0], /duplicate ack for/);
|
||
|
||
// Wired exactly as the real-tree test wires it: folded into diffEmitted's own
|
||
// errors, which must fail the whole gate — never silently pass with one winner.
|
||
const r = diffEmitted({
|
||
baseline: mf({ [WORKFLOW_KEY]: 'aaa' }),
|
||
current: mf({ [WORKFLOW_KEY]: 'aaa' }),
|
||
changedPaths: [],
|
||
ack,
|
||
baseAck: null,
|
||
mergeAckErrors,
|
||
});
|
||
assert.ok(!r.ok, 'a duplicate ack across two fragments must fail the gate');
|
||
assert.match(formatReport(r), /duplicate ack for/);
|
||
} finally {
|
||
cleanup(dir);
|
||
}
|
||
});
|
||
|
||
test('readAckSources: a malformed fragment (invalid JSON) throws, naming the fragment file', () => {
|
||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ack-malformed-'));
|
||
try {
|
||
const fragDir = path.join(dir, 'acks');
|
||
fs.mkdirSync(fragDir, { recursive: true });
|
||
fs.writeFileSync(path.join(fragDir, '1000-bad.json'), '{ not json');
|
||
|
||
assert.throws(
|
||
() => readAckSources({ legacyPath: path.join(dir, 'emitted-drift-ack.json'), fragmentsDir: fragDir }),
|
||
/1000-bad\.json.*not valid JSON/,
|
||
);
|
||
} finally {
|
||
cleanup(dir);
|
||
}
|
||
});
|
||
|
||
test('readAckSources: listAckFragmentFiles returns sorted .json names only, absent dir is empty', () => {
|
||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ack-listing-'));
|
||
try {
|
||
assert.deepEqual(listAckFragmentFiles(path.join(dir, 'missing')), []);
|
||
|
||
const fragDir = path.join(dir, 'acks');
|
||
fs.mkdirSync(fragDir, { recursive: true });
|
||
fs.writeFileSync(path.join(fragDir, '2000-z.json'), '{}');
|
||
fs.writeFileSync(path.join(fragDir, '1000-a.json'), '{}');
|
||
fs.writeFileSync(path.join(fragDir, 'README.md'), 'not a fragment');
|
||
assert.deepEqual(listAckFragmentFiles(fragDir), ['1000-a.json', '2000-z.json']);
|
||
} finally {
|
||
cleanup(dir);
|
||
}
|
||
});
|
||
|
||
test('listAckFragmentFilesAtRef: lists .json fragment names at a ref directly, sorted, non-.json excluded', () => {
|
||
const run = (args) => {
|
||
assert.equal(args[0], 'ls-tree');
|
||
assert.equal(args[args.length - 1], `${ACK_DIR_REPO_PATH}/`);
|
||
return [
|
||
`${ACK_DIR_REPO_PATH}/2000-z.json`,
|
||
`${ACK_DIR_REPO_PATH}/1000-a.json`,
|
||
`${ACK_DIR_REPO_PATH}/README.md`,
|
||
].join('\n') + '\n';
|
||
};
|
||
assert.deepEqual(listAckFragmentFilesAtRef(SHA_A, { run }), ['1000-a.json', '2000-z.json']);
|
||
});
|
||
|
||
test('listAckFragmentFilesAtRef: an absent fragment directory at the ref is zero names, not a fault', () => {
|
||
const run = () => '\n';
|
||
assert.deepEqual(listAckFragmentFilesAtRef(SHA_A, { run }), []);
|
||
});
|
||
|
||
test('listAckFragmentFilesAtRef: a git failure listing the directory throws', () => {
|
||
const run = () => { throw new Error('injected git failure'); };
|
||
assert.throws(() => listAckFragmentFilesAtRef(SHA_A, { run }), /could not list tests\/emitted-drift-acks\//);
|
||
});
|
||
|
||
test('listAckFragmentFiles: exactly MAX_ACK_FRAGMENTS entries passes, one over fails loudly (#2914 review)', () => {
|
||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ack-listing-cap-'));
|
||
try {
|
||
for (let i = 0; i < MAX_ACK_FRAGMENTS; i++) {
|
||
fs.writeFileSync(path.join(dir, `f-${String(i).padStart(4, '0')}.json`), '{}');
|
||
}
|
||
assert.equal(listAckFragmentFiles(dir).length, MAX_ACK_FRAGMENTS, 'at the cap must still pass');
|
||
|
||
fs.writeFileSync(path.join(dir, `f-${String(MAX_ACK_FRAGMENTS).padStart(4, '0')}.json`), '{}');
|
||
assert.throws(
|
||
() => listAckFragmentFiles(dir),
|
||
(err) => {
|
||
assert.match(err.message, new RegExp(escapeRegex(dir)));
|
||
assert.match(err.message, new RegExp(String(MAX_ACK_FRAGMENTS + 1)));
|
||
assert.match(err.message, new RegExp(String(MAX_ACK_FRAGMENTS)));
|
||
return true;
|
||
},
|
||
'one over the cap must throw, naming the directory, the cap, and the actual count',
|
||
);
|
||
} finally {
|
||
cleanup(dir);
|
||
}
|
||
});
|
||
|
||
test('listAckFragmentFilesAtRef: exactly MAX_ACK_FRAGMENTS entries passes, one over fails loudly (#2914 review)', () => {
|
||
const makeListing = (count) => Array.from(
|
||
{ length: count },
|
||
(_, i) => `${ACK_DIR_REPO_PATH}/f-${String(i).padStart(4, '0')}.json`,
|
||
).join('\n') + '\n';
|
||
|
||
const atCap = () => makeListing(MAX_ACK_FRAGMENTS);
|
||
assert.equal(
|
||
listAckFragmentFilesAtRef(SHA_A, { run: atCap }).length,
|
||
MAX_ACK_FRAGMENTS,
|
||
'at the cap must still pass',
|
||
);
|
||
|
||
const overCap = () => makeListing(MAX_ACK_FRAGMENTS + 1);
|
||
assert.throws(
|
||
() => listAckFragmentFilesAtRef(SHA_A, { run: overCap }),
|
||
(err) => {
|
||
assert.match(err.message, new RegExp(escapeRegex(`${ACK_DIR_REPO_PATH}/ at "${SHA_A}"`)));
|
||
assert.match(err.message, new RegExp(String(MAX_ACK_FRAGMENTS + 1)));
|
||
assert.match(err.message, new RegExp(String(MAX_ACK_FRAGMENTS)));
|
||
return true;
|
||
},
|
||
'one over the cap must throw, naming the directory, the cap, and the actual count',
|
||
);
|
||
});
|
||
|
||
// ─── The migration pin is gone: #3078 emptied the directory it protected (#3078) ──
|
||
// #2914 migrated the legacy shared file's 35 entries into the legacy-migration bucket
|
||
// fragment and this file pinned that fragment's continued existence, on the premise
|
||
// that a fragment left on `next` is inert-but-harmless. #3078 removed that premise:
|
||
// fragments don't share a file, but they DO share a path key space, so a fully-spent
|
||
// fragment owns keys it can no longer gate, and the next PR to grow one of those paths
|
||
// is hard-blocked. `--guard-next` now sweeps all-spent fragments, and the whole
|
||
// directory was emptied — the legacy-migration bucket included. The maintainer's
|
||
// decision on #3078 states plainly it was never permanent.
|
||
//
|
||
// The three tests that pinned it are gone rather than adapted: each asserted the
|
||
// presence of a specific merged fragment, which is exactly the state the guard now
|
||
// forbids. What they protected is covered instead by the fragment-lifecycle section
|
||
// (`assertNoAllSpentFragments`) added by #3078, and #2733's own control-flow
|
||
// protection lives in `tests/spec-phase-probe-reachability.test.cjs`, which never
|
||
// depended on the ack. The #2733 spec-phase.md byte-delta entry (31987 -> 31997) is
|
||
// spent by construction: `next`'s published emitted baseline has carried the
|
||
// post-#2733 bytes since that PR merged, so `sizeBaseline === sizeCurrent` and there
|
||
// is no growth left for it to clear.
|
||
|
||
test('an empty fragment directory is the healthy steady state, end to end (#3078)', () => {
|
||
// Absent directory is zero fragments, not a fault.
|
||
const missingDir = path.join(REPO_ROOT, 'tests', 'no-such-emitted-drift-acks-dir');
|
||
assert.deepEqual(listAckFragmentFiles(missingDir), []);
|
||
|
||
// On THIS checkout, ACK_DIR may or may not exist — other PRs merge fragments over
|
||
// time, so this must not become a new pin either way. Assert the invariant that
|
||
// holds regardless.
|
||
assert.ok(Array.isArray(listAckFragmentFiles(ACK_DIR)));
|
||
assert.ok(assertNoAllSpentFragments([]).ok);
|
||
|
||
// The deadlock-avoidance path the empty state now takes every day: a tree carrying
|
||
// no ack never consults the base, which is what keeps a repair PR landable — and is
|
||
// now the steady state rather than the exception.
|
||
const r = diffEmitted({
|
||
baseline: mf({}),
|
||
current: mf({}),
|
||
changedPaths: [],
|
||
ack: null,
|
||
baseAck: null,
|
||
});
|
||
assert.ok(r.ok);
|
||
assert.deepEqual(r.staleAcks, []);
|
||
assert.deepEqual(r.spentAcks, []);
|
||
});
|
||
|
||
// ─── readAckFileAtRef: the base-side reader (#2789) ──────────────────────────
|
||
//
|
||
// This half never runs in the remote runner — the real-tree test skips there, because a
|
||
// shallow clone has no `origin/*` to resolve. Without these, replacing the body with
|
||
// `return null` would fail nothing while silently restoring the pre-#2789 gate. The git
|
||
// runner is injected rather than monkeypatched: deterministic on every OS, and no
|
||
// dependence on the host repo's actual refs.
|
||
|
||
const fakeGit = (handlers) => (args) => {
|
||
if (args[0] === 'ls-tree') return handlers.lsTree ? handlers.lsTree() : `${ACK_REPO_PATH}\n`;
|
||
if (args[0] === 'show') return handlers.show ? handlers.show() : '{}';
|
||
throw new Error(`unexpected git call: ${args.join(' ')}`);
|
||
};
|
||
|
||
test('readAckFileAtRef: absent at the ref is the healthy steady state and returns null', () => {
|
||
// ls-tree exits 0 with EMPTY output when the path simply is not there.
|
||
const doc = readAckFileAtRef(SHA_A, { run: fakeGit({ lsTree: () => '\n' }) });
|
||
assert.equal(doc, null);
|
||
});
|
||
|
||
test('readAckFileAtRef: present and valid parses through', () => {
|
||
const payload = { version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'r' } } };
|
||
const doc = readAckFileAtRef(SHA_A, {
|
||
run: fakeGit({ show: () => JSON.stringify(payload) }),
|
||
});
|
||
assert.deepEqual(doc, payload);
|
||
});
|
||
|
||
test('readAckFileAtRef: a READ FAILURE throws — it must never degrade to "inherit nothing"', () => {
|
||
// The whole point. Returning null here looks armed (every entry stays live) but a LIVE
|
||
// entry is precisely the one that CAN CONSUME a delta, so a genuinely new unexplained
|
||
// ripple would come back `acked` instead of `unattributable` — silently the entire
|
||
// pre-#2789 gate. Same law as resolveChangedPaths: a failed git read is an error.
|
||
const boom = () => { throw new Error('injected git failure'); };
|
||
|
||
assert.throws(
|
||
() => readAckFileAtRef(SHA_A, { run: fakeGit({ lsTree: boom }) }),
|
||
/could not list the ack/,
|
||
);
|
||
assert.throws(
|
||
() => readAckFileAtRef(SHA_A, { run: fakeGit({ show: boom }) }),
|
||
/exists at .* but could not be read/,
|
||
);
|
||
});
|
||
|
||
test('readAckFileAtRef: present but empty or unparseable throws, like the head-side reader', () => {
|
||
assert.throws(
|
||
() => readAckFileAtRef(SHA_A, { run: fakeGit({ show: () => ' \n' }) }),
|
||
/present at .* but empty/,
|
||
);
|
||
assert.throws(
|
||
() => readAckFileAtRef(SHA_A, { run: fakeGit({ show: () => '{ not json' }) }),
|
||
/is not valid JSON/,
|
||
);
|
||
});
|
||
|
||
test('readAckFileAtRef: refuses an option-shaped ref rather than handing it to git', () => {
|
||
// execFileSync's array form stops shell metacharacters but NOT git's option parsing:
|
||
// `git show` honors --output=<file>, which writes. The guard belongs with the argument,
|
||
// since this helper is exported and its callers are not the only possible ones.
|
||
const never = () => { throw new Error('git must not be invoked at all'); };
|
||
for (const bad of ['--output=/tmp/pwn', '-next', '--upload-pack=x', '', null, undefined, 42]) {
|
||
assert.throws(
|
||
() => readAckFileAtRef(bad, { run: fakeGit({ lsTree: never, show: never }) }),
|
||
/refusing to read the ack/,
|
||
`${JSON.stringify(bad)} must be refused`,
|
||
);
|
||
}
|
||
});
|
||
|
||
// ─── readAckSourcesAtRef: the base-side UNION reader (#2914) ─────────────────
|
||
//
|
||
// Mirrors readAckFileAtRef's fakeGit harness above, extended to also answer `ls-tree`
|
||
// on the FRAGMENT DIRECTORY and `show` for each fragment name it lists — a base-side
|
||
// stand-in for "the legacy file plus every fragment, as they existed at that ref".
|
||
|
||
const fakeMultiGit = ({ legacy, fragments = {} } = {}) => (args) => {
|
||
if (args[0] === 'ls-tree') {
|
||
const target = args[args.length - 1];
|
||
if (target === ACK_REPO_PATH) return legacy !== undefined ? `${ACK_REPO_PATH}\n` : '\n';
|
||
if (target === `${ACK_DIR_REPO_PATH}/`) {
|
||
const names = Object.keys(fragments);
|
||
return names.length ? names.map((n) => `${ACK_DIR_REPO_PATH}/${n}`).join('\n') + '\n' : '\n';
|
||
}
|
||
// readAckFileAtRef's OWN per-file existence check, once per fragment name it was
|
||
// told about by the directory listing above — a second, distinct ls-tree call.
|
||
const name = target.slice(target.lastIndexOf('/') + 1);
|
||
if (`${ACK_DIR_REPO_PATH}/${name}` === target && Object.prototype.hasOwnProperty.call(fragments, name)) {
|
||
return `${target}\n`;
|
||
}
|
||
throw new Error(`fakeMultiGit: unexpected ls-tree target ${target}`);
|
||
}
|
||
if (args[0] === 'show') {
|
||
const spec = args[1];
|
||
const p = spec.slice(spec.indexOf(':') + 1);
|
||
if (p === ACK_REPO_PATH) return legacy;
|
||
const name = p.slice(p.lastIndexOf('/') + 1);
|
||
if (Object.prototype.hasOwnProperty.call(fragments, name)) return fragments[name];
|
||
throw new Error(`fakeMultiGit: unexpected show path ${p}`);
|
||
}
|
||
throw new Error(`fakeMultiGit: unexpected git call: ${args.join(' ')}`);
|
||
};
|
||
|
||
test('readAckSourcesAtRef: fragments-only at a ref', () => {
|
||
const { doc } = readAckSourcesAtRef(SHA_A, {
|
||
run: fakeMultiGit({
|
||
fragments: {
|
||
'1000-a.json': JSON.stringify({ version: ACK_VERSION, paths: { 'a.md': { reason: 'ra' } } }),
|
||
'1001-b.json': JSON.stringify({ version: ACK_VERSION, paths: { 'b.md': { reason: 'rb' } } }),
|
||
},
|
||
}),
|
||
});
|
||
assert.deepEqual(plain(doc.paths), { 'a.md': { reason: 'ra' }, 'b.md': { reason: 'rb' } });
|
||
});
|
||
|
||
test('readAckSourcesAtRef: legacy-only at a ref (no fragments directory)', () => {
|
||
const { doc } = readAckSourcesAtRef(SHA_A, {
|
||
run: fakeMultiGit({ legacy: JSON.stringify({ version: ACK_VERSION, paths: { 'legacy.md': { reason: 'from legacy' } } }) }),
|
||
});
|
||
assert.deepEqual(plain(doc.paths), { 'legacy.md': { reason: 'from legacy' } });
|
||
});
|
||
|
||
test('readAckSourcesAtRef: legacy and fragments together at a ref', () => {
|
||
const { doc } = readAckSourcesAtRef(SHA_A, {
|
||
run: fakeMultiGit({
|
||
legacy: JSON.stringify({ version: ACK_VERSION, paths: { 'legacy.md': { reason: 'from legacy' } } }),
|
||
fragments: { '1000-a.json': JSON.stringify({ version: ACK_VERSION, paths: { 'a.md': { reason: 'from fragment' } } }) },
|
||
}),
|
||
});
|
||
assert.deepEqual(plain(doc.paths), { 'legacy.md': { reason: 'from legacy' }, 'a.md': { reason: 'from fragment' } });
|
||
});
|
||
|
||
test('readAckSourcesAtRef: neither legacy nor any fragment exists at the ref', () => {
|
||
const { doc } = readAckSourcesAtRef(SHA_A, { run: fakeMultiGit({}) });
|
||
assert.equal(doc, null);
|
||
});
|
||
|
||
test('readAckSourcesAtRef: an empty fragments directory at the ref, no legacy file', () => {
|
||
const { doc } = readAckSourcesAtRef(SHA_A, { run: fakeMultiGit({ fragments: {} }) });
|
||
assert.equal(doc, null, 'zero fragments and no legacy file at the ref is still the healthy steady state');
|
||
});
|
||
|
||
test('readAckSourcesAtRef: a base-side duplicate across fragments does not throw (discarded like other base schema issues)', () => {
|
||
// Matches this module's existing precedent for the base side (see diffEmitted's real
|
||
// -tree caller: "Base-side SCHEMA errors are deliberately discarded"). A base-side
|
||
// duplicate is `next`'s own health, not this diff's to answer for, and
|
||
// mergeAckSources's first-source-wins keeps the STRICT reading even with no error
|
||
// surfaced -- an entry can only be spent against the ONE reason actually kept.
|
||
const { doc } = readAckSourcesAtRef(SHA_A, {
|
||
run: fakeMultiGit({
|
||
fragments: {
|
||
'1000-a.json': JSON.stringify({ version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'first' } } }),
|
||
'1001-b.json': JSON.stringify({ version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'second' } } }),
|
||
},
|
||
}),
|
||
});
|
||
assert.equal(doc.paths[WORKFLOW_KEY].reason, 'first');
|
||
});
|
||
|
||
test('readAckSourcesAtRef: a fragment that is unreadable at the ref still throws', () => {
|
||
const fragRelPath = `${ACK_DIR_REPO_PATH}/1000-a.json`;
|
||
const run = (args) => {
|
||
if (args[0] === 'ls-tree') {
|
||
const target = args[args.length - 1];
|
||
if (target === ACK_REPO_PATH) return '\n';
|
||
if (target === `${ACK_DIR_REPO_PATH}/`) return `${fragRelPath}\n`;
|
||
// readAckFileAtRef's OWN existence check for the individual fragment file, prior
|
||
// to `show` — it exists at this ref, so the failure below is a genuine read fault.
|
||
if (target === fragRelPath) return `${fragRelPath}\n`;
|
||
throw new Error(`unexpected ls-tree target: ${target}`);
|
||
}
|
||
if (args[0] === 'show') throw new Error('injected git failure');
|
||
throw new Error(`unexpected git call: ${args.join(' ')}`);
|
||
};
|
||
assert.throws(
|
||
() => readAckSourcesAtRef(SHA_A, { run }),
|
||
/exists at .* but could not be read/,
|
||
);
|
||
});
|
||
|
||
test('OMITTING baseAck while an ack is present is a loud error, never a silent pass', () => {
|
||
// This is what makes the production seam non-revertible in silence. Drop `baseAck:`
|
||
// from the real-tree call and the gate fails loudly here, instead of quietly restoring
|
||
// #2768 with every other test still green. Same discipline the module already applies
|
||
// to `changedPaths`: a missing input is an error, not an empty set.
|
||
const r = diffEmitted({
|
||
baseline: mf({ [WORKFLOW_KEY]: 'aaa' }),
|
||
current: mf({ [WORKFLOW_KEY]: 'aaa' }),
|
||
changedPaths: [],
|
||
ack: { version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'x' } } },
|
||
});
|
||
assert.ok(!r.ok);
|
||
assert.match(r.errors.join('\n'), /baseAck was not supplied/);
|
||
});
|
||
|
||
test('with no ack ENTRIES, baseAck is not required — the healthy steady state stays quiet', () => {
|
||
// Absence of an ack file is the normal case for almost every PR. It must not be made
|
||
// to carry a new required argument it has no use for — and neither must a document
|
||
// that is present but declares nothing, which `parseAck` accepts as legal (see
|
||
// 'non-object ack JSON is rejected'). Only a real ENTRY can be spent or live, so only
|
||
// a real entry needs the base side.
|
||
for (const ack of [undefined, null, {}, { version: ACK_VERSION }, { paths: {} }]) {
|
||
const r = diffEmitted({
|
||
baseline: mf({ [WORKFLOW_KEY]: 'aaa' }),
|
||
current: mf({ [WORKFLOW_KEY]: 'bbb' }),
|
||
changedPaths: [WORKFLOW_SRC],
|
||
ack,
|
||
});
|
||
assert.deepEqual(r.errors, [], `ack ${JSON.stringify(ack)} must need no baseAck`);
|
||
assert.ok(r.ok);
|
||
}
|
||
});
|
||
|
||
test('a spent size-growth ack neither fails nor clears a further growth', () => {
|
||
const ack = { version: ACK_VERSION, paths: { 'plan-phase.md': { reason: 'grew once, deliberately' } } };
|
||
const shared = {
|
||
baseline: mf({ [WORKFLOW_KEY]: 'aaa' }),
|
||
current: mf({ [WORKFLOW_KEY]: 'aaa' }),
|
||
changedPaths: [],
|
||
ack,
|
||
baseAck: ack,
|
||
};
|
||
|
||
// Absorbed: base and current agree on size, so there is no growth left to explain.
|
||
const settled = diffEmitted({ ...shared, sizeBaseline: { 'plan-phase.md': 5000 }, sizeCurrent: { 'plan-phase.md': 5000 } });
|
||
assert.deepEqual(settled.staleAcks, []);
|
||
assert.ok(settled.ok);
|
||
|
||
// A further growth is a NEW ripple: the spent ack must not silently absorb it.
|
||
const grewAgain = diffEmitted({ ...shared, sizeBaseline: { 'plan-phase.md': 5000 }, sizeCurrent: { 'plan-phase.md': 5400 } });
|
||
assert.equal(grewAgain.grown.length, 1);
|
||
assert.equal(grewAgain.grown[0].acked, false, 'a spent ack must not clear a further growth');
|
||
assert.ok(!grewAgain.ok);
|
||
});
|
||
|
||
test('non-object ack JSON is rejected, not treated as empty', () => {
|
||
// Reading these as "no acks" would SILENTLY DISARM the gate — indistinguishable
|
||
// from a healthy run, which is the worst failure available here.
|
||
for (const bad of [0, 'a string', [], true]) {
|
||
const { errors } = parseAck(bad);
|
||
assert.ok(errors.length > 0, `${JSON.stringify(bad)} must be rejected`);
|
||
assert.match(errors.join('\n'), /must be a JSON object/);
|
||
}
|
||
assert.deepEqual(parseAck(null).errors, [], 'absent is legal');
|
||
assert.deepEqual(parseAck({}).errors, [], 'empty object is legal');
|
||
assert.equal(parseAck({ version: 99, paths: {} }).errors.length, 1, 'version drift is caught');
|
||
});
|
||
|
||
// ─── 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,
|
||
ack: { version: ACK_VERSION, paths },
|
||
baseAck: null,
|
||
});
|
||
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,
|
||
ack: { version: ACK_VERSION, paths: { 'verify-work.md': { reason: 'new UAT section' } } },
|
||
baseAck: null,
|
||
});
|
||
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 },
|
||
ack: { version: ACK_VERSION, paths: { 'verify-work.md': { reason: 'new UAT section' } } },
|
||
baseAck: null,
|
||
});
|
||
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 },
|
||
// Sits BEFORE the spread so a row can still override it, while every row that passes
|
||
// an `ack` inline gets the explicit "nothing inherited from the base" reading rather
|
||
// than tripping `diffEmitted`'s required-baseAck error (#2789).
|
||
baseAck: null,
|
||
...extra,
|
||
});
|
||
|
||
/** The one block of `kind`, or undefined. */
|
||
const blockOf = (report, kind) => report.blocks.find((b) => b.kind === kind);
|
||
|
||
test('a growth-only failure carries the byte delta, the key rule, and an ack entry', () => {
|
||
// The pre-#2778 report stopped after the byte delta. The suite's only coverage of the
|
||
// remediation reached it through the UNATTRIBUTABLE branch, so a growth-only regression
|
||
// was invisible — which is why this fixture carries no hash movement at all.
|
||
const r = growthOnly();
|
||
assert.equal(r.unattributable.length, 0, 'this fixture must isolate the growth branch');
|
||
assert.ok(!r.ok);
|
||
|
||
const report = buildReport(r);
|
||
const growth = blockOf(report, 'unacked-growth');
|
||
assert.ok(growth, 'the growth branch must produce a block');
|
||
assert.equal(growth.count, 1);
|
||
assert.deepEqual(growth.items[0], {
|
||
name: 'explore.md', from: 11127, to: 13230, delta: 2103, acked: false,
|
||
});
|
||
assert.equal(growth.keyRule, REMEDIATION.growthKeyRule, 'growth keys on the bare filename');
|
||
|
||
assert.deepEqual(report.ackable, [
|
||
{ key: 'explore.md', reason: REMEDIATION.growthReason },
|
||
], 'the ack entry must be keyed on the file that actually grew');
|
||
});
|
||
|
||
test('the renderer emits the ack file, the document, and the do-not-regenerate line', () => {
|
||
// The one place rendered text is the object of the test: proving the IR above actually
|
||
// reaches the contributor. Everything it asserts is an identity comparison against the
|
||
// frozen surface, so rewording any sentence cannot fail this.
|
||
const msg = formatReport(growthOnly());
|
||
assert.ok(msg.includes('explore.md grew 2103 bytes (11127 -> 13230)'), 'the delta still leads');
|
||
assert.ok(msg.includes(REMEDIATION.ackFile), 'the message must name the ack file');
|
||
assert.ok(msg.includes(REMEDIATION.createIfAbsent), 'it must say the file may not exist yet');
|
||
assert.ok(msg.includes(REMEDIATION.growthKeyRule), 'it must state the bare-filename key rule');
|
||
assert.ok(msg.includes(REMEDIATION.doNotRegenerate), 'it must say not to regenerate');
|
||
assert.ok(
|
||
msg.includes(REMEDIATION.ackDocument([{ key: 'explore.md', reason: REMEDIATION.growthReason }])),
|
||
'the printed document must be the one the IR describes',
|
||
);
|
||
});
|
||
|
||
test('the document the report teaches is accepted by parseAck', () => {
|
||
// The divergence killer. A report that teaches a schema the parser rejects is worse
|
||
// than no report: the contributor follows it, is rejected anyway, and now distrusts the
|
||
// gate. This pins the taught shape to the accepted shape in one assertion.
|
||
const taught = REMEDIATION.ackDocument([{ key: 'explore.md', reason: 'a real reason' }]);
|
||
const { entries, errors } = parseAck(JSON.parse(taught));
|
||
assert.deepEqual(errors, [], 'the taught document must parse with zero errors');
|
||
assert.equal(entries.get('explore.md').reason, 'a real reason');
|
||
|
||
// And it must actually clear the gate it is offered to clear.
|
||
const r = growthOnly({ ack: JSON.parse(taught) });
|
||
assert.equal(r.grown[0].acked, true);
|
||
assert.deepEqual(r.staleAcks, []);
|
||
assert.ok(r.ok, 'following the printed instructions must turn the lane green');
|
||
});
|
||
|
||
test('the taught document derives its version from ACK_VERSION', () => {
|
||
// A hand-typed `"version": 1` beside a live ACK_VERSION is the generative-fix-divergence
|
||
// class: bump one, the other lies. Asserting the relationship — not the literal — is
|
||
// what makes the bump safe.
|
||
assert.equal(JSON.parse(REMEDIATION.ackDocument([{ key: 'x.md', reason: 'r' }])).version, ACK_VERSION);
|
||
});
|
||
|
||
test('the remediation surface is frozen and points at the fragment directory, not the legacy file', () => {
|
||
// #2914: the remedy is a NEW fragment under ACK_DIR, never the single legacy file —
|
||
// asserting `ackFile === ACK_FILE` here would pin the exact bandaid this design
|
||
// replaces (a shared filename every PR is tempted back onto).
|
||
assert.ok(Object.isFrozen(REMEDIATION), 'the exported surface must not be mutable');
|
||
assert.equal(REMEDIATION.ackDir, ACK_DIR_PURE, 'one definition, not a second literal');
|
||
assert.ok(REMEDIATION.ackFile.startsWith(`${ACK_DIR_PURE}/`), 'the taught path must live under the fragment directory');
|
||
assert.notEqual(REMEDIATION.ackFile, ACK_FILE, 'the remedy must not be the legacy shared file');
|
||
});
|
||
|
||
test('a ripple and a growth in one report share ONE document', () => {
|
||
// The combination nobody writes down, and the most likely real shape: a feature PR that
|
||
// both grows a workflow AND ripples an emitted path.
|
||
//
|
||
// Caught in review: printing a complete document per branch made each read as "the file
|
||
// to create", so a contributor pasting the second over the first silently loses the
|
||
// first acknowledgment — an ack-lost failure with no signal. One document, one file.
|
||
const r = diffEmitted({
|
||
baseline: mf({ [WORKFLOW_KEY]: 'aaa' }),
|
||
current: mf({ [WORKFLOW_KEY]: 'bbb' }),
|
||
changedPaths: ['README.md'],
|
||
sizeBaseline: { 'explore.md': 11127 },
|
||
sizeCurrent: { 'explore.md': 13230 },
|
||
});
|
||
const report = buildReport(r);
|
||
assert.equal(blockOf(report, 'unattributable').keyRule, REMEDIATION.rippleKeyRule);
|
||
assert.equal(blockOf(report, 'unacked-growth').keyRule, REMEDIATION.growthKeyRule);
|
||
|
||
// Both key spaces, one ack set, in list order.
|
||
assert.deepEqual(report.ackable, [
|
||
{ key: WORKFLOW_KEY, reason: REMEDIATION.rippleReason },
|
||
{ key: 'explore.md', reason: REMEDIATION.growthReason },
|
||
]);
|
||
|
||
// And the rendered document is genuinely one object holding both.
|
||
const doc = JSON.parse(REMEDIATION.ackDocument(report.ackable));
|
||
assert.deepEqual(Object.keys(doc.paths).sort(), [WORKFLOW_KEY, 'explore.md'].sort());
|
||
const { errors } = parseAck(doc);
|
||
assert.deepEqual(errors, [], 'the combined document must parse');
|
||
|
||
const msg = formatReport(r);
|
||
assert.equal(
|
||
msg.split('{"version"').length - 1, 1,
|
||
'exactly one document may be printed — two would invite pasting one over the other',
|
||
);
|
||
});
|
||
|
||
test('a stale ack names the file it lives in and the delete-the-file case', () => {
|
||
// Pre-#2778 this said acks "must be deleted" without naming the file they live in. It
|
||
// also never said what to do when the last entry goes: an empty-but-present ack file
|
||
// parses fine and is "legal", but it destroys the ADR-2719 §3 property that the file's
|
||
// PRESENCE is the alarm.
|
||
const r = diffEmitted({
|
||
baseline: mf({ [WORKFLOW_KEY]: 'aaa' }),
|
||
current: mf({ [WORKFLOW_KEY]: 'aaa' }),
|
||
changedPaths: [],
|
||
ack: { version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'old' } } },
|
||
baseAck: null,
|
||
});
|
||
const stale = blockOf(buildReport(r), 'stale-acks');
|
||
assert.deepEqual(stale.items, [WORKFLOW_KEY]);
|
||
assert.equal(stale.fix, REMEDIATION.staleAckFix);
|
||
assert.match(stale.fix, /delete the file/, 'the last-entry case must be covered');
|
||
|
||
// A stale-only report has nothing to acknowledge — it must NOT offer a document.
|
||
assert.deepEqual(buildReport(r).ackable, [], 'deleting an ack is not acknowledging one');
|
||
});
|
||
|
||
test('growth and a stale ack in one report keep both remedies', () => {
|
||
// The contributor is adding one entry and removing another in the same file.
|
||
const r = growthOnly({ ack: { version: ACK_VERSION, paths: { 'gone.md': { reason: 'outlived' } } } });
|
||
assert.deepEqual(r.staleAcks, ['gone.md']);
|
||
assert.equal(r.grown[0].acked, false);
|
||
|
||
const report = buildReport(r);
|
||
assert.ok(blockOf(report, 'unacked-growth'), 'the growth still needs an ack');
|
||
assert.ok(blockOf(report, 'stale-acks'), 'the stale entry still needs deleting');
|
||
assert.deepEqual(report.ackable, [{ key: 'explore.md', reason: REMEDIATION.growthReason }],
|
||
'only the growth is ackable; the stale entry is removed, not added');
|
||
});
|
||
|
||
test('the validation early-return renders instead of throwing', () => {
|
||
// Found while building #2778. diffEmitted's input-validation early return omitted
|
||
// `newFileCapExceeded`, and formatReport reads `result.newFileCapExceeded.length`
|
||
// unconditionally — so this path threw `TypeError: Cannot read properties of
|
||
// undefined` instead of printing its errors.
|
||
//
|
||
// Worst possible place for it: this branch is what runs when `git diff` failed or a
|
||
// manifest came back malformed. The crash replaced the only message that would have
|
||
// named the infrastructure problem, and a TypeError in a test helper reads like a
|
||
// broken test rather than a broken environment.
|
||
for (const bad of [
|
||
{ baseline: null, current: {}, changedPaths: [] },
|
||
{ baseline: {}, current: null, changedPaths: [] },
|
||
{ baseline: {}, current: {}, changedPaths: null },
|
||
{ baseline: [], current: {}, changedPaths: [] },
|
||
]) {
|
||
const r = diffEmitted(bad);
|
||
assert.ok(!r.ok);
|
||
assert.ok(r.errors.length > 0);
|
||
assert.deepEqual(r.newFileCapExceeded, [], 'every returned shape must carry every bucket');
|
||
|
||
const report = buildReport(r);
|
||
assert.equal(blockOf(report, 'errors').count, r.errors.length, 'the errors must render');
|
||
assert.deepEqual(report.ackable, [], 'a malformed input is not something to acknowledge');
|
||
}
|
||
});
|
||
|
||
test('a failed git diff renders as an error, never as "nothing changed"', () => {
|
||
// The comment on that validation branch says a failed `git diff` must never be read as
|
||
// an empty change set. That contract is only worth anything if the resulting report is
|
||
// renderable — which it was not until the bucket above was restored.
|
||
const r = diffEmitted({ baseline: mf({}), current: mf({}), changedPaths: null });
|
||
assert.match(r.errors.join('\n'), /changedPaths must be an array/);
|
||
assert.match(formatReport(r), /changedPaths must be an array/);
|
||
});
|
||
|
||
test('a passing result produces no blocks and nothing to acknowledge', () => {
|
||
// Remediation must never leak into a green run — it is failure text, not advice.
|
||
const report = buildReport(diffEmitted({ baseline: mf({}), current: mf({}), changedPaths: [] }));
|
||
assert.deepEqual(report.blocks, []);
|
||
assert.deepEqual(report.ackable, []);
|
||
assert.equal(formatReport(diffEmitted({ baseline: mf({}), current: mf({}), changedPaths: [] })), '');
|
||
});
|
||
|
||
test('an acked growth produces no block and nothing to acknowledge', () => {
|
||
// The contributor already did the thing the remediation asks for; repeating it is noise.
|
||
const r = growthOnly({
|
||
ack: { version: ACK_VERSION, paths: { 'explore.md': { reason: 'new mode section' } } },
|
||
baseAck: null,
|
||
});
|
||
assert.ok(r.ok);
|
||
const report = buildReport(r);
|
||
assert.equal(blockOf(report, 'unacked-growth'), undefined, 'an acknowledged growth is not a failure');
|
||
assert.deepEqual(report.ackable, []);
|
||
});
|
||
|
||
test('shrinkage produces no block and nothing to acknowledge', () => {
|
||
const r = diffEmitted({
|
||
baseline: mf({}), current: mf({}), changedPaths: [],
|
||
sizeBaseline: { 'a.md': 9000 }, sizeCurrent: { 'a.md': 8000 },
|
||
});
|
||
assert.deepEqual(buildReport(r).blocks, [], 'shrinkage is reported in the result, never as failure');
|
||
assert.deepEqual(buildReport(r).ackable, []);
|
||
});
|
||
|
||
test('a mixed grown set offers an ack entry only for the unacked files', () => {
|
||
const r = diffEmitted({
|
||
baseline: mf({}), current: mf({}), changedPaths: [],
|
||
sizeBaseline: { 'kept.md': 100, 'loud.md': 100 },
|
||
sizeCurrent: { 'kept.md': 200, 'loud.md': 200 },
|
||
ack: { version: ACK_VERSION, paths: { 'kept.md': { reason: 'declared' } } },
|
||
baseAck: null,
|
||
});
|
||
const report = buildReport(r);
|
||
const growth = blockOf(report, 'unacked-growth');
|
||
assert.equal(growth.count, 1, 'only the unacked one is counted');
|
||
assert.deepEqual(growth.items.map((g) => g.name), ['loud.md']);
|
||
assert.deepEqual(report.ackable, [{ key: 'loud.md', reason: REMEDIATION.growthReason }],
|
||
'the document must key on the unacked file, not the acked one');
|
||
});
|
||
|
||
test('the ack set is capped at the sample limit at limit-1 / limit / limit+1', () => {
|
||
// CLAUDE.md's boundary rule. The document must not name rows the report chose not to
|
||
// print — a contributor cannot acknowledge a path they were never shown.
|
||
const build = (n) => {
|
||
const sizeBaseline = {}; const sizeCurrent = {};
|
||
for (let i = 0; i < n; i++) {
|
||
const k = `g${String(i).padStart(3, '0')}.md`;
|
||
sizeBaseline[k] = 100; sizeCurrent[k] = 200;
|
||
}
|
||
return diffEmitted({ baseline: mf({}), current: mf({}), changedPaths: [], sizeBaseline, sizeCurrent });
|
||
};
|
||
for (const [n, expected] of [[19, 19], [20, 20], [21, 20]]) {
|
||
const report = buildReport(build(n), { sampleLimit: 20 });
|
||
assert.equal(blockOf(report, 'unacked-growth').count, n, `count reports all ${n}`);
|
||
assert.equal(report.ackable.length, expected, `the document names ${expected} at n=${n}`);
|
||
assert.ok(
|
||
formatReport(build(n), { sampleLimit: 20 }).includes(REMEDIATION.growthKeyRule),
|
||
`the key rule must survive n=${n}`,
|
||
);
|
||
}
|
||
});
|
||
|
||
test('the new-file cap block carries no ack affordance', () => {
|
||
// The cap is NOT ack-able — the fix is extraction. Offering a document here would teach
|
||
// a contributor to write an entry that cannot clear the gate, which is worse than the
|
||
// silence it replaced.
|
||
const r = diffEmitted({
|
||
baseline: mf({}), current: mf({}), changedPaths: [],
|
||
sizeBaseline: {}, sizeCurrent: { 'new-workflow.md': NEW_FILE_CAP + 1 },
|
||
});
|
||
const report = buildReport(r);
|
||
const cap = blockOf(report, 'new-file-cap');
|
||
assert.equal(cap.count, 1);
|
||
assert.equal(cap.keyRule, undefined, 'the cap has no key rule because it has no ack');
|
||
assert.deepEqual(report.ackable, [], 'the cap must never offer an acknowledgment');
|
||
assert.ok(!formatReport(r).includes(REMEDIATION.ackFile), 'and must not point at the ack file');
|
||
});
|
||
|
||
// ─── New-file cap (ADR-1610 Decision point 3, revived after #2724) ───────────
|
||
//
|
||
// tests/workflow-size-baseline.json used to double as the "has this file been
|
||
// 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 },
|
||
ack: { version: ACK_VERSION, paths: { 'new-workflow.md': { reason: 'trying to bypass it' } } },
|
||
baseAck: null,
|
||
});
|
||
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');
|
||
},
|
||
);
|
||
|
||
test('readAckFile: absent is legal, malformed and unreadable are not', () => {
|
||
const tmp = fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'gsd-ack-'));
|
||
try {
|
||
const ackPath = path.join(tmp, 'emitted-drift-ack.json');
|
||
|
||
// Absent == no acks. The healthy steady state.
|
||
assert.equal(readAckFile(ackPath), null);
|
||
|
||
// Present and valid.
|
||
fs.writeFileSync(ackPath, JSON.stringify({ version: ACK_VERSION, paths: {} }));
|
||
assert.deepEqual(readAckFile(ackPath), { version: ACK_VERSION, paths: {} });
|
||
|
||
// Present but empty — must NOT be read as absent.
|
||
fs.writeFileSync(ackPath, '');
|
||
assert.throws(() => readAckFile(ackPath), /present but empty/);
|
||
|
||
// Present but not JSON.
|
||
fs.writeFileSync(ackPath, '{not json');
|
||
assert.throws(() => readAckFile(ackPath), /not valid JSON/);
|
||
|
||
// Unreadable: monkeypatch the fs method, restore in `finally`. NEVER chmod 0o000 —
|
||
// root bypasses mode bits, so the test would silently pass with zero coverage in
|
||
// root Docker/CI. This exercises the SUT (readAckFile), not fs itself.
|
||
fs.writeFileSync(ackPath, JSON.stringify({ version: ACK_VERSION, paths: {} }));
|
||
const orig = fs.readFileSync;
|
||
try {
|
||
fs.readFileSync = () => { throw new Error('injected ack read failure'); };
|
||
assert.throws(() => readAckFile(ackPath), /injected ack read failure/);
|
||
} finally {
|
||
fs.readFileSync = orig;
|
||
}
|
||
// Restoration is real, not assumed.
|
||
assert.deepEqual(readAckFile(ackPath), { version: ACK_VERSION, paths: {} });
|
||
} finally {
|
||
cleanup(tmp);
|
||
}
|
||
});
|
||
|
||
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]),
|
||
ack: { version: ACK_VERSION, paths: ackPaths },
|
||
baseAck: null,
|
||
});
|
||
|
||
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);
|
||
// #2914: unions the legacy single file with every per-PR fragment under
|
||
// tests/emitted-drift-acks/. `mergeAckErrors` (e.g. two sources naming the same path)
|
||
// is folded into `diffEmitted`'s own errors below, exactly like any other ack schema
|
||
// problem — never silently resolved.
|
||
const { doc: ack, errors: mergeAckErrors } = readAckSources();
|
||
const current = currentManifests();
|
||
|
||
// Consult the base side ONLY when this tree actually has a document to classify.
|
||
// `readAckSourcesAtRef` throws on a base it cannot read, which is right — but reading
|
||
// it unconditionally would DEADLOCK the repo if `next` ever carried a corrupt ack (a
|
||
// bad merge leaving conflict markers in exactly the file class this epic exists over):
|
||
// every PR would go red, INCLUDING the PR that deletes the corrupt file and repairs
|
||
// base. A tree carrying no ack has nothing to inherit, so it needs no base read — which
|
||
// is precisely the shape of the repair PR, and it lands and unblocks everyone.
|
||
const baseAck = ack === null ? null : readAckSourcesAtRef(baseSha).doc;
|
||
|
||
// 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,
|
||
ack,
|
||
// The base side of the ack lifecycle (#2789). Without it a MERGED ack is
|
||
// indistinguishable from one that never explained anything, which is what reddened
|
||
// `next` for five commits and every PR branching off it (#2768). Entries already
|
||
// present here are spent: inert, never stale, and unable to pre-clear a new ripple.
|
||
//
|
||
// Keyed on `baseSha`, not `base`: the baseline half is already validated against that
|
||
// exact sha, so both halves of the base side provably describe the SAME commit, and a
|
||
// ref that moved between `resolveBase()` and here cannot split them.
|
||
baseAck,
|
||
sizeBaseline: resolvedBaseline.sizeBaseline,
|
||
sizeCurrent: currentSizes(),
|
||
mergeAckErrors,
|
||
});
|
||
|
||
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/);
|
||
});
|
||
});
|
||
|
||
// ─── E. #3842: stage the sweep — hold a fragment an open PR still touches ───
|
||
//
|
||
// The sweep landed by #3078 deletes every all-spent fragment unconditionally. When an
|
||
// OPEN pull request still modifies that same file, deleting it hands that PR a
|
||
// `modify/delete` conflict on its very next merge attempt — the exact shared-file
|
||
// conflict fragments were adopted (#2914) to end, reintroduced by the sweep itself.
|
||
// #3330, #3774, and #3648 all conflicted the first time the sweep ran, each with the
|
||
// swept fragment as its ONLY conflicting path. `assertNoAllSpentFragments` now takes an
|
||
// optional `openPrTouchedPaths` to defer sweeping those; `fetchOpenPrTouchedAckPaths`
|
||
// computes that set with one `gh pr list` call; `runGuardNext` wires the two together
|
||
// behind an opt-in `--defer-to-open-prs` flag so every pre-#3842 caller (including every
|
||
// test above, and section C's real-git-repo tests) is completely unaffected.
|
||
|
||
describe('#3842: assertNoAllSpentFragments defers to open PRs', () => {
|
||
test('omitting openPrTouchedPaths entirely is byte-identical to pre-#3842 behavior', () => {
|
||
const spent = doc({ a: { reason: 'a' } });
|
||
const withoutOpt = assertNoAllSpentFragments([frag('a.json', spent, spent)]);
|
||
const withEmptyOpt = assertNoAllSpentFragments([frag('a.json', spent, spent)], {});
|
||
assert.deepEqual(withoutOpt, withEmptyOpt);
|
||
assert.ok(!withoutOpt.ok);
|
||
assert.deepEqual(withoutOpt.sweepable, ['a.json']);
|
||
});
|
||
|
||
test('an all-spent fragment named by an open PR is held, not reported as sweepable', () => {
|
||
const spent = doc({ a: { reason: 'a' } });
|
||
const r = assertNoAllSpentFragments(
|
||
[frag('a.json', spent, spent)],
|
||
{ openPrTouchedPaths: new Set([`${ACK_DIR_REPO_PATH}/a.json`]) },
|
||
);
|
||
assert.ok(r.ok, 'nothing left safe to sweep, so the guard must pass');
|
||
assert.deepEqual(r.sweepable, []);
|
||
assert.match(r.message, /deferred:.*held back because an open pull request/s);
|
||
assert.match(r.message, new RegExp(`${ACK_DIR_REPO_PATH}/a\\.json.*held`));
|
||
assert.doesNotMatch(r.message, /git rm/, 'a held fragment must never carry a git rm remedy');
|
||
});
|
||
|
||
test('a mix of held and safe-to-sweep fragments fails only on the safe one', () => {
|
||
const spentHeld = doc({ a: { reason: 'a' } });
|
||
const spentSafe = doc({ b: { reason: 'b' } });
|
||
const r = assertNoAllSpentFragments(
|
||
[frag('held.json', spentHeld, spentHeld), frag('safe.json', spentSafe, spentSafe)],
|
||
{ openPrTouchedPaths: new Set([`${ACK_DIR_REPO_PATH}/held.json`]) },
|
||
);
|
||
assert.ok(!r.ok, 'one fragment is still safe to sweep, so the guard must still fail');
|
||
assert.deepEqual(r.sweepable, ['safe.json']);
|
||
assert.match(r.message, /safe\.json/);
|
||
assert.match(r.message, /git rm[^\n]*safe\.json/);
|
||
assert.match(r.message, /held\.json.*held/s);
|
||
assert.doesNotMatch(r.message, /git rm[^\n]*held\.json/);
|
||
});
|
||
|
||
test('the "unknown" sentinel holds every otherwise-sweepable fragment (a failed open-PR lookup must never sweep blind)', () => {
|
||
const spentA = doc({ a: { reason: 'a' } });
|
||
const spentB = doc({ b: { reason: 'b' } });
|
||
const r = assertNoAllSpentFragments(
|
||
[frag('a.json', spentA, spentA), frag('b.json', spentB, spentB)],
|
||
{ openPrTouchedPaths: 'unknown' },
|
||
);
|
||
assert.ok(r.ok);
|
||
assert.deepEqual(r.sweepable, []);
|
||
assert.match(r.message, /open-PR check unavailable/);
|
||
assert.match(r.message, /a\.json/);
|
||
assert.match(r.message, /b\.json/);
|
||
});
|
||
|
||
test('a PARTIALLY spent fragment is left alone regardless of open-PR touch — the open-PR set only ever narrows an already-sweepable list', () => {
|
||
const live = doc({ c: { reason: 'c-new' } });
|
||
const liveBase = doc({ c: { reason: 'c-old' } });
|
||
const r = assertNoAllSpentFragments(
|
||
[frag('live.json', live, liveBase)],
|
||
{ openPrTouchedPaths: new Set([`${ACK_DIR_REPO_PATH}/live.json`]) },
|
||
);
|
||
assert.ok(r.ok);
|
||
assert.deepEqual(r.sweepable, []);
|
||
assert.doesNotMatch(r.message, /live\.json/, 'a partially-spent fragment is never mentioned by either rule');
|
||
});
|
||
});
|
||
|
||
describe('#3842: fetchOpenPrTouchedAckPaths', () => {
|
||
const prsWithFile = (relPath) => JSON.stringify([{ number: 1, files: [{ path: relPath }] }]);
|
||
|
||
test('returns only paths under the fragment directory, ignoring unrelated changed files', () => {
|
||
const stdout = JSON.stringify([
|
||
{ number: 1, files: [{ path: `${ACK_DIR_REPO_PATH}/a.json` }, { path: 'README.md' }] },
|
||
{ number: 2, files: [{ path: 'src/foo.cts' }] },
|
||
]);
|
||
const paths = fetchOpenPrTouchedAckPaths({ execGh: () => stdout });
|
||
assert.deepEqual([...paths], [`${ACK_DIR_REPO_PATH}/a.json`]);
|
||
});
|
||
|
||
test('accepts a bare-string file entry, not only { path }-shaped ones', () => {
|
||
const stdout = JSON.stringify([{ number: 1, files: [`${ACK_DIR_REPO_PATH}/a.json`] }]);
|
||
const paths = fetchOpenPrTouchedAckPaths({ execGh: () => stdout });
|
||
assert.deepEqual([...paths], [`${ACK_DIR_REPO_PATH}/a.json`]);
|
||
});
|
||
|
||
test('a PR with no files array, or an empty one, contributes nothing and does not throw', () => {
|
||
const stdout = JSON.stringify([{ number: 1 }, { number: 2, files: [] }]);
|
||
const paths = fetchOpenPrTouchedAckPaths({ execGh: () => stdout });
|
||
assert.deepEqual([...paths], []);
|
||
});
|
||
|
||
test('zero open PRs is an empty set, not an error', () => {
|
||
const paths = fetchOpenPrTouchedAckPaths({ execGh: () => '[]' });
|
||
assert.deepEqual([...paths], []);
|
||
});
|
||
|
||
test('invokes gh with the expected argv: pr list, open state, number+files json, and a --limit', () => {
|
||
let capturedArgs;
|
||
fetchOpenPrTouchedAckPaths({
|
||
execGh: (args) => { capturedArgs = args; return '[]'; },
|
||
limit: 42,
|
||
});
|
||
assert.deepEqual(capturedArgs, ['pr', 'list', '--state', 'open', '--json', 'number,files', '--limit', '42']);
|
||
});
|
||
|
||
test('malformed JSON from gh throws, rather than degrading to an empty (falsely "nothing touched") set', () => {
|
||
assert.throws(
|
||
() => fetchOpenPrTouchedAckPaths({ execGh: () => '{ not json' }),
|
||
/did not return valid JSON/,
|
||
);
|
||
});
|
||
|
||
test('a non-array JSON value from gh throws', () => {
|
||
assert.throws(
|
||
() => fetchOpenPrTouchedAckPaths({ execGh: () => '{"unexpected":"shape"}' }),
|
||
/expected a JSON array/,
|
||
);
|
||
});
|
||
|
||
test('a real execGh failure (gh missing, unauthenticated, rate-limited) propagates rather than being swallowed here', () => {
|
||
assert.throws(
|
||
() => fetchOpenPrTouchedAckPaths({
|
||
execGh: () => { throw new Error('gh: command not found'); },
|
||
}),
|
||
/command not found/,
|
||
);
|
||
});
|
||
|
||
// Boundary coverage at the MAX_OPEN_PRS cap: limit-1, limit, limit+1.
|
||
test(`boundary: ${MAX_OPEN_PRS - 1} open PRs (limit-1) is accepted without truncation risk`, () => {
|
||
const n = MAX_OPEN_PRS - 1;
|
||
const stdout = JSON.stringify(Array.from({ length: n }, (_, i) => ({ number: i, files: [] })));
|
||
assert.doesNotThrow(() => fetchOpenPrTouchedAckPaths({ execGh: () => stdout }));
|
||
});
|
||
|
||
test(`boundary: exactly ${MAX_OPEN_PRS} open PRs (limit) throws — a list this long might be truncated by --limit itself`, () => {
|
||
const n = MAX_OPEN_PRS;
|
||
const stdout = JSON.stringify(Array.from({ length: n }, (_, i) => ({ number: i, files: [] })));
|
||
assert.throws(
|
||
() => fetchOpenPrTouchedAckPaths({ execGh: () => stdout }),
|
||
/at or above the cap/,
|
||
);
|
||
});
|
||
|
||
test(`boundary: ${MAX_OPEN_PRS + 1} open PRs (limit+1) also throws`, () => {
|
||
const n = MAX_OPEN_PRS + 1;
|
||
const stdout = JSON.stringify(Array.from({ length: n }, (_, i) => ({ number: i, files: [] })));
|
||
assert.throws(
|
||
() => fetchOpenPrTouchedAckPaths({ execGh: () => stdout }),
|
||
/at or above the cap/,
|
||
);
|
||
});
|
||
|
||
test('sanity: a stub returning a single PR that touches one fragment is detected (guards against a vacuously-passing stub)', () => {
|
||
const paths = fetchOpenPrTouchedAckPaths({ execGh: () => prsWithFile(`${ACK_DIR_REPO_PATH}/x.json`) });
|
||
assert.equal(paths.has(`${ACK_DIR_REPO_PATH}/x.json`), true);
|
||
assert.equal(paths.size, 1);
|
||
});
|
||
|
||
// `gh pr list --json files` truncates each PR's own file list at MAX_PR_FILES, silently
|
||
// (#3842, PR #3848: 124 files changed, 100 returned, none of the two ack paths among
|
||
// them because the list stops mid `gsd-core/workflows/`, which sorts before `tests/`).
|
||
// A PR AT the cap is answered with one additional paginated `gh api` call; below the
|
||
// cap, the single `gh pr list` call is trusted as-is.
|
||
const filler = (n) => Array.from(
|
||
{ length: n },
|
||
(_, i) => ({ path: `gsd-core/workflows/w${String(i).padStart(4, '0')}.md` }),
|
||
);
|
||
|
||
test('a sub-cap PR is answered by the single list call', () => {
|
||
const files = filler(MAX_PR_FILES - 2).concat([{ path: `${ACK_DIR_REPO_PATH}/a.json` }]);
|
||
const stdout = JSON.stringify([{ number: 1, files }]);
|
||
let calls = 0;
|
||
const paths = fetchOpenPrTouchedAckPaths({ execGh: () => { calls += 1; return stdout; } });
|
||
assert.equal(calls, 1);
|
||
assert.equal(paths.has(`${ACK_DIR_REPO_PATH}/a.json`), true);
|
||
});
|
||
|
||
test('a PR at the file cap is re-fetched, because full and truncated look identical', () => {
|
||
const files = filler(MAX_PR_FILES);
|
||
const stdout = JSON.stringify([{ number: 77, files }]);
|
||
const paths = fetchOpenPrTouchedAckPaths({
|
||
execGh: (args) => (args[0] === 'pr' ? stdout : `${ACK_DIR_REPO_PATH}/a.json\n`),
|
||
});
|
||
assert.equal(paths.has(`${ACK_DIR_REPO_PATH}/a.json`), true);
|
||
});
|
||
|
||
test('a PR past the file cap has its ack path recovered by the re-fetch', () => {
|
||
const files = filler(MAX_PR_FILES + 1);
|
||
const stdout = JSON.stringify([{ number: 77, files }]);
|
||
const paths = fetchOpenPrTouchedAckPaths({
|
||
execGh: (args) => (args[0] === 'pr' ? stdout : `${ACK_DIR_REPO_PATH}/a.json\n`),
|
||
});
|
||
assert.equal(paths.has(`${ACK_DIR_REPO_PATH}/a.json`), true);
|
||
});
|
||
|
||
test('the re-fetch asks the paginated REST endpoint for that PR', () => {
|
||
const files = filler(MAX_PR_FILES);
|
||
const stdout = JSON.stringify([{ number: 77, files }]);
|
||
let capturedArgs;
|
||
fetchOpenPrTouchedAckPaths({
|
||
execGh: (args) => {
|
||
if (args[0] === 'pr') return stdout;
|
||
capturedArgs = args;
|
||
return '';
|
||
},
|
||
});
|
||
assert.ok(capturedArgs.includes('api'));
|
||
assert.ok(capturedArgs.includes('repos/{owner}/{repo}/pulls/77/files'));
|
||
assert.ok(capturedArgs.includes('--paginate'));
|
||
});
|
||
|
||
test('a failed re-fetch throws rather than trusting the truncated list', () => {
|
||
const files = filler(MAX_PR_FILES);
|
||
const stdout = JSON.stringify([{ number: 77, files }]);
|
||
assert.throws(() => fetchOpenPrTouchedAckPaths({
|
||
execGh: (args) => {
|
||
if (args[0] === 'pr') return stdout;
|
||
throw new Error('gh: rate limited');
|
||
},
|
||
}));
|
||
});
|
||
|
||
test("a PR beyond GitHub's own file ceiling throws rather than returning a partial set", () => {
|
||
const files = filler(MAX_PR_FILES);
|
||
const stdout = JSON.stringify([{ number: 77, files }]);
|
||
const apiOut = Array.from(
|
||
{ length: GITHUB_MAX_PR_FILES },
|
||
(_, i) => `gsd-core/workflows/w${i}.md`,
|
||
).join('\n');
|
||
assert.throws(() => fetchOpenPrTouchedAckPaths({
|
||
execGh: (args) => (args[0] === 'pr' ? stdout : apiOut),
|
||
}));
|
||
});
|
||
|
||
// Boundary coverage at GitHub's own file ceiling: limit-1, limit, limit+1.
|
||
// (limit is the test immediately above.)
|
||
test("boundary: a re-fetch just under GitHub's file ceiling is accepted", () => {
|
||
const files = filler(MAX_PR_FILES);
|
||
const stdout = JSON.stringify([{ number: 77, files }]);
|
||
const paths = Array.from(
|
||
{ length: GITHUB_MAX_PR_FILES - 1 },
|
||
(_, i) => `gsd-core/workflows/w${i}.md`,
|
||
);
|
||
paths[0] = `${ACK_DIR_REPO_PATH}/a.json`;
|
||
const apiOut = paths.join('\n');
|
||
let result;
|
||
assert.doesNotThrow(() => {
|
||
result = fetchOpenPrTouchedAckPaths({
|
||
execGh: (args) => (args[0] === 'pr' ? stdout : apiOut),
|
||
});
|
||
});
|
||
assert.equal(result.has(`${ACK_DIR_REPO_PATH}/a.json`), true);
|
||
});
|
||
|
||
test('boundary: a re-fetch one past GitHub\'s file ceiling also throws', () => {
|
||
const files = filler(MAX_PR_FILES);
|
||
const stdout = JSON.stringify([{ number: 77, files }]);
|
||
const apiOut = Array.from(
|
||
{ length: GITHUB_MAX_PR_FILES + 1 },
|
||
(_, i) => `gsd-core/workflows/w${i}.md`,
|
||
).join('\n');
|
||
assert.throws(() => fetchOpenPrTouchedAckPaths({
|
||
execGh: (args) => (args[0] === 'pr' ? stdout : apiOut),
|
||
}));
|
||
});
|
||
|
||
test('a re-fetched PR that touches no fragment contributes nothing', () => {
|
||
const files = filler(MAX_PR_FILES);
|
||
const stdout = JSON.stringify([{ number: 77, files }]);
|
||
const apiOut = filler(MAX_PR_FILES + 5).map((f) => f.path).join('\n');
|
||
const paths = fetchOpenPrTouchedAckPaths({
|
||
execGh: (args) => (args[0] === 'pr' ? stdout : apiOut),
|
||
});
|
||
assert.equal(paths.size, 0);
|
||
});
|
||
|
||
test('each capped PR is re-fetched independently', () => {
|
||
const cappedFiles = filler(MAX_PR_FILES);
|
||
const subCapFiles = filler(MAX_PR_FILES - 1);
|
||
const stdout = JSON.stringify([
|
||
{ number: 1, files: cappedFiles },
|
||
{ number: 2, files: cappedFiles },
|
||
{ number: 3, files: subCapFiles },
|
||
]);
|
||
let apiCalls = 0;
|
||
const paths = fetchOpenPrTouchedAckPaths({
|
||
execGh: (args) => {
|
||
if (args[0] === 'pr') return stdout;
|
||
apiCalls += 1;
|
||
const prNumber = args[1].match(/pulls\/(\d+)\/files/)[1];
|
||
return prNumber === '2' ? `${ACK_DIR_REPO_PATH}/a.json\n` : 'gsd-core/workflows/w0000.md\n';
|
||
},
|
||
});
|
||
assert.equal(apiCalls, 2);
|
||
assert.deepEqual([...paths], [`${ACK_DIR_REPO_PATH}/a.json`]);
|
||
});
|
||
});
|
||
|
||
describe('#3842: runGuardNext wires --defer-to-open-prs end to end against a real repo', () => {
|
||
test('without --defer-to-open-prs, the open-PR fetcher is never called and behavior is unchanged', () => {
|
||
const repo = makeGuardNextRepo();
|
||
try {
|
||
repo.writeFrag('a.json', { version: ACK_VERSION, paths: { 'x.md': { reason: 'why' } } });
|
||
const c2 = repo.commit('add fragment');
|
||
fs.writeFileSync(path.join(repo.dir, 'README.md'), 'unrelated\n');
|
||
repo.commit('unrelated change');
|
||
|
||
let calls = 0;
|
||
const result = runGuardNext({
|
||
argv: ['node', 'script', '--guard-next', '--base-ref', c2],
|
||
cwd: repo.dir,
|
||
fetchOpenPrPaths: () => { calls += 1; return new Set(); },
|
||
});
|
||
assert.equal(calls, 0, 'the fetcher must not be invoked without the opt-in flag');
|
||
assert.ok(!result.ok, 'the fully-spent fragment must still fail the guard');
|
||
assert.ok(result.lines.some((l) => l.includes('a.json')));
|
||
} finally {
|
||
cleanup(repo.dir);
|
||
}
|
||
});
|
||
|
||
test('with --defer-to-open-prs, a fragment an "open PR" touches is held instead of failing the guard', () => {
|
||
const repo = makeGuardNextRepo();
|
||
try {
|
||
repo.writeFrag('a.json', { version: ACK_VERSION, paths: { 'x.md': { reason: 'why' } } });
|
||
const c2 = repo.commit('add fragment');
|
||
fs.writeFileSync(path.join(repo.dir, 'README.md'), 'unrelated\n');
|
||
repo.commit('unrelated change');
|
||
|
||
const result = runGuardNext({
|
||
argv: ['node', 'script', '--guard-next', '--base-ref', c2, '--defer-to-open-prs'],
|
||
cwd: repo.dir,
|
||
fetchOpenPrPaths: () => new Set([`${ACK_DIR_REPO_PATH}/a.json`]),
|
||
});
|
||
assert.ok(result.ok, 'the only sweepable fragment is held, so the guard must pass');
|
||
assert.ok(result.lines.some((l) => l.includes('a.json') && l.includes('held')));
|
||
} finally {
|
||
cleanup(repo.dir);
|
||
}
|
||
});
|
||
|
||
test('with --defer-to-open-prs, a failing fetcher holds everything rather than sweeping blind, and still reports why', () => {
|
||
const repo = makeGuardNextRepo();
|
||
try {
|
||
repo.writeFrag('a.json', { version: ACK_VERSION, paths: { 'x.md': { reason: 'why' } } });
|
||
const c2 = repo.commit('add fragment');
|
||
fs.writeFileSync(path.join(repo.dir, 'README.md'), 'unrelated\n');
|
||
repo.commit('unrelated change');
|
||
|
||
const result = runGuardNext({
|
||
argv: ['node', 'script', '--guard-next', '--base-ref', c2, '--defer-to-open-prs'],
|
||
cwd: repo.dir,
|
||
fetchOpenPrPaths: () => { throw new Error('gh: rate limited'); },
|
||
});
|
||
assert.ok(result.ok, 'an unverifiable open-PR set must hold rather than sweep');
|
||
assert.ok(result.lines.some((l) => l.includes('gh: rate limited')));
|
||
assert.ok(result.lines.some((l) => l.includes('open-PR check unavailable')));
|
||
} finally {
|
||
cleanup(repo.dir);
|
||
}
|
||
});
|
||
|
||
test('a re-fetch failure degrades to the unknown sentinel, not to an empty set', () => {
|
||
const repo = makeGuardNextRepo();
|
||
try {
|
||
repo.writeFrag('a.json', { version: ACK_VERSION, paths: { 'x.md': { reason: 'why' } } });
|
||
const c2 = repo.commit('add fragment');
|
||
fs.writeFileSync(path.join(repo.dir, 'README.md'), 'unrelated\n');
|
||
repo.commit('unrelated change');
|
||
|
||
const result = runGuardNext({
|
||
argv: ['node', 'script', '--guard-next', '--base-ref', c2, '--defer-to-open-prs'],
|
||
cwd: repo.dir,
|
||
fetchOpenPrPaths: () => { throw new Error('fetchPrFiles: PR #77 re-fetch failed'); },
|
||
});
|
||
assert.ok(result.ok, 'an unverifiable open-PR set must hold rather than sweep');
|
||
assert.ok(result.lines.some((l) => l.includes('a.json') && l.includes('held')));
|
||
assert.ok(result.lines.some((l) => l.includes('open-PR check unavailable')));
|
||
} finally {
|
||
cleanup(repo.dir);
|
||
}
|
||
});
|
||
|
||
test('with --defer-to-open-prs, a fragment untouched by any open PR still sweeps (the flag only narrows, never widens, the safe set)', () => {
|
||
const repo = makeGuardNextRepo();
|
||
try {
|
||
repo.writeFrag('a.json', { version: ACK_VERSION, paths: { 'x.md': { reason: 'why' } } });
|
||
const c2 = repo.commit('add fragment');
|
||
fs.writeFileSync(path.join(repo.dir, 'README.md'), 'unrelated\n');
|
||
repo.commit('unrelated change');
|
||
|
||
const result = runGuardNext({
|
||
argv: ['node', 'script', '--guard-next', '--base-ref', c2, '--defer-to-open-prs'],
|
||
cwd: repo.dir,
|
||
fetchOpenPrPaths: () => new Set(['tests/emitted-drift-acks/unrelated-other.json']),
|
||
});
|
||
assert.ok(!result.ok, 'a.json is untouched by any open PR, so it must still be reported as sweepable');
|
||
assert.ok(result.lines.some((l) => l.includes('git rm') && l.includes('a.json')));
|
||
} finally {
|
||
cleanup(repo.dir);
|
||
}
|
||
});
|
||
});
|