#!/usr/bin/env bun/** * Asserts that every committed `routeTree.gen.ts` matches what its generator * produces from the current route contract. * * The route trees are GENERATED (`bun run routes:gen` in each package, from * `tsr.routes.ts` / `consoleRoutes()`) but COMMITTED, because the build and the * type-level `Link to=...` checking both read them off disk. That makes them a * classic drift surface: change the route contract, forget to regenerate, and * the committed tree still compiles — the routes it lacks simply don't exist at * runtime. * * Nothing else catches it. The generated files open with a suppression pragma * that opts them out of type checking entirely, so `typecheck` cannot see a * missing route; lint doesn't read them semantically; and unit tests import * the very tree they are drifting against. This bug class has already escaped * once and shipped a live 404 for a route that was in the contract but not in * the committed tree. * * So: regenerate every tree into place, diff against git, and restore. The * generators are pure functions of the checked-in route config, so a clean * checkout is a no-op and any diff is real drift. * * Usage: * bun run scripts/check-route-trees.ts */import { spawnSync } from "node:child_process";import { readFileSync, writeFileSync } from "node:fs";import { resolve } from "node:path"; const repoRoot = resolve(import.meta.dir, ".."); /** * Every package with a committed route tree, as (workspace dir, tree path) * pairs. Each dir has a `routes:gen` script; the tree is the file it writes. */const TREES = [ { dir: "packages/react", tree: "packages/react/src/routes/routeTree.gen.ts" }, { dir: "packages/app", tree: "packages/app/src/routeTree.gen.ts" }, { dir: "apps/cloud", tree: "apps/cloud/src/routeTree.gen.ts" }, { dir: "apps/host-selfhost", tree: "apps/host-selfhost/web/routeTree.gen.ts" }, { dir: "apps/host-cloudflare", tree: "apps/host-cloudflare/web/routeTree.gen.ts" },] as const; const run = (command: string, args: readonly string[], cwd: string) => spawnSync(command, [...args], { cwd, encoding: "utf8" }); /** * Compare on ROUTE CONTENT, not byte-for-byte. * * Two generators write these files: each package's `routes:gen` script, and the * TanStack vite plugin during `dev`. They agree on every route, import, and * type — they disagree only on blank lines in the trailing `declare module` * block. Comparing raw bytes would therefore fail on any checkout where a dev * server had run, which is a false alarm about formatting, not the drift this * guards (a route present in the contract and missing from the tree). Blank * lines cannot encode a route, so dropping them loses no signal. */const routeContent = (source: string): string => source .split("\n") .filter((line) => line.trim() !== "") .join("\n"); // Snapshot the committed content so a failed run leaves the checkout exactly as// it found it — this script must be safe to run on a dirty working tree.const before = new Map<string, string>();for (const { tree } of TREES) { before.set(tree, readFileSync(resolve(repoRoot, tree), "utf8"));} const restore = () => { for (const [tree, content] of before) writeFileSync(resolve(repoRoot, tree), content);}; /** The lines each side has and the other does not — enough to name the route * that drifted, without pulling in a diff library for a CI guard. */const lineDiff = (committed: string, regenerated: string): string => { const committedLines = new Set(committed.split("\n")); const regeneratedLines = new Set(regenerated.split("\n")); const missing = [...regeneratedLines].filter((line) => !committedLines.has(line)); const extra = [...committedLines].filter((line) => !regeneratedLines.has(line)); const show = (label: string, lines: readonly string[]) => lines.length === 0 ? [] : [` ${label}:`, ...lines.slice(0, 20).map((line) => ` ${line}`)]; return [ ...show("in the regenerated tree but NOT committed (missing routes)", missing), ...show("committed but NOT regenerated (stale routes)", extra), ].join("\n");}; type Failure = { readonly tree: string; readonly detail: string };const failures: Failure[] = []; for (const { dir, tree } of TREES) { const generated = run("bun", ["run", "routes:gen"], resolve(repoRoot, dir)); if (generated.status !== 0) { failures.push({ tree, detail: `\`bun run routes:gen\` failed in ${dir}:\n${generated.stderr || generated.stdout}`, }); continue; } const after = readFileSync(resolve(repoRoot, tree), "utf8"); const committed = routeContent(before.get(tree) ?? ""); const regenerated = routeContent(after); if (regenerated !== committed) { // Show the actual drift rather than just naming the file: what changed is // usually the whole answer (a route added to the contract, or removed). // Diffed against the SNAPSHOT rather than via `git diff`, so this reports // correctly even when the committed file was already dirty. failures.push({ tree, detail: `regenerating produced a different tree:\n${lineDiff(committed, regenerated)}`, }); }} restore(); if (failures.length > 0) { const lines = failures.map((f) => ` - ${f.tree}: ${f.detail}`).join("\n\n"); console.error( `\nRoute-tree check FAILED (${failures.length} tree(s) out of date):\n${lines}\n\n` + "Cause: a committed routeTree.gen.ts does not match what its generator\n" + "produces from the current route contract — the contract changed and the\n" + "tree was not regenerated. These files opt out of type checking, so the\n" + "typecheck job cannot catch this; the app just serves a 404 for the\n" + "missing route.\n\n" + "Fix: regenerate and commit the trees:\n" + TREES.map(({ dir }) => ` bun run --cwd ${dir} routes:gen`).join("\n") + "\n", ); process.exit(1);} console.log(`Route-tree check passed: ${TREES.length} generated tree(s) match their contract.`);
#!/usr/bin/env bun
/**
* Asserts that every committed `routeTree.gen.ts` matches what its generator
* produces from the current route contract.
*
* The route trees are GENERATED (`bun run routes:gen` in each package, from
* `tsr.routes.ts` / `consoleRoutes()`) but COMMITTED, because the build and the
* type-level `Link to=...` checking both read them off disk. That makes them a
* classic drift surface: change the route contract, forget to regenerate, and
* the committed tree still compiles — the routes it lacks simply don't exist at
* runtime.
*
* Nothing else catches it. The generated files open with a suppression pragma
* that opts them out of type checking entirely, so `typecheck` cannot see a
* missing route; lint doesn't read them semantically; and unit tests import
* the very tree they are drifting against. This bug class has already escaped
* once and shipped a live 404 for a route that was in the contract but not in
* the committed tree.
*
* So: regenerate every tree into place, diff against git, and restore. The
* generators are pure functions of the checked-in route config, so a clean
* checkout is a no-op and any diff is real drift.
*
* Usage:
* bun run scripts/check-route-trees.ts
*/
import { spawnSync } from "node:child_process";
import { readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
const repoRoot = resolve(import.meta.dir, "..");
/**
* Every package with a committed route tree, as (workspace dir, tree path)
* pairs. Each dir has a `routes:gen` script; the tree is the file it writes.
*/
const TREES = [
{ dir: "packages/react", tree: "packages/react/src/routes/routeTree.gen.ts" },
{ dir: "packages/app", tree: "packages/app/src/routeTree.gen.ts" },
{ dir: "apps/cloud", tree: "apps/cloud/src/routeTree.gen.ts" },
{ dir: "apps/host-selfhost", tree: "apps/host-selfhost/web/routeTree.gen.ts" },
{ dir: "apps/host-cloudflare", tree: "apps/host-cloudflare/web/routeTree.gen.ts" },
] as const;
const run = (command: string, args: readonly string[], cwd: string) =>
spawnSync(command, [...args], { cwd, encoding: "utf8" });
/**
* Compare on ROUTE CONTENT, not byte-for-byte.
*
* Two generators write these files: each package's `routes:gen` script, and the
* TanStack vite plugin during `dev`. They agree on every route, import, and
* type — they disagree only on blank lines in the trailing `declare module`
* block. Comparing raw bytes would therefore fail on any checkout where a dev
* server had run, which is a false alarm about formatting, not the drift this
* guards (a route present in the contract and missing from the tree). Blank
* lines cannot encode a route, so dropping them loses no signal.
*/
const routeContent = (source: string): string =>
source
.split("\n")
.filter((line) => line.trim() !== "")
.join("\n");
// Snapshot the committed content so a failed run leaves the checkout exactly as
// it found it — this script must be safe to run on a dirty working tree.
const before = new Map<string, string>();
for (const { tree } of TREES) {
before.set(tree, readFileSync(resolve(repoRoot, tree), "utf8"));
}
const restore = () => {
for (const [tree, content] of before) writeFileSync(resolve(repoRoot, tree), content);
};
/** The lines each side has and the other does not — enough to name the route
* that drifted, without pulling in a diff library for a CI guard. */
const lineDiff = (committed: string, regenerated: string): string => {
const committedLines = new Set(committed.split("\n"));
const regeneratedLines = new Set(regenerated.split("\n"));
const missing = [...regeneratedLines].filter((line) => !committedLines.has(line));
const extra = [...committedLines].filter((line) => !regeneratedLines.has(line));
const show = (label: string, lines: readonly string[]) =>
lines.length === 0
? []
: [` ${label}:`, ...lines.slice(0, 20).map((line) => ` ${line}`)];
return [
...show("in the regenerated tree but NOT committed (missing routes)", missing),
...show("committed but NOT regenerated (stale routes)", extra),
].join("\n");
};
type Failure = { readonly tree: string; readonly detail: string };
const failures: Failure[] = [];
for (const { dir, tree } of TREES) {
const generated = run("bun", ["run", "routes:gen"], resolve(repoRoot, dir));
if (generated.status !== 0) {
failures.push({
tree,
detail: `\`bun run routes:gen\` failed in ${dir}:\n${generated.stderr || generated.stdout}`,
});
continue;
}
const after = readFileSync(resolve(repoRoot, tree), "utf8");
const committed = routeContent(before.get(tree) ?? "");
const regenerated = routeContent(after);
if (regenerated !== committed) {
// Show the actual drift rather than just naming the file: what changed is
// usually the whole answer (a route added to the contract, or removed).
// Diffed against the SNAPSHOT rather than via `git diff`, so this reports
// correctly even when the committed file was already dirty.
failures.push({
tree,
detail: `regenerating produced a different tree:\n${lineDiff(committed, regenerated)}`,
});
}
}
restore();
if (failures.length > 0) {
const lines = failures.map((f) => ` - ${f.tree}: ${f.detail}`).join("\n\n");
console.error(
`\nRoute-tree check FAILED (${failures.length} tree(s) out of date):\n${lines}\n\n` +
"Cause: a committed routeTree.gen.ts does not match what its generator\n" +
"produces from the current route contract — the contract changed and the\n" +
"tree was not regenerated. These files opt out of type checking, so the\n" +
"typecheck job cannot catch this; the app just serves a 404 for the\n" +
"missing route.\n\n" +
"Fix: regenerate and commit the trees:\n" +
TREES.map(({ dir }) => ` bun run --cwd ${dir} routes:gen`).join("\n") +
"\n",
);
process.exit(1);
}
console.log(`Route-tree check passed: ${TREES.length} generated tree(s) match their contract.`);