Skip to content

Commit 47f0e4c

Browse files
committed
feat(tools): add a real typecheck step (bun build does not typecheck)
bun build bundles without checking types, so a field that is read but never declared compiles cleanly and stays broken silently. Typecheck.ts runs tsc --noEmit over two roots: hooks/ + LIFEOS/TOOLS/, and the Pulse server, which needs its own pass because its dependencies live in its own tree. The tsconfigs are generated into a non-standard filename and deleted at the end of the run, so no file named tsconfig.json ever lands at the install root, where Bun would read it at runtime and a single resolution field would move live hook and Pulse behaviour. Requested in #1614.
1 parent 58381b3 commit 47f0e4c

1 file changed

Lines changed: 142 additions & 0 deletions

File tree

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
#!/usr/bin/env bun
2+
/**
3+
* Typecheck.ts — `bun build` does not typecheck. This does.
4+
*
5+
* WHY IT EXISTS. Bun bundles without checking types, so a field that is read but
6+
* never declared bundles cleanly and therefore "compiles". The build was answering
7+
* a weaker question than the one we thought we were asking, and code stayed broken
8+
* for days while every signal stayed green.
9+
*
10+
* TWO PASSES, NOT ONE:
11+
* 1. root — hooks/ + LIFEOS/TOOLS/
12+
* 2. Pulse — LIFEOS/PULSE/**
13+
*
14+
* They are two passes rather than one wider `include` because Pulse has its own
15+
* dependencies and its own node_modules; folding it into the root pass makes both
16+
* resolve against the wrong tree.
17+
*
18+
* THE CONFIGS ARE GENERATED AND THROWN AWAY, ON PURPOSE. A file literally named
19+
* `tsconfig.json` at the install root would be read by BUN AT RUNTIME for module
20+
* resolution: a single `paths` / `baseUrl` / `jsx` field in it would silently move
21+
* the behaviour of every live hook and of the running Pulse server. Writing the
22+
* configs under a non-standard name, and deleting them at the end, makes runtime
23+
* invariance true BY CONSTRUCTION rather than by discipline — only `tsc -p` ever
24+
* opens them.
25+
*
26+
* THE TOMBSTONE IS LOUD ON PURPOSE. A silent exclusion is a false green with a UI:
27+
* after a while nobody remembers that some code is not being looked at, and "the
28+
* typecheck passes" starts to mean less than it appears to. Every run says it out
29+
* loud. Excluded does not mean healthy — it means NOT LOOKED AT.
30+
*
31+
* USAGE bun LIFEOS/TOOLS/Typecheck.ts
32+
* EXIT 0 = both passes clean · 1 = at least one pass has type errors
33+
*/
34+
import { spawnSync } from "node:child_process";
35+
import { existsSync, writeFileSync, rmSync } from "node:fs";
36+
import { join, resolve } from "node:path";
37+
38+
/** This file lives at <configRoot>/LIFEOS/TOOLS/, so the root is two levels up.
39+
* Derived from the script's own location rather than assumed to be ~/.claude:
40+
* the install root is configurable and hardcoding it makes the tool lie on any
41+
* install that put it elsewhere. */
42+
const ROOT = resolve(import.meta.dir, "..", "..");
43+
const PULSE = join(ROOT, "LIFEOS", "PULSE");
44+
45+
const COMPILER_OPTIONS = {
46+
target: "ESNext",
47+
module: "ESNext",
48+
moduleResolution: "bundler",
49+
types: ["bun"],
50+
strict: true,
51+
noEmit: true,
52+
skipLibCheck: true,
53+
esModuleInterop: true,
54+
allowImportingTsExtensions: true,
55+
resolveJsonModule: true,
56+
forceConsistentCasingInFileNames: true,
57+
};
58+
59+
interface Pass {
60+
label: string;
61+
/** Directory the generated config is written into; `include` is relative to it. */
62+
cwd: string;
63+
include: string[];
64+
exclude: string[];
65+
/** Printed when the pass is skipped because `cwd` does not exist. */
66+
absentNote: string;
67+
}
68+
69+
const PASSES: Pass[] = [
70+
{
71+
label: "root (hooks/ + LIFEOS/TOOLS/)",
72+
cwd: ROOT,
73+
include: ["hooks/**/*.ts", "LIFEOS/TOOLS/**/*.ts"],
74+
exclude: ["node_modules", "**/node_modules/**", "LIFEOS/PULSE/**"],
75+
absentNote: "install root not found",
76+
},
77+
{
78+
label: "Pulse server (LIFEOS/PULSE/**)",
79+
cwd: PULSE,
80+
include: ["**/*.ts"],
81+
// Observability is a self-contained Next.js app with its own package.json,
82+
// node_modules, next.config.ts and `@/` alias. It deserves its own pass with
83+
// its own dependencies; folding it in here would only produce noise.
84+
exclude: ["node_modules", "**/node_modules/**", "Observability/**"],
85+
absentNote: "LIFEOS/PULSE not present in this install",
86+
},
87+
];
88+
89+
console.log("─".repeat(72));
90+
console.log("⚠ TOMBSTONE — not everything is being looked at:");
91+
console.log(" • LIFEOS/PULSE/Observability/** (standalone Next.js app, own deps)");
92+
console.log(" Excluded does NOT mean healthy. It means NOT LOOKED AT.");
93+
console.log("─".repeat(72));
94+
95+
let failed = 0;
96+
let ran = 0;
97+
98+
for (const p of PASSES) {
99+
console.log(`\n▶ ${p.label}`);
100+
101+
if (!existsSync(p.cwd)) {
102+
// Loud, and counted as neither pass nor fail: "I could not look" is a
103+
// different statement from "I looked and it was clean".
104+
console.log(`⏭ skipped — ${p.absentNote}`);
105+
continue;
106+
}
107+
108+
const configPath = join(p.cwd, ".typecheck.generated.json");
109+
try {
110+
writeFileSync(
111+
configPath,
112+
JSON.stringify(
113+
{
114+
"//": "GENERATED by LIFEOS/TOOLS/Typecheck.ts and deleted at the end of the run. Never named tsconfig.json: Bun reads that name at runtime and a resolution field here would move live behaviour.",
115+
compilerOptions: COMPILER_OPTIONS,
116+
include: p.include,
117+
exclude: p.exclude,
118+
},
119+
null,
120+
2,
121+
),
122+
);
123+
124+
const r = spawnSync("bunx", ["tsc", "--noEmit", "-p", configPath], {
125+
stdio: "inherit",
126+
cwd: p.cwd,
127+
});
128+
const status = r.status ?? 1;
129+
ran++;
130+
if (status !== 0) failed++;
131+
console.log(status === 0 ? `✅ ${p.label}: clean` : `❌ ${p.label}: type errors`);
132+
} finally {
133+
// Always remove it, including when tsc throws or the run is interrupted, so a
134+
// stray config can never be left behind where Bun might grow to read it.
135+
rmSync(configPath, { force: true });
136+
}
137+
}
138+
139+
// Every pass always runs, even after a failure: stopping at the first one would
140+
// hide the second one's errors, and seeing ALL of them is the entire point.
141+
console.log(`\n${failed === 0 ? "✅" : "❌"} ${ran} pass(es) run · ${failed} with type errors`);
142+
process.exit(failed > 0 ? 1 : 0);

0 commit comments

Comments
 (0)