* feat(#3908): the scanners distinguish an empty diff from one they could not compute collect_files ended 2>/dev/null || true, which destroyed the evidence three ways: the redirect discarded git's diagnostic, the pipe replaced git's status with grep's, and || true forced success regardless. Four distinct conditions - an established-empty diff, a bad ref, no repository, and a repository with no commits - all reported clean, and a secret scanner reporting clean because git failed is indistinguishable from an all-clear to any gate consuming it. git now runs separately from the filter so its status and diagnostic both survive. An established-empty diff exits NO_INPUT; a scope that could not be established exits UNAVAILABLE; the usage sites move off 2 to USAGE. || true is retained on the filter alone, where it is correct: a diff of only images is empty, not failed. Codes are sourced from a generated shell fragment rather than written into three scripts, so a re-allocation cannot desync them, and a missing fragment fails loudly instead of falling back to literals. The security workflow is updated in the same change: without it, a docs-only PR would newly fail the job. * fix(#3908): keep scanner stderr out of the file list, and drop try/finally from test bodies Capturing git and find output with 2>&1 was right for the failure path but wrong for the success path: a warning emitted alongside a successful diff flowed into the file list and was treated as a filename. stderr is now captured separately, forwarded as a warning on success and as the diagnostic on failure, and never folded into the list. Also converts the control tests' try/finally blocks to t.after(), which CONTRIBUTING bans inside a test body because it masks failures. * chore(#3908): backfill changeset pr number * docs(#3908): record the scanners' four-outcome exit contract SECURITY.md is root-level, so the docs gate correctly held: a Changed fragment owes a file under docs/. The contract also belongs where the feature is described, as REQ-SCAN-INJ-05. docs/FEATURES.md is GENERATED from per-feature fragments (#3840) - the first edit went into the generated file and gen-features --check caught it, which is the same edit-the-output drift this epic exists to close. The fragment is the source; FEATURES.md is regenerated. --------- Co-authored-by: sim <sim@local>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* gen-exit-code-registry.cjs — generates THREE byte-identical/derived
|
||||
* gen-exit-code-registry.cjs — generates FOUR byte-identical/derived
|
||||
* artifacts from the declaration at gsd-core/bin/shared/exit-codes.json:
|
||||
* - gsd-core/bin/lib/exit-code-registry.cjs (tsc-adjacent build tree)
|
||||
* - scripts/lib/exit-code-registry.cjs (committed, for scripts/
|
||||
@@ -12,28 +12,34 @@
|
||||
* against the shape the .cjs artifacts above actually export — generated
|
||||
* from the SAME ENTRY_FIELD_TYPES table serializeRegistry() uses, so the
|
||||
* two can never independently drift).
|
||||
* - gsd-core/bin/shared/exit-codes.sh (POSIX sh, safe under
|
||||
* `set -u`: one `export EXIT_<NAME>=<code>` per entry, sourced by the
|
||||
* bash scanners under scripts/ so a shell caller never re-invents a
|
||||
* literal exit-code integer — ADR-3889 Phase 4, #3908).
|
||||
*
|
||||
* ADR-3889 ("One exit-code registry — 0 and 1 are free, everything else is
|
||||
* allocated") Phase 1 (#3905) built the single-output allocator; Phase 2
|
||||
* (#3906) added the second .cjs emission so scripts/ has its own committed
|
||||
* copy instead of reaching into gitignored build output, and a follow-up
|
||||
* closed the review finding that the .d.cts was hand-maintained with no gate
|
||||
* by generating it here too.
|
||||
* copy instead of reaching into gitignored build output; a follow-up closed
|
||||
* the review finding that the .d.cts was hand-maintained with no gate by
|
||||
* generating it here too; Phase 4 (#3908) added the shell fragment so the
|
||||
* three bash scanners can source symbolic names instead of hardcoding
|
||||
* integers.
|
||||
*
|
||||
* The two .cjs artifacts are byte-identical: serializeRegistry() only encodes
|
||||
* the DECLARATION path (for the banner comment), never the output path, so
|
||||
* one generated string is written to both locations unchanged. The .d.cts is
|
||||
* a separate, smaller derivation (a structural type, not a per-entry table)
|
||||
* but is generated and --check-gated exactly the same way.
|
||||
* one generated string is written to both locations unchanged. The .d.cts
|
||||
* and .sh artifacts are separate, smaller derivations but are generated and
|
||||
* --check-gated exactly the same way.
|
||||
*
|
||||
* Nothing in this script emits a registered exit code itself; wiring
|
||||
* consumers onto the registry is separate work.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/gen-exit-code-registry.cjs # same as --write
|
||||
* node scripts/gen-exit-code-registry.cjs --write # write all three artifacts
|
||||
* node scripts/gen-exit-code-registry.cjs --write # write all four artifacts
|
||||
* node scripts/gen-exit-code-registry.cjs --check # exit 1 if ANY committed artifact is stale
|
||||
* node scripts/gen-exit-code-registry.cjs --declaration <path> --out <path> --scripts-out <path> --dts-out <path> # override for tests
|
||||
* node scripts/gen-exit-code-registry.cjs --declaration <path> --out <path> --scripts-out <path> --dts-out <path> --sh-out <path> # override for tests
|
||||
* node scripts/gen-exit-code-registry.cjs --json # emit ONE JSON report on stdout instead of human prose
|
||||
*/
|
||||
|
||||
@@ -47,6 +53,7 @@ const DEFAULT_DECLARATION_PATH = path.join(REPO_ROOT, 'gsd-core', 'bin', 'shared
|
||||
const DEFAULT_OUTPUT_PATH = path.join(REPO_ROOT, 'gsd-core', 'bin', 'lib', 'exit-code-registry.cjs');
|
||||
const DEFAULT_SCRIPTS_OUTPUT_PATH = path.join(REPO_ROOT, 'scripts', 'lib', 'exit-code-registry.cjs');
|
||||
const DEFAULT_DTS_OUTPUT_PATH = path.join(REPO_ROOT, 'src', 'exit-code-registry.d.cts');
|
||||
const DEFAULT_SH_OUTPUT_PATH = path.join(REPO_ROOT, 'gsd-core', 'bin', 'shared', 'exit-codes.sh');
|
||||
|
||||
/**
|
||||
* Single source of the entry field list (name -> TS type), in emission order.
|
||||
@@ -82,14 +89,15 @@ const REASON = Object.freeze({
|
||||
});
|
||||
|
||||
const USAGE_MESSAGE = [
|
||||
'Usage: node scripts/gen-exit-code-registry.cjs [--write|--check] [--declaration <path>] [--out <path>] [--scripts-out <path>] [--dts-out <path>] [--json]',
|
||||
'Usage: node scripts/gen-exit-code-registry.cjs [--write|--check] [--declaration <path>] [--out <path>] [--scripts-out <path>] [--dts-out <path>] [--sh-out <path>] [--json]',
|
||||
' (no flag) same as --write',
|
||||
' --write write all three generated registry artifacts',
|
||||
' --write write all four generated registry artifacts',
|
||||
' --check exit 1 if ANY committed artifact is stale',
|
||||
' --declaration override the declaration path (default: gsd-core/bin/shared/exit-codes.json)',
|
||||
' --out override the primary output artifact path (default: gsd-core/bin/lib/exit-code-registry.cjs)',
|
||||
' --scripts-out override the secondary output artifact path (default: scripts/lib/exit-code-registry.cjs)',
|
||||
' --dts-out override the ambient type declaration path (default: src/exit-code-registry.d.cts)',
|
||||
' --sh-out override the shell-sourceable fragment path (default: gsd-core/bin/shared/exit-codes.sh)',
|
||||
' --json emit ONE JSON report ({ok, reason, context, detail?}) on stdout instead of human-readable prose',
|
||||
].join('\n');
|
||||
|
||||
@@ -435,6 +443,42 @@ function serializeDts(declarationPath) {
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the shell-sourceable fragment: one `export EXIT_<NAME>=<code>`
|
||||
* line per declared entry, POSIX sh, safe under `set -u` (a sourced file that
|
||||
* only ever ASSIGNS variables can never trip an unset-variable check,
|
||||
* regardless of what the caller's shell had in scope beforehand).
|
||||
*
|
||||
* Consumed by the bash scanners under scripts/ via `. gsd-core/bin/shared/
|
||||
* exit-codes.sh` (ADR-3889 Phase 4, #3908) so a shell caller resolves a
|
||||
* symbolic name instead of hardcoding a literal integer that can silently
|
||||
* drift from the registry.
|
||||
*/
|
||||
function serializeSh(entries, declarationPath) {
|
||||
const relDeclaration = path.relative(REPO_ROOT, declarationPath).split(path.sep).join('/');
|
||||
const banner = [
|
||||
'#!/bin/sh',
|
||||
'# GENERATED FILE — DO NOT EDIT BY HAND.',
|
||||
`# Source of truth: ${relDeclaration}. Regenerate with:`,
|
||||
'# node scripts/gen-exit-code-registry.cjs --write',
|
||||
'#',
|
||||
'# One `export EXIT_<NAME>=<code>` per gsd-core/bin/shared/exit-codes.json',
|
||||
'# entry (ADR-3889 §2, #3905/#3906/#3908). POSIX sh, safe under `set -u`:',
|
||||
'# sourcing this file only ever ASSIGNS variables, never reads one, so it',
|
||||
'# cannot trip an unset-variable check regardless of the caller\'s existing',
|
||||
'# environment.',
|
||||
'#',
|
||||
'# Usage (from a scanner under scripts/):',
|
||||
'# . "$(dirname "$0")/../gsd-core/bin/shared/exit-codes.sh"',
|
||||
'# exit "$EXIT_UNAVAILABLE"',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const lines = entries.map((e) => `export EXIT_${e.name}=${e.code}`);
|
||||
|
||||
return banner + lines.join('\n') + '\n';
|
||||
}
|
||||
|
||||
/**
|
||||
* Load, validate, and serialize the declaration in one step.
|
||||
* @returns {{ok:true,content:string}|{ok:false,reason:string,message:string}}
|
||||
@@ -446,7 +490,7 @@ function buildRegistryContent(declarationPath) {
|
||||
const validated = validateEntries(loaded.entries);
|
||||
if (!validated.ok) return validated;
|
||||
|
||||
return { ok: true, content: serializeRegistry(loaded.entries, declarationPath) };
|
||||
return { ok: true, content: serializeRegistry(loaded.entries, declarationPath), entries: loaded.entries };
|
||||
}
|
||||
|
||||
function printFail(result) {
|
||||
@@ -487,7 +531,7 @@ function emitOk(reason, humanMessage, json) {
|
||||
console.log(humanMessage);
|
||||
}
|
||||
|
||||
function doWrite(declarationPath, outPath, scriptsOutPath, dtsPath, json) {
|
||||
function doWrite(declarationPath, outPath, scriptsOutPath, dtsPath, shPath, json) {
|
||||
const result = buildRegistryContent(declarationPath);
|
||||
if (!result.ok) {
|
||||
emitFail(result, json);
|
||||
@@ -503,10 +547,13 @@ function doWrite(declarationPath, outPath, scriptsOutPath, dtsPath, json) {
|
||||
const dtsContent = serializeDts(declarationPath);
|
||||
fs.mkdirSync(path.dirname(dtsPath), { recursive: true });
|
||||
fs.writeFileSync(dtsPath, dtsContent, 'utf8');
|
||||
const shContent = serializeSh(result.entries, declarationPath);
|
||||
fs.mkdirSync(path.dirname(shPath), { recursive: true });
|
||||
fs.writeFileSync(shPath, shContent, 'utf8');
|
||||
emitOk(
|
||||
REASON.OK,
|
||||
`ok gen-exit-code-registry: wrote ${outPath}\nok gen-exit-code-registry: wrote ${scriptsOutPath}\n`
|
||||
+ `ok gen-exit-code-registry: wrote ${dtsPath}`,
|
||||
+ `ok gen-exit-code-registry: wrote ${dtsPath}\nok gen-exit-code-registry: wrote ${shPath}`,
|
||||
json,
|
||||
);
|
||||
return 0;
|
||||
@@ -542,23 +589,25 @@ function checkOneArtifact(artifactLabel, artifactPath, content) {
|
||||
}
|
||||
|
||||
/**
|
||||
* --check verifies ALL THREE committed artifacts against the same freshly
|
||||
* --check verifies ALL FOUR committed artifacts against the same freshly
|
||||
* generated content and fails naming which one drifted (or is missing) if
|
||||
* any does. Checked in a fixed order (primary, secondary, dts) so a
|
||||
* any does. Checked in a fixed order (primary, secondary, dts, sh) so a
|
||||
* single-artifact failure is always reported deterministically.
|
||||
*/
|
||||
function doCheck(declarationPath, outPath, scriptsOutPath, dtsPath, json) {
|
||||
function doCheck(declarationPath, outPath, scriptsOutPath, dtsPath, shPath, json) {
|
||||
const result = buildRegistryContent(declarationPath);
|
||||
if (!result.ok) {
|
||||
emitFail(result, json);
|
||||
return 1;
|
||||
}
|
||||
const dtsContent = serializeDts(declarationPath);
|
||||
const shContent = serializeSh(result.entries, declarationPath);
|
||||
|
||||
const artifacts = [
|
||||
['primary', outPath, result.content],
|
||||
['secondary', scriptsOutPath, result.content],
|
||||
['dts', dtsPath, dtsContent],
|
||||
['sh', shPath, shContent],
|
||||
];
|
||||
for (const [artifactLabel, artifactPath, content] of artifacts) {
|
||||
const checked = checkOneArtifact(artifactLabel, artifactPath, content);
|
||||
@@ -572,14 +621,15 @@ function doCheck(declarationPath, outPath, scriptsOutPath, dtsPath, json) {
|
||||
REASON.OK,
|
||||
`ok gen-exit-code-registry: ${outPath} matches ${declarationPath}\n`
|
||||
+ `ok gen-exit-code-registry: ${scriptsOutPath} matches ${declarationPath}\n`
|
||||
+ `ok gen-exit-code-registry: ${dtsPath} matches ${declarationPath}`,
|
||||
+ `ok gen-exit-code-registry: ${dtsPath} matches ${declarationPath}\n`
|
||||
+ `ok gen-exit-code-registry: ${shPath} matches ${declarationPath}`,
|
||||
json,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {{mode:'write'|'check', declarationPath:?string, outPath:?string, scriptsOutPath:?string, dtsPath:?string, json:boolean}}
|
||||
* @returns {{mode:'write'|'check', declarationPath:?string, outPath:?string, scriptsOutPath:?string, dtsPath:?string, shPath:?string, json:boolean}}
|
||||
*/
|
||||
function parseArgs(argv) {
|
||||
let mode = null;
|
||||
@@ -587,6 +637,7 @@ function parseArgs(argv) {
|
||||
let outPath = null;
|
||||
let scriptsOutPath = null;
|
||||
let dtsPath = null;
|
||||
let shPath = null;
|
||||
let json = false;
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
@@ -622,12 +673,18 @@ function parseArgs(argv) {
|
||||
dtsPath = value;
|
||||
} else if (arg.startsWith('--dts-out=')) {
|
||||
dtsPath = arg.slice('--dts-out='.length);
|
||||
} else if (arg === '--sh-out') {
|
||||
const value = argv[++i];
|
||||
if (value === undefined) throw new Error('--sh-out requires a value');
|
||||
shPath = value;
|
||||
} else if (arg.startsWith('--sh-out=')) {
|
||||
shPath = arg.slice('--sh-out='.length);
|
||||
} else {
|
||||
throw new Error(`unrecognized argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { mode: mode || 'write', declarationPath, outPath, scriptsOutPath, dtsPath, json };
|
||||
return { mode: mode || 'write', declarationPath, outPath, scriptsOutPath, dtsPath, shPath, json };
|
||||
}
|
||||
|
||||
function main() {
|
||||
@@ -649,10 +706,11 @@ function main() {
|
||||
const outPath = args.outPath || DEFAULT_OUTPUT_PATH;
|
||||
const scriptsOutPath = args.scriptsOutPath || DEFAULT_SCRIPTS_OUTPUT_PATH;
|
||||
const dtsPath = args.dtsPath || DEFAULT_DTS_OUTPUT_PATH;
|
||||
const shPath = args.shPath || DEFAULT_SH_OUTPUT_PATH;
|
||||
|
||||
return args.mode === 'check'
|
||||
? doCheck(declarationPath, outPath, scriptsOutPath, dtsPath, args.json)
|
||||
: doWrite(declarationPath, outPath, scriptsOutPath, dtsPath, args.json);
|
||||
? doCheck(declarationPath, outPath, scriptsOutPath, dtsPath, shPath, args.json)
|
||||
: doWrite(declarationPath, outPath, scriptsOutPath, dtsPath, shPath, args.json);
|
||||
}
|
||||
|
||||
if (require.main === module) process.exitCode = main();
|
||||
@@ -664,6 +722,7 @@ module.exports = {
|
||||
DEFAULT_OUTPUT_PATH,
|
||||
DEFAULT_SCRIPTS_OUTPUT_PATH,
|
||||
DEFAULT_DTS_OUTPUT_PATH,
|
||||
DEFAULT_SH_OUTPUT_PATH,
|
||||
ENTRY_FIELD_TYPES,
|
||||
isAllocatableCode,
|
||||
bandFor,
|
||||
@@ -672,6 +731,7 @@ module.exports = {
|
||||
loadDeclaration,
|
||||
serializeRegistry,
|
||||
serializeDts,
|
||||
serializeSh,
|
||||
buildRegistryContent,
|
||||
parseArgs,
|
||||
main,
|
||||
|
||||
Reference in New Issue
Block a user