* 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:
@@ -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}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
41
tests/helpers/inject-stdin-error.cjs
Normal file
41
tests/helpers/inject-stdin-error.cjs
Normal 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)'));
|
||||
});
|
||||
@@ -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)', () => {
|
||||
|
||||
Reference in New Issue
Block a user