Files
msd-core/scripts/gen-section-manifest.cjs
Tom Boucher f1af47766a chore(#1671): widen the when= grammar and key the section manifest per workflow — Phase 6.1 (#3013)
* 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>
2026-08-02 22:36:45 -04:00

639 lines
26 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 introduced the artifact,
* `.gsd/phase/chore-2932-init-section-manifest/40-design.md`; Phase 6.1 /
* issue #2992 generalized it from single-workflow to PER-WORKFLOW,
* `.gsd/phase/chore-2992-widen-when-vocabulary/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 the init CLI's run-time selection 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.
*
* Shape (#2992 Phase 6.1): `{ "workflows": { "<workflow-name>": [{id, when,
* read}, ...], ... } }`, where `<workflow-name>` is a source `.md` file's
* basename without extension. A workflow with zero explicit sections
* contributes NO key at all (absence, not `[]` — an init caller for that
* workflow must degrade to `null`, never be attributed some OTHER workflow's
* sections). Keys are serialized in sorted (filename) order; each workflow's
* own sections stay in document order. The pre-6.1 shape was a single flat
* `{ sections: [...] }` array with no workflow key at all — `isValidManifestShape`
* REJECTS that shape outright so a stale committed artifact can never be
* mis-attributed to whichever workflow asks first (design row C4).
*
* Per design's "Rejected" list, a workflow's section list 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
* (most workflows today) contributes no key to `workflows` 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: `{ workflows: { <name>:
* [{id, when, read}], ... } }` (#2992 Phase 6.1). Workflow keys are
* serialized in sorted (filename) order; a workflow's own sections stay in
* document order. A workflow with zero explicit sections contributes NO key
* at all — absence, not `[]` (design row C4/C11: absence must degrade to
* `null` at the init seam, never be confused with "computed, no sections").
* 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 {{ workflows: Record<string, 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 workflows = {};
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');
const sections = [];
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}`,
);
}
workflows[workflowName] = sections;
}
return { workflows };
}
// ─── Serialization ────────────────────────────────────────────────────────────
/**
* @param {{ workflows: Record<string, Array<{id: string, when: string, read: string}>> }} manifest
* @returns {string}
*/
function serializeManifest(manifest) {
return JSON.stringify(manifest, null, 2) + '\n';
}
/**
* Workflow-count / section-count pair for a manifest's `workflows` map, used
* by the `--write` stdout summary and the `--check` OK message.
*
* @param {{ workflows: Record<string, Array<{id: string, when: string, read: string}>> }} manifest
* @returns {{ workflowCount: number, sectionCount: number }}
*/
function countManifest(manifest) {
const workflowNames = Object.keys(manifest.workflows);
const sectionCount = workflowNames.reduce((sum, name) => sum + manifest.workflows[name].length, 0);
return { workflowCount: workflowNames.length, sectionCount };
}
/**
* True when `parsed` has the expected committed-manifest shape (#2992 Phase
* 6.1): a plain object (not an array, not null) carrying a `workflows` plain
* object (not an array, not null) whose every own value is an array of
* `{id, when, read}` string triples. Rejects `0`, `"s"`, `[]`, `null`, `true`
* — AND rejects the pre-6.1 flat `{sections:[...]}` shape (no `workflows`
* key ⇒ `parsed.workflows` is `undefined`, which fails the object check),
* so a stale committed artifact can never be silently mis-attributed to
* whichever workflow asks first (design row C4).
*
* @param {unknown} parsed
* @returns {boolean}
*/
function isValidManifestShape(parsed) {
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return false;
const workflows = parsed.workflows;
if (workflows === null || typeof workflows !== 'object' || Array.isArray(workflows)) return false;
return Object.values(workflows).every(
(sections) =>
Array.isArray(sections) &&
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 {workflows:{<name>:[{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`,
};
}
const { workflowCount, sectionCount } = countManifest(live);
return {
ok: true,
reason: REASON.OK_UP_TO_DATE,
subject: null,
message: `${manifestPath} is up to date (${workflowCount} workflow${workflowCount === 1 ? '' : 's'}, ${sectionCount} section${sectionCount === 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) {
const { workflowCount, sectionCount } = countManifest(manifest);
process.stdout.write(`Wrote ${opts.manifestPath}\n ${workflowCount} workflow${workflowCount === 1 ? '' : 's'}, ${sectionCount} section${sectionCount === 1 ? '' : 's'}\n`);
}
if (writeErr) {
throw new ExitError(1, opts.json ? undefined : writeErr.message);
}
}
// ─── Exports (for tests) ──────────────────────────────────────────────────────
module.exports = {
loadWorkflowFragmentsLib,
findOrphanStepFiles,
buildFreshManifest,
serializeManifest,
countManifest,
isValidManifestShape,
writeManifestAtomically,
checkReport,
parseArgs,
ManifestBuildError,
REASON,
WORKFLOW_FRAGMENTS_LIB_PATH,
WORKFLOWS_DIR,
MANIFEST_PATH,
ROOT,
};
// ─── CLI entry point ──────────────────────────────────────────────────────────
if (require.main === module) {
runMain(main);
}