diff --git a/tests/feat-3598-generator-correctness.test.cjs b/tests/feat-3598-generator-correctness.test.cjs new file mode 100644 index 000000000..5a8c74604 --- /dev/null +++ b/tests/feat-3598-generator-correctness.test.cjs @@ -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. */ +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`, + ); + } + }); +});