@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.
- package/LICENSE +21 -0
- package/README.md +99 -0
- package/dist/check.d.ts +100 -0
- package/dist/check.d.ts.map +1 -0
- package/dist/check.js +690 -0
- package/dist/check.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +208 -0
- package/dist/cli.js.map +1 -0
- package/dist/docs-search.d.ts +30 -0
- package/dist/docs-search.d.ts.map +1 -0
- package/dist/docs-search.js +94 -0
- package/dist/docs-search.js.map +1 -0
- package/dist/doctor.d.ts +52 -0
- package/dist/doctor.d.ts.map +1 -0
- package/dist/doctor.js +291 -0
- package/dist/doctor.js.map +1 -0
- package/dist/examples.d.ts +30 -0
- package/dist/examples.d.ts.map +1 -0
- package/dist/examples.js +70 -0
- package/dist/examples.js.map +1 -0
- package/dist/introspect.d.ts +64 -0
- package/dist/introspect.d.ts.map +1 -0
- package/dist/introspect.js +243 -0
- package/dist/introspect.js.map +1 -0
- package/dist/load.d.ts +51 -0
- package/dist/load.d.ts.map +1 -0
- package/dist/load.js +70 -0
- package/dist/load.js.map +1 -0
- package/dist/mcp-docs-tools.d.ts +13 -0
- package/dist/mcp-docs-tools.d.ts.map +1 -0
- package/dist/mcp-docs-tools.js +63 -0
- package/dist/mcp-docs-tools.js.map +1 -0
- package/dist/mcp-http.d.ts +43 -0
- package/dist/mcp-http.d.ts.map +1 -0
- package/dist/mcp-http.js +136 -0
- package/dist/mcp-http.js.map +1 -0
- package/dist/mcp-protocol.d.ts +97 -0
- package/dist/mcp-protocol.d.ts.map +1 -0
- package/dist/mcp-protocol.js +248 -0
- package/dist/mcp-protocol.js.map +1 -0
- package/dist/mcp-render.d.ts +17 -0
- package/dist/mcp-render.d.ts.map +1 -0
- package/dist/mcp-render.js +73 -0
- package/dist/mcp-render.js.map +1 -0
- package/dist/mcp-run.d.ts +24 -0
- package/dist/mcp-run.d.ts.map +1 -0
- package/dist/mcp-run.js +142 -0
- package/dist/mcp-run.js.map +1 -0
- package/dist/mcp-ws.d.ts +26 -0
- package/dist/mcp-ws.d.ts.map +1 -0
- package/dist/mcp-ws.js +258 -0
- package/dist/mcp-ws.js.map +1 -0
- package/dist/mcp.d.ts +42 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +712 -0
- package/dist/mcp.js.map +1 -0
- package/dist/openapi-tool.d.ts +6 -0
- package/dist/openapi-tool.d.ts.map +1 -0
- package/dist/openapi-tool.js +48 -0
- package/dist/openapi-tool.js.map +1 -0
- package/dist/scaffold.d.ts +35 -0
- package/dist/scaffold.d.ts.map +1 -0
- package/dist/scaffold.js +140 -0
- package/dist/scaffold.js.map +1 -0
- package/dist/search-terms.d.ts +17 -0
- package/dist/search-terms.d.ts.map +1 -0
- package/dist/search-terms.js +82 -0
- package/dist/search-terms.js.map +1 -0
- package/dist/test-tool.d.ts +29 -0
- package/dist/test-tool.d.ts.map +1 -0
- package/dist/test-tool.js +150 -0
- package/dist/test-tool.js.map +1 -0
- package/docs/examples.json +314 -0
- package/docs/llms-full.txt +4325 -0
- package/package.json +58 -0
- package/src/check.ts +790 -0
- package/src/cli.ts +237 -0
- package/src/docs-search.ts +111 -0
- package/src/doctor.ts +349 -0
- package/src/examples.ts +89 -0
- package/src/introspect.ts +284 -0
- package/src/load.ts +129 -0
- package/src/mcp-docs-tools.ts +72 -0
- package/src/mcp-http.ts +155 -0
- package/src/mcp-protocol.ts +326 -0
- package/src/mcp-render.ts +78 -0
- package/src/mcp-run.ts +153 -0
- package/src/mcp-ws.ts +328 -0
- package/src/mcp.ts +863 -0
- package/src/openapi-tool.ts +51 -0
- package/src/scaffold.ts +164 -0
- package/src/search-terms.ts +89 -0
- 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
|
+
}
|