@nifrajs/cli 3.1.0 → 3.3.0

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 (158) hide show
  1. package/README.md +10 -0
  2. package/dist/assure-hydration.d.ts.map +1 -1
  3. package/dist/assure-hydration.js +6 -1
  4. package/dist/assure-hydration.js.map +1 -1
  5. package/dist/assure.d.ts.map +1 -1
  6. package/dist/assure.js +3 -2
  7. package/dist/assure.js.map +1 -1
  8. package/dist/check-diagnostics.d.ts +165 -0
  9. package/dist/check-diagnostics.d.ts.map +1 -0
  10. package/dist/check-diagnostics.js +1009 -0
  11. package/dist/check-diagnostics.js.map +1 -0
  12. package/dist/check-scan.d.ts +245 -0
  13. package/dist/check-scan.d.ts.map +1 -0
  14. package/dist/check-scan.js +1599 -0
  15. package/dist/check-scan.js.map +1 -0
  16. package/dist/check.d.ts +4 -324
  17. package/dist/check.d.ts.map +1 -1
  18. package/dist/check.js +8 -2366
  19. package/dist/check.js.map +1 -1
  20. package/dist/cli.d.ts.map +1 -1
  21. package/dist/cli.js +247 -10
  22. package/dist/cli.js.map +1 -1
  23. package/dist/command-catalog.d.ts +21 -1
  24. package/dist/command-catalog.d.ts.map +1 -1
  25. package/dist/command-catalog.js +122 -0
  26. package/dist/command-catalog.js.map +1 -1
  27. package/dist/env-file.js +5 -5
  28. package/dist/env-file.js.map +1 -1
  29. package/dist/frontend-guidance.d.ts +57 -0
  30. package/dist/frontend-guidance.d.ts.map +1 -0
  31. package/dist/frontend-guidance.js +195 -0
  32. package/dist/frontend-guidance.js.map +1 -0
  33. package/dist/levels-tool.d.ts.map +1 -1
  34. package/dist/levels-tool.js +6 -0
  35. package/dist/levels-tool.js.map +1 -1
  36. package/dist/load.d.ts.map +1 -1
  37. package/dist/load.js +4 -1
  38. package/dist/load.js.map +1 -1
  39. package/dist/mcp-context.d.ts +48 -0
  40. package/dist/mcp-context.d.ts.map +1 -0
  41. package/dist/mcp-context.js +215 -0
  42. package/dist/mcp-context.js.map +1 -0
  43. package/dist/mcp-docs-tools.d.ts.map +1 -1
  44. package/dist/mcp-docs-tools.js +31 -0
  45. package/dist/mcp-docs-tools.js.map +1 -1
  46. package/dist/mcp-exec.d.ts +74 -0
  47. package/dist/mcp-exec.d.ts.map +1 -0
  48. package/dist/mcp-exec.js +844 -0
  49. package/dist/mcp-exec.js.map +1 -0
  50. package/dist/mcp-http.js +1 -1
  51. package/dist/mcp-io.d.ts +26 -0
  52. package/dist/mcp-io.d.ts.map +1 -0
  53. package/dist/mcp-io.js +100 -0
  54. package/dist/mcp-io.js.map +1 -0
  55. package/dist/mcp-reflect.d.ts +16 -0
  56. package/dist/mcp-reflect.d.ts.map +1 -0
  57. package/dist/mcp-reflect.js +101 -0
  58. package/dist/mcp-reflect.js.map +1 -0
  59. package/dist/mcp-root.d.ts +7 -0
  60. package/dist/mcp-root.d.ts.map +1 -1
  61. package/dist/mcp-root.js +51 -3
  62. package/dist/mcp-root.js.map +1 -1
  63. package/dist/mcp.d.ts +10 -92
  64. package/dist/mcp.d.ts.map +1 -1
  65. package/dist/mcp.js +13 -1232
  66. package/dist/mcp.js.map +1 -1
  67. package/dist/openapi-tool.d.ts +5 -10
  68. package/dist/openapi-tool.d.ts.map +1 -1
  69. package/dist/openapi-tool.js +21 -2
  70. package/dist/openapi-tool.js.map +1 -1
  71. package/dist/openapi-types.d.ts +21 -0
  72. package/dist/openapi-types.d.ts.map +1 -0
  73. package/dist/openapi-types.js +214 -0
  74. package/dist/openapi-types.js.map +1 -0
  75. package/dist/port.d.ts.map +1 -1
  76. package/dist/port.js +2 -1
  77. package/dist/port.js.map +1 -1
  78. package/dist/project-facts.d.ts +2 -1
  79. package/dist/project-facts.d.ts.map +1 -1
  80. package/dist/project-facts.js.map +1 -1
  81. package/dist/release-verification.d.ts +4 -1
  82. package/dist/release-verification.d.ts.map +1 -1
  83. package/dist/release-verification.js +6 -138
  84. package/dist/release-verification.js.map +1 -1
  85. package/dist/rules/codes.d.ts +4 -0
  86. package/dist/rules/codes.d.ts.map +1 -1
  87. package/dist/rules/codes.js +4 -0
  88. package/dist/rules/codes.js.map +1 -1
  89. package/dist/rules/islands.d.ts +4 -0
  90. package/dist/rules/islands.d.ts.map +1 -0
  91. package/dist/rules/islands.js +155 -0
  92. package/dist/rules/islands.js.map +1 -0
  93. package/dist/rules/nano.d.ts +6 -0
  94. package/dist/rules/nano.d.ts.map +1 -0
  95. package/dist/rules/nano.js +264 -0
  96. package/dist/rules/nano.js.map +1 -0
  97. package/dist/rules/security.d.ts.map +1 -1
  98. package/dist/rules/security.js +38 -4
  99. package/dist/rules/security.js.map +1 -1
  100. package/dist/scaffold.d.ts +12 -6
  101. package/dist/scaffold.d.ts.map +1 -1
  102. package/dist/scaffold.js +149 -10
  103. package/dist/scaffold.js.map +1 -1
  104. package/dist/search-terms.d.ts.map +1 -1
  105. package/dist/search-terms.js +5 -3
  106. package/dist/search-terms.js.map +1 -1
  107. package/dist/stylex-migrate.d.ts +38 -0
  108. package/dist/stylex-migrate.d.ts.map +1 -0
  109. package/dist/stylex-migrate.js +1052 -0
  110. package/dist/stylex-migrate.js.map +1 -0
  111. package/dist/sync-manifest.d.ts.map +1 -1
  112. package/dist/sync-manifest.js +2 -1
  113. package/dist/sync-manifest.js.map +1 -1
  114. package/dist/verification-plan.d.ts +24 -0
  115. package/dist/verification-plan.d.ts.map +1 -0
  116. package/dist/verification-plan.js +116 -0
  117. package/dist/verification-plan.js.map +1 -0
  118. package/dist/work-graph.d.ts.map +1 -1
  119. package/dist/work-graph.js +6 -3
  120. package/dist/work-graph.js.map +1 -1
  121. package/docs/examples.json +46 -4
  122. package/docs/llms-full.txt +1346 -28
  123. package/docs/types.json +4185 -87
  124. package/package.json +9 -9
  125. package/src/assure-hydration.ts +6 -1
  126. package/src/assure.ts +4 -2
  127. package/src/check-diagnostics.ts +1219 -0
  128. package/src/check-scan.ts +1825 -0
  129. package/src/check.ts +47 -2752
  130. package/src/cli.ts +252 -10
  131. package/src/command-catalog.ts +155 -0
  132. package/src/env-file.ts +5 -5
  133. package/src/frontend-guidance.ts +259 -0
  134. package/src/levels-tool.ts +6 -0
  135. package/src/load.ts +4 -1
  136. package/src/mcp-context.ts +266 -0
  137. package/src/mcp-docs-tools.ts +37 -0
  138. package/src/mcp-exec.ts +1040 -0
  139. package/src/mcp-http.ts +1 -1
  140. package/src/mcp-io.ts +118 -0
  141. package/src/mcp-reflect.ts +140 -0
  142. package/src/mcp-root.ts +62 -4
  143. package/src/mcp.ts +61 -1511
  144. package/src/openapi-tool.ts +32 -2
  145. package/src/openapi-types.ts +261 -0
  146. package/src/port.ts +2 -1
  147. package/src/project-facts.ts +2 -4
  148. package/src/release-verification.ts +22 -164
  149. package/src/rules/codes.ts +4 -0
  150. package/src/rules/islands.ts +171 -0
  151. package/src/rules/nano.ts +295 -0
  152. package/src/rules/security.ts +40 -4
  153. package/src/scaffold.ts +169 -9
  154. package/src/search-terms.ts +7 -3
  155. package/src/stylex-migrate.ts +1139 -0
  156. package/src/sync-manifest.ts +2 -1
  157. package/src/verification-plan.ts +242 -0
  158. package/src/work-graph.ts +6 -3
package/src/check.ts CHANGED
@@ -16,1832 +16,57 @@
16
16
  * anything fails. Pure scanners (`scanFetchText`, `scanServerOnlyImports`) are unit-tested.
17
17
  */
18
18
 
19
- import { existsSync, readFileSync, realpathSync } from "node:fs"
20
- import { dirname, isAbsolute, join, resolve, sep } from "node:path"
21
- import type { AssuranceConfig, AssuranceReport } from "@nifrajs/core/assurance"
22
- import { type ProjectEvidenceSnapshot, snapshotProjectEvidence } from "@nifrajs/core/evidence"
23
- import { Glob } from "bun"
24
- import type * as TSApi from "typescript"
25
- import type { CapabilityProjectReport } from "./capabilities-tool.ts"
26
- import { type Diagnostic, diagnostic } from "./diagnostics.ts"
27
- import { createSourceFacts, type SourceFacts } from "./internal/source-facts.ts"
19
+ import { existsSync, readFileSync } from "node:fs"
20
+ import { dirname, isAbsolute, join } from "node:path"
21
+ import {
22
+ type CheckAssuranceContext,
23
+ type CheckConfig,
24
+ type CheckDiagnostic,
25
+ type CheckResult,
26
+ collectCheckDiagnostics,
27
+ type RuleOverride,
28
+ } from "./check-diagnostics.ts"
29
+ import { createSourceFacts } from "./internal/source-facts.ts"
28
30
  import { importProjectTypeScript, type TypeScriptApi } from "./internal/typescript-import.ts"
29
31
  // Type-only: `pipeline-report.ts` imports this module's source scanners, so a value import here would
30
32
  // close a cycle. Doctor is what actually runs the collector (see the `pipeline` rule below).
31
- import type { PipelineReport } from "./pipeline-report.ts"
32
- import { freezeProjectFacts, type ProjectFactsSeed } from "./project-facts.ts"
33
+ import type { ProjectFactsSeed } from "./project-facts.ts"
33
34
  import { RULE_CODES } from "./rules/codes.ts"
34
- import { commentBlockMarkerReason } from "./rules/comment-markers.ts"
35
- import { parseRulePacks, type RuleContext, runRuleRegistry, sourceIndex } from "./rules/index.ts"
36
- import { LEGACY_RULE_CODES, legacyRules } from "./rules/legacy.ts"
37
- import { routeRules } from "./rules/routes.ts"
38
- import { securityRules } from "./rules/security.ts"
35
+ import { sourceIndex } from "./rules/index.ts"
36
+
37
+ export type {
38
+ CheckAnalysisInput,
39
+ CheckAssuranceContext,
40
+ CheckConfig,
41
+ CheckDiagnostic,
42
+ CheckDiagnosticsOptions,
43
+ CheckResult,
44
+ CheckSuggestion,
45
+ CheckTypecheckResult,
46
+ RuleOverride,
47
+ } from "./check-diagnostics.ts"
48
+
49
+ import {
50
+ createProjectSqlImports,
51
+ type ModuleReader,
52
+ type ModuleResolver,
53
+ resolveServerOnlyChains,
54
+ type SourceFinding,
55
+ type StaticRouteFinding,
56
+ scanFetchText,
57
+ scanInterpolatedSql,
58
+ scanRemovedImports,
59
+ scanResponseRoutes,
60
+ scanServerManifestDrift,
61
+ scanStaticRouteText,
62
+ scanUntypedClient,
63
+ type TransitiveServerImportFinding,
64
+ walkSource,
65
+ } from "./check-scan.ts"
66
+
67
+ export * from "./check-scan.ts"
39
68
 
40
- export interface SourceFinding {
41
- readonly file: string
42
- readonly line: number
43
- readonly snippet: string
44
- }
45
- /** A server-only-import finding, carrying the offending module specifier on top of the base location so
46
- * the diagnostic can show the import chain `routeFile → specifier` (the direct edge the regex scan sees). */
47
- export interface ServerImportFinding extends SourceFinding {
48
- /** The server-only module specifier the route top-level-imports (e.g. `pg`, `node:fs`, `../db`). */
49
- readonly specifier: string
50
- }
51
-
52
- // `fetch( <ws> ('|"|`) / (not /)` - a string/template arg starting with a single `/` is a relative URL,
53
- // i.e. same-origin = this app's own API. `(?<![.\w])` skips `.fetch(` (a method) and `prefetch(`; the
54
- // `(?!\/)` skips protocol-relative `//host` (an external origin). A variable arg (`fetch(url)`) is left
55
- // alone on purpose - undecidable from source, and flagging it would punish legitimate external calls.
56
- const GLOBAL_FETCH_CALL = /(?<![.\w])fetch\s*\(/g
57
- const FETCH_CALL = /(?<![.\w])fetch\s*\(/g
58
- const HTTP_VERBS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"])
59
- const SIMPLE_REWRITE_METHODS = new Set(["GET", "DELETE", "HEAD", "OPTIONS"])
60
-
61
- // Don't scan deps, build output, or generated client entries. `dist(-<runtime>)?` also covers
62
- // per-runtime output dirs (dist-bun/dist-node/dist-deno/dist-vercel). Never source, for any scan.
63
- const IGNORED_DIR =
64
- /(^|\/)(node_modules|dist(-[a-z0-9]+)?|build|\.nifra|\.git|\.wrangler|coverage)\//
65
- // A test/spec module. Excluded from `nifra check`'s scans, which are about what SHIPS - a test
66
- // legitimately drives `fetch`, hand-rolls a client, and calls a route directly. It is NOT excluded from
67
- // `nifra doctor`: tsc typechecks tests, so an import a test declares nowhere is a real broken build.
68
- const TEST_FILE = /\.(test|spec)\.[cm]?[jt]sx?$/
69
- const IGNORED = new RegExp(`${IGNORED_DIR.source}|${TEST_FILE.source}`)
70
-
71
- // A file under `routes/` - a page module bundled for the browser, where a server-only import is unsafe.
72
69
  const ROUTE_FILE = /(^|\/)routes\//
73
-
74
- // Module specifiers that must never be VALUE-imported into a route module: node:/bun: builtins, common
75
- // DB drivers/ORМ server entrypoints, and the conventional `./db` module the scaffold generates.
76
- const SERVER_ONLY =
77
- /^(?: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?)?$/
78
-
79
- // A static, non-type import with a string specifier. `import type …` is erased at build, so it's safe
80
- // and skipped. Dynamic `import(…)` (the correct way to lazy-load server code in a loader) has `(` right
81
- // after `import`, so `import\s+` never matches it.
82
- const STATIC_IMPORT = /\bimport\s+(?!type\b)(?:[^'"();]*?\bfrom\s+)?['"]([^'"]+)['"]/g
83
-
84
- const ROUTE_REGISTRATION_DQ = /\.([A-Za-z]+)\s*\(\s*"((?:\\.|[^"\\])*)"/g
85
- const ROUTE_REGISTRATION_SQ = /\.([A-Za-z]+)\s*\(\s*'((?:\\.|[^'\\])*)'/g
86
- const IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/
87
-
88
- export interface StaticRouteFinding extends SourceFinding {
89
- readonly method: string
90
- readonly path: string
91
- }
92
-
93
- interface SimpleFetchCall {
94
- readonly path: string
95
- readonly method: string
96
- readonly start: number
97
- readonly end: number
98
- }
99
-
100
- /** Line number (1-based) of a match index within `content`. */
101
- const lineAt = (content: string, index: number): number =>
102
- content.slice(0, index).split("\n").length
103
-
104
- function parseQuotedLiteral(raw: string): string | undefined {
105
- try {
106
- return JSON.parse(raw) as string
107
- } catch {
108
- if (!raw.startsWith("'") || !raw.endsWith("'")) return undefined
109
- try {
110
- // Convert the single-quoted body to a JSON string body by scan, handling backslash pairs
111
- // atomically: `\'` becomes `'`, other escapes pass through untouched, and a bare `"` is
112
- // escaped. A quote-only `replace` mis-handles content with backslashes (double-escaping
113
- // an already-escaped quote, or letting a trailing backslash swallow the added escape).
114
- const inner = raw.slice(1, -1)
115
- let body = ""
116
- for (let i = 0; i < inner.length; i++) {
117
- const ch = inner[i] as string
118
- if (ch === "\\" && i + 1 < inner.length) {
119
- const next = inner[i + 1] as string
120
- body += next === "'" ? "'" : ch + next
121
- i++
122
- } else if (ch === '"') {
123
- body += '\\"'
124
- } else {
125
- body += ch
126
- }
127
- }
128
- return JSON.parse(`"${body}"`) as string
129
- } catch {
130
- return undefined
131
- }
132
- }
133
- }
134
-
135
- function findMatchingParen(src: string, openIndex: number): number | undefined {
136
- let depth = 0
137
- let quote: '"' | "'" | "`" | undefined
138
- for (let i = openIndex; i < src.length; i++) {
139
- const c = src[i]
140
- if (quote !== undefined) {
141
- if (c === "\\") {
142
- i++
143
- continue
144
- }
145
- if (c === quote) quote = undefined
146
- continue
147
- }
148
- if (c === '"' || c === "'" || c === "`") {
149
- quote = c
150
- continue
151
- }
152
- if (c === "(") depth++
153
- else if (c === ")") {
154
- depth--
155
- if (depth === 0) return i
156
- }
157
- }
158
- return undefined
159
- }
160
-
161
- function splitTopLevelArgs(src: string): string[] | undefined {
162
- const args: string[] = []
163
- let start = 0
164
- let depth = 0
165
- let quote: '"' | "'" | "`" | undefined
166
- for (let i = 0; i < src.length; i++) {
167
- const c = src[i]
168
- if (quote !== undefined) {
169
- if (c === "\\") {
170
- i++
171
- continue
172
- }
173
- if (c === quote) quote = undefined
174
- continue
175
- }
176
- if (c === '"' || c === "'" || c === "`") {
177
- quote = c
178
- continue
179
- }
180
- if (c === "(" || c === "{" || c === "[") depth++
181
- else if (c === ")" || c === "}" || c === "]") depth--
182
- else if (c === "," && depth === 0) {
183
- args.push(src.slice(start, i).trim())
184
- start = i + 1
185
- }
186
- }
187
- if (quote !== undefined || depth !== 0) return undefined
188
- const tail = src.slice(start).trim()
189
- if (tail !== "") args.push(tail)
190
- return args
191
- }
192
-
193
- function parseMethodOnlyInit(raw: string | undefined): string | undefined {
194
- if (raw === undefined) return "GET"
195
- const init = raw.trim()
196
- if (!/^\{[\s\S]*\}$/.test(init)) return undefined
197
- const body = init.slice(1, -1).trim()
198
- if (body === "") return "GET"
199
- const m = /^(?:"method"|'method'|method)\s*:\s*(["'])([A-Za-z]+)\1\s*,?$/.exec(body)
200
- if (!m) return undefined
201
- const method = (m[2] ?? "").toUpperCase()
202
- return HTTP_VERBS.has(method) ? method : undefined
203
- }
204
-
205
- function parseSimpleFetchCall(snippet: string): SimpleFetchCall | undefined {
206
- FETCH_CALL.lastIndex = 0
207
- const matches = [...snippet.matchAll(FETCH_CALL)]
208
- if (matches.length !== 1) return undefined
209
- const match = matches[0]
210
- if (match === undefined || match.index === undefined) return undefined
211
- const open = match.index + match[0].lastIndexOf("(")
212
- const close = findMatchingParen(snippet, open)
213
- if (close === undefined) return undefined
214
-
215
- const args = splitTopLevelArgs(snippet.slice(open + 1, close))
216
- if (args === undefined || args.length === 0 || args.length > 2) return undefined
217
- const first = args[0] ?? ""
218
- const quote = first[0]
219
- if ((quote !== '"' && quote !== "'") || first[first.length - 1] !== quote) return undefined
220
- const path = parseQuotedLiteral(first)
221
- if (
222
- path === undefined ||
223
- !path.startsWith("/") ||
224
- path.startsWith("//") ||
225
- path.includes("?") ||
226
- path.includes("#")
227
- ) {
228
- return undefined
229
- }
230
- const method = parseMethodOnlyInit(args[1])
231
- if (method === undefined) return undefined
232
- return { path, method, start: match.index, end: close + 1 }
233
- }
234
-
235
- function isPotentialBackendSource(code: string): boolean {
236
- return (
237
- code.includes("@nifrajs/core") ||
238
- /(?<![.\w])server\s*\(/.test(code) ||
239
- /(?:from\s*|import\s*)["']hono["']/.test(code) ||
240
- /new\s+Hono(?:<[^>]+>)?\s*\(/.test(code)
241
- )
242
- }
243
-
244
- function scanRoutePattern(
245
- file: string,
246
- content: string,
247
- code: string,
248
- pattern: RegExp,
249
- facts?: SourceFacts,
250
- ): StaticRouteFinding[] {
251
- const out: StaticRouteFinding[] = []
252
- const lines = content.split("\n")
253
- pattern.lastIndex = 0
254
- for (let m = pattern.exec(code); m !== null; m = pattern.exec(code)) {
255
- const method = (m[1] ?? "").toUpperCase()
256
- if (!HTTP_VERBS.has(method)) continue
257
- const path = parseQuotedLiteral(
258
- `${pattern === ROUTE_REGISTRATION_DQ ? '"' : "'"}${m[2] ?? ""}${pattern === ROUTE_REGISTRATION_DQ ? '"' : "'"}`,
259
- )
260
- if (path === undefined || !path.startsWith("/") || path.startsWith("//")) continue
261
- if (facts !== undefined) {
262
- const source = facts.parse(file, content)
263
- // A parseable source model is authoritative for candidate locations. If parsing is unavailable
264
- // or the source is malformed, retain the lexical finding and fail closed.
265
- if (
266
- source !== undefined &&
267
- facts.isRouteRegistrationAt(source, m.index, method, path) !== true
268
- )
269
- continue
270
- }
271
- const line = lineAt(content, m.index)
272
- out.push({ file, line, snippet: (lines[line - 1] ?? "").trim(), method, path })
273
- }
274
- return out
275
- }
276
-
277
- /**
278
- * Blank, with spaces (newlines preserved, so every byte offset - and line number - is unchanged):
279
- * - `//` line comments and block comments;
280
- * - the CONTENTS of backtick template literals - code-as-text (doc `CodeBlock` examples, code
281
- * generators), never a real statement to lint.
282
- * Single/double-quoted strings are KEPT (a real import/Response specifier lives in one). A small
283
- * char-state machine, not a full lexer: it doesn't model regex literals, so a regex containing a quote
284
- * could mis-skip - in practice the constructs these scanners look for sit before any such regex.
285
- * Shared by the source scanners here and by `nifra doctor` ({@link ./doctor.ts}).
286
- */
287
- export function stripComments(src: string): string {
288
- const out = src.split("")
289
- const n = src.length
290
- let i = 0
291
- const blank = (a: number, b: number): void => {
292
- for (let k = a; k < b; k++) if (out[k] !== "\n") out[k] = " "
293
- }
294
- while (i < n) {
295
- const c = src[i]
296
- const d = src[i + 1]
297
- if (c === "/" && d === "/") {
298
- let j = i + 2
299
- while (j < n && src[j] !== "\n") j++
300
- blank(i, j)
301
- i = j
302
- } else if (c === "/" && d === "*") {
303
- let j = i + 2
304
- while (j < n && !(src[j] === "*" && src[j + 1] === "/")) j++
305
- j = Math.min(j + 2, n)
306
- blank(i, j)
307
- i = j
308
- } else if (c === "`") {
309
- let j = i + 1
310
- while (j < n && src[j] !== "`") {
311
- if (src[j] === "\\") j++
312
- j++
313
- }
314
- blank(i + 1, j) // keep the backticks, blank the code-as-text inside
315
- i = j + 1
316
- } else if (c === "'" || c === '"') {
317
- let j = i + 1
318
- while (j < n && src[j] !== c) {
319
- if (src[j] === "\\") j++ // skip the escaped char
320
- j++
321
- }
322
- i = j + 1
323
- } else {
324
- i++
325
- }
326
- }
327
- return out.join("")
328
- }
329
-
330
- /** Blank comments and every quoted/template literal while preserving offsets. Unlike
331
- * {@link stripComments}, this is a code-position mask: scanners use it to find a call expression in
332
- * executable code, then inspect the original source for the call's literal argument. That prevents
333
- * documentation strings such as `const example = 'client("/")'` from becoming diagnostics. */
334
- export function codePositionMask(src: string): string {
335
- const out = src.split("")
336
- const n = src.length
337
- let i = 0
338
- const blank = (a: number, b: number): void => {
339
- for (let k = a; k < b; k++) if (out[k] !== "\n") out[k] = " "
340
- }
341
- while (i < n) {
342
- const c = src[i]
343
- const d = src[i + 1]
344
- if (c === "/" && d === "/") {
345
- let j = i + 2
346
- while (j < n && src[j] !== "\n") j++
347
- blank(i, j)
348
- i = j
349
- } else if (c === "/" && d === "*") {
350
- let j = i + 2
351
- while (j < n && !(src[j] === "*" && src[j + 1] === "/")) j++
352
- j = Math.min(j + 2, n)
353
- blank(i, j)
354
- i = j
355
- } else if (c === "'" || c === '"' || c === "`") {
356
- const quote = c
357
- let j = i + 1
358
- while (j < n && src[j] !== quote) {
359
- if (src[j] === "\\") j++
360
- j++
361
- }
362
- j = Math.min(j + 1, n)
363
- blank(i, j)
364
- i = j
365
- } else {
366
- i++
367
- }
368
- }
369
- return out.join("")
370
- }
371
-
372
- const firstArgumentIndex = (content: string, callEnd: number): number => {
373
- let index = callEnd
374
- while (/\s/.test(content[index] ?? "")) index++
375
- return index
376
- }
377
-
378
- // Read the literal URL path of a `fetch("/…")` call starting at `start` (the index of the leading `/`),
379
- // stopping at the first quote / query / hash / template-expression boundary, and test it against the
380
- // allowlist. A template like `/auth/${id}` matches on its literal head (`/auth`), so a dynamic sign-in URL
381
- // is covered too. Match is segment-anchored - `/auth` blesses `/auth` and `/auth/**`, never `/authors`.
382
- function matchesExternalMount(content: string, start: number, mounts: readonly string[]): boolean {
383
- let end = start
384
- while (end < content.length) {
385
- const ch = content[end]
386
- if (ch === "'" || ch === '"' || ch === "`" || ch === "?" || ch === "#") break
387
- if (ch === "$" && content[end + 1] === "{") break
388
- end++
389
- }
390
- const path = content.slice(start, end)
391
- // A `..` segment means the runtime path escapes the literal prefix (`/auth/../api/admin` resolves to
392
- // `/api/admin`), so it must NOT be blessed by an `/auth` mount - never let traversal hide a real own-API
393
- // fetch. Match is otherwise segment-anchored (`/auth` blesses `/auth` and `/auth/**`, never `/authors`).
394
- try {
395
- if (path.split("/").some((segment) => decodeURIComponent(segment) === "..")) return false
396
- } catch {
397
- // A malformed escape is not a prefix we can prove stays inside the external mount.
398
- return false
399
- }
400
- return mounts.some((prefix) => path === prefix || path.startsWith(`${prefix}/`))
401
- }
402
-
403
- function findTopLevelPropertyColon(src: string): number | undefined {
404
- let depth = 0
405
- let quote: '"' | "'" | "`" | undefined
406
- for (let i = 0; i < src.length; i++) {
407
- const c = src[i]
408
- if (quote !== undefined) {
409
- if (c === "\\") {
410
- i++
411
- continue
412
- }
413
- if (c === quote) quote = undefined
414
- continue
415
- }
416
- if (c === '"' || c === "'" || c === "`") {
417
- quote = c
418
- continue
419
- }
420
- if (c === "(" || c === "{" || c === "[") depth++
421
- else if (c === ")" || c === "}" || c === "]") depth--
422
- else if (c === ":" && depth === 0) return i
423
- }
424
- return undefined
425
- }
426
-
427
- function parseStaticPropertyName(raw: string): string | undefined {
428
- const name = raw.trim()
429
- if (IDENT.test(name)) return name
430
- const quoted = parseQuotedLiteral(name)
431
- if (quoted !== undefined) return quoted
432
- if (name.startsWith("[") && name.endsWith("]")) {
433
- return parseQuotedLiteral(name.slice(1, -1).trim())
434
- }
435
- return undefined
436
- }
437
-
438
- /** Return a statically named property from a plain object literal. Deliberately does not follow
439
- * spreads, identifiers, or helper calls: only an inline literal proves this fetch is Nifra's page-data
440
- * transport rather than an arbitrary same-origin API request. */
441
- function staticObjectProperty(raw: string | undefined, name: string): string | undefined {
442
- if (raw === undefined) return undefined
443
- const object = raw.trim()
444
- if (!object.startsWith("{") || !object.endsWith("}")) return undefined
445
- const properties = splitTopLevelArgs(object.slice(1, -1))
446
- if (properties === undefined) return undefined
447
- for (const property of properties) {
448
- const colon = findTopLevelPropertyColon(property)
449
- if (colon === undefined || parseStaticPropertyName(property.slice(0, colon)) !== name) continue
450
- return property.slice(colon + 1).trim()
451
- }
452
- return undefined
453
- }
454
-
455
- function hasLiteralNifraDataHeader(content: string, openParen: number): boolean {
456
- const closeParen = findMatchingParen(content, openParen)
457
- if (closeParen === undefined) return false
458
- const args = splitTopLevelArgs(content.slice(openParen + 1, closeParen))
459
- if (args === undefined || args.length !== 2) return false
460
- const headers = staticObjectProperty(args[1], "headers")
461
- return staticObjectProperty(headers, "x-nifra-data") !== undefined
462
- }
463
-
464
- /** Scan one file's text for hand-rolled own-API `fetch()` calls. Pure + line-accurate. `externalMounts`
465
- * are intentional non-typed mount prefixes (from `nifra.check.json`, e.g. a mounted better-auth owning
466
- * `/auth/**`) - a relative fetch into one is deliberate, not drift, so it's skipped. A fetch with an
467
- * inline literal `x-nifra-data` header is Nifra's page-loader transport, not a backend API call, and is
468
- * skipped too. */
469
- export function scanFetchText(
470
- file: string,
471
- content: string,
472
- externalMounts: readonly string[] = [],
473
- ): SourceFinding[] {
474
- const out: SourceFinding[] = []
475
- const lines = content.split("\n")
476
- const code = codePositionMask(content)
477
- GLOBAL_FETCH_CALL.lastIndex = 0
478
- for (let m = GLOBAL_FETCH_CALL.exec(code); m !== null; m = GLOBAL_FETCH_CALL.exec(code)) {
479
- const argument = firstArgumentIndex(content, m.index + m[0].length)
480
- const quote = content[argument]
481
- if (quote !== "'" && quote !== '"' && quote !== "`") continue
482
- if (content[argument + 1] !== "/" || content[argument + 2] === "/") continue
483
- const openParen = m.index + m[0].lastIndexOf("(")
484
- if (hasLiteralNifraDataHeader(content, openParen)) continue
485
- if (externalMounts.length > 0 && matchesExternalMount(content, argument + 1, externalMounts))
486
- continue
487
- const line = lineAt(content, m.index)
488
- out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() })
489
- }
490
- return out
491
- }
492
-
493
- /** Statically collect simple Nifra route registrations from source, without importing app code. */
494
- export function scanStaticRouteText(
495
- file: string,
496
- content: string,
497
- facts?: SourceFacts,
498
- ): StaticRouteFinding[] {
499
- const code = stripComments(content)
500
- if (!isPotentialBackendSource(code)) return []
501
- return [
502
- ...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_DQ, facts),
503
- ...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_SQ, facts),
504
- ].sort(bySite)
505
- }
506
-
507
- /**
508
- * SQL built by interpolating a value into the statement text. The value is not a parameter, it is
509
- * STATEMENT, so anything the caller controls can end the literal and continue as SQL.
510
- *
511
- * Two things this deliberately does not flag, because both are safe and flagging them would train
512
- * people to ignore the rule:
513
- *
514
- * - A TAGGED template - ``sql`... ${id} ...` `` in postgres.js, drizzle or kysely. The tag receives the
515
- * substitutions separately and binds them; that is the parameterised form, not a bypass of it.
516
- * - A literal with no substitution at all. `db.query("SELECT … WHERE id = ?")` is the shape being
517
- * argued for.
518
- *
519
- * A SQL keyword is required in the literal, so `cache.query(`user:${id}`)` stays quiet. The named
520
- * escape hatches (`$queryRawUnsafe`, `sql.unsafe`) are flagged on the call alone: their whole purpose
521
- * is to take a statement as text, and a substitution in one is the exact thing the name warns about.
522
- */
523
- const SQL_METHODS = new Set([
524
- "query",
525
- "queryRaw",
526
- "prepare",
527
- "exec",
528
- "execute",
529
- "run",
530
- "unsafe",
531
- "$queryRawUnsafe",
532
- "$executeRawUnsafe",
533
- "raw",
534
- ])
535
- const SQL_KEYWORD =
536
- /\b(select|insert\s+into|update|delete\s+from|drop\s+table|create\s+table|alter\s+table|truncate|from|where|values|set|join|union)\b/i
537
- const ALWAYS_UNSAFE = new Set(["unsafe", "$queryRawUnsafe", "$executeRawUnsafe", "raw"])
538
-
539
- interface SqlExpressionShape {
540
- readonly staticText: string
541
- readonly dynamic: boolean
542
- readonly concatenated: boolean
543
- readonly literal: boolean
544
- readonly parameterizedTag: boolean
545
- }
546
-
547
- /** Module-scope `const` initializers by name - the one binding form the SQL scanner resolves. */
548
- type ModuleConsts = ReadonlyMap<string, TSApi.Expression>
549
-
550
- /** A name some other module exports, named exactly as the edge that reaches it writes it. */
551
- interface ExportEdge {
552
- readonly specifier: string
553
- readonly exported: string
554
- }
555
-
556
- /**
557
- * One module's provable module-scope bindings: its top-level `const` initializers plus the exact
558
- * import/export edges this scan is allowed to follow.
559
- *
560
- * Everything absent from these maps stays unresolved, and an unresolved interpolation keeps flagging.
561
- * That is the whole safety argument: resolution can only ever make this rule QUIETER, so a form the
562
- * scan cannot prove must fall out as "unknown" rather than be guessed at. Deliberately absent:
563
- * `export default`, `export let`/`var`, destructured or computed names, default and namespace imports,
564
- * `export * as ns`, `require`, and dynamic `import()`.
565
- */
566
- interface SqlModuleScope {
567
- /** The module's identity - an absolute path from the loader, or the entry file's path as given. Keys
568
- * the parsed-scope cache and the cycle guard, and is the base its own specifiers resolve against. */
569
- readonly id: string
570
- readonly consts: ModuleConsts
571
- /** Local name bound by `import { X as local }` → the edge that provides it. */
572
- readonly imports: ReadonlyMap<string, ExportEdge>
573
- /** Exported name → the local name it reads (`export { a as b }` → `b` → `a`). */
574
- readonly localExports: ReadonlyMap<string, string>
575
- /** Exported name → another module's export (`export { a as b } from "./m"`). */
576
- readonly reExports: ReadonlyMap<string, ExportEdge>
577
- /** `export * from "./m"` specifiers, in source order. */
578
- readonly starExports: readonly string[]
579
- }
580
-
581
- /** Read one module's top-level `const` bindings and its import/export edges. Pure syntax - no checker,
582
- * no filesystem. Destructured names, `let`, `var`, and anything nested stay out on purpose: a
583
- * module-scope `const` is the only form that provably cannot carry request data. */
584
- function sqlModuleScope(ts: TypeScriptApi, id: string, source: TSApi.SourceFile): SqlModuleScope {
585
- const consts = new Map<string, TSApi.Expression>()
586
- const imports = new Map<string, ExportEdge>()
587
- const localExports = new Map<string, string>()
588
- const reExports = new Map<string, ExportEdge>()
589
- const starExports: string[] = []
590
- for (const statement of source.statements) {
591
- if (ts.isVariableStatement(statement)) {
592
- if ((statement.declarationList.flags & ts.NodeFlags.Const) === 0) continue
593
- const exported =
594
- statement.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) === true
595
- for (const declaration of statement.declarationList.declarations) {
596
- if (!ts.isIdentifier(declaration.name) || declaration.initializer === undefined) continue
597
- consts.set(declaration.name.text, declaration.initializer)
598
- if (exported) localExports.set(declaration.name.text, declaration.name.text)
599
- }
600
- continue
601
- }
602
- if (ts.isImportDeclaration(statement)) {
603
- const clause = statement.importClause
604
- if (clause === undefined || clause.isTypeOnly) continue
605
- if (!ts.isStringLiteral(statement.moduleSpecifier)) continue
606
- const bindings = clause.namedBindings
607
- // Only `import { X }` / `import { X as local }`. A default or namespace import binds a value
608
- // whose contents this scan cannot prove, so it stays unbound and every use of it flags.
609
- if (bindings === undefined || !ts.isNamedImports(bindings)) continue
610
- for (const element of bindings.elements) {
611
- if (element.isTypeOnly) continue
612
- imports.set(element.name.text, {
613
- specifier: statement.moduleSpecifier.text,
614
- exported: element.propertyName?.text ?? element.name.text,
615
- })
616
- }
617
- continue
618
- }
619
- if (!ts.isExportDeclaration(statement) || statement.isTypeOnly) continue
620
- const from = statement.moduleSpecifier
621
- if (from !== undefined && !ts.isStringLiteral(from)) continue
622
- const clause = statement.exportClause
623
- if (clause === undefined) {
624
- // No clause and a specifier is `export * from "./m"`. `export * as ns from "./m"` carries a
625
- // NamespaceExport clause instead and falls out below, unresolved, which is what we want.
626
- if (from !== undefined) starExports.push(from.text)
627
- continue
628
- }
629
- if (!ts.isNamedExports(clause)) continue
630
- for (const element of clause.elements) {
631
- if (element.isTypeOnly) continue
632
- const local = element.propertyName?.text ?? element.name.text
633
- if (from === undefined) localExports.set(element.name.text, local)
634
- else reExports.set(element.name.text, { specifier: from.text, exported: local })
635
- }
636
- }
637
- return { id, consts, imports, localExports, reExports, starExports }
638
- }
639
-
640
- /** A module's identity + source, as resolved from a specifier. `id` must be stable and absolute: it
641
- * keys the parsed-scope cache, seeds the cycle guard, and is what the module's own relative specifiers
642
- * resolve against. */
643
- export interface SqlModuleSource {
644
- readonly id: string
645
- readonly content: string
646
- }
647
-
648
- /**
649
- * Resolves a specifier written in the module `fromModule` to the target's identity + source, or
650
- * `undefined` when it cannot be resolved, read, or proved to be in-project source.
651
- *
652
- * Injected rather than built in, for the same reason {@link ModuleResolver} is: it keeps
653
- * {@link scanInterpolatedSql} pure and filesystem-free, so the resolution rules can be unit-tested
654
- * against a virtual module graph. {@link createProjectSqlImports} is the on-disk implementation.
655
- */
656
- export type SqlModuleLoader = (fromModule: string, specifier: string) => SqlModuleSource | undefined
657
-
658
- /** Cross-module resolution state for one run: the loader plus a parsed-scope cache, so a fragments
659
- * module imported by fifty callers is read and parsed once. A cached `undefined` is a REFUSAL (the
660
- * module did not parse), cached so it is not re-attempted. Build with {@link createSqlImports}. */
661
- export interface SqlImports {
662
- readonly load: SqlModuleLoader
663
- readonly scopes: Map<string, SqlModuleScope | undefined>
664
- }
665
-
666
- export const createSqlImports = (load: SqlModuleLoader): SqlImports => ({
667
- load,
668
- scopes: new Map(),
669
- })
670
-
671
- /** Where an expression's identifiers are read: the module the expression physically lives in, plus the
672
- * run's cross-module state. `imports === undefined` is same-file-only resolution - the behavior before
673
- * imported fragments were followed, and still what an unconfigured `scanInterpolatedSql` call does. */
674
- interface SqlScope {
675
- readonly module: SqlModuleScope
676
- readonly imports: SqlImports | undefined
677
- }
678
-
679
- /** Parse a module for the SQL scan, or `undefined` when it does not parse. Malformed source is already
680
- * a typecheck failure; parser recovery nodes are not evidence of anything, so a file that fails to
681
- * parse contributes no findings and proves no constant. */
682
- function parseSqlSource(
683
- ts: TypeScriptApi,
684
- file: string,
685
- content: string,
686
- ): TSApi.SourceFile | undefined {
687
- const kind = /\.[cm]?tsx?$/.test(file) ? ts.ScriptKind.TSX : ts.ScriptKind.JS
688
- const source = ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true, kind)
689
- const parseDiagnostics = (
690
- source as TSApi.SourceFile & { readonly parseDiagnostics?: readonly TSApi.Diagnostic[] }
691
- ).parseDiagnostics
692
- return parseDiagnostics !== undefined && parseDiagnostics.length > 0 ? undefined : source
693
- }
694
-
695
- /** Module-boundary hops one name may cross. Barrels are why it is not 1: `route → index → fragments`
696
- * already spends two, and the third leaves room for one more layer without letting the scan wander the
697
- * module graph. Past the cap the name is unresolved, so the interpolation flags. */
698
- const SQL_IMPORT_HOPS = 3
699
-
700
- /** Load + parse the module a specifier names, through the run's cache. */
701
- function loadSqlModuleScope(
702
- ts: TypeScriptApi,
703
- imports: SqlImports,
704
- fromModule: string,
705
- specifier: string,
706
- ): SqlModuleScope | undefined {
707
- const loaded = imports.load(fromModule, specifier)
708
- if (loaded === undefined) return undefined
709
- if (imports.scopes.has(loaded.id)) return imports.scopes.get(loaded.id)
710
- const source = parseSqlSource(ts, loaded.id, loaded.content)
711
- const scope = source === undefined ? undefined : sqlModuleScope(ts, loaded.id, source)
712
- imports.scopes.set(loaded.id, scope)
713
- return scope
714
- }
715
-
716
- /** One exported name resolved to the `const` initializer behind it, together with the scope that
717
- * initializer's OWN identifiers must be read in. */
718
- interface ResolvedExport {
719
- readonly expression: TSApi.Expression
720
- readonly scope: SqlModuleScope
721
- }
722
-
723
- /**
724
- * Follow an exported name to its `const` initializer, across re-export hops.
725
- *
726
- * Handles the three forms a fragments module actually uses: `export const NAME = …`, the two-statement
727
- * `const NAME = …; export { NAME }` (including the pass-through `import { A } …; export { A }`), and
728
- * barrels - `export { X } from "./m"` and `export * from "./m"`. Bounded by {@link SQL_IMPORT_HOPS} and
729
- * a per-chain visited set, so a cyclic barrel terminates instead of recursing.
730
- *
731
- * Two `export *` sources providing the same name is a conflict, and the scan refuses rather than
732
- * picking one: guessing here would silence a real finding.
733
- */
734
- function resolveExportedConst(
735
- ts: TypeScriptApi,
736
- imports: SqlImports,
737
- scope: SqlModuleScope,
738
- name: string,
739
- hops: number,
740
- seen: ReadonlySet<string>,
741
- ): ResolvedExport | undefined {
742
- if (hops > SQL_IMPORT_HOPS) return undefined
743
- const key = `${scope.id}#${name}`
744
- if (seen.has(key)) return undefined
745
- const nextSeen = new Set(seen).add(key)
746
- const follow = (edge: ExportEdge): ResolvedExport | undefined => {
747
- const target = loadSqlModuleScope(ts, imports, scope.id, edge.specifier)
748
- return target === undefined
749
- ? undefined
750
- : resolveExportedConst(ts, imports, target, edge.exported, hops + 1, nextSeen)
751
- }
752
- const local = scope.localExports.get(name)
753
- if (local !== undefined) {
754
- const initializer = scope.consts.get(local)
755
- if (initializer !== undefined) return { expression: initializer, scope }
756
- // `import { A } from "./a"; export { A }` - the same pass-through as `export { A } from "./a"`,
757
- // written in two statements. Anything else the name could be (a `let`, a function, a class) has no
758
- // initializer in `consts` and no import edge, so it lands on `undefined` and flags.
759
- const via = scope.imports.get(local)
760
- return via === undefined ? undefined : follow(via)
761
- }
762
- const reExported = scope.reExports.get(name)
763
- if (reExported !== undefined) return follow(reExported)
764
- let found: ResolvedExport | undefined
765
- for (const specifier of scope.starExports) {
766
- const hit = follow({ specifier, exported: name })
767
- if (hit === undefined) continue
768
- if (found !== undefined) return undefined
769
- found = hit
770
- }
771
- return found
772
- }
773
-
774
- /** Whether `name`, read at `at`, might be re-declared by anything between the read and module scope.
775
- * Conservative on purpose: the OUTERMOST enclosing function/block subtree is scanned for ANY binding
776
- * of the name (parameter, `var`/`let`/`const`, catch, local function, destructuring element) - so a
777
- * hoisted `var` in a sibling block, or a binding in a branch that never runs, still refuses
778
- * resolution. Over-refusal flags a safe line (the pre-feature behavior); under-refusal would resolve
779
- * the wrong binding, which is why the cheap-and-broad scan wins over scope simulation. A use with no
780
- * enclosing function or block reads module scope directly and cannot be shadowed. */
781
- function isShadowedAt(ts: TypeScriptApi, at: TSApi.Node, name: string): boolean {
782
- let scope: TSApi.Node | undefined
783
- for (let node: TSApi.Node | undefined = at.parent; node !== undefined; node = node.parent) {
784
- if (ts.isSourceFile(node)) break
785
- if (ts.isFunctionLike(node) || ts.isBlock(node)) scope = node
786
- }
787
- if (scope === undefined) return false
788
- const bindsName = (binding: TSApi.BindingName): boolean =>
789
- ts.isIdentifier(binding)
790
- ? binding.text === name
791
- : binding.elements.some(
792
- (element) =>
793
- !ts.isOmittedExpression(element) &&
794
- (ts.isIdentifier(element.name) ? element.name.text === name : bindsName(element.name)),
795
- )
796
- let found = false
797
- const scan = (node: TSApi.Node): void => {
798
- if (found) return
799
- if (
800
- (ts.isVariableDeclaration(node) || ts.isParameter(node)) &&
801
- bindsName(node.name as TSApi.BindingName)
802
- ) {
803
- found = true
804
- return
805
- }
806
- if (ts.isFunctionDeclaration(node) && node.name?.text === name) {
807
- found = true
808
- return
809
- }
810
- ts.forEachChild(node, scan)
811
- }
812
- scan(scope)
813
- return found
814
- }
815
-
816
- const CONST_RESOLUTION_DEPTH = 5
817
-
818
- /**
819
- * Resolve an interpolated expression to compile-time text, or `undefined` when it can carry runtime
820
- * data. Pure syntax, no type checker - the same honest limit the rule already documents. What
821
- * resolves: string/number literals, no-substitution templates, templates whose every span resolves,
822
- * ternaries with both branches resolvable (BOTH branch texts are returned, so a hostile keyword in
823
- * either still feeds the keyword scan), `as`/`satisfies` wrappers, a module-scope `const` whose
824
- * initializer resolves (recursively, depth-capped), and - when `scope.imports` is configured - such a
825
- * `const` imported by name from another in-project module. Everything else - `let`, a parameter, a
826
- * call, a member access, a shadowed name, a bare-specifier import - stays unresolved and the
827
- * interpolation is flagged exactly as before.
828
- */
829
- function resolveStaticSqlText(
830
- ts: TypeScriptApi,
831
- node: TSApi.Expression,
832
- scope: SqlScope,
833
- depth: number,
834
- ): string | undefined {
835
- if (depth > CONST_RESOLUTION_DEPTH) return undefined
836
- if (ts.isParenthesizedExpression(node)) {
837
- return resolveStaticSqlText(ts, node.expression, scope, depth)
838
- }
839
- if (ts.isAsExpression(node) || ts.isSatisfiesExpression(node)) {
840
- return resolveStaticSqlText(ts, node.expression, scope, depth)
841
- }
842
- if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text
843
- if (ts.isNumericLiteral(node)) return node.text
844
- if (ts.isTemplateExpression(node)) {
845
- let text = node.head.text
846
- for (const span of node.templateSpans) {
847
- const part = resolveStaticSqlText(ts, span.expression, scope, depth + 1)
848
- if (part === undefined) return undefined
849
- text += part + span.literal.text
850
- }
851
- return text
852
- }
853
- if (ts.isConditionalExpression(node)) {
854
- const whenTrue = resolveStaticSqlText(ts, node.whenTrue, scope, depth + 1)
855
- const whenFalse = resolveStaticSqlText(ts, node.whenFalse, scope, depth + 1)
856
- if (whenTrue === undefined || whenFalse === undefined) return undefined
857
- return `${whenTrue} ${whenFalse}`
858
- }
859
- if (ts.isIdentifier(node)) {
860
- const initializer = scope.module.consts.get(node.text)
861
- const edge = initializer === undefined ? scope.module.imports.get(node.text) : undefined
862
- // Membership first: `isShadowedAt` walks a subtree, and most identifiers in a query are neither a
863
- // module const nor an import binding.
864
- if (initializer === undefined && edge === undefined) return undefined
865
- // Shadowing is a property of where the identifier is WRITTEN, so one check is right on both sides
866
- // of an import hop: an initializer in the imported module sits at module scope with nothing above
867
- // it to shadow it, while a use inside a function in this file is still refused.
868
- if (isShadowedAt(ts, node, node.text)) return undefined
869
- if (initializer !== undefined) return resolveStaticSqlText(ts, initializer, scope, depth + 1)
870
- if (edge === undefined || scope.imports === undefined) return undefined
871
- const target = loadSqlModuleScope(ts, scope.imports, scope.module.id, edge.specifier)
872
- if (target === undefined) return undefined
873
- const exported = resolveExportedConst(ts, scope.imports, target, edge.exported, 1, new Set())
874
- if (exported === undefined) return undefined
875
- // The imported initializer's own identifiers read THAT module's consts, never this file's. A
876
- // caller that happens to have its own `const DIR` must not silently supply the fragment's `DIR`,
877
- // which is the one way cross-module resolution could resolve to text nobody wrote.
878
- return resolveStaticSqlText(
879
- ts,
880
- exported.expression,
881
- { module: exported.scope, imports: scope.imports },
882
- depth + 1,
883
- )
884
- }
885
- return undefined
886
- }
887
-
888
- /** A template's contribution to the shape: resolved span texts fold into `staticText` (so the
889
- * keyword scan still sees what a `const` carries); any unresolvable span keeps it `dynamic`. */
890
- function templateShape(
891
- ts: TypeScriptApi,
892
- template: TSApi.TemplateExpression,
893
- scope: SqlScope | undefined,
894
- ): { staticText: string; dynamic: boolean } {
895
- let staticText = template.head.text
896
- let dynamic = false
897
- for (const span of template.templateSpans) {
898
- const resolved =
899
- scope === undefined ? undefined : resolveStaticSqlText(ts, span.expression, scope, 0)
900
- if (resolved === undefined) dynamic = true
901
- else staticText += resolved
902
- staticText += span.literal.text
903
- }
904
- return { staticText, dynamic }
905
- }
906
-
907
- /** A same-file binding the argument resolver proved holds one expression: a `const`, or a `let` the
908
- * whole file never reassigns. `"shadow"` marks a nearer binding of the name we can NOT read through (a
909
- * parameter, `var`, destructured, uninitialized, reassigned `let`) - it stops the outward walk so a
910
- * farther, resolvable const of the same name is never read in its place. */
911
- type ResolvableBinding = { readonly initializer: TSApi.Expression } | "shadow"
912
-
913
- /** Whether `name` is written to anywhere in the file - a plain, compound, or `++`/`--` assignment to a
914
- * bare identifier of that name. A `let` that is is over-cautiously refused (it might be reassigned from
915
- * request data); a `const` never needs this. Over-refusal only ever leaves an interpolation flagged as
916
- * before, never the reverse. */
917
- function isReassignedInFile(ts: TypeScriptApi, source: TSApi.SourceFile, name: string): boolean {
918
- let found = false
919
- const visit = (node: TSApi.Node): void => {
920
- if (found) return
921
- if (
922
- ts.isBinaryExpression(node) &&
923
- node.operatorToken.kind >= ts.SyntaxKind.FirstAssignment &&
924
- node.operatorToken.kind <= ts.SyntaxKind.LastAssignment &&
925
- ts.isIdentifier(node.left) &&
926
- node.left.text === name
927
- ) {
928
- found = true
929
- return
930
- }
931
- if (
932
- (ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) &&
933
- (node.operator === ts.SyntaxKind.PlusPlusToken ||
934
- node.operator === ts.SyntaxKind.MinusMinusToken) &&
935
- ts.isIdentifier(node.operand) &&
936
- node.operand.text === name
937
- ) {
938
- found = true
939
- return
940
- }
941
- ts.forEachChild(node, visit)
942
- }
943
- visit(source)
944
- return found
945
- }
946
-
947
- /** Does `binding` bind `name` (as a plain identifier or nested inside a destructuring pattern)? Mirrors
948
- * the pattern walk in {@link isShadowedAt}. */
949
- function bindingNameCovers(ts: TypeScriptApi, binding: TSApi.BindingName, name: string): boolean {
950
- return ts.isIdentifier(binding)
951
- ? binding.text === name
952
- : binding.elements.some(
953
- (element) =>
954
- !ts.isOmittedExpression(element) &&
955
- (ts.isIdentifier(element.name)
956
- ? element.name.text === name
957
- : bindingNameCovers(ts, element.name, name)),
958
- )
959
- }
960
-
961
- /** Inspect the bindings a single scope node introduces DIRECTLY (not nested) for `name`: parameters and
962
- * catch variables of a function/catch, `var`/`let`/`const` and function/class declarations of a
963
- * statement list. Returns the readable initializer, `"shadow"` for a form we cannot read through, or
964
- * `undefined` when this scope does not bind the name at all. */
965
- function directBinding(
966
- ts: TypeScriptApi,
967
- source: TSApi.SourceFile,
968
- scope: TSApi.Node,
969
- name: string,
970
- ): ResolvableBinding | undefined {
971
- if (ts.isFunctionLike(scope)) {
972
- for (const parameter of scope.parameters) {
973
- if (bindingNameCovers(ts, parameter.name, name)) return "shadow"
974
- }
975
- }
976
- if (ts.isCatchClause(scope)) {
977
- const declared = scope.variableDeclaration
978
- if (declared !== undefined && bindingNameCovers(ts, declared.name, name)) return "shadow"
979
- }
980
- const statements: readonly TSApi.Statement[] | undefined = ts.isSourceFile(scope)
981
- ? scope.statements
982
- : ts.isBlock(scope) || ts.isModuleBlock(scope)
983
- ? scope.statements
984
- : ts.isCaseClause(scope) || ts.isDefaultClause(scope)
985
- ? scope.statements
986
- : undefined
987
- const lists: TSApi.VariableDeclarationList[] = []
988
- if (statements !== undefined) {
989
- for (const statement of statements) {
990
- if (ts.isVariableStatement(statement)) lists.push(statement.declarationList)
991
- if (
992
- (ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) &&
993
- statement.name?.text === name
994
- ) {
995
- return "shadow"
996
- }
997
- }
998
- }
999
- if (
1000
- (ts.isForStatement(scope) || ts.isForInStatement(scope) || ts.isForOfStatement(scope)) &&
1001
- scope.initializer !== undefined &&
1002
- ts.isVariableDeclarationList(scope.initializer)
1003
- ) {
1004
- lists.push(scope.initializer)
1005
- }
1006
- for (const list of lists) {
1007
- for (const declaration of list.declarations) {
1008
- if (!bindingNameCovers(ts, declaration.name, name)) continue
1009
- // A destructured or uninitialized binding, or a `var` (hoisting + reassignment risk), is not a
1010
- // value we can read one expression out of.
1011
- if (!ts.isIdentifier(declaration.name) || declaration.initializer === undefined)
1012
- return "shadow"
1013
- const isConst = (list.flags & ts.NodeFlags.Const) !== 0
1014
- const isLet = (list.flags & ts.NodeFlags.Let) !== 0
1015
- if (isConst) return { initializer: declaration.initializer }
1016
- if (isLet && !isReassignedInFile(ts, source, name))
1017
- return { initializer: declaration.initializer }
1018
- return "shadow"
1019
- }
1020
- }
1021
- return undefined
1022
- }
1023
-
1024
- /**
1025
- * Resolve a query-call ARGUMENT identifier to the expression it was initialized from, so a statement
1026
- * parked in a variable is scanned as if it had been written inline at the call. This is the counterpart
1027
- * to {@link resolveStaticSqlText}: that folds a const's text INTO a template's shape; this unfolds the
1028
- * whole statement back out of the variable it was lifted into. Without it, ANY interpolated-SQL finding
1029
- * could be silenced, invisibly, by hoisting the statement to `const q = …` first.
1030
- *
1031
- * Same-file only. Nearest enclosing declaration wins, so a shadowing local is read and never a
1032
- * same-named module const. Reads through a `const`, or a `let` the file never reassigns, at function or
1033
- * module scope; transitive through an identifier-valued initializer; depth-capped. A parameter, an
1034
- * imported or destructured binding, a reassigned `let`, a `var`, or a name with no same-file
1035
- * declaration returns `undefined` - which leaves the argument scanned as itself (unflagged unless it is
1036
- * a module const that resolves to static text: exactly the pre-feature behavior).
1037
- */
1038
- function resolveArgumentInitializer(
1039
- ts: TypeScriptApi,
1040
- node: TSApi.Identifier,
1041
- depth: number,
1042
- ): TSApi.Expression | undefined {
1043
- if (depth > CONST_RESOLUTION_DEPTH) return undefined
1044
- const source = node.getSourceFile()
1045
- const name = node.text
1046
- for (let scope: TSApi.Node | undefined = node.parent; scope !== undefined; scope = scope.parent) {
1047
- const binding = directBinding(ts, source, scope, name)
1048
- if (binding === "shadow") return undefined
1049
- if (binding !== undefined) {
1050
- let init = binding.initializer
1051
- while (ts.isParenthesizedExpression(init)) init = init.expression
1052
- return ts.isIdentifier(init) ? resolveArgumentInitializer(ts, init, depth + 1) : init
1053
- }
1054
- if (ts.isSourceFile(scope)) break
1055
- }
1056
- return undefined
1057
- }
1058
-
1059
- /** Describe only the first argument's syntax; comments/strings elsewhere cannot influence the result. */
1060
- function sqlExpressionShape(
1061
- ts: TypeScriptApi,
1062
- node: TSApi.Expression,
1063
- scope?: SqlScope,
1064
- ): SqlExpressionShape {
1065
- if (ts.isParenthesizedExpression(node)) return sqlExpressionShape(ts, node.expression, scope)
1066
- if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
1067
- return {
1068
- staticText: node.text,
1069
- dynamic: false,
1070
- concatenated: false,
1071
- literal: true,
1072
- parameterizedTag: false,
1073
- }
1074
- }
1075
- if (ts.isTaggedTemplateExpression(node)) {
1076
- const tag = node.tag
1077
- const parameterizedTag =
1078
- (ts.isIdentifier(tag) && tag.text === "sql") ||
1079
- (ts.isPropertyAccessExpression(tag) && tag.getText() === "Prisma.sql")
1080
- const template = node.template
1081
- const templated = ts.isTemplateExpression(template)
1082
- ? templateShape(ts, template, parameterizedTag ? undefined : scope)
1083
- : { staticText: template.text, dynamic: false }
1084
- const staticText = templated.staticText
1085
- // Only the ecosystem's binding tags are trusted, by NAME - which is the honest limit of a scanner
1086
- // that reads syntax and runs no type checker. `sql` is what postgres.js, drizzle, slonik and Bun's
1087
- // own driver all call theirs, and `Prisma.sql` is Prisma's; taking those at their word is a
1088
- // deliberate trade, and it is worth stating what it costs: a no-op function named `sql` in the
1089
- // same file is trusted too, so this rule finds mistakes, not an adversary who reads it first.
1090
- //
1091
- // Trusting anything else widens that for no gain. This list briefly also held `sqlIdentifiers`,
1092
- // a name invented for Nifra's own adapters - a second forgeable name that no user has a
1093
- // convention for, added so Nifra's source would pass its own rule. Names earn their place here by
1094
- // being what drivers already call the thing, not by being convenient for the framework.
1095
- //
1096
- // Everything else - `String.raw`, an identity tag, a member access that merely ends in `.sql` -
1097
- // is treated as plain interpolation.
1098
- return {
1099
- staticText,
1100
- dynamic: templated.dynamic && !parameterizedTag,
1101
- concatenated: false,
1102
- literal: !ts.isTemplateExpression(template),
1103
- parameterizedTag,
1104
- }
1105
- }
1106
- if (ts.isTemplateExpression(node)) {
1107
- const templated = templateShape(ts, node, scope)
1108
- return {
1109
- staticText: templated.staticText,
1110
- dynamic: templated.dynamic,
1111
- concatenated: false,
1112
- literal: false,
1113
- parameterizedTag: false,
1114
- }
1115
- }
1116
- if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) {
1117
- const left = sqlExpressionShape(ts, node.left, scope)
1118
- const right = sqlExpressionShape(ts, node.right, scope)
1119
- return {
1120
- staticText: left.staticText + right.staticText,
1121
- // A resolved const operand is static text, not a dynamic part - so the old `!literal` test
1122
- // narrows to what it always actually meant: an operand that carries runtime data (dynamic) or
1123
- // a binding tag being concatenated into plain text (which un-parameterizes it).
1124
- dynamic: left.dynamic || right.dynamic || left.parameterizedTag || right.parameterizedTag,
1125
- concatenated: true,
1126
- literal: left.literal && right.literal,
1127
- parameterizedTag: false,
1128
- }
1129
- }
1130
- // A resolvable expression (a module-`const` name, a literal ternary) contributes its compile-time
1131
- // text and stays non-dynamic - this is what lets `"SELECT " + COLS + " FROM t"` and
1132
- // `db.query(`${COLS} FROM t WHERE id = $1`)` pass while a mutable or unprovable name still flags.
1133
- // `literal` stays false: the named escape hatches (`unsafe`, `$queryRawUnsafe`) keep flagging a
1134
- // resolved const, because taking statement text from ANY variable is what their rule warns about.
1135
- if (scope !== undefined) {
1136
- const resolved = resolveStaticSqlText(ts, node, scope, 0)
1137
- if (resolved !== undefined) {
1138
- return {
1139
- staticText: resolved,
1140
- dynamic: false,
1141
- concatenated: false,
1142
- literal: false,
1143
- parameterizedTag: false,
1144
- }
1145
- }
1146
- }
1147
- // A bare name that did not resolve to compile-time text. If it is a same-file binding we can prove
1148
- // holds one expression, scan THAT expression's shape - so a statement hoisted into `const q = …` is
1149
- // read exactly as if it were written inline at the call, closing the "extract a variable to launder
1150
- // the finding" hole. `literal` is forced false: the value arrived through a variable, which is the
1151
- // property the `unsafe`/`$queryRawUnsafe` hatch rule keys on.
1152
- if (ts.isIdentifier(node)) {
1153
- const initializer = resolveArgumentInitializer(ts, node, 0)
1154
- if (initializer !== undefined) {
1155
- return { ...sqlExpressionShape(ts, initializer, scope), literal: false }
1156
- }
1157
- }
1158
- return {
1159
- staticText: "",
1160
- dynamic: true,
1161
- concatenated: false,
1162
- literal: false,
1163
- parameterizedTag: false,
1164
- }
1165
- }
1166
-
1167
- function sqlMethod(
1168
- ts: TypeScriptApi,
1169
- call: TSApi.CallExpression,
1170
- ): { method: string; receiver: string } | undefined {
1171
- if (ts.isPropertyAccessExpression(call.expression)) {
1172
- const method = call.expression.name.text
1173
- return SQL_METHODS.has(method)
1174
- ? { method, receiver: call.expression.expression.getText() }
1175
- : undefined
1176
- }
1177
- if (
1178
- ts.isElementAccessExpression(call.expression) &&
1179
- call.expression.argumentExpression !== undefined &&
1180
- ts.isStringLiteral(call.expression.argumentExpression)
1181
- ) {
1182
- const method = call.expression.argumentExpression.text
1183
- return SQL_METHODS.has(method)
1184
- ? { method, receiver: call.expression.expression.getText() }
1185
- : undefined
1186
- }
1187
- return undefined
1188
- }
1189
-
1190
- const SQL_RECEIVER = /(?:^|\.)(?:db|sql|prisma|drizzle|database|conn|connection|client|tx)$/i
1191
-
1192
- /** The opt-out pragma that silences one interpolated-SQL finding. Written `// nifra-expect sql-dynamic:
1193
- * <reason>` on the flagged line or in the comment block directly above it; the reason after the colon
1194
- * is mandatory (see {@link commentBlockMarkerReason}). Scoped to the single statement it sits on. */
1195
- const SQL_DYNAMIC_PRAGMA = "nifra-expect sql-dynamic"
1196
-
1197
- /**
1198
- * Scan one file's text for SQL assembled by interpolation. Pure + line-accurate.
1199
- *
1200
- * `imports` opts into following a fragment `const` into another module. Without it the scan is
1201
- * same-file only, which is what the unit tests exercise and what any caller that has no module graph
1202
- * to offer gets. With it, only what {@link createProjectSqlImports} will hand back is followed.
1203
- */
1204
- export function scanInterpolatedSql(
1205
- file: string,
1206
- content: string,
1207
- ts: TypeScriptApi,
1208
- imports?: SqlImports,
1209
- ): SourceFinding[] {
1210
- const out: SourceFinding[] = []
1211
- const lines = content.split("\n")
1212
- // Malformed source is already a typecheck failure. Do not turn parser recovery nodes into a second,
1213
- // misleading SQL diagnostic (notably an unterminated template recovered as an interpolation).
1214
- const source = parseSqlSource(ts, file, content)
1215
- if (source === undefined) return out
1216
- // Module-scope bindings, read once per file: an interpolated `const` that resolves to compile-time
1217
- // text cannot carry request data, so the shared-projection idiom (`const COLS = "id, name"` …
1218
- // `${COLS}`) stays quiet while mutable, parameter, shadowed, and unprovable names flag as before.
1219
- const scope: SqlScope = { module: sqlModuleScope(ts, file, source), imports }
1220
- const visit = (node: TSApi.Node): void => {
1221
- if (ts.isCallExpression(node)) {
1222
- const sink = sqlMethod(ts, node)
1223
- const argument = node.arguments[0]
1224
- if (sink !== undefined && argument !== undefined) {
1225
- const shape = sqlExpressionShape(ts, argument, scope)
1226
- const unsafeEscape =
1227
- ALWAYS_UNSAFE.has(sink.method) && !shape.literal && !shape.parameterizedTag
1228
- const assembledSql =
1229
- shape.dynamic &&
1230
- (SQL_KEYWORD.test(shape.staticText) ||
1231
- (shape.concatenated && SQL_RECEIVER.test(sink.receiver)))
1232
- if (unsafeEscape || assembledSql) {
1233
- const index = node.getStart(source)
1234
- const line = source.getLineAndCharacterOfPosition(index).line + 1
1235
- // Sanctioned escape hatch for genuinely-dynamic-but-bound SQL (batch VALUES placeholder
1236
- // generation, an allowlisted identifier). The reason is mandatory: a bare `// nifra-expect
1237
- // sql-dynamic` with no reason after the colon does NOT silence, so the hatch always leaves a
1238
- // greppable audit trail instead of becoming a second laundering trick.
1239
- const silenced = commentBlockMarkerReason(lines, line, SQL_DYNAMIC_PRAGMA) !== undefined
1240
- if (!silenced) out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() })
1241
- }
1242
- }
1243
- }
1244
- ts.forEachChild(node, visit)
1245
- }
1246
- visit(source)
1247
- return out
1248
- }
1249
-
1250
- /**
1251
- * Collect interpolated-SQL findings across the project.
1252
- *
1253
- * Returns `undefined` - not an empty array - when TypeScript is not installed. The rule parses source
1254
- * with the compiler, so without it there is nothing to report and, more to the point, no basis for
1255
- * reporting nothing. An empty result would read as "no interpolated SQL found", which is the one
1256
- * answer a security rule must never give when it did not run.
1257
- */
1258
- export async function scanProjectSql(cwd: string): Promise<SourceFinding[] | undefined> {
1259
- const ts = await importProjectTypeScript(cwd)
1260
- if (ts === undefined) return undefined
1261
- const out: SourceFinding[] = []
1262
- const imports = createProjectSqlImports(cwd)
1263
- await walkSource(cwd, (rel, content) =>
1264
- out.push(...scanInterpolatedSql(rel, content, ts, imports)),
1265
- )
1266
- return out.sort(bySite)
1267
- }
1268
-
1269
- /** A declaration file. Excluded from cross-module resolution: `declare const X: string` has no
1270
- * initializer to read, so following one could only ever produce a "proved" the source never wrote. */
1271
- const SQL_DECLARATION_FILE = /\.d\.[cm]?ts$/
1272
- /** The extensions the SQL scan will parse - the same set {@link walkSource} globs. */
1273
- const SQL_SOURCE_FILE = /\.[cm]?tsx?$/
1274
-
1275
- /**
1276
- * The on-disk loader for cross-module `const` resolution, scoped to one project.
1277
- *
1278
- * Deliberately narrow, because every widening here is a way for a real finding to go quiet:
1279
- *
1280
- * - **Relative specifiers only.** A bare specifier names a dependency whose exports can change without
1281
- * this project changing, so "provably a `const` string" is not a property the project owns.
1282
- * - **Inside `cwd`, outside the ignored trees.** The resolved path is realpath'd and must still sit
1283
- * under the project root: a symlink pointing out of the tree is not source this scan reads. The
1284
- * `node_modules` / `dist` / build exclusions are the same ones the walk applies.
1285
- * - **Real source only.** A `.d.ts` carries no initializer, and a non-TS file is not parsed.
1286
- *
1287
- * A miss on any of those returns `undefined`, which leaves the name unresolved and the interpolation
1288
- * flagged - the pre-feature answer.
1289
- */
1290
- export function createProjectSqlImports(cwd: string): SqlImports {
1291
- const root = (() => {
1292
- try {
1293
- return realpathSync(resolve(cwd))
1294
- } catch {
1295
- return resolve(cwd)
1296
- }
1297
- })()
1298
- return createSqlImports((fromModule, specifier) => {
1299
- if (!isRelativeSpecifier(specifier)) return undefined
1300
- try {
1301
- // `fromModule` is the cwd-RELATIVE path the walk supplies for an entry file, and ABSOLUTE for
1302
- // every module reached through a hop (their ids are the resolved paths).
1303
- const fromAbs = isAbsolute(fromModule) ? fromModule : join(root, fromModule)
1304
- const resolved = realpathSync(Bun.resolveSync(specifier, dirname(fromAbs)))
1305
- if (!resolved.startsWith(root + sep)) return undefined
1306
- const rel = resolved.slice(root.length + 1)
1307
- if (IGNORED_DIR.test(rel)) return undefined
1308
- if (SQL_DECLARATION_FILE.test(resolved) || !SQL_SOURCE_FILE.test(resolved)) return undefined
1309
- return { id: resolved, content: readFileSync(resolved, "utf8") }
1310
- } catch {
1311
- return undefined // unresolvable, unreadable, or outside the project
1312
- }
1313
- })
1314
- }
1315
-
1316
- // `client("` / `client('` / `client(\`` - a URL-first call WITHOUT the `<typeof app>` generic:
1317
- // the compiler has nothing to derive types from, so the anti-drift guarantee silently vanishes.
1318
- // `client<typeof app>("…")` has `<…>` between the name and `(` so it never matches; the published-
1319
- // contract form `client(contract, url)` starts with an identifier, not a quote - also unmatched.
1320
- const CLIENT_CALL = /(?<![.\w])client\s*\(/g
1321
-
1322
- /** Scan one file's text for untyped `client("…")` calls. Pure + line-accurate. */
1323
- export function scanUntypedClient(file: string, content: string): SourceFinding[] {
1324
- const out: SourceFinding[] = []
1325
- const lines = content.split("\n")
1326
- const code = codePositionMask(content)
1327
- CLIENT_CALL.lastIndex = 0
1328
- for (let m = CLIENT_CALL.exec(code); m !== null; m = CLIENT_CALL.exec(code)) {
1329
- const argument = firstArgumentIndex(content, m.index + m[0].length)
1330
- const quote = content[argument]
1331
- if (quote !== "'" && quote !== '"' && quote !== "`") continue
1332
- const line = lineAt(content, m.index)
1333
- out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() })
1334
- }
1335
- return out
1336
- }
1337
-
1338
- /**
1339
- * Specifiers that no longer exist, and what replaced them.
1340
- *
1341
- * These are the breaks a typecheck does not catch and a test suite does not either. `@nifrajs/core/ws`
1342
- * was a side-effect import removed in 2.0 in favour of `.use(websocket())`; a consuming package kept
1343
- * the import and a 533-test suite stayed green while the app could not boot. `@nifrajs/budget` folded
1344
- * into core with no npm deprecation, so `bun install` fails workspace-wide with a resolution error
1345
- * that names neither the cause nor the replacement.
1346
- *
1347
- * A lint is the right shape for this: it runs before boot, names the file and line, and states the
1348
- * replacement - which is what neither the resolver error nor the runtime failure does.
1349
- */
1350
- const REMOVED_IMPORTS: ReadonlyArray<{
1351
- readonly specifier: string
1352
- readonly since: string
1353
- readonly replacement: string
1354
- /** Flag ONLY the bare side-effect form (`import "x"`). A module that still exports values is not
1355
- * removed - only its install-by-import behaviour is - so flagging every import of it would be
1356
- * wrong and would train people to ignore the rule. */
1357
- readonly sideEffectOnly?: boolean
1358
- }> = [
1359
- {
1360
- specifier: "@nifrajs/budget",
1361
- since: "2.0",
1362
- replacement:
1363
- 'import from "@nifrajs/core/budget" - the package folded into core and npm `latest` is still 1.13.0, so a `^2` range resolves to nothing',
1364
- },
1365
- {
1366
- specifier: "@nifrajs/core/ws",
1367
- since: "2.0",
1368
- sideEffectOnly: true,
1369
- replacement:
1370
- 'a bare `import "@nifrajs/core/ws"` no longer installs the runtime - register it explicitly with `.use(websocket())`, importing `websocket` from the same module',
1371
- },
1372
- ]
1373
-
1374
- /** Whether the import statement at `index` is the bare side-effect form (`import "x"`), rather than
1375
- * one with bindings (`import { y } from "x"`). */
1376
- function isSideEffectImport(content: string, index: number): boolean {
1377
- // `staticImportEdges` reports the index of the STATEMENT, not of the specifier, so read forward:
1378
- // a side-effect import is `import` followed directly by the quoted specifier, with no bindings and
1379
- // no `from` in between.
1380
- return /^import\s*["'`]/.test(content.slice(index, index + 32))
1381
- }
1382
-
1383
- /** Flag an import of a specifier that no longer exists. Pure; matches the exact specifier or a subpath
1384
- * of it, so `@nifrajs/budget/x` is caught alongside `@nifrajs/budget`. */
1385
- export function scanRemovedImports(file: string, content: string): SourceFinding[] {
1386
- const out: SourceFinding[] = []
1387
- const lines = content.split("\n")
1388
- for (const edge of staticImportEdges(content)) {
1389
- const removed = REMOVED_IMPORTS.find(
1390
- (entry) =>
1391
- edge.specifier === entry.specifier || edge.specifier.startsWith(`${entry.specifier}/`),
1392
- )
1393
- if (removed === undefined) continue
1394
- if (removed.sideEffectOnly === true && !isSideEffectImport(content, edge.index)) continue
1395
- const line = lineAt(content, edge.index)
1396
- out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() })
1397
- }
1398
- return out
1399
- }
1400
-
1401
- /** Scan a route module for top-level server-only imports. Returns `[]` for non-route files (only
1402
- * `routes/` modules are browser-bundled, so a server-only import elsewhere is fine). Each finding carries
1403
- * the offending `specifier` so the diagnostic can render the `routeFile → specifier` chain. Pure. */
1404
- export function scanServerOnlyImports(
1405
- file: string,
1406
- content: string,
1407
- facts?: SourceFacts,
1408
- ): ServerImportFinding[] {
1409
- if (!ROUTE_FILE.test(file)) return []
1410
- const out: ServerImportFinding[] = []
1411
- const lines = content.split("\n")
1412
- const code = stripComments(content)
1413
- const positions = codePositionMask(content)
1414
- STATIC_IMPORT.lastIndex = 0
1415
- for (let m = STATIC_IMPORT.exec(code); m !== null; m = STATIC_IMPORT.exec(code)) {
1416
- if (positions[m.index] === " ") continue
1417
- const specifier = m[1] ?? ""
1418
- if (!SERVER_ONLY.test(specifier)) continue
1419
- if (facts !== undefined) {
1420
- const source = facts.parse(file, content)
1421
- // Inline `import { type X } from "…"` is erased just like `import type`; don't call it a
1422
- // runtime leak. A parse failure keeps the old lexical finding so the security rule fails closed.
1423
- if (source !== undefined && facts.isValueImportAt(source, m.index, specifier) === false)
1424
- continue
1425
- }
1426
- const line = lineAt(content, m.index)
1427
- out.push({ file, line, snippet: (lines[line - 1] ?? "").trim(), specifier })
1428
- }
1429
- return out
1430
- }
1431
-
1432
- // ---------------------------------------------------------------------------------------------------
1433
- // #4.4 - TRANSITIVE import-chain resolution for `server-only-import`. `scanServerOnlyImports` above is a
1434
- // pure per-file regex scan: it sees only the route's DIRECT `import` line, so it reports the direct edge
1435
- // `routes/x → ../db`. The build leak-guard, which has the real module graph, reports the full transitive
1436
- // `route → ../data → ../db → pg`. These helpers give `nifra check` the same depth via a BOUNDED
1437
- // import-resolution walk: from a flagged route, resolve its local imports (`Bun.resolveSync`), BFS the
1438
- // local module graph, and build the shortest chain to a server-only SINK. It's best-effort: an import
1439
- // that can't be precisely resolved (a bare pkg, a tsconfig path alias) falls back to the direct edge.
1440
- // ---------------------------------------------------------------------------------------------------
1441
-
1442
- // Server-only specifiers that are TERMINAL sinks - a bare `node:`/`bun:` builtin or a known server-only
1443
- // npm package. These are never local source we can walk into, so the chain ends here. (Same vocabulary
1444
- // as SERVER_ONLY, minus the relative `../db` arm - a relative `db` module IS local source we resolve.)
1445
- const SERVER_ONLY_SINK =
1446
- /^(?:node:|bun:)|^(?:postgres|pg|mysql2|ioredis|redis|better-sqlite3|mongodb|@libsql\/client)$|^drizzle-orm\/(?:node-postgres|postgres-js|bun-sqlite|libsql|mysql2|pglite)\b/
1447
- // The `.server` convention: a module named `*.server.ts(x)` is server-only (the client build empties it).
1448
- const SERVER_MODULE_FILE = /\.server(\.[cm]?[jt]sx?)?$/
1449
- // The explicit poison-import marker (`@nifrajs/web/server-only`) - a module opting into the client-leak
1450
- // guard. A resolved file whose source carries this side-effect import is a server-only sink.
1451
- const SERVER_ONLY_MARKER_IMPORT = /import\s+["']@nifrajs\/web\/server-only["']/
1452
- // Depth/visited caps keep the walk linear + cycle-safe. A route's server-only dependency sits within a
1453
- // few hops in practice; the bound stops a pathological graph from blowing up the per-file scan.
1454
- const TRANSITIVE_MAX_DEPTH = 8
1455
- const TRANSITIVE_MAX_VISITED = 200
1456
-
1457
- /** A relative module specifier (`./x`, `../y`) - the only kind we resolve + walk into (a bare specifier
1458
- * is either a sink we recognise by name or a third-party dep we don't follow into node_modules). */
1459
- const isRelativeSpecifier = (spec: string): boolean =>
1460
- spec.startsWith("./") || spec.startsWith("../")
1461
-
1462
- // A FRESH copy of the static-import regex per scan. The transitive walk is REENTRANT - the outer
1463
- // per-route loop and the inner BFS both scan imports - and a single shared global-flag regex carries
1464
- // `lastIndex` state, so reusing the module-level `STATIC_IMPORT` across nested calls corrupts the outer
1465
- // loop's position (it restarts forever). A fresh instance per call keeps each scan's state private.
1466
- const staticImportRegex = (): RegExp => new RegExp(STATIC_IMPORT.source, STATIC_IMPORT.flags)
1467
-
1468
- /** Extract the static, non-type import specifiers from a module's source (the edges to follow/inspect),
1469
- * each with the match index (for line attribution). Mirrors {@link STATIC_IMPORT}, so `import type` +
1470
- * dynamic `import()` are already excluded. Uses a fresh regex instance, so it's safe under the reentrant
1471
- * transitive walk. Pure. */
1472
- function staticImportEdges(
1473
- content: string,
1474
- facts?: SourceFacts,
1475
- file?: string,
1476
- ): Array<{ specifier: string; index: number }> {
1477
- const edges: Array<{ specifier: string; index: number }> = []
1478
- const re = staticImportRegex()
1479
- const code = stripComments(content)
1480
- const positions = codePositionMask(content)
1481
- for (let m = re.exec(code); m !== null; m = re.exec(code)) {
1482
- if (positions[m.index] === " ") continue
1483
- if (m[1] === undefined) continue
1484
- if (facts !== undefined && file !== undefined) {
1485
- const source = facts.parse(file, content)
1486
- if (source !== undefined && facts.isValueImportAt(source, m.index, m[1]) === false) continue
1487
- }
1488
- edges.push({ specifier: m[1], index: m.index })
1489
- }
1490
- return edges
1491
- }
1492
-
1493
- /** Extract the static, non-type import specifiers from a module's source (the edges to follow/inspect).
1494
- * Mirrors {@link STATIC_IMPORT}, so `import type` + dynamic `import()` are already excluded. Pure. */
1495
- export function parseStaticImports(content: string, facts?: SourceFacts, file?: string): string[] {
1496
- return staticImportEdges(content, facts, file).map((e) => e.specifier)
1497
- }
1498
-
1499
- /** The server-only SINK an import specifier names directly (a `node:`/`bun:` builtin or a known
1500
- * server-only package), or `undefined` if it isn't a by-name sink. Pure - the label is the specifier
1501
- * itself (it's already the actionable name). */
1502
- export function directSinkSpecifier(spec: string): string | undefined {
1503
- return SERVER_ONLY_SINK.test(spec) ? spec : undefined
1504
- }
1505
-
1506
- /** One transitive server-only finding: the route module, the route's offending top-level import (the
1507
- * first hop / `specifier`), the line + snippet of that import, and the FULL chain to the sink. */
1508
- export interface TransitiveServerImportFinding extends ServerImportFinding {
1509
- /** `[routeFile, ...as-written specifiers…, sink]` - the shortest path the walk found. Length 2 means
1510
- * the route imports the sink directly (same as the regex scan's direct edge). */
1511
- readonly chain: readonly string[]
1512
- /** True when a precise transitive resolve wasn't possible for the first hop (a bare pkg / path alias),
1513
- * so the chain is the honest direct edge rather than a fabricated deeper path. */
1514
- readonly fallback: boolean
1515
- }
1516
-
1517
- /** A resolver from `(fromFile, specifier)` to an absolute module path, or `undefined` when it can't be
1518
- * resolved precisely (bare pkg, path alias, missing file). Abstracted so the BFS is unit-testable with a
1519
- * fake graph (no real fs). The production resolver wraps `Bun.resolveSync`. */
1520
- export type ModuleResolver = (fromFile: string, specifier: string) => string | undefined
1521
- /** Reads a resolved module's source, or `undefined` if unreadable. Abstracted for the same reason. */
1522
- export type ModuleReader = (absPath: string) => string | undefined
1523
-
1524
- /**
1525
- * BFS the LOCAL module graph from a route file for the SHORTEST import chain that reaches a server-only
1526
- * sink, returning `[routeFile, …as-written specifiers…, sink]` or `undefined` if none is reachable. A
1527
- * node's outgoing edges are its static imports; an edge is followed only when it's a RELATIVE specifier
1528
- * that `resolve` maps to a readable local file (so the walk never descends into node_modules or chases an
1529
- * unresolvable alias). At each node, a by-name sink import (`node:fs`, `postgres`) OR a resolved
1530
- * `*.server` / `server-only`-marked dependency terminates the chain. Bounded by depth + a visited set, so
1531
- * it's linear and cycle-free. `routeFile`/`routeContent` seed the walk; `resolve`/`read` supply the graph
1532
- * - pure given those, so it's unit-testable with a fake graph.
1533
- */
1534
- export function walkServerOnlyChain(
1535
- routeFile: string,
1536
- routeContent: string,
1537
- resolve: ModuleResolver,
1538
- read: ModuleReader,
1539
- facts?: SourceFacts,
1540
- ): readonly string[] | undefined {
1541
- // Frontier nodes carry the absolute file to inspect, the source to scan, the display chain so far
1542
- // (route + the as-written specifier of each hop), and the file the imports resolve relative to.
1543
- interface Node {
1544
- readonly abs: string
1545
- readonly content: string
1546
- readonly chain: readonly string[]
1547
- readonly depth: number
1548
- }
1549
- const seen = new Set<string>([routeFile])
1550
- let frontier: Node[] = [{ abs: routeFile, content: routeContent, chain: [routeFile], depth: 0 }]
1551
- let visited = 0
1552
- while (frontier.length > 0) {
1553
- const next: Node[] = []
1554
- for (const node of frontier) {
1555
- if (node.depth >= TRANSITIVE_MAX_DEPTH) continue
1556
- for (const spec of parseStaticImports(node.content, facts, node.abs)) {
1557
- // (a) A by-name sink (builtin / known server-only pkg) → the chain ends here (shortest first,
1558
- // since BFS reaches the nearest sink before any deeper one).
1559
- const sink = directSinkSpecifier(spec)
1560
- if (sink !== undefined) return [...node.chain, sink]
1561
- // (b) Only relative specifiers are local source we resolve + walk into. A bare third-party
1562
- // specifier that isn't a known sink is a leaf - don't follow it into node_modules.
1563
- if (!isRelativeSpecifier(spec)) continue
1564
- const abs = resolve(node.abs, spec)
1565
- if (abs === undefined || seen.has(abs)) continue
1566
- // (c) A resolved `*.server` module is a server-only sink by the `.server` convention - the chain
1567
- // ends at it (named by the as-written specifier).
1568
- if (SERVER_MODULE_FILE.test(abs)) return [...node.chain, spec]
1569
- const content = read(abs)
1570
- if (content === undefined) continue // unreadable → can't walk; treat as a leaf
1571
- // (d) A resolved module that opts into the `server-only` marker is a sink too.
1572
- if (SERVER_ONLY_MARKER_IMPORT.test(content)) return [...node.chain, spec]
1573
- if (visited >= TRANSITIVE_MAX_VISITED) continue
1574
- visited++
1575
- seen.add(abs)
1576
- next.push({ abs, content, chain: [...node.chain, spec], depth: node.depth + 1 })
1577
- }
1578
- }
1579
- frontier = next
1580
- }
1581
- return undefined
1582
- }
1583
-
1584
- /**
1585
- * Resolve the FULL transitive server-only chain(s) for a route module, given a real fs-backed resolver +
1586
- * reader. For each of the route's top-level imports, if a precise transitive walk finds a sink, the
1587
- * finding carries the full chain; otherwise - when the first hop is itself a direct by-name sink the
1588
- * regex scan already flags, or when a relative import can't be resolved - it falls back to the DIRECT
1589
- * edge (`fallback: true`), never a fabricated chain. Returns one finding per offending top-level import,
1590
- * de-duplicated to the shortest chain per first-hop specifier. Non-route files yield `[]`.
1591
- */
1592
- export function resolveServerOnlyChains(
1593
- file: string,
1594
- content: string,
1595
- resolve: ModuleResolver,
1596
- read: ModuleReader,
1597
- facts?: SourceFacts,
1598
- ): TransitiveServerImportFinding[] {
1599
- if (!ROUTE_FILE.test(file)) return []
1600
- const lines = content.split("\n")
1601
- const out: TransitiveServerImportFinding[] = []
1602
- const flaggedSpecifiers = new Set<string>()
1603
- // Collect the route's import edges up front (fresh-regex scan) so the per-edge logic below can call
1604
- // the REENTRANT transitive walk without corrupting a shared regex's `lastIndex` (the walk also scans
1605
- // imports). Driving `STATIC_IMPORT.exec` here directly would restart this loop forever.
1606
- for (const { specifier, index } of staticImportEdges(content, facts, file)) {
1607
- if (flaggedSpecifiers.has(specifier)) continue
1608
- const line = lineAt(content, index)
1609
- const snippet = (lines[line - 1] ?? "").trim()
1610
- // (1) A direct by-name sink (`node:fs`, `postgres`) - the route imports it itself; chain is the
1611
- // direct edge. (Length-2 chain == the regex scan's existing `[route, specifier]`.)
1612
- if (directSinkSpecifier(specifier) !== undefined) {
1613
- flaggedSpecifiers.add(specifier)
1614
- out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: false })
1615
- continue
1616
- }
1617
- // (2) A relative local import - try the transitive walk from the resolved dependency. If it reaches
1618
- // a sink, emit the full chain rooted at THIS route's import line.
1619
- if (isRelativeSpecifier(specifier)) {
1620
- const abs = resolve(file, specifier)
1621
- if (abs === undefined) {
1622
- // Unresolvable relative import. If it's the known server-only `../db` convention the regex scan
1623
- // flags directly, fall back to the direct edge (precise resolve impossible - say so via
1624
- // `fallback`). Any other unresolvable relative import we can't assert is server-only - skip it.
1625
- if (SERVER_ONLY.test(specifier)) {
1626
- flaggedSpecifiers.add(specifier)
1627
- out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: true })
1628
- }
1629
- continue
1630
- }
1631
- // The `.server` / marker sink can be the first hop itself.
1632
- if (SERVER_MODULE_FILE.test(abs)) {
1633
- flaggedSpecifiers.add(specifier)
1634
- out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: false })
1635
- continue
1636
- }
1637
- const depContent = read(abs)
1638
- if (depContent === undefined) continue
1639
- if (SERVER_ONLY_MARKER_IMPORT.test(depContent)) {
1640
- flaggedSpecifiers.add(specifier)
1641
- out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: false })
1642
- continue
1643
- }
1644
- // Walk INTO the dependency: build the chain `[depAbs, …]` then re-root it at the route's import.
1645
- const subChain = walkServerOnlyChain(abs, depContent, resolve, read, facts)
1646
- if (subChain !== undefined) {
1647
- flaggedSpecifiers.add(specifier)
1648
- // subChain is `[depAbs, …hops…, sink]`; replace its head (the resolved dep path) with the
1649
- // route's as-written specifier so the chain reads `routes/x → ../data → ../db → node:crypto`.
1650
- const chain = [file, specifier, ...subChain.slice(1)]
1651
- out.push({ file, line, snippet, specifier, chain, fallback: false })
1652
- continue
1653
- }
1654
- // (3) The relative `../db`-style convention the regex scan flags directly, but where the walk
1655
- // found no deeper sink (e.g. the file wasn't readable past the first hop) - fall back to the
1656
- // direct edge so we still surface the known-server-only convention rather than going silent.
1657
- if (SERVER_ONLY.test(specifier)) {
1658
- flaggedSpecifiers.add(specifier)
1659
- out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: true })
1660
- }
1661
- }
1662
- }
1663
- return out
1664
- }
1665
-
1666
- // A route handler returning a raw `Response` (`=> new Response(`, `return Response.json(`, …) makes the
1667
- // typed client infer `data: never` (`Jsonify<Response>` is `never`) - so frontend/backend drift detection
1668
- // silently vanishes for that route. Advisory, not a failure: a raw Response is sometimes intended
1669
- // (redirects, files, streams). Fix is to return a plain object, or declare `{ response: t.… }` to type it.
1670
- const RESPONSE_RETURN = /(?:=>\s*|return\s+)(?:new\s+Response|Response\s*\.\s*json)\s*\(/g
1671
-
1672
- /** Scan a backend module (one that calls `server(`) for handlers returning a raw `Response`, which collapses
1673
- * the typed client's `data` to `never`. Pure + line-accurate; returns `[]` for files with no `server(` call. */
1674
- export function scanResponseRoutes(
1675
- file: string,
1676
- content: string,
1677
- facts?: SourceFacts,
1678
- ): SourceFinding[] {
1679
- // Strip comments + template literals first: a commented-out or doc-example `return new Response(`
1680
- // must not raise a spurious advisory. Lengths are preserved, so lineAt + the raw-line snippet align.
1681
- const code = stripComments(content)
1682
- if (!/(?<![.\w])server\s*\(/.test(code)) return []
1683
- const out: SourceFinding[] = []
1684
- const lines = content.split("\n")
1685
- RESPONSE_RETURN.lastIndex = 0
1686
- for (let m = RESPONSE_RETURN.exec(code); m !== null; m = RESPONSE_RETURN.exec(code)) {
1687
- if (facts !== undefined) {
1688
- const source = facts.parse(file, content)
1689
- // A parseable AST rejects `return new Response(` text that only lives in a normal string. On
1690
- // malformed source we keep the lexical candidate; the typecheck gate will report the syntax error.
1691
- if (source !== undefined && facts.isResponseSyntaxAt(source, m.index) !== true) continue
1692
- }
1693
- const line = lineAt(content, m.index)
1694
- // A `// nifra-expect raw-response` pragma marks an intentional raw Response (a file/redirect that can't
1695
- // be a typed route), silencing this advisory. Honor it on the return line itself, OR on the line above
1696
- // ONLY when that line is a standalone comment - so a TRAILING pragma on one route's return line can't
1697
- // leak down and suppress the very next route's genuine advisory. `stripComments` blanks the pragma in
1698
- // `code` (so it never affects the match); the raw `lines` still carry it.
1699
- const thisLine = lines[line - 1] ?? ""
1700
- const aboveLine = lines[line - 2] ?? ""
1701
- const aboveSuppresses =
1702
- aboveLine.trimStart().startsWith("//") && aboveLine.includes(RAW_RESPONSE_PRAGMA)
1703
- if (thisLine.includes(RAW_RESPONSE_PRAGMA) || aboveSuppresses) continue
1704
- out.push({ file, line, snippet: thisLine.trim() })
1705
- }
1706
- return out
1707
- }
1708
-
1709
- /** The opt-out pragma that suppresses the {@link scanResponseRoutes} advisory for an intentional raw Response. */
1710
- const RAW_RESPONSE_PRAGMA = "nifra-expect raw-response"
1711
-
1712
- /** Walk the project's `.ts`/`.tsx` source (skipping deps and build output), calling `visit` per file.
1713
- * Exported so `nifra doctor` ({@link ./doctor.ts}) scans the same source surface as `nifra check`.
1714
- *
1715
- * `includeTests` widens the surface to `*.test.ts`/`*.spec.ts`, which the default excludes. It exists
1716
- * for `nifra doctor`: `tsc` compiles tests, so a test importing a package no manifest declares is a
1717
- * build that a clean install cannot produce - the exact false confidence doctor is for. */
1718
- /**
1719
- * Paths git considers ignored under `cwd`, in ONE batched `git check-ignore` call - so `.gitignore`
1720
- * (root + nested + the global excludesfile) is honoured, not just the built-in {@link IGNORED} list. This
1721
- * is what keeps the scan out of generated/build trees that live under a repo but aren't source: a monorepo
1722
- * that gitignores, say, `builder/projects/` (238 generated apps) would otherwise be walked in full - 40k+
1723
- * findings, a 50 MB+ result that overwhelms the caller. Returns an EMPTY set when there's no git / not a
1724
- * repo / git errors, so the scan simply degrades to the {@link IGNORED} regex - never throws, never blocks.
1725
- */
1726
- async function gitIgnored(cwd: string, rels: readonly string[]): Promise<Set<string>> {
1727
- if (rels.length === 0) return new Set()
1728
- try {
1729
- const proc = Bun.spawn(["git", "check-ignore", "--stdin"], {
1730
- cwd,
1731
- stdin: "pipe",
1732
- stdout: "pipe",
1733
- stderr: "ignore",
1734
- })
1735
- proc.stdin.write(`${rels.join("\n")}\n`)
1736
- await proc.stdin.end()
1737
- // git echoes back each ignored path exactly as fed on stdin (one per line); exit 1 = none matched.
1738
- const out = await new Response(proc.stdout).text()
1739
- await proc.exited
1740
- return new Set(
1741
- out
1742
- .split("\n")
1743
- .map((s) => s.trim())
1744
- .filter(Boolean),
1745
- )
1746
- } catch {
1747
- return new Set()
1748
- }
1749
- }
1750
-
1751
- export async function walkSource(
1752
- cwd: string,
1753
- visit: (rel: string, content: string) => void,
1754
- opts: { readonly includeTests?: boolean } = {},
1755
- ): Promise<void> {
1756
- const skip = opts.includeTests === true ? IGNORED_DIR : IGNORED
1757
- // List candidates first (cheap - no reads), drop the built-in ignores, then exclude gitignored paths in
1758
- // one batch before the (expensive) reads. So a gitignored generated/build tree is never read or scanned.
1759
- const rels: string[] = []
1760
- for await (const rel of new Glob("**/*.{ts,tsx,mts,cts}").scan({ cwd, dot: false })) {
1761
- if (!skip.test(rel)) rels.push(rel)
1762
- }
1763
- const ignored = await gitIgnored(cwd, rels)
1764
- for (const rel of rels) {
1765
- if (ignored.has(rel)) continue
1766
- visit(rel, await Bun.file(join(cwd, rel)).text())
1767
- }
1768
- }
1769
-
1770
- const bySite = (a: SourceFinding, b: SourceFinding): number =>
1771
- a.file.localeCompare(b.file) || a.line - b.line
1772
-
1773
- /** Collect own-API `fetch()` findings across the project. */
1774
- export async function scanProject(cwd: string): Promise<SourceFinding[]> {
1775
- const out: SourceFinding[] = []
1776
- await walkSource(cwd, (rel, content) => out.push(...scanFetchText(rel, content)))
1777
- return out.sort(bySite)
1778
- }
1779
-
1780
- // #7 - server-manifest drift. `server-manifest.ts` is a COMMITTED generated file (it bakes the route
1781
- // list + client-entry hash for a disk-less worker). If `routes/` changes but the manifest isn't
1782
- // regenerated, the worker serves a stale route table - a silent edge break that no other check catches.
1783
- // We diff each committed manifest's imported route files against the live `routes/` tree.
1784
-
1785
- // The marker comment `generateServerManifest` emits at the top of the file - identifies a generated
1786
- // manifest unambiguously (so a user file merely named `server-manifest.ts` isn't mistaken for one).
1787
- const GENERATED_MARKER = "GENERATED by @nifrajs/web generateServerManifest"
1788
- // The first route-import specifier's prefix up to `routes/` (e.g. `./`, `../`, `./src/`) - used to
1789
- // locate the routes dir relative to the manifest, and to strip to route-relative keys.
1790
- const ROUTES_PREFIX = /["'](\.{1,2}(?:\/[^"'/]+)*?\/routes\/)[^"']+["']/
1791
- // Route file extensions discovery recognises (mirrors `@nifrajs/web/fs`'s filter).
1792
- const ROUTE_FILE_EXT = /\.(tsx|jsx|svelte|vue|mdx)$/
1793
-
1794
- export interface ManifestDriftFinding {
1795
- /** The committed server-manifest file (relative to cwd). */
1796
- readonly file: string
1797
- /** Route files in `routes/` missing from the manifest (stale manifest - these routes won't serve). */
1798
- readonly missing: readonly string[]
1799
- /** Route files the manifest imports that no longer exist in `routes/` (a dangling import). */
1800
- readonly extra: readonly string[]
1801
- }
1802
-
1803
- /**
1804
- * Scan the project for committed, generated `server-manifest.ts` files and report any that have drifted
1805
- * from the live `routes/` tree. For each, the routes dir is derived from the manifest's own import
1806
- * prefix (so a manifest that imports `../routes/x` is checked against the sibling `routes/`), its route
1807
- * imports are parsed (`parseManifestRouteFiles`), and the set is diffed against the actual route files
1808
- * on disk. A clean manifest yields no finding. Returns one finding per drifted manifest. Never throws on
1809
- * a per-manifest miss (an unreadable routes dir is simply skipped).
1810
- */
1811
- export async function scanServerManifestDrift(cwd: string): Promise<ManifestDriftFinding[]> {
1812
- const { parseManifestRouteFiles, diffManifestRoutes, isManifestInSync } = await import(
1813
- "@nifrajs/web/build"
1814
- )
1815
- const findings: ManifestDriftFinding[] = []
1816
- for await (const rel of new Glob("**/server-manifest.ts").scan({ cwd, dot: false })) {
1817
- if (IGNORED.test(rel)) continue
1818
- const source = await Bun.file(join(cwd, rel)).text()
1819
- if (!source.includes(GENERATED_MARKER)) continue // not a generated manifest - skip
1820
- const prefixMatch = ROUTES_PREFIX.exec(source)
1821
- if (prefixMatch?.[1] === undefined) continue // no `routes/` imports → nothing to diff (empty app)
1822
- const routesPrefix = prefixMatch[1]
1823
- const manifestFiles = parseManifestRouteFiles(source, routesPrefix)
1824
- // The routes dir sits at `<manifest dir>/<prefix>` - resolve it relative to the manifest file.
1825
- const manifestDir = rel.includes("/") ? rel.slice(0, rel.lastIndexOf("/")) : ""
1826
- const routesDir = join(cwd, manifestDir, routesPrefix)
1827
- let discovered: string[]
1828
- try {
1829
- discovered = (
1830
- await Array.fromAsync(new Glob("**/*.{tsx,jsx,svelte,vue,mdx}").scan({ cwd: routesDir }))
1831
- )
1832
- .map((f) => f.replaceAll("\\", "/"))
1833
- .filter((f) => ROUTE_FILE_EXT.test(f))
1834
- } catch {
1835
- continue // routes dir gone/unreadable - not a drift we can assess
1836
- }
1837
- const drift = diffManifestRoutes(manifestFiles, discovered)
1838
- if (!isManifestInSync(drift)) {
1839
- findings.push({ file: rel, missing: drift.missing, extra: drift.extra })
1840
- }
1841
- }
1842
- return findings.sort((a, b) => a.file.localeCompare(b.file))
1843
- }
1844
-
1845
70
  interface TypecheckResult {
1846
71
  readonly ran: boolean
1847
72
  readonly ok: boolean
@@ -1921,129 +146,8 @@ async function typecheck(cwd: string, signal?: AbortSignal): Promise<TypecheckRe
1921
146
  }
1922
147
 
1923
148
  // `src/x.tsx(12,5): error TS2322: <message>` → one structured diagnostic.
1924
- const TSC_LINE = /^(.+?)\((\d+),\d+\):\s*(?:error|warning)\s+TS\d+:\s*(.+)$/
1925
-
1926
- /** A single machine-readable check failure - the unit an agent (or CI) acts on. */
1927
- export interface CheckDiagnostic {
1928
- readonly rule: string
1929
- /** `error` fails the gate (a real contract break); `warning` is advisory - surfaced to the agent but
1930
- * does NOT fail `nifra check`, for patterns that are sometimes intentional (a route returning a raw
1931
- * `Response`, which silently drops the typed client to `data: never` but is valid for files/redirects). */
1932
- readonly severity: "error" | "warning" | "info"
1933
- /** Stable diagnostic protocol code. The legacy `rule` remains for compatibility. */
1934
- readonly code?: string
1935
- readonly file?: string
1936
- readonly line?: number
1937
- readonly message: string
1938
- /** The canonical, rule-level fix - clean of the per-occurrence snippet, so an agent can apply it
1939
- * directly. Set for the lint rules (they have one correct fix); omitted for `typecheck` (the fix is
1940
- * specific to each type error). */
1941
- readonly fix?: string
1942
- /** A richer, agent-oriented fix hint. Diffs are only emitted when the edit is mechanical and local;
1943
- * ambiguous cases give concrete steps instead of pretending the checker can safely rewrite code. */
1944
- readonly suggestion?: CheckSuggestion
1945
- /**
1946
- * The import chain that pulls server-only code into the browser bundle, as display labels
1947
- * `[routeFile, …as-written specifiers…, sink]`. Set only on `server-only-import`.
1948
- *
1949
- * #4.4: this is now the FULL **transitive** chain - a bounded import-resolution walk (`Bun.resolveSync`
1950
- * from each file's dir, BFS the local module graph) follows `route → ../data → ../db → node:crypto`,
1951
- * matching the build leak-guard's depth (`detectNodeBuiltinsInClient` in `@nifrajs/web/build`). A
1952
- * length-2 chain (`[routeFile, specifier]`) means the route imports the sink directly. When a hop can't
1953
- * be resolved precisely (a bare pkg, a tsconfig path alias), the walk degrades to the honest direct
1954
- * edge rather than fabricating a deeper path - never a lie.
1955
- */
1956
- readonly chain?: readonly string[]
1957
- readonly evidence?: readonly string[]
1958
- readonly verify?: string
1959
- }
1960
-
1961
- export interface CheckSuggestion {
1962
- readonly kind: "edit" | "command" | "manual"
1963
- readonly title: string
1964
- readonly diff?: string
1965
- /** argv array, not a shell string, so MCP clients can run it without quoting hazards. */
1966
- readonly command?: readonly string[]
1967
- readonly steps?: readonly string[]
1968
- }
1969
-
1970
- /** The structured result of a full check - what `--json` prints and the `nifra_check` MCP tool returns. */
1971
- export interface CheckResult {
1972
- readonly ok: boolean
1973
- readonly typecheck: "pass" | "fail" | "skipped"
1974
- /** Why `typecheck` is `"skipped"` (no tsconfig.json, typescript not installed, lints-only mode).
1975
- * Absent when the typecheck ran. Echoed in the human report so a skip is never a dim mystery. */
1976
- readonly typecheckNote?: string
1977
- readonly diagnostics: readonly CheckDiagnostic[]
1978
- /** Normalized diagnostics with stable codes for agents and external renderers. */
1979
- readonly structuredDiagnostics?: readonly Diagnostic[]
1980
- /**
1981
- * Which bundler this app's phases run on, read statically from the config, and how nifra concluded
1982
- * it. Returned even when nothing is wrong: an agent reading this project has to know which plugin
1983
- * slot is live and which toolchain compiles a component before its next edit, and every `pipeline`
1984
- * diagnostic below is only interpretable against it. Absent when the directory is not a nifra app.
1985
- */
1986
- readonly pipeline?: PipelineReport
1987
- /** Intentional non-typed mount prefixes declared in `nifra.check.json` (e.g. `/auth` for a mounted
1988
- * better-auth). Echoed here so `--json` / the MCP tool / the report can show what the typed-client scan
1989
- * deliberately skipped - a suppressed prefix stays auditable instead of silently hiding real drift. */
1990
- readonly externalMounts?: readonly string[]
1991
- /** Active per-rule overrides from `nifra.check.json` `rules`, echoed verbatim so a retagged or
1992
- * suppressed finding stays auditable in `--json`, the MCP tool, and the human report - config can
1993
- * lower (or raise) the gate, but never invisibly. */
1994
- readonly ruleOverrides?: Readonly<Record<string, RuleOverride>>
1995
- /** Set only when the caller passed `maxDiagnostics` and there were more - `diagnostics` then holds the
1996
- * first `shown` of `total`. It caps the serialized size so the `nifra_check` MCP tool can't emit a
1997
- * message large enough to break the stdio transport; fix the shown diagnostics and re-run for the rest. */
1998
- readonly truncated?: { readonly shown: number; readonly total: number }
1999
- }
2000
-
2001
- /**
2002
- * Pre-resolved route-assurance inputs, so the same reflection that `nifra assure` / `nifra levels`
2003
- * already ran can feed `check`'s capability + trust-manifest diagnostics instead of a second pass.
2004
- * Supplied by {@link collectProjectVerification}. When omitted, `collectCheckResult` loads and computes
2005
- * these itself (the standalone `nifra_check` MCP path); the two routes produce byte-identical results.
2006
- */
2007
- export interface CheckAssuranceContext {
2008
- /** Whether `nifra.assurance.ts` exists: the gate for running the assurance-fed diagnostics at all. */
2009
- readonly present: boolean
2010
- /** The loaded config, when it loaded. Absent when the file is missing or {@link error} is set. */
2011
- readonly config?: AssuranceConfig
2012
- /** The failure from loading/evaluating the config, surfaced as a `capability-config` diagnostic. */
2013
- readonly error?: unknown
2014
- /** `evaluateRouteAssurance` over the config's source + policy (drives the trust-manifest check). */
2015
- readonly routeAssurance?: AssuranceReport
2016
- /** Static capability provenance, when the config declares a capabilities policy. */
2017
- readonly capability?: CapabilityProjectReport
2018
- /** Canonical token-only evidence reused by manifest and other offline projections. */
2019
- readonly evidence?: ProjectEvidenceSnapshot
2020
- }
2021
-
2022
- /** Optional per-project `nifra.check.json` - pure data (no code execution), so it's safe to read before
2023
- * the app is built or even importable, preserving check's pre-`loadApp` invariant. */
2024
- export interface CheckConfig {
2025
- readonly externalMounts: readonly string[]
2026
- readonly rules: Readonly<Record<string, RuleOverride>>
2027
- }
2028
-
2029
- /**
2030
- * One entry of `nifra.check.json` `rules`, keyed by legacy rule name (`response-route`) or stable
2031
- * NF- code (`NF-S002`) - one key retags the finding in both diagnostic views. `severity: "off"`
2032
- * drops the rule's findings; `ignore` drops findings whose file matches any of the globs. Overrides
2033
- * are applied centrally BEFORE `ok` is computed and echoed in the result and the human report -
2034
- * configuration can lower (or raise) the gate, but never invisibly.
2035
- */
2036
- export interface RuleOverride {
2037
- readonly severity?: "error" | "warn" | "info" | "off"
2038
- readonly ignore?: readonly string[]
2039
- }
2040
-
2041
149
  const OVERRIDE_SEVERITIES: readonly string[] = ["error", "warn", "info", "off"]
2042
150
 
2043
- /** Load `nifra.check.json` if present. Fail-open + total: absent → empty; malformed JSON → empty plus a
2044
- * parse `error`; an invalid entry → skipped plus a `warnings` note the caller surfaces as a `warning`
2045
- * diagnostic (never throws, never blocks the gate). Mount entries are normalized to bare path prefixes
2046
- * (`/auth/**` and `/auth/` both become `/auth`). */
2047
151
  async function loadCheckConfig(
2048
152
  cwd: string,
2049
153
  ): Promise<{ config: CheckConfig; error?: string; warnings: readonly string[] }> {
@@ -2116,160 +220,6 @@ async function loadCheckConfig(
2116
220
  }
2117
221
  }
2118
222
 
2119
- const UNTYPED_CLIENT_HINT =
2120
- 'client("…") without a type argument - write client<typeof app>("…") (or client(contract, url)) so the compiler can catch drift'
2121
- const FETCH_HINT =
2122
- "hand-rolled fetch() to your own API - call it through client<typeof app> (from @nifrajs/client) so the compiler catches drift"
2123
- const SERVER_IMPORT_HINT =
2124
- "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"
2125
- const RESPONSE_ROUTE_HINT =
2126
- "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); for a stream use a typed SSE route (`app.sse(...)`), which keeps typed events; or, if a raw Response is intended (file/redirect), add `{ response: t.… }` or a `// nifra-expect raw-response` comment to mark it and silence this"
2127
- const PIPELINE_DOC_HINT =
2128
- "nifra runs one bundler per phase - `vitePlugins` feed Vite, `clientPlugins`/`serverPlugins` feed Bun, and the file `nifra build` imports the adapter from is bundled into the server. See the Gotchas section of the Dev & HMR guide."
2129
- const UNDECLARED_DEP_HINT =
2130
- "imported package is not declared in package.json dependencies - run bun add to declare it"
2131
- const SQL_COMPILER_MISSING_HINT =
2132
- "the interpolated-SQL rule did NOT run - it parses source with the TypeScript compiler, which is an optional peer and is not installed here. This report says nothing about SQL injection either way. Install it with `bun add -d typescript`"
2133
- const SQL_INTERPOLATION_EXAMPLE = "$" + "{value}"
2134
- const INTERPOLATED_SQL_HINT = `SQL built by interpolating a value into the statement text - the value becomes statement, not a parameter, so anything the caller controls can end the literal and continue as SQL. Pass it as a bound parameter (\`?\` / \`$1\` and an argument), or use your driver's tagged template (sql\`… ${SQL_INTERPOLATION_EXAMPLE} …\`), which binds the substitutions for you`
2135
- const MANIFEST_DRIFT_HINT =
2136
- "server-manifest.ts is out of sync with routes/ - re-run the build to regenerate it (a disk-less worker bakes this route table, so the drift is a silent edge break), then commit it"
2137
- const TRUST_MANIFEST_DRIFT_HINT =
2138
- "nifra.manifest.json is missing, invalid, or out of sync - run `nifra manifest emit`, review it, and commit the regenerated trust artifact"
2139
- const CAPABILITY_HINT =
2140
- "effect/capability assurance failed - align the route declaration with approved adapter provenance; never bypass an owned effect seam"
2141
-
2142
- function oneLineDiff(file: string, line: number, before: string, after: string): string {
2143
- return `--- ${file}:${line}\n+++ ${file}:${line}\n@@\n-${before}\n+${after}`
2144
- }
2145
-
2146
- function untypedClientSuggestion(f: SourceFinding): CheckSuggestion {
2147
- const replacement = f.snippet.replace(/(?<![.\w])client\s*\(/, "client<typeof app>(")
2148
- return replacement === f.snippet
2149
- ? {
2150
- kind: "manual",
2151
- title: "Add the app type argument to the client factory",
2152
- steps: [
2153
- 'Change `client("...")` to `client<typeof app>("...")`.',
2154
- "Make sure the backend app type is imported or otherwise in scope.",
2155
- ],
2156
- }
2157
- : {
2158
- kind: "edit",
2159
- title: "Insert `<typeof app>` into the client factory call",
2160
- diff: oneLineDiff(f.file, f.line, f.snippet, replacement),
2161
- steps: ["Make sure the backend app type is imported or otherwise in scope."],
2162
- }
2163
- }
2164
-
2165
- function routeKey(method: string, path: string): string {
2166
- return `${method.toUpperCase()} ${path}`
2167
- }
2168
-
2169
- function typedClientCall(method: string, path: string): string {
2170
- const segs = path.split("/").filter((seg) => seg !== "")
2171
- let chain = "api"
2172
- if (segs.length === 0) chain += ".index"
2173
- else {
2174
- for (const seg of segs) {
2175
- chain += IDENT.test(seg) ? `.${seg}` : `[${JSON.stringify(seg)}]`
2176
- }
2177
- }
2178
- return `${chain}.${method.toLowerCase()}()`
2179
- }
2180
-
2181
- function staticRouteMap(routes: readonly StaticRouteFinding[]): Map<string, StaticRouteFinding[]> {
2182
- const out = new Map<string, StaticRouteFinding[]>()
2183
- for (const route of routes) {
2184
- if (route.path.includes(":") || route.path.includes("*")) continue
2185
- const key = routeKey(route.method, route.path)
2186
- const bucket = out.get(key)
2187
- if (bucket === undefined) out.set(key, [route])
2188
- else bucket.push(route)
2189
- }
2190
- return out
2191
- }
2192
-
2193
- function ownFetchEditSuggestion(
2194
- f: SourceFinding,
2195
- routes: Map<string, StaticRouteFinding[]>,
2196
- ): CheckSuggestion | undefined {
2197
- const call = parseSimpleFetchCall(f.snippet)
2198
- if (call === undefined || !SIMPLE_REWRITE_METHODS.has(call.method)) return undefined
2199
- const matches = routes.get(routeKey(call.method, call.path))
2200
- if (matches === undefined || matches.length !== 1) return undefined
2201
- const replacementCall = typedClientCall(call.method, call.path)
2202
- const replacement = `${f.snippet.slice(0, call.start)}${replacementCall}${f.snippet.slice(call.end)}`
2203
- if (replacement === f.snippet) return undefined
2204
- const route = matches[0]
2205
- if (route === undefined) return undefined
2206
- return {
2207
- kind: "edit",
2208
- title: "Rewrite simple own-API fetch to the typed nifra client",
2209
- diff: oneLineDiff(f.file, f.line, f.snippet, replacement),
2210
- steps: [
2211
- `Matched ${route.method} ${route.path} at ${route.file}:${route.line}.`,
2212
- "Use an in-scope typed client named `api` (`client<typeof app>(baseUrl)` or the route loader/action `api`).",
2213
- "Update downstream `Response` handling to branch on `{ ok, data, error }` if this variable is used later.",
2214
- ],
2215
- }
2216
- }
2217
-
2218
- function ownFetchSuggestion(
2219
- f: SourceFinding,
2220
- routes: Map<string, StaticRouteFinding[]>,
2221
- ): CheckSuggestion {
2222
- const exact = ownFetchEditSuggestion(f, routes)
2223
- if (exact !== undefined) return exact
2224
- return {
2225
- kind: "manual",
2226
- title: "Replace own-API fetch with the typed nifra client",
2227
- steps: [
2228
- "Call `nifra_routes` or read `nifra://routes` for the exact typed-client call form.",
2229
- "Create `const api = client<typeof app>(baseUrl)` from `@nifrajs/client`.",
2230
- "Replace the relative `fetch()` call with the generated `api...get/post/...` call and branch on `{ ok, data, error }`.",
2231
- ],
2232
- }
2233
- }
2234
-
2235
- function serverImportSuggestion(
2236
- specifier: string,
2237
- chain: readonly string[],
2238
- fallback: boolean,
2239
- ): CheckSuggestion {
2240
- const sink = chain[chain.length - 1] ?? specifier
2241
- // Surface the resolved chain in the fix steps so the agent sees the full path (`route → ../data →
2242
- // ../db → node:crypto`) and which module to cut - not just the route's own top-level import.
2243
- const chainStep =
2244
- chain.length > 2
2245
- ? fallback
2246
- ? `Server-only code reaches this route through \`${chain.join(" → ")}\` (the deeper chain couldn't be resolved precisely - trace it from \`${specifier}\`).`
2247
- : `Server-only code reaches this route transitively: \`${chain.join(" → ")}\`. The sink is \`${sink}\`; break the chain at the first hop (\`${specifier}\`) or move the sink behind the server boundary.`
2248
- : undefined
2249
- return {
2250
- kind: "manual",
2251
- title: "Move server-only code behind the route server boundary",
2252
- steps: [
2253
- ...(chainStep !== undefined ? [chainStep] : []),
2254
- `Remove the top-level \`import … from "${specifier}"\` from this route module (it's bundled for the browser).`,
2255
- "Access backend/data work through the route `loader`/`action` context (`api`, `env`, or project server context).",
2256
- `If a direct module import is unavoidable, lazy-load it (\`await import("${specifier}")\`) inside the server-only loader/action path.`,
2257
- ],
2258
- }
2259
- }
2260
-
2261
- function responseRouteSuggestion(): CheckSuggestion {
2262
- return {
2263
- kind: "manual",
2264
- title: "Preserve typed-client response inference",
2265
- steps: [
2266
- "Prefer returning a plain object from JSON routes; nifra serializes it for you.",
2267
- "For a stream, use a typed SSE route - `app.sse(...)` (or `sse(c, run)` from `@nifrajs/core/server`) - which keeps typed events instead of collapsing the client to `data: never`.",
2268
- "If this route must return a raw Response (redirect, file), declare an explicit response schema, or add a `// nifra-expect raw-response` comment above the return to mark it intentional and silence this advisory.",
2269
- ],
2270
- }
2271
- }
2272
-
2273
223
  interface CheckCollectionOptions {
2274
224
  readonly lintsOnly?: boolean
2275
225
  readonly signal?: AbortSignal
@@ -2370,668 +320,13 @@ async function buildProjectScan(
2370
320
 
2371
321
  /** Run the three checks and assemble a structured, machine-readable result. The single source the CLI
2372
322
  * report, `--json`, and the MCP tool all render from. */
323
+ /** Run the scan, freeze its facts, and project them into the public diagnostic result. */
2373
324
  export async function collectCheckResult(
2374
325
  cwd: string,
2375
326
  opts: CheckCollectionOptions = {},
2376
327
  ): Promise<CheckResult> {
2377
328
  const scan = await buildProjectScan(cwd, opts)
2378
- const factsSeed = scan.facts
2379
- const { fetches, untypedClients, removedImports, responseRoutes, interpolatedSql } =
2380
- factsSeed.sourceFindings
2381
- const staticRoutes = factsSeed.routes
2382
- const serverImports = factsSeed.importGraph
2383
- const { doctor: dr, manifestDrift } = factsSeed.packages
2384
- const { checkConfig } = factsSeed.policies
2385
- const tc = scan.typecheck
2386
- const sqlCompiler = scan.sqlCompiler
2387
- const checkConfigError = scan.checkConfigError
2388
- const checkConfigWarnings = scan.checkConfigWarnings
2389
- const diagnostics: CheckDiagnostic[] = []
2390
- const structuredExtras: Diagnostic[] = []
2391
- if (checkConfigError !== undefined) {
2392
- diagnostics.push({
2393
- rule: "check-config",
2394
- severity: "warning",
2395
- file: "nifra.check.json",
2396
- message: `nifra.check.json could not be parsed (${checkConfigError}) - its external-mount allowlist was ignored`,
2397
- fix: "Fix the JSON syntax in nifra.check.json",
2398
- })
2399
- }
2400
- for (const warning of checkConfigWarnings) {
2401
- diagnostics.push({
2402
- rule: "check-config",
2403
- severity: "warning",
2404
- file: "nifra.check.json",
2405
- message: `nifra.check.json: ${warning}`,
2406
- fix: "Fix the entry in nifra.check.json",
2407
- })
2408
- }
2409
- if (tc.missingTypeScript === true) {
2410
- // A tsconfig with no reachable `typescript` install is a broken gate, not a benign skip: the
2411
- // contract check the project asked for (by having a tsconfig) silently didn't run. Fail closed.
2412
- diagnostics.push({
2413
- rule: "typecheck",
2414
- severity: "error",
2415
- file: "tsconfig.json",
2416
- message:
2417
- "tsconfig.json is present but no `typescript` install was found from this directory upward - the typecheck gate did NOT run",
2418
- fix: "bun add -d typescript",
2419
- suggestion: {
2420
- kind: "command",
2421
- title: "Install TypeScript so the contract gate can run",
2422
- command: ["bun", "add", "-d", "typescript"],
2423
- },
2424
- })
2425
- }
2426
- if (tc.ran && !tc.ok) {
2427
- const lines = (tc.output ?? "").split("\n")
2428
- let matched = false
2429
- for (const l of lines) {
2430
- const m = TSC_LINE.exec(l.trim())
2431
- if (m) {
2432
- matched = true
2433
- diagnostics.push({
2434
- rule: "typecheck",
2435
- severity: "error",
2436
- file: m[1] as string,
2437
- line: Number(m[2]),
2438
- message: m[3] as string,
2439
- suggestion: {
2440
- kind: "manual",
2441
- title: "Fix the TypeScript contract error",
2442
- steps: [
2443
- "Open the reported file and line.",
2444
- "Align the handler, route schema, or typed-client call with the compiler error.",
2445
- "Run `nifra_check` again after the edit.",
2446
- ],
2447
- },
2448
- })
2449
- }
2450
- }
2451
- if (!matched)
2452
- diagnostics.push({
2453
- rule: "typecheck",
2454
- severity: "error",
2455
- message: tc.output || "typecheck failed",
2456
- suggestion: {
2457
- kind: "manual",
2458
- title: "Fix the TypeScript contract error",
2459
- steps: ["Run `tsc --noEmit` locally for the full compiler output."],
2460
- },
2461
- })
2462
- }
2463
- const routes = staticRouteMap(staticRoutes)
2464
- for (const f of [...fetches].sort(bySite)) {
2465
- diagnostics.push({
2466
- rule: "typed-client",
2467
- severity: "error",
2468
- file: f.file,
2469
- line: f.line,
2470
- message: `${f.snippet} - ${FETCH_HINT}`,
2471
- fix: FETCH_HINT,
2472
- suggestion: ownFetchSuggestion(f, routes),
2473
- })
2474
- }
2475
- for (const f of [...removedImports].sort(bySite)) {
2476
- const entry = REMOVED_IMPORTS.find(
2477
- (candidate) =>
2478
- f.snippet.includes(`"${candidate.specifier}`) ||
2479
- f.snippet.includes(`'${candidate.specifier}`),
2480
- )
2481
- diagnostics.push({
2482
- rule: "removed-import",
2483
- severity: "error",
2484
- file: f.file,
2485
- line: f.line,
2486
- message: `${f.snippet} - removed in nifra ${entry?.since ?? "2.0"}: ${entry?.replacement ?? "see the changelog"}`,
2487
- fix: entry?.replacement ?? "see the changelog",
2488
- })
2489
- }
2490
- for (const f of [...untypedClients].sort(bySite)) {
2491
- diagnostics.push({
2492
- rule: "untyped-client",
2493
- severity: "error",
2494
- file: f.file,
2495
- line: f.line,
2496
- message: `${f.snippet} - ${UNTYPED_CLIENT_HINT}`,
2497
- fix: UNTYPED_CLIENT_HINT,
2498
- suggestion: untypedClientSuggestion(f),
2499
- })
2500
- }
2501
- for (const f of [...serverImports].sort(bySite)) {
2502
- // #4.4: the FULL transitive chain the import-resolution walk found - `route → ../data → ../db →
2503
- // node:crypto`, matching the build leak-guard's depth - instead of just the direct edge. The chain's
2504
- // tail is the actual server-only sink; the head is the route. When a precise resolve wasn't possible
2505
- // (a bare pkg / path alias), `fallback` is set and the chain degrades to the honest direct edge.
2506
- const chain = f.chain
2507
- const sink = chain[chain.length - 1] ?? f.specifier
2508
- diagnostics.push({
2509
- rule: "server-only-import",
2510
- severity: "error",
2511
- file: f.file,
2512
- line: f.line,
2513
- message: `${f.snippet} - server-only "${sink}" reaches the browser bundle via ${chain.join(" → ")}${f.fallback ? " (direct edge - couldn't resolve the transitive chain precisely)" : ""}; ${SERVER_IMPORT_HINT}`,
2514
- fix: SERVER_IMPORT_HINT,
2515
- chain,
2516
- suggestion: serverImportSuggestion(f.specifier, chain, f.fallback),
2517
- })
2518
- }
2519
- if (sqlCompiler === undefined) {
2520
- // A security rule that could not run must say so. Silence here is indistinguishable from a clean
2521
- // result, and this is the one rule whose clean result means "no SQL injection was found".
2522
- diagnostics.push({
2523
- rule: "interpolated-sql",
2524
- severity: "warning",
2525
- message: SQL_COMPILER_MISSING_HINT,
2526
- fix: SQL_COMPILER_MISSING_HINT,
2527
- suggestion: {
2528
- kind: "command",
2529
- title: "Install TypeScript so the SQL rule can run",
2530
- command: ["bun add -d typescript"],
2531
- },
2532
- })
2533
- }
2534
- for (const f of [...interpolatedSql].sort(bySite)) {
2535
- diagnostics.push({
2536
- rule: "interpolated-sql",
2537
- severity: "error",
2538
- file: f.file,
2539
- line: f.line,
2540
- message: `${f.snippet} - ${INTERPOLATED_SQL_HINT}`,
2541
- fix: "bind the value as a parameter instead of interpolating it into the statement",
2542
- suggestion: {
2543
- kind: "manual",
2544
- title: "Bind the value instead of interpolating it",
2545
- steps: [
2546
- "Replace the interpolation with a placeholder your driver understands (`?` for SQLite/MySQL, `$1` for Postgres).",
2547
- "Pass the value as an argument alongside the statement, so the driver binds it.",
2548
- `Or switch to the driver's tagged template (sql\`… ${SQL_INTERPOLATION_EXAMPLE} …\`): the tag receives substitutions separately and binds them.`,
2549
- "An identifier that genuinely cannot be bound (a table or column name) must be checked against an allowlist you control, never taken from the request.",
2550
- "If the statement is dynamic but every value is already bound (generating `($1),($2),…` for a batch insert), mark it with a `// nifra-expect sql-dynamic: <reason>` comment on the line above - the reason is required.",
2551
- ],
2552
- },
2553
- })
2554
- }
2555
- // Advisory - surfaced but NOT folded into `ok`, so it never fails the gate (a raw Response is valid).
2556
- for (const f of [...responseRoutes].sort(bySite)) {
2557
- diagnostics.push({
2558
- rule: "response-route",
2559
- severity: "warning",
2560
- file: f.file,
2561
- line: f.line,
2562
- message: `${f.snippet} - ${RESPONSE_ROUTE_HINT}`,
2563
- fix: RESPONSE_ROUTE_HINT,
2564
- suggestion: responseRouteSuggestion(),
2565
- })
2566
- }
2567
- if (dr.ran) {
2568
- for (const f of dr.findings) {
2569
- diagnostics.push({
2570
- rule: "undeclared-dependency",
2571
- severity: "error",
2572
- file: f.file,
2573
- line: f.line,
2574
- message: `imports ${f.package} which is not declared in package.json - ${UNDECLARED_DEP_HINT}`,
2575
- fix: `add ${f.package} to package.json dependencies`,
2576
- suggestion: {
2577
- kind: "command",
2578
- title: `Declare ${f.package} in package.json`,
2579
- command: ["bun", "add", f.package],
2580
- },
2581
- })
2582
- }
2583
- for (const finding of dr.duplicateInstalls) {
2584
- const copies = finding.copies.map((copy) => `${copy.version} at ${copy.path}`).join("; ")
2585
- diagnostics.push({
2586
- rule: "duplicate-install",
2587
- severity: "error",
2588
- message: `${finding.package} identity preflight found ${finding.cause} (${copies}) - ${finding.explanation}${finding.topology === undefined ? "" : `. Topology: ${finding.topology}`}${finding.scope === undefined ? "" : `. Scope: ${finding.scope}`}`,
2589
- fix: finding.remediation,
2590
- suggestion: {
2591
- kind: "manual",
2592
- title: `Deduplicate ${finding.package}`,
2593
- steps: [
2594
- "Align workspace dependency and peer ranges on one compatible version.",
2595
- "Remove stale nested installs and run the package manager from the workspace root.",
2596
- 'If the copies come from a linked sibling repository and collapsing them is not an option, declare the package: "nifra": { "singleCopy": ["<package>"] } in package.json, plus the bunfig.toml preload.',
2597
- "Run `nifra doctor` again; do not suppress an undeclared same-version duplicate path.",
2598
- ],
2599
- },
2600
- })
2601
- }
2602
- // Declared single-copy: the resolver collapses these before anything loads, so the build is sound
2603
- // and the gate stays green. It is still worth a line - the guarantee lives in a declaration now,
2604
- // and a reader who deletes it gets a null hook dispatcher with no obvious cause.
2605
- for (const finding of dr.deduplicatedInstalls ?? []) {
2606
- const copies = finding.copies.map((copy) => copy.path).join("; ")
2607
- diagnostics.push({
2608
- rule: "duplicate-install",
2609
- severity: "warning",
2610
- message: `${finding.package} is installed at ${finding.copies.length} paths (${copies}) and is declared single-copy - nifra resolves every duplicate to this app's copy`,
2611
- fix: finding.remediation,
2612
- })
2613
- }
2614
- // Advisory (never fails the gate): while actively editing a linked package its dist is always
2615
- // momentarily behind. The finding earns its keep when a dev server is about to start against it -
2616
- // Bun reads live `src` while Vite's SSR runner reads the artifact, so a stale one 500s inside
2617
- // framework/shared-package code and reads exactly like an upstream regression.
2618
- for (const f of dr.staleDists) {
2619
- // The rebuild is only actionable with the LOCATION: a workspace link routinely points into a
2620
- // sibling repo, so "rebuild @nifrajs/core" left the developer to go find the checkout and guess
2621
- // the script. Name the directory and the package's own script, or say plainly that it declares
2622
- // none - never suggest a command that would fail.
2623
- const rebuild =
2624
- f.buildScript === undefined
2625
- ? `${f.packageDir} declares no build script - build it the way that package expects`
2626
- : `cd ${f.packageDir} && bun run ${f.buildScript} (or \`nifra fix --code NF-C010\`)`
2627
- diagnostics.push({
2628
- rule: "stale-workspace-dist",
2629
- severity: "warning",
2630
- message: f.missing
2631
- ? `${f.package} was never built - ${f.distFile} is missing, but its export map serves it to Vite SSR/node consumers while Bun reads src - ${rebuild}`
2632
- : `${f.package} has a stale build artifact - ${f.distFile} is ${f.behindSeconds}s older than ${f.sourceFile}, and Vite SSR/node consumers read the artifact while Bun reads src - ${rebuild}`,
2633
- fix: rebuild,
2634
- evidence: [f.package, f.distFile, f.sourceFile, f.packageDir],
2635
- suggestion: {
2636
- kind: "manual",
2637
- title: `Rebuild ${f.package}`,
2638
- steps: [
2639
- rebuild,
2640
- "Only workspace-linked installs drift; npm tarballs are immutable and never flagged.",
2641
- ],
2642
- },
2643
- })
2644
- }
2645
- }
2646
- // The two-pipeline rule, read from the config as TEXT (doctor collects it - see ./pipeline-report.ts).
2647
- // `loadApp` already refuses a misplaced plugin, but only when something loads the app; a check that
2648
- // never executes project code, and CI that never starts a dev server, would otherwise meet these for
2649
- // the first time as a production server that built cleanly and died at startup.
2650
- for (const f of dr.pipeline?.findings ?? []) {
2651
- diagnostics.push({
2652
- rule: "pipeline",
2653
- severity: f.severity,
2654
- file: f.file,
2655
- ...(f.line !== undefined ? { line: f.line } : {}),
2656
- message: f.message,
2657
- fix: f.fix,
2658
- suggestion: { kind: "manual", title: f.fix, steps: [PIPELINE_DOC_HINT] },
2659
- })
2660
- }
2661
- // #7: a committed server-manifest.ts that drifted from routes/ - name the exact missing/extra routes.
2662
- for (const f of manifestDrift) {
2663
- const parts: string[] = []
2664
- if (f.missing.length > 0) parts.push(`missing from manifest: ${f.missing.join(", ")}`)
2665
- if (f.extra.length > 0) parts.push(`stale in manifest: ${f.extra.join(", ")}`)
2666
- diagnostics.push({
2667
- rule: "server-manifest-drift",
2668
- severity: "error",
2669
- file: f.file,
2670
- message: `${f.file} drifted from routes/ (${parts.join("; ")}) - ${MANIFEST_DRIFT_HINT}`,
2671
- fix: MANIFEST_DRIFT_HINT,
2672
- evidence: [...f.missing, ...f.extra],
2673
- suggestion: {
2674
- kind: "manual",
2675
- title: "Regenerate the committed server manifest",
2676
- steps: [
2677
- "Re-run your build (`nifra build --target <t>` or your build script) - it regenerates server-manifest.ts from the current routes/.",
2678
- "Commit the updated server-manifest.ts.",
2679
- ],
2680
- },
2681
- })
2682
- }
2683
-
2684
- // G+B+D+F: when the project opts into capability assurance, `nifra check` becomes the static
2685
- // provenance firewall as well as the typed-contract gate. Loading is explicit/config-owned; projects
2686
- // without nifra.assurance.ts retain the historical scan and hot path unchanged.
2687
- const provided = opts.assurance
2688
- let applicationRulePacks: readonly import("./rules/index.ts").RulePack[] = []
2689
- let loadedPolicy = provided
2690
- let loadedCapability: CapabilityProjectReport | undefined = provided?.capability
2691
- const assuranceConfigPath = join(cwd, "nifra.assurance.ts")
2692
- if (provided !== undefined ? provided.present : existsSync(assuranceConfigPath)) {
2693
- try {
2694
- // Either the shared reflection from `collectProjectVerification` (no second pass) or, on the
2695
- // standalone path, loaded + reflected here. Both branches yield the same config + evidence.
2696
- let config: AssuranceConfig
2697
- let project: CapabilityProjectReport | undefined
2698
- let routeAssurance: AssuranceReport | undefined
2699
- let evidence: ProjectEvidenceSnapshot | undefined
2700
- if (provided !== undefined) {
2701
- // A load/evaluate failure travels as `provided.error`; re-throwing lands it in the same
2702
- // capability-config diagnostic the standalone catch produces.
2703
- if (provided.error !== undefined) throw provided.error
2704
- config = provided.config as AssuranceConfig
2705
- project = provided.capability
2706
- routeAssurance = provided.routeAssurance
2707
- evidence = provided.evidence
2708
- } else {
2709
- const { loadAssuranceConfig } = await import("./assure.ts")
2710
- config = await loadAssuranceConfig(cwd)
2711
- if (config.capabilities !== undefined) {
2712
- const { collectCapabilityProjectReport } = await import("./capabilities-tool.ts")
2713
- project = await collectCapabilityProjectReport(cwd, config.source, config.capabilities)
2714
- }
2715
- loadedPolicy = {
2716
- present: true,
2717
- config,
2718
- ...(routeAssurance === undefined ? {} : { routeAssurance }),
2719
- ...(project === undefined ? {} : { capability: project }),
2720
- ...(evidence === undefined ? {} : { evidence }),
2721
- }
2722
- loadedCapability = project
2723
- }
2724
- applicationRulePacks = parseRulePacks(config.rulePacks)
2725
- const capabilityReport = project?.report
2726
- if (config.capabilities !== undefined && project !== undefined) {
2727
- for (const finding of project.report.findings) {
2728
- const violation =
2729
- finding.code === "forbidden-effect-import"
2730
- ? project.violations.find(
2731
- (candidate) =>
2732
- candidate.method === finding.method && candidate.path === finding.path,
2733
- )
2734
- : undefined
2735
- const truncation =
2736
- finding.code === "provenance-truncated"
2737
- ? project.truncations.find(
2738
- (candidate) =>
2739
- candidate.method === finding.method && candidate.path === finding.path,
2740
- )
2741
- : undefined
2742
- // An unmatched seam is a policy defect, not a route defect: the fix is in the policy file,
2743
- // so it gets its own steps instead of the route-side provenance guidance.
2744
- const seamFix =
2745
- finding.code === "unmatched-provenance-seam"
2746
- ? "Write the seam exactly as the code imports it, or delete the rule."
2747
- : undefined
2748
- diagnostics.push({
2749
- rule: "capability-assurance",
2750
- severity: "error",
2751
- ...(violation !== undefined
2752
- ? { file: violation.module, chain: violation.chain }
2753
- : truncation !== undefined
2754
- ? { chain: truncation.chain }
2755
- : {}),
2756
- message: `${finding.message}${seamFix === undefined ? ` - ${CAPABILITY_HINT}` : ""}`,
2757
- fix: seamFix ?? CAPABILITY_HINT,
2758
- suggestion:
2759
- seamFix === undefined
2760
- ? {
2761
- kind: "manual",
2762
- title: "Restore declared effect provenance",
2763
- steps: [
2764
- "Route effectful work through an import listed in capabilities.provenance.imports.",
2765
- "Declare the exact capability token on the route; do not widen unrelated routes in the same file.",
2766
- "For domain writes, add the adapter the capability definition requires: `schema.idempotency` for the `request` tier, `.use(durableCommand({ journal }))` from @nifrajs/middleware for the `durable` tier.",
2767
- "Run `nifra capabilities snapshot` only after assurance passes, then review the lockfile diff.",
2768
- ],
2769
- }
2770
- : {
2771
- kind: "manual",
2772
- title: "Point the provenance rule at a module that exists",
2773
- steps: [
2774
- "Copy the specifier from the import statement itself - it is matched as written, with no extension or index resolution.",
2775
- "Use a trailing `/*` when the seam is a directory of modules (`@myorg/db/*`).",
2776
- "For a routeModules entry, give the project-relative path of the file that implements the route.",
2777
- "Delete the rule if the seam it governed is gone; leaving it in place proves nothing.",
2778
- ],
2779
- },
2780
- })
2781
- }
2782
- }
2783
- if (config.manifest !== undefined) {
2784
- const { buildNifraManifest, parseNifraManifest, serializeNifraManifest } = await import(
2785
- "@nifrajs/core/manifest"
2786
- )
2787
- const assurance =
2788
- routeAssurance ??
2789
- (await import("@nifrajs/core/assurance")).evaluateRouteAssurance(
2790
- config.source,
2791
- config.policy,
2792
- {
2793
- ...(config.capabilities !== undefined
2794
- ? { definitions: config.capabilities.definitions }
2795
- : {}),
2796
- },
2797
- )
2798
- evidence ??= snapshotProjectEvidence(config.source, {
2799
- assurance,
2800
- ...(capabilityReport !== undefined ? { capabilities: capabilityReport } : {}),
2801
- })
2802
- const path = resolve(cwd, config.manifest.path ?? "nifra.manifest.json")
2803
- let message: string | undefined
2804
- if (!assurance.ok || (capabilityReport !== undefined && !capabilityReport.ok)) {
2805
- message =
2806
- "the configured assurance policy is failing, so a trusted manifest cannot be built"
2807
- } else if (!existsSync(path)) {
2808
- message = "the configured trust manifest is missing"
2809
- } else {
2810
- try {
2811
- const current = await buildNifraManifest({
2812
- evidence,
2813
- assurance,
2814
- ...(capabilityReport !== undefined ? { capabilities: capabilityReport } : {}),
2815
- })
2816
- const storedText = await Bun.file(path).text()
2817
- const stored = await parseNifraManifest(storedText, path)
2818
- const expectedText = `${serializeNifraManifest(current)}\n`
2819
- if (storedText !== expectedText || stored.contentHash !== current.contentHash) {
2820
- message = "the configured trust manifest does not match live route reflection"
2821
- }
2822
- } catch (error) {
2823
- message = `the configured trust manifest is invalid: ${error instanceof Error ? error.message : String(error)}`
2824
- }
2825
- }
2826
- if (message !== undefined) {
2827
- diagnostics.push({
2828
- rule: "manifest-drift",
2829
- severity: "error",
2830
- file: path,
2831
- message: `${message} - ${TRUST_MANIFEST_DRIFT_HINT}`,
2832
- fix: TRUST_MANIFEST_DRIFT_HINT,
2833
- suggestion: {
2834
- kind: "command",
2835
- title: "Regenerate the signed-manifest input artifact",
2836
- command: ["nifra", "manifest", "emit"],
2837
- steps: [
2838
- "Review the route, assurance, capability, and classification delta before committing it.",
2839
- ],
2840
- },
2841
- })
2842
- }
2843
- }
2844
- } catch (err) {
2845
- loadedPolicy = { present: true, error: err }
2846
- diagnostics.push({
2847
- rule: "capability-config",
2848
- severity: "error",
2849
- file: "nifra.assurance.ts",
2850
- message: `capability assurance config could not be evaluated: ${err instanceof Error ? err.message : String(err)}`,
2851
- suggestion: {
2852
- kind: "manual",
2853
- title: "Repair the assurance config",
2854
- steps: [
2855
- "Ensure nifra.assurance.ts default-exports defineAssuranceConfig({ source, policy, capabilities }).",
2856
- "Fix configuration/import errors; the provenance firewall fails closed when its policy cannot load.",
2857
- ],
2858
- },
2859
- })
2860
- }
2861
- }
2862
-
2863
- try {
2864
- const { checkContractsLock } = await import("./contracts.ts")
2865
- if (!existsSync(join(cwd, "backend.ts")) && !existsSync(join(cwd, "contracts.lock.json"))) {
2866
- throw new Error("contract source not configured")
2867
- }
2868
- const contract = await checkContractsLock(cwd)
2869
- if (!contract.present) {
2870
- structuredExtras.push(
2871
- diagnostic({
2872
- code: "NF-K001",
2873
- severity: "info",
2874
- message: "no contract lock; run `nifra contracts snapshot` to enable drift detection",
2875
- verify: "nifra contracts snapshot",
2876
- }),
2877
- )
2878
- } else {
2879
- if (contract.vacuous) {
2880
- // Every route hashes to the empty-schema digest, so the lock exists but the drift rule guards
2881
- // nothing: it can only ever compare "no schema" to "no schema". Say so, or a green contract check
2882
- // reads as proof of a stable contract when there is no contract to be stable.
2883
- structuredExtras.push(
2884
- diagnostic({
2885
- code: "NF-K001",
2886
- severity: "info",
2887
- message:
2888
- "no route schemas declared - the trust manifest is vacuous: every contract hash is the empty-schema digest, so drift detection guards nothing. Declare route schemas (body/query/params/response) so the lock has a contract to protect.",
2889
- verify: "nifra check --lints-only",
2890
- }),
2891
- )
2892
- }
2893
- for (const finding of contract.diagnostics) {
2894
- diagnostics.push({
2895
- rule: "contract-drift",
2896
- severity: "error",
2897
- code: "NF-K001",
2898
- ...(finding.route !== undefined ? { evidence: [finding.route] } : {}),
2899
- message: finding.message,
2900
- fix: "run `nifra contracts snapshot` after reviewing the contract change",
2901
- verify: "nifra check --lints-only",
2902
- })
2903
- }
2904
- }
2905
- } catch (error) {
2906
- if (error instanceof Error && error.message === "contract source not configured") {
2907
- // API-only source scans have no contract surface to snapshot.
2908
- } else {
2909
- diagnostics.push({
2910
- rule: "contract-drift",
2911
- severity: "error",
2912
- code: "NF-K001",
2913
- message: `contract lock could not be checked: ${error instanceof Error ? error.message : String(error)}`,
2914
- verify: "nifra contracts snapshot",
2915
- })
2916
- }
2917
- }
2918
-
2919
- const projectFacts = freezeProjectFacts(
2920
- {
2921
- ...factsSeed,
2922
- policies: {
2923
- ...factsSeed.policies,
2924
- ...(loadedPolicy === undefined ? {} : { assurance: loadedPolicy }),
2925
- ...(loadedCapability === undefined ? {} : { capability: loadedCapability }),
2926
- rulePacks: applicationRulePacks,
2927
- },
2928
- },
2929
- diagnostics,
2930
- )
2931
- const ruleContext: RuleContext = {
2932
- root: cwd,
2933
- sources: projectFacts.source,
2934
- project: projectFacts,
2935
- }
2936
- const structuredDiagnostics = [...structuredExtras]
2937
- let registryDiagnostics: Diagnostic[] = []
2938
- try {
2939
- registryDiagnostics = await runRuleRegistry(
2940
- ruleContext,
2941
- [...legacyRules, ...securityRules, ...routeRules],
2942
- projectFacts.policies.rulePacks,
2943
- )
2944
- structuredDiagnostics.push(...registryDiagnostics)
2945
- } catch (error) {
2946
- registryDiagnostics = [
2947
- diagnostic({
2948
- code: "NF-C017",
2949
- severity: "error",
2950
- message: `rule registry failed closed: ${error instanceof Error ? error.message : String(error)}`,
2951
- fix: { recipe: "rule-pack.repair", command: "nifra check --lints-only" },
2952
- verify: "nifra check --lints-only",
2953
- }),
2954
- ]
2955
- structuredDiagnostics.push(...registryDiagnostics)
2956
- }
2957
- for (const item of registryDiagnostics) {
2958
- // The legacy adapters publish stable structured findings; the human compatibility view already
2959
- // contains their original rule names. Security rules and application packs still need a legacy-view
2960
- // row because they have no pre-registry diagnostic.
2961
- if (legacyRules.some((rule) => rule.code === item.code)) continue
2962
- diagnostics.push({
2963
- rule: item.code,
2964
- severity: item.severity === "error" ? "error" : "warning",
2965
- code: item.code,
2966
- ...(item.file !== undefined ? { file: item.file } : {}),
2967
- ...(item.line !== undefined ? { line: item.line } : {}),
2968
- message: item.message,
2969
- ...(item.evidence !== undefined ? { evidence: item.evidence } : {}),
2970
- ...(item.verify !== undefined ? { verify: item.verify } : {}),
2971
- })
2972
- }
2973
-
2974
- // Per-rule overrides from `nifra.check.json` `rules`, applied centrally to BOTH diagnostic views
2975
- // before `ok` is computed - never inside individual rules, so no rule can dodge (or double-apply)
2976
- // them. A key matches the legacy rule name or the stable NF- code.
2977
- const overrideFor = (...keys: (string | undefined)[]): RuleOverride | undefined => {
2978
- for (const key of keys) {
2979
- if (key !== undefined && checkConfig.rules[key] !== undefined) return checkConfig.rules[key]
2980
- }
2981
- return undefined
2982
- }
2983
- const ignoreGlobs = new Map<RuleOverride, Glob[]>()
2984
- const dropped = (override: RuleOverride, file: string | undefined): boolean => {
2985
- if (override.severity === "off") return true
2986
- if (override.ignore === undefined || file === undefined) return false
2987
- let globs = ignoreGlobs.get(override)
2988
- if (globs === undefined) {
2989
- globs = override.ignore.map((pattern) => new Glob(pattern))
2990
- ignoreGlobs.set(override, globs)
2991
- }
2992
- return globs.some((glob) => glob.match(file))
2993
- }
2994
- const codeToLegacy = new Map(
2995
- Object.entries(LEGACY_RULE_CODES).map(([name, code]) => [code, name]),
2996
- )
2997
- const finalDiagnostics = diagnostics.flatMap<CheckDiagnostic>((d) => {
2998
- const override = overrideFor(d.rule, d.code, LEGACY_RULE_CODES[d.rule])
2999
- if (override === undefined) return [d]
3000
- if (dropped(override, d.file)) return []
3001
- // "off" is fully handled by `dropped` above - only real severities reach the retag.
3002
- if (override.severity === undefined || override.severity === "off") return [d]
3003
- return [{ ...d, severity: override.severity === "warn" ? "warning" : override.severity }]
3004
- })
3005
- const finalStructured = structuredDiagnostics.flatMap<Diagnostic>((d) => {
3006
- const override = overrideFor(d.code, codeToLegacy.get(d.code))
3007
- if (override === undefined) return [d]
3008
- if (dropped(override, d.file)) return []
3009
- if (override.severity === undefined || override.severity === "off") return [d]
3010
- return [Object.freeze({ ...d, severity: override.severity })]
3011
- })
3012
-
3013
- // Cap the diagnostics when asked (the MCP path), so a project with thousands of findings can't return a
3014
- // message that breaks the stdio transport. `ok` reflects the FULL set - truncation never flips it.
3015
- const total = finalDiagnostics.length
3016
- const max = opts.maxDiagnostics
3017
- const shown = max !== undefined && total > max ? finalDiagnostics.slice(0, max) : finalDiagnostics
3018
- const result: CheckResult = {
3019
- ok: !finalDiagnostics.some((diagnostic) => diagnostic.severity === "error"),
3020
- typecheck: tc.ran ? (tc.ok ? "pass" : "fail") : "skipped",
3021
- ...(!tc.ran && tc.note !== undefined ? { typecheckNote: tc.note } : {}),
3022
- diagnostics: shown,
3023
- ...(dr.pipeline !== undefined ? { pipeline: dr.pipeline } : {}),
3024
- ...(checkConfig.externalMounts.length > 0
3025
- ? { externalMounts: checkConfig.externalMounts }
3026
- : {}),
3027
- ...(Object.keys(checkConfig.rules).length > 0 ? { ruleOverrides: checkConfig.rules } : {}),
3028
- ...(shown.length < total ? { truncated: { shown: shown.length, total } } : {}),
3029
- }
3030
- Object.defineProperty(result, "structuredDiagnostics", {
3031
- value: finalStructured.slice(0, shown.length + structuredExtras.length),
3032
- enumerable: false,
3033
- })
3034
- return result
329
+ return collectCheckDiagnostics(cwd, scan, opts)
3035
330
  }
3036
331
 
3037
332
  /** The named rule sections of the human report, in print order. A rule absent from this list is NOT