@terpjs/contract 0.5.10 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The `terp-routes` executable (ADR 0092) — a real entry point, deliberately.
4
+ *
5
+ * This file exists instead of an `import.meta.url === process.argv[1]` guard inside the
6
+ * module: npm installs a bin as a shim/symlink, so the module's own URL and argv[1] do
7
+ * NOT match when it is invoked as `terp-routes`, and such a guard silently skips the
8
+ * whole program while still exiting 0. That failure is invisible in the worst possible
9
+ * way — a drift check that passes because it never ran.
10
+ */
11
+ import { run } from "../src/routes-codegen.js";
12
+
13
+ try {
14
+ process.exit(run(process.argv.slice(2)));
15
+ } catch (error) {
16
+ console.error(error instanceof Error ? error.message : String(error));
17
+ process.exit(2);
18
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@terpjs/contract",
3
- "version": "0.5.10",
3
+ "version": "0.6.1",
4
4
  "type": "module",
5
5
  "description": "Terp frontend contract \u2014 the OpenAPI-generated TypeScript client, design tokens, and the stack-agnostic module/route/nav + auth types.",
6
6
  "exports": {
@@ -10,18 +10,23 @@
10
10
  "./tokens.css": "./src/tokens.css"
11
11
  },
12
12
  "types": "./src/index.ts",
13
+ "bin": {
14
+ "terp-routes": "./bin/terp-routes.js"
15
+ },
13
16
  "scripts": {
14
17
  "generate": "openapi-typescript ./openapi.json --output ./src/schema.d.ts",
15
18
  "tokens": "node scripts/build-tokens.mjs",
16
- "typecheck": "tsc --noEmit"
19
+ "typecheck": "tsc --noEmit",
20
+ "test": "vitest run"
17
21
  },
18
22
  "devDependencies": {
19
23
  "openapi-typescript": "^7.13.0",
20
24
  "style-dictionary": "^5.5.0",
21
- "typescript": "^5.9.3"
25
+ "vitest": "^4.1.9"
22
26
  },
23
27
  "dependencies": {
24
- "openapi-fetch": "^0.17.0"
28
+ "openapi-fetch": "^0.17.0",
29
+ "typescript": "^5.9.3"
25
30
  },
26
31
  "license": "Apache-2.0",
27
32
  "repository": {
@@ -0,0 +1,277 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `terp-routes` — emit an app's route types from its module manifests (ADR 0092).
4
+ *
5
+ * A Terp app's router is built at RUNTIME from manifest data, so TanStack Router's
6
+ * type registry is empty and nothing checks a route path or a param name: the raw read
7
+ * was an unchecked cast, and a typo'd path was a dead link that shipped green. The
8
+ * information to check them is already checked in — `routes` is a static array of
9
+ * object literals — so this generator extracts it and writes a committed
10
+ * `src/routes.gen.d.ts` that augments `TerpRouteTable` in @terpjs/react-core.
11
+ *
12
+ * Extraction is FAIL CLOSED, which is the whole point: a route whose `path` is not a
13
+ * plain string literal (a template literal, a constant, a spread) cannot be read
14
+ * statically, so the generator refuses and NAMES it instead of quietly emitting a
15
+ * partial map — a table missing a route is worse than no table, because it turns a real
16
+ * path into a type error and teaches authors to distrust the check.
17
+ *
18
+ * Scope is the app's own manifests (`src/modules/<name>/module.tsx`). Routes a packaged
19
+ * area mounts (react-core's admin area) are deliberately NOT keyed: the artifact stays a
20
+ * pure function of the app's own source, so it never drifts because a dependency's
21
+ * internals changed. Link to those paths with `Link` / the router's own `navigate`.
22
+ *
23
+ * Usage:
24
+ * terp-routes # write src/routes.gen.d.ts
25
+ * terp-routes --check # exit non-zero when the committed file is stale
26
+ * terp-routes --out <file> --modules-dir <dir>
27
+ */
28
+
29
+ import fs from "node:fs";
30
+ import path from "node:path";
31
+
32
+ import ts from "typescript";
33
+
34
+ /** The declaration file's header — states what regenerates it, so an editor stops. */
35
+ const HEADER = [
36
+ "// Generated by `terp routes` from this app's module manifests. Do not edit.",
37
+ "//",
38
+ "// Maps every route path the manifests declare to that route's params, so",
39
+ "// useRouteParams / useRouteParam / useTerpNavigate check paths and param names at",
40
+ "// compile time (ADR 0092). Regenerate after changing a manifest route — `terp verify`",
41
+ "// fails on a stale copy. Routes mounted by a packaged area (the admin area) are not",
42
+ "// keyed here: this file is a pure function of this app's own manifests.",
43
+ "",
44
+ 'import "@terpjs/react-core";',
45
+ "",
46
+ 'declare module "@terpjs/react-core" {',
47
+ " interface TerpRouteTable {",
48
+ ];
49
+
50
+ /** Param names a manifest path declares, in order. Both dialects: `:id` and `$id`. */
51
+ export function paramNamesOf(routePath) {
52
+ return [...routePath.matchAll(/(?:^|\/)[:$]([A-Za-z_][A-Za-z0-9_]*)/g)].map((match) => match[1]);
53
+ }
54
+
55
+ /** The canonical table key: the manifest's stack-agnostic spelling (`:id`). */
56
+ export function canonicalPath(routePath) {
57
+ return routePath.replace(/(^|\/)\$([A-Za-z_][A-Za-z0-9_]*)/g, "$1:$2");
58
+ }
59
+
60
+ /**
61
+ * Every route path a source file's `defineModuleManifest(...)` calls declare.
62
+ *
63
+ * Returns `{ paths, problems }`. A shape that cannot be read statically lands in
64
+ * `problems` (with file:line) rather than being skipped — the caller refuses on any.
65
+ */
66
+ export function extractRoutePaths(sourceText, label) {
67
+ const source = ts.createSourceFile(label, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
68
+ const paths = [];
69
+ const problems = [];
70
+ let manifests = 0;
71
+
72
+ const at = (node) => {
73
+ const { line } = source.getLineAndCharacterOfPosition(node.getStart(source));
74
+ return `${label}:${line + 1}`;
75
+ };
76
+
77
+ const readRoutesArray = (routesValue) => {
78
+ if (!ts.isArrayLiteralExpression(routesValue)) {
79
+ problems.push(
80
+ `${at(routesValue)}: \`routes\` is not an array literal, so its paths cannot be read ` +
81
+ "statically. Declare the routes inline in defineModuleManifest(...).",
82
+ );
83
+ return;
84
+ }
85
+ for (const element of routesValue.elements) {
86
+ if (!ts.isObjectLiteralExpression(element)) {
87
+ problems.push(
88
+ `${at(element)}: a route is not an object literal (a spread or a reference?), so its ` +
89
+ "path cannot be read statically. Declare each route inline.",
90
+ );
91
+ continue;
92
+ }
93
+ const pathProperty = element.properties.find(
94
+ (property) =>
95
+ ts.isPropertyAssignment(property) &&
96
+ (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) &&
97
+ property.name.text === "path",
98
+ );
99
+ if (pathProperty === undefined) {
100
+ problems.push(`${at(element)}: a route declares no \`path\`.`);
101
+ continue;
102
+ }
103
+ const value = pathProperty.initializer;
104
+ if (!ts.isStringLiteral(value)) {
105
+ problems.push(
106
+ `${at(value)}: a route \`path\` is not a plain string literal, so it cannot be read ` +
107
+ "statically. Write the path inline (e.g. path: \"/records/:recordId\").",
108
+ );
109
+ continue;
110
+ }
111
+ paths.push(value.text);
112
+ }
113
+ };
114
+
115
+ const visit = (node) => {
116
+ if (
117
+ ts.isCallExpression(node) &&
118
+ ((ts.isIdentifier(node.expression) && node.expression.text === "defineModuleManifest") ||
119
+ (ts.isPropertyAccessExpression(node.expression) &&
120
+ node.expression.name.text === "defineModuleManifest"))
121
+ ) {
122
+ manifests += 1;
123
+ const [argument] = node.arguments;
124
+ if (argument === undefined || !ts.isObjectLiteralExpression(argument)) {
125
+ problems.push(
126
+ `${at(node)}: defineModuleManifest was not called with an object literal, so its routes ` +
127
+ "cannot be read statically.",
128
+ );
129
+ } else {
130
+ const routes = argument.properties.find(
131
+ (property) =>
132
+ ts.isPropertyAssignment(property) &&
133
+ (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) &&
134
+ property.name.text === "routes",
135
+ );
136
+ if (routes === undefined) {
137
+ problems.push(`${at(argument)}: the manifest declares no \`routes\`.`);
138
+ } else {
139
+ readRoutesArray(routes.initializer);
140
+ }
141
+ }
142
+ }
143
+ ts.forEachChild(node, visit);
144
+ };
145
+ visit(source);
146
+
147
+ if (manifests === 0) {
148
+ problems.push(
149
+ `${label}: no defineModuleManifest(...) call found. A module slot declares its routes there; ` +
150
+ "a module whose manifest is built elsewhere cannot be extracted.",
151
+ );
152
+ }
153
+ return { paths, problems };
154
+ }
155
+
156
+ /** The module files to scan: `<modulesDir>/<name>/module.tsx` (or `.ts`), sorted. */
157
+ export function moduleFiles(modulesDir) {
158
+ if (!fs.existsSync(modulesDir)) {
159
+ return [];
160
+ }
161
+ const found = [];
162
+ for (const entry of fs.readdirSync(modulesDir, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : 1))) {
163
+ if (!entry.isDirectory()) {
164
+ continue;
165
+ }
166
+ for (const basename of ["module.tsx", "module.ts"]) {
167
+ const candidate = path.join(modulesDir, entry.name, basename);
168
+ if (fs.existsSync(candidate)) {
169
+ found.push(candidate);
170
+ break;
171
+ }
172
+ }
173
+ }
174
+ return found;
175
+ }
176
+
177
+ /** Render the declaration file for *paths* — deterministic: deduped, sorted, LF. */
178
+ export function renderRouteTable(paths) {
179
+ const unique = [...new Set(paths.map(canonicalPath))].sort();
180
+ const lines = [...HEADER];
181
+ for (const routePath of unique) {
182
+ const params = paramNamesOf(routePath);
183
+ const shape =
184
+ params.length === 0
185
+ ? "Record<never, never>"
186
+ : `{ ${params.map((name) => `${name}: string`).join("; ")} }`;
187
+ lines.push(` ${JSON.stringify(routePath)}: ${shape};`);
188
+ }
189
+ lines.push(" }", "}", "");
190
+ return lines.join("\n");
191
+ }
192
+
193
+ /**
194
+ * Extract from every module file under *modulesDir* and render the declaration file.
195
+ * Throws with every problem listed when any route resists static reading (fail closed).
196
+ */
197
+ export function generateRouteTable(modulesDir) {
198
+ const files = moduleFiles(modulesDir);
199
+ if (files.length === 0) {
200
+ throw new Error(
201
+ `No module slots found under ${modulesDir}. A Terp app declares its routes in ` +
202
+ "src/modules/<name>/module.tsx; pass --modules-dir if this app keeps them elsewhere.",
203
+ );
204
+ }
205
+ const paths = [];
206
+ const problems = [];
207
+ for (const file of files) {
208
+ const result = extractRoutePaths(fs.readFileSync(file, "utf8"), path.relative(process.cwd(), file).split(path.sep).join("/"));
209
+ paths.push(...result.paths);
210
+ problems.push(...result.problems);
211
+ }
212
+ if (problems.length > 0) {
213
+ throw new Error(
214
+ "terp routes refused to emit a partial route table — every route must be readable " +
215
+ "statically, or the generated types would turn a real path into a type error:\n" +
216
+ problems.map((problem) => ` - ${problem}`).join("\n"),
217
+ );
218
+ }
219
+ return renderRouteTable(paths);
220
+ }
221
+
222
+ /** Parse `--flag value` / `--flag` argv into a plain object. */
223
+ function parseArgs(argv) {
224
+ const options = { out: "src/routes.gen.d.ts", modulesDir: "src/modules", check: false };
225
+ for (let index = 0; index < argv.length; index += 1) {
226
+ const argument = argv[index];
227
+ if (argument === "--check") {
228
+ options.check = true;
229
+ } else if (argument === "--out") {
230
+ options.out = argv[(index += 1)];
231
+ } else if (argument === "--modules-dir") {
232
+ options.modulesDir = argv[(index += 1)];
233
+ } else {
234
+ throw new Error(`terp-routes: unknown argument ${argument}`);
235
+ }
236
+ }
237
+ if (options.out === undefined || options.modulesDir === undefined) {
238
+ throw new Error("terp-routes: --out and --modules-dir need a value");
239
+ }
240
+ return options;
241
+ }
242
+
243
+ /** The CLI: write the table, or (with --check) refuse a stale committed copy. */
244
+ export function run(argv, { cwd = process.cwd(), log = console.log } = {}) {
245
+ const options = parseArgs(argv);
246
+ const outPath = path.resolve(cwd, options.out);
247
+ const rendered = generateRouteTable(path.resolve(cwd, options.modulesDir));
248
+ const onDisk = fs.existsSync(outPath) ? fs.readFileSync(outPath, "utf8") : null;
249
+
250
+ if (options.check) {
251
+ if (onDisk === rendered) {
252
+ log(`terp routes: ${options.out} is current`);
253
+ return 0;
254
+ }
255
+ log(
256
+ `${options.out} is ${onDisk === null ? "missing" : "stale"} — the module manifests declare ` +
257
+ "different routes than the committed route table.\nFix: npm --prefix frontend run routes " +
258
+ "(or `uv run terp routes`), then commit the regenerated file.",
259
+ );
260
+ return 1;
261
+ }
262
+
263
+ if (onDisk === rendered) {
264
+ log(`terp routes: ${options.out} unchanged`);
265
+ return 0;
266
+ }
267
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
268
+ // newline unchanged: the rendered text is LF-only, so the committed artifact stays
269
+ // byte-stable on Windows and the --check comparison never trips on line endings.
270
+ fs.writeFileSync(outPath, rendered, "utf8");
271
+ log(`terp routes: wrote ${options.out}`);
272
+ return 0;
273
+ }
274
+
275
+ // No self-executing guard here on purpose: `bin/terp-routes.js` is the entry point. An
276
+ // `import.meta.url === argv[1]` check does not hold when npm invokes the bin through its
277
+ // shim, and it fails by doing nothing at exit 0 — a check that silently never checks.
@@ -0,0 +1,273 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ import { afterEach, describe, expect, it } from "vitest";
8
+
9
+ import {
10
+ canonicalPath,
11
+ extractRoutePaths,
12
+ generateRouteTable,
13
+ moduleFiles,
14
+ paramNamesOf,
15
+ renderRouteTable,
16
+ run,
17
+ } from "./routes-codegen.js";
18
+
19
+ // The route-types generator (ADR 0092): it turns manifest route literals into the
20
+ // declaration file that makes a wrong path or param name a typecheck error. Its two
21
+ // contracts are determinism (a committed artifact that drift-checks) and failing closed
22
+ // (never emit a partial table, because a missing route reads as a *wrong* route).
23
+
24
+ const temporaries = [];
25
+
26
+ afterEach(() => {
27
+ for (const directory of temporaries.splice(0)) {
28
+ fs.rmSync(directory, { recursive: true, force: true });
29
+ }
30
+ });
31
+
32
+ /** An app tree with one module per entry: `{ notes: "<module.tsx source>" }`. */
33
+ function appWithModules(modules) {
34
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "terp-routes-"));
35
+ temporaries.push(root);
36
+ for (const [name, source] of Object.entries(modules)) {
37
+ const directory = path.join(root, "src", "modules", name);
38
+ fs.mkdirSync(directory, { recursive: true });
39
+ fs.writeFileSync(path.join(directory, "module.tsx"), source, "utf8");
40
+ }
41
+ return root;
42
+ }
43
+
44
+ function manifest(routes) {
45
+ return [
46
+ 'import { defineModuleManifest } from "@terpjs/contract";',
47
+ "export const manifest = defineModuleManifest({",
48
+ ' name: "x",',
49
+ ` routes: [${routes}],`,
50
+ "});",
51
+ ].join("\n");
52
+ }
53
+
54
+ describe("param extraction from a path", () => {
55
+ it("reads both dialects and canonicalises to the manifest spelling", () => {
56
+ expect(paramNamesOf("/records/:recordId")).toEqual(["recordId"]);
57
+ expect(paramNamesOf("/records/$recordId")).toEqual(["recordId"]);
58
+ expect(paramNamesOf("/spaces/:spaceId/items/:itemId")).toEqual(["spaceId", "itemId"]);
59
+ expect(paramNamesOf("/records")).toEqual([]);
60
+ // The packaged admin manifest uses the TanStack spelling; the table is keyed the
61
+ // stack-agnostic way, so both spellings land on one key.
62
+ expect(canonicalPath("/admin/users/$userId")).toBe("/admin/users/:userId");
63
+ expect(canonicalPath("/records/:recordId")).toBe("/records/:recordId");
64
+ });
65
+ });
66
+
67
+ describe("extraction from a module manifest", () => {
68
+ it("reads every route path a manifest declares", () => {
69
+ const { paths, problems } = extractRoutePaths(
70
+ manifest('{ path: "/records", view: "List" }, { path: "/records/:recordId", view: "Detail" }'),
71
+ "module.tsx",
72
+ );
73
+ expect(problems).toEqual([]);
74
+ expect(paths).toEqual(["/records", "/records/:recordId"]);
75
+ });
76
+
77
+ it("reads a manifest declared as a property value, not only `export const manifest`", () => {
78
+ // The packaged admin area's shape: defineModuleManifest(...) inside an object literal.
79
+ const { paths, problems } = extractRoutePaths(
80
+ [
81
+ 'import { defineModuleManifest } from "@terpjs/contract";',
82
+ "export const adminModule = {",
83
+ ' manifest: defineModuleManifest({ name: "a", routes: [{ path: "/admin", view: "Hub" }] }),',
84
+ "};",
85
+ ].join("\n"),
86
+ "module.tsx",
87
+ );
88
+ expect(problems).toEqual([]);
89
+ expect(paths).toEqual(["/admin"]);
90
+ });
91
+
92
+ it("refuses a path that is not a plain string literal, naming file and line", () => {
93
+ const { paths, problems } = extractRoutePaths(
94
+ manifest("{ path: `/records/${base}`, view: \"List\" }"),
95
+ "src/modules/records/module.tsx",
96
+ );
97
+ expect(paths).toEqual([]);
98
+ expect(problems).toHaveLength(1);
99
+ expect(problems[0]).toContain("src/modules/records/module.tsx:4");
100
+ expect(problems[0]).toContain("not a plain string literal");
101
+ });
102
+
103
+ it("refuses a spread route and a non-literal routes array", () => {
104
+ const spread = extractRoutePaths(manifest("...shared"), "module.tsx");
105
+ expect(spread.problems[0]).toContain("not an object literal");
106
+
107
+ const dynamic = extractRoutePaths(
108
+ [
109
+ 'import { defineModuleManifest } from "@terpjs/contract";',
110
+ "export const manifest = defineModuleManifest({ name: \"x\", routes: buildRoutes() });",
111
+ ].join("\n"),
112
+ "module.tsx",
113
+ );
114
+ expect(dynamic.problems[0]).toContain("not an array literal");
115
+ });
116
+
117
+ it("refuses a module slot with no manifest call at all", () => {
118
+ const { problems } = extractRoutePaths("export const views = {};", "module.tsx");
119
+ expect(problems[0]).toContain("no defineModuleManifest");
120
+ });
121
+
122
+ it("refuses a route with no path", () => {
123
+ const { problems } = extractRoutePaths(manifest('{ view: "List" }'), "module.tsx");
124
+ expect(problems[0]).toContain("declares no `path`");
125
+ });
126
+ });
127
+
128
+ describe("rendering the declaration file", () => {
129
+ it("is deterministic: deduped, sorted, LF-only, one trailing newline", () => {
130
+ const first = renderRouteTable(["/b", "/a", "/b"]);
131
+ const second = renderRouteTable(["/b", "/b", "/a"]);
132
+ expect(first).toBe(second);
133
+ expect(first).not.toContain("\r");
134
+ expect(first.endsWith("}\n")).toBe(true);
135
+ expect(first.indexOf('"/a"')).toBeLessThan(first.indexOf('"/b"'));
136
+ });
137
+
138
+ it("types a parameterised route's params and leaves a paramless route empty", () => {
139
+ const rendered = renderRouteTable(["/records/:recordId", "/records", "/s/:a/i/:b"]);
140
+ expect(rendered).toContain('"/records": Record<never, never>;');
141
+ expect(rendered).toContain('"/records/:recordId": { recordId: string };');
142
+ expect(rendered).toContain('"/s/:a/i/:b": { a: string; b: string };');
143
+ });
144
+
145
+ it("augments the react-core table so the app's own types pick it up", () => {
146
+ const rendered = renderRouteTable(["/"]);
147
+ expect(rendered).toContain('declare module "@terpjs/react-core"');
148
+ expect(rendered).toContain("interface TerpRouteTable");
149
+ // A module augmentation only applies from a file that IS a module.
150
+ expect(rendered).toContain('import "@terpjs/react-core";');
151
+ });
152
+ });
153
+
154
+ describe("generating across an app's module slots", () => {
155
+ it("merges every slot and dedupes a path two modules claim", () => {
156
+ const root = appWithModules({
157
+ home: manifest('{ path: "/", view: "Home" }'),
158
+ notes: manifest('{ path: "/", view: "NotesList" }, { path: "/notes/:noteId", view: "Note" }'),
159
+ });
160
+ const rendered = generateRouteTable(path.join(root, "src", "modules"));
161
+ expect(rendered.match(/"\/":/g)).toHaveLength(1);
162
+ expect(rendered).toContain('"/notes/:noteId": { noteId: string };');
163
+ });
164
+
165
+ it("refuses the whole run when any one slot resists static reading", () => {
166
+ const root = appWithModules({
167
+ good: manifest('{ path: "/good", view: "G" }'),
168
+ bad: manifest("{ path: ROUTES.bad, view: \"B\" }"),
169
+ });
170
+ expect(() => generateRouteTable(path.join(root, "src", "modules"))).toThrow(
171
+ /refused to emit a partial route table/,
172
+ );
173
+ });
174
+
175
+ it("refuses an app with no module slots rather than emitting an empty table", () => {
176
+ const root = appWithModules({});
177
+ expect(() => generateRouteTable(path.join(root, "src", "modules"))).toThrow(/No module slots/);
178
+ });
179
+
180
+ it("finds module.ts as well as module.tsx, in a stable order", () => {
181
+ const root = appWithModules({ b: manifest('{ path: "/b", view: "B" }') });
182
+ const plain = path.join(root, "src", "modules", "a");
183
+ fs.mkdirSync(plain, { recursive: true });
184
+ fs.writeFileSync(path.join(plain, "module.ts"), manifest('{ path: "/a", view: "A" }'), "utf8");
185
+ expect(moduleFiles(path.join(root, "src", "modules")).map((file) => path.basename(path.dirname(file)))).toEqual(["a", "b"]);
186
+ });
187
+ });
188
+
189
+ describe("the CLI's write and --check modes", () => {
190
+ it("writes the table, then reports it unchanged on a second run", () => {
191
+ const root = appWithModules({ home: manifest('{ path: "/", view: "Home" }') });
192
+ const messages = [];
193
+ const log = (message) => messages.push(message);
194
+
195
+ expect(run([], { cwd: root, log })).toBe(0);
196
+ const written = fs.readFileSync(path.join(root, "src", "routes.gen.d.ts"), "utf8");
197
+ expect(written).toContain('"/": Record<never, never>;');
198
+ expect(messages[0]).toContain("wrote");
199
+
200
+ expect(run([], { cwd: root, log })).toBe(0);
201
+ expect(messages[1]).toContain("unchanged");
202
+ });
203
+
204
+ it("--check passes on a current file and refuses a stale or missing one, with the fix", () => {
205
+ const root = appWithModules({ home: manifest('{ path: "/", view: "Home" }') });
206
+ const messages = [];
207
+ const log = (message) => messages.push(message);
208
+
209
+ // Missing: the app never generated.
210
+ expect(run(["--check"], { cwd: root, log })).toBe(1);
211
+ expect(messages.at(-1)).toContain("missing");
212
+ expect(messages.at(-1)).toContain("run routes");
213
+
214
+ run([], { cwd: root, log: () => {} });
215
+ expect(run(["--check"], { cwd: root, log })).toBe(0);
216
+ expect(messages.at(-1)).toContain("is current");
217
+
218
+ // Stale: a manifest gained a route after the last generate.
219
+ fs.writeFileSync(
220
+ path.join(root, "src", "modules", "home", "module.tsx"),
221
+ manifest('{ path: "/", view: "Home" }, { path: "/late/:lateId", view: "Late" }'),
222
+ "utf8",
223
+ );
224
+ expect(run(["--check"], { cwd: root, log })).toBe(1);
225
+ expect(messages.at(-1)).toContain("stale");
226
+ });
227
+
228
+ it("the shipped bin actually runs the generator (a bin that no-ops would fake every check)", () => {
229
+ // Regression guard for a real bug: the module used to self-execute behind an
230
+ // `import.meta.url === process.argv[1]` guard, which does NOT hold when npm invokes
231
+ // the bin through its shim — so `terp-routes --check` did nothing and exited 0. A
232
+ // drift check that passes because it never ran is worse than no check, and only
233
+ // spawning the actual executable can catch it.
234
+ const bin = path.resolve(fileURLToPath(new URL(".", import.meta.url)), "..", "bin", "terp-routes.js");
235
+ const root = appWithModules({ home: manifest('{ path: "/", view: "Home" }') });
236
+
237
+ const written = spawnSync(process.execPath, [bin], { cwd: root, encoding: "utf8" });
238
+ expect(written.status).toBe(0);
239
+ expect(written.stdout).toContain("wrote");
240
+ expect(fs.existsSync(path.join(root, "src", "routes.gen.d.ts"))).toBe(true);
241
+
242
+ const current = spawnSync(process.execPath, [bin, "--check"], { cwd: root, encoding: "utf8" });
243
+ expect(current.status).toBe(0);
244
+ expect(current.stdout).toContain("is current");
245
+
246
+ // And it reports a real drift with a non-zero exit, not a silent pass.
247
+ fs.writeFileSync(
248
+ path.join(root, "src", "modules", "home", "module.tsx"),
249
+ manifest('{ path: "/", view: "Home" }, { path: "/added/:addedId", view: "Added" }'),
250
+ "utf8",
251
+ );
252
+ const stale = spawnSync(process.execPath, [bin, "--check"], { cwd: root, encoding: "utf8" });
253
+ expect(stale.status).toBe(1);
254
+ expect(stale.stdout).toContain("stale");
255
+
256
+ // A manifest it cannot read statically exits 2 and names the file.
257
+ fs.writeFileSync(
258
+ path.join(root, "src", "modules", "home", "module.tsx"),
259
+ manifest("{ path: ROUTES.home, view: \"Home\" }"),
260
+ "utf8",
261
+ );
262
+ const refused = spawnSync(process.execPath, [bin], { cwd: root, encoding: "utf8" });
263
+ expect(refused.status).toBe(2);
264
+ expect(refused.stderr).toContain("refused to emit a partial route table");
265
+ });
266
+
267
+ it("honours --out and --modules-dir, and refuses an unknown argument", () => {
268
+ const root = appWithModules({ home: manifest('{ path: "/", view: "Home" }') });
269
+ expect(run(["--out", "types/routes.d.ts", "--modules-dir", "src/modules"], { cwd: root, log: () => {} })).toBe(0);
270
+ expect(fs.existsSync(path.join(root, "types", "routes.d.ts"))).toBe(true);
271
+ expect(() => run(["--nope"], { cwd: root, log: () => {} })).toThrow(/unknown argument/);
272
+ });
273
+ });