* test(#1882): stage one file per commit in the base-ref ancestry fixture CI failed on ubuntu-24 inside this test's setup loop, before any code under test ran: at commit 32 of 60 the index referenced a blob whose object write had not landed -- "invalid object ... for 'base-31.txt' / Error building trees". The loop staged with `git add .`, which re-stages every file already in the tree. Across 60 iterations that rehashes O(n squared) blobs -- roughly 1,800 stagings and 60 full index rewrites to add 60 one-line files -- and that churn is what the object store failed under. Each commit only ever adds a single new file, so staging that one path is equivalent and removes the redundant work entirely. Verified the loop still builds the intended history: 61 commits, git fsck clean. The fixture already carries a note from an earlier fix in this epic recording that it passed on ubuntu-22 and windows-24 and failed on ubuntu-24 for the same commit. That was a different stage -- fetch versus diff -- but the same lane and the same brittleness, so this is the second time this fixture's cost has surfaced as a red build rather than as a test failure. Not caused by this PR's change, which touches two configuration lists and cannot reach a scratch git repository in tmpdir. Fixed here rather than deferred, because the run surfaced it. Refs #1879 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(#1881): prove an unreadable ROADMAP is indistinguishable from an absent one Failing-first. Encodes the issue's runtime repro: an unreadable ROADMAP.md makes getRoadmapPhaseInternal return the same null it returns for "phase not found", and getMilestoneInfo return the same {v1.0, milestone} it returns for a project with no roadmap at all -- so a permission or I/O fault reads as a brand-new project. Half these cases exist to hold the opposite line. getMilestoneInfo has no existsSync guard, so platformReadSync's null-for-ENOENT is converted to a synthetic Error carrying no errno, and that lands in the SAME catch as a real EACCES. Reporting unconditionally there would flag every project without a ROADMAP.md -- every brand-new project -- as corrupt. The absent case, the errno-less error, a non-string errno, unparseable content and a genuinely missing phase are all pinned silent. One case guards a decision rather than behaviour: an unreadable STATE.md alone must stay silent, because the inner catch that swallows it is deliberate and documented under the #2245 audit as an optional enhancement falling back to ROADMAP-only heuristics. Two more pin the invariant ADR-1411 names explicitly -- neither function may throw, because src/state.cts removed its own defensive try/catch on the strength of that guarantee. Assertions are on the frozen reason enum and the emission counter, never on diagnostic prose. Faults are injected by overriding the platformReadSync seam and restoring in t.after(), never chmod 0o000, which root bypasses. Adds the ROADMAP_UNREADABLE reason to the shared vocabulary as scaffolding; no call site emits it yet, which is what makes these tests red. Refs #1879 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#1881): report an unreadable ROADMAP instead of reading it as absent getRoadmapPhaseInternal returned null for a read failure exactly as it does for "phase not found", and getMilestoneInfo returned {v1.0, milestone} exactly as it does for a project with no roadmap -- so a permission or I/O fault presented as a brand-new project and workflows synthesised a blank phase or skipped requirement extraction with no signal. Both return values are preserved exactly, per ADR-1411's amendment: continuity is correct, the silence was the defect. Each catch now reports through the shared unusable-input seam that shipped with #1882 rather than a second copy of the same mechanism. The discriminator is the errno, and it is load-bearing in the silent direction. getMilestoneInfo has no existsSync guard, so platformReadSync's null-for-ENOENT is converted into a synthetic Error with no code that lands in the same catch as a real EACCES. Reporting unconditionally there would flag every project without a ROADMAP.md -- every brand-new project -- as corrupt. A genuine read fault always carries an errno; absence never does. The parse is regex over text and cannot throw, so nothing else reaches these catches. Neither function gains a throw. ADR-1411 names this explicitly: src/state.cts removed its defensive try/catch around getMilestoneInfo under the #2245 audit because it never throws, and two tests pin that. The inner STATE.md catch stays untouched and silent -- its fallback to ROADMAP-only heuristics is a deliberate, documented optional-enhancement path, not a fault. Where the fix belongs was the design question. platformReadSync does not leak: it keeps absent and unusable as two channels, exactly as an abstraction should. Both callers re-collapsed that distinction, so the fix is caller-side and the projection seam -- with roughly ninety other dependents -- is untouched. Closes #1881 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(#1881): admit the roadmap reason to the locked vocabulary The seam documents adding a reason as three coordinated changes -- the enum entry, the emitting call site, and the test that locks Object.keys(...).sort(). This PR made the first two and the lock caught the third, which is the whole point of pinning the key set rather than asserting each value exists. The roadmap suite no longer re-locks the full set. Two complete locks would mean two files to update every time a later phase adds a reason, and #1883 and #1884 are both going to. The canonical lock stays in the seam's own suite; the roadmap suite asserts only the value it introduces. Refs #1879 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#1881): resolve the roadmap path inside the try, not outside it Naming the file in the diagnostic required the resolved path in the catch, and the obvious way to get it was to hoist `path.join(planningDir(cwd), 'ROADMAP.md')` above the try. planningDir throws a plain Error for an invalid GSD_WORKSTREAM or GSD_PROJECT segment -- one containing a slash, backslash or `..` -- so hoisting it let that throw escape uncaught. That broke the exact invariant ADR-1411 names as this file's hazard: src/state.cts removed its defensive try/catch around getMilestoneInfo under the #2245 audit because that function never throws. Of its callers only archivePhaseDirectories wraps it; cmdInitExecutePhase, cmdInitNewMilestone, cmdInitMilestoneOp, cmdInitManager, cmdInitProgress, cmdProgressRender and cmdStats all call it bare, so a workstream name with a slash in it crashed the CLI outright instead of degrading. The previous commit asserted "neither function gains a throw -- two tests pin that". That was false. Both tests inject faults through platformReadSync only and never through planningDir, so neither could have exercised the path that broke. The guarantee was claimed, not demonstrated. The path is now declared before the try and resolved inside it, so the catch can still name the file when there is one, and a path that never resolved reports nothing and returns the sentinel unchanged. The two test names are narrowed to what they actually prove -- that a failing READ does not throw -- and a new case injects the planningDir failure directly, which is what would have caught this. getRoadmapPhaseInternal carried the same hazard, resolving the path outside its try since before this branch. It is fixed the same way rather than left: ADR-227 is explicit that throwing breaks pipeline continuity, this read path already degrades to null for every other failure, and a PR whose purpose is hardening this invariant is the wrong place to leave the sibling crashing. Behaviour otherwise unchanged and re-verified: healthy lookups, EACCES reporting on both functions, absent-roadmap silence, and the errno discriminator all unaffected. Refs #1879 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#1881): backfill changeset pr number to 2729 --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
273 lines
12 KiB
JavaScript
273 lines
12 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* tests/mutation-workflow-base-ref.test.cjs
|
|
*
|
|
* Regression tests for the mutation-gate base-ref fetch (issue #2452).
|
|
*
|
|
* Background: `.github/workflows/mutation.yml` checks out with `fetch-depth: 0`
|
|
* (full history) and then re-fetched the base branch with `--depth=1`. That
|
|
* shallow re-fetch truncates the base ref's ancestry, so the three-dot diff in
|
|
* scripts/mutation-matrix.cjs (`git diff --name-only origin/<base>...HEAD`)
|
|
* can no longer compute a merge base and aborts with
|
|
* `fatal: origin/next...HEAD: no merge base`, exit 2. The `detect` job then
|
|
* fails and the `mutate` shards never run — the 80% mutation-score threshold
|
|
* goes UNVERIFIED rather than enforced.
|
|
*
|
|
* The failure is branch-position dependent, which is why it went unnoticed: a
|
|
* branch already level with the base incidentally passes (its merge base IS
|
|
* the single fetched commit), while a branch that is BEHIND the base fails.
|
|
*
|
|
* Test 1 is the contract guard (RED on origin/next, GREEN after the fix).
|
|
* Test 2 is a real-git mechanism proof: it reconstructs the runner's ref
|
|
* topology in a temp repo and demonstrates that the shallow fetch breaks the
|
|
* three-dot diff while a full fetch resolves it — proving the fix is both
|
|
* necessary and sufficient rather than asserting on YAML text alone.
|
|
*/
|
|
|
|
const { test, describe } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
const { execFileSync } = require('child_process');
|
|
const helpers = require('./helpers.cjs');
|
|
|
|
const WORKFLOWS_DIR = path.resolve(__dirname, '..', '.github', 'workflows');
|
|
|
|
/**
|
|
* Every workflow whose lint step diffs against the base with the three-dot
|
|
* form (`origin/<base>...HEAD`). All of them need the BASE REF's ancestry, so
|
|
* none may shallow-fetch it. mutation.yml used --depth=1 and failed outright;
|
|
* the other two used --depth=50, shrinking the window further still.
|
|
*
|
|
* This guard covers the base-ref FETCH only. The shallow *checkout* depth on
|
|
* changeset-required.yml / docs-required.yml is a separate, deliberate cost
|
|
* control with fail-closed semantics, owned by
|
|
* tests/policy-lint-shallow-checkout.test.cjs — do not conflate the two.
|
|
*/
|
|
const THREE_DOT_WORKFLOWS = [
|
|
{ file: 'mutation.yml', consumer: 'scripts/mutation-matrix.cjs' },
|
|
{ file: 'changeset-required.yml', consumer: 'scripts/changeset/lint.cjs' },
|
|
{ file: 'docs-required.yml', consumer: 'scripts/lint-docs-required.cjs' },
|
|
];
|
|
|
|
// Bounded: git subprocesses in tests must never hang a CI lane.
|
|
const GIT_TIMEOUT_MS = 30_000;
|
|
|
|
function git(cwd, args) {
|
|
return execFileSync('git', args, {
|
|
cwd,
|
|
encoding: 'utf8',
|
|
timeout: GIT_TIMEOUT_MS,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Extract the `run:` body of the named step from the workflow YAML.
|
|
* Deliberately a small hand parser rather than a YAML dep: this asserts on the
|
|
* literal command line the runner executes, which is the contract at issue.
|
|
*
|
|
* Handles both inline (`run: git fetch ...`) and block-scalar (`run: |`) forms;
|
|
* a block scalar returns its dedented body so a future refactor to multi-line
|
|
* `run:` cannot silently degrade the guard into asserting on the literal "|".
|
|
* Comment lines are skipped so a commented-out step of the same name cannot
|
|
* shadow the real one.
|
|
*/
|
|
function runBodyForStep(yaml, stepName) {
|
|
const lines = yaml.split(/\r?\n/);
|
|
const nameIdx = lines.findIndex(
|
|
(l) => !/^\s*#/.test(l) && l.includes(`- name: ${stepName}`),
|
|
);
|
|
if (nameIdx === -1) return null;
|
|
|
|
for (let i = nameIdx + 1; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
// Next step begins -> the step had no run: body.
|
|
if (/^\s*- name:/.test(line) && !/^\s*#/.test(line)) return null;
|
|
const m = line.match(/^\s*run:\s*(.*)$/);
|
|
if (!m) continue;
|
|
|
|
const inline = m[1].trim();
|
|
if (!/^[|>][-+]?$/.test(inline)) return inline;
|
|
|
|
// Block scalar: collect the indented body until the indentation drops.
|
|
const runIndent = line.match(/^(\s*)/)[1].length;
|
|
const body = [];
|
|
for (let j = i + 1; j < lines.length; j++) {
|
|
const bodyLine = lines[j];
|
|
if (bodyLine.trim() === '') {
|
|
body.push('');
|
|
continue;
|
|
}
|
|
const indent = bodyLine.match(/^(\s*)/)[1].length;
|
|
if (indent <= runIndent) break;
|
|
body.push(bodyLine.trim());
|
|
}
|
|
return body.join('\n').trim();
|
|
}
|
|
return null;
|
|
}
|
|
|
|
describe('#2452 CI gates: base-ref fetch must preserve ancestry', () => {
|
|
for (const { file, consumer } of THREE_DOT_WORKFLOWS) {
|
|
test(`${file}: "Fetch base ref for diff" does not shallow-fetch the base`, () => {
|
|
const yaml = fs.readFileSync(path.join(WORKFLOWS_DIR, file), 'utf8');
|
|
const runBody = runBodyForStep(yaml, 'Fetch base ref for diff');
|
|
|
|
assert.ok(
|
|
runBody,
|
|
`Expected a "Fetch base ref for diff" step with a run: body in ${file}. ` +
|
|
'If the step was renamed, update this test to match — do not delete the guard.',
|
|
);
|
|
|
|
assert.ok(
|
|
runBody.startsWith('git fetch origin'),
|
|
`Expected the step to fetch the base ref, got: ${runBody}`,
|
|
);
|
|
|
|
assert.ok(
|
|
!/--depth[=\s]/.test(runBody),
|
|
`${file}: the base-ref fetch must NOT be shallow. A --depth fetch truncates ` +
|
|
"the base branch's ancestry, so the three-dot diff in " +
|
|
`${consumer} cannot compute a merge base and the job dies with ` +
|
|
'`fatal: ...: no merge base` (#2452). Offending command: ' +
|
|
runBody,
|
|
);
|
|
});
|
|
}
|
|
|
|
// How far the base branch advances past the branch point. Chosen so the
|
|
// merge base sits OUTSIDE the old --depth=50 window, making the boundary
|
|
// between "cushion masks the bug" and "cushion exhausted" directly testable.
|
|
const BASE_ADVANCE = 60;
|
|
// Base chain is: tip … BASE_ADVANCE commits … branch point. So the branch
|
|
// point is the (BASE_ADVANCE + 1)-th commit from the tip — the exact depth
|
|
// at which a shallow base fetch first contains a usable merge base.
|
|
const MERGE_BASE_DEPTH = BASE_ADVANCE + 1;
|
|
|
|
test('base-ref fetch depth determines whether the three-dot diff resolves', () => {
|
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2452-'));
|
|
try {
|
|
// ---- origin: a base branch that advances past a feature branch --------
|
|
const origin = path.join(tmp, 'origin');
|
|
fs.mkdirSync(origin);
|
|
git(origin, ['init', '--quiet', '--initial-branch=base']);
|
|
git(origin, ['config', 'user.email', 'test@example.com']);
|
|
git(origin, ['config', 'user.name', 'Test']);
|
|
// Ambient commit.gpgsign=true would otherwise break these commits in CI.
|
|
git(origin, ['config', 'commit.gpgsign', 'false']);
|
|
|
|
fs.writeFileSync(path.join(origin, 'seed.txt'), 'seed\n');
|
|
git(origin, ['add', '.']);
|
|
git(origin, ['commit', '--quiet', '-m', 'seed']);
|
|
|
|
// Feature branch diverges here — this commit is the merge base.
|
|
git(origin, ['checkout', '--quiet', '-b', 'feature']);
|
|
fs.writeFileSync(path.join(origin, 'covered.cts'), 'export const x = 1;\n');
|
|
git(origin, ['add', '.']);
|
|
git(origin, ['commit', '--quiet', '-m', 'feature change']);
|
|
|
|
// Base then advances, leaving `feature` BEHIND — the failing condition.
|
|
git(origin, ['checkout', '--quiet', 'base']);
|
|
for (let n = 1; n <= BASE_ADVANCE; n++) {
|
|
fs.writeFileSync(path.join(origin, `base-${n}.txt`), `${n}\n`);
|
|
// Stage only the file this iteration created. `git add .` re-stages every
|
|
// file already in the tree, so across BASE_ADVANCE iterations it rehashes
|
|
// O(n²) blobs — roughly 1,800 stagings and 60 full index rewrites to add 60
|
|
// one-line files. That churn is what this loop failed on in CI: the index
|
|
// ended up referencing a blob whose object write had not landed
|
|
// ("invalid object … for 'base-31.txt' / Error building trees") at commit 32
|
|
// of 60. Staging the single new path is equivalent here — each commit adds
|
|
// exactly one file — and removes the redundant work entirely.
|
|
git(origin, ['add', `base-${n}.txt`]);
|
|
git(origin, ['commit', '--quiet', '-m', `base advance ${n}`]);
|
|
}
|
|
|
|
// ---- runner: has the PR head, must fetch the base ref separately ------
|
|
// Each variant gets its OWN clone, modelling independent workflow runs.
|
|
// They must not share a repo: once a shallow fetch writes a .git/shallow
|
|
// boundary, a later plain `git fetch` does NOT un-shallow it (that needs
|
|
// --unshallow), so repairing in place would test a scenario the workflow
|
|
// never encounters.
|
|
function runnerDiff(name, baseFetchArgs) {
|
|
const dir = path.join(tmp, name);
|
|
fs.mkdirSync(dir);
|
|
git(dir, ['init', '--quiet']);
|
|
git(dir, ['config', 'user.email', 'test@example.com']);
|
|
git(dir, ['config', 'user.name', 'Test']);
|
|
git(dir, ['config', 'commit.gpgsign', 'false']);
|
|
git(dir, ['remote', 'add', 'origin', origin]);
|
|
git(dir, ['fetch', '--quiet', 'origin', 'feature']);
|
|
git(dir, ['checkout', '--quiet', 'FETCH_HEAD']);
|
|
// The FETCH is inside the try, not before it. A base fetch whose shallow
|
|
// boundary lands short of the merge base can fail during the FETCH itself
|
|
// ("unable to parse commit" — the boundary commit's parent is unavailable)
|
|
// rather than succeeding and leaving the DIFF to fail with "no merge base".
|
|
// Which of the two git picks is version/transport dependent: this test
|
|
// passed on ubuntu-22 and windows-24 and failed on ubuntu-24 for the same
|
|
// commit. Both outcomes mean the same thing for what this guard protects —
|
|
// a shallow base ref cannot resolve the three-dot diff — so both are
|
|
// recorded as ok:false instead of one of them escaping as a crash.
|
|
try {
|
|
git(dir, ['fetch', '--quiet', 'origin', 'base', ...baseFetchArgs]);
|
|
return { ok: true, out: git(dir, ['diff', '--name-only', 'origin/base...HEAD']).trim() };
|
|
} catch (err) {
|
|
return { ok: false, err: String(err.stderr || err.message) };
|
|
}
|
|
}
|
|
|
|
// (a) --depth=1 — mutation.yml's pre-fix command. Always broken.
|
|
const depth1 = runnerDiff('runner-depth-1', ['--depth=1']);
|
|
assert.equal(
|
|
depth1.ok,
|
|
false,
|
|
'Expected the three-dot diff to FAIL after a --depth=1 base fetch. ' +
|
|
'If this stops holding, the #2452 mechanism no longer reproduces and ' +
|
|
'this guard needs revisiting.',
|
|
);
|
|
// Asserting git's exact wording couples this guard to a git version:
|
|
// "no merge base" (diff-time) and "unable to parse commit" (fetch-time)
|
|
// are the same condition reported at different stages. CONTRIBUTING also
|
|
// prohibits raw text matching on subprocess output — the typed outcome
|
|
// above (`ok === false`) IS the contract this test exists to pin.
|
|
assert.ok(depth1.err.length > 0, 'a failed shallow diff must report a cause');
|
|
|
|
// (b) BOUNDARY, just below: the merge base is one commit out of reach.
|
|
// This is the changeset-required.yml / docs-required.yml --depth=50 case
|
|
// generalized — the cushion only ever postponed the same failure.
|
|
const below = runnerDiff('runner-depth-below', [`--depth=${MERGE_BASE_DEPTH - 1}`]);
|
|
assert.equal(
|
|
below.ok,
|
|
false,
|
|
`Expected FAIL at --depth=${MERGE_BASE_DEPTH - 1} (merge base one commit ` +
|
|
'beyond the shallow boundary) — this is why a bounded cushion is not a fix',
|
|
);
|
|
assert.ok(below.err.length > 0, 'a failed shallow diff must report a cause');
|
|
|
|
// (c) BOUNDARY, exactly deep enough: the merge base is the last commit in.
|
|
const atDepth = runnerDiff('runner-depth-at', [`--depth=${MERGE_BASE_DEPTH}`]);
|
|
assert.equal(
|
|
atDepth.ok,
|
|
true,
|
|
`Expected SUCCESS at --depth=${MERGE_BASE_DEPTH} (merge base exactly at the ` +
|
|
`shallow boundary), got: ${atDepth.err}`,
|
|
);
|
|
assert.equal(atDepth.out, 'covered.cts');
|
|
|
|
// (d) Unbounded — the shipped fix. Correct regardless of how far behind.
|
|
const full = runnerDiff('runner-full', []);
|
|
assert.equal(full.ok, true, `Expected the full-fetch diff to succeed, got: ${full.err}`);
|
|
assert.equal(
|
|
full.out,
|
|
'covered.cts',
|
|
'After a full base fetch the three-dot diff must resolve and report ' +
|
|
"exactly the feature branch's changed files",
|
|
);
|
|
} finally {
|
|
helpers.cleanup(tmp);
|
|
}
|
|
});
|
|
});
|