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