Files
msd-core/tests/default-flip-documentation-lint.test.cjs
Tom Boucher 941b62249e enhance(#3906): two terminators over one registry, with a versioned exit projection (#3924)
* feat(#3906): two terminators over one registry, with a versioned projection

Adds terminateNow (write-then-terminate, for callers that cannot wait for the event loop) beside runMain (drain-then-exit), both projecting through one shared function so they cannot disagree - the parity the ADR makes mandatory. A failed write does not change the exit code: letting it propagate would fail a hook open, which is what the fail-closed branches exist to prevent.

The projection is versioned. v1 reproduces today's integers, including keeping a payload-carried degraded result at exit 0 - ADR-2980 ratified that across 60 sites and declined normalizing it on measured blast radius. v2 applies the registry. --exit-contract=v2 or GSD_EXIT_CONTRACT=v2 selects it; an unrecognized version throws rather than silently defaulting.

The registry is now emitted beside both copies of the exit module, so it resolves as a sibling in the built tree and in the committed scripts/ copy that must load on an unbuilt clone.

* fix(#3906): actually restrict code 2 to terminateNow, and generate the registry's type

The claim that terminateNow is the only place 2 can be produced was false: runMain's outcome arm applied no guard, so runMain(()=>'HOOK_DENY') set exitCode 2 through the drain path - and the parity matrix demonstrated it while calling it parity. runMain now refuses any outcome projecting to the hook-protocol code, gated on the code rather than the name so an alias cannot slip past, and the matrix asserts the restriction instead of contradicting it.

The ambient type for the generated registry was hand-written with no gate against the generator's actual output - the declared-surface-diverges-from-runtime defect class this epic exists to close, reintroduced inside it. It is now a third generated artifact covered by the same --check. Also converts every test-body try/finally to t.after().

* test(#3906): derive the glossary fixture's dependencies instead of hand-listing them

Adding a require to scripts/lib/cli-exit.cjs broke 31 tests in one suite that built its fixture from a hand-written dependency list, so the new sibling was absent and the copied script could not load. copyScriptWithDeps walks the require graph and exists for exactly this class - #3412 paid the same bill when one new require broke 82 tests across two suites. Migrating rather than adding another copyFileSync line keeps the class closed. The other nine suites referencing that path were triaged; none copies-and-spawns, so none needed migrating.

* fix(#3906): enumerate the new shipped file, drop a vendor name from shipped data, and fix three test defects

install: scripts/lib/exit-code-registry.cjs was missing from GSD_SCRIPTS_LIB_FILES, so it shipped to every install and orphaned on uninstall.

The registry gave HOOK_DENY a meaning naming one harness, and that string ships into every runtime's tree - a guard correctly caught it leaking into the hermes and qwen installs. The registry is runtime-neutral infrastructure; the vendor name belongs in the ADR, not in shipped data.

Two more fixture harnesses built their trees from hand-listed dependencies and broke on the new require; both migrated to the derived helper, and all 23 copy-and-spawn candidates were enumerated so the class is closed rather than patched. One generator test used a fixture code that collided with a real allocation, so the generator correctly reported a duplicate where the test expected drift. The large-payload test embedded a 256KB literal in the child's argv, exceeding Linux's 128KiB MAX_ARG_STRLEN so the child never started - it now builds the payload inside the child.

* chore(#3906): backfill changeset pr number

* docs(#3906): document the exit-code contract selector

P2 is the first phase of this epic with a user-invocable surface, so the flag and env var owe a reference entry. Records what actually differs between v1 and v2 today (one outcome), that an unrecognized value is rejected rather than silently defaulted, and the fail-safe property that makes switching safe.

---------

Co-authored-by: sim <sim@local>
2026-08-27 03:31:02 -04:00

195 lines
8.5 KiB
JavaScript

'use strict';
process.env.GSD_TEST_MODE = '1';
/**
* Default-flip-documentation lint (DEFECT.DEFAULT-FLIP-DOCUMENTATION,
* CONTEXT.md).
*
* scripts/lint-default-flip-documentation.cjs fails a PR that changes an
* EXISTING default value in gsd-core/bin/shared/config-defaults.manifest.json
* (the single source `CONFIG_DEFAULTS` loads at runtime) without a
* `## Breaking Changes` PR-body section covering the migration semantics
* (#3309: the v2 default flip from mid-flight to end-of-phase).
*
* Scope note: this check is deliberately narrower than the full DEFECT text
* — it does NOT cover `buildNewProjectConfig`'s hardcoded object literal in
* src/config.cts, because that literal mixes env-derived branches with
* CONFIG_DEFAULTS spreads and cannot be reduced to a resolved value map from
* source text alone without executing the compiled module at both refs. A
* line/text diff of that literal would inherit the exact false-positive
* risk (a harmless refactor reading as a "flip") this check exists to
* avoid, so it is left out rather than shipped noisy. See the script's own
* header comment for the full rationale.
*/
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 ROOT = path.join(__dirname, '..');
const LINT_SCRIPT = path.join(ROOT, 'scripts', 'lint-default-flip-documentation.cjs');
const { flatten, findDefaultValueChanges, evaluateDefaultFlipDoc, MANIFEST_PATH } = require(LINT_SCRIPT);
const { cleanup } = require('./helpers.cjs');
const { runNode } = require('./helpers/process-seam.cjs');
const { gitOrThrow } = require('./helpers/git-fixture.cjs');
const { copyScriptWithDeps } = require('./helpers/copy-script-fixture.cjs');
const LINT_SCRIPT_REL = path.join('scripts', 'lint-default-flip-documentation.cjs');
describe('default-flip-documentation lint: flatten (pure)', () => {
test('flattens a nested object into dot-path leaves', () => {
assert.deepEqual(
flatten({ workflow: { human_verify_mode: 'end-of-phase' }, model_profile: 'balanced' }),
{ 'workflow.human_verify_mode': 'end-of-phase', model_profile: 'balanced' },
);
});
test('an array is a leaf, not recursed into (reordering reads as one change, not N)', () => {
assert.deepEqual(flatten({ tags: ['a', 'b'] }), { tags: ['a', 'b'] });
});
});
describe('default-flip-documentation lint: findDefaultValueChanges (pure)', () => {
test('the real #3309 defect shape IS a change: an existing key value differs', () => {
const changes = findDefaultValueChanges(
{ 'workflow.human_verify_mode': 'mid-flight' },
{ 'workflow.human_verify_mode': 'end-of-phase' },
);
assert.deepEqual(changes, [{ key: 'workflow.human_verify_mode', from: 'mid-flight', to: 'end-of-phase' }]);
});
test('LOOKALIKE: a brand-new key (addition, not a flip) is NOT a change', () => {
const changes = findDefaultValueChanges({ a: 1 }, { a: 1, b: 2 });
assert.deepEqual(changes, []);
});
test('LOOKALIKE: a removed key (not a flip either) is NOT a change', () => {
const changes = findDefaultValueChanges({ a: 1, b: 2 }, { a: 1 });
assert.deepEqual(changes, []);
});
test('LOOKALIKE: the whole object reordered/restructured with identical resolved values is NOT a change (the false-positive the audit called out)', () => {
const base = { workflow: { a: 1, b: 2 }, git: { create_tag: true } };
const head = { git: { create_tag: true }, workflow: { b: 2, a: 1 } };
assert.deepEqual(findDefaultValueChanges(flatten(base), flatten(head)), []);
});
test('an unchanged value is not reported', () => {
assert.deepEqual(findDefaultValueChanges({ a: 1 }, { a: 1 }), []);
});
});
describe('default-flip-documentation lint: evaluateDefaultFlipDoc (pure)', () => {
test('no changes: always ok regardless of PR body', () => {
assert.equal(evaluateDefaultFlipDoc([], '').ok, true);
});
test('a real flip with no Breaking Changes section in the PR body fails', () => {
const verdict = evaluateDefaultFlipDoc([{ key: 'x', from: 1, to: 2 }], 'just a normal PR description');
assert.equal(verdict.ok, false);
});
test('a real flip WITH a "## Breaking Changes" heading in the PR body passes', () => {
const verdict = evaluateDefaultFlipDoc(
[{ key: 'x', from: 1, to: 2 }],
'Summary\n\n## Breaking Changes\n\nNew default takes effect on config-set.',
);
assert.equal(verdict.ok, true);
});
test('the heading match is case-insensitive and tolerates heading level', () => {
const verdict = evaluateDefaultFlipDoc([{ key: 'x', from: 1, to: 2 }], '# breaking changes\ndetails');
assert.equal(verdict.ok, true);
});
});
describe('default-flip-documentation lint: main() end-to-end wiring', () => {
const git = (dir, ...args) => gitOrThrow(args, { cwd: dir });
function buildRepo(tmpDir, baseManifest, headManifest) {
git(tmpDir, 'init', '-q', '-b', 'main');
git(tmpDir, 'config', 'user.email', 'test@example.com');
git(tmpDir, 'config', 'user.name', 'Test');
const manifestAbs = path.join(tmpDir, MANIFEST_PATH);
fs.mkdirSync(path.dirname(manifestAbs), { recursive: true });
fs.writeFileSync(manifestAbs, JSON.stringify(baseManifest));
git(tmpDir, 'add', '-A');
git(tmpDir, 'commit', '-q', '-m', 'base');
git(tmpDir, 'update-ref', 'refs/remotes/origin/main', 'HEAD');
git(tmpDir, 'checkout', '-q', '-b', 'pr');
fs.writeFileSync(manifestAbs, JSON.stringify(headManifest));
git(tmpDir, 'add', '-A');
git(tmpDir, 'commit', '-q', '-m', 'pr');
return copyScriptWithDeps(ROOT, tmpDir, LINT_SCRIPT_REL);
}
function runWithPrBody(tmpDir, scriptCopy, prBody) {
const eventPath = path.join(tmpDir, 'event.json');
fs.writeFileSync(eventPath, JSON.stringify({ pull_request: { body: prBody } }));
return runNode(
[scriptCopy],
{
cwd: tmpDir,
env: { ...process.env, GITHUB_BASE_REF: 'main', GITHUB_EVENT_PATH: eventPath },
},
);
}
test('exit 1: a flipped default with no Breaking Changes section in the PR body', (t) => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-default-flip-e2e-'));
t.after(() => cleanup(tmpDir));
const scriptCopy = buildRepo(
tmpDir,
{ workflow: { human_verify_mode: 'mid-flight' } },
{ workflow: { human_verify_mode: 'end-of-phase' } },
);
const result = runWithPrBody(tmpDir, scriptCopy, 'Flips the default. No migration notes.');
assert.equal(result.exitCode, 1, `expected exit 1, got ${result.exitCode}: ${result.stderr}`);
assert.match(result.stderr, /DEFAULT-FLIP-DOCUMENTATION/);
});
test('exit 0: a flipped default WITH a Breaking Changes section', (t) => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-default-flip-e2e-doc-'));
t.after(() => cleanup(tmpDir));
const scriptCopy = buildRepo(
tmpDir,
{ workflow: { human_verify_mode: 'mid-flight' } },
{ workflow: { human_verify_mode: 'end-of-phase' } },
);
const result = runWithPrBody(
tmpDir,
scriptCopy,
'## Breaking Changes\n\nNew default takes effect when config.json is regenerated; opt back in with `gsd config-set workflow.human_verify_mode mid-flight`.',
);
assert.equal(result.exitCode, 0, `expected exit 0, got ${result.exitCode}: ${result.stderr}`);
});
test('LOOKALIKE: manifest restructured/reformatted with identical resolved values does NOT fail, even with no Breaking Changes section', (t) => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-default-flip-e2e-reformat-'));
t.after(() => cleanup(tmpDir));
const scriptCopy = buildRepo(
tmpDir,
{ a: 1, workflow: { x: true, y: false } },
{ workflow: { y: false, x: true }, a: 1 },
);
const result = runWithPrBody(tmpDir, scriptCopy, 'Pure reformat, no PR body sections at all.');
assert.equal(result.exitCode, 0, `expected exit 0 (no real value change), got ${result.exitCode}: ${result.stderr}`);
});
test('exit 0 and skip when there is no PR event payload (push / local run)', (t) => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-default-flip-e2e-noevent-'));
t.after(() => cleanup(tmpDir));
const scriptCopy = buildRepo(tmpDir, { a: 1 }, { a: 2 });
const result = runNode(
[scriptCopy],
{ cwd: tmpDir, env: { ...process.env, GITHUB_BASE_REF: 'main', GITHUB_EVENT_PATH: '' } },
);
assert.equal(result.exitCode, 0, `expected exit 0, got ${result.exitCode}: ${result.stderr}`);
assert.match(result.stdout, /skipping/);
});
});