* chore(#2992): widen the when= grammar and key the section manifest per workflow Epic #1671 Phase 6.1. Two blockers stopped the fragment model reaching any file beyond execute-phase.md: the when= vocabulary was frozen at 4 atoms (3 execute-phase-specific), and the section manifest was single-workflow by construction with 'execute-phase' hardcoded into buildSectionManifestField. - widen WHEN_VOCABULARY 4 -> 14 via a coordinated ADR-1671 amendment; the grammar stays CLOSED (one atom, no operators, negation or nesting) and WHEN_PREDICATES stays a hand-written literal map, never deriving a predicate from its atom string - InvocationFacts gains flags: ReadonlySet<string> plus three computed state booleans; add the missing reverse vocabulary/predicate parity guard - key the manifest artifact per workflow; a stale flat {sections:[...]} artifact now fails shape validation instead of being misattributed - wire the field into six init entry points and parse the flags each needs An atom ships only with both a real consuming section and a fact the init seam actually computes. Six surveyed atoms are withheld because their workflows have no dedicated init entry point; an atom without a computed fact evaluates false forever and silently disables its own section. Fixes a defect found while wiring: parseNamedArgs always materializes a boolean flag key, so folding its false into the absent sentinel is required or every flag reads as present and gating is silently always-on. Also resolves ADR-1671:194 by measurement: --mvp stays unmarkable, because its interleaved sites are always-run flag resolution and a ~340 byte block that already delegates lazily. Refs #2992 * fix(#2992): treat any falsy option value as an absent flag and reject unsafe manifest read paths Findings from two orthogonal reviews (Claude /code-review + an isolated adversarial pass); both independently reproduced the first one. - MAJOR: the flags-builder treated only `undefined` as absent, but parseNamedArgs yields `null` for an absent value-flag and `false` for an absent boolean-flag, so `--granularity` read as present on every plan-phase invocation. Fixed at the root: a flag is present iff its option value is truthy. The six per-handler `|| undefined` folds are now redundant and removed, which also closes the duplicate-translation and missed-onboard-handler findings. - MAJOR: state:needs-codebase-map had zero coverage. Added unit, property and real-CLI integration tests. - MINOR: reject absolute, UNC/drive and `..`-traversing `read` paths in the manifest, degrading the whole load to null like every other shape violation. Verified: `/etc/passwd` previously reached section_manifest.read. - MINOR: corrected a stale "4 to 20" doc comment; the vocabulary is 14. Refs #2992 * test(#2992): update the generator suite for the per-workflow manifest shape The remote matrix went red with 5 unique failures, identical on linux-node22 and linux-node24, all in tests/gen-section-manifest.test.cjs. Re-keying the artifact to {workflows:{...}} left this suite asserting the old flat {sections:[...]} shape; nothing else in the tree still does. - three tests read manifest.sections.length, now undefined; retargeted at workflows.<name> with their original intent preserved (a fenced or loop-host marker still asserts NO section is produced, not merely a changed count) - the stale-manifest test wrote its fixture in the OLD shape, so it tripped shape validation and stopped exercising staleness at all. Its fixture is now valid-but-mismatched so FAIL_STALE is genuinely reached again. - added the coverage that exposed: a pre-6.1 flat artifact must report FAIL_MANIFEST_MALFORMED_SHAPE. That is the real upgrade path for an installed tree and nothing covered it. Refs #2992 * chore(#2992): backfill changeset pr number to 3013 --------- Co-authored-by: sim <sim@local>
601 lines
24 KiB
JavaScript
601 lines
24 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* gen-section-manifest.cjs drift-guard tests — 50-test-matrix.md rows 29-41
|
|
* (issue #2932, epic #1671 Phase 5,
|
|
* `.gsd/phase/chore-2932-init-section-manifest/50-test-matrix.md` section D).
|
|
*
|
|
* Every test spawns the real CLI (execFileSync) against a temp fixture tree
|
|
* shaped like the real repo (`<root>/gsd-core/workflows/<name>.md` +
|
|
* `<root>/gsd-core/workflows/<name>/steps/<id>.md`), using the generator's
|
|
* `--workflows-dir`/`--manifest-path`/`--repo-root` overrides — no fs
|
|
* monkeypatching except rows 40/41, which inject the exact fault
|
|
* `writeManifestAtomically` cannot otherwise be made to hit (CONTRIBUTING.md
|
|
* / CLAUDE.md cross-platform IO-failure rule: monkeypatch the `fs` method,
|
|
* restore via `t.after()`, never `chmod 0o000`).
|
|
*/
|
|
|
|
const { describe, test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const { execFileSync } = require('node:child_process');
|
|
|
|
const { createTempDir, cleanup } = require('./helpers.cjs');
|
|
const {
|
|
buildFreshManifest,
|
|
writeManifestAtomically,
|
|
loadWorkflowFragmentsLib,
|
|
checkReport,
|
|
ManifestBuildError,
|
|
REASON,
|
|
WORKFLOW_FRAGMENTS_LIB_PATH,
|
|
} = require('../scripts/gen-section-manifest.cjs');
|
|
|
|
const ROOT = path.resolve(__dirname, '..');
|
|
const SCRIPT = path.join(ROOT, 'scripts', 'gen-section-manifest.cjs');
|
|
|
|
const STACK_FRAME_RE = /\n\s+at\s+\S+\s+\(.*:\d+:\d+\)/;
|
|
|
|
// ─── Fixture builder ───────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Build a `<tmpRoot>/gsd-core/workflows/` tree containing one workflow file
|
|
* plus (optionally) its `steps/` directory, matching the real repo's
|
|
* relative shape exactly so `--repo-root <tmpRoot>` produces the same
|
|
* `gsd-core/workflows/...` POSIX `read` paths the real generator emits.
|
|
*
|
|
* @param {string} tmpRoot
|
|
* @param {string} workflowName - e.g. "sample" -> gsd-core/workflows/sample.md
|
|
* @param {string} sourceContent
|
|
* @param {{[stepFileName: string]: string}} [stepFiles]
|
|
* @returns {{ workflowsDir: string, manifestPath: string }}
|
|
*/
|
|
function buildFixture(tmpRoot, workflowName, sourceContent, stepFiles = {}) {
|
|
const workflowsDir = path.join(tmpRoot, 'gsd-core', 'workflows');
|
|
fs.mkdirSync(workflowsDir, { recursive: true });
|
|
fs.writeFileSync(path.join(workflowsDir, `${workflowName}.md`), sourceContent, 'utf8');
|
|
|
|
if (Object.keys(stepFiles).length > 0) {
|
|
const stepsDir = path.join(workflowsDir, workflowName, 'steps');
|
|
fs.mkdirSync(stepsDir, { recursive: true });
|
|
for (const [name, content] of Object.entries(stepFiles)) {
|
|
fs.writeFileSync(path.join(stepsDir, name), content, 'utf8');
|
|
}
|
|
}
|
|
|
|
return { workflowsDir, manifestPath: path.join(workflowsDir, 'section-manifest.json') };
|
|
}
|
|
|
|
/** A single well-formed marker + matching step-file body. */
|
|
function markerSource(id, when, refFile) {
|
|
return [
|
|
'# Sample workflow',
|
|
'',
|
|
`<!-- gsd:section id="${id}" when="${when}" -->`,
|
|
`Read and execute \`gsd-core/workflows/sample/steps/${refFile}\`.`,
|
|
'<!-- /gsd:section -->',
|
|
'',
|
|
].join('\n');
|
|
}
|
|
|
|
/**
|
|
* @param {string[]} args
|
|
* @param {string} cwd
|
|
* @returns {{code: number, stdout: string, stderr: string}}
|
|
*/
|
|
function runGenSectionManifest(args, cwd = ROOT) {
|
|
try {
|
|
const stdout = execFileSync(process.execPath, [SCRIPT, ...args], {
|
|
cwd,
|
|
encoding: 'utf8',
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
timeout: 30000,
|
|
});
|
|
return { code: 0, stdout, stderr: '' };
|
|
} catch (err) {
|
|
return {
|
|
code: err.status ?? 1,
|
|
stdout: err.stdout ? err.stdout.toString() : '',
|
|
stderr: err.stderr ? err.stderr.toString() : '',
|
|
};
|
|
}
|
|
}
|
|
|
|
function parseJsonReport(stdout) {
|
|
return JSON.parse(stdout.trim());
|
|
}
|
|
|
|
// ─── D. Generator drift-guard (rows 29-41) ─────────────────────────────────
|
|
|
|
describe('gen-section-manifest.cjs --check / --write (matrix D)', () => {
|
|
test('checkExitsZeroWhenManifestMatchesSource (row 29)', (t) => {
|
|
const tmpRoot = createTempDir('gen-section-manifest-');
|
|
t.after(() => cleanup(tmpRoot));
|
|
|
|
const { workflowsDir, manifestPath } = buildFixture(
|
|
tmpRoot,
|
|
'sample',
|
|
markerSource('handle-x', 'always', 'handle-x.md'),
|
|
{ 'handle-x.md': '<step name="handle_x">\nbody\n</step>\n' },
|
|
);
|
|
const fresh = buildFreshManifest(workflowsDir, tmpRoot);
|
|
fs.writeFileSync(manifestPath, JSON.stringify(fresh, null, 2) + '\n', 'utf8');
|
|
|
|
const r = runGenSectionManifest([
|
|
'--check', '--workflows-dir', workflowsDir, '--manifest-path', manifestPath, '--repo-root', tmpRoot,
|
|
]);
|
|
assert.equal(r.code, 0, `stderr: ${r.stderr}`);
|
|
});
|
|
|
|
test('checkExitsOneAndNamesFileWhenManifestIsStale (row 30)', (t) => {
|
|
const tmpRoot = createTempDir('gen-section-manifest-');
|
|
t.after(() => cleanup(tmpRoot));
|
|
|
|
const { workflowsDir, manifestPath } = buildFixture(
|
|
tmpRoot,
|
|
'sample',
|
|
markerSource('handle-x', 'always', 'handle-x.md'),
|
|
{ 'handle-x.md': '<step name="handle_x">\nbody\n</step>\n' },
|
|
);
|
|
// Stale: valid {workflows:{...}} shape, but "when" no longer matches the
|
|
// source's marker (must reach FAIL_STALE, not trip the shape check).
|
|
fs.writeFileSync(
|
|
manifestPath,
|
|
JSON.stringify({ workflows: { sample: [{ id: 'handle-x', when: 'flag:--wave', read: 'gsd-core/workflows/sample/steps/handle-x.md' }] } }, null, 2) + '\n',
|
|
'utf8',
|
|
);
|
|
|
|
const r = runGenSectionManifest([
|
|
'--check', '--json', '--workflows-dir', workflowsDir, '--manifest-path', manifestPath, '--repo-root', tmpRoot,
|
|
]);
|
|
assert.equal(r.code, 1);
|
|
assert.doesNotMatch(r.stderr, STACK_FRAME_RE, 'no bare stack trace for a stale manifest');
|
|
const report = parseJsonReport(r.stdout);
|
|
assert.equal(report.ok, false);
|
|
assert.equal(report.reason, REASON.FAIL_STALE);
|
|
assert.equal(report.subject, manifestPath, 'report must name the stale manifest file');
|
|
});
|
|
|
|
test('writeThenCheckRoundTripsClean (row 31)', (t) => {
|
|
const tmpRoot = createTempDir('gen-section-manifest-');
|
|
t.after(() => cleanup(tmpRoot));
|
|
|
|
const { workflowsDir, manifestPath } = buildFixture(
|
|
tmpRoot,
|
|
'sample',
|
|
markerSource('handle-x', 'always', 'handle-x.md'),
|
|
{ 'handle-x.md': '<step name="handle_x">\nbody\n</step>\n' },
|
|
);
|
|
|
|
const w = runGenSectionManifest([
|
|
'--write', '--workflows-dir', workflowsDir, '--manifest-path', manifestPath, '--repo-root', tmpRoot,
|
|
]);
|
|
assert.equal(w.code, 0, `stderr: ${w.stderr}`);
|
|
assert.ok(fs.existsSync(manifestPath));
|
|
|
|
const c = runGenSectionManifest([
|
|
'--check', '--workflows-dir', workflowsDir, '--manifest-path', manifestPath, '--repo-root', tmpRoot,
|
|
]);
|
|
assert.equal(c.code, 0, '--check must be clean immediately after --write');
|
|
});
|
|
|
|
test('checkFailsCleanlyWhenManifestAbsent (row 32)', (t) => {
|
|
const tmpRoot = createTempDir('gen-section-manifest-');
|
|
t.after(() => cleanup(tmpRoot));
|
|
|
|
const { workflowsDir, manifestPath } = buildFixture(
|
|
tmpRoot,
|
|
'sample',
|
|
markerSource('handle-x', 'always', 'handle-x.md'),
|
|
{ 'handle-x.md': '<step name="handle_x">\nbody\n</step>\n' },
|
|
);
|
|
assert.equal(fs.existsSync(manifestPath), false, 'sanity: manifest must not exist yet');
|
|
|
|
const r = runGenSectionManifest([
|
|
'--check', '--json', '--workflows-dir', workflowsDir, '--manifest-path', manifestPath, '--repo-root', tmpRoot,
|
|
]);
|
|
assert.equal(r.code, 1);
|
|
assert.doesNotMatch(r.stderr, STACK_FRAME_RE, 'an absent manifest must not crash, just fail closed');
|
|
const report = parseJsonReport(r.stdout);
|
|
assert.equal(report.ok, false);
|
|
assert.equal(report.reason, REASON.FAIL_MANIFEST_MISSING);
|
|
assert.equal(report.subject, manifestPath);
|
|
});
|
|
|
|
test('checkFailsCleanlyOnEmptyManifestFile (row 33)', (t) => {
|
|
const tmpRoot = createTempDir('gen-section-manifest-');
|
|
t.after(() => cleanup(tmpRoot));
|
|
|
|
const { workflowsDir, manifestPath } = buildFixture(
|
|
tmpRoot,
|
|
'sample',
|
|
markerSource('handle-x', 'always', 'handle-x.md'),
|
|
{ 'handle-x.md': '<step name="handle_x">\nbody\n</step>\n' },
|
|
);
|
|
fs.writeFileSync(manifestPath, '', 'utf8');
|
|
|
|
const r = runGenSectionManifest([
|
|
'--check', '--json', '--workflows-dir', workflowsDir, '--manifest-path', manifestPath, '--repo-root', tmpRoot,
|
|
]);
|
|
assert.equal(r.code, 1);
|
|
assert.doesNotMatch(r.stderr, STACK_FRAME_RE, 'no stack trace for an empty manifest file');
|
|
const report = parseJsonReport(r.stdout);
|
|
assert.equal(report.ok, false);
|
|
assert.equal(report.reason, REASON.FAIL_MANIFEST_UNPARSEABLE);
|
|
});
|
|
|
|
test('rejectsValidJsonThatIsNotTheExpectedShape (row 34)', (t) => {
|
|
const tmpRoot = createTempDir('gen-section-manifest-');
|
|
t.after(() => cleanup(tmpRoot));
|
|
|
|
const { workflowsDir, manifestPath } = buildFixture(
|
|
tmpRoot,
|
|
'sample',
|
|
markerSource('handle-x', 'always', 'handle-x.md'),
|
|
{ 'handle-x.md': '<step name="handle_x">\nbody\n</step>\n' },
|
|
);
|
|
|
|
for (const hostileJson of ['0', '"s"', '[]', 'null', 'true']) {
|
|
fs.writeFileSync(manifestPath, hostileJson, 'utf8');
|
|
const r = runGenSectionManifest([
|
|
'--check', '--json', '--workflows-dir', workflowsDir, '--manifest-path', manifestPath, '--repo-root', tmpRoot,
|
|
]);
|
|
assert.equal(r.code, 1, `hostile JSON ${hostileJson} must fail --check`);
|
|
assert.doesNotMatch(r.stderr, STACK_FRAME_RE, `hostile JSON ${hostileJson} must not crash`);
|
|
const report = parseJsonReport(r.stdout);
|
|
assert.equal(report.ok, false, `hostile JSON ${hostileJson}`);
|
|
assert.equal(report.reason, REASON.FAIL_MANIFEST_MALFORMED_SHAPE, `hostile JSON ${hostileJson}`);
|
|
}
|
|
});
|
|
|
|
test('rejectsPre6_1FlatSectionsShapeAsMalformed (upgrade-path supplemental: an installed tree still carrying the old flat {sections:[...]} artifact)', (t) => {
|
|
const tmpRoot = createTempDir('gen-section-manifest-');
|
|
t.after(() => cleanup(tmpRoot));
|
|
|
|
const { workflowsDir, manifestPath } = buildFixture(
|
|
tmpRoot,
|
|
'sample',
|
|
markerSource('handle-x', 'always', 'handle-x.md'),
|
|
{ 'handle-x.md': '<step name="handle_x">\nbody\n</step>\n' },
|
|
);
|
|
|
|
// Pre-6.1 committed artifact shape: a flat `{sections:[...]}` array with
|
|
// no `workflows` key at all. Must be rejected as malformed, never
|
|
// silently attributed to whichever workflow asks first (design row C4).
|
|
fs.writeFileSync(
|
|
manifestPath,
|
|
JSON.stringify({ sections: [{ id: 'handle-x', when: 'always', read: 'gsd-core/workflows/sample/steps/handle-x.md' }] }, null, 2) + '\n',
|
|
'utf8',
|
|
);
|
|
|
|
const r = runGenSectionManifest([
|
|
'--check', '--json', '--workflows-dir', workflowsDir, '--manifest-path', manifestPath, '--repo-root', tmpRoot,
|
|
]);
|
|
assert.equal(r.code, 1);
|
|
assert.doesNotMatch(r.stderr, STACK_FRAME_RE, 'a pre-6.1 flat manifest must not crash the generator');
|
|
const report = parseJsonReport(r.stdout);
|
|
assert.equal(report.ok, false);
|
|
assert.equal(report.reason, REASON.FAIL_MANIFEST_MALFORMED_SHAPE, 'pre-6.1 flat {sections:[...]} shape must be rejected, not mistaken for up-to-date or stale');
|
|
});
|
|
|
|
test('checkFailsWhenMarkerReferencesMissingStepFile (row 35)', (t) => {
|
|
const tmpRoot = createTempDir('gen-section-manifest-');
|
|
t.after(() => cleanup(tmpRoot));
|
|
|
|
// Marker present, but the step file it names is never created.
|
|
const { workflowsDir, manifestPath } = buildFixture(
|
|
tmpRoot,
|
|
'sample',
|
|
markerSource('handle-x', 'always', 'handle-x.md'),
|
|
{},
|
|
);
|
|
|
|
const r = runGenSectionManifest([
|
|
'--check', '--json', '--workflows-dir', workflowsDir, '--manifest-path', manifestPath, '--repo-root', tmpRoot,
|
|
]);
|
|
assert.equal(r.code, 1);
|
|
assert.doesNotMatch(r.stderr, STACK_FRAME_RE, 'a missing step file must not crash the generator');
|
|
const report = parseJsonReport(r.stdout);
|
|
assert.equal(report.ok, false);
|
|
assert.equal(report.reason, REASON.FAIL_MISSING_STEP_FILE);
|
|
assert.equal(
|
|
report.subject,
|
|
'gsd-core/workflows/sample/steps/handle-x.md',
|
|
'report must name the missing step file path',
|
|
);
|
|
});
|
|
|
|
test('checkFailsOnOrphanStepFile (row 36)', (t) => {
|
|
const tmpRoot = createTempDir('gen-section-manifest-');
|
|
t.after(() => cleanup(tmpRoot));
|
|
|
|
// handle-x.md is the legitimate marker target; stray.md is referenced by
|
|
// nothing (not the marker, not any prose in the parent or in handle-x.md).
|
|
const { workflowsDir, manifestPath } = buildFixture(
|
|
tmpRoot,
|
|
'sample',
|
|
markerSource('handle-x', 'always', 'handle-x.md'),
|
|
{
|
|
'handle-x.md': '<step name="handle_x">\nbody\n</step>\n',
|
|
'stray.md': '<step name="unreachable">\nnever referenced anywhere\n</step>\n',
|
|
},
|
|
);
|
|
|
|
const r = runGenSectionManifest([
|
|
'--check', '--json', '--workflows-dir', workflowsDir, '--manifest-path', manifestPath, '--repo-root', tmpRoot,
|
|
]);
|
|
assert.equal(r.code, 1);
|
|
assert.doesNotMatch(r.stderr, STACK_FRAME_RE, 'an orphan step file must not crash the generator');
|
|
const report = parseJsonReport(r.stdout);
|
|
assert.equal(report.ok, false);
|
|
assert.equal(report.reason, REASON.FAIL_ORPHAN_STEP_FILE);
|
|
assert.equal(
|
|
report.subject,
|
|
'gsd-core/workflows/sample/steps/stray.md',
|
|
'report must name the orphan step file path',
|
|
);
|
|
});
|
|
|
|
test('nestedStepReferenceIsNotFlaggedAsOrphan (supplemental: proves the reachability fixed-point, not just the negative case)', (t) => {
|
|
const tmpRoot = createTempDir('gen-section-manifest-');
|
|
t.after(() => cleanup(tmpRoot));
|
|
|
|
// handle-x.md is the marker target; handle-x.md's OWN prose delegates to
|
|
// handle-x-run.md, which is referenced by NO marker directly — mirrors
|
|
// the real repo's regression-gate.md -> regression-gate-run.md shape.
|
|
const { workflowsDir, manifestPath } = buildFixture(
|
|
tmpRoot,
|
|
'sample',
|
|
markerSource('handle-x', 'always', 'handle-x.md'),
|
|
{
|
|
'handle-x.md': 'Read and execute `gsd-core/workflows/sample/steps/handle-x-run.md`.\n',
|
|
'handle-x-run.md': 'the nested run body\n',
|
|
},
|
|
);
|
|
|
|
const fresh = buildFreshManifest(workflowsDir, tmpRoot);
|
|
assert.equal(fresh.workflows.sample.length, 1);
|
|
fs.writeFileSync(manifestPath, JSON.stringify(fresh, null, 2) + '\n', 'utf8');
|
|
|
|
const r = runGenSectionManifest([
|
|
'--check', '--workflows-dir', workflowsDir, '--manifest-path', manifestPath, '--repo-root', tmpRoot,
|
|
]);
|
|
assert.equal(r.code, 0, `handle-x-run.md must resolve as reachable, not orphan; stderr: ${r.stderr}`);
|
|
});
|
|
|
|
test('producesIdenticalManifestForCrlfAndLfSources (row 37)', (t) => {
|
|
const lfRoot = createTempDir('gen-section-manifest-lf-');
|
|
const crlfRoot = createTempDir('gen-section-manifest-crlf-');
|
|
t.after(() => {
|
|
cleanup(lfRoot);
|
|
cleanup(crlfRoot);
|
|
});
|
|
|
|
const lfSource = markerSource('handle-x', 'state:has-prior-phases', 'handle-x.md');
|
|
const lfStep = '<step name="handle_x">\nbody\n</step>\n';
|
|
const { workflowsDir: lfDir } = buildFixture(lfRoot, 'sample', lfSource, { 'handle-x.md': lfStep });
|
|
const { workflowsDir: crlfDir } = buildFixture(
|
|
crlfRoot,
|
|
'sample',
|
|
lfSource.replace(/\n/g, '\r\n'),
|
|
{ 'handle-x.md': lfStep.replace(/\n/g, '\r\n') },
|
|
);
|
|
|
|
const lfManifest = buildFreshManifest(lfDir, lfRoot);
|
|
const crlfManifest = buildFreshManifest(crlfDir, crlfRoot);
|
|
assert.deepEqual(crlfManifest, lfManifest, 'CRLF and LF sources must produce an identical manifest');
|
|
});
|
|
|
|
test('ignoresSectionShapedLineInsideFencedBlock (row 38)', (t) => {
|
|
const tmpRoot = createTempDir('gen-section-manifest-');
|
|
t.after(() => cleanup(tmpRoot));
|
|
|
|
const source = [
|
|
'# Sample workflow',
|
|
'',
|
|
'<!-- gsd:section id="handle-x" when="always" -->',
|
|
'Read and execute `gsd-core/workflows/sample/steps/handle-x.md`.',
|
|
'<!-- /gsd:section -->',
|
|
'',
|
|
'Documentation of the marker grammar (must NOT be treated as real):',
|
|
'```',
|
|
'<!-- gsd:section id="fake" when="always" -->',
|
|
'never a real section',
|
|
'<!-- /gsd:section -->',
|
|
'```',
|
|
'',
|
|
].join('\n');
|
|
|
|
const { workflowsDir, manifestPath } = buildFixture(tmpRoot, 'sample', source, {
|
|
'handle-x.md': '<step name="handle_x">\nbody\n</step>\n',
|
|
});
|
|
|
|
const fresh = buildFreshManifest(workflowsDir, tmpRoot);
|
|
assert.equal(fresh.workflows.sample.length, 1, 'the fenced marker-shaped lines must not produce a section');
|
|
assert.equal(fresh.workflows.sample[0].id, 'handle-x');
|
|
|
|
fs.writeFileSync(manifestPath, JSON.stringify(fresh, null, 2) + '\n', 'utf8');
|
|
const r = runGenSectionManifest([
|
|
'--check', '--workflows-dir', workflowsDir, '--manifest-path', manifestPath, '--repo-root', tmpRoot,
|
|
]);
|
|
assert.equal(r.code, 0, `stderr: ${r.stderr}`);
|
|
});
|
|
|
|
test('leavesLoopHostMarkerUntouched (row 39)', (t) => {
|
|
const tmpRoot = createTempDir('gen-section-manifest-');
|
|
t.after(() => cleanup(tmpRoot));
|
|
|
|
const source = [
|
|
'<!-- gsd:loop-host name="example" -->',
|
|
'',
|
|
'# Sample workflow',
|
|
'',
|
|
'<!-- gsd:section id="handle-x" when="always" -->',
|
|
'Read and execute `gsd-core/workflows/sample/steps/handle-x.md`.',
|
|
'<!-- /gsd:section -->',
|
|
'',
|
|
].join('\n');
|
|
|
|
const { workflowsDir, manifestPath } = buildFixture(tmpRoot, 'sample', source, {
|
|
'handle-x.md': '<step name="handle_x">\nbody\n</step>\n',
|
|
});
|
|
|
|
const fresh = buildFreshManifest(workflowsDir, tmpRoot);
|
|
assert.equal(fresh.workflows.sample.length, 1, 'gsd:loop-host must never be treated as a gsd:section marker');
|
|
assert.equal(fresh.workflows.sample[0].id, 'handle-x');
|
|
|
|
fs.writeFileSync(manifestPath, JSON.stringify(fresh, null, 2) + '\n', 'utf8');
|
|
const r = runGenSectionManifest([
|
|
'--check', '--workflows-dir', workflowsDir, '--manifest-path', manifestPath, '--repo-root', tmpRoot,
|
|
]);
|
|
assert.equal(r.code, 0, `stderr: ${r.stderr}`);
|
|
});
|
|
|
|
test('writesManifestAtomically (row 40)', (t) => {
|
|
const tmpRoot = createTempDir('gen-section-manifest-');
|
|
t.after(() => cleanup(tmpRoot));
|
|
|
|
const manifestPath = path.join(tmpRoot, 'section-manifest.json');
|
|
const originalContent = '{"sections":[{"id":"pre-existing","when":"always","read":"x.md"}]}\n';
|
|
fs.writeFileSync(manifestPath, originalContent, 'utf8');
|
|
|
|
// Cross-platform IO-failure injection (CLAUDE.md rule): monkeypatch the
|
|
// fs method, restore via t.after() (never chmod 0o000 — root bypasses
|
|
// mode bits, silently zero-coverage in root Docker/CI).
|
|
const origRenameSync = fs.renameSync;
|
|
fs.renameSync = function patchedRenameSync() {
|
|
throw new Error('injected rename failure (never a real fs fault)');
|
|
};
|
|
t.after(() => {
|
|
fs.renameSync = origRenameSync;
|
|
});
|
|
|
|
assert.throws(() => writeManifestAtomically(manifestPath, '{"sections":[]}\n'), (err) => {
|
|
// Finding 2 (#2932 review): the throw site must attach the typed
|
|
// FAIL_WRITE_ERROR reason, not just a plain ExitError, so a downstream
|
|
// `--write --json` consumer can emit the same {ok,reason,subject}
|
|
// envelope every other failure path in this file produces.
|
|
assert.ok(err instanceof ManifestBuildError, 'must throw a ManifestBuildError, not a plain ExitError');
|
|
assert.equal(err.reason, REASON.FAIL_WRITE_ERROR);
|
|
assert.equal(err.subject, manifestPath);
|
|
return true;
|
|
});
|
|
|
|
assert.equal(
|
|
fs.readFileSync(manifestPath, 'utf8'),
|
|
originalContent,
|
|
'a rename failure must leave the pre-existing manifest completely untouched, never truncated',
|
|
);
|
|
const leftoverTmp = fs.readdirSync(tmpRoot).filter((f) => f.includes('.tmp-'));
|
|
assert.deepEqual(leftoverTmp, [], 'the temp file must be cleaned up even when the rename step fails');
|
|
});
|
|
|
|
test('surfacesWriteFailureAndCleansTemp (row 41)', (t) => {
|
|
const tmpRoot = createTempDir('gen-section-manifest-');
|
|
t.after(() => cleanup(tmpRoot));
|
|
|
|
const manifestPath = path.join(tmpRoot, 'section-manifest.json');
|
|
assert.equal(fs.existsSync(manifestPath), false, 'sanity: no pre-existing manifest');
|
|
|
|
const origWriteFileSync = fs.writeFileSync;
|
|
fs.writeFileSync = function patchedWriteFileSync(target, ...rest) {
|
|
if (target === manifestPath || (typeof target === 'string' && target.includes('.tmp-'))) {
|
|
throw new Error('injected write failure (never a real fs fault)');
|
|
}
|
|
return origWriteFileSync.call(fs, target, ...rest);
|
|
};
|
|
t.after(() => {
|
|
fs.writeFileSync = origWriteFileSync;
|
|
});
|
|
|
|
assert.throws(() => writeManifestAtomically(manifestPath, '{"sections":[]}\n'), (err) => {
|
|
assert.ok(err instanceof ManifestBuildError, 'must throw a ManifestBuildError, not a plain ExitError');
|
|
assert.equal(err.reason, REASON.FAIL_WRITE_ERROR);
|
|
assert.equal(err.subject, manifestPath);
|
|
return true;
|
|
});
|
|
|
|
assert.equal(fs.existsSync(manifestPath), false, 'the target manifest must never be created on a write failure');
|
|
const leftover = fs.readdirSync(tmpRoot).filter((f) => f.includes('.tmp-'));
|
|
assert.deepEqual(leftover, [], 'no temp file must leak when the initial write itself fails');
|
|
});
|
|
|
|
test('loadWorkflowFragmentsLib throws ManifestBuildError with FAIL_LIB_NOT_BUILT when the compiled lib cannot be read (Finding 2, #2932 review)', (t) => {
|
|
// Cross-platform IO-failure injection (CLAUDE.md rule): monkeypatch the
|
|
// `fs` method Node's own module loader uses to read the file content
|
|
// (never chmod 0o000 — root bypasses mode bits, silently zero-coverage in
|
|
// root Docker/CI, and would also mutate a file shared by concurrent test
|
|
// workers). The real compiled artifact is never touched or renamed.
|
|
const origReadFileSync = fs.readFileSync;
|
|
fs.readFileSync = function patchedReadFileSync(target, ...rest) {
|
|
if (target === WORKFLOW_FRAGMENTS_LIB_PATH) {
|
|
throw new Error('injected read failure (never a real fs fault)');
|
|
}
|
|
return origReadFileSync.call(fs, target, ...rest);
|
|
};
|
|
t.after(() => {
|
|
fs.readFileSync = origReadFileSync;
|
|
});
|
|
|
|
assert.throws(() => loadWorkflowFragmentsLib(), (err) => {
|
|
assert.ok(err instanceof ManifestBuildError, 'must throw a ManifestBuildError, not a plain ExitError');
|
|
assert.equal(err.reason, REASON.FAIL_LIB_NOT_BUILT);
|
|
assert.equal(err.subject, 'gsd-core/bin/lib/workflow-fragments.cjs');
|
|
assert.match(err.message, /npm run build:lib/);
|
|
return true;
|
|
});
|
|
});
|
|
|
|
test('checkReport surfaces FAIL_LIB_NOT_BUILT as the same typed envelope other --check failures produce (Finding 2, #2932 review)', (t) => {
|
|
const tmpRoot = createTempDir('gen-section-manifest-');
|
|
t.after(() => cleanup(tmpRoot));
|
|
|
|
const { workflowsDir, manifestPath } = buildFixture(
|
|
tmpRoot,
|
|
'sample',
|
|
markerSource('handle-x', 'always', 'handle-x.md'),
|
|
{ 'handle-x.md': '<step name="handle_x">\nbody\n</step>\n' },
|
|
);
|
|
|
|
const origReadFileSync = fs.readFileSync;
|
|
fs.readFileSync = function patchedReadFileSync(target, ...rest) {
|
|
if (target === WORKFLOW_FRAGMENTS_LIB_PATH) {
|
|
throw new Error('injected read failure (never a real fs fault)');
|
|
}
|
|
return origReadFileSync.call(fs, target, ...rest);
|
|
};
|
|
t.after(() => {
|
|
fs.readFileSync = origReadFileSync;
|
|
});
|
|
|
|
const report = checkReport(workflowsDir, manifestPath, tmpRoot);
|
|
assert.equal(report.ok, false);
|
|
assert.equal(report.reason, REASON.FAIL_LIB_NOT_BUILT);
|
|
assert.equal(report.subject, 'gsd-core/bin/lib/workflow-fragments.cjs');
|
|
});
|
|
});
|
|
|
|
// ─── REASON enum shape lock (mirrors gen-context-index.cjs precedent) ──────
|
|
|
|
describe('gen-section-manifest.cjs REASON enum', () => {
|
|
test('REASON key set is exactly the documented set', () => {
|
|
assert.deepEqual(Object.keys(REASON).sort(), [
|
|
'FAIL_LIB_NOT_BUILT',
|
|
'FAIL_MANIFEST_MALFORMED_SHAPE',
|
|
'FAIL_MANIFEST_MISSING',
|
|
'FAIL_MANIFEST_UNPARSEABLE',
|
|
'FAIL_MISSING_STEP_FILE',
|
|
'FAIL_ORPHAN_STEP_FILE',
|
|
'FAIL_SOURCE_PARSE_ERROR',
|
|
'FAIL_STALE',
|
|
'FAIL_WRITE_ERROR',
|
|
'OK_UP_TO_DATE',
|
|
]);
|
|
});
|
|
|
|
test('REASON is frozen', () => {
|
|
assert.ok(Object.isFrozen(REASON));
|
|
});
|
|
});
|