* 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:
@@ -51,9 +51,13 @@
|
||||
* renderCoverageMatrix(rows) -> string
|
||||
* DEFAULT_API_COVERAGE_TERMS
|
||||
*
|
||||
* CLI:
|
||||
* CLI (ADR-3889 Phase 3, #3907):
|
||||
* echo "$SCOPE" | node gsd-core/bin/lib/api-coverage.cjs [--json]
|
||||
* exit 0 = integration detected, 1 = none, 2 = startup error
|
||||
* exit 0 = integration detected, 1 = none (real input, examined, no signal),
|
||||
* NO_INPUT (registry, src/cli-exit.cts) = stdin empty/whitespace-only,
|
||||
* UNAVAILABLE (registry) = stdin read failed
|
||||
* --json additionally prints the typed IR on stdout (unchanged for 0/1);
|
||||
* NO_INPUT/UNAVAILABLE print {skipped:true, reason:"no_input"|"stdin_error"}
|
||||
*/
|
||||
|
||||
import { stripFencedCode, scanInlineCodeSpans, extractFencedBlock } from './markdown-sectionizer.cjs';
|
||||
@@ -939,13 +943,27 @@ export function renderCoverageMatrix(rows: readonly CoverageRow[]): string {
|
||||
// ── CLI entry point ──────────────────────────────────────────────────────────
|
||||
// Reads phase-scope text from STDIN (not argv) to avoid OS ARG_MAX limits.
|
||||
// Invoked by workflow bash as: echo "$SCOPE" | node .../api-coverage.cjs [--json]
|
||||
// Exit 0 = integration detected, 1 = none, 2 = startup error. Mirrors
|
||||
// assumption-delta.cjs / ui-safety-gate.cjs.
|
||||
//
|
||||
// Exit codes (ADR-3889 Phase 3, #3907): 0 = integration detected, 1 = none
|
||||
// (real input, examined, no signal), NO_INPUT (registry — src/cli-exit.cts) =
|
||||
// stdin empty/whitespace-only, UNAVAILABLE (registry) = stdin read failed.
|
||||
// The prior single "2 = startup error" arm let empty input flow into the
|
||||
// detector and exit 1 — an unexamined input reported as an authoritative
|
||||
// negative. Under --json, NO_INPUT/UNAVAILABLE emit `{skipped:true,
|
||||
// reason:"no_input"|"stdin_error"}` — no `detected` key, so a stale
|
||||
// `detected:false` can never sit beside `skipped:true`; the detected/no-signal
|
||||
// `--json` payloads are byte-identical to before. Terminates via terminateNow
|
||||
// (write-then-terminate, total by construction), not raw process.exit — these
|
||||
// exits fire from inside a stdin event handler. Mirrors assumption-delta.cjs /
|
||||
// ui-safety-gate.cjs.
|
||||
|
||||
if (require.main === module) {
|
||||
const argv = process.argv.slice(2);
|
||||
const wantJson = argv.includes('--json');
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const cliExit = require('./cli-exit.cjs') as { terminateNow: (outcome: string, payload: unknown) => never };
|
||||
|
||||
let termsOverride: Partial<ApiCoverageTermSet> | undefined;
|
||||
const verbsIdx = argv.indexOf('--verbs');
|
||||
const verbsVal = verbsIdx !== -1 ? argv[verbsIdx + 1] : undefined;
|
||||
@@ -970,14 +988,18 @@ if (require.main === module) {
|
||||
process.stdin.on('data', (chunk: string) => chunks.push(chunk));
|
||||
process.stdin.on('end', () => {
|
||||
const input = chunks.join('');
|
||||
const result = detectApiIntegration(input, termsOverride);
|
||||
if (wantJson) {
|
||||
process.stdout.write(JSON.stringify(result) + '\n');
|
||||
// Whitespace-only counts as empty (ADR-3889 §1's NO_INPUT: "zero units
|
||||
// were in scope, and that emptiness is known to be genuine"). Real input
|
||||
// — including a lone NUL byte, which .trim() does not strip — always
|
||||
// falls through to the detector.
|
||||
if (input.trim().length === 0) {
|
||||
cliExit.terminateNow('NO_INPUT', wantJson ? { skipped: true, reason: 'no_input' } : undefined);
|
||||
}
|
||||
process.exit(result.detected ? 0 : 1);
|
||||
const result = detectApiIntegration(input, termsOverride);
|
||||
cliExit.terminateNow(result.detected ? 'PASS' : 'FAIL', wantJson ? result : undefined);
|
||||
});
|
||||
process.stdin.on('error', (err: Error) => {
|
||||
process.stderr.write(`ERROR: api-coverage.cjs stdin read failed: ${err.message}\n`);
|
||||
process.exit(2);
|
||||
cliExit.terminateNow('UNAVAILABLE', wantJson ? { skipped: true, reason: 'stdin_error' } : undefined);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -20,17 +20,19 @@
|
||||
* tunable (config + the `terms` parameter) so teams can widen it.
|
||||
* - FENCED CODE BLOCKS ARE STRIPPED first (via the markdown-sectionizer seam)
|
||||
* so a trigger term that appears only inside a code snippet does not fire.
|
||||
* - Mirrors ui-safety-gate.cts: a pure function + a STDIN-reading CLI whose
|
||||
* exit codes mirror grep (0 = signal found, 1 = none, 2 = usage error).
|
||||
* - Mirrors ui-safety-gate.cts: a pure function + a STDIN-reading CLI.
|
||||
*
|
||||
* Public API:
|
||||
* detectAssumptionDelta(text, terms?) -> { detected, signals, terms }
|
||||
* DEFAULT_ASSUMPTION_DELTA_TERMS
|
||||
*
|
||||
* CLI:
|
||||
* CLI (ADR-3889 Phase 3, #3907):
|
||||
* echo "$PHASE_SECTION" | node gsd-core/bin/lib/assumption-delta.cjs [--json]
|
||||
* exit 0 = signal detected, 1 = none, 2 = startup error
|
||||
* --json additionally prints the typed IR on stdout
|
||||
* exit 0 = signal detected, 1 = none (real input, examined, no signal),
|
||||
* NO_INPUT (registry, src/cli-exit.cts) = stdin empty/whitespace-only,
|
||||
* UNAVAILABLE (registry) = stdin read failed
|
||||
* --json additionally prints the typed IR on stdout (unchanged for 0/1);
|
||||
* NO_INPUT/UNAVAILABLE print {skipped:true, reason:"no_input"|"stdin_error"}
|
||||
*/
|
||||
|
||||
import { stripFencedCode } from './markdown-sectionizer.cjs';
|
||||
@@ -214,11 +216,25 @@ export function detectAssumptionDelta(
|
||||
// ── CLI entry point ──────────────────────────────────────────────────────────
|
||||
// Reads phase-section text from STDIN (not argv) to avoid OS ARG_MAX limits.
|
||||
// Invoked by workflow bash as: echo "$PHASE_SECTION" | node .../assumption-delta.cjs [--json]
|
||||
// Exit 0 = signal detected, 1 = none, 2 = startup error. Mirrors ui-safety-gate.
|
||||
//
|
||||
// Exit codes (ADR-3889 Phase 3, #3907): 0 = signal detected, 1 = none (real
|
||||
// input, examined, no signal), NO_INPUT (registry — src/cli-exit.cts) = stdin
|
||||
// empty/whitespace-only, UNAVAILABLE (registry) = stdin read failed. The prior
|
||||
// single "2 = startup error" arm let empty input flow into the detector and
|
||||
// exit 1 — an unexamined input reported as an authoritative negative. Under
|
||||
// --json, NO_INPUT/UNAVAILABLE emit `{skipped:true, reason:"no_input"|
|
||||
// "stdin_error"}` — no `detected` key, so a stale `detected:false` can never
|
||||
// sit beside `skipped:true`; the detected/no-signal `--json` payloads are
|
||||
// byte-identical to before. Terminates via terminateNow (write-then-terminate,
|
||||
// total by construction), not raw process.exit — these exits fire from inside
|
||||
// a stdin event handler. Mirrors ui-safety-gate.cjs / api-coverage.cjs.
|
||||
|
||||
if (require.main === module) {
|
||||
const argv = process.argv.slice(2);
|
||||
const wantJson = argv.includes('--json');
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const cliExit = require('./cli-exit.cjs') as { terminateNow: (outcome: string, payload: unknown) => never };
|
||||
// --terms <csv>: config-tunable vocabulary override. Replaces the
|
||||
// pluralization cues (the primary trigger); optional/chosen keep defaults.
|
||||
// An EMPTY value ("") or a flag-shaped value restores the curated defaults
|
||||
@@ -241,15 +257,19 @@ if (require.main === module) {
|
||||
|
||||
process.stdin.on('end', () => {
|
||||
const input = chunks.join('');
|
||||
const result = detectAssumptionDelta(input, termsOverride);
|
||||
if (wantJson) {
|
||||
process.stdout.write(JSON.stringify(result) + '\n');
|
||||
// Whitespace-only counts as empty (ADR-3889 §1's NO_INPUT: "zero units
|
||||
// were in scope, and that emptiness is known to be genuine"). Real input
|
||||
// — including a lone NUL byte, which .trim() does not strip — always
|
||||
// falls through to the detector.
|
||||
if (input.trim().length === 0) {
|
||||
cliExit.terminateNow('NO_INPUT', wantJson ? { skipped: true, reason: 'no_input' } : undefined);
|
||||
}
|
||||
process.exit(result.detected ? 0 : 1);
|
||||
const result = detectAssumptionDelta(input, termsOverride);
|
||||
cliExit.terminateNow(result.detected ? 'PASS' : 'FAIL', wantJson ? result : undefined);
|
||||
});
|
||||
|
||||
process.stdin.on('error', (err: Error) => {
|
||||
process.stderr.write(`ERROR: assumption-delta.cjs stdin read failed: ${err.message}\n`);
|
||||
process.exit(2);
|
||||
cliExit.terminateNow('UNAVAILABLE', wantJson ? { skipped: true, reason: 'stdin_error' } : undefined);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* UI Safety Gate — shell-free implementation (ADR-457 build-at-publish: the
|
||||
* hand-written bin/lib/ui-safety-gate.cjs collapsed to a TypeScript source of
|
||||
* hand-written root copy of this file collapsed to a TypeScript source of
|
||||
* truth). Behaviour is preserved byte-for-behaviour from the prior hand-written
|
||||
* .cjs; only types are added.
|
||||
*
|
||||
@@ -21,13 +21,17 @@
|
||||
*
|
||||
* CLI usage — reads phase-section text from STDIN to avoid ARG_MAX limits:
|
||||
* echo "$PHASE_SECTION" | node gsd-core/bin/lib/ui-safety-gate.cjs
|
||||
* echo $? → 0 if UI tokens found, 1 if not, 2 on usage error
|
||||
* echo $? → 0 if UI tokens found, 1 if not (real input, none found),
|
||||
* NO_INPUT (registry, src/cli-exit.cts) if stdin was empty or
|
||||
* whitespace-only, UNAVAILABLE (registry) if stdin read failed
|
||||
* (ADR-3889 Phase 3, #3907)
|
||||
*
|
||||
* Exit codes mirror grep: 0 = match found, 1 = no match, 2 = usage error.
|
||||
*
|
||||
* Canonical location: gsd-core/bin/lib/ui-safety-gate.cjs (#448)
|
||||
* This path is deployed by the GSD installer to $RUNTIME_DIR/gsd-core/bin/lib/.
|
||||
* bin/lib/ui-safety-gate.cjs (root) is retained for source-repo and npm usage.
|
||||
* The former root-level copy of this file (outside gsd-core/) was removed in
|
||||
* #3907 as dead code: no installer reference, no workflow invocation, and no
|
||||
* fallback chain in shipped content ever pointed at it. Do not re-create it.
|
||||
*/
|
||||
|
||||
export interface UiPresenceResult {
|
||||
@@ -134,9 +138,29 @@ export function checkUiPresence(text: string): UiPresenceResult {
|
||||
// ── CLI entry point ─────────────────────────────────────────────────────────
|
||||
// Reads phase-section text from STDIN (not argv) to avoid OS ARG_MAX limits.
|
||||
// Invoked by workflow .md bash blocks as: echo "$PHASE_SECTION" | node .../ui-safety-gate.cjs
|
||||
// Exit 0 = UI found, 1 = no UI, 2 = startup error.
|
||||
//
|
||||
// Exit codes (ADR-3889 Phase 3, #3907): 0 = UI found, 1 = no UI (real input,
|
||||
// examined, nothing found), NO_INPUT (registry — see src/cli-exit.cts) = stdin
|
||||
// closed with zero bytes (or whitespace-only), UNAVAILABLE (registry) = stdin
|
||||
// read failed. The prior single "2 = startup error" arm conflated "I was
|
||||
// handed nothing" with "the phase says it has no UI" — those are different
|
||||
// answers; only the former is new here. `hint === 'no'` on REAL input still
|
||||
// exits 1 — that is the author's own declaration, not an empty-input
|
||||
// artifact.
|
||||
//
|
||||
// Terminates via terminateNow (src/cli-exit.cts), not raw process.exit: these
|
||||
// exits fire from inside a stdin event handler, which is exactly what
|
||||
// terminateNow (write-then-terminate) exists for, and it is total (cannot
|
||||
// throw or return). This module never emits --json, so every payload here is
|
||||
// `undefined` — terminateNow's JSON.stringify(undefined) is not a valid
|
||||
// Buffer source and is swallowed by its own emission-failure guard, so
|
||||
// nothing is written to stdout, preserving this CLI's historical contract of
|
||||
// silence.
|
||||
|
||||
if (require.main === module) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const cliExit = require('./cli-exit.cjs') as { terminateNow: (outcome: string, payload: unknown) => never };
|
||||
|
||||
// Collect stdin chunks asynchronously.
|
||||
const chunks: string[] = [];
|
||||
process.stdin.setEncoding('utf-8');
|
||||
@@ -145,12 +169,19 @@ if (require.main === module) {
|
||||
|
||||
process.stdin.on('end', () => {
|
||||
const input = chunks.join('');
|
||||
// Whitespace-only counts as empty (ADR-3889 §1's NO_INPUT: "zero units
|
||||
// were in scope, and that emptiness is known to be genuine"). Real input
|
||||
// — including a lone NUL byte, which .trim() does not strip — always
|
||||
// falls through to the detector.
|
||||
if (input.trim().length === 0) {
|
||||
cliExit.terminateNow('NO_INPUT', undefined);
|
||||
}
|
||||
const result = checkUiPresence(input);
|
||||
process.exit(result.hasUI ? 0 : 1);
|
||||
cliExit.terminateNow(result.hasUI ? 'PASS' : 'FAIL', undefined);
|
||||
});
|
||||
|
||||
process.stdin.on('error', (err: Error) => {
|
||||
process.stderr.write(`ERROR: ui-safety-gate.cjs stdin read failed: ${err.message}\n`);
|
||||
process.exit(2);
|
||||
cliExit.terminateNow('UNAVAILABLE', undefined);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user