Commit 6a5e679
authored
fix(drift): require real drift resolution, not fixture relaxation, before PR/merge (#310)
## The hole
`scripts/fix-drift.ts`'s abort guard accepted any auto-fix run that
changed a
file under `src/` (`builderFiles.length > 0 || testFiles.length > 0`)
and exited
0. The drift tests are three-way triangulations (SDK vs real API vs
mock), and
the **SDK leg is literally the repo fixture**
`src/__tests__/drift/sdk-shapes.ts`.
So the LLM could "resolve" drift by **relaxing that fixture**: deleting
the
field makes the SDK leg null → the `critical` branch is unreachable →
the
collector re-reports clean (exit 0) with the production mock
**untouched** →
WS-1's hardened merge gate sees green CI and auto-merges. Re-running the
collector alone can't catch this because its own SDK leg reads the
relaxed
fixture.
## The fix — a 3-signal predicate
New `scripts/drift-success-predicate.ts` (pure `evaluateDriftResolved()`
+ thin
CLI). `resolved: true` requires ALL of:
1. **Authoritative** — post-fix collector re-run clean (`exit 0` AND
`criticalCount 0`). Distinct exits 2/5/1 → `STILL_DIRTY` /
`QUARANTINE_AFTER_FIX`
/ `COLLECTOR_INFRA`.
2. **Production change present** — ≥1 file under `src/**` excluding
`src/__tests__/`. A relaxation NEVER changes one. Empty →
`NO_PRODUCTION_CHANGE`;
only comparison-leg files → `COMPARISON_LEG_ONLY` (the headline cheat).
3. **No standalone comparison-leg relaxation** — a gameable-leg edit
(`sdk-shapes.ts` / `providers.ts` / `ws-providers.ts` / harness
`helpers.ts`)
must be accompanied by a production change; and `schema.ts` (its
`ALLOWLISTED_PATHS`) or any `*.drift.ts` assertion edit is **always**
blocked
as `SUPPRESSION_SUSPECTED`, even alongside a production change.
Legit fixture targets (`model-registry.ts`, `model-family.ts`,
`voice-models.ts`)
are sanctioned only via the report's `builderFile`/`typesFile` (canary
routing).
An off-target production change → `PRODUCTION_CHANGE_OFF_TARGET`
(WARNING, still
blocks). Distinct exit codes (0, 10–16, 2) route each failure to a named
needs-human Slack reason.
### Wiring
- `fix-drift.ts` `createPr()`: the predicate replaces the weak guard and
runs
**before any `git add`/commit** (blocked verdict → no PR → no merge).
Legacy
exit-4 guard retained as a fallback when the workflow doesn't pass
`--post-fix-report`/`--post-fix-exit`.
- `.github/workflows/fix-drift.yml`: fresh authoritative re-collect to
`drift-report.post-fix.json`, uploaded as an artifact; new "Assert drift
truly
resolved" step; new reasons wired into the Slack `case` block.
- Fixed a latent bug in the assert step: `PIPESTATUS[0]` after a
`VAR="$(pipeline)"` assignment reports the assignment's status (always
0), not
the predicate's — so the step would never have blocked a cheat. Now the
predicate runs to `predicate.log`, its real exit is captured directly,
and the
reason is grepped from the log.
## Red-green proof
Unit suite (`src/__tests__/drift-success-predicate.test.ts`, 28 tests,
no LLM):
**RED** — stubbing `evaluateDriftResolved` to always accept (== the old
`testFiles>0` guard) fails all 10 cheat-detection cases, including the
headline:
```
❯ src/__tests__/drift-success-predicate.test.ts (28 tests | 10 failed)
× HEADLINE: fixture-relaxation-only (sdk-shapes.ts) → COMPARISON_LEG_ONLY, and the OLD guard would have ACCEPTED it
× schema/allowlist edit + real builder change → SUPPRESSION_SUSPECTED
× *.drift.ts assertion loosened only → SUPPRESSION_SUSPECTED
× no changes at all → NO_PRODUCTION_CHANGE
× production change but collector still dirty (exit 2) → STILL_DIRTY
... (10 total)
```
**GREEN** — real implementation restored: 28/28 pass.
**CLI transcript** (the exact cheat vs a real fix):
```
############## RED: fixture-relaxation-only (sdk-shapes.ts) ##############
DRIFT NOT RESOLVED [comparison-leg-only]: Fix changed ONLY comparison-leg files
(src/__tests__/drift/sdk-shapes.ts) with no production mock-builder change —
this relaxes the drift detector instead of fixing the mock. The exact cheat this gate blocks.
Offending files: src/__tests__/drift/sdk-shapes.ts
reason=comparison-leg-only
RED exit code: 11
############## GREEN: real production fix (src/helpers.ts) ##############
Drift genuinely resolved: post-fix collector clean, real production change
(src/helpers.ts), no comparison-leg relaxation.
reason=resolved
GREEN exit code: 0
```
Full suite (`pnpm test`): 4533 passed / 44 skipped. `prettier --check`,
`eslint`,
`tsc --noEmit`, `pnpm build`, `actionlint` (incl. shellcheck) all green.
## Out of scope — residual WS-2b (flagged)
**Hybrid gaming**: a fix that relaxes `sdk-shapes.ts` AND makes a token
edit to
the *named* production builder passes both signal 1 (collector clean —
its own
SDK leg reads the relaxed fixture) and signal 2 (production change
present). Fully
closing this needs an **SDK leg sourced independently of the repo
fixture** (e.g.
generated from the vendored SDK's `.d.ts` at collect time). That is
tracked as
WS-2b and is out of this PR's scope; `SUPPRESSION_SUSPECTED` on
schema/allowlist
plus the OFF_TARGET warning are the partial mitigations here.
---
## Update (CR fix round 1, 75c42ab) — WS-2b CLOSED for auto-merge
The "residual WS-2b" hybrid above is now **closed for the auto-merge
path**. The
predicate no longer ignores a gameable-leg edit when a production file
also
changed: **any** edit to a gameable leg (`sdk-shapes.ts`, the
real-API/harness
files incl. `voice-models.ts`, `providers.ts`/`ws-providers.ts`,
`schema.ts`,
`*.drift.ts`) now blocks **regardless** of how many production files
also changed.
So relaxing `sdk-shapes.ts` + a trivial on-target production edit →
needs-human
(`SUPPRESSION_SUSPECTED`), never auto-merge.
The independently-sourced SDK leg remains a good future hardening for
the
*detection* side, but is no longer required to keep the hybrid cheat out
of
auto-merge — a leg edit can never reach a PR/merge unattended.
Also in this round: dual-classified `voice-models.ts` blocks
(precedence);
empty sanctioned-target set fails closed; `--changed-file` lists are
cross-checked
against git (fail-closed on mismatch); the no-post-fix-args legacy PR
path is
removed (fail-closed); exit 5/1 get distinct reasons ahead of
`criticalCount`;
`readReport` rejects a missing timestamp. Real-predicate red→green locks
added
(WS-2b headline flips resolved→SUPPRESSION_SUSPECTED) plus a revert
guard.10 files changed
Lines changed: 3232 additions & 1148 deletions
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
16 | 16 | | |
17 | 17 | | |
18 | 18 | | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
19 | 22 | | |
20 | | - | |
| 23 | + | |
This file was deleted.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
This file was deleted.
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
0 commit comments