'use strict'; /** * Behavioral tests for scripts/gen-features.cjs — the docs/FEATURES.md * generator and fragment gate (#3840). * * TWO LAYERS, deliberately: * * 1. Pure-function tests over the generator's INTERMEDIATE REPRESENTATION — * the parsed fragment records, the assembled group/section ordering, and * the typed violation objects. CONTRIBUTING.md forbids raw text matching * on outputs, and for a generator that rule bites hardest: asserting on * rendered markdown would pin cosmetics and miss semantics. The IR is the * contract; the markdown is one projection of it. * * 2. CLI tests that drive the real script as a subprocess against SYNTHETIC * corpora in a temp dir, asserting exit codes and the typed `--json` * report — never stderr prose. * * WHAT THIS FILE DELIBERATELY DOES NOT ASSERT: the number of features, the * highest id, or the full list of groups. Every one of those is a shared * mutable cell that every feature-adding PR would have to edit — exactly the * merge-conflict class #3840 exists to delete. Pinning a count here would move * the conflict from docs/FEATURES.md into this file. Invariants only. */ const { test, describe } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const fc = require('./helpers/fast-check-setup.cjs'); const { createTempDir, cleanup } = require('./helpers.cjs'); const { copyScriptWithDeps } = require('./helpers/copy-script-fixture.cjs'); const { runNode, OUTCOME } = require('./helpers/process-seam.cjs'); const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); const gen = require('../scripts/gen-features.cjs'); const { REASON, MIN_BODY_HEADING_DEPTH, START_MARKER, END_MARKER, slugify, parseFrontmatter, renderFrontmatter, shallowBodyHeadings, forgedRegionMarker, FORBIDDEN_BODY_SUBSTRINGS, defaultOrder, buildCorpus, renderFeatures, spliceIntoFeatures, } = gen; const REPO_ROOT = path.resolve(__dirname, '..'); const SCRIPT_REL = path.join('scripts', 'gen-features.cjs'); /** Seed pinned to the issue number so a failure is reproducible by name. */ const FC_SEED = 3840; const FC_RUNS = 200; // --------------------------------------------------------------------------- // Fixture harness // --------------------------------------------------------------------------- /** * Build a throwaway repo whose docs/features/ contains exactly `fragments` * (name -> text) and whose scripts/ holds a copy of the generator plus its * transitive relative-require graph. * * The generator resolves its scan root from `path.join(__dirname, '..')`, so a * fixture run of the REAL script would scan the real repo. Copying is what * makes a synthetic corpus possible at all. */ function makeRepo(t, fragments, { notes = {}, doc, extraDocs = {} } = {}) { // helpers.cleanup (not raw fs.rmSync) carries the Windows-EBUSY retry budget. const root = createTempDir('gsd-features-'); t.after(() => cleanup(root)); fs.mkdirSync(path.join(root, 'docs', 'features', '_groups'), { recursive: true }); copyScriptWithDeps(REPO_ROOT, root, SCRIPT_REL); for (const [name, body] of Object.entries(fragments)) { fs.writeFileSync(path.join(root, 'docs', 'features', name), body); } for (const [name, body] of Object.entries(notes)) { fs.writeFileSync(path.join(root, 'docs', 'features', '_groups', name), body); } for (const [rel, body] of Object.entries(extraDocs)) { const abs = path.join(root, 'docs', rel); fs.mkdirSync(path.dirname(abs), { recursive: true }); fs.writeFileSync(abs, body); } fs.writeFileSync( path.join(root, 'docs', 'FEATURES.md'), doc === undefined ? `# Features\n\n${START_MARKER}\n${END_MARKER}\n` : doc, ); return root; } /** Run the generator in `root`. Never throws — the seam returns data. */ function run(root, args = []) { return runNode([path.join(root, SCRIPT_REL), ...args], { cwd: root, timeoutMs: PROBE_TIMEOUT_MS, }); } /** Run with `--json` and return the parsed typed report. */ function report(root) { const res = run(root, ['--json']); assert.equal(res.outcome, OUTCOME.EXITED); return JSON.parse(res.stdout); } /** Reasons present in a `--json` report, deduplicated and sorted. */ function reasonsIn(rep) { return [...new Set(rep.violations.map((v) => v.reason))].sort(); } const fragment = (id, title, group, body = '**Purpose:** x.', extra = {}) => renderFrontmatter({ id, title, group, ...extra }, `${body}\n`); /** * Reason string when this host cannot create a symlink, else `false`. * * Unprivileged Windows without Developer Mode rejects `symlinkSync` with * EPERM. A bare `return` there would be a PASS, not a skip — the documented * trap — so the skip is declared to the runner instead. */ function symlinkSkip() { const probe = createTempDir('gsd-symlink-probe-'); try { fs.writeFileSync(path.join(probe, 'target'), 'x'); fs.symlinkSync(path.join(probe, 'target'), path.join(probe, 'link')); return false; } catch { return 'this host cannot create symlinks (unprivileged Windows)'; } finally { // helpers.cleanup, not fs.rmSync — it carries the Windows-EBUSY retry budget. cleanup(probe); } } // --------------------------------------------------------------------------- // Frontmatter parser — the seam every fragment passes through // --------------------------------------------------------------------------- describe('parseFrontmatter', () => { test('splits a well-formed fragment into typed data and body', () => { const { data, body } = parseFrontmatter( '---\nid: 168\ntitle: Runtime Identity\ngroup: v1.7.0 Features\n---\n\n**Purpose:** x.\n', ); assert.deepEqual(data, { id: '168', title: 'Runtime Identity', group: 'v1.7.0 Features' }); assert.equal(body, '**Purpose:** x.\n'); }); test('reports a missing opening fence as data:null rather than guessing', () => { assert.equal(parseFrontmatter('**Purpose:** x.\n').data, null); }); test('reports an unterminated fence as data:null', () => { assert.equal(parseFrontmatter('---\nid: 1\ntitle: X\n').data, null); }); test('normalizes CRLF so a Windows-authored fragment parses identically', () => { const lf = parseFrontmatter('---\nid: 1\ntitle: X\ngroup: G\n---\n\nbody\n'); const crlf = parseFrontmatter('---\r\nid: 1\r\ntitle: X\r\ngroup: G\r\n---\r\n\r\nbody\r\n'); assert.deepEqual(crlf, lf); }); test('preserves a colon inside a value (only the FIRST colon separates)', () => { const { data } = parseFrontmatter('---\ntitle: Ship: the final step\n---\n\nb\n'); assert.deepEqual(data, { title: 'Ship: the final step' }); }); test('round-trips values that need quoting, so no fragment can be lossy', () => { for (const value of [' padded ', '"quoted"', '', 'plain']) { const text = renderFrontmatter({ title: value }, 'body\n'); assert.equal(parseFrontmatter(text).data.title, value, `value ${JSON.stringify(value)}`); } }); test('PROPERTY: parse(render(data, body)) === {data, body} for any scalar record', () => { const key = fc .tuple( fc.constantFrom('a', 'b', 'c', 'i', 'k', 'x', 'z'), fc.stringMatching(/^[a-z0-9_-]{0,8}$/), ) .map(([head, tail]) => head + tail); // Values exclude newlines and lone CR: a newline would forge a second // frontmatter line, which is a different (rejected) document, not a // round-trip failure. Everything else — including quotes, colons and // padding — must survive. const value = fc.string({ maxLength: 40 }).filter((s) => !/[\r\n]/.test(s)); const body = fc.string({ maxLength: 60 }).map((s) => `${s.replace(/\r/g, '')}\n`); fc.assert( fc.property(fc.dictionary(key, value, { maxKeys: 6 }), body, (data, b) => { const parsed = parseFrontmatter(renderFrontmatter(data, b)); // fc.dictionary yields null-prototype objects; the parser yields plain // ones. Spread both so the comparison is about CONTENT, not prototype. assert.deepEqual({ ...parsed.data }, { ...data }); assert.equal(parsed.body, b); }), { seed: FC_SEED, numRuns: FC_RUNS }, ); }); }); // --------------------------------------------------------------------------- // Anchor derivation — the thing that keeps inbound #N links resolving // --------------------------------------------------------------------------- describe('slugify', () => { test('reproduces the live anchors the migration had to freeze', () => { const cases = [ ['1. Project Initialization', '1-project-initialization'], ['27b. Existing Codebase Onboarding', '27b-existing-codebase-onboarding'], ['6.5. Ship', '65-ship'], ['69. STATE.md Consistency Gates', '69-statemd-consistency-gates'], ['70. Autonomous `--to N` Flag', '70-autonomous---to-n-flag'], ['101. Hard Stop Safety Gates in /gsd-progress --next', '101-hard-stop-safety-gates-in-gsd-progress---next'], ['152. Statusline Token Count & Git Segment', '152-statusline-token-count--git-segment'], ['149. Embeddable Orchestration System (Host-Integration Interface)', '149-embeddable-orchestration-system-host-integration-interface'], ['Core Features', 'core-features'], ['v1.42.1 Features', 'v1421-features'], ]; for (const [heading, anchor] of cases) assert.equal(slugify(heading), anchor, heading); }); test('PROPERTY: an anchor never contains a character outside [\\w-]', () => { fc.assert( fc.property(fc.string({ maxLength: 60 }), (s) => { assert.equal(/^[\w-]*$/.test(slugify(s)), true, JSON.stringify(s)); }), { seed: FC_SEED, numRuns: FC_RUNS }, ); }); test('PROPERTY: slugify is idempotent on its own output', () => { fc.assert( fc.property(fc.stringMatching(/^[A-Za-z0-9 .&()`-]{0,40}$/), (s) => { assert.equal(slugify(slugify(s)), slugify(s)); }), { seed: FC_SEED, numRuns: FC_RUNS }, ); }); }); // --------------------------------------------------------------------------- // Body heading depth — boundary coverage at limit-1 / limit / limit+1 // --------------------------------------------------------------------------- describe('shallowBodyHeadings', () => { test('the guarded limit is h4', () => { assert.equal(MIN_BODY_HEADING_DEPTH, 4); }); test('BOUNDARY limit-1 (h2) is rejected — it would forge a group', () => { const hits = shallowBodyHeadings('intro\n\n## Forged Group\n'); assert.deepEqual(hits, [{ line: 3, depth: 2 }]); }); test('BOUNDARY limit (h3) is rejected — it would forge a sibling section', () => { const hits = shallowBodyHeadings('intro\n\n### 999. Forged Section\n'); assert.deepEqual(hits, [{ line: 3, depth: 3 }]); }); test('BOUNDARY limit+1 (h4) is accepted — it nests inside the feature', () => { assert.deepEqual(shallowBodyHeadings('intro\n\n#### Sub-heading\n'), []); }); test('h1 is rejected too (depth 1 is shallower still)', () => { assert.deepEqual(shallowBodyHeadings('# Title\n'), [{ line: 1, depth: 1 }]); }); test('a heading-shaped line inside a fenced block is content, not structure', () => { assert.deepEqual(shallowBodyHeadings('a\n\n```md\n## Sample\n### Sample\n```\n\nb\n'), []); assert.deepEqual(shallowBodyHeadings('a\n\n~~~\n## Sample\n~~~\n'), []); }); test('a longer fence is not closed by a shorter one inside it', () => { assert.deepEqual(shallowBodyHeadings('````\n```\n## Sample\n```\n````\n'), []); }); test('a `#` with no following space is not a heading', () => { assert.deepEqual(shallowBodyHeadings('#hashtag\n'), []); }); }); // --------------------------------------------------------------------------- // Ordering // --------------------------------------------------------------------------- describe('forgedRegionMarker', () => { test('names the marker a body forges, so the violation can carry it', () => { assert.equal(forgedRegionMarker('a\n\nb'), ''), '` // through, and `indexOf`/`lastIndexOf` in the splice would still find the // real substring inside it. Prefixes close that gap. assert.deepEqual([...FORBIDDEN_BODY_SUBSTRINGS], [''), '\n\nsmuggled tail'), }); const rep = report(root); assert.deepEqual(reasonsIn(rep), [REASON.BODY_FORGES_REGION_MARKER]); assert.equal(rep.violations[0].marker, ''), }); assert.deepEqual(reasonsIn(report(root)), [REASON.BODY_FORGES_REGION_MARKER]); }); test('a group note that forges a region marker is rejected too', (t) => { const root = makeRepo( t, { 'a.md': fragment('1', 'A', 'Core Features') }, { notes: { 'core-features.md': renderFrontmatter({ group: 'Core Features' }, '\n') } }, ); assert.deepEqual(reasonsIn(report(root)), [REASON.BODY_FORGES_REGION_MARKER]); }); test('a stray END marker inside the region cannot shrink it — the LAST one wins', (t) => { // Defence in depth: the fragment gate above rejects a forged marker at the // source, but a marker that reaches docs/FEATURES.md some other way (a hand // edit, a bad merge) must still not become the de facto boundary and freeze // everything after it as hand-authored. const doc = [ '# Features', '', START_MARKER, 'stale body', END_MARKER, 'MUST NOT SURVIVE', END_MARKER, '', '## Related', '', ].join('\n'); const root = makeRepo(t, { 'a.md': fragment('1', 'Alpha', 'G') }, { doc }); assert.equal(run(root, ['--write']).exitCode, 0); const after = fs.readFileSync(path.join(root, 'docs', 'FEATURES.md'), 'utf8'); assert.equal(after.includes('MUST NOT SURVIVE'), false); assert.equal(after.includes('## Related'), true, 'real trailing content is preserved'); // Idempotent: a second write is a fixed point, so the forgery cannot recur. assert.equal(run(root, ['--check']).exitCode, 0); }); test('a symlinked fragment is refused, not followed', { skip: symlinkSkip() }, (t) => { const root = makeRepo(t, { 'real.md': fragment('1', 'Real', 'G') }); const secret = path.join(root, 'secret.txt'); fs.writeFileSync(secret, 'SUPER-SECRET-BYTES\n'); fs.symlinkSync(secret, path.join(root, 'docs', 'features', 'evil.md')); const rep = report(root); assert.deepEqual(reasonsIn(rep), [REASON.DIRENT_NOT_REGULAR_FILE]); assert.equal(rep.violations[0].file, 'docs/features/evil.md'); // Fail-closed keeps the bytes out of the document; belt and braces, assert it. assert.equal(run(root, ['--write']).exitCode, 1); const doc = fs.readFileSync(path.join(root, 'docs', 'FEATURES.md'), 'utf8'); assert.equal(doc.includes('SUPER-SECRET-BYTES'), false); }); test('a symlinked group note is refused too', { skip: symlinkSkip() }, (t) => { const root = makeRepo(t, { 'a.md': fragment('1', 'A', 'G') }); const secret = path.join(root, 'secret.txt'); fs.writeFileSync(secret, 'SUPER-SECRET-BYTES\n'); fs.symlinkSync(secret, path.join(root, 'docs', 'features', '_groups', 'g.md')); assert.deepEqual(reasonsIn(report(root)), [REASON.DIRENT_NOT_REGULAR_FILE]); }); test('an empty corpus is not a crash', (t) => { const root = makeRepo(t, {}); const rep = report(root); assert.equal(rep.featureCount, 0); assert.equal(rep.groupCount, 0); assert.deepEqual(rep.violations, []); }); });