diff --git a/scripts/gen-skill-docs.ts b/scripts/gen-skill-docs.ts index 6028ea4ea..f5dd794b2 100644 --- a/scripts/gen-skill-docs.ts +++ b/scripts/gen-skill-docs.ts @@ -962,6 +962,20 @@ function findTemplates(): string[] { } const ALL_HOSTS: Host[] = ALL_HOST_NAMES as Host[]; + +/** + * The generator's whole executable body. Import-purity contract: importing + * this module must NEVER touch the tree — test/gen-skill-docs.test.ts pulls + * assertSinglePreamble via require(), test/catalog-trim.test.ts imports + * helpers, and before this guard existed every such import regenerated all + * 71 SKILL.md in place at module-load time (the root cause of half the + * TREE_MUTATING serial shard; hazard class #2532). Pinned by + * test/gen-skill-docs-import-purity.test.ts. + * + * Returns the process exit code. Kept synchronous so the module stays + * require()-able (see the llms.txt IIFE note below). + */ +export function main(): number { const hostsToRun: Host[] = HOST_ARG_VAL === 'all' ? ALL_HOSTS : [HOST]; const failures: { host: string; error: Error }[] = []; @@ -1077,7 +1091,7 @@ for (const currentHost of hostsToRun) { if (DRY_RUN && hasChanges) { console.error(`\nGenerated SKILL.md files are stale (${currentHost} host). Run: bun run gen:skill-docs --host ${currentHost}`); - if (HOST_ARG_VAL !== 'all') process.exit(1); + if (HOST_ARG_VAL !== 'all') return 1; failures.push({ host: currentHost, error: new Error('Stale files detected') }); } @@ -1112,7 +1126,7 @@ for (const currentHost of hostsToRun) { // in the same commit" is only a real gate if every host failure is fatal here. if (failures.length > 0 && HOST_ARG_VAL === 'all') { console.error(`\n${failures.length} host(s) failed: ${failures.map(f => f.host).join(', ')}`); - process.exit(1); + return 1; } // Single host dry-run failure already handled above @@ -1150,3 +1164,14 @@ if (!DRY_RUN) { } })(); } + +return 0; +} + +if (import.meta.main) { + // Failure exits are immediate (matching the old top-level process.exit + // behavior); success leaves the event loop to drain so the llms.txt + // fire-and-forget IIFE inside main() finishes its write. + const code = main(); + if (code !== 0) process.exit(code); +} diff --git a/test/gen-skill-docs-import-purity.test.ts b/test/gen-skill-docs-import-purity.test.ts new file mode 100644 index 000000000..e109b73ba --- /dev/null +++ b/test/gen-skill-docs-import-purity.test.ts @@ -0,0 +1,52 @@ +/** + * Importing scripts/gen-skill-docs.ts must not touch the tree. + * + * Before the main() guard, the generator's whole body executed at module + * load: any `import`/`require` of it (test/gen-skill-docs.test.ts pulls + * assertSinglePreamble; test/catalog-trim.test.ts imports helpers) + * regenerated all 71 SKILL.md in place — the root cause of half the + * TREE_MUTATING serial-shard entries (hazard class #2532). A regression + * here silently re-poisons parallel shards with mid-window tree rewrites. + * + * The probe runs in a subprocess so a regression can't contaminate THIS + * process, and asserts on mtimes rather than git status — the working tree + * may legitimately carry uncommitted SKILL.md edits while this runs; what + * must not happen is the import WRITING files. + */ +import { describe, expect, test } from 'bun:test'; +import * as path from 'node:path'; + +const ROOT = path.resolve(__dirname, '..'); + +describe('gen-skill-docs import purity', () => { + test('importing the module neither writes SKILL.md nor runs main()', () => { + const probe = ` + const fs = require('node:fs'); + const path = require('node:path'); + const ROOT = ${JSON.stringify(ROOT)}; + const targets = [ + path.join(ROOT, 'ship', 'SKILL.md'), + path.join(ROOT, 'review', 'SKILL.md'), + path.join(ROOT, 'gstack', 'llms.txt'), + ].filter((p) => fs.existsSync(p)); + if (targets.length === 0) throw new Error('probe rot: no generated targets found'); + const before = targets.map((p) => fs.statSync(p).mtimeMs); + const mod = require(path.join(ROOT, 'scripts', 'gen-skill-docs.ts')); + if (typeof mod.main !== 'function') throw new Error('main() export missing'); + const after = targets.map((p) => fs.statSync(p).mtimeMs); + for (let i = 0; i < targets.length; i++) { + if (before[i] !== after[i]) throw new Error('import mutated ' + targets[i]); + } + console.log('IMPORT_PURE'); + `; + const out = Bun.spawnSync(['bun', '-e', probe], { cwd: ROOT }); + const stdout = out.stdout.toString(); + const stderr = out.stderr.toString(); + expect(stderr, stderr).not.toContain('import mutated'); + expect(stdout).toContain('IMPORT_PURE'); + // The import must also not have run generation output (the "GENERATED:" + // lines main() prints) — load-time execution is the exact regression. + expect(stdout).not.toContain('GENERATED:'); + expect(out.exitCode).toBe(0); + }); +});