Skip to content

Commit 78f97b5

Browse files
committed
fix: remaining drift remediation changes
1 parent e95e8bf commit 78f97b5

1 file changed

Lines changed: 185 additions & 49 deletions

File tree

scripts/drift-success-predicate.ts

Lines changed: 185 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -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
*/
150165
const 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
*/
179231
export 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
*/
191239
export 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

Comments
 (0)