enhance(#3908): the scanners distinguish an empty diff from one they could not compute (#3937)

* 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:
Tom Boucher
2026-08-27 13:11:13 -04:00
committed by GitHub
parent 7f3119a29f
commit 1e67ec9737
31 changed files with 681 additions and 78 deletions

View File

@@ -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,