Skip to content

Commit 1626aef

Browse files
committed
fix(build): stop tsc packages from no-op'ing their own emit
Adding four modules to @loopover/contract produced a GREEN `@loopover/contract:build` in CI followed five seconds later, on the same runner, by `Cannot find module '@loopover/contract/local-config'` from the miner's build. `tsc -p` with `incremental` (inherited from the root tsconfig) decides what to emit from .tsbuildinfo alone -- it never checks whether the outputs that stamp describes are still on disk. turbo caches .tsbuildinfo alongside dist/, so a run where the two come back out of lockstep, or a --force run over a leftover stamp, makes tsc declare itself up to date and emit nothing. Reproduced exactly by deleting dist/ with the stamp left in place. All three packages that cache .tsbuildinfo as a turbo output have the same exposure, so each clears the stamp before compiling; any new file in any of them could have hit this. The UI preview workflow separately never built the contract at all -- it builds the engine and then runs ui:openapi, which now reaches src/openapi/schemas.ts's re-export of the public-API schemas. It builds both packages now. package-lock.json was out of sync with two real changes: the contract's new @cloudflare/workers-types devDependency and the UI's new @loopover/contract dependency.
1 parent 8aae70f commit 1626aef

6 files changed

Lines changed: 55 additions & 24 deletions

File tree

.github/workflows/ui-preview.yml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,10 @@ jobs:
7979
# away the result. `npx turbo run build --filter=@loopover/ui` replaces the last two `ui:build`
8080
# steps (`extension:build && miner-extension:build && ui build` already covered by
8181
# @loopover/ui#build's own turbo.json dependsOn) and skips ui-miner entirely.
82+
# @loopover/contract joins the engine filter because `ui:openapi` imports src/openapi/schemas.ts,
83+
# which re-exports the public-API response schemas from @loopover/contract/public-api (#9282/#9521).
84+
# Without it this step failed with ERR_MODULE_NOT_FOUND on dist/public-api.js -- the contract was
85+
# never built here at all, only in the CI workflow's own steps.
8286
- name: Build UI
8387
env:
8488
VITE_LOOPOVER_API_ORIGIN: https://api.loopover.ai
@@ -87,7 +91,7 @@ jobs:
8791
# production build (ui-deploy.yml) does NOT set this, so the escape hatch is dead-code-eliminated
8892
# from prod. (#authed-route-preview)
8993
VITE_PREVIEW: "1"
90-
run: npm run ui:kit:build && npx turbo run build --filter=@loopover/engine && npm run ui:openapi && npx turbo run build --filter=@loopover/ui
94+
run: npm run ui:kit:build && npx turbo run build --filter=@loopover/engine --filter=@loopover/contract && npm run ui:openapi && npx turbo run build --filter=@loopover/ui
9195

9296
# Deploys stay best-effort when the Cloudflare secrets aren't configured (Reviewbot shows
9397
# before-only) — mirrors ui-preview-deploy.yml's guard.
@@ -177,7 +181,7 @@ jobs:
177181
env:
178182
VITE_LOOPOVER_API_ORIGIN: https://api.loopover.ai
179183
VITE_PREVIEW: "1"
180-
run: npm run ui:kit:build && npx turbo run build --filter=@loopover/engine && npm run ui:openapi && npx turbo run build --filter=@loopover/ui
184+
run: npm run ui:kit:build && npx turbo run build --filter=@loopover/engine --filter=@loopover/contract && npm run ui:openapi && npx turbo run build --filter=@loopover/ui
181185

182186
# The trusted deploy workflow downloads this by name + run-id. It contains only the built bundle
183187
# (server/ + client/) — no secrets, no source needed downstream.

package-lock.json

Lines changed: 4 additions & 19 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/loopover-contract/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@
6666
"CHANGELOG.md"
6767
],
6868
"scripts": {
69-
"build": "tsc -p tsconfig.json"
69+
"build": "node -e \"require('node:fs').rmSync('.tsbuildinfo',{force:true})\" && tsc -p tsconfig.json"
7070
},
7171
"dependencies": {
7272
"zod": "^4.4.3"

packages/loopover-mcp/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141
],
4242
"scripts": {
4343
"build": "npm run build:tsc && npm run build:verify",
44-
"build:tsc": "tsc -p tsconfig.json && node --experimental-strip-types scripts/strip-bin-sourcemap.ts",
44+
"build:tsc": "node -e \"require('node:fs').rmSync('.tsbuildinfo',{force:true})\" && tsc -p tsconfig.json && node --experimental-strip-types scripts/strip-bin-sourcemap.ts",
4545
"build:verify": "node --experimental-strip-types scripts/check-syntax.ts"
4646
},
4747
"dependencies": {

packages/loopover-miner/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
"benchmark": "node --experimental-strip-types scripts/benchmark.ts",
4444
"cross-repo-eval": "node --experimental-strip-types scripts/cross-repo-evaluation.ts",
4545
"build": "npm run build:tsc && npm run build:verify",
46-
"build:tsc": "tsc -p tsconfig.json",
46+
"build:tsc": "node -e \"require('node:fs').rmSync('.tsbuildinfo',{force:true})\" && tsc -p tsconfig.json",
4747
"build:verify": "node --experimental-strip-types scripts/check-syntax.ts"
4848
},
4949
"dependencies": {
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { readFileSync } from "node:fs";
2+
import { join } from "node:path";
3+
import { describe, expect, it } from "vitest";
4+
5+
// REGRESSION (#9521): a package whose build is `tsc -p` with `incremental` (inherited from the root
6+
// tsconfig) decides what to emit from `.tsbuildinfo` ALONE — it never checks whether the outputs it
7+
// describes are still on disk. turbo caches `.tsbuildinfo` alongside `dist/`, so any run where the two come
8+
// back out of lockstep (or a `--force` run over a leftover stamp) makes tsc declare itself up to date and
9+
// emit NOTHING, leaving `dist/` without the new files.
10+
//
11+
// That is not hypothetical: adding four modules to @loopover/contract produced a green
12+
// `@loopover/contract:build` in CI followed immediately by
13+
// `Cannot find module '@loopover/contract/local-config'` from the miner's own build, on the same runner,
14+
// five seconds apart. Every package that caches `.tsbuildinfo` as a turbo output has the same exposure, so
15+
// each of them deletes the stamp before building.
16+
17+
const PACKAGES_CACHING_TSBUILDINFO = ["loopover-contract", "loopover-mcp", "loopover-miner"] as const;
18+
19+
function buildScript(pkg: string): string {
20+
const manifest = JSON.parse(readFileSync(join(process.cwd(), `packages/${pkg}/package.json`), "utf8")) as {
21+
scripts: Record<string, string>;
22+
};
23+
return manifest.scripts["build:tsc"] ?? manifest.scripts.build!;
24+
}
25+
26+
describe("tsc packages cannot no-op their own emit (#9521)", () => {
27+
it.each(PACKAGES_CACHING_TSBUILDINFO)("%s clears .tsbuildinfo before compiling", (pkg) => {
28+
const script = buildScript(pkg);
29+
expect(script, `${pkg}'s build must not trust a stale incremental stamp`).toContain(".tsbuildinfo");
30+
// The clear must come FIRST — after tsc it would defeat the purpose entirely.
31+
expect(script.indexOf(".tsbuildinfo"), `${pkg} must clear the stamp before tsc runs`).toBeLessThan(script.indexOf("tsc -p"));
32+
});
33+
34+
it("names every package that caches .tsbuildinfo as a turbo output — a new one must be added here", () => {
35+
const turbo = readFileSync(join(process.cwd(), "turbo.json"), "utf8");
36+
// Strip comments; turbo.json is JSONC and the comments themselves mention .tsbuildinfo.
37+
const withoutComments = turbo.replace(/^\s*\/\/.*$/gm, "");
38+
const tasks = [...withoutComments.matchAll(/"(@loopover\/[a-z-]+)#build(?::tsc)?":\s*\{[^}]*?"outputs":\s*\[([^\]]*)\]/gs)];
39+
const caching = tasks.filter(([, , outputs]) => outputs!.includes(".tsbuildinfo")).map(([, name]) => name!.replace("@loopover/", "loopover-"));
40+
expect(caching.sort()).toEqual([...PACKAGES_CACHING_TSBUILDINFO].sort());
41+
});
42+
});

0 commit comments

Comments
 (0)