Files
msd-core/scripts/gen-hooks-cli-exit.cjs
Jakub Zych a9a7a328e6 refactor: hard-fork GSD -> MSD (Make Software Done)
Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD
across contents and paths, upstream package/repo coordinates -> @golem15/msd-core
and golem15com/msd-core. Deep links into upstream history, sibling upstream
packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is.

Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line,
package/plugin identity, regenerated lockfile, install-tree fixtures, derived
registries and benchmark baseline; migration checksum baseline re-locked
(MSD keeps its own install state, so no install had applied the old sums);
sort-order and regex-escaped expectations in tests adjusted.
2026-10-06 01:47:40 +02:00

240 lines
9.4 KiB
JavaScript

#!/usr/bin/env node
/**
* gen-hooks-cli-exit.cjs — generates hooks/lib/cli-exit.js from a fresh
* compile of src/cli-exit.cts.
*
* ADR-3889 Phase 7 (#3911): shipped hooks must be able to terminate through
* `terminateNow` WITHOUT depending on any build artifact. hooks/ runs
* straight from a raw, unbuilt clone (a hook is required by name via
* `require('./lib/cli-exit.js')` relative to the hook's own __dirname), so
* the generated file is compiled to a THROWAWAY outDir rather than read from
* msd-core/bin/lib/ — reading the tracked build output would let a stale
* build produce a false green. This mirrors scripts/gen-scripts-cli-exit.cjs
* exactly (same compile-to-temp strategy, same banner/check/write shape);
* kept as a SIBLING script rather than folded into that one because
* gen-scripts-cli-exit.cjs is hard-coded to a single output path/extension
* (`scripts/lib/cli-exit.cjs`) throughout its BANNER prose and REASON
* messaging — parameterizing it for a second, differently-extensioned
* target (`.js`, not `.cjs`, to match the hooks/lib/*.js convention) would
* tangle a script that is otherwise simple and single-purpose.
*
* Usage:
* node scripts/gen-hooks-cli-exit.cjs # same as --write
* node scripts/gen-hooks-cli-exit.cjs --write # write hooks/lib/cli-exit.js
* node scripts/gen-hooks-cli-exit.cjs --check # exit 1 if committed file is stale
* node scripts/gen-hooks-cli-exit.cjs --out <path> # override the output path (default: hooks/lib/cli-exit.js) — honoured by BOTH --write and --check
*
* `--out` restores parity with the sibling scripts/gen-exit-code-registry.cjs,
* which already supports `--out`/`--scripts-out`/`--hooks-out`/`--dts-out`/
* `--sh-out` overrides honoured by its own `--check`. This script hardcoding
* `OUTPUT_PATH` with no override was an inconsistency between two sibling
* generators, not an intentionally narrower surface — tests need to redirect
* `--check` at a disposable tmpdir copy instead of corrupting the real
* committed artifact in place.
*/
'use strict';
const { execFileSync } = require('node:child_process');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const REPO_ROOT = path.resolve(__dirname, '..');
const OUTPUT_PATH = path.join(REPO_ROOT, 'hooks', 'lib', 'cli-exit.js');
const COMPILE_TIMEOUT_MS = 60_000;
/** Frozen reason codes so tests assert on structure, not prose. */
const REASON = Object.freeze({
OK: 'ok_generated_sync',
DRIFTED: 'fail_generated_drifted',
BUILD_FAILED: 'fail_build_failed',
MISSING_EMIT: 'fail_missing_emit',
USAGE: 'fail_usage',
});
const USAGE_MESSAGE = [
'Usage: node scripts/gen-hooks-cli-exit.cjs [--write|--check] [--out <path>]',
' (no flag) same as --write',
' --write write hooks/lib/cli-exit.js',
' --check exit 1 if the committed file is stale',
' --out override the output artifact path (default: hooks/lib/cli-exit.js)',
].join('\n');
const BANNER = [
'// GENERATED FILE — DO NOT EDIT BY HAND.',
'// Source of truth: src/cli-exit.cts. Regenerate with:',
'// node scripts/gen-hooks-cli-exit.cjs --write',
'// Byte-compared by `npm run lint:generated-sync` (#3911, ADR-3889 Phase 7).',
'//',
'// Why this copy exists: hooks/ runs straight from a raw, unbuilt clone — a',
'// shipped hook must be able to `require(\'./lib/cli-exit.js\')` relative to',
'// its own __dirname and terminate through `terminateNow` without depending',
'// on any build artifact. msd-core/bin/lib/cli-exit.cjs is gitignored tsc',
'// output and doubles as the build sentinel, so it cannot be required from',
'// here. `.js`, not `.cjs`, to match the hooks/lib/*.js convention. Hence one',
'// source, three emitted locations (msd-core/bin/lib, scripts/lib, hooks/lib).',
'',
'',
].join('\n');
/** Compile the whole project to a throwaway outDir so the work tree is untouched. */
function compileToTemp() {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-hooks-cli-exit-'));
try {
execFileSync(
process.execPath,
[
path.join(REPO_ROOT, 'node_modules', 'typescript', 'bin', 'tsc'),
'-p', path.join(REPO_ROOT, 'tsconfig.build.json'),
'--outDir', tmp,
// A throwaway outDir must not reuse the in-tree incremental state, or
// tsc skips emit for files it believes are already current.
'--incremental', 'false',
'--tsBuildInfoFile', 'null',
],
{ cwd: REPO_ROOT, encoding: 'utf8', stdio: 'pipe', timeout: COMPILE_TIMEOUT_MS },
);
return { ok: true, dir: tmp };
} catch (err) {
fs.rmSync(tmp, { recursive: true, force: true });
const detail = [err.stdout, err.stderr].filter(Boolean).join('\n').trim();
return { ok: false, detail };
}
}
/**
* Compile src/cli-exit.cts to a throwaway outDir and return the expected
* generated content (banner + compiled bytes, with the sibling registry
* require rewritten to the `.js` extension hooks/lib/ uses), or a failure
* descriptor.
*
* @returns {{ ok: true, content: string } | { ok: false, reason: string, detail?: string }}
*/
function buildExpectedContent() {
const build = compileToTemp();
if (!build.ok) {
return { ok: false, reason: REASON.BUILD_FAILED, detail: build.detail };
}
try {
const emitted = path.join(build.dir, 'cli-exit.cjs');
if (!fs.existsSync(emitted)) {
return { ok: false, reason: REASON.MISSING_EMIT, detail: `no emit produced at ${emitted} from src/cli-exit.cts` };
}
const compiled = fs.readFileSync(emitted, 'utf8');
// hooks/lib/ ships `.js`, not `.cjs` (matching the hooks/lib/*.js
// convention — see the module header), so the sibling registry require
// tsc emits for src/cli-exit.cts's `require('./exit-code-registry.cjs')`
// must be rewritten to resolve the `.js` copy scripts/gen-exit-code-
// registry.cjs emits alongside this file, not the `.cjs` one. A literal,
// anchored replace (never a regex) so this can only ever touch the exact
// string tsc is known to emit for that one import.
const REGISTRY_REQUIRE_CJS = 'require("./exit-code-registry.cjs")';
const REGISTRY_REQUIRE_JS = 'require("./exit-code-registry.js")';
if (!compiled.includes(REGISTRY_REQUIRE_CJS)) {
return {
ok: false,
reason: REASON.MISSING_EMIT,
detail: `expected compiled output to contain ${REGISTRY_REQUIRE_CJS} (the sibling registry require) — tsc's emit shape may have changed`,
};
}
const rewritten = compiled.split(REGISTRY_REQUIRE_CJS).join(REGISTRY_REQUIRE_JS);
return { ok: true, content: BANNER + rewritten };
} finally {
fs.rmSync(build.dir, { recursive: true, force: true });
}
}
function doWrite(outPath) {
const result = buildExpectedContent();
if (!result.ok) {
console.error(`FAIL gen-hooks-cli-exit: ${result.reason}`);
if (result.detail) console.error(result.detail);
return 1;
}
fs.mkdirSync(path.dirname(outPath), { recursive: true });
fs.writeFileSync(outPath, result.content, 'utf8');
console.log(`ok gen-hooks-cli-exit: wrote ${path.relative(REPO_ROOT, outPath)}`);
return 0;
}
function doCheck(outPath) {
const result = buildExpectedContent();
if (!result.ok) {
console.error(`FAIL gen-hooks-cli-exit: ${result.reason}`);
if (result.detail) console.error(result.detail);
return 1;
}
if (!fs.existsSync(outPath)) {
console.error(`FAIL gen-hooks-cli-exit: ${REASON.MISSING_EMIT}`);
console.error(` ${path.relative(REPO_ROOT, outPath)} does not exist. Run:`);
console.error(' node scripts/gen-hooks-cli-exit.cjs --write');
return 1;
}
const committed = fs.readFileSync(outPath, 'utf8');
if (committed !== result.content) {
console.error(`FAIL gen-hooks-cli-exit: ${REASON.DRIFTED}`);
console.error(
` ${path.relative(REPO_ROOT, outPath)} (${committed.length} bytes) != ` +
`compile of src/cli-exit.cts (${result.content.length} bytes)`,
);
console.error('');
console.error('Regenerate with:');
console.error(' node scripts/gen-hooks-cli-exit.cjs --write');
return 1;
}
console.log(`ok gen-hooks-cli-exit: ${path.relative(REPO_ROOT, outPath)} matches src/cli-exit.cts`);
return 0;
}
/**
* @returns {{mode:'write'|'check', outPath:?string}}
*/
function parseArgs(argv) {
let mode = null;
let outPath = null;
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--write' || arg === '--check') {
if (mode !== null) {
throw new Error(`conflicting mode flags: --${mode} and ${arg}`);
}
mode = arg === '--write' ? 'write' : 'check';
} else if (arg === '--out') {
const value = argv[++i];
if (value === undefined) throw new Error('--out requires a value');
outPath = value;
} else if (arg.startsWith('--out=')) {
outPath = arg.slice('--out='.length);
} else {
throw new Error(`unrecognized argument: ${arg}`);
}
}
return { mode: mode || 'write', outPath };
}
function main() {
let args;
try {
args = parseArgs(process.argv.slice(2));
} catch (err) {
console.error(`FAIL gen-hooks-cli-exit: ${REASON.USAGE}`);
console.error(` ${err.message}`);
console.error(USAGE_MESSAGE);
return 1;
}
const outPath = args.outPath || OUTPUT_PATH;
return args.mode === 'check' ? doCheck(outPath) : doWrite(outPath);
}
if (require.main === module) process.exitCode = main();
module.exports = { REASON, buildExpectedContent, OUTPUT_PATH, BANNER };