Files
msd-core/tests/lint-compiled-artifact-sync.test.cjs
Tom Boucher 2e1ede6d99 fix(#4093): give advance-plan's zero-labeled-fields failure a disk-derived recovery decline (#4318)
* test(#4093): regression matrix for advance-plan zero-labeled-fields decline

* fix(#4093): give advance-plan's zero-labeled-fields failure a disk-derived recovery decline

* refactor(#4093): collapse IIFE to a plain block (review finding)

* docs(#4093): document the advance-plan recovery decline + changeset

* chore(#4093): backfill PR number in changeset

* fix(#4093): budget lint-compiled-artifact-sync's tsc compile as a compile, not a probe

---------

Co-authored-by: sim <sim@local>
2026-09-05 10:46:37 -04:00

203 lines
9.7 KiB
JavaScript

'use strict';
/**
* Regression test for #2657.
*
* Nine compiled `.cjs` artifacts under gsd-core/bin/lib/ were tracked in git
* despite each having a matching src/*.cts source, violating ADR-457's
* build-at-publish contract ("bin/lib/*.cjs" must be a gitignored build
* artifact, never checked-in source of truth). A tracked compiled artifact
* can silently drift from its source without anyone noticing — #2653
* demonstrated exactly this for api-coverage.cjs, which shipped four days
* behind its .cts with CI green throughout.
*
* This asserts the ADR-457 end state for all nine: none tracked, all
* gitignored, and the regime-agnostic sync guard (added in #2656,
* scripts/lint-compiled-artifact-sync.cjs) reports the empty tracked set.
*
* Two of the nine (markdown-table.cjs, write-set.cjs) already had a
* .gitignore pattern before this fix (added by #2248) but were never
* `git rm --cached`; the other seven had no .gitignore pattern at all. Both
* gaps produce the same `git ls-files` symptom, so both are covered by the
* same assertions here.
*
* ── Diagnostics discipline ────────────────────────────────────────────────
* Every git invocation below uses `spawnSync` (never throws) and every
* assertion explicitly checks the exit status BEFORE interpreting output.
* A git command that errors (bad cwd, dubious-ownership refusal, missing
* binary, anything) must never be silently read as a legitimate "not
* ignored" / "still tracked" answer — that conflates "the property does not
* hold" with "I could not determine whether the property holds", which is a
* distinct defect from the bug this file guards against. On any failure,
* the assertion message includes the resolved cwd, exit status, and stderr,
* so a red run is self-diagnosing without a second round-trip.
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const { runGit, runNode, OUTCOME } = require('./helpers/process-seam.cjs');
const { toLegacyResult } = require('./helpers/git-fixture.cjs');
const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
const { trackedCompiledArtifacts } = require('../scripts/lint-compiled-artifact-sync.cjs');
const REPO_ROOT = path.join(__dirname, '..');
const LIB_DIR = 'gsd-core/bin/lib';
const NINE_ARTIFACTS = [
'api-coverage.cjs',
'assumption-delta.cjs',
'claude-orchestration-command-router.cjs',
'claude-orchestration.cjs',
'external-job.cjs',
'markdown-table.cjs',
'runtime-artifact-install-plan.cjs',
'state-transition.cjs',
'write-set.cjs',
].map((name) => `${LIB_DIR}/${name}`);
/**
* Run a command via the process seam (never throws) and return a legacy
* `{status, stdout, stderr, signal}` shape. `cmd` is either `'git'` (routed
* through `runGit`) or `process.execPath` (routed through `runNode`) — the
* only two callers below. Throws immediately, with full context, only on a
* genuine spawn failure (binary not found, etc.) — a condition no caller
* here can meaningfully interpret as a match/no-match answer.
*/
function run(cmd, args, opts) {
const options = { cwd: REPO_ROOT, timeoutMs: PROBE_TIMEOUT_MS, ...opts };
const result = cmd === 'git' ? runGit(args, options) : runNode(args, options);
if (result.outcome === OUTCOME.SPAWN_FAILED) {
throw new Error(
`${cmd} ${args.join(' ')} failed to spawn (cwd=${REPO_ROOT}): ${result.stderr || result.code}`,
);
}
return { ...toLegacyResult(result), signal: result.signal };
}
/** Render a failed command's full context for an assertion message. */
function describeFailure(cmd, args, result) {
return (
`${cmd} ${args.join(' ')} (cwd=${REPO_ROOT}) exited ${result.status}` +
(result.signal ? ` (signal ${result.signal})` : '') +
`\n stderr: ${(result.stderr || '(empty)').trim()}` +
`\n stdout: ${(result.stdout || '(empty)').trim()}`
);
}
function git(args) {
// -c safe.directory=REPO_ROOT: containerized CI checkouts are frequently
// owned by a different uid than the one running node --test, and git
// refuses to operate at all on such a repo ("detected dubious ownership")
// unless explicitly trusted. Scoped per-invocation (not written to any
// config file), matching the same fix applied to
// scripts/lint-compiled-artifact-sync.cjs's own git() helper, which has
// the identical defect (#2657 diagnostic run: `git ls-files` there failed
// with the same "dubious ownership" fatal in the runner).
return run('git', ['-c', `safe.directory=${REPO_ROOT}`, ...args]);
}
/** `git ls-files <LIB_DIR>`, asserting success before trusting the output. */
function trackedLibFiles() {
const args = ['ls-files', LIB_DIR];
const result = git(args);
assert.equal(
result.status,
0,
`git ls-files must exit 0 before its output can be trusted as "nothing tracked":\n${describeFailure('git', args, result)}`,
);
return new Set(result.stdout.split('\n').filter(Boolean));
}
/**
* `git check-ignore -q <path>` has exactly two legitimate outcomes: exit 0
* (ignored) and exit 1 (not ignored) — check-ignore(1). Any other exit code
* or a signal is an infrastructure failure, not a "not ignored" answer, and
* must not be conflated with one.
*/
function isIgnored(artifactPath) {
const args = ['check-ignore', '-q', artifactPath];
const result = git(args);
if (result.status === 0) return true;
if (result.status === 1) return false;
throw new Error(
`git check-ignore for ${artifactPath} returned neither a match (0) nor a legitimate ` +
`no-match (1) exit code — this is an infrastructure failure, not evidence the path ` +
`is unignored:\n${describeFailure('git', args, result)}`,
);
}
// Shared shape for both "none of the nine should still be in state X" checks
// below: derive the still-bad subset via `isBad`, then assert it's empty.
function assertNoneStillBad(isBad, failureLabel) {
const stillBad = NINE_ARTIFACTS.filter(isBad);
assert.deepEqual(
stillBad,
[],
`expected none of the nine ${failureLabel}; still: ${stillBad.join(', ') || '(none)'}`,
);
}
describe('fix-2657: compiled .cjs artifacts are gitignored, not tracked (ADR-457)', () => {
test('none of the nine ADR-457 migration-gap artifacts are tracked by git', () => {
const tracked = trackedLibFiles();
assertNoneStillBad((p) => tracked.has(p), 'to be tracked');
});
test('every one of the nine paths is ignored per git', () => {
// Deliberately WITHOUT --no-index: git-check-ignore(1) operates on the
// pathname alone and does not require the file to exist on disk (true
// both with and without --no-index — this repo's gsd-test runner checks
// out a fresh shallow clone per sha, where an untracked, gitignored path
// exists as a pattern match only, never as a file on disk). Plain
// check-ignore is preferred here over --no-index specifically because it
// also honors git's "a still-TRACKED path is never reported ignored"
// rule (check-ignore(1)) — which is exactly the property under test: a
// path that still matches a .gitignore pattern while ALSO remaining
// tracked (the pre-fix state for two of the nine, whose pattern
// predates this fix per #2248) must still read as "not ignored," the
// same as the seven with no pattern at all. --no-index would blur that
// distinction by reporting the two as ignored regardless of tracking.
assertNoneStillBad((p) => !isIgnored(p), 'to be reported not-ignored by git');
});
test('trackedCompiledArtifacts() reports the ADR-457 empty-set end state for the nine', () => {
let pairs;
try {
pairs = trackedCompiledArtifacts();
} catch (err) {
// trackedCompiledArtifacts() (scripts/lint-compiled-artifact-sync.cjs)
// wraps its OWN internal `git ls-files gsd-core/bin/lib` call, with cwd
// resolved from that script's own __dirname (should equal REPO_ROOT
// here regardless of caller). A throw means THAT invocation failed —
// not that any artifact is still tracked. Surface it, don't mask it.
assert.fail(
`trackedCompiledArtifacts() threw instead of returning a result — this indicates its ` +
`internal git invocation failed, not that any of the nine is still tracked:\n` +
`${err && err.stack ? err.stack : err}`,
);
}
const stillPresentArtifacts = new Set(pairs.map((p) => p.artifact));
assertNoneStillBad((p) => stillPresentArtifacts.has(p), 'to appear in trackedCompiledArtifacts()');
});
test('lint-compiled-artifact-sync exits 0 with nothing left to check', () => {
// #4093 CI: this spawn is NOT the PROBE class the shared default below
// describes. The script's "nothing left to check" path still runs a FULL
// `tsc -p tsconfig.build.json` compile to a throwaway outDir whenever any
// compiled artifact remains tracked (ten are, deliberately — ADR-457's
// staged end state), and under CI shard load that compile can exceed the
// 15s probe budget, dying to a SIGTERM with empty piped stdout (observed
// twice on ubuntu shard 1/3). Per helpers/timeouts.cjs's own rule, a call
// site that genuinely differs from its class — "a real `tsc` compile" —
// keeps its own local constant with its own justifying comment; see
// tests/ensure-runtime-build.test.cjs's BUILD_TIMEOUT_MS for the other
// instance. 60s is that same class, sized for the cold-cache CI case.
const TSC_COMPILE_TIMEOUT_MS = 60000;
const args = [path.join(REPO_ROOT, 'scripts', 'lint-compiled-artifact-sync.cjs')];
const result = run(process.execPath, args, { timeoutMs: TSC_COMPILE_TIMEOUT_MS });
assert.equal(result.status, 0, describeFailure(process.execPath, args, result));
});
});