@nifrajs/cli 0.1.0-alpha.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.
Files changed (95) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +99 -0
  3. package/dist/check.d.ts +100 -0
  4. package/dist/check.d.ts.map +1 -0
  5. package/dist/check.js +690 -0
  6. package/dist/check.js.map +1 -0
  7. package/dist/cli.d.ts +3 -0
  8. package/dist/cli.d.ts.map +1 -0
  9. package/dist/cli.js +208 -0
  10. package/dist/cli.js.map +1 -0
  11. package/dist/docs-search.d.ts +30 -0
  12. package/dist/docs-search.d.ts.map +1 -0
  13. package/dist/docs-search.js +94 -0
  14. package/dist/docs-search.js.map +1 -0
  15. package/dist/doctor.d.ts +52 -0
  16. package/dist/doctor.d.ts.map +1 -0
  17. package/dist/doctor.js +291 -0
  18. package/dist/doctor.js.map +1 -0
  19. package/dist/examples.d.ts +30 -0
  20. package/dist/examples.d.ts.map +1 -0
  21. package/dist/examples.js +70 -0
  22. package/dist/examples.js.map +1 -0
  23. package/dist/introspect.d.ts +64 -0
  24. package/dist/introspect.d.ts.map +1 -0
  25. package/dist/introspect.js +243 -0
  26. package/dist/introspect.js.map +1 -0
  27. package/dist/load.d.ts +51 -0
  28. package/dist/load.d.ts.map +1 -0
  29. package/dist/load.js +70 -0
  30. package/dist/load.js.map +1 -0
  31. package/dist/mcp-docs-tools.d.ts +13 -0
  32. package/dist/mcp-docs-tools.d.ts.map +1 -0
  33. package/dist/mcp-docs-tools.js +63 -0
  34. package/dist/mcp-docs-tools.js.map +1 -0
  35. package/dist/mcp-http.d.ts +43 -0
  36. package/dist/mcp-http.d.ts.map +1 -0
  37. package/dist/mcp-http.js +136 -0
  38. package/dist/mcp-http.js.map +1 -0
  39. package/dist/mcp-protocol.d.ts +97 -0
  40. package/dist/mcp-protocol.d.ts.map +1 -0
  41. package/dist/mcp-protocol.js +248 -0
  42. package/dist/mcp-protocol.js.map +1 -0
  43. package/dist/mcp-render.d.ts +17 -0
  44. package/dist/mcp-render.d.ts.map +1 -0
  45. package/dist/mcp-render.js +73 -0
  46. package/dist/mcp-render.js.map +1 -0
  47. package/dist/mcp-run.d.ts +24 -0
  48. package/dist/mcp-run.d.ts.map +1 -0
  49. package/dist/mcp-run.js +142 -0
  50. package/dist/mcp-run.js.map +1 -0
  51. package/dist/mcp-ws.d.ts +26 -0
  52. package/dist/mcp-ws.d.ts.map +1 -0
  53. package/dist/mcp-ws.js +258 -0
  54. package/dist/mcp-ws.js.map +1 -0
  55. package/dist/mcp.d.ts +42 -0
  56. package/dist/mcp.d.ts.map +1 -0
  57. package/dist/mcp.js +712 -0
  58. package/dist/mcp.js.map +1 -0
  59. package/dist/openapi-tool.d.ts +6 -0
  60. package/dist/openapi-tool.d.ts.map +1 -0
  61. package/dist/openapi-tool.js +48 -0
  62. package/dist/openapi-tool.js.map +1 -0
  63. package/dist/scaffold.d.ts +35 -0
  64. package/dist/scaffold.d.ts.map +1 -0
  65. package/dist/scaffold.js +140 -0
  66. package/dist/scaffold.js.map +1 -0
  67. package/dist/search-terms.d.ts +17 -0
  68. package/dist/search-terms.d.ts.map +1 -0
  69. package/dist/search-terms.js +82 -0
  70. package/dist/search-terms.js.map +1 -0
  71. package/dist/test-tool.d.ts +29 -0
  72. package/dist/test-tool.d.ts.map +1 -0
  73. package/dist/test-tool.js +150 -0
  74. package/dist/test-tool.js.map +1 -0
  75. package/docs/examples.json +314 -0
  76. package/docs/llms-full.txt +4325 -0
  77. package/package.json +58 -0
  78. package/src/check.ts +790 -0
  79. package/src/cli.ts +237 -0
  80. package/src/docs-search.ts +111 -0
  81. package/src/doctor.ts +349 -0
  82. package/src/examples.ts +89 -0
  83. package/src/introspect.ts +284 -0
  84. package/src/load.ts +129 -0
  85. package/src/mcp-docs-tools.ts +72 -0
  86. package/src/mcp-http.ts +155 -0
  87. package/src/mcp-protocol.ts +326 -0
  88. package/src/mcp-render.ts +78 -0
  89. package/src/mcp-run.ts +153 -0
  90. package/src/mcp-ws.ts +328 -0
  91. package/src/mcp.ts +863 -0
  92. package/src/openapi-tool.ts +51 -0
  93. package/src/scaffold.ts +164 -0
  94. package/src/search-terms.ts +89 -0
  95. package/src/test-tool.ts +181 -0
package/src/check.ts ADDED
@@ -0,0 +1,790 @@
1
+ /**
2
+ * `nifra check` — the agent's (and CI's) definition of done. It makes the guarantees that keep a nifra
3
+ * app drift-proof actually *fire*, instead of relying on the agent to remember them:
4
+ *
5
+ * 1. **typecheck** (`tsc --noEmit`) — the frontend↔backend contract is compiler-enforced. The typed
6
+ * client derives request + response types from the routes, so a shape mismatch is a type error.
7
+ * 2. **typed-client lint** — flags hand-rolled `fetch()` to this app's *own* API (a relative URL),
8
+ * which bypasses `client<typeof app>` so the compiler can't see the drift.
9
+ * 3. **server-only-import lint** — flags a top-level import of server-only code (a DB driver, `node:`/
10
+ * `bun:` builtins, the `./db` module) into a `routes/` page module. Those modules are bundled for
11
+ * the browser too, so the import ships server code to the client and breaks the build — the #1
12
+ * full-stack footgun. Reach server resources via `c.db` / `ctx.api`, never a top-level import.
13
+ *
14
+ * `collectCheckResult` returns a structured, machine-readable result (consumed by `--json` and the
15
+ * `nifra_check` MCP tool, so an agent acts on diagnostics instead of scraping prose). Exits non-zero if
16
+ * anything fails. Pure scanners (`scanFetchText`, `scanServerOnlyImports`) are unit-tested.
17
+ */
18
+
19
+ import { join } from "node:path"
20
+ import { Glob } from "bun"
21
+
22
+ export interface SourceFinding {
23
+ readonly file: string
24
+ readonly line: number
25
+ readonly snippet: string
26
+ }
27
+ /** @deprecated kept for back-compat with existing tests; prefer {@link SourceFinding}. */
28
+ export type FetchFinding = SourceFinding
29
+
30
+ // `fetch( <ws> ('|"|`) / (not /)` — a string/template arg starting with a single `/` is a relative URL,
31
+ // i.e. same-origin = this app's own API. `(?<![.\w])` skips `.fetch(` (a method) and `prefetch(`; the
32
+ // `(?!\/)` skips protocol-relative `//host` (an external origin). A variable arg (`fetch(url)`) is left
33
+ // alone on purpose — undecidable from source, and flagging it would punish legitimate external calls.
34
+ const OWN_API_FETCH = /(?<![.\w])fetch\s*\(\s*['"`]\/(?!\/)/g
35
+ const FETCH_CALL = /(?<![.\w])fetch\s*\(/g
36
+ const HTTP_VERBS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"])
37
+ const SIMPLE_REWRITE_METHODS = new Set(["GET", "DELETE", "HEAD", "OPTIONS"])
38
+
39
+ // Don't scan deps, build output, generated client entries, or tests (which legitimately drive `fetch`).
40
+ // `dist(-<runtime>)?` also covers per-runtime output dirs (dist-bun/dist-node/dist-deno/dist-vercel).
41
+ const IGNORED =
42
+ /(^|\/)(node_modules|dist(-[a-z0-9]+)?|build|\.nifra|\.git|\.wrangler|coverage)\/|\.(test|spec)\.[cm]?[jt]sx?$/
43
+
44
+ // A file under `routes/` — a page module bundled for the browser, where a server-only import is unsafe.
45
+ const ROUTE_FILE = /(^|\/)routes\//
46
+
47
+ // Module specifiers that must never be VALUE-imported into a route module: node:/bun: builtins, common
48
+ // DB drivers/ORМ server entrypoints, and the conventional `./db` module the scaffold generates.
49
+ const SERVER_ONLY =
50
+ /^(?:node:|bun:)|^(?:postgres|pg|mysql2|ioredis|redis|better-sqlite3|mongodb|@libsql\/client)$|^drizzle-orm\/(?:node-postgres|postgres-js|bun-sqlite|libsql|mysql2|pglite)\b|^(?:\.\.?\/)+db(?:\.[cm]?[jt]sx?)?$/
51
+
52
+ // A static, non-type import with a string specifier. `import type …` is erased at build, so it's safe
53
+ // and skipped. Dynamic `import(…)` (the correct way to lazy-load server code in a loader) has `(` right
54
+ // after `import`, so `import\s+` never matches it.
55
+ const STATIC_IMPORT = /\bimport\s+(?!type\b)(?:[^'"();]*?\bfrom\s+)?['"]([^'"]+)['"]/g
56
+
57
+ const ROUTE_REGISTRATION_DQ = /\.([A-Za-z]+)\s*\(\s*"((?:\\.|[^"\\])*)"/g
58
+ const ROUTE_REGISTRATION_SQ = /\.([A-Za-z]+)\s*\(\s*'((?:\\.|[^'\\])*)'/g
59
+ const IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/
60
+
61
+ export interface StaticRouteFinding extends SourceFinding {
62
+ readonly method: string
63
+ readonly path: string
64
+ }
65
+
66
+ interface SimpleFetchCall {
67
+ readonly path: string
68
+ readonly method: string
69
+ readonly start: number
70
+ readonly end: number
71
+ }
72
+
73
+ /** Line number (1-based) of a match index within `content`. */
74
+ const lineAt = (content: string, index: number): number =>
75
+ content.slice(0, index).split("\n").length
76
+
77
+ function parseQuotedLiteral(raw: string): string | undefined {
78
+ try {
79
+ return JSON.parse(raw) as string
80
+ } catch {
81
+ if (!raw.startsWith("'") || !raw.endsWith("'")) return undefined
82
+ try {
83
+ return JSON.parse(`"${raw.slice(1, -1).replace(/"/g, '\\"')}"`) as string
84
+ } catch {
85
+ return undefined
86
+ }
87
+ }
88
+ }
89
+
90
+ function findMatchingParen(src: string, openIndex: number): number | undefined {
91
+ let depth = 0
92
+ let quote: '"' | "'" | "`" | undefined
93
+ for (let i = openIndex; i < src.length; i++) {
94
+ const c = src[i]
95
+ if (quote !== undefined) {
96
+ if (c === "\\") {
97
+ i++
98
+ continue
99
+ }
100
+ if (c === quote) quote = undefined
101
+ continue
102
+ }
103
+ if (c === '"' || c === "'" || c === "`") {
104
+ quote = c
105
+ continue
106
+ }
107
+ if (c === "(") depth++
108
+ else if (c === ")") {
109
+ depth--
110
+ if (depth === 0) return i
111
+ }
112
+ }
113
+ return undefined
114
+ }
115
+
116
+ function splitTopLevelArgs(src: string): string[] | undefined {
117
+ const args: string[] = []
118
+ let start = 0
119
+ let depth = 0
120
+ let quote: '"' | "'" | "`" | undefined
121
+ for (let i = 0; i < src.length; i++) {
122
+ const c = src[i]
123
+ if (quote !== undefined) {
124
+ if (c === "\\") {
125
+ i++
126
+ continue
127
+ }
128
+ if (c === quote) quote = undefined
129
+ continue
130
+ }
131
+ if (c === '"' || c === "'" || c === "`") {
132
+ quote = c
133
+ continue
134
+ }
135
+ if (c === "(" || c === "{" || c === "[") depth++
136
+ else if (c === ")" || c === "}" || c === "]") depth--
137
+ else if (c === "," && depth === 0) {
138
+ args.push(src.slice(start, i).trim())
139
+ start = i + 1
140
+ }
141
+ }
142
+ if (quote !== undefined || depth !== 0) return undefined
143
+ const tail = src.slice(start).trim()
144
+ if (tail !== "") args.push(tail)
145
+ return args
146
+ }
147
+
148
+ function parseMethodOnlyInit(raw: string | undefined): string | undefined {
149
+ if (raw === undefined) return "GET"
150
+ const init = raw.trim()
151
+ if (!/^\{[\s\S]*\}$/.test(init)) return undefined
152
+ const body = init.slice(1, -1).trim()
153
+ if (body === "") return "GET"
154
+ const m = /^(?:"method"|'method'|method)\s*:\s*(["'])([A-Za-z]+)\1\s*,?$/.exec(body)
155
+ if (!m) return undefined
156
+ const method = (m[2] ?? "").toUpperCase()
157
+ return HTTP_VERBS.has(method) ? method : undefined
158
+ }
159
+
160
+ function parseSimpleFetchCall(snippet: string): SimpleFetchCall | undefined {
161
+ FETCH_CALL.lastIndex = 0
162
+ const matches = [...snippet.matchAll(FETCH_CALL)]
163
+ if (matches.length !== 1) return undefined
164
+ const match = matches[0]
165
+ if (match === undefined || match.index === undefined) return undefined
166
+ const open = match.index + match[0].lastIndexOf("(")
167
+ const close = findMatchingParen(snippet, open)
168
+ if (close === undefined) return undefined
169
+
170
+ const args = splitTopLevelArgs(snippet.slice(open + 1, close))
171
+ if (args === undefined || args.length === 0 || args.length > 2) return undefined
172
+ const first = args[0] ?? ""
173
+ const quote = first[0]
174
+ if ((quote !== '"' && quote !== "'") || first[first.length - 1] !== quote) return undefined
175
+ const path = parseQuotedLiteral(first)
176
+ if (
177
+ path === undefined ||
178
+ !path.startsWith("/") ||
179
+ path.startsWith("//") ||
180
+ path.includes("?") ||
181
+ path.includes("#")
182
+ ) {
183
+ return undefined
184
+ }
185
+ const method = parseMethodOnlyInit(args[1])
186
+ if (method === undefined) return undefined
187
+ return { path, method, start: match.index, end: close + 1 }
188
+ }
189
+
190
+ function isPotentialNifraBackendSource(code: string): boolean {
191
+ return code.includes("@nifrajs/core") || /(?<![.\w])server\s*\(/.test(code)
192
+ }
193
+
194
+ function scanRoutePattern(
195
+ file: string,
196
+ content: string,
197
+ code: string,
198
+ pattern: RegExp,
199
+ ): StaticRouteFinding[] {
200
+ const out: StaticRouteFinding[] = []
201
+ const lines = content.split("\n")
202
+ pattern.lastIndex = 0
203
+ for (let m = pattern.exec(code); m !== null; m = pattern.exec(code)) {
204
+ const method = (m[1] ?? "").toUpperCase()
205
+ if (!HTTP_VERBS.has(method)) continue
206
+ const path = parseQuotedLiteral(
207
+ `${pattern === ROUTE_REGISTRATION_DQ ? '"' : "'"}${m[2] ?? ""}${pattern === ROUTE_REGISTRATION_DQ ? '"' : "'"}`,
208
+ )
209
+ if (path === undefined || !path.startsWith("/") || path.startsWith("//")) continue
210
+ const line = lineAt(content, m.index)
211
+ out.push({ file, line, snippet: (lines[line - 1] ?? "").trim(), method, path })
212
+ }
213
+ return out
214
+ }
215
+
216
+ /**
217
+ * Blank, with spaces (newlines preserved, so every byte offset — and line number — is unchanged):
218
+ * - `//` line comments and block comments;
219
+ * - the CONTENTS of backtick template literals — code-as-text (doc `CodeBlock` examples, code
220
+ * generators), never a real statement to lint.
221
+ * Single/double-quoted strings are KEPT (a real import/Response specifier lives in one). A small
222
+ * char-state machine, not a full lexer: it doesn't model regex literals, so a regex containing a quote
223
+ * could mis-skip — in practice the constructs these scanners look for sit before any such regex.
224
+ * Shared by the source scanners here and by `nifra doctor` ({@link ./doctor.ts}).
225
+ */
226
+ export function stripComments(src: string): string {
227
+ const out = src.split("")
228
+ const n = src.length
229
+ let i = 0
230
+ const blank = (a: number, b: number): void => {
231
+ for (let k = a; k < b; k++) if (out[k] !== "\n") out[k] = " "
232
+ }
233
+ while (i < n) {
234
+ const c = src[i]
235
+ const d = src[i + 1]
236
+ if (c === "/" && d === "/") {
237
+ let j = i + 2
238
+ while (j < n && src[j] !== "\n") j++
239
+ blank(i, j)
240
+ i = j
241
+ } else if (c === "/" && d === "*") {
242
+ let j = i + 2
243
+ while (j < n && !(src[j] === "*" && src[j + 1] === "/")) j++
244
+ j = Math.min(j + 2, n)
245
+ blank(i, j)
246
+ i = j
247
+ } else if (c === "`") {
248
+ let j = i + 1
249
+ while (j < n && src[j] !== "`") {
250
+ if (src[j] === "\\") j++
251
+ j++
252
+ }
253
+ blank(i + 1, j) // keep the backticks, blank the code-as-text inside
254
+ i = j + 1
255
+ } else if (c === "'" || c === '"') {
256
+ let j = i + 1
257
+ while (j < n && src[j] !== c) {
258
+ if (src[j] === "\\") j++ // skip the escaped char
259
+ j++
260
+ }
261
+ i = j + 1
262
+ } else {
263
+ i++
264
+ }
265
+ }
266
+ return out.join("")
267
+ }
268
+
269
+ /** Scan one file's text for hand-rolled own-API `fetch()` calls. Pure + line-accurate. */
270
+ export function scanFetchText(file: string, content: string): SourceFinding[] {
271
+ const out: SourceFinding[] = []
272
+ const lines = content.split("\n")
273
+ OWN_API_FETCH.lastIndex = 0
274
+ for (let m = OWN_API_FETCH.exec(content); m !== null; m = OWN_API_FETCH.exec(content)) {
275
+ const line = lineAt(content, m.index)
276
+ out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() })
277
+ }
278
+ return out
279
+ }
280
+
281
+ /** Statically collect simple Nifra route registrations from source, without importing app code. */
282
+ export function scanStaticRouteText(file: string, content: string): StaticRouteFinding[] {
283
+ const code = stripComments(content)
284
+ if (!isPotentialNifraBackendSource(code)) return []
285
+ return [
286
+ ...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_DQ),
287
+ ...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_SQ),
288
+ ].sort(bySite)
289
+ }
290
+
291
+ // `client("` / `client('` / `client(\`` — a URL-first call WITHOUT the `<typeof app>` generic:
292
+ // the compiler has nothing to derive types from, so the anti-drift guarantee silently vanishes.
293
+ // `client<typeof app>("…")` has `<…>` between the name and `(` so it never matches; the published-
294
+ // contract form `client(contract, url)` starts with an identifier, not a quote — also unmatched.
295
+ const UNTYPED_CLIENT = /(?<![.\w])client\s*\(\s*['"`]/g
296
+
297
+ /** Scan one file's text for untyped `client("…")` calls. Pure + line-accurate. */
298
+ export function scanUntypedClient(file: string, content: string): SourceFinding[] {
299
+ const out: SourceFinding[] = []
300
+ const lines = content.split("\n")
301
+ UNTYPED_CLIENT.lastIndex = 0
302
+ for (let m = UNTYPED_CLIENT.exec(content); m !== null; m = UNTYPED_CLIENT.exec(content)) {
303
+ const line = lineAt(content, m.index)
304
+ out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() })
305
+ }
306
+ return out
307
+ }
308
+
309
+ /** Scan a route module for top-level server-only imports. Returns `[]` for non-route files (only
310
+ * `routes/` modules are browser-bundled, so a server-only import elsewhere is fine). Pure. */
311
+ export function scanServerOnlyImports(file: string, content: string): SourceFinding[] {
312
+ if (!ROUTE_FILE.test(file)) return []
313
+ const out: SourceFinding[] = []
314
+ const lines = content.split("\n")
315
+ STATIC_IMPORT.lastIndex = 0
316
+ for (let m = STATIC_IMPORT.exec(content); m !== null; m = STATIC_IMPORT.exec(content)) {
317
+ if (!SERVER_ONLY.test(m[1] ?? "")) continue
318
+ const line = lineAt(content, m.index)
319
+ out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() })
320
+ }
321
+ return out
322
+ }
323
+
324
+ // A route handler returning a raw `Response` (`=> new Response(`, `return Response.json(`, …) makes the
325
+ // typed client infer `data: never` (`Jsonify<Response>` is `never`) — so frontend/backend drift detection
326
+ // silently vanishes for that route. Advisory, not a failure: a raw Response is sometimes intended
327
+ // (redirects, files, streams). Fix is to return a plain object, or declare `{ response: t.… }` to type it.
328
+ const RESPONSE_RETURN = /(?:=>\s*|return\s+)(?:new\s+Response|Response\s*\.\s*json)\s*\(/g
329
+
330
+ /** Scan a backend module (one that calls `server(`) for handlers returning a raw `Response`, which collapses
331
+ * the typed client's `data` to `never`. Pure + line-accurate; returns `[]` for files with no `server(` call. */
332
+ export function scanResponseRoutes(file: string, content: string): SourceFinding[] {
333
+ // Strip comments + template literals first: a commented-out or doc-example `return new Response(`
334
+ // must not raise a spurious advisory. Lengths are preserved, so lineAt + the raw-line snippet align.
335
+ const code = stripComments(content)
336
+ if (!/(?<![.\w])server\s*\(/.test(code)) return []
337
+ const out: SourceFinding[] = []
338
+ const lines = content.split("\n")
339
+ RESPONSE_RETURN.lastIndex = 0
340
+ for (let m = RESPONSE_RETURN.exec(code); m !== null; m = RESPONSE_RETURN.exec(code)) {
341
+ const line = lineAt(content, m.index)
342
+ out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() })
343
+ }
344
+ return out
345
+ }
346
+
347
+ /** Walk the project's `.ts`/`.tsx` source (skipping deps/build/tests), calling `visit` per file.
348
+ * Exported so `nifra doctor` ({@link ./doctor.ts}) scans the same source surface as `nifra check`. */
349
+ export async function walkSource(
350
+ cwd: string,
351
+ visit: (rel: string, content: string) => void,
352
+ ): Promise<void> {
353
+ for await (const rel of new Glob("**/*.{ts,tsx,mts,cts}").scan({ cwd, dot: false })) {
354
+ if (IGNORED.test(rel)) continue
355
+ visit(rel, await Bun.file(join(cwd, rel)).text())
356
+ }
357
+ }
358
+
359
+ const bySite = (a: SourceFinding, b: SourceFinding): number =>
360
+ a.file.localeCompare(b.file) || a.line - b.line
361
+
362
+ /** Collect own-API `fetch()` findings across the project (kept for back-compat + reuse). */
363
+ export async function scanProject(cwd: string): Promise<SourceFinding[]> {
364
+ const out: SourceFinding[] = []
365
+ await walkSource(cwd, (rel, content) => out.push(...scanFetchText(rel, content)))
366
+ return out.sort(bySite)
367
+ }
368
+
369
+ interface TypecheckResult {
370
+ readonly ran: boolean
371
+ readonly ok: boolean
372
+ readonly note?: string
373
+ readonly output?: string
374
+ readonly cancelled?: boolean
375
+ }
376
+
377
+ /** Run the project's own `tsc --noEmit`, if TypeScript + a tsconfig are present. Never auto-installs. */
378
+ async function typecheck(cwd: string, signal?: AbortSignal): Promise<TypecheckResult> {
379
+ const tsconfig = join(cwd, "tsconfig.json")
380
+ const tscBin = join(cwd, "node_modules", "typescript", "bin", "tsc")
381
+ if (!(await Bun.file(tsconfig).exists()))
382
+ return { ran: false, ok: true, note: "no tsconfig.json" }
383
+ if (!(await Bun.file(tscBin).exists())) {
384
+ return { ran: false, ok: true, note: "typescript not installed (run: bun add -d typescript)" }
385
+ }
386
+ if (signal?.aborted) return { ran: true, ok: false, cancelled: true, output: "cancelled" }
387
+ const proc = Bun.spawn(["bun", tscBin, "--noEmit", "-p", tsconfig], {
388
+ cwd,
389
+ stdout: "pipe",
390
+ stderr: "pipe",
391
+ })
392
+ let cancelled = false
393
+ const abort = (): void => {
394
+ cancelled = true
395
+ proc.kill()
396
+ }
397
+ signal?.addEventListener("abort", abort, { once: true })
398
+ let out = ""
399
+ let err = ""
400
+ let code: number | null = null
401
+ try {
402
+ const result = await Promise.all([
403
+ new Response(proc.stdout).text(),
404
+ new Response(proc.stderr).text(),
405
+ proc.exited,
406
+ ])
407
+ out = result[0]
408
+ err = result[1]
409
+ code = result[2]
410
+ } finally {
411
+ signal?.removeEventListener("abort", abort)
412
+ }
413
+ return {
414
+ ran: true,
415
+ ok: code === 0 && !cancelled,
416
+ output: cancelled ? "cancelled" : `${out}${err}`.trim(),
417
+ ...(cancelled ? { cancelled: true } : {}),
418
+ }
419
+ }
420
+
421
+ // `src/x.tsx(12,5): error TS2322: <message>` → one structured diagnostic.
422
+ const TSC_LINE = /^(.+?)\((\d+),\d+\):\s*(?:error|warning)\s+TS\d+:\s*(.+)$/
423
+
424
+ /** A single machine-readable check failure — the unit an agent (or CI) acts on. */
425
+ export interface CheckDiagnostic {
426
+ readonly rule:
427
+ | "typecheck"
428
+ | "typed-client"
429
+ | "untyped-client"
430
+ | "server-only-import"
431
+ | "response-route"
432
+ | "undeclared-dependency"
433
+ /** `error` fails the gate (a real contract break); `warning` is advisory — surfaced to the agent but
434
+ * does NOT fail `nifra check`, for patterns that are sometimes intentional (a route returning a raw
435
+ * `Response`, which silently drops the typed client to `data: never` but is valid for files/redirects). */
436
+ readonly severity: "error" | "warning"
437
+ readonly file?: string
438
+ readonly line?: number
439
+ readonly message: string
440
+ /** The canonical, rule-level fix — clean of the per-occurrence snippet, so an agent can apply it
441
+ * directly. Set for the lint rules (they have one correct fix); omitted for `typecheck` (the fix is
442
+ * specific to each type error). */
443
+ readonly fix?: string
444
+ /** A richer, agent-oriented fix hint. Diffs are only emitted when the edit is mechanical and local;
445
+ * ambiguous cases give concrete steps instead of pretending the checker can safely rewrite code. */
446
+ readonly suggestion?: CheckSuggestion
447
+ }
448
+
449
+ export interface CheckSuggestion {
450
+ readonly kind: "edit" | "command" | "manual"
451
+ readonly title: string
452
+ readonly diff?: string
453
+ /** argv array, not a shell string, so MCP clients can run it without quoting hazards. */
454
+ readonly command?: readonly string[]
455
+ readonly steps?: readonly string[]
456
+ }
457
+
458
+ /** The structured result of a full check — what `--json` prints and the `nifra_check` MCP tool returns. */
459
+ export interface CheckResult {
460
+ readonly ok: boolean
461
+ readonly typecheck: "pass" | "fail" | "skipped"
462
+ readonly diagnostics: readonly CheckDiagnostic[]
463
+ }
464
+
465
+ const UNTYPED_CLIENT_HINT =
466
+ 'client("…") without a type argument — write client<typeof app>("…") (or client(contract, url)) so the compiler can catch drift'
467
+ const FETCH_HINT =
468
+ "hand-rolled fetch() to your own API — call it through client<typeof app> (from @nifrajs/client) so the compiler catches drift"
469
+ const SERVER_IMPORT_HINT =
470
+ "server-only import in a route module (bundled for the browser) — reach it via c.db / ctx.api inside a loader, never a top-level import"
471
+ const RESPONSE_ROUTE_HINT =
472
+ "route handler returns a raw Response — the typed client infers `data: never`, so drift detection is lost for this route. Return a plain object (it's serialized for you), or add `{ response: t.… }` to the route if a raw Response is intended (file/redirect/stream)"
473
+ const UNDECLARED_DEP_HINT =
474
+ "imported package is not declared in package.json dependencies — run bun add to declare it"
475
+
476
+ function oneLineDiff(file: string, line: number, before: string, after: string): string {
477
+ return `--- ${file}:${line}\n+++ ${file}:${line}\n@@\n-${before}\n+${after}`
478
+ }
479
+
480
+ function untypedClientSuggestion(f: SourceFinding): CheckSuggestion {
481
+ const replacement = f.snippet.replace(/(?<![.\w])client\s*\(/, "client<typeof app>(")
482
+ return replacement === f.snippet
483
+ ? {
484
+ kind: "manual",
485
+ title: "Add the app type argument to the client factory",
486
+ steps: [
487
+ 'Change `client("...")` to `client<typeof app>("...")`.',
488
+ "Make sure the backend app type is imported or otherwise in scope.",
489
+ ],
490
+ }
491
+ : {
492
+ kind: "edit",
493
+ title: "Insert `<typeof app>` into the client factory call",
494
+ diff: oneLineDiff(f.file, f.line, f.snippet, replacement),
495
+ steps: ["Make sure the backend app type is imported or otherwise in scope."],
496
+ }
497
+ }
498
+
499
+ function routeKey(method: string, path: string): string {
500
+ return `${method.toUpperCase()} ${path}`
501
+ }
502
+
503
+ function typedClientCall(method: string, path: string): string {
504
+ const segs = path.split("/").filter((seg) => seg !== "")
505
+ let chain = "api"
506
+ if (segs.length === 0) chain += ".index"
507
+ else {
508
+ for (const seg of segs) {
509
+ chain += IDENT.test(seg) ? `.${seg}` : `[${JSON.stringify(seg)}]`
510
+ }
511
+ }
512
+ return `${chain}.${method.toLowerCase()}()`
513
+ }
514
+
515
+ function staticRouteMap(routes: readonly StaticRouteFinding[]): Map<string, StaticRouteFinding[]> {
516
+ const out = new Map<string, StaticRouteFinding[]>()
517
+ for (const route of routes) {
518
+ if (route.path.includes(":") || route.path.includes("*")) continue
519
+ const key = routeKey(route.method, route.path)
520
+ const bucket = out.get(key)
521
+ if (bucket === undefined) out.set(key, [route])
522
+ else bucket.push(route)
523
+ }
524
+ return out
525
+ }
526
+
527
+ function ownFetchEditSuggestion(
528
+ f: SourceFinding,
529
+ routes: Map<string, StaticRouteFinding[]>,
530
+ ): CheckSuggestion | undefined {
531
+ const call = parseSimpleFetchCall(f.snippet)
532
+ if (call === undefined || !SIMPLE_REWRITE_METHODS.has(call.method)) return undefined
533
+ const matches = routes.get(routeKey(call.method, call.path))
534
+ if (matches === undefined || matches.length !== 1) return undefined
535
+ const replacementCall = typedClientCall(call.method, call.path)
536
+ const replacement = `${f.snippet.slice(0, call.start)}${replacementCall}${f.snippet.slice(call.end)}`
537
+ if (replacement === f.snippet) return undefined
538
+ const route = matches[0]
539
+ if (route === undefined) return undefined
540
+ return {
541
+ kind: "edit",
542
+ title: "Rewrite simple own-API fetch to the typed nifra client",
543
+ diff: oneLineDiff(f.file, f.line, f.snippet, replacement),
544
+ steps: [
545
+ `Matched ${route.method} ${route.path} at ${route.file}:${route.line}.`,
546
+ "Use an in-scope typed client named `api` (`client<typeof app>(baseUrl)` or the route loader/action `api`).",
547
+ "Update downstream `Response` handling to branch on `{ ok, data, error }` if this variable is used later.",
548
+ ],
549
+ }
550
+ }
551
+
552
+ function ownFetchSuggestion(
553
+ f: SourceFinding,
554
+ routes: Map<string, StaticRouteFinding[]>,
555
+ ): CheckSuggestion {
556
+ const exact = ownFetchEditSuggestion(f, routes)
557
+ if (exact !== undefined) return exact
558
+ return {
559
+ kind: "manual",
560
+ title: "Replace own-API fetch with the typed nifra client",
561
+ steps: [
562
+ "Call `nifra_routes` or read `nifra://routes` for the exact typed-client call form.",
563
+ "Create `const api = client<typeof app>(baseUrl)` from `@nifrajs/client`.",
564
+ "Replace the relative `fetch()` call with the generated `api...get/post/...` call and branch on `{ ok, data, error }`.",
565
+ ],
566
+ }
567
+ }
568
+
569
+ function serverImportSuggestion(): CheckSuggestion {
570
+ return {
571
+ kind: "manual",
572
+ title: "Move server-only code behind the route server boundary",
573
+ steps: [
574
+ "Remove the top-level server-only import from the route module.",
575
+ "Access backend/data work through the route `loader`/`action` context (`api`, `env`, or project server context).",
576
+ "If a direct module import is unavoidable, lazy-load it inside the server-only loader/action path.",
577
+ ],
578
+ }
579
+ }
580
+
581
+ function responseRouteSuggestion(): CheckSuggestion {
582
+ return {
583
+ kind: "manual",
584
+ title: "Preserve typed-client response inference",
585
+ steps: [
586
+ "Prefer returning a plain object from JSON routes; nifra serializes it for you.",
587
+ "If this route must return a raw Response (redirect, file, stream), declare an explicit response schema or accept the warning.",
588
+ ],
589
+ }
590
+ }
591
+
592
+ /** Run the three checks and assemble a structured, machine-readable result. The single source the CLI
593
+ * report, `--json`, and the MCP tool all render from. */
594
+ export async function collectCheckResult(
595
+ cwd: string,
596
+ opts: { readonly lintsOnly?: boolean; readonly signal?: AbortSignal } = {},
597
+ ): Promise<CheckResult> {
598
+ const fetches: SourceFinding[] = []
599
+ const staticRoutes: StaticRouteFinding[] = []
600
+ const untypedClients: SourceFinding[] = []
601
+ const serverImports: SourceFinding[] = []
602
+ const responseRoutes: SourceFinding[] = []
603
+ // lintsOnly: skip the tsc pass (seconds on a big project) and run just the near-instant source
604
+ // lints — the agent inner-loop mode; the full gate stays the definition of done.
605
+ const [tc, _, dr] = await Promise.all([
606
+ opts.lintsOnly
607
+ ? Promise.resolve<TypecheckResult>({ ran: false, ok: true, note: "skipped (lintsOnly)" })
608
+ : typecheck(cwd, opts.signal),
609
+ walkSource(cwd, (rel, content) => {
610
+ fetches.push(...scanFetchText(rel, content))
611
+ staticRoutes.push(...scanStaticRouteText(rel, content))
612
+ untypedClients.push(...scanUntypedClient(rel, content))
613
+ serverImports.push(...scanServerOnlyImports(rel, content))
614
+ responseRoutes.push(...scanResponseRoutes(rel, content))
615
+ }),
616
+ import("./doctor.ts").then((m) => m.collectDoctorResult(cwd)),
617
+ ])
618
+
619
+ const diagnostics: CheckDiagnostic[] = []
620
+ if (tc.ran && !tc.ok) {
621
+ const lines = (tc.output ?? "").split("\n")
622
+ let matched = false
623
+ for (const l of lines) {
624
+ const m = TSC_LINE.exec(l.trim())
625
+ if (m) {
626
+ matched = true
627
+ diagnostics.push({
628
+ rule: "typecheck",
629
+ severity: "error",
630
+ file: m[1] as string,
631
+ line: Number(m[2]),
632
+ message: m[3] as string,
633
+ suggestion: {
634
+ kind: "manual",
635
+ title: "Fix the TypeScript contract error",
636
+ steps: [
637
+ "Open the reported file and line.",
638
+ "Align the handler, route schema, or typed-client call with the compiler error.",
639
+ "Run `nifra_check` again after the edit.",
640
+ ],
641
+ },
642
+ })
643
+ }
644
+ }
645
+ if (!matched)
646
+ diagnostics.push({
647
+ rule: "typecheck",
648
+ severity: "error",
649
+ message: tc.output || "typecheck failed",
650
+ suggestion: {
651
+ kind: "manual",
652
+ title: "Fix the TypeScript contract error",
653
+ steps: ["Run `tsc --noEmit` locally for the full compiler output."],
654
+ },
655
+ })
656
+ }
657
+ const routes = staticRouteMap(staticRoutes)
658
+ for (const f of fetches.sort(bySite)) {
659
+ diagnostics.push({
660
+ rule: "typed-client",
661
+ severity: "error",
662
+ file: f.file,
663
+ line: f.line,
664
+ message: `${f.snippet} — ${FETCH_HINT}`,
665
+ fix: FETCH_HINT,
666
+ suggestion: ownFetchSuggestion(f, routes),
667
+ })
668
+ }
669
+ for (const f of untypedClients.sort(bySite)) {
670
+ diagnostics.push({
671
+ rule: "untyped-client",
672
+ severity: "error",
673
+ file: f.file,
674
+ line: f.line,
675
+ message: `${f.snippet} — ${UNTYPED_CLIENT_HINT}`,
676
+ fix: UNTYPED_CLIENT_HINT,
677
+ suggestion: untypedClientSuggestion(f),
678
+ })
679
+ }
680
+ for (const f of serverImports.sort(bySite)) {
681
+ diagnostics.push({
682
+ rule: "server-only-import",
683
+ severity: "error",
684
+ file: f.file,
685
+ line: f.line,
686
+ message: `${f.snippet} — ${SERVER_IMPORT_HINT}`,
687
+ fix: SERVER_IMPORT_HINT,
688
+ suggestion: serverImportSuggestion(),
689
+ })
690
+ }
691
+ // Advisory — surfaced but NOT folded into `ok`, so it never fails the gate (a raw Response is valid).
692
+ for (const f of responseRoutes.sort(bySite)) {
693
+ diagnostics.push({
694
+ rule: "response-route",
695
+ severity: "warning",
696
+ file: f.file,
697
+ line: f.line,
698
+ message: `${f.snippet} — ${RESPONSE_ROUTE_HINT}`,
699
+ fix: RESPONSE_ROUTE_HINT,
700
+ suggestion: responseRouteSuggestion(),
701
+ })
702
+ }
703
+ if (dr.ran) {
704
+ for (const f of dr.findings) {
705
+ diagnostics.push({
706
+ rule: "undeclared-dependency",
707
+ severity: "error",
708
+ file: f.file,
709
+ line: f.line,
710
+ message: `imports ${f.package} which is not declared in package.json — ${UNDECLARED_DEP_HINT}`,
711
+ fix: `add ${f.package} to package.json dependencies`,
712
+ suggestion: {
713
+ kind: "command",
714
+ title: `Declare ${f.package} in package.json`,
715
+ command: ["bun", "add", f.package],
716
+ },
717
+ })
718
+ }
719
+ }
720
+
721
+ return {
722
+ ok:
723
+ tc.ok &&
724
+ fetches.length === 0 &&
725
+ untypedClients.length === 0 &&
726
+ serverImports.length === 0 &&
727
+ (!dr.ran || dr.findings.length === 0),
728
+ typecheck: tc.ran ? (tc.ok ? "pass" : "fail") : "skipped",
729
+ diagnostics,
730
+ }
731
+ }
732
+
733
+ /** Run the full check; print a report (`--json` for machine output) and return whether it passed. */
734
+ export async function runCheck(
735
+ cwd: string,
736
+ opts: { readonly json?: boolean; readonly lintsOnly?: boolean } = {},
737
+ ): Promise<boolean> {
738
+ const result = await collectCheckResult(cwd, { lintsOnly: opts.lintsOnly ?? false })
739
+ if (opts.json) {
740
+ console.log(JSON.stringify(result, null, 2))
741
+ return result.ok
742
+ }
743
+
744
+ console.log("nifra check\n")
745
+ console.log(
746
+ result.typecheck === "pass"
747
+ ? "✓ typecheck passed"
748
+ : result.typecheck === "fail"
749
+ ? "✗ typecheck failed — the frontend/backend contract is broken"
750
+ : "• typecheck skipped (no tsconfig / typescript not installed)",
751
+ )
752
+ const counts = (rule: CheckDiagnostic["rule"]): CheckDiagnostic[] =>
753
+ result.diagnostics.filter((d) => d.rule === rule)
754
+ for (const [rule, label] of [
755
+ ["typecheck", "typecheck"],
756
+ ["typed-client", "hand-rolled fetch() to your own API"],
757
+ ["untyped-client", 'client("…") missing its <typeof app> type argument'],
758
+ ["server-only-import", "server-only import in a route module"],
759
+ ["response-route", "route returns a raw Response (typed client → data: never)"],
760
+ ["undeclared-dependency", "undeclared dependency in package.json"],
761
+ ] as const) {
762
+ const ds = counts(rule)
763
+ if (rule === "response-route") {
764
+ // Advisory: surfaced with ⚠, never folded into pass/fail.
765
+ console.log(ds.length === 0 ? `✓ ${label}: none` : `⚠ ${label}: ${ds.length} (advisory)`)
766
+ } else if (rule !== "typecheck") {
767
+ console.log(ds.length === 0 ? `✓ ${label}: none` : `✗ ${label}: ${ds.length}`)
768
+ }
769
+ for (const d of ds) {
770
+ console.log(` ${d.file ?? ""}${d.line ? `:${d.line}` : ""} ${d.message}`)
771
+ if (d.suggestion !== undefined) {
772
+ console.log(` fix: ${d.suggestion.title}`)
773
+ if (d.suggestion.command !== undefined) {
774
+ console.log(` command: ${d.suggestion.command.join(" ")}`)
775
+ }
776
+ if (d.suggestion.diff !== undefined) {
777
+ console.log(
778
+ d.suggestion.diff
779
+ .split("\n")
780
+ .map((line) => ` ${line}`)
781
+ .join("\n"),
782
+ )
783
+ }
784
+ for (const step of d.suggestion.steps ?? []) console.log(` - ${step}`)
785
+ }
786
+ }
787
+ }
788
+ console.log(result.ok ? "\n✓ check passed" : "\n✗ check failed")
789
+ return result.ok
790
+ }