Files
msd-core/scripts/gen-section-manifest.cjs
Tom Boucher a987cf2731 chore(#2932): emit a per-invocation section manifest from the init bundle (#2987)
* 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>
2026-08-02 12:34:41 -04:00

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);
}