* chore(#2932): emit a per-invocation section manifest from init Extends the init bundle with a typed per-invocation section manifest so an invocation loads only the branch guidance it will actually take. The three flag/state-gated branches in execute-phase.md move into their own step files; the parent keeps its gsd:section markers wrapping a one-line on-demand reference, so each section's prose lives in exactly one file and the parent shrinks 93369 -> 89507 bytes. A new drift-guarded generator derives the shipped section manifest from those markers, and a new pure evaluator maps invocation facts to applicable section ids. The evaluator is a lookup over the frozen WHEN_VOCABULARY, never a parser (Greenspun's Tenth Rule, ADR-1671:69); a parity test asserts the vocabulary and the predicate map stay exhaustively in sync. Closes #2932 * fix(#2932): fail closed on prototype-chain when values An isolated adversarial review found WHEN_PREDICATES[section.when] was a bracket lookup on a plain-prototype object, so inherited Object.prototype members resolved as predicates: "constructor"/"toString"/"valueOf"/ "hasOwnProperty" returned truthy and SILENTLY INCLUDED the section, and "__proto__" threw an untyped TypeError carrying no .reason. Both violate the module's documented fail-closed contract, and the manifest is read from disk at run time so it cannot be assumed trustworthy. Builds the predicate map on a null prototype and guards the lookup with an explicit Object.hasOwn check. Adds table-driven coverage for nine Object.prototype-shaped keys asserting the TYPED reason (asserting only that it throws would still pass while broken) plus a fast-check property injecting a hostile value at an arbitrary document position. * test(#2932): retarget execute-phase step assertions at extracted step files * fix(#2932): emit typed reasons for generator lib-load and write failures * fix(#2932): restore launcher preamble in extracted steps and refresh derived fixtures * chore(#2932): backfill changeset pr number to 2987 --------- Co-authored-by: sim <sim@local>
591 lines
24 KiB
JavaScript
591 lines
24 KiB
JavaScript
#!/usr/bin/env node
|
|
'use strict';
|
|
|
|
/**
|
|
* gen-section-manifest.cjs — generates gsd-core/workflows/section-manifest.json
|
|
* from the `<!-- gsd:section -->` markers in gsd-core/workflows/*.md (ADR-1671
|
|
* epic #1671, Phase 5 / issue #2932, `.gsd/phase/chore-2932-init-section-manifest/40-design.md`).
|
|
*
|
|
* Reuses `parseWorkflowSections` from the compiled `workflow-fragments.cjs`
|
|
* (src/workflow-fragments.cts, Phase 3 / #2930) UNCHANGED — this module never
|
|
* re-implements marker parsing (design "Rejected #6"; a second parser is the
|
|
* `DEFECT.GENERATIVE-FIX` divergence class).
|
|
*
|
|
* The committed artifact is placed INSIDE the `gsd-core/` tree (not `docs/`,
|
|
* unlike `docs/CONTEXT-INDEX.json`/`docs/INVENTORY-MANIFEST.json`) because it
|
|
* must SHIP: `bin/install.js`'s `copyWithPathReplacement` only copies
|
|
* `gsd-core/`, and Phase 5's run-time selection (a later commit) reads this
|
|
* artifact from the INSTALLED tree, not the dev repo. `copyWithPathReplacement`
|
|
* only runs `composeWorkflow`/converters on `*.md` — a `.json` leaf falls
|
|
* through to a plain `fs.copyFileSync`, so the artifact ships byte-identical.
|
|
*
|
|
* Per design's "Rejected" list, the manifest carries NEITHER section content
|
|
* (would duplicate every section's bytes, fighting Phase 4's emitted-byte
|
|
* caps) NOR line numbers (re-drifts on any line shift, and per-runtime
|
|
* converters rewrite text so ranges would differ per runtime). It carries
|
|
* only `{id, when, read}` triples — `read` is a POSIX-normalized path,
|
|
* relative to the repo root, of the step file the section body was moved to.
|
|
*
|
|
* Usage:
|
|
* node scripts/gen-section-manifest.cjs # print to stdout
|
|
* node scripts/gen-section-manifest.cjs --write # write the manifest
|
|
* node scripts/gen-section-manifest.cjs --check # exit 1 if stale/invalid
|
|
* node scripts/gen-section-manifest.cjs --check --json # same, + typed report
|
|
* node scripts/gen-section-manifest.cjs --write --workflows-dir <p> --manifest-path <p>
|
|
* # override the two hardcoded
|
|
* # repo-root paths (tests point
|
|
* # the real CLI at a temp fixture
|
|
* # tree with no fs monkeypatching)
|
|
*
|
|
* Only `.md` files directly inside `--workflows-dir` are scanned (not files
|
|
* already inside a `<workflow>/steps/` subdirectory — those are MOVED-TO
|
|
* output, never source-with-markers). A workflow with zero explicit sections
|
|
* (88 of 89 today) contributes nothing to the manifest and is never
|
|
* orphan-checked — orphan-checking is scoped only to a workflow's OWN
|
|
* `steps/` directory, and only for workflows that declare at least one
|
|
* `gsd:section` marker.
|
|
*
|
|
* `--check` fails closed (exit 1, never a stack trace) on:
|
|
* - the compiled `workflow-fragments.cjs` dependency being unbuilt/unloadable
|
|
* (FAIL_LIB_NOT_BUILT — `npm run build:lib` has not run)
|
|
* - the live build itself failing: a marker's derived step file does not
|
|
* exist on disk (FAIL_MISSING_STEP_FILE), a step file in a managed
|
|
* workflow's `steps/` dir is referenced by no marker AND no reachable
|
|
* prose reference (FAIL_ORPHAN_STEP_FILE — see `findOrphanStepFiles`),
|
|
* or the source itself fails to parse (FAIL_SOURCE_PARSE_ERROR, wraps
|
|
* `parseWorkflowSections`' typed `WorkflowFragmentsError`)
|
|
* - the committed manifest: absent (FAIL_MANIFEST_MISSING), empty/unparseable
|
|
* JSON (FAIL_MANIFEST_UNPARSEABLE), valid JSON but the wrong shape
|
|
* (FAIL_MANIFEST_MALFORMED_SHAPE — `0`, `"s"`, `[]`, `null`, `true` all
|
|
* land here), or parseable-and-shaped but not equal to the live build
|
|
* (FAIL_STALE)
|
|
*
|
|
* `--write --json` reports the same typed envelope on failure: the compiled
|
|
* dependency being unloadable (FAIL_LIB_NOT_BUILT, same as above) or the
|
|
* atomic write itself failing (FAIL_WRITE_ERROR — see `writeManifestAtomically`).
|
|
*
|
|
* Orphan detection (`findOrphanStepFiles`) is plain substring reachability
|
|
* over prose, NOT a second marker parser: starting from the parent workflow's
|
|
* raw text, it does a fixed-point search for the literal token
|
|
* `steps/<basename>` across the parent text and every already-reached step
|
|
* file's own text (so a step file that is itself an on-demand delegation
|
|
* TARGET of another step file — e.g. `regression-gate.md`'s own "Read and
|
|
* execute `.../steps/regression-gate-run.md`" line — is correctly resolved
|
|
* as non-orphan without the generator needing to know that convention).
|
|
*
|
|
* `--write` writes atomically: content lands at a same-directory temp path
|
|
* first, then `fs.renameSync` swaps it into place. If either step throws, the
|
|
* temp path is removed (best-effort) and the target manifest is left exactly
|
|
* as it was — never truncated or partially written.
|
|
*/
|
|
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
|
|
|
|
const ROOT = path.resolve(__dirname, '..');
|
|
const WORKFLOWS_DIR = path.join(ROOT, 'gsd-core', 'workflows');
|
|
const MANIFEST_PATH = path.join(WORKFLOWS_DIR, 'section-manifest.json');
|
|
|
|
// ─── Typed reason enum (CONTRIBUTING.md "Prohibited: Raw Text Matching") ───────
|
|
|
|
/**
|
|
* Stable reason codes for `checkReport`'s `reason` field. Tests assert via
|
|
* `assert.equal(report.reason, REASON.X)` rather than regex-matching the
|
|
* human-readable prose the non-JSON `--check` mode still writes to
|
|
* stdout/stderr.
|
|
*
|
|
* Adding a new reason requires updating this map AND the test that locks
|
|
* `Object.keys(REASON).sort()` as a coordinated change.
|
|
*/
|
|
const REASON = Object.freeze({
|
|
OK_UP_TO_DATE: 'ok_up_to_date',
|
|
FAIL_STALE: 'fail_stale',
|
|
FAIL_MANIFEST_MISSING: 'fail_manifest_missing',
|
|
FAIL_MANIFEST_UNPARSEABLE: 'fail_manifest_unparseable',
|
|
FAIL_MANIFEST_MALFORMED_SHAPE: 'fail_manifest_malformed_shape',
|
|
FAIL_MISSING_STEP_FILE: 'fail_missing_step_file',
|
|
FAIL_ORPHAN_STEP_FILE: 'fail_orphan_step_file',
|
|
FAIL_SOURCE_PARSE_ERROR: 'fail_source_parse_error',
|
|
FAIL_LIB_NOT_BUILT: 'fail_lib_not_built',
|
|
FAIL_WRITE_ERROR: 'fail_write_error',
|
|
});
|
|
|
|
// ─── Loaders ──────────────────────────────────────────────────────────────────
|
|
|
|
const WORKFLOW_FRAGMENTS_LIB_PATH = path.join(ROOT, 'gsd-core', 'bin', 'lib', 'workflow-fragments.cjs');
|
|
|
|
/**
|
|
* Load the compiled workflow-fragments library. The artifact is a gitignored
|
|
* tsc build output of src/workflow-fragments.cts and only exists after
|
|
* `npm run build:lib`. Throws a clean `ManifestBuildError` (REASON.FAIL_LIB_NOT_BUILT;
|
|
* never a bare MODULE_NOT_FOUND stack) naming the remedy when it is missing —
|
|
* `ManifestBuildError` extends `ExitError`, so `runMain` still prints only the
|
|
* friendly message, and `checkReport`/the `--write --json` path can still read
|
|
* `.reason`/`.subject` off it to emit the typed envelope.
|
|
*
|
|
* @returns {{ parseWorkflowSections: Function }}
|
|
*/
|
|
function loadWorkflowFragmentsLib() {
|
|
try {
|
|
delete require.cache[require.resolve(WORKFLOW_FRAGMENTS_LIB_PATH)];
|
|
return require(WORKFLOW_FRAGMENTS_LIB_PATH);
|
|
} catch (err) {
|
|
const subject = relPosix(ROOT, WORKFLOW_FRAGMENTS_LIB_PATH);
|
|
throw new ManifestBuildError(
|
|
REASON.FAIL_LIB_NOT_BUILT,
|
|
subject,
|
|
`Cannot load ${subject}: ${err && err.message}\n` +
|
|
'Run:\n npm run build:lib\n',
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─── POSIX path helpers ─────────────────────────────────────────────────────
|
|
|
|
/** Unconditional backslash->forward-slash normalization (CONTEXT.md
|
|
* path-separator-normalization rule: never gate on `path.sep`). */
|
|
function toPosix(p) {
|
|
return p.replace(/\\/g, '/');
|
|
}
|
|
|
|
/**
|
|
* Repo-root-relative POSIX path for a file under `repoRoot`.
|
|
*
|
|
* @param {string} repoRoot
|
|
* @param {string} absPath
|
|
*/
|
|
function relPosix(repoRoot, absPath) {
|
|
return toPosix(path.relative(repoRoot, absPath));
|
|
}
|
|
|
|
// ─── Orphan detection (plain substring reachability, not a marker parser) ────
|
|
|
|
/**
|
|
* Fixed-point reachability scan over a managed workflow's `steps/` directory:
|
|
* a step file is "reached" once the literal token `steps/<its-basename>`
|
|
* appears in the parent workflow's raw text OR in the text of any
|
|
* already-reached step file (so nested delegation — a step file that itself
|
|
* names another step file — resolves without the generator knowing that
|
|
* convention explicitly). Returns the SORTED list of `.md` basenames in
|
|
* `stepsDir` that are never reached (i.e. orphans). Returns `[]` if
|
|
* `stepsDir` does not exist.
|
|
*
|
|
* @param {string} parentText - the workflow.md's raw source text
|
|
* @param {string} stepsDir - absolute path to `<workflow>/steps/`
|
|
* @returns {string[]}
|
|
*/
|
|
function findOrphanStepFiles(parentText, stepsDir) {
|
|
if (!fs.existsSync(stepsDir)) return [];
|
|
const files = fs
|
|
.readdirSync(stepsDir, { withFileTypes: true })
|
|
.filter((d) => d.isFile() && d.name.endsWith('.md'))
|
|
.map((d) => d.name)
|
|
.sort();
|
|
|
|
const textOf = new Map(files.map((f) => [f, fs.readFileSync(path.join(stepsDir, f), 'utf8')]));
|
|
const reached = new Set();
|
|
const haystacks = [parentText];
|
|
|
|
let changed = true;
|
|
while (changed) {
|
|
changed = false;
|
|
for (const f of files) {
|
|
if (reached.has(f)) continue;
|
|
const token = `steps/${f}`;
|
|
if (haystacks.some((h) => h.includes(token))) {
|
|
reached.add(f);
|
|
haystacks.push(textOf.get(f));
|
|
changed = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return files.filter((f) => !reached.has(f));
|
|
}
|
|
|
|
// ─── Live manifest build ─────────────────────────────────────────────────────
|
|
|
|
/** Thrown by `buildFreshManifest`/`loadWorkflowFragmentsLib`/`writeManifestAtomically`
|
|
* for every fail-closed condition below; carries a stable `reason` + the
|
|
* offending `subject` path so `checkReport`/the `--write --json` path never
|
|
* need to string-match a message. Extends `ExitError` (not plain `Error`) so
|
|
* `runMain` still prints only the friendly `message` — never a bare stack
|
|
* trace — for the `default`/`--write` (non-`--json`) code paths that let it
|
|
* propagate uncaught, exactly like every other `ExitError`. */
|
|
class ManifestBuildError extends ExitError {
|
|
constructor(reason, subject, message) {
|
|
super(1, message);
|
|
this.name = 'ManifestBuildError';
|
|
this.reason = reason;
|
|
this.subject = subject;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Scan `workflowsDir` for `.md` files carrying `gsd:section` markers and
|
|
* build the live (freshly-derived) manifest: `{ sections: [{id, when, read}] }`,
|
|
* in document order (files sorted by filename, sections in each file's own
|
|
* document order). Throws `ManifestBuildError` on any fail-closed condition
|
|
* (missing step file, orphan step file, unparseable source).
|
|
*
|
|
* @param {string} workflowsDir - defaults to the real repo-root gsd-core/workflows/
|
|
* @param {string} repoRoot - root `read` paths are computed relative to
|
|
* @returns {{ sections: Array<{id: string, when: string, read: string}> }}
|
|
*/
|
|
function buildFreshManifest(workflowsDir = WORKFLOWS_DIR, repoRoot = ROOT) {
|
|
const { parseWorkflowSections } = loadWorkflowFragmentsLib();
|
|
|
|
const workflowFiles = fs
|
|
.readdirSync(workflowsDir, { withFileTypes: true })
|
|
.filter((d) => d.isFile() && d.name.endsWith('.md'))
|
|
.map((d) => d.name)
|
|
.sort();
|
|
|
|
const sections = [];
|
|
|
|
for (const fileName of workflowFiles) {
|
|
const filePath = path.join(workflowsDir, fileName);
|
|
const relSourcePath = relPosix(repoRoot, filePath);
|
|
let content;
|
|
try {
|
|
content = fs.readFileSync(filePath, 'utf8');
|
|
} catch (err) {
|
|
throw new ManifestBuildError(REASON.FAIL_SOURCE_PARSE_ERROR, relSourcePath, `Cannot read ${relSourcePath}: ${err && err.message}`);
|
|
}
|
|
|
|
let parsed;
|
|
try {
|
|
parsed = parseWorkflowSections(content, relSourcePath);
|
|
} catch (err) {
|
|
throw new ManifestBuildError(REASON.FAIL_SOURCE_PARSE_ERROR, relSourcePath, `${relSourcePath}: ${err && err.message}`);
|
|
}
|
|
|
|
const explicitSections = parsed.filter((s) => s.explicit);
|
|
if (explicitSections.length === 0) continue;
|
|
|
|
const workflowName = fileName.replace(/\.md$/, '');
|
|
const stepsDir = path.join(workflowsDir, workflowName, 'steps');
|
|
|
|
for (const section of explicitSections) {
|
|
const stepFileAbs = path.join(stepsDir, `${section.id}.md`);
|
|
if (!fs.existsSync(stepFileAbs)) {
|
|
const relStepPath = relPosix(repoRoot, stepFileAbs);
|
|
throw new ManifestBuildError(
|
|
REASON.FAIL_MISSING_STEP_FILE,
|
|
relStepPath,
|
|
`${relSourcePath}: section "${section.id}" (when="${section.when}") names step file ${relStepPath}, which does not exist`,
|
|
);
|
|
}
|
|
sections.push({
|
|
id: section.id,
|
|
when: section.when,
|
|
read: relPosix(repoRoot, stepFileAbs),
|
|
});
|
|
}
|
|
|
|
const orphans = findOrphanStepFiles(content, stepsDir);
|
|
if (orphans.length > 0) {
|
|
const relOrphanPath = relPosix(repoRoot, path.join(stepsDir, orphans[0]));
|
|
throw new ManifestBuildError(
|
|
REASON.FAIL_ORPHAN_STEP_FILE,
|
|
relOrphanPath,
|
|
`${relOrphanPath} is not referenced by any gsd:section marker or reachable "steps/" reference in ${relSourcePath}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
return { sections };
|
|
}
|
|
|
|
// ─── Serialization ────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* @param {{ sections: Array<{id: string, when: string, read: string}> }} manifest
|
|
* @returns {string}
|
|
*/
|
|
function serializeManifest(manifest) {
|
|
return JSON.stringify(manifest, null, 2) + '\n';
|
|
}
|
|
|
|
/**
|
|
* True when `parsed` has the expected committed-manifest shape: a plain
|
|
* object (not an array, not null) carrying a `sections` array of
|
|
* `{id, when, read}` string triples. Rejects `0`, `"s"`, `[]`, `null`, `true`.
|
|
*
|
|
* @param {unknown} parsed
|
|
* @returns {boolean}
|
|
*/
|
|
function isValidManifestShape(parsed) {
|
|
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return false;
|
|
if (!Array.isArray(parsed.sections)) return false;
|
|
return parsed.sections.every(
|
|
(s) => s !== null && typeof s === 'object' && typeof s.id === 'string' && typeof s.when === 'string' && typeof s.read === 'string',
|
|
);
|
|
}
|
|
|
|
// ─── Atomic write ─────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Write `content` to `targetPath` atomically: write to a same-directory temp
|
|
* path, then `fs.renameSync` it into place (same filesystem, so the rename is
|
|
* atomic). On ANY failure (the write or the rename), the temp path is removed
|
|
* best-effort and a `ManifestBuildError` (REASON.FAIL_WRITE_ERROR, subject
|
|
* `targetPath`) is thrown — `targetPath` is left exactly as it was before the
|
|
* call, never truncated or partially written. `ManifestBuildError` extends
|
|
* `ExitError`, so a caller that lets it propagate uncaught (e.g. `--write`
|
|
* without `--json`) still gets only the friendly message, never a stack trace.
|
|
*
|
|
* @param {string} targetPath
|
|
* @param {string} content
|
|
*/
|
|
function writeManifestAtomically(targetPath, content) {
|
|
const tmpPath = path.join(path.dirname(targetPath), `.${path.basename(targetPath)}.tmp-${process.pid}-${Date.now()}`);
|
|
try {
|
|
fs.writeFileSync(tmpPath, content, 'utf8');
|
|
fs.renameSync(tmpPath, targetPath);
|
|
} catch (err) {
|
|
try {
|
|
fs.unlinkSync(tmpPath);
|
|
} catch (_cleanupErr) {
|
|
// best-effort: tmpPath may never have been created (writeFileSync itself threw)
|
|
}
|
|
throw new ManifestBuildError(REASON.FAIL_WRITE_ERROR, targetPath, `Cannot write ${targetPath}: ${err && err.message}`);
|
|
}
|
|
}
|
|
|
|
// ─── Typed check report ───────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Empty-report shape shared by every early-exit branch below.
|
|
*
|
|
* @returns {{ subject: string | null }}
|
|
*/
|
|
function emptyReportFields() {
|
|
return { subject: null };
|
|
}
|
|
|
|
/**
|
|
* Compute the full `--check` result as a typed, non-throwing report.
|
|
*
|
|
* @param {string} [workflowsDir]
|
|
* @param {string} [manifestPath]
|
|
* @param {string} [repoRoot]
|
|
* @returns {{ ok: boolean, reason: string, subject: string | null, message: string }}
|
|
*/
|
|
function checkReport(workflowsDir = WORKFLOWS_DIR, manifestPath = MANIFEST_PATH, repoRoot = ROOT) {
|
|
let live;
|
|
try {
|
|
live = buildFreshManifest(workflowsDir, repoRoot);
|
|
} catch (err) {
|
|
if (err instanceof ManifestBuildError) {
|
|
return { ok: false, reason: err.reason, subject: err.subject, message: `${err.message}\n` };
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
if (!fs.existsSync(manifestPath)) {
|
|
return {
|
|
ok: false,
|
|
reason: REASON.FAIL_MANIFEST_MISSING,
|
|
...emptyReportFields(),
|
|
subject: manifestPath,
|
|
message: `${manifestPath} does not exist. Run:\n node scripts/gen-section-manifest.cjs --write\n`,
|
|
};
|
|
}
|
|
|
|
let committedText;
|
|
try {
|
|
committedText = fs.readFileSync(manifestPath, 'utf8');
|
|
} catch (err) {
|
|
return {
|
|
ok: false,
|
|
reason: REASON.FAIL_MANIFEST_UNPARSEABLE,
|
|
subject: manifestPath,
|
|
message: `Cannot read ${manifestPath}: ${err && err.message}\n`,
|
|
};
|
|
}
|
|
|
|
let committed;
|
|
try {
|
|
committed = JSON.parse(committedText);
|
|
} catch (err) {
|
|
return {
|
|
ok: false,
|
|
reason: REASON.FAIL_MANIFEST_UNPARSEABLE,
|
|
subject: manifestPath,
|
|
message: `${manifestPath} is not valid JSON: ${err && err.message}\n` +
|
|
'Run:\n node scripts/gen-section-manifest.cjs --write\n',
|
|
};
|
|
}
|
|
|
|
if (!isValidManifestShape(committed)) {
|
|
return {
|
|
ok: false,
|
|
reason: REASON.FAIL_MANIFEST_MALFORMED_SHAPE,
|
|
subject: manifestPath,
|
|
message: `${manifestPath} is valid JSON but does not have the expected {sections:[{id,when,read}]} shape.\n` +
|
|
'Run:\n node scripts/gen-section-manifest.cjs --write\n',
|
|
};
|
|
}
|
|
|
|
if (JSON.stringify(committed) !== JSON.stringify(live)) {
|
|
return {
|
|
ok: false,
|
|
reason: REASON.FAIL_STALE,
|
|
subject: manifestPath,
|
|
message: `${manifestPath} is stale. Run:\n node scripts/gen-section-manifest.cjs --write\n`,
|
|
};
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
reason: REASON.OK_UP_TO_DATE,
|
|
subject: null,
|
|
message: `${manifestPath} is up to date (${live.sections.length} section${live.sections.length === 1 ? '' : 's'}).\n`,
|
|
};
|
|
}
|
|
|
|
// ─── Argument parsing ─────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* @param {string|undefined} value
|
|
* @returns {boolean}
|
|
*/
|
|
function isMissingPathValue(value) {
|
|
return value === undefined || value === '' || value.startsWith('-');
|
|
}
|
|
|
|
/**
|
|
* @param {string[]} argv
|
|
* @returns {{ mode: 'check'|'write'|'default'|'unknown', json: boolean, workflowsDir: string, manifestPath: string, repoRoot: string, unknownArg?: string, usageMessage?: string }}
|
|
*/
|
|
function parseArgs(argv) {
|
|
const opts = { mode: 'default', json: false, workflowsDir: WORKFLOWS_DIR, manifestPath: MANIFEST_PATH, repoRoot: ROOT };
|
|
let sawCheck = false;
|
|
let sawWrite = false;
|
|
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const arg = argv[i];
|
|
if (arg === '--check') {
|
|
sawCheck = true;
|
|
opts.mode = 'check';
|
|
} else if (arg === '--write') {
|
|
sawWrite = true;
|
|
opts.mode = 'write';
|
|
} else if (arg === '--json') {
|
|
opts.json = true;
|
|
} else if (arg === '--workflows-dir' || arg === '--manifest-path' || arg === '--repo-root') {
|
|
const value = argv[i + 1];
|
|
if (isMissingPathValue(value)) {
|
|
return {
|
|
...opts,
|
|
mode: 'unknown',
|
|
unknownArg: arg,
|
|
usageMessage: `${arg} requires a non-empty path argument (got ${value === undefined ? 'nothing' : JSON.stringify(value)})`,
|
|
};
|
|
}
|
|
i++;
|
|
if (arg === '--workflows-dir') opts.workflowsDir = path.resolve(value);
|
|
else if (arg === '--manifest-path') opts.manifestPath = path.resolve(value);
|
|
else opts.repoRoot = path.resolve(value);
|
|
} else {
|
|
opts.mode = 'unknown';
|
|
opts.unknownArg = arg;
|
|
}
|
|
}
|
|
|
|
if (sawCheck && sawWrite) {
|
|
return { ...opts, mode: 'unknown', usageMessage: '--check and --write are mutually exclusive' };
|
|
}
|
|
|
|
return opts;
|
|
}
|
|
|
|
// ─── Main ─────────────────────────────────────────────────────────────────────
|
|
|
|
function main() {
|
|
const opts = parseArgs(process.argv.slice(2));
|
|
|
|
if (opts.mode === 'unknown') {
|
|
process.stderr.write('Usage: gen-section-manifest.cjs [--write|--check] [--json] [--workflows-dir <path>] [--manifest-path <path>] [--repo-root <path>]\n');
|
|
if (opts.usageMessage) process.stderr.write(`${opts.usageMessage}\n`);
|
|
throw new ExitError(1);
|
|
}
|
|
|
|
if (opts.mode === 'default') {
|
|
process.stdout.write(serializeManifest(buildFreshManifest(opts.workflowsDir, opts.repoRoot)));
|
|
return;
|
|
}
|
|
|
|
if (opts.mode === 'check') {
|
|
const report = checkReport(opts.workflowsDir, opts.manifestPath, opts.repoRoot);
|
|
|
|
if (opts.json) {
|
|
process.stdout.write(JSON.stringify({ ok: report.ok, reason: report.reason, subject: report.subject }) + '\n');
|
|
} else if (report.ok) {
|
|
process.stdout.write(report.message);
|
|
}
|
|
|
|
if (!report.ok) {
|
|
throw new ExitError(1, opts.json ? undefined : report.message);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// opts.mode === 'write'. Same typed-envelope pattern as --check --json above:
|
|
// a ManifestBuildError (FAIL_LIB_NOT_BUILT from buildFreshManifest,
|
|
// FAIL_WRITE_ERROR from writeManifestAtomically) is caught here so --json
|
|
// still emits {ok, reason, subject} instead of letting the error propagate
|
|
// to runMain unobserved by the JSON caller.
|
|
let manifest;
|
|
let writeErr;
|
|
try {
|
|
manifest = buildFreshManifest(opts.workflowsDir, opts.repoRoot);
|
|
writeManifestAtomically(opts.manifestPath, serializeManifest(manifest));
|
|
} catch (err) {
|
|
if (!(err instanceof ManifestBuildError)) throw err;
|
|
writeErr = err;
|
|
}
|
|
|
|
if (opts.json) {
|
|
process.stdout.write(JSON.stringify(
|
|
writeErr
|
|
? { ok: false, reason: writeErr.reason, subject: writeErr.subject }
|
|
: { ok: true, reason: REASON.OK_UP_TO_DATE, subject: null },
|
|
) + '\n');
|
|
} else if (!writeErr) {
|
|
process.stdout.write(`Wrote ${opts.manifestPath}\n ${manifest.sections.length} section${manifest.sections.length === 1 ? '' : 's'}\n`);
|
|
}
|
|
|
|
if (writeErr) {
|
|
throw new ExitError(1, opts.json ? undefined : writeErr.message);
|
|
}
|
|
}
|
|
|
|
// ─── Exports (for tests) ──────────────────────────────────────────────────────
|
|
|
|
module.exports = {
|
|
loadWorkflowFragmentsLib,
|
|
findOrphanStepFiles,
|
|
buildFreshManifest,
|
|
serializeManifest,
|
|
isValidManifestShape,
|
|
writeManifestAtomically,
|
|
checkReport,
|
|
parseArgs,
|
|
ManifestBuildError,
|
|
REASON,
|
|
WORKFLOW_FRAGMENTS_LIB_PATH,
|
|
WORKFLOWS_DIR,
|
|
MANIFEST_PATH,
|
|
ROOT,
|
|
};
|
|
|
|
// ─── CLI entry point ──────────────────────────────────────────────────────────
|
|
|
|
if (require.main === module) {
|
|
runMain(main);
|
|
}
|