fix(#3770): require intentional RED evidence before GREEN (#4279)

* test(3770): add failing tests for intentional RED evidence gate

RED: classifyRedEvidence / buildRedEvidenceRecord / check tdd-red-evidence do
not exist yet; every row fails on require. Per #3770 only an intentional
target-test failure may authorize GREEN; zero-test discovery, fixture crashes,
unrelated failures, and unexpected green are INVALID_RED.

* fix(3770): require intentional RED evidence before GREEN

Only an intentional failure of the TARGET test (distinctly named, TAP-reported
assertion failure) classifies as RED_EVIDENCE_OK and authorizes GREEN. Zero-test
discovery, fixture/load crashes (file-named failures), nonzero exits without a
failing test, unrelated failures, unexpected greens, and malformed/missing
records are INVALID_RED and block GREEN.

- src/tdd-red-evidence.cts: pure classifier + persisted record builder (reuses
  the prohibition-enforcement TAP primitives; fail-closed, never throws)
- check tdd-red-evidence <record.json>: validates the persisted record
  (command, exit code, failing test, expected, actual)
- gsd-executor.md / references/tdd.md / references/execute-mvp-tdd.md: RED now
  requires the evidence record + gate verdict, not a nonzero exit or a RED: tag

* chore(3770): regenerate inventory manifest for tdd-red-evidence.cjs

* fix(3770): fit executor fail-fast under size cap, fix unrelated-failure fixture, ignore generated lib

- gsd-executor.md: compress the #3770 fail-fast rule to one line (49149 B <
  49152 cap; line-count parity keeps the #2751 PROSE_ALLOWLIST line 816 valid)
- tests: the row-6 fixture used String.replace (first-occurrence), so the
  `not ok` line still named the target test and the classifier was right to
  accept it; replaceAll makes the failure genuinely unrelated
- eslint.config.mjs: ignore tsc-generated bin/lib/tdd-red-evidence.cjs
  (lint the src/*.cts source, per ADR-457 migration rule)

Emitted-Drift-Ack-Growth: gsd-executor.md — the #3770 fail-fast rule now requires intentional RED evidence (check tdd-red-evidence) before GREEN; +172 bytes, kept under the LARGE cap and on one line

* chore(3770): add changeset

* chore(3770): backfill PR number in changeset

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-09-04 16:44:38 -04:00
committed by GitHub
parent 2f4f7538e9
commit 8249ebcf6e
10 changed files with 560 additions and 14 deletions

View File

@@ -37,6 +37,7 @@ const { getRoadmapPhaseWithFallback } = roadmapModule;
import gapCheckerModule = require('./gap-checker.cjs');
const { runGapAnalysis } = gapCheckerModule;
import { routeProhibitionEnforcement } from './prohibition-enforcement.cjs';
import { classifyRedEvidence, buildRedEvidenceRecord } from './tdd-red-evidence.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import gatePredicateEval = require('./gate-predicate-evaluator.cjs');
const { evaluatePredicate } = gatePredicateEval;
@@ -935,6 +936,83 @@ function cmdTddReviewCheckpoint(projectDir: string, args: string[], raw: boolean
output(result, raw, undefined);
}
// ─── tdd-red-evidence (#3770) ──────────────────────────────────────────────────
/**
* tdd-red-evidence: validates a persisted RED-phase test-run record for a
* `type: tdd` plan (#3770). Only an INTENTIONAL failure of the target test
* (verdict RED_EVIDENCE_OK) may authorize GREEN; zero-test discovery, fixture/
* load crashes, nonzero exits without a failing test, unrelated failures, and
* unexpected greens are INVALID_RED and block GREEN.
*
* The record is the JSON the executor persists after running the RED command:
* { command, exitCode, output, targetTest, targetFile?, expected?, actual? }
* Fail-closed: a missing/unreadable/unparseable record is INVALID_RED
* (reason unreadable_record), never a pass.
*
* Args: check tdd-red-evidence <record.json>
*/
function cmdTddRedEvidence(_projectDir: string, args: string[], raw: boolean): void {
const recordPath = typeof args[2] === 'string' ? args[2] : '';
if (!recordPath) {
error('tdd-red-evidence requires a record path: check tdd-red-evidence <record.json>', ERROR_REASON.SDK_MISSING_ARG);
return;
}
const resolved = path.resolve(recordPath);
const text = readIfExists(resolved);
const input = ((): Record<string, unknown> | null => {
if (!text) return null;
try {
return (JSON.parse(text) ?? {}) as Record<string, unknown>;
} catch {
return null;
}
})();
if (!input) {
output(
{
passed: false,
block: true,
verdict: 'INVALID_RED',
reason: 'unreadable_record',
record: resolved,
readError: text ? `record is not valid JSON: ${resolved}` : `record not found or unreadable: ${resolved}`,
},
raw,
undefined,
);
return;
}
const evidenceInput = {
command: input['command'],
exitCode: input['exitCode'],
output: input['output'],
targetTest: input['targetTest'],
targetFile: input['targetFile'],
expected: input['expected'],
actual: input['actual'],
};
const result = classifyRedEvidence(evidenceInput);
const record = buildRedEvidenceRecord(evidenceInput, result);
output(
{
// Uniform gate contract: block = !passed. INVALID_RED blocks GREEN.
passed: result.verdict === 'RED_EVIDENCE_OK',
block: result.verdict !== 'RED_EVIDENCE_OK',
verdict: result.verdict,
reason: result.reason,
evidence: result.evidence,
record,
message:
result.verdict === 'RED_EVIDENCE_OK'
? `RED evidence verified: target test "${result.evidence.target_test}" failed as expected (exit ${result.evidence.exit_code}). GREEN authorized.`
: `INVALID_RED (${result.reason}): GREEN blocked. Fix the RED phase — only an intentional failure of target test "${result.evidence.target_test}" authorizes production edits.`,
},
raw,
undefined,
);
}
/**
* Resolve a phase argument to an absolute phase directory, or '' when it
* cannot be resolved. Shared by every `check` arm that probes a phase's
@@ -1701,6 +1779,12 @@ function routeCheckCommand({ args, cwd, raw }: RouteCheckCommandOptions): void {
cmdTddReviewCheckpoint(cwd, args, raw);
return;
}
if (subcommand === 'tdd-red-evidence') {
// #3770: intentional-RED evidence gate — only a target-test failure may
// authorize GREEN. Validates the persisted record; never executes anything.
cmdTddRedEvidence(cwd, args, raw);
return;
}
if (subcommand === 'ui-safety-gate') {
cmdUiSafetyGate(cwd, args, raw);
return;
@@ -1744,7 +1828,7 @@ function routeCheckCommand({ args, cwd, raw }: RouteCheckCommandOptions): void {
routeProhibitionEnforcement(args, raw);
return;
}
error('Unknown check subcommand. Available: api-coverage-verify-pre, auto-mode, decision-coverage-plan, decision-coverage-verify, gap-analysis-plan-post, predicate, prohibition-enforcement, tdd-review-checkpoint, ui-plan-gate, ui-safety-gate, verify-command-paths, verify-failure-directions, verify-schema-drift, verify-codebase-drift, verify-context-drift', ERROR_REASON.SDK_UNKNOWN_COMMAND);
error('Unknown check subcommand. Available: api-coverage-verify-pre, auto-mode, decision-coverage-plan, decision-coverage-verify, gap-analysis-plan-post, predicate, prohibition-enforcement, tdd-red-evidence, tdd-review-checkpoint, ui-plan-gate, ui-safety-gate, verify-command-paths, verify-failure-directions, verify-schema-drift, verify-codebase-drift, verify-context-drift', ERROR_REASON.SDK_UNKNOWN_COMMAND);
}
export = {
@@ -1757,6 +1841,7 @@ export = {
cmdVerifyCommandPaths,
cmdVerifyFailureDirections,
cmdTddReviewCheckpoint,
cmdTddRedEvidence,
cmdCheckPredicate,
buildPredicateDeps,
parsePredicateFlags,