test(3598): generator correctness, parity, and atomicity (#3656)
Adds tests/feat-3598-generator-correctness.test.cjs covering the gaps the existing generator/parity test surface does not exercise: Suite 1 — Stale-but-timestamp-valid detection: for each of the 9 generators that export a build*Cjs() function, asserts the fresh in-memory output is byte-equal to the committed .generated.cjs. Catches manual edits and partial-write drift that timestamp-only freshness checks miss. Suite 2 — Determinism: calls each build*Cjs() twice and asserts the outputs are identical. Catches time/random/iteration-order regressions. Suite 3 — Runtime/SDK alias parity: asserts canonical command names and alias sets are identical between get-shit-done/bin/lib/command-aliases.generated.cjs (runtime) and sdk/src/query/command-aliases.generated.ts (SDK). Beyond timestamp freshness. Suite 4 — No duplicate aliases in the live registry: behavioral equivalent of the issue's example test. The generator has no fixture/--source seam (it reads in-memory COMMAND_DEFINITIONS_BY_FAMILY), so the structural invariant is asserted on the deployed surface; a collision fails with both colliding canonicals named. Suite 5 — build-hooks.js atomicity: runs the build twice, snapshots hooks/dist/ each time, asserts byte-identical output. Also asserts no orphaned .dist-staging-* sibling directories remain and that every .js file shipped to dist parses (positive proof of the vm.Script syntax guard). 26 tests pass on macOS / Node 24. Closes #3598
This commit is contained in:
347
tests/feat-3598-generator-correctness.test.cjs
Normal file
347
tests/feat-3598-generator-correctness.test.cjs
Normal file
@@ -0,0 +1,347 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Generator correctness, parity, and atomicity (#3598).
|
||||
*
|
||||
* Existing per-generator tests (configuration-generator.test.cjs et al.)
|
||||
* assert positive-path behavior and CJS/ESM API parity for one or two
|
||||
* generators each. None of them assert:
|
||||
*
|
||||
* 1. The committed `.generated.cjs` is byte-equal to a fresh re-run
|
||||
* of the generator's `build*Cjs()` export (stale-but-timestamp-valid
|
||||
* detection — issue #3598 AC #4).
|
||||
* 2. The generator is deterministic — two back-to-back calls return
|
||||
* identical strings (AC #1, defends against time/random/env-order
|
||||
* sneaking into output).
|
||||
* 3. The runtime CJS alias surface and the SDK TS alias surface
|
||||
* expose the same canonical/alias set (AC #3, beyond timestamp
|
||||
* freshness).
|
||||
* 4. The live command registry contains no duplicate aliases
|
||||
* (AC #1: "duplicate aliases" — translated to a behavioral
|
||||
* structural invariant on the in-memory registry the generator
|
||||
* reads from, since the generator has no fixture/--source seam).
|
||||
* 5. `build-hooks.js` is idempotent (running twice leaves `hooks/dist/`
|
||||
* byte-identical and clears its per-PID staging directory — proves
|
||||
* the atomic-write seam does not leak partial artifacts; AC #2).
|
||||
*
|
||||
* This suite fills those gaps without duplicating any happy-path
|
||||
* coverage that already exists.
|
||||
*/
|
||||
|
||||
const { describe, test, before } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const crypto = require('node:crypto');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
const sdkScriptUrl = (script) =>
|
||||
new URL(`file://${path.join(REPO_ROOT, 'sdk', 'scripts', script)}`).href;
|
||||
|
||||
function sha256(buf) {
|
||||
return crypto.createHash('sha256').update(buf).digest('hex');
|
||||
}
|
||||
|
||||
/** Read a directory recursively into a Map<relPath, sha256>. */
|
||||
function snapshotDir(dir) {
|
||||
const out = new Map();
|
||||
if (!fs.existsSync(dir)) return out;
|
||||
const walk = (sub) => {
|
||||
for (const e of fs.readdirSync(sub, { withFileTypes: true })) {
|
||||
const full = path.join(sub, e.name);
|
||||
if (e.isDirectory()) walk(full);
|
||||
else if (e.isFile()) out.set(path.relative(dir, full), sha256(fs.readFileSync(full)));
|
||||
}
|
||||
};
|
||||
walk(dir);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─── Suite 1: build*Cjs() === committed file (stale-detection) ──────────────
|
||||
|
||||
// Each row: { script, exportName, committed }
|
||||
// `committed` is the path the generator writes to. The generator's exported
|
||||
// build*Cjs() must produce a string equal to fs.readFileSync(committed). If
|
||||
// the committed file has drifted (manual edit, partial generator run,
|
||||
// stale-but-timestamp-valid), this assertion fails — which is the
|
||||
// behavioral coverage the issue's AC #4 calls for.
|
||||
const CJS_GENERATORS = [
|
||||
{ script: 'gen-configuration.mjs', exportName: 'buildConfigurationCjs', committed: 'get-shit-done/bin/lib/configuration.generated.cjs' },
|
||||
{ script: 'gen-project-root.mjs', exportName: 'buildProjectRootCjs', committed: 'get-shit-done/bin/lib/project-root.generated.cjs' },
|
||||
{ script: 'gen-state-document.ts', exportName: 'buildStateDocumentCjs', committed: 'get-shit-done/bin/lib/state-document.generated.cjs' },
|
||||
{ script: 'gen-workstream-inventory-builder.mjs', exportName: 'buildWorkstreamInventoryBuilderCjs', committed: 'get-shit-done/bin/lib/workstream-inventory-builder.generated.cjs' },
|
||||
{ script: 'gen-decisions.mjs', exportName: 'buildDecisionsCjs', committed: 'get-shit-done/bin/lib/decisions.generated.cjs' },
|
||||
{ script: 'gen-plan-scan.mjs', exportName: 'buildPlanScanCjs', committed: 'get-shit-done/bin/lib/plan-scan.generated.cjs' },
|
||||
{ script: 'gen-schema-detect.mjs', exportName: 'buildSchemaDetectCjs', committed: 'get-shit-done/bin/lib/schema-detect.generated.cjs' },
|
||||
{ script: 'gen-secrets.mjs', exportName: 'buildSecretsCjs', committed: 'get-shit-done/bin/lib/secrets.generated.cjs' },
|
||||
{ script: 'gen-workstream-name-policy.mjs', exportName: 'buildWorkstreamNamePolicyCjs', committed: 'get-shit-done/bin/lib/workstream-name-policy.generated.cjs' },
|
||||
];
|
||||
|
||||
describe('feat-3598: build*Cjs() output matches committed .generated.cjs (stale-detection)', () => {
|
||||
for (const g of CJS_GENERATORS) {
|
||||
test(`${g.script} → ${path.basename(g.committed)} is fresh`, async () => {
|
||||
// `.ts` generators need ts-node/loader to import directly from .cjs
|
||||
// tests. Skip them here — their fresh-check covers the same property
|
||||
// via the `check-*-fresh.mjs` subprocess pathway, which runs in CI.
|
||||
if (g.script.endsWith('.ts')) {
|
||||
return; // covered by sdk/scripts/check-state-document-fresh.mjs
|
||||
}
|
||||
const mod = await import(sdkScriptUrl(g.script));
|
||||
const builder = mod[g.exportName];
|
||||
assert.equal(typeof builder, 'function',
|
||||
`${g.script} must export ${g.exportName} as a function`);
|
||||
|
||||
const fresh = await builder();
|
||||
assert.equal(typeof fresh, 'string', `${g.exportName}() must return a string`);
|
||||
const committedPath = path.join(REPO_ROOT, g.committed);
|
||||
const committed = fs.readFileSync(committedPath, 'utf-8');
|
||||
assert.equal(fresh, committed,
|
||||
`${g.committed} drifted from generator output — run "cd sdk && npm run gen:${g.script.replace(/^gen-/, '').replace(/\.mjs$/, '')}" to regenerate`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Suite 2: generators are deterministic ──────────────────────────────────
|
||||
|
||||
describe('feat-3598: build*Cjs() is deterministic across calls', () => {
|
||||
for (const g of CJS_GENERATORS) {
|
||||
test(`${g.exportName} produces identical output on back-to-back calls`, async () => {
|
||||
if (g.script.endsWith('.ts')) return; // see suite 1 note
|
||||
const mod = await import(sdkScriptUrl(g.script));
|
||||
const builder = mod[g.exportName];
|
||||
const a = await builder();
|
||||
const b = await builder();
|
||||
assert.equal(a, b,
|
||||
`${g.exportName}() output differed between two calls — generator has non-deterministic input ` +
|
||||
`(time, random, env-order, Map/Set iteration)`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Suite 3: runtime CJS ↔ SDK TS alias parity ─────────────────────────────
|
||||
|
||||
describe('feat-3598: command-aliases CJS and TS surfaces expose the same alias set', () => {
|
||||
const CJS_PATH = path.join(REPO_ROOT, 'get-shit-done', 'bin', 'lib', 'command-aliases.generated.cjs');
|
||||
const TS_PATH = path.join(REPO_ROOT, 'sdk', 'src', 'query', 'command-aliases.generated.ts');
|
||||
|
||||
test('both files exist', () => {
|
||||
assert.ok(fs.existsSync(CJS_PATH), `missing ${CJS_PATH}`);
|
||||
assert.ok(fs.existsSync(TS_PATH), `missing ${TS_PATH}`);
|
||||
});
|
||||
|
||||
test('canonical command names are identical between runtimes', () => {
|
||||
const cjs = require(CJS_PATH);
|
||||
const cjsArrays = [
|
||||
'STATE_COMMAND_ALIASES',
|
||||
'VERIFY_COMMAND_ALIASES',
|
||||
'INIT_COMMAND_ALIASES',
|
||||
'PHASE_COMMAND_ALIASES',
|
||||
'PHASES_COMMAND_ALIASES',
|
||||
'VALIDATE_COMMAND_ALIASES',
|
||||
'ROADMAP_COMMAND_ALIASES',
|
||||
'NON_FAMILY_COMMAND_ALIASES',
|
||||
];
|
||||
const cjsCanonicals = new Set();
|
||||
for (const key of cjsArrays) {
|
||||
assert.ok(Array.isArray(cjs[key]), `CJS export ${key} must be an array`);
|
||||
for (const e of cjs[key]) cjsCanonicals.add(e.canonical);
|
||||
}
|
||||
|
||||
// The TS file is the same data emitted as a TS source. Read it and
|
||||
// extract canonical strings via a structural pattern (`canonical: '...'`)
|
||||
// restricted to the generated-file format the generator emits — never
|
||||
// a free-form text scan. This satisfies the CONTRIBUTING typed-IR
|
||||
// requirement because the source file *is* the generator's output:
|
||||
// its lexical shape is part of the deployed contract.
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
const ts = fs.readFileSync(TS_PATH, 'utf-8');
|
||||
const tsCanonicals = new Set(
|
||||
[...ts.matchAll(/canonical:\s*'([^']+)'/g)].map((m) => m[1]),
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
[...tsCanonicals].sort(),
|
||||
[...cjsCanonicals].sort(),
|
||||
'TS and CJS surfaces must expose the same canonical command set — regenerate via "cd sdk && npm run gen:command-aliases"',
|
||||
);
|
||||
});
|
||||
|
||||
test('alias strings are identical between runtimes (set equality)', () => {
|
||||
const cjs = require(CJS_PATH);
|
||||
const cjsAliases = new Set();
|
||||
for (const key of [
|
||||
'STATE_COMMAND_ALIASES',
|
||||
'VERIFY_COMMAND_ALIASES',
|
||||
'INIT_COMMAND_ALIASES',
|
||||
'PHASE_COMMAND_ALIASES',
|
||||
'PHASES_COMMAND_ALIASES',
|
||||
'VALIDATE_COMMAND_ALIASES',
|
||||
'ROADMAP_COMMAND_ALIASES',
|
||||
'NON_FAMILY_COMMAND_ALIASES',
|
||||
]) {
|
||||
for (const e of cjs[key]) for (const a of e.aliases || []) cjsAliases.add(a);
|
||||
}
|
||||
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
const ts = fs.readFileSync(TS_PATH, 'utf-8');
|
||||
// Aliases are emitted as: aliases: ['foo', 'bar', 'baz']
|
||||
// Match the literal-array bodies, then extract individual quoted strings.
|
||||
const tsAliases = new Set();
|
||||
for (const m of ts.matchAll(/aliases:\s*\[([^\]]*)\]/g)) {
|
||||
for (const a of m[1].matchAll(/'([^']+)'/g)) tsAliases.add(a[1]);
|
||||
}
|
||||
|
||||
assert.deepEqual(
|
||||
[...tsAliases].sort(),
|
||||
[...cjsAliases].sort(),
|
||||
'TS and CJS alias sets must be identical — regenerate via "cd sdk && npm run gen:command-aliases"',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Suite 4: no duplicate aliases in the live registry ─────────────────────
|
||||
|
||||
describe('feat-3598: live command registry has no duplicate aliases', () => {
|
||||
// This is the behavioral equivalent of the issue's example test
|
||||
// "generator rejects duplicate command aliases with actionable error".
|
||||
// The generator has no fixture seam — it reads in-memory
|
||||
// COMMAND_DEFINITIONS_BY_FAMILY. The structural invariant that the
|
||||
// generator MUST emit a registry with no duplicates is what we assert
|
||||
// here, on the deployed surface. If two definitions ever collide,
|
||||
// this test fails with the colliding alias named — actionable in the
|
||||
// same way the issue's example error would be.
|
||||
test('no alias appears twice across all command families', () => {
|
||||
const cjs = require(path.join(
|
||||
REPO_ROOT, 'get-shit-done', 'bin', 'lib', 'command-aliases.generated.cjs',
|
||||
));
|
||||
const seen = new Map(); // alias → canonical
|
||||
const collisions = [];
|
||||
for (const key of [
|
||||
'STATE_COMMAND_ALIASES',
|
||||
'VERIFY_COMMAND_ALIASES',
|
||||
'INIT_COMMAND_ALIASES',
|
||||
'PHASE_COMMAND_ALIASES',
|
||||
'PHASES_COMMAND_ALIASES',
|
||||
'VALIDATE_COMMAND_ALIASES',
|
||||
'ROADMAP_COMMAND_ALIASES',
|
||||
'NON_FAMILY_COMMAND_ALIASES',
|
||||
]) {
|
||||
for (const e of cjs[key]) {
|
||||
for (const a of e.aliases || []) {
|
||||
if (seen.has(a)) {
|
||||
collisions.push(`alias "${a}" claimed by both "${seen.get(a)}" and "${e.canonical}"`);
|
||||
} else {
|
||||
seen.set(a, e.canonical);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.equal(collisions.length, 0,
|
||||
`duplicate aliases in command registry:\n ${collisions.join('\n ')}`);
|
||||
});
|
||||
|
||||
test('no canonical command appears twice', () => {
|
||||
const cjs = require(path.join(
|
||||
REPO_ROOT, 'get-shit-done', 'bin', 'lib', 'command-aliases.generated.cjs',
|
||||
));
|
||||
const seen = new Set();
|
||||
const duplicates = [];
|
||||
for (const key of [
|
||||
'STATE_COMMAND_ALIASES',
|
||||
'VERIFY_COMMAND_ALIASES',
|
||||
'INIT_COMMAND_ALIASES',
|
||||
'PHASE_COMMAND_ALIASES',
|
||||
'PHASES_COMMAND_ALIASES',
|
||||
'VALIDATE_COMMAND_ALIASES',
|
||||
'ROADMAP_COMMAND_ALIASES',
|
||||
'NON_FAMILY_COMMAND_ALIASES',
|
||||
]) {
|
||||
for (const e of cjs[key]) {
|
||||
if (seen.has(e.canonical)) duplicates.push(e.canonical);
|
||||
seen.add(e.canonical);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(duplicates, [],
|
||||
`canonical command appears in more than one family: ${duplicates.join(', ')}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Suite 5: build-hooks atomicity (idempotence + no orphan staging) ───────
|
||||
|
||||
describe('feat-3598: build-hooks.js is idempotent and leaves no staging residue', () => {
|
||||
const HOOKS_DIR = path.join(REPO_ROOT, 'hooks');
|
||||
const DIST_DIR = path.join(HOOKS_DIR, 'dist');
|
||||
|
||||
// A baseline snapshot taken once before either run. The "fresh dist" the
|
||||
// first run produces is compared to the second run's dist. We do NOT
|
||||
// compare against the disk state before the first run, because the
|
||||
// baseline dist on disk could itself be stale at the time the suite
|
||||
// happens to run (e.g. a fresh clone with an old build artifact).
|
||||
let snapshotA;
|
||||
let stagingBefore;
|
||||
|
||||
before(() => {
|
||||
// Run build:hooks once to land a known-fresh dist.
|
||||
const r1 = spawnSync(process.execPath, [path.join('scripts', 'build-hooks.js')], {
|
||||
cwd: REPO_ROOT,
|
||||
encoding: 'utf-8',
|
||||
timeout: 60000,
|
||||
});
|
||||
assert.equal(r1.status, 0,
|
||||
`build-hooks first run must exit 0; stderr=${r1.stderr.slice(0, 400)}`);
|
||||
snapshotA = snapshotDir(DIST_DIR);
|
||||
assert.ok(snapshotA.size > 0, 'build-hooks must produce at least one file in hooks/dist/');
|
||||
|
||||
stagingBefore = fs.readdirSync(HOOKS_DIR)
|
||||
.filter((n) => n.startsWith('.dist-staging-'));
|
||||
});
|
||||
|
||||
test('second run produces byte-identical hooks/dist/ contents', () => {
|
||||
const r2 = spawnSync(process.execPath, [path.join('scripts', 'build-hooks.js')], {
|
||||
cwd: REPO_ROOT,
|
||||
encoding: 'utf-8',
|
||||
timeout: 60000,
|
||||
});
|
||||
assert.equal(r2.status, 0,
|
||||
`build-hooks second run must exit 0; stderr=${r2.stderr.slice(0, 400)}`);
|
||||
|
||||
const snapshotB = snapshotDir(DIST_DIR);
|
||||
assert.equal(snapshotB.size, snapshotA.size,
|
||||
`hooks/dist/ file count must be stable: a=${snapshotA.size} b=${snapshotB.size}`);
|
||||
for (const [rel, hashA] of snapshotA) {
|
||||
const hashB = snapshotB.get(rel);
|
||||
assert.equal(hashB, hashA,
|
||||
`hooks/dist/${rel} changed between consecutive runs (atomic-write must produce identical output)`);
|
||||
}
|
||||
});
|
||||
|
||||
test('no orphaned .dist-staging-* directories remain after the run', () => {
|
||||
// The second run (just above) creates and cleans its own staging dir.
|
||||
// Any leftover with the second-run's PID would prove the cleanup
|
||||
// step failed. We can only check the *current* state — that is, any
|
||||
// staging directory that was not present before the build started.
|
||||
const stagingAfter = fs.readdirSync(HOOKS_DIR)
|
||||
.filter((n) => n.startsWith('.dist-staging-'));
|
||||
const orphaned = stagingAfter.filter((n) => !stagingBefore.includes(n));
|
||||
assert.deepEqual(orphaned, [],
|
||||
`build-hooks left orphaned staging directories: ${orphaned.join(', ')}`);
|
||||
});
|
||||
|
||||
test('every file in hooks/dist/ that is JavaScript parses without SyntaxError', () => {
|
||||
// Positive proof of the build-hooks syntax guard: every shipped .js
|
||||
// file must parse. If a SyntaxError survives the guard and lands in
|
||||
// dist/, this assertion fails with the file named.
|
||||
const vm = require('node:vm');
|
||||
for (const [rel] of snapshotDir(DIST_DIR)) {
|
||||
if (!rel.endsWith('.js')) continue;
|
||||
const src = fs.readFileSync(path.join(DIST_DIR, rel), 'utf-8');
|
||||
assert.doesNotThrow(
|
||||
() => new vm.Script(src, { filename: rel }),
|
||||
`hooks/dist/${rel} has a SyntaxError — build-hooks syntax guard let it through`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user