Skip to content

Commit 77a8be0

Browse files
authored
fix(test): unbreak main — seed the downloadable corpus #9966 now requires (#9974)
main has been red since #9966 (40133d7): every commit after it fails test/integration/public-eval-scores-route.test.ts with "expected [] to have a length of 1". Bisected -- the commit before it passes 9/9. #9966 made the DOWNLOADABLE corpus the only commitment source, deliberately dropping the persisted-backtest-run fallback: a commitment must name bytes a reader can fetch and re-hash. Correct change, but this suite's fixture recorded only the human OVERRIDES, and the corpus is built from rule-FIRED events joined to them -- so it produced a 0-case corpus, no commitment, and no record. Confirmed by probing loadPublicEvalCorpus directly: caseCount 0 with overrides alone, 20 once the fired events are seeded too. Seeding both halves restores what the fixture always meant to represent: 20 decided cases a reader can download. Changing the expectation to `records: []` would have gone green while silently dropping the recordDigest-recomputability check this suite exists for. The commitment assertion moves off the literal "freeze-point-checksum" (the old contract) and onto what /v1/public/eval-corpus actually serves, so the two surfaces cannot drift apart while both still look correct in isolation.
1 parent c32cd16 commit 77a8be0

4 files changed

Lines changed: 221 additions & 5 deletions

File tree

package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
"db:migrations:check": "tsx scripts/check-migrations.ts",
6161
"db:migrations:immutable:check": "tsx scripts/check-released-migrations-immutable.ts",
6262
"turbo-inputs:check": "tsx scripts/check-turbo-typecheck-inputs.ts",
63+
"typecheck-coverage:check": "tsx scripts/check-typecheck-coverage.ts",
6364
"fixture-clock-races:check": "tsx scripts/check-fixture-clock-races.ts",
6465
"workspace-dep-ranges:check": "tsx scripts/check-workspace-dep-ranges.ts",
6566
"db:schema-drift:check": "tsx scripts/check-schema-drift.ts",
@@ -70,7 +71,7 @@
7071
"ui:build": "npm run ui:kit:build && turbo run build --filter=@loopover/engine --filter=@loopover/contract && npm run ui:openapi && npm --workspace @loopover/ui run build && npm --workspace @loopover/ui-miner run build",
7172
"ui:preview": "npm run ui:build && wrangler dev --config apps/loopover-ui/dist/server/wrangler.json --ip 127.0.0.1 --port 4173 --local",
7273
"ui:lint": "npm run ui:kit:build && npm --workspace @loopover/ui-kit run format:check && npm --workspace @loopover/ui run format:check && npm --workspace @loopover/ui run lint && npm --workspace @loopover/ui-miner run format:check && npm --workspace @loopover/ui-miner run lint",
73-
"ui:typecheck": "npm run ui:kit:build && npm --workspace @loopover/ui run typecheck && npm --workspace @loopover/ui-miner run typecheck",
74+
"ui:typecheck": "npm run ui:kit:build && npm --workspace @loopover/ui-kit run typecheck && npm --workspace @loopover/ui run typecheck && npm --workspace @loopover/ui-miner run typecheck",
7475
"preui:test": "npm run check-node-version",
7576
"ui:test": "npm run ui:kit:build && npm --workspace @loopover/ui run test && npm --workspace @loopover/ui-miner run test",
7677
"ui:openapi": "tsx scripts/write-ui-openapi.ts",
@@ -121,7 +122,7 @@
121122
"changelog:check:mcp": "node --experimental-strip-types scripts/check-changelog.ts --mcp",
122123
"mcp:release-due": "tsx scripts/check-mcp-release-due.ts --json",
123124
"mcp:release-candidate": "tsx scripts/check-mcp-release-candidate.ts",
124-
"typecheck": "npm run typecheck:root && npm run typecheck:packages",
125+
"typecheck": "npm run typecheck:root && npm run typecheck:packages && npm run ui:typecheck",
125126
"typecheck:root": "tsc --noEmit",
126127
"typecheck:packages": "tsc -p packages/loopover-contract/tsconfig.json --noEmit && tsc -p packages/loopover-mcp/tsconfig.json --noEmit && tsc -p packages/loopover-miner/tsconfig.json --noEmit",
127128
"check-node-version": "node --experimental-strip-types scripts/check-node-version.ts",
@@ -145,7 +146,7 @@
145146
"test:smoke:browser:install": "playwright install chromium",
146147
"test:smoke:browser": "node --experimental-strip-types scripts/smoke-ui-browser.ts",
147148
"pretest:ci": "npm run check-node-version",
148-
"test:ci": "git diff --check && npm run actionlint && npm run lint:composite-actions && npm run db:migrations:check && npm run db:migrations:immutable:check && npm run turbo-inputs:check && npm run workspace-dep-ranges:check && npm run db:schema-drift:check && npm run selfhost:env-reference:check && npm run miner:env-reference:check && npm run selfhost:validate-observability && npm run cf-typegen:check && npm run build --workspace @loopover/engine && npm run build --workspace @loopover/discovery-index && npm run build:mcp && npm run build:miner && npm run build --workspace @loopover/ui-kit && npm run typecheck && npm run test:coverage && npm run test:engine-parity && npm run test:live-gate-parity && npm run test:driver-parity && npm run validate:mcp && npm run test --workspace @loopover/engine && npm run test:workers && npm run test:mcp-pack && npm run test:contract-pack && npm run test:miner-pack && npm run test:engine-pack && npm run test:ui-kit-pack && npm run test:miner-deployment-docs-audit && npm run rees:test && npm run ui:openapi:check && npm run control-plane:contract:check && npm run control-plane:openapi:check && npm run ui:version-audit && npm run docs:drift-check && npm run coverage-boltons:check && npm run import-specifiers:check && npm run ui-derived-types:check && npm run server-manifest:check && npm run dead-source-files:check && npm run dead-exports:check && npm run publishable-deps:check && npm run fixture-clock-races:check && npm run checkers-wired:check && npm run release-commit-parsing:check && npm run regate-sort-key:check && npm run maintainer-associations:check && npm run command-redelivery-guards:check && npm run dispatch-gate-reasons:check && npm run validate:no-hand-written-js && npm run replay-runner-manifest:check && npm run coco-dev-versions:check && npm run branding-drift:check && npm run manifest:drift-check && npm run engine-parity:drift-check && npm run engines-nvmrc:check && npm run release-manifest:sync:check && npm run release-linked-versions:check && npm run command-reference:check && npm run mcp:tool-reference:check && npm run mcp:client-config:check && npm run contract:api-schemas:check && npm run ui:lint && npm run ui:typecheck && npm run ui:test && npm run ui:build",
149+
"test:ci": "git diff --check && npm run actionlint && npm run lint:composite-actions && npm run db:migrations:check && npm run db:migrations:immutable:check && npm run turbo-inputs:check && npm run workspace-dep-ranges:check && npm run db:schema-drift:check && npm run selfhost:env-reference:check && npm run miner:env-reference:check && npm run selfhost:validate-observability && npm run cf-typegen:check && npm run build --workspace @loopover/engine && npm run build --workspace @loopover/discovery-index && npm run build:mcp && npm run build:miner && npm run build --workspace @loopover/ui-kit && npm run typecheck && npm run test:coverage && npm run test:engine-parity && npm run test:live-gate-parity && npm run test:driver-parity && npm run validate:mcp && npm run test --workspace @loopover/engine && npm run test:workers && npm run test:mcp-pack && npm run test:contract-pack && npm run test:miner-pack && npm run test:engine-pack && npm run test:ui-kit-pack && npm run test:miner-deployment-docs-audit && npm run rees:test && npm run ui:openapi:check && npm run control-plane:contract:check && npm run control-plane:openapi:check && npm run ui:version-audit && npm run docs:drift-check && npm run coverage-boltons:check && npm run import-specifiers:check && npm run ui-derived-types:check && npm run server-manifest:check && npm run dead-source-files:check && npm run dead-exports:check && npm run publishable-deps:check && npm run fixture-clock-races:check && npm run typecheck-coverage:check && npm run checkers-wired:check && npm run release-commit-parsing:check && npm run regate-sort-key:check && npm run maintainer-associations:check && npm run command-redelivery-guards:check && npm run dispatch-gate-reasons:check && npm run validate:no-hand-written-js && npm run replay-runner-manifest:check && npm run coco-dev-versions:check && npm run branding-drift:check && npm run manifest:drift-check && npm run engine-parity:drift-check && npm run engines-nvmrc:check && npm run release-manifest:sync:check && npm run release-linked-versions:check && npm run command-reference:check && npm run mcp:tool-reference:check && npm run mcp:client-config:check && npm run contract:api-schemas:check && npm run ui:lint && npm run ui:typecheck && npm run ui:test && npm run ui:build",
149150
"test:release": "npm run test:ci && npm run changelog:check",
150151
"test:release:mcp": "npm run test:ci",
151152
"test:watch": "vitest",
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
#!/usr/bin/env node
2+
// `npm run typecheck` must actually typecheck everything that can be typechecked (#9860).
3+
//
4+
// THE INCIDENT. #9815 turned main red because `ChatActionDispatchResult` was closed into a union and a
5+
// miner-UI mock stopped satisfying it. The change was validated locally with `npm run typecheck`, which
6+
// passed -- because `ui:typecheck` was in `test:ci` but NOT in the root `typecheck` chain. A contributor
7+
// running the obvious command got a green result on a tree that does not compile.
8+
//
9+
// That is the worst shape a check can have: not missing, but SILENTLY PARTIAL. A missing check is noticed
10+
// the first time something breaks; a partial one is trusted precisely because it passes.
11+
//
12+
// WHAT THIS COMPUTES. Every workspace that declares its own `typecheck` script, and whether the root
13+
// `typecheck` script reaches it -- following `npm run <script>` references transitively, so a workspace
14+
// covered through an intermediate script (`ui:typecheck` -> `npm --workspace @loopover/ui run typecheck`)
15+
// counts as covered. Anything declaring a typecheck nobody runs is reported.
16+
//
17+
// This is #9853's bar applied to one more hand-maintained list: compute the fact rather than remember it.
18+
// The failure that motivated the issue was not "someone forgot to add it" -- it was that nothing could tell
19+
// them they had forgotten.
20+
import { readFileSync, readdirSync } from "node:fs";
21+
import { join } from "node:path";
22+
import { fileURLToPath, URL } from "node:url";
23+
24+
export type TypecheckGap = { workspace: string; script: string };
25+
26+
/** Every `npm run <name>` this script body invokes (the root package's own scripts). */
27+
function referencedRootScripts(body: string): string[] {
28+
// `npm run x`, `npm run x --silent`, `npm --silent run x` -- all forms used in this package.json.
29+
return [...body.matchAll(/npm\s+(?:--\S+\s+)*run\s+([\w:.-]+)/g)].map((match) => match[1]).filter((name): name is string => Boolean(name));
30+
}
31+
32+
/** Every workspace whose OWN `typecheck` this script body invokes directly. */
33+
function referencedWorkspaces(body: string): string[] {
34+
// `npm --workspace @scope/name run typecheck` and `npm run typecheck --workspace @scope/name`.
35+
const names = [
36+
...body.matchAll(/npm\s+--workspace[= ]\s*(\S+)\s+run\s+([\w:.-]+)/g),
37+
...body.matchAll(/npm\s+run\s+([\w:.-]+)\s+--workspace[= ]\s*(\S+)/g),
38+
];
39+
const out: string[] = [];
40+
for (const match of names) {
41+
// The two patterns capture (workspace, script) and (script, workspace) respectively; the workspace is
42+
// whichever capture looks like a package name.
43+
const [a, b] = [match[1], match[2]];
44+
const workspace = a?.startsWith("@") || a?.includes("/") ? a : b;
45+
const script = workspace === a ? b : a;
46+
if (workspace && script === "typecheck") out.push(workspace);
47+
}
48+
return out;
49+
}
50+
51+
/**
52+
* PURE: workspaces that declare a `typecheck` script the root `typecheck` never reaches.
53+
*
54+
* `scripts` is the root package's script map; `workspacesWithTypecheck` is every workspace package name that
55+
* declares one. Reachability follows `npm run` references transitively from `entry`, because a workspace is
56+
* covered whether it is invoked directly or through an intermediate script.
57+
*/
58+
export function findTypecheckGaps(
59+
scripts: Readonly<Record<string, string>>,
60+
workspacesWithTypecheck: readonly string[],
61+
entry = "typecheck",
62+
): TypecheckGap[] {
63+
const covered = new Set<string>();
64+
const seen = new Set<string>();
65+
const queue: string[] = [entry];
66+
while (queue.length > 0) {
67+
const name = queue.shift();
68+
if (!name || seen.has(name)) continue;
69+
seen.add(name);
70+
const body = scripts[name];
71+
if (body === undefined) continue;
72+
for (const workspace of referencedWorkspaces(body)) covered.add(workspace);
73+
// A `tsc -p packages/<x>/tsconfig.json` counts too: the root chain typechecks that project directly
74+
// without going through the workspace's own script.
75+
for (const match of body.matchAll(/-p\s+((?:packages|apps)\/[\w.-]+)\//g)) {
76+
const dir = match[1];
77+
if (dir) covered.add(dir);
78+
}
79+
queue.push(...referencedRootScripts(body));
80+
}
81+
return workspacesWithTypecheck
82+
.filter((workspace) => !covered.has(workspace) && !covered.has(workspace.replace(/^@[\w-]+\//, "")))
83+
.map((workspace) => ({ workspace, script: "typecheck" }));
84+
}
85+
86+
/** Workspace package names (and their directories) that declare their own `typecheck` script. */
87+
export function workspacesDeclaringTypecheck(root: string): { name: string; dir: string }[] {
88+
const out: { name: string; dir: string }[] = [];
89+
for (const group of ["apps", "packages"]) {
90+
let dirs: string[];
91+
try {
92+
dirs = readdirSync(join(root, group), { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
93+
} catch {
94+
continue;
95+
}
96+
for (const dir of dirs) {
97+
try {
98+
const manifest = JSON.parse(readFileSync(join(root, group, dir, "package.json"), "utf8")) as { name?: string; scripts?: Record<string, string> };
99+
if (manifest.name && manifest.scripts?.typecheck) out.push({ name: manifest.name, dir: `${group}/${dir}` });
100+
} catch {
101+
// not a workspace package
102+
}
103+
}
104+
}
105+
return out;
106+
}
107+
108+
function main(): void {
109+
const root = join(fileURLToPath(new URL(".", import.meta.url)), "..");
110+
const rootManifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as { scripts?: Record<string, string> };
111+
const declared = workspacesDeclaringTypecheck(root);
112+
const gaps = findTypecheckGaps(rootManifest.scripts ?? {}, declared.map((entry) => entry.name));
113+
114+
if (gaps.length > 0) {
115+
console.error("`npm run typecheck` does not reach every workspace that declares one:\n");
116+
for (const gap of gaps) console.error(` ${gap.workspace} (declares "${gap.script}", never invoked)`);
117+
console.error(
118+
"\n A typecheck that passes while part of the tree does not compile is worse than no typecheck: it is\n" +
119+
" trusted BECAUSE it passes. #9815 turned main red exactly this way -- the change was validated with\n" +
120+
" `npm run typecheck`, which did not cover apps/**.\n\n" +
121+
" Fix: chain the workspace into the root `typecheck` script (directly, or through one it already\n" +
122+
" calls), so the obvious command means what a contributor assumes it means.",
123+
);
124+
process.exit(1);
125+
}
126+
console.log(`typecheck-coverage: OK — all ${declared.length} workspace typecheck script(s) are reachable from \`npm run typecheck\`.`);
127+
}
128+
129+
if (process.argv[1]?.endsWith("check-typecheck-coverage.ts")) main();

test/integration/public-eval-scores-route.test.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,21 @@ const NOW = Date.parse("2026-07-27T12:00:00.000Z");
1212
async function seedConfirmedPrecisionData(env: Env): Promise<void> {
1313
const store = createSignalStore(env);
1414
for (let i = 0; i < 20; i += 1) {
15+
const occurredAt = new Date(NOW - 1000 - i).toISOString();
16+
// #9966 made the DOWNLOADABLE corpus the only commitment source, so a record is publishable only where
17+
// /v1/public/eval-corpus can actually serve the cases behind it. That corpus is built from rule-FIRED
18+
// events joined to their overrides, and this fixture recorded only the overrides -- so it produced a
19+
// 0-case corpus, no commitment, and no record, which is why this suite went red on main.
20+
//
21+
// Seeding both halves restores what the fixture was always meant to represent: 20 decided cases a reader
22+
// can download and re-hash. Asserting `records: []` instead would have kept the suite green while
23+
// silently dropping the recordDigest-recomputability check these tests exist for.
24+
await store.recordRuleFired({ ruleId: "ai_consensus_defect", targetKey: `acme/widgets#${i + 1}`, outcome: "close", occurredAt });
1525
await store.recordHumanOverride({
1626
ruleId: "ai_consensus_defect",
1727
targetKey: `acme/widgets#${i + 1}`,
1828
verdict: i < 16 ? "confirmed" : "reversed",
19-
occurredAt: new Date(NOW - 1000 - i).toISOString(),
29+
occurredAt,
2030
});
2131
}
2232
await recordAuditEvent(env, {
@@ -59,7 +69,14 @@ describe("GET /v1/public/eval-scores (#9266, epic #8534, spec #9215)", () => {
5969
// coverage is 1, not null: abstained is structurally 0 for this work-unit kind, so the record's own
6070
// decided/(decided+abstained) is fully determined and the published field states it (#9643).
6171
expect(record?.score).toEqual({ decided: 20, confirmed: 16, precision: 0.8, recall: null, coverage: 1, abstained: 0 });
62-
expect(record?.commitments.corpusChecksum).toBe("freeze-point-checksum");
72+
// #9966: the commitment is the checksum of the corpus a reader can DOWNLOAD, not the persisted backtest
73+
// run's freeze point. That is the whole point of the change -- a commitment must name bytes someone can
74+
// fetch and re-hash. Asserted against what /v1/public/eval-corpus actually serves rather than a literal,
75+
// so the two surfaces cannot drift apart while both still look correct in isolation.
76+
const corpusRes = await createApp().request("/v1/public/eval-corpus?ruleId=ai_consensus_defect", {}, env);
77+
const corpus = (await corpusRes.json()) as { checksum: string; caseCount: number };
78+
expect(corpus.caseCount).toBe(20);
79+
expect(record?.commitments.corpusChecksum).toBe(corpus.checksum);
6380
expect(record?.subject).toEqual({ kind: "agent", id: ORB_GATE_SUBJECT_ID });
6481

6582
const { recordDigest, ...rest } = record as EvalScoreRecord;

0 commit comments

Comments
 (0)