enhance(#3907): gates report no-input instead of a verdict they never reached (#3932)

* feat(#3907): gates report no-input instead of asserting a verdict they never reached

The three stdin-reading gates bound 2 to a stdin read error only, with no arm for stdin closed at zero bytes - so empty input flowed into the detector, found nothing, and exited 1, which each module's own comment defines as a negative verdict. An unset PHASE_SECTION made the UI gate assert the phase has no UI. Empty and whitespace-only input now exit NO_INPUT, and a read error exits UNAVAILABLE rather than a locally-invented 2, both resolved through the registry and delivered by terminateNow.

The exit code was only half of it: under --json the same input emitted {detected:false}, byte-identical to the fabricated payload #3909 exists to fix, and the blocking coverage gate reads that payload. Empty input now emits the in-tree {skipped:true,reason} form with no detected key at all.

teams-status is excluded: it never reads stdin and has no invented 2, so the four-module framing in the issue and ADR is wrong. The dead root bin/lib/ui-safety-gate.cjs is deleted - no installer reference, no workflow invocation, and the live fallback chains are for other modules. Its removal restores the unit tests to the module that actually ships; they had been asserting the stale copy's two-field shape, which is why it drifted unnoticed.

* fix(#3907): drive gate tests through the process seam, and make removed-but-needed basename-precise

CONTRIBUTING requires every subprocess go through tests/helpers/process-seam.cjs; two of the three gate suites hand-rolled spawnSync while the third, added in the same change, used runNode correctly for the identical injection case. Converted the blocks this change added, leaving pre-existing ones alone.

Deleting one of two files sharing a basename made lint-removed-but-needed report 14 references that were all to the surviving canonical module - the false-positive class its own docstring names. It now matches on the deleted file's full path when a surviving file shares its basename, which is more precise rather than weaker: a genuine full-path reference still fails, and behaviour is unchanged when no basename collides. It immediately caught a docstring on this branch that spelled the deleted path.

* test(#3907): update the one existing assertion that pinned the old empty-stdin verdict

A pre-existing test asserted exit 1 on empty stdin - the defect this phase removes - and was missed because the change added new blocks without auditing existing ones pinning the old contract. Audited the rest: the other three status-1 assertions in that file all feed real input and are the genuine-negative controls that must keep returning 1, so exactly one was stale. The retired 2 is gone from the describe's contract comment too.

* chore(#3907): backfill changeset pr number

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-27 11:37:31 -04:00
committed by GitHub
parent ad6abc8965
commit c5f2b94b27
16 changed files with 838 additions and 177 deletions

View File

@@ -1003,3 +1003,115 @@ describe('api-coverage CLI — STDIN + exit codes', () => {
assert.strictEqual(r.exitCode, 1);
});
});
// ──────────────────────────────────────────────────────────────────────────────
// ADR-3889 Phase 3 (#3907): NO_INPUT / UNAVAILABLE — empty/whitespace-only
// stdin and a stdin read error must not be reported as the authoritative
// "no integration" verdict (exit 1). Spawns the REAL module (not the pure
// detector) and asserts on the child's exit status + parsed stdout, per
// RULESET.TESTS.
// ──────────────────────────────────────────────────────────────────────────────
describe('api-coverage CLI — NO_INPUT / UNAVAILABLE (ADR-3889 Phase 3, #3907)', () => {
const CLI = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'api-coverage.cjs');
const INJECT_STDIN_ERROR = path.join(__dirname, 'helpers', 'inject-stdin-error.cjs');
const { exitCodeFor } = require('../gsd-core/bin/lib/exit-code-registry.cjs');
const { runNode } = require('./helpers/process-seam.cjs');
const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
function runCliJson(stdin, extraArgs = [], extraEnv = {}) {
const r = runNode([CLI, '--json', ...extraArgs], {
input: stdin,
timeoutMs: PROBE_TIMEOUT_MS,
env: { ...process.env, ...extraEnv },
});
return { exitCode: r.exitCode, stdout: r.stdout, stderr: r.stderr };
}
// ── The controls (load-bearing): without these, "always return NO_INPUT"
// would satisfy the empty/whitespace-only assertions below. ──────────────
test('control: detected input still exits 0 with unchanged --json payload', () => {
const r = runCliJson('Integrate the Stripe API for payments');
assert.strictEqual(r.exitCode, 0);
const body = JSON.parse(r.stdout);
assert.strictEqual(body.detected, true);
assert.ok(!('skipped' in body), 'a detected result must not carry a skipped key');
});
test('control: genuine-negative (real input, no signal) still exits 1, not NO_INPUT', () => {
const r = runCliJson('Refactor the login helper');
assert.strictEqual(r.exitCode, 1);
const body = JSON.parse(r.stdout);
assert.strictEqual(body.detected, false);
assert.deepStrictEqual(body.signals, []);
assert.ok(!('skipped' in body), 'a genuine no-signal result must not carry a skipped key');
});
test('empty stdin exits NO_INPUT (registry integer, never hardcoded)', () => {
const r = runCliJson('');
assert.strictEqual(r.exitCode, exitCodeFor('NO_INPUT'));
});
test('whitespace-only stdin (spaces / newlines / tabs / CRLF) exits NO_INPUT', () => {
for (const ws of [' ', '\n\n\n', '\t\t\t', '\r\n\r\n', ' \n\t\r\n ']) {
const r = runCliJson(ws);
assert.strictEqual(r.exitCode, exitCodeFor('NO_INPUT'), `ws=${JSON.stringify(ws)}`);
}
});
test('"x" and " x " are REAL input — must NOT be NO_INPUT', () => {
const bare = runCliJson('x');
assert.notStrictEqual(bare.exitCode, exitCodeFor('NO_INPUT'));
assert.strictEqual(bare.exitCode, 1);
const padded = runCliJson(' x ');
assert.notStrictEqual(padded.exitCode, exitCodeFor('NO_INPUT'));
assert.strictEqual(padded.exitCode, 1);
});
test('a NUL byte is real input (not stripped by whitespace trimming)', () => {
const r = runCliJson('\0');
assert.notStrictEqual(r.exitCode, exitCodeFor('NO_INPUT'));
assert.strictEqual(r.exitCode, 1);
});
test('--json empty stdin: {"skipped":true,"reason":"no_input"} with NO detected key', () => {
const r = runCliJson('');
const body = JSON.parse(r.stdout);
assert.deepStrictEqual(body, { skipped: true, reason: 'no_input' });
assert.ok(!('detected' in body), 'skipped payload must not carry a detected key');
});
test('--verbs foo with empty stdin is still NO_INPUT (flag does not bypass the input check)', () => {
const r = runCliJson('', ['--verbs', 'foo']);
assert.strictEqual(r.exitCode, exitCodeFor('NO_INPUT'));
});
test('empty --verbs/--nouns value still restores curated defaults (unrelated to stdin gating)', () => {
const r = runCliJson('Integrate the Stripe API for payments', ['--verbs', '', '--nouns', '']);
assert.strictEqual(r.exitCode, 0, 'empty flag values must fall back to defaults → still detects');
const body = JSON.parse(r.stdout);
assert.strictEqual(body.detected, true);
});
test('a stdin read error exits UNAVAILABLE (injected via monkeypatched process.stdin, not chmod)', () => {
const r = runNode(['-r', INJECT_STDIN_ERROR, CLI, '--json'], { timeoutMs: PROBE_TIMEOUT_MS });
assert.strictEqual(r.exitCode, exitCodeFor('UNAVAILABLE'));
const body = JSON.parse(r.stdout);
assert.deepStrictEqual(body, { skipped: true, reason: 'stdin_error' });
assert.ok(!('detected' in body));
assert.notStrictEqual(body.reason, 'no_input', 'stdin_error must be distinguishable from no_input');
});
test('NO_INPUT / UNAVAILABLE codes are identical under GSD_EXIT_CONTRACT=v1 and v2', () => {
for (const version of ['v1', 'v2']) {
const emptyResult = runCliJson('', [], { GSD_EXIT_CONTRACT: version });
assert.strictEqual(emptyResult.exitCode, exitCodeFor('NO_INPUT'), `NO_INPUT under ${version}`);
const errResult = runNode(['-r', INJECT_STDIN_ERROR, CLI, '--json'], {
timeoutMs: PROBE_TIMEOUT_MS,
env: { ...process.env, GSD_EXIT_CONTRACT: version },
});
assert.strictEqual(errResult.exitCode, exitCodeFor('UNAVAILABLE'), `UNAVAILABLE under ${version}`);
}
});
});

View File

@@ -6,13 +6,15 @@
* rendered prose — per RULESET.TESTS (no raw text matching on outputs).
*
* The detector mirrors ui-safety-gate.cts: a pure function plus a STDIN-reading
* CLI (exit 0 = signal detected, 1 = none, 2 = usage error).
* CLI (exit 0 = signal detected, 1 = none; NO_INPUT/UNAVAILABLE registry codes
* for empty/whitespace-only stdin and a stdin read error, per ADR-3889 Phase 3).
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const { spawnSync } = require('node:child_process');
const path = require('node:path');
const { exitCodeFor } = require('../gsd-core/bin/lib/exit-code-registry.cjs');
const MODULE_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'assumption-delta.cjs');
@@ -174,7 +176,10 @@ describe('detectAssumptionDelta — pure detector (#1561)', () => {
});
describe('assumption-delta CLI — STDIN exit codes (mirrors ui-safety-gate)', () => {
// Exit code contract: 0 = signal detected, 1 = none, 2 = usage/startup error.
// Exit code contract: 0 = signal detected, 1 = genuine negative (real input,
// no signal). Empty/whitespace-only stdin and a stdin read error are NOT
// "1" — nothing was examined, so they resolve NO_INPUT / UNAVAILABLE via
// the exit-code registry (see the ADR-3889 Phase 3 describe block below).
function runCli(stdin) {
const res = spawnSync(process.execPath, [MODULE_PATH], {
input: stdin,
@@ -194,9 +199,13 @@ describe('assumption-delta CLI — STDIN exit codes (mirrors ui-safety-gate)', (
assert.strictEqual(r.status, 1);
});
test('exit 1 on empty stdin (no signal)', () => {
// Empty stdin is NOT a "no signal" verdict — nothing was examined, so it
// must be distinct from the genuine-negative case directly above (real
// input, no cue found). Per ADR-3889 Phase 3 (#3907), it resolves the
// registry's NO_INPUT code, never a hardcoded exit status.
test('exit NO_INPUT on empty stdin (nothing examined, not a genuine negative)', () => {
const r = runCli('');
assert.strictEqual(r.status, 1);
assert.strictEqual(r.status, exitCodeFor('NO_INPUT'));
});
test('--json emits typed IR with detected field on stdout (exit 0)', () => {
@@ -249,6 +258,115 @@ describe('assumption-delta CLI — STDIN exit codes (mirrors ui-safety-gate)', (
});
});
// ──────────────────────────────────────────────────────────────────────────────
// ADR-3889 Phase 3 (#3907): NO_INPUT / UNAVAILABLE — empty/whitespace-only
// stdin and a stdin read error must not be reported as the authoritative
// "no signal" verdict (exit 1). Spawns the REAL module and asserts on the
// child's exit status + parsed stdout, per RULESET.TESTS.
// ──────────────────────────────────────────────────────────────────────────────
describe('assumption-delta CLI — NO_INPUT / UNAVAILABLE (ADR-3889 Phase 3, #3907)', () => {
const INJECT_STDIN_ERROR = path.join(__dirname, 'helpers', 'inject-stdin-error.cjs');
const { runNode } = require('./helpers/process-seam.cjs');
const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
function runCliJson(stdin, extraArgs = [], extraEnv = {}) {
const r = runNode([MODULE_PATH, '--json', ...extraArgs], {
input: stdin,
timeoutMs: PROBE_TIMEOUT_MS,
env: { ...process.env, ...extraEnv },
});
return { exitCode: r.exitCode, stdout: r.stdout, stderr: r.stderr };
}
// ── The controls (load-bearing): without these, "always return NO_INPUT"
// would satisfy the empty/whitespace-only assertions below. ──────────────
test('control: detected input still exits 0 with unchanged --json payload', () => {
const r = runCliJson('This phase adds a second platform alongside the existing one.');
assert.strictEqual(r.exitCode, 0);
const body = JSON.parse(r.stdout);
assert.strictEqual(body.detected, true);
assert.ok(!('skipped' in body));
});
test('control: genuine-negative (real input, no signal) still exits 1, not NO_INPUT', () => {
const r = runCliJson('Refactor the login function to be smaller.');
assert.strictEqual(r.exitCode, 1);
const body = JSON.parse(r.stdout);
assert.strictEqual(body.detected, false);
assert.deepStrictEqual(body.signals, []);
assert.ok(!('skipped' in body));
});
test('empty stdin exits NO_INPUT (registry integer, never hardcoded)', () => {
const r = runCliJson('');
assert.strictEqual(r.exitCode, exitCodeFor('NO_INPUT'));
});
test('whitespace-only stdin (spaces / newlines / tabs / CRLF) exits NO_INPUT', () => {
for (const ws of [' ', '\n\n\n', '\t\t\t', '\r\n\r\n', ' \n\t\r\n ']) {
const r = runCliJson(ws);
assert.strictEqual(r.exitCode, exitCodeFor('NO_INPUT'), `ws=${JSON.stringify(ws)}`);
}
});
test('"x" and " x " are REAL input — must NOT be NO_INPUT', () => {
const bare = runCliJson('x');
assert.notStrictEqual(bare.exitCode, exitCodeFor('NO_INPUT'));
assert.strictEqual(bare.exitCode, 1);
const padded = runCliJson(' x ');
assert.notStrictEqual(padded.exitCode, exitCodeFor('NO_INPUT'));
assert.strictEqual(padded.exitCode, 1);
});
test('a NUL byte is real input (not stripped by whitespace trimming)', () => {
const r = runCliJson('\0');
assert.notStrictEqual(r.exitCode, exitCodeFor('NO_INPUT'));
assert.strictEqual(r.exitCode, 1);
});
test('--json empty stdin: {"skipped":true,"reason":"no_input"} with NO detected key', () => {
const r = runCliJson('');
const body = JSON.parse(r.stdout);
assert.deepStrictEqual(body, { skipped: true, reason: 'no_input' });
assert.ok(!('detected' in body));
});
test('--terms foo with empty stdin is still NO_INPUT (flag does not bypass the input check)', () => {
const r = runCliJson('', ['--terms', 'foo']);
assert.strictEqual(r.exitCode, exitCodeFor('NO_INPUT'));
});
test('empty --terms value still restores curated defaults (unrelated to stdin gating)', () => {
const r = runCliJson('adds a second platform', ['--terms', '']);
assert.strictEqual(r.exitCode, 0, 'empty --terms must fall back to defaults → still detects');
const body = JSON.parse(r.stdout);
assert.strictEqual(body.detected, true);
});
test('a stdin read error exits UNAVAILABLE (injected via monkeypatched process.stdin, not chmod)', () => {
const r = runNode(['-r', INJECT_STDIN_ERROR, MODULE_PATH, '--json'], { timeoutMs: PROBE_TIMEOUT_MS });
assert.strictEqual(r.exitCode, exitCodeFor('UNAVAILABLE'));
const body = JSON.parse(r.stdout);
assert.deepStrictEqual(body, { skipped: true, reason: 'stdin_error' });
assert.ok(!('detected' in body));
assert.notStrictEqual(body.reason, 'no_input', 'stdin_error must be distinguishable from no_input');
});
test('NO_INPUT / UNAVAILABLE codes are identical under GSD_EXIT_CONTRACT=v1 and v2', () => {
for (const version of ['v1', 'v2']) {
const emptyResult = runCliJson('', [], { GSD_EXIT_CONTRACT: version });
assert.strictEqual(emptyResult.exitCode, exitCodeFor('NO_INPUT'), `NO_INPUT under ${version}`);
const errResult = runNode(['-r', INJECT_STDIN_ERROR, MODULE_PATH, '--json'], {
timeoutMs: PROBE_TIMEOUT_MS,
env: { ...process.env, GSD_EXIT_CONTRACT: version },
});
assert.strictEqual(errResult.exitCode, exitCodeFor('UNAVAILABLE'), `UNAVAILABLE under ${version}`);
}
});
});
// ─── Hardening (Codex Step-4 review fixes) ────────────────────────────────────
describe('assumption-delta hardening (Codex review)', () => {
const { detectAssumptionDelta } = require(MODULE_PATH);

View File

@@ -25,6 +25,7 @@ const os = require('node:os');
const path = require('node:path');
const { cleanup } = require('./helpers.cjs');
const { copyScriptWithDeps } = require('./helpers/copy-script-fixture.cjs');
const { computeUiSafetyGate } = require('../gsd-core/bin/lib/check-command-router.cjs');
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -276,9 +277,9 @@ describe('computeUiSafetyGate — ui.safety-gate check logic (#1168)', () => {
* Root cause (#3718): shell-based invocation (with locale env-var prefix) silently
* degrades on Windows PowerShell — the prefix is not recognised by pwsh.
*
* Fix (#3718, Approach A): gate logic moved to `bin/lib/ui-safety-gate.cjs` (Node.js).
* Fix (#3718, Approach A): gate logic moved to `gsd-core/bin/lib/ui-safety-gate.cjs` (Node.js).
* Reads phase text from STDIN (not argv) to avoid OS ARG_MAX limits.
* Invoked as: printf '%s' "$PHASE_SECTION" | node "${GSD_REPO_ROOT}/bin/lib/ui-safety-gate.cjs"
* Invoked as: printf '%s' "$PHASE_SECTION" | node "${GSD_REPO_ROOT}/gsd-core/bin/lib/ui-safety-gate.cjs"
* Path anchored to repo root via `git rev-parse --show-toplevel`.
*
* Test strategy:
@@ -300,7 +301,7 @@ const { runNode, runHook } = require('./helpers/process-seam.cjs');
const { toLegacyResult } = require('./helpers/git-fixture.cjs');
const { PROBE_TIMEOUT_MS, HOOK_FANOUT_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
const HELPER_PATH = path.join(__dirname, '..', 'bin', 'lib', 'ui-safety-gate.cjs');
const HELPER_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'ui-safety-gate.cjs');
const PLAN_PHASE_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'plan-phase.md');
const AUTONOMOUS_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'autonomous.md');
const AUTONOMOUS_UI_DESIGN_CONTRACT_REF_PATH = path.join(
@@ -418,9 +419,15 @@ describe('Cross-shell portability — spawnSync with shell:false, stdin (#3718)'
assert.strictEqual(result.status, 1, `Expected exit 1 (no UI), got ${result.status}. stderr: ${result.stderr}`);
});
test('spawn with shell:false + stdin: empty input exits 1', () => {
test('spawn with shell:false + stdin: empty input exits NO_INPUT (#3907)', () => {
// See the `gsd-core/bin/lib/ui-safety-gate.cjs CLI — NO_INPUT / UNAVAILABLE`
// describe block below for the full NO_INPUT/UNAVAILABLE coverage matrix.
const { exitCodeFor } = require('../gsd-core/bin/lib/exit-code-registry.cjs');
const result = spawnGate('');
assert.strictEqual(result.status, 1, `Expected exit 1 (no UI for empty input), got ${result.status}. stderr: ${result.stderr}`);
assert.strictEqual(
result.status, exitCodeFor('NO_INPUT'),
`Expected NO_INPUT (${exitCodeFor('NO_INPUT')}) for empty input, got ${result.status}. stderr: ${result.stderr}`,
);
});
test('spawn with shell:false + stdin: CRLF line endings handled correctly', () => {
@@ -449,6 +456,120 @@ describe('Cross-shell portability — spawnSync with shell:false, stdin (#3718)'
});
});
// ── ADR-3889 Phase 3 (#3907): NO_INPUT / UNAVAILABLE exit codes ───────────────
// The prior single "2 = startup error" arm was bound to stdin.on('error') only
// — there was no arm for stdin closed with ZERO BYTES, so empty input flowed
// into the detector and exited 1 ("no UI"), asserting a verdict about input
// that was never examined. These tests spawn the REAL module and assert on
// the child's exit status, per RULESET.TESTS.
//
// NOTE (scope note): this block targets `gsd-core/bin/lib/ui-safety-gate.cjs`
// — the tsc-compiled output of `src/ui-safety-gate.cts`, the module #3907
// fixed. That is also the only shipped copy: the hand-written root
// `bin/lib/ui-safety-gate.cjs` was removed as dead code (#3907) — no
// installer reference, no workflow invocation, no fallback chain — so there
// is no second copy to keep in sync.
describe('gsd-core/bin/lib/ui-safety-gate.cjs CLI — NO_INPUT / UNAVAILABLE (ADR-3889 Phase 3, #3907)', () => {
const { exitCodeFor } = require('../gsd-core/bin/lib/exit-code-registry.cjs');
const GSD_CORE_HELPER_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'ui-safety-gate.cjs');
const INJECT_STDIN_ERROR = path.join(__dirname, 'helpers', 'inject-stdin-error.cjs');
function spawnCompiledGate(input, extraEnv = {}) {
return runNode([GSD_CORE_HELPER_PATH], {
input,
env: { ...process.env, ...extraEnv },
timeoutMs: PROBE_TIMEOUT_MS,
});
}
// ── The controls (load-bearing): without these, "always return NO_INPUT"
// would satisfy the empty/whitespace-only assertions below. ──────────────
test('control: a detected input still exits 0', () => {
const result = spawnCompiledGate('Build the analytics dashboard.');
assert.strictEqual(result.exitCode, 0, `stderr: ${result.stderr}`);
});
test('control: a genuine-negative (real input, no signal) still exits 1, not NO_INPUT', () => {
const result = spawnCompiledGate('Requirements analysis for the backend service.');
assert.strictEqual(result.exitCode, 1, `stderr: ${result.stderr}`);
});
test('whitespace-only stdin (spaces / newlines / tabs / CR) exits NO_INPUT', () => {
for (const ws of [' ', '\n\n\n', '\t\t\t', '\r\r\r', ' \n\t\r\n ']) {
const result = spawnCompiledGate(ws);
assert.strictEqual(
result.exitCode, exitCodeFor('NO_INPUT'),
`whitespace-only input ${JSON.stringify(ws)} must exit NO_INPUT; got ${result.exitCode}. stderr: ${result.stderr}`,
);
}
});
test('"x" and " x " are REAL input — must NOT be NO_INPUT', () => {
const bare = spawnCompiledGate('x');
assert.notStrictEqual(bare.exitCode, exitCodeFor('NO_INPUT'), `"x" must not be NO_INPUT; stderr: ${bare.stderr}`);
assert.strictEqual(bare.exitCode, 1, `"x" carries no UI token, so it is the genuine negative (1); stderr: ${bare.stderr}`);
const padded = spawnCompiledGate(' x ');
assert.notStrictEqual(padded.exitCode, exitCodeFor('NO_INPUT'), `" x " must not be NO_INPUT; stderr: ${padded.stderr}`);
assert.strictEqual(padded.exitCode, 1, `" x " carries no UI token, so it is the genuine negative (1); stderr: ${padded.stderr}`);
});
test('a NUL byte is real input (not stripped by whitespace trimming) — falls through to the detector', () => {
const result = spawnCompiledGate('\0');
assert.notStrictEqual(result.exitCode, exitCodeFor('NO_INPUT'), `NUL byte must not be treated as empty; stderr: ${result.stderr}`);
assert.strictEqual(result.exitCode, 1, `NUL byte alone carries no UI token; stderr: ${result.stderr}`);
});
test('#3907 negative space: an explicit `**UI hint**: no` on REAL input still exits 1, not NO_INPUT', () => {
// "the phase says it has no UI" and "I was handed nothing" are different
// answers (ui-safety-gate.cts:124-126 returns hasUI:false deliberately here).
const result = spawnCompiledGate('**UI hint**: no\n\nBuild the dashboard UI.\n');
assert.strictEqual(result.exitCode, 1, `hint:no on real input must exit 1, not NO_INPUT; stderr: ${result.stderr}`);
assert.notStrictEqual(result.exitCode, exitCodeFor('NO_INPUT'));
});
test('a stdin read error exits UNAVAILABLE (injected via monkeypatched process.stdin, not chmod)', () => {
const result = runNode(['-r', INJECT_STDIN_ERROR, GSD_CORE_HELPER_PATH], { timeoutMs: PROBE_TIMEOUT_MS });
assert.strictEqual(
result.exitCode, exitCodeFor('UNAVAILABLE'),
`stdin read error must exit UNAVAILABLE (${exitCodeFor('UNAVAILABLE')}); got ${result.exitCode}. stderr: ${result.stderr}`,
);
});
test('NO_INPUT / UNAVAILABLE codes are identical under GSD_EXIT_CONTRACT=v1 and v2', () => {
for (const version of ['v1', 'v2']) {
const emptyResult = spawnCompiledGate('', { GSD_EXIT_CONTRACT: version });
assert.strictEqual(
emptyResult.exitCode, exitCodeFor('NO_INPUT'),
`NO_INPUT must be ${exitCodeFor('NO_INPUT')} under ${version}; got ${emptyResult.exitCode}`,
);
const errResult = runNode(['-r', INJECT_STDIN_ERROR, GSD_CORE_HELPER_PATH], {
env: { ...process.env, GSD_EXIT_CONTRACT: version },
timeoutMs: PROBE_TIMEOUT_MS,
});
assert.strictEqual(
errResult.exitCode, exitCodeFor('UNAVAILABLE'),
`UNAVAILABLE must be ${exitCodeFor('UNAVAILABLE')} under ${version}; got ${errResult.exitCode}`,
);
}
});
});
// ── The root copy stays dead (#3907) ──────────────────────────────────────────
// The hand-written root `bin/lib/ui-safety-gate.cjs` was removed as dead code:
// no installer reference (bin/install.js only ever requires
// gsd-core/bin/lib/*), no workflow or capability content invokes it, and it
// had no fallback chain in shipped content. This guards against it silently
// coming back (e.g. a re-added generator, a stray hand copy).
describe('bin/lib/ui-safety-gate.cjs (root copy) — removed as dead code (#3907)', () => {
test('root bin/lib/ui-safety-gate.cjs does not exist', () => {
const rootCopy = path.join(__dirname, '..', 'bin', 'lib', 'ui-safety-gate.cjs');
assert.strictEqual(fs.existsSync(rootCopy), false,
'bin/lib/ui-safety-gate.cjs must not exist — it was removed as dead code in #3907');
});
});
// ── Behavioral test matrix (via module import) ────────────────────────────────
/**
@@ -572,7 +693,7 @@ describe('UI gate resolves the helper against RUNTIME_DIR, not the consuming rep
// the CWD is a consuming project that has no bin/lib of its own.
const GATE_SNIPPET = [
'_GSD_RT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"',
'UI_GATE_JS=$(for _c in "$_GSD_RT/gsd-core/bin/lib/ui-safety-gate.cjs" "$_GSD_RT/bin/lib/ui-safety-gate.cjs" "$_GSD_RT/.claude/bin/lib/ui-safety-gate.cjs" "$HOME/.claude/gsd-core/bin/lib/ui-safety-gate.cjs" "$HOME/.claude/bin/lib/ui-safety-gate.cjs"; do [ -f "$_c" ] && { echo "$_c"; break; }; done)',
'UI_GATE_JS=$(for _c in "$_GSD_RT/gsd-core/bin/lib/ui-safety-gate.cjs" "$_GSD_RT/.claude/bin/lib/ui-safety-gate.cjs" "$HOME/.claude/gsd-core/bin/lib/ui-safety-gate.cjs" "$HOME/.claude/bin/lib/ui-safety-gate.cjs"; do [ -f "$_c" ] && { echo "$_c"; break; }; done)',
'if [ -n "$UI_GATE_JS" ]; then printf \'%s\' "$PHASE_SECTION" | node "$UI_GATE_JS" >/dev/null 2>&1; HAS_UI=$?; else HAS_UI=0; fi',
'echo "$HAS_UI"',
].join('\n');
@@ -622,12 +743,11 @@ describe('UI gate resolves the helper against RUNTIME_DIR, not the consuming rep
const fakeRuntime = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-installed-rt-'));
const consumingProject = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-consuming-'));
try {
const installedLibDir = path.join(fakeRuntime, 'gsd-core', 'bin', 'lib');
fs.mkdirSync(installedLibDir, { recursive: true });
fs.copyFileSync(
path.join(REPO_ROOT, 'gsd-core', 'bin', 'lib', 'ui-safety-gate.cjs'),
path.join(installedLibDir, 'ui-safety-gate.cjs')
);
// ui-safety-gate.cjs requires ./cli-exit.cjs (-> ./exit-code-registry.cjs),
// so a hand-copy of just the one file would MODULE_NOT_FOUND. Walk the
// require graph instead (tests/helpers/copy-script-fixture.cjs) — see
// its docstring for the #3412-class bug this avoids.
copyScriptWithDeps(REPO_ROOT, fakeRuntime, path.join('gsd-core', 'bin', 'lib', 'ui-safety-gate.cjs'));
// Same bash FAN-OUT class as runGateFrom above (git rev-parse + a
// candidate-path probe loop + node) — see HOOK_FANOUT_TIMEOUT_MS in
@@ -678,10 +798,30 @@ describe('checkUiPresence() return value API', () => {
assert.strictEqual(uiCount, 1, 'Duplicate tokens must be deduplicated');
});
test('non-string input returns { hasUI: false, tokens: [] }', () => {
assert.deepStrictEqual(checkUiPresence(null), { hasUI: false, tokens: [] });
assert.deepStrictEqual(checkUiPresence(undefined), { hasUI: false, tokens: [] });
assert.deepStrictEqual(checkUiPresence(42), { hasUI: false, tokens: [] });
test('non-string input returns { hasUI: false, tokens: [], matchedToken: null, matchedLine: null }', () => {
const expected = { hasUI: false, tokens: [], matchedToken: null, matchedLine: null };
assert.deepStrictEqual(checkUiPresence(null), expected);
assert.deepStrictEqual(checkUiPresence(undefined), expected);
assert.deepStrictEqual(checkUiPresence(42), expected);
});
test('UI-present input sets matchedToken/matchedLine to the first match', () => {
const result = checkUiPresence('Build the dashboard and UI form');
assert.strictEqual(result.matchedToken, 'dashboard');
assert.strictEqual(result.matchedLine, 'Build the dashboard and UI form');
});
test('no-UI input sets matchedToken/matchedLine to null', () => {
const result = checkUiPresence('Requirements: backend REST API only');
assert.strictEqual(result.matchedToken, null);
assert.strictEqual(result.matchedLine, null);
});
test('"**UI hint**: no" short-circuits to hasUI:false with matchedToken/matchedLine null', () => {
const result = checkUiPresence('**UI hint**: no\nBuild the dashboard');
assert.strictEqual(result.hasUI, false);
assert.strictEqual(result.matchedToken, null);
assert.strictEqual(result.matchedLine, null);
});
test('multiple distinct UI tokens on same line are ALL captured', () => {

View File

@@ -0,0 +1,41 @@
'use strict';
/**
* Deterministic stdin-read-failure injector for exit-code tests (ADR-3889
* Phase 3, #3907).
*
* Cross-platform IO-failure injection must be done via method monkeypatching,
* never mode-bit tricks (`fs.chmodSync(path, 0o000)` is bypassed by root in
* Docker/CI and would assert zero coverage there). This module replaces
* `process.stdin` with a fake EventEmitter BEFORE the target CLI module loads
* — load it with `node -r tests/helpers/inject-stdin-error.cjs <target.cjs>`
* — so the target's own `process.stdin.on('data'|'end'|'error', ...)`
* subscriptions attach to the fake, and the fake emits ONLY `'error'`
* (never `'data'`/`'end'`), deterministically exercising the module's
* stdin-read-failure arm without any real pipe/fd involved.
*
* `-r` (require preload) runs before Node loads the main module, so the
* spawned CLI still sees `require.main === module` for ITSELF — the
* preload script's own module identity never satisfies that check — which is
* what lets the target's `if (require.main === module)` CLI entry point run
* normally, reading from (the now-fake) `process.stdin`.
*/
const { EventEmitter } = require('node:events');
const fakeStdin = new EventEmitter();
fakeStdin.setEncoding = () => {};
fakeStdin.resume = () => {};
fakeStdin.pause = () => {};
Object.defineProperty(process, 'stdin', {
value: fakeStdin,
configurable: true,
});
// Fire on the next tick — after the target module's require.main===module
// block has synchronously registered its 'data'/'end'/'error' listeners —
// so the 'error' event is never emitted to zero listeners.
setImmediate(() => {
fakeStdin.emit('error', new Error('simulated stdin read failure (injected by inject-stdin-error.cjs)'));
});

View File

@@ -1,4 +1,4 @@
// docs-guard-exempt: docs/getting-started.md and docs/setup.md are synthetic { file, content } corpus fixtures fed to a lint checker, not real repo docs.
// docs-guard-exempt: docs/getting-started.md, docs/setup.md, docs/README.md, and docs/some-doc.md are synthetic { file, content } corpus fixtures fed to a lint checker, not real repo docs.
'use strict';
process.env.GSD_TEST_MODE = '1';
@@ -22,6 +22,8 @@ const ROOT = path.join(__dirname, '..');
const LINT_SCRIPT = path.join(ROOT, 'scripts', 'lint-removed-but-needed.cjs');
const {
referencesBasename,
referencesPath,
buildSurvivingBasenames,
referencesNpmLockfileDependency,
findSurvivingReferences,
classifyTestReference,
@@ -65,6 +67,84 @@ describe('removed-but-needed lint: referencesNpmLockfileDependency (pure)', () =
});
});
// ─── #3907: basename-collision refinement — match by full path, not bare
// basename, when the deleted file's basename also belongs to a surviving
// file. The original defect shape: #3907 deleted `bin/lib/ui-safety-gate.cjs`
// while the SEPARATE, still-live `gsd-core/bin/lib/ui-safety-gate.cjs`
// survived — every reference to the survivor (including the survivor
// referencing itself) was misattributed to the deletion.
describe('removed-but-needed lint: referencesPath (pure, #3907)', () => {
test('an exact full-path reference is found', () => {
assert.equal(referencesPath('see bin/lib/ui-safety-gate.cjs for the old copy', 'bin/lib/ui-safety-gate.cjs'), true);
});
test('a longer path that has the target as a SUFFIX is NOT a false match', () => {
assert.equal(
referencesPath('canonical: gsd-core/bin/lib/ui-safety-gate.cjs', 'bin/lib/ui-safety-gate.cjs'),
false,
);
});
test('no reference at all is not found', () => {
assert.equal(referencesPath('nothing to see here', 'bin/lib/ui-safety-gate.cjs'), false);
});
});
describe('removed-but-needed lint: buildSurvivingBasenames (pure, #3907)', () => {
test('collects basenames from repo-relative paths', () => {
const set = buildSurvivingBasenames(['gsd-core/bin/lib/ui-safety-gate.cjs', 'docs/README.md']);
assert.equal(set.has('ui-safety-gate.cjs'), true);
assert.equal(set.has('README.md'), true);
assert.equal(set.has('nope.md'), false);
});
});
describe('removed-but-needed lint: findSurvivingReferences basename-collision refinement (#3907)', () => {
test('PROBE 1 — unique basename, surviving reference STILL FAILS (refinement did not neuter the guard)', () => {
const violations = findSurvivingReferences(
['bin/lib/unique-only.cjs'],
[{ file: 'gsd-core/some-consumer.cjs', content: "require('./unique-only.cjs')" }],
buildSurvivingBasenames(['gsd-core/some-consumer.cjs']),
);
assert.equal(violations.length, 1);
assert.match(violations[0].reason, /basename 'unique-only\.cjs' still referenced/);
});
test('PROBE 2 — colliding basename, FULL-PATH reference to the deleted file STILL FAILS', () => {
const survivingBasenames = buildSurvivingBasenames(['gsd-core/bin/lib/ui-safety-gate.cjs']);
const violations = findSurvivingReferences(
['bin/lib/ui-safety-gate.cjs'],
[{ file: 'docs/some-doc.md', content: 'see bin/lib/ui-safety-gate.cjs for the old copy' }],
survivingBasenames,
);
assert.equal(violations.length, 1);
assert.match(violations[0].reason, /full path 'bin\/lib\/ui-safety-gate\.cjs' still referenced/);
});
test('PROBE 3 — colliding basename, bare-basename reference only PASSES (the actual #3907 shape)', () => {
const survivingBasenames = buildSurvivingBasenames(['gsd-core/bin/lib/ui-safety-gate.cjs']);
const violations = findSurvivingReferences(
['bin/lib/ui-safety-gate.cjs'],
[
{ file: 'gsd-core/bin/lib/check-command-router.cjs', content: "require('./ui-safety-gate.cjs')" },
{ file: 'gsd-core/bin/lib/ui-safety-gate.cjs', content: '// canonical gsd-core/bin/lib/ui-safety-gate.cjs' },
],
survivingBasenames,
);
assert.deepEqual(violations, []);
});
test('no collision (default empty set): behaviour is unchanged from the original basename rule', () => {
const violations = findSurvivingReferences(
['bin/lib/ui-safety-gate.cjs'],
[{ file: 'gsd-core/bin/lib/check-command-router.cjs', content: "require('./ui-safety-gate.cjs')" }],
);
assert.equal(violations.length, 1);
assert.match(violations[0].reason, /basename 'ui-safety-gate\.cjs' still referenced/);
});
});
describe('removed-but-needed lint: findSurvivingReferences (pure)', () => {
test('the real #3316 defect shape IS flagged: package-lock.json deleted, workflow still runs npm ci', () => {
const violations = findSurvivingReferences(
@@ -231,6 +311,58 @@ describe('removed-but-needed lint: main() end-to-end wiring', () => {
});
});
describe('removed-but-needed lint: main() end-to-end, basename-collision refinement (#3907)', () => {
test('exit 0 reproducing the real #3907 shape: deleted root copy, surviving gsd-core twin referencing itself', (t) => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-removed-but-needed-e2e-collision-clean-'));
t.after(() => cleanup(tmpDir));
buildTempRepo(
tmpDir,
[
{ file: 'bin/lib/ui-safety-gate.cjs', content: '// root copy\n' },
{
file: 'gsd-core/bin/lib/ui-safety-gate.cjs',
content: '// canonical gsd-core/bin/lib/ui-safety-gate.cjs\nmodule.exports = {};\n',
},
{
file: 'gsd-core/bin/lib/check-command-router.cjs',
content: "require('./ui-safety-gate.cjs');\n",
},
],
[{ file: 'bin/lib/ui-safety-gate.cjs', content: null }],
);
const scriptCopy = copyScriptInto(tmpDir);
const result = runNode(
[scriptCopy],
{ cwd: tmpDir, env: { ...process.env, GSD_REMOVED_BUT_NEEDED_BASE: 'main' } },
);
assert.equal(result.exitCode, 0, `expected exit 0, got ${result.exitCode}: ${result.stderr}`);
});
test('exit 1 when, despite the basename collision, something still references the deleted file by its FULL PATH', (t) => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-removed-but-needed-e2e-collision-full-path-'));
t.after(() => cleanup(tmpDir));
buildTempRepo(
tmpDir,
[
{ file: 'bin/lib/ui-safety-gate.cjs', content: '// root copy\n' },
{
file: 'gsd-core/bin/lib/ui-safety-gate.cjs',
content: '// canonical gsd-core/bin/lib/ui-safety-gate.cjs\nmodule.exports = {};\n',
},
{ file: 'docs/setup.md', content: 'run: node bin/lib/ui-safety-gate.cjs to check\n' },
],
[{ file: 'bin/lib/ui-safety-gate.cjs', content: null }],
);
const scriptCopy = copyScriptInto(tmpDir);
const result = runNode(
[scriptCopy],
{ cwd: tmpDir, env: { ...process.env, GSD_REMOVED_BUT_NEEDED_BASE: 'main' } },
);
assert.equal(result.exitCode, 1, `expected exit 1, got ${result.exitCode}: ${result.stderr}`);
assert.match(result.stderr, /full path 'bin\/lib\/ui-safety-gate\.cjs' still referenced/);
});
});
// ─── #3565: tests/ arm — pins-existence vs asserts-absence ──────────────────
describe('removed-but-needed lint: classifyTestReference (pure, #3565)', () => {