@@ -99,7 +99,18 @@ export interface PredicateInputs {
9999 report : DriftReport ;
100100 /** Exit code of the re-run collector (0 clean / 2 dirty / 5 quarantine / 1 infra). */
101101 postFixCollectorExit : number ;
102- /** criticalCount parsed from the re-run report (belt-and-suspenders vs exit 0). */
102+ /**
103+ * criticalCount parsed from the re-run report (belt-and-suspenders vs exit 0).
104+ *
105+ * FIX #7 — INDEPENDENCE CAVEAT: this signal (and the collector exit code) is
106+ * derived from the SAME fixtures the fixer was told to make pass, so it is
107+ * NOT independent of a fixture-relaxation cheat — a run that relaxed the SDK
108+ * leg reports clean here too. It is therefore only trustworthy BECAUSE fix #1
109+ * now blocks ANY gameable-leg edit (see isGameableLeg / the SUPPRESSION_SUSPECTED
110+ * + COMPARISON_LEG_ONLY branches) regardless of production files. That
111+ * always-block rule is load-bearing: it is what makes a clean post-fix signal
112+ * mean "the mock was really fixed" rather than "the leg was relaxed".
113+ */
103114 postFixCriticalCount : number ;
104115}
105116
@@ -145,12 +156,15 @@ const HARNESS_FILES: ReadonlySet<string> = new Set([
145156 * (the known-models canary routes fixes to these model-list files). These are
146157 * NOT gameable comparison legs — adding a newly-shipped model id here is a
147158 * legit fix, not a relaxation. They are allowed as accompanying changes and are
148- * never counted as a standalone comparison-leg cheat.
159+ * never counted as a comparison-leg cheat.
160+ *
161+ * FIX #2 — `voice-models.ts` is deliberately NOT listed here: it is also a
162+ * real-API HARNESS file, and block-classification wins over legit-accept
163+ * (fail-closed precedence). It is classified as a gameable leg in isGameableLeg.
149164 */
150165const LEGIT_FIXTURE_TARGETS : ReadonlySet < string > = new Set ( [
151166 "src/__tests__/drift/model-registry.ts" ,
152167 "src/__tests__/drift/model-family.ts" ,
153- "src/__tests__/drift/voice-models.ts" ,
154168] ) ;
155169
156170/**
@@ -171,30 +185,59 @@ export function isDriftTestFile(file: string): boolean {
171185}
172186
173187/**
174- * SUPPRESSION surface: editing these ALWAYS blocks, even alongside a production
175- * change, because silencing the detector (allowlist growth, loosened assertions)
176- * is never a valid drift fix. The schema file carries ALLOWLISTED_PATHS; the
177- * `*.drift.ts` files carry the assertions.
188+ * GAMEABLE-LEG (the block set). Editing ANY of these can erase a drift diff
189+ * WITHOUT changing the mock output — either by relaxing a comparison leg (the
190+ * SDK-shape fixture, the schema/allowlist, the real-API harness) or by loosening
191+ * a `*.drift.ts` assertion. Every one of these is a HUMAN-REVIEWED artifact: a
192+ * legitimate auto-remediation updates the mock BUILDER to match the SDK, and the
193+ * leg only changes on a deliberate human vendored-SDK bump. So any leg edit —
194+ * ALONE OR ACCOMPANIED BY A PRODUCTION CHANGE — is fail-closed to needs-human
195+ * (SUPPRESSION_SUSPECTED). This closes the WS-2b hybrid cheat (relax a leg +
196+ * one trivial on-target production edit), which the old "only check legs when
197+ * productionFiles===0" logic let through to auto-merge.
198+ *
199+ * FIX #2 — CLASSIFICATION PRECEDENCE: a file that is BOTH a harness leg AND a
200+ * legit fixture target (e.g. `voice-models.ts`) is treated as gameable (block).
201+ * Block-classification wins over legit-accept; fail-closed. Only PURE legit
202+ * fixture targets (model-registry.ts / model-family.ts), which are NOT harness
203+ * legs, are excluded — a canary model-list fix routes to those plus its
204+ * production builder and is not blocked.
205+ */
206+ export function isGameableLeg ( file : string ) : boolean {
207+ // Block-set membership is checked FIRST (fix #2 precedence): a file that is
208+ // BOTH a harness leg AND a legit fixture target (voice-models.ts) matches
209+ // HARNESS_FILES here and blocks, before the legit-target set is ever consulted.
210+ if ( file === SDK_SHAPES_FILE ) return true ;
211+ if ( file === SCHEMA_FILE ) return true ;
212+ if ( HARNESS_FILES . has ( file ) ) return true ;
213+ if ( isDriftTestFile ( file ) ) return true ;
214+ // PURE legit fixture targets (not also a harness leg) are explicitly
215+ // non-gameable — a canary model-list fix routes here plus its production
216+ // builder and must not be blocked.
217+ if ( LEGIT_FIXTURE_TARGETS . has ( file ) ) return false ;
218+ // Everything else (production files, unrelated paths) is non-gameable.
219+ return false ;
220+ }
221+
222+ /**
223+ * SUPPRESSION surface (the NARROW always-SUPPRESSION_SUSPECTED subset): the
224+ * triangulation schema/allowlist and `*.drift.ts` assertion files. Editing these
225+ * ACTIVELY SILENCES the detector (allowlist growth, loosened assertion) and is
226+ * never a valid fix — so it ALWAYS maps to SUPPRESSION_SUSPECTED, standalone or
227+ * alongside a production change. (The broader gameable-leg set below — sdk-shapes,
228+ * harness — also always blocks per fix #1, but maps to COMPARISON_LEG_ONLY when
229+ * standalone and SUPPRESSION_SUSPECTED only when paired with a production change.)
178230 */
179231export function isSuppressionSurface ( file : string ) : boolean {
180232 return file === SCHEMA_FILE || isDriftTestFile ( file ) ;
181233}
182234
183235/**
184- * GAMEABLE-COMPARISON-LEG: a comparison leg (SDK fixture, schema, real-API
185- * harness, or drift-test assertion) whose edit can erase a diff WITHOUT
186- * changing mock output. Explicitly EXCLUDES LEGIT_FIXTURE_TARGETS
187- * (canary model-list fixtures) — those are legit fix targets, not cheats.
188- * Note `voice-models.ts` is both a harness file AND a legit canary target; it
189- * is treated as a legit target (excluded here) so a canary fix is not blocked.
236+ * GAMEABLE-COMPARISON-LEG (retained for API compatibility / classification
237+ * unit coverage). Same full set as isGameableLeg — every leg edit blocks.
190238 */
191239export function isComparisonLeg ( file : string ) : boolean {
192- if ( LEGIT_FIXTURE_TARGETS . has ( file ) ) return false ;
193- if ( file === SDK_SHAPES_FILE ) return true ;
194- if ( file === SCHEMA_FILE ) return true ;
195- if ( HARNESS_FILES . has ( file ) ) return true ;
196- if ( isDriftTestFile ( file ) ) return true ;
197- return false ;
240+ return isGameableLeg ( file ) ;
198241}
199242
200243/**
@@ -220,16 +263,14 @@ export function evaluateDriftResolved(i: PredicateInputs): PredicateResult {
220263
221264 // ---- Signal 1: AUTHORITATIVE — collector clean on re-run. -------------
222265 // Checked FIRST: a dirty/quarantine/infra collector makes any file-set moot.
223- if ( postFixCollectorExit === 2 || postFixCriticalCount > 0 ) {
224- return {
225- resolved : false ,
226- reason : PredicateReason . STILL_DIRTY ,
227- detail :
228- "Post-fix drift collector still reports critical drift " +
229- `(exit ${ postFixCollectorExit } , criticalCount ${ postFixCriticalCount } ). The fix did not resolve the drift.` ,
230- offendingFiles : [ ] ,
231- } ;
232- }
266+ //
267+ // FIX #6 — the collector-STATE classification (exit 2/5/1) is checked BEFORE
268+ // the belt-and-suspenders criticalCount>0 branch. A quarantine (exit 5) or an
269+ // infra failure (exit 1) that ALSO happens to carry a parseable criticalCount>0
270+ // is a quarantine/infra event, NOT a plain STILL_DIRTY — it gets its own
271+ // distinct reason so the Slack alert names the real cause. STILL_DIRTY is
272+ // reserved for exit 2, or a clean-looking exit 0 that nonetheless reports
273+ // criticalCount>0 (report/exit disagreement).
233274 if ( postFixCollectorExit === 5 ) {
234275 return {
235276 resolved : false ,
@@ -248,6 +289,16 @@ export function evaluateDriftResolved(i: PredicateInputs): PredicateResult {
248289 offendingFiles : [ ] ,
249290 } ;
250291 }
292+ if ( postFixCollectorExit === 2 || postFixCriticalCount > 0 ) {
293+ return {
294+ resolved : false ,
295+ reason : PredicateReason . STILL_DIRTY ,
296+ detail :
297+ "Post-fix drift collector still reports critical drift " +
298+ `(exit ${ postFixCollectorExit } , criticalCount ${ postFixCriticalCount } ). The fix did not resolve the drift.` ,
299+ offendingFiles : [ ] ,
300+ } ;
301+ }
251302 if ( postFixCollectorExit !== 0 ) {
252303 // Any other non-zero exit is an unrecognized collector state — fail closed.
253304 return {
@@ -260,12 +311,13 @@ export function evaluateDriftResolved(i: PredicateInputs): PredicateResult {
260311
261312 // ---- Classify the changed-file set. -----------------------------------
262313 const productionFiles = changedFiles . filter ( isProductionFile ) ;
263- const comparisonLegFiles = changedFiles . filter ( isComparisonLeg ) ;
314+ const gameableLegFiles = changedFiles . filter ( isGameableLeg ) ;
264315 const suppressionFiles = changedFiles . filter ( isSuppressionSurface ) ;
265316
266- // ---- Signal 3 (suppression): ALWAYS block. ----------------------------
267- // Edits to the allowlist/schema or a *.drift.ts assertion silence the
268- // detector and are never a valid fix — block even with a production change.
317+ // ---- Signal 3a (suppression surface): ALWAYS block, standalone or paired. -
318+ // Editing the schema/allowlist or a *.drift.ts assertion actively SILENCES the
319+ // detector — never a valid fix, even alongside a production change. Distinct
320+ // reason so the workflow's Slack alert names "silenced the detector".
269321 if ( suppressionFiles . length > 0 ) {
270322 return {
271323 resolved : false ,
@@ -277,21 +329,46 @@ export function evaluateDriftResolved(i: PredicateInputs): PredicateResult {
277329 } ;
278330 }
279331
280- // ---- Signal 2: PRODUCTION change present. ------------------------------
281- if ( productionFiles . length === 0 ) {
282- // No production change at all. Distinguish the pure comparison-leg cheat
283- // (the headline case) from a truly empty / non-source change.
284- if ( comparisonLegFiles . length > 0 ) {
332+ // ---- Signal 3b (gameable leg): ALWAYS block, independent of production. -
333+ // FIX #1 (HEADLINE) + FIX #2 — a legitimate auto-remediation updates the mock
334+ // BUILDER to match the SDK; it NEVER edits a comparison/SDK/harness leg (those
335+ // change only on a deliberate human vendored-SDK bump). So the presence of ANY
336+ // gameable-leg file blocks REGARDLESS of how many production files also
337+ // changed. This closes the WS-2b hybrid cheat (relax sdk-shapes.ts + one
338+ // trivial on-target production edit) that the old "only check legs when
339+ // productionFiles===0" logic passed straight to auto-merge. voice-models.ts is
340+ // dual-classified (harness + legit target) and blocks here (fix #2 precedence).
341+ //
342+ // Reason split (both distinct, both NEEDS-HUMAN in the workflow):
343+ // • leg edit WITH a production change → SUPPRESSION_SUSPECTED — the WS-2b
344+ // hybrid, the dangerous auto-merge vector.
345+ // • leg edit with NO production change → COMPARISON_LEG_ONLY — a pure
346+ // relaxation, no mock fix even attempted.
347+ if ( gameableLegFiles . length > 0 ) {
348+ if ( productionFiles . length > 0 ) {
285349 return {
286350 resolved : false ,
287- reason : PredicateReason . COMPARISON_LEG_ONLY ,
351+ reason : PredicateReason . SUPPRESSION_SUSPECTED ,
288352 detail :
289- "Fix changed ONLY comparison-leg files " +
290- `(${ comparisonLegFiles . join ( ", " ) } ) with no production mock-builder change — ` +
291- "this relaxes the drift detector instead of fixing the mock . The exact cheat this gate blocks ." ,
292- offendingFiles : comparisonLegFiles ,
353+ "Fix edited a gameable comparison/SDK/harness leg " +
354+ `(${ gameableLegFiles . join ( ", " ) } ) ALONGSIDE a production change — relaxing a leg is never a valid ` +
355+ "drift fix (a real fix updates the mock builder, not the leg) . The WS-2b hybrid cheat. Needs human review ." ,
356+ offendingFiles : gameableLegFiles ,
293357 } ;
294358 }
359+ return {
360+ resolved : false ,
361+ reason : PredicateReason . COMPARISON_LEG_ONLY ,
362+ detail :
363+ "Fix changed ONLY comparison/SDK/harness-leg files " +
364+ `(${ gameableLegFiles . join ( ", " ) } ) with no production mock-builder change — ` +
365+ "this relaxes the drift detector instead of fixing the mock. The exact cheat this gate blocks." ,
366+ offendingFiles : gameableLegFiles ,
367+ } ;
368+ }
369+
370+ // ---- Signal 2: PRODUCTION change present. ------------------------------
371+ if ( productionFiles . length === 0 ) {
295372 return {
296373 resolved : false ,
297374 reason : PredicateReason . NO_PRODUCTION_CHANGE ,
@@ -301,12 +378,28 @@ export function evaluateDriftResolved(i: PredicateInputs): PredicateResult {
301378 } ;
302379 }
303380
304- // ---- Signal 3 (off-target WARNING): production change must intersect ---
305- // the report's sanctioned target set. A shared helper MAY legitimately be
306- // the real fix, so this WARNS and blocks but is distinct from a cheat.
381+ // ---- Signal 3 (off-target): production change must intersect the report's
382+ // sanctioned target set. A shared helper MAY legitimately be the real fix, so
383+ // this WARNS and blocks but is distinct from a cheat.
384+ //
385+ // FIX #3 — an EMPTY sanctioned-target set is fail-closed, not a free pass. The
386+ // old `targets.size > 0` guard disabled the off-target check entirely when the
387+ // report named no targets, accepting ANY production change. An empty target set
388+ // means the report could not name where to fix the drift — route to human
389+ // rather than rubber-stamp an unverifiable production change.
307390 const targets = sanctionedTargets ( report ) ;
391+ if ( targets . size === 0 ) {
392+ return {
393+ resolved : false ,
394+ reason : PredicateReason . PRODUCTION_CHANGE_OFF_TARGET ,
395+ detail :
396+ "Drift report named ZERO sanctioned fix targets (no builderFile/typesFile) — cannot verify the " +
397+ `production change (${ productionFiles . join ( ", " ) } ) landed where the drift lives. Fail-closed, needs human review.` ,
398+ offendingFiles : productionFiles ,
399+ } ;
400+ }
308401 const onTarget = productionFiles . some ( ( f ) => targets . has ( f ) ) ;
309- if ( targets . size > 0 && ! onTarget ) {
402+ if ( ! onTarget ) {
310403 return {
311404 resolved : false ,
312405 reason : PredicateReason . PRODUCTION_CHANGE_OFF_TARGET ,
@@ -422,6 +515,19 @@ export function readReport(path: string): DriftReport {
422515 `Drift report at ${ path } has invalid structure: expected { entries: [...] }` ,
423516 ) ;
424517 }
518+ // FIX #6 — align this (previously loose) validator with the stricter
519+ // fix-drift.ts:readDriftReport: a report missing/carrying a non-string
520+ // `timestamp` is a corrupt/truncated collector run and must fail-closed rather
521+ // than be silently trusted as a clean "drift is gone" signal. NOTE: an EMPTY
522+ // `entries: []` is intentionally ACCEPTED — that is exactly what the collector
523+ // emits when no drift remains (the legitimate clean signal); the trust anchor
524+ // for "clean" is the collector EXIT CODE plus fix #1's always-block-on-leg-edit
525+ // rule, not a non-empty entries array.
526+ if ( typeof ( parsed as Record < string , unknown > ) . timestamp !== "string" ) {
527+ throw new PredicateConfigError (
528+ `Drift report at ${ path } is missing a string "timestamp" — corrupt/truncated report, failing closed` ,
529+ ) ;
530+ }
425531 return parsed as DriftReport ;
426532}
427533
@@ -454,6 +560,33 @@ export function gitChangedFiles(): string[] {
454560 return out . split ( "\n" ) . filter ( Boolean ) . map ( parsePorcelainLine ) ;
455561}
456562
563+ /**
564+ * FIX #4 — AUTHORITATIVE changed-file set. The git working tree is the single
565+ * source of truth for what actually changed. An explicit `--changed-file` list
566+ * is only a hint, and a supplied list that OMITS a relaxed leg would BLIND the
567+ * predicate (the exact WS-2b vector, from the other side). So when a list is
568+ * supplied we cross-check it against git as a set and fail-closed
569+ * (PredicateConfigError → exit 2) on ANY disagreement — a missing file (leg
570+ * hidden) OR an extra file (phantom) both mean the caller's view diverges from
571+ * ground truth and the verdict cannot be trusted. When no list is supplied we
572+ * use the git set directly.
573+ */
574+ export function crossCheckChangedFiles ( explicit : string [ ] | null , git : string [ ] ) : string [ ] {
575+ if ( explicit === null ) return git ;
576+ const gitSet = new Set ( git ) ;
577+ const explicitSet = new Set ( explicit ) ;
578+ const missing = git . filter ( ( f ) => ! explicitSet . has ( f ) ) ; // git has it, list omits it
579+ const extra = explicit . filter ( ( f ) => ! gitSet . has ( f ) ) ; // list has it, git does not
580+ if ( missing . length > 0 || extra . length > 0 ) {
581+ throw new PredicateConfigError (
582+ "--changed-file list disagrees with the git working tree (fail-closed): " +
583+ `${ missing . length > 0 ? `omitted by the list: ${ missing . join ( ", " ) } ; ` : "" } ` +
584+ `${ extra . length > 0 ? `not present in git: ${ extra . join ( ", " ) } ` : "" } ` . trim ( ) ,
585+ ) ;
586+ }
587+ return explicit ;
588+ }
589+
457590/**
458591 * Run the predicate from CLI args. Returns the process exit code and prints
459592 * `detail` (and offending files for LOUD reasons) to stdout/stderr.
@@ -481,12 +614,15 @@ export function runCli(argv: string[]): number {
481614 return REASON_EXIT_CODE [ PredicateReason . CONFIG_ERROR ] ;
482615 }
483616
617+ // FIX #4 — always derive the AUTHORITATIVE changed set from git, and
618+ // cross-check any supplied --changed-file list against it (fail-closed on
619+ // mismatch) so a leg-omitting list cannot blind the predicate.
484620 let changedFiles : string [ ] ;
485621 try {
486- changedFiles = args . changedFiles ?? gitChangedFiles ( ) ;
622+ changedFiles = crossCheckChangedFiles ( args . changedFiles , gitChangedFiles ( ) ) ;
487623 } catch ( err : unknown ) {
488624 const msg = err instanceof Error ? err . message : String ( err ) ;
489- console . error ( `CONFIG_ERROR: could not read changed files from git: ${ msg } ` ) ;
625+ console . error ( `CONFIG_ERROR: ${ msg } ` ) ;
490626 console . log ( `reason=${ PredicateReason . CONFIG_ERROR } ` ) ;
491627 return REASON_EXIT_CODE [ PredicateReason . CONFIG_ERROR ] ;
492628 }
0 commit comments