@nifrajs/cli 2.14.1 → 3.2.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 (203) 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 -2346
  19. package/dist/check.js.map +1 -1
  20. package/dist/cli.d.ts.map +1 -1
  21. package/dist/cli.js +285 -10
  22. package/dist/cli.js.map +1 -1
  23. package/dist/command-catalog.d.ts +27 -1
  24. package/dist/command-catalog.d.ts.map +1 -1
  25. package/dist/command-catalog.js +142 -2
  26. package/dist/command-catalog.js.map +1 -1
  27. package/dist/doctor.d.ts +42 -0
  28. package/dist/doctor.d.ts.map +1 -1
  29. package/dist/doctor.js +64 -7
  30. package/dist/doctor.js.map +1 -1
  31. package/dist/env-file.d.ts +17 -0
  32. package/dist/env-file.d.ts.map +1 -0
  33. package/dist/env-file.js +113 -0
  34. package/dist/env-file.js.map +1 -0
  35. package/dist/fix-recipes.d.ts.map +1 -1
  36. package/dist/fix-recipes.js +91 -15
  37. package/dist/fix-recipes.js.map +1 -1
  38. package/dist/frontend-guidance.d.ts +57 -0
  39. package/dist/frontend-guidance.d.ts.map +1 -0
  40. package/dist/frontend-guidance.js +195 -0
  41. package/dist/frontend-guidance.js.map +1 -0
  42. package/dist/internal/reserved-segment-codemod.d.ts +36 -0
  43. package/dist/internal/reserved-segment-codemod.d.ts.map +1 -0
  44. package/dist/internal/reserved-segment-codemod.js +134 -0
  45. package/dist/internal/reserved-segment-codemod.js.map +1 -0
  46. package/dist/internal/typescript-import.d.ts +1 -1
  47. package/dist/internal/typescript-import.d.ts.map +1 -1
  48. package/dist/internal/typescript-import.js +62 -11
  49. package/dist/internal/typescript-import.js.map +1 -1
  50. package/dist/introspect.d.ts +26 -1
  51. package/dist/introspect.d.ts.map +1 -1
  52. package/dist/introspect.js +74 -24
  53. package/dist/introspect.js.map +1 -1
  54. package/dist/levels-tool.d.ts.map +1 -1
  55. package/dist/levels-tool.js +6 -0
  56. package/dist/levels-tool.js.map +1 -1
  57. package/dist/load.d.ts.map +1 -1
  58. package/dist/load.js +4 -1
  59. package/dist/load.js.map +1 -1
  60. package/dist/mcp-context.d.ts +48 -0
  61. package/dist/mcp-context.d.ts.map +1 -0
  62. package/dist/mcp-context.js +215 -0
  63. package/dist/mcp-context.js.map +1 -0
  64. package/dist/mcp-docs-tools.d.ts.map +1 -1
  65. package/dist/mcp-docs-tools.js +31 -0
  66. package/dist/mcp-docs-tools.js.map +1 -1
  67. package/dist/mcp-exec.d.ts +74 -0
  68. package/dist/mcp-exec.d.ts.map +1 -0
  69. package/dist/mcp-exec.js +844 -0
  70. package/dist/mcp-exec.js.map +1 -0
  71. package/dist/mcp-http.js +1 -1
  72. package/dist/mcp-http.js.map +1 -1
  73. package/dist/mcp-io.d.ts +26 -0
  74. package/dist/mcp-io.d.ts.map +1 -0
  75. package/dist/mcp-io.js +100 -0
  76. package/dist/mcp-io.js.map +1 -0
  77. package/dist/mcp-reflect.d.ts +16 -0
  78. package/dist/mcp-reflect.d.ts.map +1 -0
  79. package/dist/mcp-reflect.js +101 -0
  80. package/dist/mcp-reflect.js.map +1 -0
  81. package/dist/mcp-root.d.ts +7 -0
  82. package/dist/mcp-root.d.ts.map +1 -1
  83. package/dist/mcp-root.js +51 -3
  84. package/dist/mcp-root.js.map +1 -1
  85. package/dist/mcp.d.ts +10 -84
  86. package/dist/mcp.d.ts.map +1 -1
  87. package/dist/mcp.js +13 -1170
  88. package/dist/mcp.js.map +1 -1
  89. package/dist/openapi-tool.d.ts +5 -10
  90. package/dist/openapi-tool.d.ts.map +1 -1
  91. package/dist/openapi-tool.js +21 -2
  92. package/dist/openapi-tool.js.map +1 -1
  93. package/dist/openapi-types.d.ts +21 -0
  94. package/dist/openapi-types.d.ts.map +1 -0
  95. package/dist/openapi-types.js +214 -0
  96. package/dist/openapi-types.js.map +1 -0
  97. package/dist/port.d.ts.map +1 -1
  98. package/dist/port.js +2 -1
  99. package/dist/port.js.map +1 -1
  100. package/dist/project-facts.d.ts +2 -1
  101. package/dist/project-facts.d.ts.map +1 -1
  102. package/dist/project-facts.js.map +1 -1
  103. package/dist/recipes/3.0.0.d.ts +10 -0
  104. package/dist/recipes/3.0.0.d.ts.map +1 -0
  105. package/dist/recipes/3.0.0.js +23 -0
  106. package/dist/recipes/3.0.0.js.map +1 -0
  107. package/dist/recipes/index.d.ts.map +1 -1
  108. package/dist/recipes/index.js +2 -0
  109. package/dist/recipes/index.js.map +1 -1
  110. package/dist/release-verification.d.ts +4 -1
  111. package/dist/release-verification.d.ts.map +1 -1
  112. package/dist/release-verification.js +6 -123
  113. package/dist/release-verification.js.map +1 -1
  114. package/dist/rules/codes.d.ts +4 -0
  115. package/dist/rules/codes.d.ts.map +1 -1
  116. package/dist/rules/codes.js +4 -0
  117. package/dist/rules/codes.js.map +1 -1
  118. package/dist/rules/islands.d.ts +4 -0
  119. package/dist/rules/islands.d.ts.map +1 -0
  120. package/dist/rules/islands.js +155 -0
  121. package/dist/rules/islands.js.map +1 -0
  122. package/dist/rules/nano.d.ts +6 -0
  123. package/dist/rules/nano.d.ts.map +1 -0
  124. package/dist/rules/nano.js +264 -0
  125. package/dist/rules/nano.js.map +1 -0
  126. package/dist/rules/routes.d.ts.map +1 -1
  127. package/dist/rules/routes.js +17 -30
  128. package/dist/rules/routes.js.map +1 -1
  129. package/dist/rules/security.d.ts.map +1 -1
  130. package/dist/rules/security.js +38 -4
  131. package/dist/rules/security.js.map +1 -1
  132. package/dist/scaffold.d.ts +12 -6
  133. package/dist/scaffold.d.ts.map +1 -1
  134. package/dist/scaffold.js +149 -10
  135. package/dist/scaffold.js.map +1 -1
  136. package/dist/search-terms.d.ts.map +1 -1
  137. package/dist/search-terms.js +5 -3
  138. package/dist/search-terms.js.map +1 -1
  139. package/dist/stylex-migrate.d.ts +38 -0
  140. package/dist/stylex-migrate.d.ts.map +1 -0
  141. package/dist/stylex-migrate.js +1052 -0
  142. package/dist/stylex-migrate.js.map +1 -0
  143. package/dist/sync-manifest.d.ts.map +1 -1
  144. package/dist/sync-manifest.js +2 -1
  145. package/dist/sync-manifest.js.map +1 -1
  146. package/dist/verification-plan.d.ts +24 -0
  147. package/dist/verification-plan.d.ts.map +1 -0
  148. package/dist/verification-plan.js +116 -0
  149. package/dist/verification-plan.js.map +1 -0
  150. package/dist/work-graph.d.ts.map +1 -1
  151. package/dist/work-graph.js +6 -3
  152. package/dist/work-graph.js.map +1 -1
  153. package/dist/workspace-link.d.ts +37 -0
  154. package/dist/workspace-link.d.ts.map +1 -0
  155. package/dist/workspace-link.js +72 -0
  156. package/dist/workspace-link.js.map +1 -0
  157. package/docs/examples.json +37 -1
  158. package/docs/llms-full.txt +1598 -44
  159. package/docs/types.json +5157 -172
  160. package/package.json +9 -9
  161. package/src/assure-hydration.ts +6 -1
  162. package/src/assure.ts +4 -2
  163. package/src/check-diagnostics.ts +1219 -0
  164. package/src/check-scan.ts +1825 -0
  165. package/src/check.ts +47 -2731
  166. package/src/cli.ts +291 -10
  167. package/src/command-catalog.ts +177 -2
  168. package/src/doctor.ts +99 -8
  169. package/src/env-file.ts +121 -0
  170. package/src/fix-recipes.ts +101 -15
  171. package/src/frontend-guidance.ts +259 -0
  172. package/src/internal/reserved-segment-codemod.ts +150 -0
  173. package/src/internal/typescript-import.ts +61 -11
  174. package/src/introspect.ts +82 -21
  175. package/src/levels-tool.ts +6 -0
  176. package/src/load.ts +4 -1
  177. package/src/mcp-context.ts +266 -0
  178. package/src/mcp-docs-tools.ts +37 -0
  179. package/src/mcp-exec.ts +1040 -0
  180. package/src/mcp-http.ts +1 -1
  181. package/src/mcp-io.ts +118 -0
  182. package/src/mcp-reflect.ts +140 -0
  183. package/src/mcp-root.ts +62 -4
  184. package/src/mcp.ts +61 -1436
  185. package/src/openapi-tool.ts +32 -2
  186. package/src/openapi-types.ts +261 -0
  187. package/src/port.ts +2 -1
  188. package/src/project-facts.ts +2 -4
  189. package/src/recipes/3.0.0.ts +24 -0
  190. package/src/recipes/index.ts +2 -0
  191. package/src/release-verification.ts +22 -147
  192. package/src/rules/codes.ts +4 -0
  193. package/src/rules/islands.ts +171 -0
  194. package/src/rules/nano.ts +295 -0
  195. package/src/rules/routes.ts +17 -30
  196. package/src/rules/security.ts +40 -4
  197. package/src/scaffold.ts +169 -9
  198. package/src/search-terms.ts +7 -3
  199. package/src/stylex-migrate.ts +1139 -0
  200. package/src/sync-manifest.ts +2 -1
  201. package/src/verification-plan.ts +242 -0
  202. package/src/work-graph.ts +6 -3
  203. package/src/workspace-link.ts +80 -0
package/dist/check.js CHANGED
@@ -15,1602 +15,16 @@
15
15
  * `nifra_check` MCP tool, so an agent acts on diagnostics instead of scraping prose). Exits non-zero if
16
16
  * anything fails. Pure scanners (`scanFetchText`, `scanServerOnlyImports`) are unit-tested.
17
17
  */
18
- import { existsSync, readFileSync, realpathSync } from "node:fs";
19
- import { dirname, isAbsolute, join, resolve, sep } from "node:path";
20
- import { snapshotProjectEvidence } from "@nifrajs/core/evidence";
21
- import { Glob } from "bun";
22
- import { diagnostic } from "./diagnostics.js";
18
+ import { existsSync, readFileSync } from "node:fs";
19
+ import { dirname, isAbsolute, join } from "node:path";
20
+ import { collectCheckDiagnostics, } from "./check-diagnostics.js";
23
21
  import { createSourceFacts } from "./internal/source-facts.js";
24
22
  import { importProjectTypeScript } from "./internal/typescript-import.js";
25
- import { freezeProjectFacts } from "./project-facts.js";
26
23
  import { RULE_CODES } from "./rules/codes.js";
27
- import { commentBlockMarkerReason } from "./rules/comment-markers.js";
28
- import { parseRulePacks, runRuleRegistry, sourceIndex } from "./rules/index.js";
29
- import { LEGACY_RULE_CODES, legacyRules } from "./rules/legacy.js";
30
- import { routeRules } from "./rules/routes.js";
31
- import { securityRules } from "./rules/security.js";
32
- // `fetch( <ws> ('|"|`) / (not /)` - a string/template arg starting with a single `/` is a relative URL,
33
- // i.e. same-origin = this app's own API. `(?<![.\w])` skips `.fetch(` (a method) and `prefetch(`; the
34
- // `(?!\/)` skips protocol-relative `//host` (an external origin). A variable arg (`fetch(url)`) is left
35
- // alone on purpose - undecidable from source, and flagging it would punish legitimate external calls.
36
- const GLOBAL_FETCH_CALL = /(?<![.\w])fetch\s*\(/g;
37
- const FETCH_CALL = /(?<![.\w])fetch\s*\(/g;
38
- const HTTP_VERBS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]);
39
- const SIMPLE_REWRITE_METHODS = new Set(["GET", "DELETE", "HEAD", "OPTIONS"]);
40
- // Don't scan deps, build output, or generated client entries. `dist(-<runtime>)?` also covers
41
- // per-runtime output dirs (dist-bun/dist-node/dist-deno/dist-vercel). Never source, for any scan.
42
- const IGNORED_DIR = /(^|\/)(node_modules|dist(-[a-z0-9]+)?|build|\.nifra|\.git|\.wrangler|coverage)\//;
43
- // A test/spec module. Excluded from `nifra check`'s scans, which are about what SHIPS - a test
44
- // legitimately drives `fetch`, hand-rolls a client, and calls a route directly. It is NOT excluded from
45
- // `nifra doctor`: tsc typechecks tests, so an import a test declares nowhere is a real broken build.
46
- const TEST_FILE = /\.(test|spec)\.[cm]?[jt]sx?$/;
47
- const IGNORED = new RegExp(`${IGNORED_DIR.source}|${TEST_FILE.source}`);
48
- // A file under `routes/` - a page module bundled for the browser, where a server-only import is unsafe.
24
+ import { sourceIndex } from "./rules/index.js";
25
+ import { createProjectSqlImports, resolveServerOnlyChains, scanFetchText, scanInterpolatedSql, scanRemovedImports, scanResponseRoutes, scanServerManifestDrift, scanStaticRouteText, scanUntypedClient, walkSource, } from "./check-scan.js";
26
+ export * from "./check-scan.js";
49
27
  const ROUTE_FILE = /(^|\/)routes\//;
50
- // Module specifiers that must never be VALUE-imported into a route module: node:/bun: builtins, common
51
- // DB drivers/ORМ server entrypoints, and the conventional `./db` module the scaffold generates.
52
- const SERVER_ONLY = /^(?: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?)?$/;
53
- // A static, non-type import with a string specifier. `import type …` is erased at build, so it's safe
54
- // and skipped. Dynamic `import(…)` (the correct way to lazy-load server code in a loader) has `(` right
55
- // after `import`, so `import\s+` never matches it.
56
- const STATIC_IMPORT = /\bimport\s+(?!type\b)(?:[^'"();]*?\bfrom\s+)?['"]([^'"]+)['"]/g;
57
- const ROUTE_REGISTRATION_DQ = /\.([A-Za-z]+)\s*\(\s*"((?:\\.|[^"\\])*)"/g;
58
- const ROUTE_REGISTRATION_SQ = /\.([A-Za-z]+)\s*\(\s*'((?:\\.|[^'\\])*)'/g;
59
- const IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
60
- /** Line number (1-based) of a match index within `content`. */
61
- const lineAt = (content, index) => content.slice(0, index).split("\n").length;
62
- function parseQuotedLiteral(raw) {
63
- try {
64
- return JSON.parse(raw);
65
- }
66
- catch {
67
- if (!raw.startsWith("'") || !raw.endsWith("'"))
68
- return undefined;
69
- try {
70
- // Convert the single-quoted body to a JSON string body by scan, handling backslash pairs
71
- // atomically: `\'` becomes `'`, other escapes pass through untouched, and a bare `"` is
72
- // escaped. A quote-only `replace` mis-handles content with backslashes (double-escaping
73
- // an already-escaped quote, or letting a trailing backslash swallow the added escape).
74
- const inner = raw.slice(1, -1);
75
- let body = "";
76
- for (let i = 0; i < inner.length; i++) {
77
- const ch = inner[i];
78
- if (ch === "\\" && i + 1 < inner.length) {
79
- const next = inner[i + 1];
80
- body += next === "'" ? "'" : ch + next;
81
- i++;
82
- }
83
- else if (ch === '"') {
84
- body += '\\"';
85
- }
86
- else {
87
- body += ch;
88
- }
89
- }
90
- return JSON.parse(`"${body}"`);
91
- }
92
- catch {
93
- return undefined;
94
- }
95
- }
96
- }
97
- function findMatchingParen(src, openIndex) {
98
- let depth = 0;
99
- let quote;
100
- for (let i = openIndex; i < src.length; i++) {
101
- const c = src[i];
102
- if (quote !== undefined) {
103
- if (c === "\\") {
104
- i++;
105
- continue;
106
- }
107
- if (c === quote)
108
- quote = undefined;
109
- continue;
110
- }
111
- if (c === '"' || c === "'" || c === "`") {
112
- quote = c;
113
- continue;
114
- }
115
- if (c === "(")
116
- depth++;
117
- else if (c === ")") {
118
- depth--;
119
- if (depth === 0)
120
- return i;
121
- }
122
- }
123
- return undefined;
124
- }
125
- function splitTopLevelArgs(src) {
126
- const args = [];
127
- let start = 0;
128
- let depth = 0;
129
- let quote;
130
- for (let i = 0; i < src.length; i++) {
131
- const c = src[i];
132
- if (quote !== undefined) {
133
- if (c === "\\") {
134
- i++;
135
- continue;
136
- }
137
- if (c === quote)
138
- quote = undefined;
139
- continue;
140
- }
141
- if (c === '"' || c === "'" || c === "`") {
142
- quote = c;
143
- continue;
144
- }
145
- if (c === "(" || c === "{" || c === "[")
146
- depth++;
147
- else if (c === ")" || c === "}" || c === "]")
148
- depth--;
149
- else if (c === "," && depth === 0) {
150
- args.push(src.slice(start, i).trim());
151
- start = i + 1;
152
- }
153
- }
154
- if (quote !== undefined || depth !== 0)
155
- return undefined;
156
- const tail = src.slice(start).trim();
157
- if (tail !== "")
158
- args.push(tail);
159
- return args;
160
- }
161
- function parseMethodOnlyInit(raw) {
162
- if (raw === undefined)
163
- return "GET";
164
- const init = raw.trim();
165
- if (!/^\{[\s\S]*\}$/.test(init))
166
- return undefined;
167
- const body = init.slice(1, -1).trim();
168
- if (body === "")
169
- return "GET";
170
- const m = /^(?:"method"|'method'|method)\s*:\s*(["'])([A-Za-z]+)\1\s*,?$/.exec(body);
171
- if (!m)
172
- return undefined;
173
- const method = (m[2] ?? "").toUpperCase();
174
- return HTTP_VERBS.has(method) ? method : undefined;
175
- }
176
- function parseSimpleFetchCall(snippet) {
177
- FETCH_CALL.lastIndex = 0;
178
- const matches = [...snippet.matchAll(FETCH_CALL)];
179
- if (matches.length !== 1)
180
- return undefined;
181
- const match = matches[0];
182
- if (match === undefined || match.index === undefined)
183
- return undefined;
184
- const open = match.index + match[0].lastIndexOf("(");
185
- const close = findMatchingParen(snippet, open);
186
- if (close === undefined)
187
- return undefined;
188
- const args = splitTopLevelArgs(snippet.slice(open + 1, close));
189
- if (args === undefined || args.length === 0 || args.length > 2)
190
- return undefined;
191
- const first = args[0] ?? "";
192
- const quote = first[0];
193
- if ((quote !== '"' && quote !== "'") || first[first.length - 1] !== quote)
194
- return undefined;
195
- const path = parseQuotedLiteral(first);
196
- if (path === undefined ||
197
- !path.startsWith("/") ||
198
- path.startsWith("//") ||
199
- path.includes("?") ||
200
- path.includes("#")) {
201
- return undefined;
202
- }
203
- const method = parseMethodOnlyInit(args[1]);
204
- if (method === undefined)
205
- return undefined;
206
- return { path, method, start: match.index, end: close + 1 };
207
- }
208
- function isPotentialBackendSource(code) {
209
- return (code.includes("@nifrajs/core") ||
210
- /(?<![.\w])server\s*\(/.test(code) ||
211
- /(?:from\s*|import\s*)["']hono["']/.test(code) ||
212
- /new\s+Hono(?:<[^>]+>)?\s*\(/.test(code));
213
- }
214
- function scanRoutePattern(file, content, code, pattern, facts) {
215
- const out = [];
216
- const lines = content.split("\n");
217
- pattern.lastIndex = 0;
218
- for (let m = pattern.exec(code); m !== null; m = pattern.exec(code)) {
219
- const method = (m[1] ?? "").toUpperCase();
220
- if (!HTTP_VERBS.has(method))
221
- continue;
222
- const path = parseQuotedLiteral(`${pattern === ROUTE_REGISTRATION_DQ ? '"' : "'"}${m[2] ?? ""}${pattern === ROUTE_REGISTRATION_DQ ? '"' : "'"}`);
223
- if (path === undefined || !path.startsWith("/") || path.startsWith("//"))
224
- continue;
225
- if (facts !== undefined) {
226
- const source = facts.parse(file, content);
227
- // A parseable source model is authoritative for candidate locations. If parsing is unavailable
228
- // or the source is malformed, retain the lexical finding and fail closed.
229
- if (source !== undefined &&
230
- facts.isRouteRegistrationAt(source, m.index, method, path) !== true)
231
- continue;
232
- }
233
- const line = lineAt(content, m.index);
234
- out.push({ file, line, snippet: (lines[line - 1] ?? "").trim(), method, path });
235
- }
236
- return out;
237
- }
238
- /**
239
- * Blank, with spaces (newlines preserved, so every byte offset - and line number - is unchanged):
240
- * - `//` line comments and block comments;
241
- * - the CONTENTS of backtick template literals - code-as-text (doc `CodeBlock` examples, code
242
- * generators), never a real statement to lint.
243
- * Single/double-quoted strings are KEPT (a real import/Response specifier lives in one). A small
244
- * char-state machine, not a full lexer: it doesn't model regex literals, so a regex containing a quote
245
- * could mis-skip - in practice the constructs these scanners look for sit before any such regex.
246
- * Shared by the source scanners here and by `nifra doctor` ({@link ./doctor.ts}).
247
- */
248
- export function stripComments(src) {
249
- const out = src.split("");
250
- const n = src.length;
251
- let i = 0;
252
- const blank = (a, b) => {
253
- for (let k = a; k < b; k++)
254
- if (out[k] !== "\n")
255
- out[k] = " ";
256
- };
257
- while (i < n) {
258
- const c = src[i];
259
- const d = src[i + 1];
260
- if (c === "/" && d === "/") {
261
- let j = i + 2;
262
- while (j < n && src[j] !== "\n")
263
- j++;
264
- blank(i, j);
265
- i = j;
266
- }
267
- else if (c === "/" && d === "*") {
268
- let j = i + 2;
269
- while (j < n && !(src[j] === "*" && src[j + 1] === "/"))
270
- j++;
271
- j = Math.min(j + 2, n);
272
- blank(i, j);
273
- i = j;
274
- }
275
- else if (c === "`") {
276
- let j = i + 1;
277
- while (j < n && src[j] !== "`") {
278
- if (src[j] === "\\")
279
- j++;
280
- j++;
281
- }
282
- blank(i + 1, j); // keep the backticks, blank the code-as-text inside
283
- i = j + 1;
284
- }
285
- else if (c === "'" || c === '"') {
286
- let j = i + 1;
287
- while (j < n && src[j] !== c) {
288
- if (src[j] === "\\")
289
- j++; // skip the escaped char
290
- j++;
291
- }
292
- i = j + 1;
293
- }
294
- else {
295
- i++;
296
- }
297
- }
298
- return out.join("");
299
- }
300
- /** Blank comments and every quoted/template literal while preserving offsets. Unlike
301
- * {@link stripComments}, this is a code-position mask: scanners use it to find a call expression in
302
- * executable code, then inspect the original source for the call's literal argument. That prevents
303
- * documentation strings such as `const example = 'client("/")'` from becoming diagnostics. */
304
- export function codePositionMask(src) {
305
- const out = src.split("");
306
- const n = src.length;
307
- let i = 0;
308
- const blank = (a, b) => {
309
- for (let k = a; k < b; k++)
310
- if (out[k] !== "\n")
311
- out[k] = " ";
312
- };
313
- while (i < n) {
314
- const c = src[i];
315
- const d = src[i + 1];
316
- if (c === "/" && d === "/") {
317
- let j = i + 2;
318
- while (j < n && src[j] !== "\n")
319
- j++;
320
- blank(i, j);
321
- i = j;
322
- }
323
- else if (c === "/" && d === "*") {
324
- let j = i + 2;
325
- while (j < n && !(src[j] === "*" && src[j + 1] === "/"))
326
- j++;
327
- j = Math.min(j + 2, n);
328
- blank(i, j);
329
- i = j;
330
- }
331
- else if (c === "'" || c === '"' || c === "`") {
332
- const quote = c;
333
- let j = i + 1;
334
- while (j < n && src[j] !== quote) {
335
- if (src[j] === "\\")
336
- j++;
337
- j++;
338
- }
339
- j = Math.min(j + 1, n);
340
- blank(i, j);
341
- i = j;
342
- }
343
- else {
344
- i++;
345
- }
346
- }
347
- return out.join("");
348
- }
349
- const firstArgumentIndex = (content, callEnd) => {
350
- let index = callEnd;
351
- while (/\s/.test(content[index] ?? ""))
352
- index++;
353
- return index;
354
- };
355
- // Read the literal URL path of a `fetch("/…")` call starting at `start` (the index of the leading `/`),
356
- // stopping at the first quote / query / hash / template-expression boundary, and test it against the
357
- // allowlist. A template like `/auth/${id}` matches on its literal head (`/auth`), so a dynamic sign-in URL
358
- // is covered too. Match is segment-anchored - `/auth` blesses `/auth` and `/auth/**`, never `/authors`.
359
- function matchesExternalMount(content, start, mounts) {
360
- let end = start;
361
- while (end < content.length) {
362
- const ch = content[end];
363
- if (ch === "'" || ch === '"' || ch === "`" || ch === "?" || ch === "#")
364
- break;
365
- if (ch === "$" && content[end + 1] === "{")
366
- break;
367
- end++;
368
- }
369
- const path = content.slice(start, end);
370
- // A `..` segment means the runtime path escapes the literal prefix (`/auth/../api/admin` resolves to
371
- // `/api/admin`), so it must NOT be blessed by an `/auth` mount - never let traversal hide a real own-API
372
- // fetch. Match is otherwise segment-anchored (`/auth` blesses `/auth` and `/auth/**`, never `/authors`).
373
- try {
374
- if (path.split("/").some((segment) => decodeURIComponent(segment) === ".."))
375
- return false;
376
- }
377
- catch {
378
- // A malformed escape is not a prefix we can prove stays inside the external mount.
379
- return false;
380
- }
381
- return mounts.some((prefix) => path === prefix || path.startsWith(`${prefix}/`));
382
- }
383
- function findTopLevelPropertyColon(src) {
384
- let depth = 0;
385
- let quote;
386
- for (let i = 0; i < src.length; i++) {
387
- const c = src[i];
388
- if (quote !== undefined) {
389
- if (c === "\\") {
390
- i++;
391
- continue;
392
- }
393
- if (c === quote)
394
- quote = undefined;
395
- continue;
396
- }
397
- if (c === '"' || c === "'" || c === "`") {
398
- quote = c;
399
- continue;
400
- }
401
- if (c === "(" || c === "{" || c === "[")
402
- depth++;
403
- else if (c === ")" || c === "}" || c === "]")
404
- depth--;
405
- else if (c === ":" && depth === 0)
406
- return i;
407
- }
408
- return undefined;
409
- }
410
- function parseStaticPropertyName(raw) {
411
- const name = raw.trim();
412
- if (IDENT.test(name))
413
- return name;
414
- const quoted = parseQuotedLiteral(name);
415
- if (quoted !== undefined)
416
- return quoted;
417
- if (name.startsWith("[") && name.endsWith("]")) {
418
- return parseQuotedLiteral(name.slice(1, -1).trim());
419
- }
420
- return undefined;
421
- }
422
- /** Return a statically named property from a plain object literal. Deliberately does not follow
423
- * spreads, identifiers, or helper calls: only an inline literal proves this fetch is Nifra's page-data
424
- * transport rather than an arbitrary same-origin API request. */
425
- function staticObjectProperty(raw, name) {
426
- if (raw === undefined)
427
- return undefined;
428
- const object = raw.trim();
429
- if (!object.startsWith("{") || !object.endsWith("}"))
430
- return undefined;
431
- const properties = splitTopLevelArgs(object.slice(1, -1));
432
- if (properties === undefined)
433
- return undefined;
434
- for (const property of properties) {
435
- const colon = findTopLevelPropertyColon(property);
436
- if (colon === undefined || parseStaticPropertyName(property.slice(0, colon)) !== name)
437
- continue;
438
- return property.slice(colon + 1).trim();
439
- }
440
- return undefined;
441
- }
442
- function hasLiteralNifraDataHeader(content, openParen) {
443
- const closeParen = findMatchingParen(content, openParen);
444
- if (closeParen === undefined)
445
- return false;
446
- const args = splitTopLevelArgs(content.slice(openParen + 1, closeParen));
447
- if (args === undefined || args.length !== 2)
448
- return false;
449
- const headers = staticObjectProperty(args[1], "headers");
450
- return staticObjectProperty(headers, "x-nifra-data") !== undefined;
451
- }
452
- /** Scan one file's text for hand-rolled own-API `fetch()` calls. Pure + line-accurate. `externalMounts`
453
- * are intentional non-typed mount prefixes (from `nifra.check.json`, e.g. a mounted better-auth owning
454
- * `/auth/**`) - a relative fetch into one is deliberate, not drift, so it's skipped. A fetch with an
455
- * inline literal `x-nifra-data` header is Nifra's page-loader transport, not a backend API call, and is
456
- * skipped too. */
457
- export function scanFetchText(file, content, externalMounts = []) {
458
- const out = [];
459
- const lines = content.split("\n");
460
- const code = codePositionMask(content);
461
- GLOBAL_FETCH_CALL.lastIndex = 0;
462
- for (let m = GLOBAL_FETCH_CALL.exec(code); m !== null; m = GLOBAL_FETCH_CALL.exec(code)) {
463
- const argument = firstArgumentIndex(content, m.index + m[0].length);
464
- const quote = content[argument];
465
- if (quote !== "'" && quote !== '"' && quote !== "`")
466
- continue;
467
- if (content[argument + 1] !== "/" || content[argument + 2] === "/")
468
- continue;
469
- const openParen = m.index + m[0].lastIndexOf("(");
470
- if (hasLiteralNifraDataHeader(content, openParen))
471
- continue;
472
- if (externalMounts.length > 0 && matchesExternalMount(content, argument + 1, externalMounts))
473
- continue;
474
- const line = lineAt(content, m.index);
475
- out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() });
476
- }
477
- return out;
478
- }
479
- /** Statically collect simple Nifra route registrations from source, without importing app code. */
480
- export function scanStaticRouteText(file, content, facts) {
481
- const code = stripComments(content);
482
- if (!isPotentialBackendSource(code))
483
- return [];
484
- return [
485
- ...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_DQ, facts),
486
- ...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_SQ, facts),
487
- ].sort(bySite);
488
- }
489
- /**
490
- * SQL built by interpolating a value into the statement text. The value is not a parameter, it is
491
- * STATEMENT, so anything the caller controls can end the literal and continue as SQL.
492
- *
493
- * Two things this deliberately does not flag, because both are safe and flagging them would train
494
- * people to ignore the rule:
495
- *
496
- * - A TAGGED template - ``sql`... ${id} ...` `` in postgres.js, drizzle or kysely. The tag receives the
497
- * substitutions separately and binds them; that is the parameterised form, not a bypass of it.
498
- * - A literal with no substitution at all. `db.query("SELECT … WHERE id = ?")` is the shape being
499
- * argued for.
500
- *
501
- * A SQL keyword is required in the literal, so `cache.query(`user:${id}`)` stays quiet. The named
502
- * escape hatches (`$queryRawUnsafe`, `sql.unsafe`) are flagged on the call alone: their whole purpose
503
- * is to take a statement as text, and a substitution in one is the exact thing the name warns about.
504
- */
505
- const SQL_METHODS = new Set([
506
- "query",
507
- "queryRaw",
508
- "prepare",
509
- "exec",
510
- "execute",
511
- "run",
512
- "unsafe",
513
- "$queryRawUnsafe",
514
- "$executeRawUnsafe",
515
- "raw",
516
- ]);
517
- const SQL_KEYWORD = /\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;
518
- const ALWAYS_UNSAFE = new Set(["unsafe", "$queryRawUnsafe", "$executeRawUnsafe", "raw"]);
519
- /** Read one module's top-level `const` bindings and its import/export edges. Pure syntax - no checker,
520
- * no filesystem. Destructured names, `let`, `var`, and anything nested stay out on purpose: a
521
- * module-scope `const` is the only form that provably cannot carry request data. */
522
- function sqlModuleScope(ts, id, source) {
523
- const consts = new Map();
524
- const imports = new Map();
525
- const localExports = new Map();
526
- const reExports = new Map();
527
- const starExports = [];
528
- for (const statement of source.statements) {
529
- if (ts.isVariableStatement(statement)) {
530
- if ((statement.declarationList.flags & ts.NodeFlags.Const) === 0)
531
- continue;
532
- const exported = statement.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) === true;
533
- for (const declaration of statement.declarationList.declarations) {
534
- if (!ts.isIdentifier(declaration.name) || declaration.initializer === undefined)
535
- continue;
536
- consts.set(declaration.name.text, declaration.initializer);
537
- if (exported)
538
- localExports.set(declaration.name.text, declaration.name.text);
539
- }
540
- continue;
541
- }
542
- if (ts.isImportDeclaration(statement)) {
543
- const clause = statement.importClause;
544
- if (clause === undefined || clause.isTypeOnly)
545
- continue;
546
- if (!ts.isStringLiteral(statement.moduleSpecifier))
547
- continue;
548
- const bindings = clause.namedBindings;
549
- // Only `import { X }` / `import { X as local }`. A default or namespace import binds a value
550
- // whose contents this scan cannot prove, so it stays unbound and every use of it flags.
551
- if (bindings === undefined || !ts.isNamedImports(bindings))
552
- continue;
553
- for (const element of bindings.elements) {
554
- if (element.isTypeOnly)
555
- continue;
556
- imports.set(element.name.text, {
557
- specifier: statement.moduleSpecifier.text,
558
- exported: element.propertyName?.text ?? element.name.text,
559
- });
560
- }
561
- continue;
562
- }
563
- if (!ts.isExportDeclaration(statement) || statement.isTypeOnly)
564
- continue;
565
- const from = statement.moduleSpecifier;
566
- if (from !== undefined && !ts.isStringLiteral(from))
567
- continue;
568
- const clause = statement.exportClause;
569
- if (clause === undefined) {
570
- // No clause and a specifier is `export * from "./m"`. `export * as ns from "./m"` carries a
571
- // NamespaceExport clause instead and falls out below, unresolved, which is what we want.
572
- if (from !== undefined)
573
- starExports.push(from.text);
574
- continue;
575
- }
576
- if (!ts.isNamedExports(clause))
577
- continue;
578
- for (const element of clause.elements) {
579
- if (element.isTypeOnly)
580
- continue;
581
- const local = element.propertyName?.text ?? element.name.text;
582
- if (from === undefined)
583
- localExports.set(element.name.text, local);
584
- else
585
- reExports.set(element.name.text, { specifier: from.text, exported: local });
586
- }
587
- }
588
- return { id, consts, imports, localExports, reExports, starExports };
589
- }
590
- export const createSqlImports = (load) => ({
591
- load,
592
- scopes: new Map(),
593
- });
594
- /** Parse a module for the SQL scan, or `undefined` when it does not parse. Malformed source is already
595
- * a typecheck failure; parser recovery nodes are not evidence of anything, so a file that fails to
596
- * parse contributes no findings and proves no constant. */
597
- function parseSqlSource(ts, file, content) {
598
- const kind = /\.[cm]?tsx?$/.test(file) ? ts.ScriptKind.TSX : ts.ScriptKind.JS;
599
- const source = ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true, kind);
600
- const parseDiagnostics = source.parseDiagnostics;
601
- return parseDiagnostics !== undefined && parseDiagnostics.length > 0 ? undefined : source;
602
- }
603
- /** Module-boundary hops one name may cross. Barrels are why it is not 1: `route → index → fragments`
604
- * already spends two, and the third leaves room for one more layer without letting the scan wander the
605
- * module graph. Past the cap the name is unresolved, so the interpolation flags. */
606
- const SQL_IMPORT_HOPS = 3;
607
- /** Load + parse the module a specifier names, through the run's cache. */
608
- function loadSqlModuleScope(ts, imports, fromModule, specifier) {
609
- const loaded = imports.load(fromModule, specifier);
610
- if (loaded === undefined)
611
- return undefined;
612
- if (imports.scopes.has(loaded.id))
613
- return imports.scopes.get(loaded.id);
614
- const source = parseSqlSource(ts, loaded.id, loaded.content);
615
- const scope = source === undefined ? undefined : sqlModuleScope(ts, loaded.id, source);
616
- imports.scopes.set(loaded.id, scope);
617
- return scope;
618
- }
619
- /**
620
- * Follow an exported name to its `const` initializer, across re-export hops.
621
- *
622
- * Handles the three forms a fragments module actually uses: `export const NAME = …`, the two-statement
623
- * `const NAME = …; export { NAME }` (including the pass-through `import { A } …; export { A }`), and
624
- * barrels - `export { X } from "./m"` and `export * from "./m"`. Bounded by {@link SQL_IMPORT_HOPS} and
625
- * a per-chain visited set, so a cyclic barrel terminates instead of recursing.
626
- *
627
- * Two `export *` sources providing the same name is a conflict, and the scan refuses rather than
628
- * picking one: guessing here would silence a real finding.
629
- */
630
- function resolveExportedConst(ts, imports, scope, name, hops, seen) {
631
- if (hops > SQL_IMPORT_HOPS)
632
- return undefined;
633
- const key = `${scope.id}#${name}`;
634
- if (seen.has(key))
635
- return undefined;
636
- const nextSeen = new Set(seen).add(key);
637
- const follow = (edge) => {
638
- const target = loadSqlModuleScope(ts, imports, scope.id, edge.specifier);
639
- return target === undefined
640
- ? undefined
641
- : resolveExportedConst(ts, imports, target, edge.exported, hops + 1, nextSeen);
642
- };
643
- const local = scope.localExports.get(name);
644
- if (local !== undefined) {
645
- const initializer = scope.consts.get(local);
646
- if (initializer !== undefined)
647
- return { expression: initializer, scope };
648
- // `import { A } from "./a"; export { A }` - the same pass-through as `export { A } from "./a"`,
649
- // written in two statements. Anything else the name could be (a `let`, a function, a class) has no
650
- // initializer in `consts` and no import edge, so it lands on `undefined` and flags.
651
- const via = scope.imports.get(local);
652
- return via === undefined ? undefined : follow(via);
653
- }
654
- const reExported = scope.reExports.get(name);
655
- if (reExported !== undefined)
656
- return follow(reExported);
657
- let found;
658
- for (const specifier of scope.starExports) {
659
- const hit = follow({ specifier, exported: name });
660
- if (hit === undefined)
661
- continue;
662
- if (found !== undefined)
663
- return undefined;
664
- found = hit;
665
- }
666
- return found;
667
- }
668
- /** Whether `name`, read at `at`, might be re-declared by anything between the read and module scope.
669
- * Conservative on purpose: the OUTERMOST enclosing function/block subtree is scanned for ANY binding
670
- * of the name (parameter, `var`/`let`/`const`, catch, local function, destructuring element) - so a
671
- * hoisted `var` in a sibling block, or a binding in a branch that never runs, still refuses
672
- * resolution. Over-refusal flags a safe line (the pre-feature behavior); under-refusal would resolve
673
- * the wrong binding, which is why the cheap-and-broad scan wins over scope simulation. A use with no
674
- * enclosing function or block reads module scope directly and cannot be shadowed. */
675
- function isShadowedAt(ts, at, name) {
676
- let scope;
677
- for (let node = at.parent; node !== undefined; node = node.parent) {
678
- if (ts.isSourceFile(node))
679
- break;
680
- if (ts.isFunctionLike(node) || ts.isBlock(node))
681
- scope = node;
682
- }
683
- if (scope === undefined)
684
- return false;
685
- const bindsName = (binding) => ts.isIdentifier(binding)
686
- ? binding.text === name
687
- : binding.elements.some((element) => !ts.isOmittedExpression(element) &&
688
- (ts.isIdentifier(element.name) ? element.name.text === name : bindsName(element.name)));
689
- let found = false;
690
- const scan = (node) => {
691
- if (found)
692
- return;
693
- if ((ts.isVariableDeclaration(node) || ts.isParameter(node)) &&
694
- bindsName(node.name)) {
695
- found = true;
696
- return;
697
- }
698
- if (ts.isFunctionDeclaration(node) && node.name?.text === name) {
699
- found = true;
700
- return;
701
- }
702
- ts.forEachChild(node, scan);
703
- };
704
- scan(scope);
705
- return found;
706
- }
707
- const CONST_RESOLUTION_DEPTH = 5;
708
- /**
709
- * Resolve an interpolated expression to compile-time text, or `undefined` when it can carry runtime
710
- * data. Pure syntax, no type checker - the same honest limit the rule already documents. What
711
- * resolves: string/number literals, no-substitution templates, templates whose every span resolves,
712
- * ternaries with both branches resolvable (BOTH branch texts are returned, so a hostile keyword in
713
- * either still feeds the keyword scan), `as`/`satisfies` wrappers, a module-scope `const` whose
714
- * initializer resolves (recursively, depth-capped), and - when `scope.imports` is configured - such a
715
- * `const` imported by name from another in-project module. Everything else - `let`, a parameter, a
716
- * call, a member access, a shadowed name, a bare-specifier import - stays unresolved and the
717
- * interpolation is flagged exactly as before.
718
- */
719
- function resolveStaticSqlText(ts, node, scope, depth) {
720
- if (depth > CONST_RESOLUTION_DEPTH)
721
- return undefined;
722
- if (ts.isParenthesizedExpression(node)) {
723
- return resolveStaticSqlText(ts, node.expression, scope, depth);
724
- }
725
- if (ts.isAsExpression(node) || ts.isSatisfiesExpression(node)) {
726
- return resolveStaticSqlText(ts, node.expression, scope, depth);
727
- }
728
- if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node))
729
- return node.text;
730
- if (ts.isNumericLiteral(node))
731
- return node.text;
732
- if (ts.isTemplateExpression(node)) {
733
- let text = node.head.text;
734
- for (const span of node.templateSpans) {
735
- const part = resolveStaticSqlText(ts, span.expression, scope, depth + 1);
736
- if (part === undefined)
737
- return undefined;
738
- text += part + span.literal.text;
739
- }
740
- return text;
741
- }
742
- if (ts.isConditionalExpression(node)) {
743
- const whenTrue = resolveStaticSqlText(ts, node.whenTrue, scope, depth + 1);
744
- const whenFalse = resolveStaticSqlText(ts, node.whenFalse, scope, depth + 1);
745
- if (whenTrue === undefined || whenFalse === undefined)
746
- return undefined;
747
- return `${whenTrue} ${whenFalse}`;
748
- }
749
- if (ts.isIdentifier(node)) {
750
- const initializer = scope.module.consts.get(node.text);
751
- const edge = initializer === undefined ? scope.module.imports.get(node.text) : undefined;
752
- // Membership first: `isShadowedAt` walks a subtree, and most identifiers in a query are neither a
753
- // module const nor an import binding.
754
- if (initializer === undefined && edge === undefined)
755
- return undefined;
756
- // Shadowing is a property of where the identifier is WRITTEN, so one check is right on both sides
757
- // of an import hop: an initializer in the imported module sits at module scope with nothing above
758
- // it to shadow it, while a use inside a function in this file is still refused.
759
- if (isShadowedAt(ts, node, node.text))
760
- return undefined;
761
- if (initializer !== undefined)
762
- return resolveStaticSqlText(ts, initializer, scope, depth + 1);
763
- if (edge === undefined || scope.imports === undefined)
764
- return undefined;
765
- const target = loadSqlModuleScope(ts, scope.imports, scope.module.id, edge.specifier);
766
- if (target === undefined)
767
- return undefined;
768
- const exported = resolveExportedConst(ts, scope.imports, target, edge.exported, 1, new Set());
769
- if (exported === undefined)
770
- return undefined;
771
- // The imported initializer's own identifiers read THAT module's consts, never this file's. A
772
- // caller that happens to have its own `const DIR` must not silently supply the fragment's `DIR`,
773
- // which is the one way cross-module resolution could resolve to text nobody wrote.
774
- return resolveStaticSqlText(ts, exported.expression, { module: exported.scope, imports: scope.imports }, depth + 1);
775
- }
776
- return undefined;
777
- }
778
- /** A template's contribution to the shape: resolved span texts fold into `staticText` (so the
779
- * keyword scan still sees what a `const` carries); any unresolvable span keeps it `dynamic`. */
780
- function templateShape(ts, template, scope) {
781
- let staticText = template.head.text;
782
- let dynamic = false;
783
- for (const span of template.templateSpans) {
784
- const resolved = scope === undefined ? undefined : resolveStaticSqlText(ts, span.expression, scope, 0);
785
- if (resolved === undefined)
786
- dynamic = true;
787
- else
788
- staticText += resolved;
789
- staticText += span.literal.text;
790
- }
791
- return { staticText, dynamic };
792
- }
793
- /** Whether `name` is written to anywhere in the file - a plain, compound, or `++`/`--` assignment to a
794
- * bare identifier of that name. A `let` that is is over-cautiously refused (it might be reassigned from
795
- * request data); a `const` never needs this. Over-refusal only ever leaves an interpolation flagged as
796
- * before, never the reverse. */
797
- function isReassignedInFile(ts, source, name) {
798
- let found = false;
799
- const visit = (node) => {
800
- if (found)
801
- return;
802
- if (ts.isBinaryExpression(node) &&
803
- node.operatorToken.kind >= ts.SyntaxKind.FirstAssignment &&
804
- node.operatorToken.kind <= ts.SyntaxKind.LastAssignment &&
805
- ts.isIdentifier(node.left) &&
806
- node.left.text === name) {
807
- found = true;
808
- return;
809
- }
810
- if ((ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) &&
811
- (node.operator === ts.SyntaxKind.PlusPlusToken ||
812
- node.operator === ts.SyntaxKind.MinusMinusToken) &&
813
- ts.isIdentifier(node.operand) &&
814
- node.operand.text === name) {
815
- found = true;
816
- return;
817
- }
818
- ts.forEachChild(node, visit);
819
- };
820
- visit(source);
821
- return found;
822
- }
823
- /** Does `binding` bind `name` (as a plain identifier or nested inside a destructuring pattern)? Mirrors
824
- * the pattern walk in {@link isShadowedAt}. */
825
- function bindingNameCovers(ts, binding, name) {
826
- return ts.isIdentifier(binding)
827
- ? binding.text === name
828
- : binding.elements.some((element) => !ts.isOmittedExpression(element) &&
829
- (ts.isIdentifier(element.name)
830
- ? element.name.text === name
831
- : bindingNameCovers(ts, element.name, name)));
832
- }
833
- /** Inspect the bindings a single scope node introduces DIRECTLY (not nested) for `name`: parameters and
834
- * catch variables of a function/catch, `var`/`let`/`const` and function/class declarations of a
835
- * statement list. Returns the readable initializer, `"shadow"` for a form we cannot read through, or
836
- * `undefined` when this scope does not bind the name at all. */
837
- function directBinding(ts, source, scope, name) {
838
- if (ts.isFunctionLike(scope)) {
839
- for (const parameter of scope.parameters) {
840
- if (bindingNameCovers(ts, parameter.name, name))
841
- return "shadow";
842
- }
843
- }
844
- if (ts.isCatchClause(scope)) {
845
- const declared = scope.variableDeclaration;
846
- if (declared !== undefined && bindingNameCovers(ts, declared.name, name))
847
- return "shadow";
848
- }
849
- const statements = ts.isSourceFile(scope)
850
- ? scope.statements
851
- : ts.isBlock(scope) || ts.isModuleBlock(scope)
852
- ? scope.statements
853
- : ts.isCaseClause(scope) || ts.isDefaultClause(scope)
854
- ? scope.statements
855
- : undefined;
856
- const lists = [];
857
- if (statements !== undefined) {
858
- for (const statement of statements) {
859
- if (ts.isVariableStatement(statement))
860
- lists.push(statement.declarationList);
861
- if ((ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) &&
862
- statement.name?.text === name) {
863
- return "shadow";
864
- }
865
- }
866
- }
867
- if ((ts.isForStatement(scope) || ts.isForInStatement(scope) || ts.isForOfStatement(scope)) &&
868
- scope.initializer !== undefined &&
869
- ts.isVariableDeclarationList(scope.initializer)) {
870
- lists.push(scope.initializer);
871
- }
872
- for (const list of lists) {
873
- for (const declaration of list.declarations) {
874
- if (!bindingNameCovers(ts, declaration.name, name))
875
- continue;
876
- // A destructured or uninitialized binding, or a `var` (hoisting + reassignment risk), is not a
877
- // value we can read one expression out of.
878
- if (!ts.isIdentifier(declaration.name) || declaration.initializer === undefined)
879
- return "shadow";
880
- const isConst = (list.flags & ts.NodeFlags.Const) !== 0;
881
- const isLet = (list.flags & ts.NodeFlags.Let) !== 0;
882
- if (isConst)
883
- return { initializer: declaration.initializer };
884
- if (isLet && !isReassignedInFile(ts, source, name))
885
- return { initializer: declaration.initializer };
886
- return "shadow";
887
- }
888
- }
889
- return undefined;
890
- }
891
- /**
892
- * Resolve a query-call ARGUMENT identifier to the expression it was initialized from, so a statement
893
- * parked in a variable is scanned as if it had been written inline at the call. This is the counterpart
894
- * to {@link resolveStaticSqlText}: that folds a const's text INTO a template's shape; this unfolds the
895
- * whole statement back out of the variable it was lifted into. Without it, ANY interpolated-SQL finding
896
- * could be silenced, invisibly, by hoisting the statement to `const q = …` first.
897
- *
898
- * Same-file only. Nearest enclosing declaration wins, so a shadowing local is read and never a
899
- * same-named module const. Reads through a `const`, or a `let` the file never reassigns, at function or
900
- * module scope; transitive through an identifier-valued initializer; depth-capped. A parameter, an
901
- * imported or destructured binding, a reassigned `let`, a `var`, or a name with no same-file
902
- * declaration returns `undefined` - which leaves the argument scanned as itself (unflagged unless it is
903
- * a module const that resolves to static text: exactly the pre-feature behavior).
904
- */
905
- function resolveArgumentInitializer(ts, node, depth) {
906
- if (depth > CONST_RESOLUTION_DEPTH)
907
- return undefined;
908
- const source = node.getSourceFile();
909
- const name = node.text;
910
- for (let scope = node.parent; scope !== undefined; scope = scope.parent) {
911
- const binding = directBinding(ts, source, scope, name);
912
- if (binding === "shadow")
913
- return undefined;
914
- if (binding !== undefined) {
915
- let init = binding.initializer;
916
- while (ts.isParenthesizedExpression(init))
917
- init = init.expression;
918
- return ts.isIdentifier(init) ? resolveArgumentInitializer(ts, init, depth + 1) : init;
919
- }
920
- if (ts.isSourceFile(scope))
921
- break;
922
- }
923
- return undefined;
924
- }
925
- /** Describe only the first argument's syntax; comments/strings elsewhere cannot influence the result. */
926
- function sqlExpressionShape(ts, node, scope) {
927
- if (ts.isParenthesizedExpression(node))
928
- return sqlExpressionShape(ts, node.expression, scope);
929
- if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
930
- return {
931
- staticText: node.text,
932
- dynamic: false,
933
- concatenated: false,
934
- literal: true,
935
- parameterizedTag: false,
936
- };
937
- }
938
- if (ts.isTaggedTemplateExpression(node)) {
939
- const tag = node.tag;
940
- const parameterizedTag = (ts.isIdentifier(tag) && tag.text === "sql") ||
941
- (ts.isPropertyAccessExpression(tag) && tag.getText() === "Prisma.sql");
942
- const template = node.template;
943
- const templated = ts.isTemplateExpression(template)
944
- ? templateShape(ts, template, parameterizedTag ? undefined : scope)
945
- : { staticText: template.text, dynamic: false };
946
- const staticText = templated.staticText;
947
- // Only the ecosystem's binding tags are trusted, by NAME - which is the honest limit of a scanner
948
- // that reads syntax and runs no type checker. `sql` is what postgres.js, drizzle, slonik and Bun's
949
- // own driver all call theirs, and `Prisma.sql` is Prisma's; taking those at their word is a
950
- // deliberate trade, and it is worth stating what it costs: a no-op function named `sql` in the
951
- // same file is trusted too, so this rule finds mistakes, not an adversary who reads it first.
952
- //
953
- // Trusting anything else widens that for no gain. This list briefly also held `sqlIdentifiers`,
954
- // a name invented for Nifra's own adapters - a second forgeable name that no user has a
955
- // convention for, added so Nifra's source would pass its own rule. Names earn their place here by
956
- // being what drivers already call the thing, not by being convenient for the framework.
957
- //
958
- // Everything else - `String.raw`, an identity tag, a member access that merely ends in `.sql` -
959
- // is treated as plain interpolation.
960
- return {
961
- staticText,
962
- dynamic: templated.dynamic && !parameterizedTag,
963
- concatenated: false,
964
- literal: !ts.isTemplateExpression(template),
965
- parameterizedTag,
966
- };
967
- }
968
- if (ts.isTemplateExpression(node)) {
969
- const templated = templateShape(ts, node, scope);
970
- return {
971
- staticText: templated.staticText,
972
- dynamic: templated.dynamic,
973
- concatenated: false,
974
- literal: false,
975
- parameterizedTag: false,
976
- };
977
- }
978
- if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) {
979
- const left = sqlExpressionShape(ts, node.left, scope);
980
- const right = sqlExpressionShape(ts, node.right, scope);
981
- return {
982
- staticText: left.staticText + right.staticText,
983
- // A resolved const operand is static text, not a dynamic part - so the old `!literal` test
984
- // narrows to what it always actually meant: an operand that carries runtime data (dynamic) or
985
- // a binding tag being concatenated into plain text (which un-parameterizes it).
986
- dynamic: left.dynamic || right.dynamic || left.parameterizedTag || right.parameterizedTag,
987
- concatenated: true,
988
- literal: left.literal && right.literal,
989
- parameterizedTag: false,
990
- };
991
- }
992
- // A resolvable expression (a module-`const` name, a literal ternary) contributes its compile-time
993
- // text and stays non-dynamic - this is what lets `"SELECT " + COLS + " FROM t"` and
994
- // `db.query(`${COLS} FROM t WHERE id = $1`)` pass while a mutable or unprovable name still flags.
995
- // `literal` stays false: the named escape hatches (`unsafe`, `$queryRawUnsafe`) keep flagging a
996
- // resolved const, because taking statement text from ANY variable is what their rule warns about.
997
- if (scope !== undefined) {
998
- const resolved = resolveStaticSqlText(ts, node, scope, 0);
999
- if (resolved !== undefined) {
1000
- return {
1001
- staticText: resolved,
1002
- dynamic: false,
1003
- concatenated: false,
1004
- literal: false,
1005
- parameterizedTag: false,
1006
- };
1007
- }
1008
- }
1009
- // A bare name that did not resolve to compile-time text. If it is a same-file binding we can prove
1010
- // holds one expression, scan THAT expression's shape - so a statement hoisted into `const q = …` is
1011
- // read exactly as if it were written inline at the call, closing the "extract a variable to launder
1012
- // the finding" hole. `literal` is forced false: the value arrived through a variable, which is the
1013
- // property the `unsafe`/`$queryRawUnsafe` hatch rule keys on.
1014
- if (ts.isIdentifier(node)) {
1015
- const initializer = resolveArgumentInitializer(ts, node, 0);
1016
- if (initializer !== undefined) {
1017
- return { ...sqlExpressionShape(ts, initializer, scope), literal: false };
1018
- }
1019
- }
1020
- return {
1021
- staticText: "",
1022
- dynamic: true,
1023
- concatenated: false,
1024
- literal: false,
1025
- parameterizedTag: false,
1026
- };
1027
- }
1028
- function sqlMethod(ts, call) {
1029
- if (ts.isPropertyAccessExpression(call.expression)) {
1030
- const method = call.expression.name.text;
1031
- return SQL_METHODS.has(method)
1032
- ? { method, receiver: call.expression.expression.getText() }
1033
- : undefined;
1034
- }
1035
- if (ts.isElementAccessExpression(call.expression) &&
1036
- call.expression.argumentExpression !== undefined &&
1037
- ts.isStringLiteral(call.expression.argumentExpression)) {
1038
- const method = call.expression.argumentExpression.text;
1039
- return SQL_METHODS.has(method)
1040
- ? { method, receiver: call.expression.expression.getText() }
1041
- : undefined;
1042
- }
1043
- return undefined;
1044
- }
1045
- const SQL_RECEIVER = /(?:^|\.)(?:db|sql|prisma|drizzle|database|conn|connection|client|tx)$/i;
1046
- /** The opt-out pragma that silences one interpolated-SQL finding. Written `// nifra-expect sql-dynamic:
1047
- * <reason>` on the flagged line or in the comment block directly above it; the reason after the colon
1048
- * is mandatory (see {@link commentBlockMarkerReason}). Scoped to the single statement it sits on. */
1049
- const SQL_DYNAMIC_PRAGMA = "nifra-expect sql-dynamic";
1050
- /**
1051
- * Scan one file's text for SQL assembled by interpolation. Pure + line-accurate.
1052
- *
1053
- * `imports` opts into following a fragment `const` into another module. Without it the scan is
1054
- * same-file only, which is what the unit tests exercise and what any caller that has no module graph
1055
- * to offer gets. With it, only what {@link createProjectSqlImports} will hand back is followed.
1056
- */
1057
- export function scanInterpolatedSql(file, content, ts, imports) {
1058
- const out = [];
1059
- const lines = content.split("\n");
1060
- // Malformed source is already a typecheck failure. Do not turn parser recovery nodes into a second,
1061
- // misleading SQL diagnostic (notably an unterminated template recovered as an interpolation).
1062
- const source = parseSqlSource(ts, file, content);
1063
- if (source === undefined)
1064
- return out;
1065
- // Module-scope bindings, read once per file: an interpolated `const` that resolves to compile-time
1066
- // text cannot carry request data, so the shared-projection idiom (`const COLS = "id, name"` …
1067
- // `${COLS}`) stays quiet while mutable, parameter, shadowed, and unprovable names flag as before.
1068
- const scope = { module: sqlModuleScope(ts, file, source), imports };
1069
- const visit = (node) => {
1070
- if (ts.isCallExpression(node)) {
1071
- const sink = sqlMethod(ts, node);
1072
- const argument = node.arguments[0];
1073
- if (sink !== undefined && argument !== undefined) {
1074
- const shape = sqlExpressionShape(ts, argument, scope);
1075
- const unsafeEscape = ALWAYS_UNSAFE.has(sink.method) && !shape.literal && !shape.parameterizedTag;
1076
- const assembledSql = shape.dynamic &&
1077
- (SQL_KEYWORD.test(shape.staticText) ||
1078
- (shape.concatenated && SQL_RECEIVER.test(sink.receiver)));
1079
- if (unsafeEscape || assembledSql) {
1080
- const index = node.getStart(source);
1081
- const line = source.getLineAndCharacterOfPosition(index).line + 1;
1082
- // Sanctioned escape hatch for genuinely-dynamic-but-bound SQL (batch VALUES placeholder
1083
- // generation, an allowlisted identifier). The reason is mandatory: a bare `// nifra-expect
1084
- // sql-dynamic` with no reason after the colon does NOT silence, so the hatch always leaves a
1085
- // greppable audit trail instead of becoming a second laundering trick.
1086
- const silenced = commentBlockMarkerReason(lines, line, SQL_DYNAMIC_PRAGMA) !== undefined;
1087
- if (!silenced)
1088
- out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() });
1089
- }
1090
- }
1091
- }
1092
- ts.forEachChild(node, visit);
1093
- };
1094
- visit(source);
1095
- return out;
1096
- }
1097
- /**
1098
- * Collect interpolated-SQL findings across the project.
1099
- *
1100
- * Returns `undefined` - not an empty array - when TypeScript is not installed. The rule parses source
1101
- * with the compiler, so without it there is nothing to report and, more to the point, no basis for
1102
- * reporting nothing. An empty result would read as "no interpolated SQL found", which is the one
1103
- * answer a security rule must never give when it did not run.
1104
- */
1105
- export async function scanProjectSql(cwd) {
1106
- const ts = await importProjectTypeScript(cwd);
1107
- if (ts === undefined)
1108
- return undefined;
1109
- const out = [];
1110
- const imports = createProjectSqlImports(cwd);
1111
- await walkSource(cwd, (rel, content) => out.push(...scanInterpolatedSql(rel, content, ts, imports)));
1112
- return out.sort(bySite);
1113
- }
1114
- /** A declaration file. Excluded from cross-module resolution: `declare const X: string` has no
1115
- * initializer to read, so following one could only ever produce a "proved" the source never wrote. */
1116
- const SQL_DECLARATION_FILE = /\.d\.[cm]?ts$/;
1117
- /** The extensions the SQL scan will parse - the same set {@link walkSource} globs. */
1118
- const SQL_SOURCE_FILE = /\.[cm]?tsx?$/;
1119
- /**
1120
- * The on-disk loader for cross-module `const` resolution, scoped to one project.
1121
- *
1122
- * Deliberately narrow, because every widening here is a way for a real finding to go quiet:
1123
- *
1124
- * - **Relative specifiers only.** A bare specifier names a dependency whose exports can change without
1125
- * this project changing, so "provably a `const` string" is not a property the project owns.
1126
- * - **Inside `cwd`, outside the ignored trees.** The resolved path is realpath'd and must still sit
1127
- * under the project root: a symlink pointing out of the tree is not source this scan reads. The
1128
- * `node_modules` / `dist` / build exclusions are the same ones the walk applies.
1129
- * - **Real source only.** A `.d.ts` carries no initializer, and a non-TS file is not parsed.
1130
- *
1131
- * A miss on any of those returns `undefined`, which leaves the name unresolved and the interpolation
1132
- * flagged - the pre-feature answer.
1133
- */
1134
- export function createProjectSqlImports(cwd) {
1135
- const root = (() => {
1136
- try {
1137
- return realpathSync(resolve(cwd));
1138
- }
1139
- catch {
1140
- return resolve(cwd);
1141
- }
1142
- })();
1143
- return createSqlImports((fromModule, specifier) => {
1144
- if (!isRelativeSpecifier(specifier))
1145
- return undefined;
1146
- try {
1147
- // `fromModule` is the cwd-RELATIVE path the walk supplies for an entry file, and ABSOLUTE for
1148
- // every module reached through a hop (their ids are the resolved paths).
1149
- const fromAbs = isAbsolute(fromModule) ? fromModule : join(root, fromModule);
1150
- const resolved = realpathSync(Bun.resolveSync(specifier, dirname(fromAbs)));
1151
- if (!resolved.startsWith(root + sep))
1152
- return undefined;
1153
- const rel = resolved.slice(root.length + 1);
1154
- if (IGNORED_DIR.test(rel))
1155
- return undefined;
1156
- if (SQL_DECLARATION_FILE.test(resolved) || !SQL_SOURCE_FILE.test(resolved))
1157
- return undefined;
1158
- return { id: resolved, content: readFileSync(resolved, "utf8") };
1159
- }
1160
- catch {
1161
- return undefined; // unresolvable, unreadable, or outside the project
1162
- }
1163
- });
1164
- }
1165
- // `client("` / `client('` / `client(\`` - a URL-first call WITHOUT the `<typeof app>` generic:
1166
- // the compiler has nothing to derive types from, so the anti-drift guarantee silently vanishes.
1167
- // `client<typeof app>("…")` has `<…>` between the name and `(` so it never matches; the published-
1168
- // contract form `client(contract, url)` starts with an identifier, not a quote - also unmatched.
1169
- const CLIENT_CALL = /(?<![.\w])client\s*\(/g;
1170
- /** Scan one file's text for untyped `client("…")` calls. Pure + line-accurate. */
1171
- export function scanUntypedClient(file, content) {
1172
- const out = [];
1173
- const lines = content.split("\n");
1174
- const code = codePositionMask(content);
1175
- CLIENT_CALL.lastIndex = 0;
1176
- for (let m = CLIENT_CALL.exec(code); m !== null; m = CLIENT_CALL.exec(code)) {
1177
- const argument = firstArgumentIndex(content, m.index + m[0].length);
1178
- const quote = content[argument];
1179
- if (quote !== "'" && quote !== '"' && quote !== "`")
1180
- continue;
1181
- const line = lineAt(content, m.index);
1182
- out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() });
1183
- }
1184
- return out;
1185
- }
1186
- /**
1187
- * Specifiers that no longer exist, and what replaced them.
1188
- *
1189
- * These are the breaks a typecheck does not catch and a test suite does not either. `@nifrajs/core/ws`
1190
- * was a side-effect import removed in 2.0 in favour of `.use(websocket())`; a consuming package kept
1191
- * the import and a 533-test suite stayed green while the app could not boot. `@nifrajs/budget` folded
1192
- * into core with no npm deprecation, so `bun install` fails workspace-wide with a resolution error
1193
- * that names neither the cause nor the replacement.
1194
- *
1195
- * A lint is the right shape for this: it runs before boot, names the file and line, and states the
1196
- * replacement - which is what neither the resolver error nor the runtime failure does.
1197
- */
1198
- const REMOVED_IMPORTS = [
1199
- {
1200
- specifier: "@nifrajs/budget",
1201
- since: "2.0",
1202
- replacement: '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',
1203
- },
1204
- {
1205
- specifier: "@nifrajs/core/ws",
1206
- since: "2.0",
1207
- sideEffectOnly: true,
1208
- replacement: 'a bare `import "@nifrajs/core/ws"` no longer installs the runtime - register it explicitly with `.use(websocket())`, importing `websocket` from the same module',
1209
- },
1210
- ];
1211
- /** Whether the import statement at `index` is the bare side-effect form (`import "x"`), rather than
1212
- * one with bindings (`import { y } from "x"`). */
1213
- function isSideEffectImport(content, index) {
1214
- // `staticImportEdges` reports the index of the STATEMENT, not of the specifier, so read forward:
1215
- // a side-effect import is `import` followed directly by the quoted specifier, with no bindings and
1216
- // no `from` in between.
1217
- return /^import\s*["'`]/.test(content.slice(index, index + 32));
1218
- }
1219
- /** Flag an import of a specifier that no longer exists. Pure; matches the exact specifier or a subpath
1220
- * of it, so `@nifrajs/budget/x` is caught alongside `@nifrajs/budget`. */
1221
- export function scanRemovedImports(file, content) {
1222
- const out = [];
1223
- const lines = content.split("\n");
1224
- for (const edge of staticImportEdges(content)) {
1225
- const removed = REMOVED_IMPORTS.find((entry) => edge.specifier === entry.specifier || edge.specifier.startsWith(`${entry.specifier}/`));
1226
- if (removed === undefined)
1227
- continue;
1228
- if (removed.sideEffectOnly === true && !isSideEffectImport(content, edge.index))
1229
- continue;
1230
- const line = lineAt(content, edge.index);
1231
- out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() });
1232
- }
1233
- return out;
1234
- }
1235
- /** Scan a route module for top-level server-only imports. Returns `[]` for non-route files (only
1236
- * `routes/` modules are browser-bundled, so a server-only import elsewhere is fine). Each finding carries
1237
- * the offending `specifier` so the diagnostic can render the `routeFile → specifier` chain. Pure. */
1238
- export function scanServerOnlyImports(file, content, facts) {
1239
- if (!ROUTE_FILE.test(file))
1240
- return [];
1241
- const out = [];
1242
- const lines = content.split("\n");
1243
- const code = stripComments(content);
1244
- const positions = codePositionMask(content);
1245
- STATIC_IMPORT.lastIndex = 0;
1246
- for (let m = STATIC_IMPORT.exec(code); m !== null; m = STATIC_IMPORT.exec(code)) {
1247
- if (positions[m.index] === " ")
1248
- continue;
1249
- const specifier = m[1] ?? "";
1250
- if (!SERVER_ONLY.test(specifier))
1251
- continue;
1252
- if (facts !== undefined) {
1253
- const source = facts.parse(file, content);
1254
- // Inline `import { type X } from "…"` is erased just like `import type`; don't call it a
1255
- // runtime leak. A parse failure keeps the old lexical finding so the security rule fails closed.
1256
- if (source !== undefined && facts.isValueImportAt(source, m.index, specifier) === false)
1257
- continue;
1258
- }
1259
- const line = lineAt(content, m.index);
1260
- out.push({ file, line, snippet: (lines[line - 1] ?? "").trim(), specifier });
1261
- }
1262
- return out;
1263
- }
1264
- // ---------------------------------------------------------------------------------------------------
1265
- // #4.4 - TRANSITIVE import-chain resolution for `server-only-import`. `scanServerOnlyImports` above is a
1266
- // pure per-file regex scan: it sees only the route's DIRECT `import` line, so it reports the direct edge
1267
- // `routes/x → ../db`. The build leak-guard, which has the real module graph, reports the full transitive
1268
- // `route → ../data → ../db → pg`. These helpers give `nifra check` the same depth via a BOUNDED
1269
- // import-resolution walk: from a flagged route, resolve its local imports (`Bun.resolveSync`), BFS the
1270
- // local module graph, and build the shortest chain to a server-only SINK. It's best-effort: an import
1271
- // that can't be precisely resolved (a bare pkg, a tsconfig path alias) falls back to the direct edge.
1272
- // ---------------------------------------------------------------------------------------------------
1273
- // Server-only specifiers that are TERMINAL sinks - a bare `node:`/`bun:` builtin or a known server-only
1274
- // npm package. These are never local source we can walk into, so the chain ends here. (Same vocabulary
1275
- // as SERVER_ONLY, minus the relative `../db` arm - a relative `db` module IS local source we resolve.)
1276
- const SERVER_ONLY_SINK = /^(?:node:|bun:)|^(?:postgres|pg|mysql2|ioredis|redis|better-sqlite3|mongodb|@libsql\/client)$|^drizzle-orm\/(?:node-postgres|postgres-js|bun-sqlite|libsql|mysql2|pglite)\b/;
1277
- // The `.server` convention: a module named `*.server.ts(x)` is server-only (the client build empties it).
1278
- const SERVER_MODULE_FILE = /\.server(\.[cm]?[jt]sx?)?$/;
1279
- // The explicit poison-import marker (`@nifrajs/web/server-only`) - a module opting into the client-leak
1280
- // guard. A resolved file whose source carries this side-effect import is a server-only sink.
1281
- const SERVER_ONLY_MARKER_IMPORT = /import\s+["']@nifrajs\/web\/server-only["']/;
1282
- // Depth/visited caps keep the walk linear + cycle-safe. A route's server-only dependency sits within a
1283
- // few hops in practice; the bound stops a pathological graph from blowing up the per-file scan.
1284
- const TRANSITIVE_MAX_DEPTH = 8;
1285
- const TRANSITIVE_MAX_VISITED = 200;
1286
- /** A relative module specifier (`./x`, `../y`) - the only kind we resolve + walk into (a bare specifier
1287
- * is either a sink we recognise by name or a third-party dep we don't follow into node_modules). */
1288
- const isRelativeSpecifier = (spec) => spec.startsWith("./") || spec.startsWith("../");
1289
- // A FRESH copy of the static-import regex per scan. The transitive walk is REENTRANT - the outer
1290
- // per-route loop and the inner BFS both scan imports - and a single shared global-flag regex carries
1291
- // `lastIndex` state, so reusing the module-level `STATIC_IMPORT` across nested calls corrupts the outer
1292
- // loop's position (it restarts forever). A fresh instance per call keeps each scan's state private.
1293
- const staticImportRegex = () => new RegExp(STATIC_IMPORT.source, STATIC_IMPORT.flags);
1294
- /** Extract the static, non-type import specifiers from a module's source (the edges to follow/inspect),
1295
- * each with the match index (for line attribution). Mirrors {@link STATIC_IMPORT}, so `import type` +
1296
- * dynamic `import()` are already excluded. Uses a fresh regex instance, so it's safe under the reentrant
1297
- * transitive walk. Pure. */
1298
- function staticImportEdges(content, facts, file) {
1299
- const edges = [];
1300
- const re = staticImportRegex();
1301
- const code = stripComments(content);
1302
- const positions = codePositionMask(content);
1303
- for (let m = re.exec(code); m !== null; m = re.exec(code)) {
1304
- if (positions[m.index] === " ")
1305
- continue;
1306
- if (m[1] === undefined)
1307
- continue;
1308
- if (facts !== undefined && file !== undefined) {
1309
- const source = facts.parse(file, content);
1310
- if (source !== undefined && facts.isValueImportAt(source, m.index, m[1]) === false)
1311
- continue;
1312
- }
1313
- edges.push({ specifier: m[1], index: m.index });
1314
- }
1315
- return edges;
1316
- }
1317
- /** Extract the static, non-type import specifiers from a module's source (the edges to follow/inspect).
1318
- * Mirrors {@link STATIC_IMPORT}, so `import type` + dynamic `import()` are already excluded. Pure. */
1319
- export function parseStaticImports(content, facts, file) {
1320
- return staticImportEdges(content, facts, file).map((e) => e.specifier);
1321
- }
1322
- /** The server-only SINK an import specifier names directly (a `node:`/`bun:` builtin or a known
1323
- * server-only package), or `undefined` if it isn't a by-name sink. Pure - the label is the specifier
1324
- * itself (it's already the actionable name). */
1325
- export function directSinkSpecifier(spec) {
1326
- return SERVER_ONLY_SINK.test(spec) ? spec : undefined;
1327
- }
1328
- /**
1329
- * BFS the LOCAL module graph from a route file for the SHORTEST import chain that reaches a server-only
1330
- * sink, returning `[routeFile, …as-written specifiers…, sink]` or `undefined` if none is reachable. A
1331
- * node's outgoing edges are its static imports; an edge is followed only when it's a RELATIVE specifier
1332
- * that `resolve` maps to a readable local file (so the walk never descends into node_modules or chases an
1333
- * unresolvable alias). At each node, a by-name sink import (`node:fs`, `postgres`) OR a resolved
1334
- * `*.server` / `server-only`-marked dependency terminates the chain. Bounded by depth + a visited set, so
1335
- * it's linear and cycle-free. `routeFile`/`routeContent` seed the walk; `resolve`/`read` supply the graph
1336
- * - pure given those, so it's unit-testable with a fake graph.
1337
- */
1338
- export function walkServerOnlyChain(routeFile, routeContent, resolve, read, facts) {
1339
- const seen = new Set([routeFile]);
1340
- let frontier = [{ abs: routeFile, content: routeContent, chain: [routeFile], depth: 0 }];
1341
- let visited = 0;
1342
- while (frontier.length > 0) {
1343
- const next = [];
1344
- for (const node of frontier) {
1345
- if (node.depth >= TRANSITIVE_MAX_DEPTH)
1346
- continue;
1347
- for (const spec of parseStaticImports(node.content, facts, node.abs)) {
1348
- // (a) A by-name sink (builtin / known server-only pkg) → the chain ends here (shortest first,
1349
- // since BFS reaches the nearest sink before any deeper one).
1350
- const sink = directSinkSpecifier(spec);
1351
- if (sink !== undefined)
1352
- return [...node.chain, sink];
1353
- // (b) Only relative specifiers are local source we resolve + walk into. A bare third-party
1354
- // specifier that isn't a known sink is a leaf - don't follow it into node_modules.
1355
- if (!isRelativeSpecifier(spec))
1356
- continue;
1357
- const abs = resolve(node.abs, spec);
1358
- if (abs === undefined || seen.has(abs))
1359
- continue;
1360
- // (c) A resolved `*.server` module is a server-only sink by the `.server` convention - the chain
1361
- // ends at it (named by the as-written specifier).
1362
- if (SERVER_MODULE_FILE.test(abs))
1363
- return [...node.chain, spec];
1364
- const content = read(abs);
1365
- if (content === undefined)
1366
- continue; // unreadable → can't walk; treat as a leaf
1367
- // (d) A resolved module that opts into the `server-only` marker is a sink too.
1368
- if (SERVER_ONLY_MARKER_IMPORT.test(content))
1369
- return [...node.chain, spec];
1370
- if (visited >= TRANSITIVE_MAX_VISITED)
1371
- continue;
1372
- visited++;
1373
- seen.add(abs);
1374
- next.push({ abs, content, chain: [...node.chain, spec], depth: node.depth + 1 });
1375
- }
1376
- }
1377
- frontier = next;
1378
- }
1379
- return undefined;
1380
- }
1381
- /**
1382
- * Resolve the FULL transitive server-only chain(s) for a route module, given a real fs-backed resolver +
1383
- * reader. For each of the route's top-level imports, if a precise transitive walk finds a sink, the
1384
- * finding carries the full chain; otherwise - when the first hop is itself a direct by-name sink the
1385
- * regex scan already flags, or when a relative import can't be resolved - it falls back to the DIRECT
1386
- * edge (`fallback: true`), never a fabricated chain. Returns one finding per offending top-level import,
1387
- * de-duplicated to the shortest chain per first-hop specifier. Non-route files yield `[]`.
1388
- */
1389
- export function resolveServerOnlyChains(file, content, resolve, read, facts) {
1390
- if (!ROUTE_FILE.test(file))
1391
- return [];
1392
- const lines = content.split("\n");
1393
- const out = [];
1394
- const flaggedSpecifiers = new Set();
1395
- // Collect the route's import edges up front (fresh-regex scan) so the per-edge logic below can call
1396
- // the REENTRANT transitive walk without corrupting a shared regex's `lastIndex` (the walk also scans
1397
- // imports). Driving `STATIC_IMPORT.exec` here directly would restart this loop forever.
1398
- for (const { specifier, index } of staticImportEdges(content, facts, file)) {
1399
- if (flaggedSpecifiers.has(specifier))
1400
- continue;
1401
- const line = lineAt(content, index);
1402
- const snippet = (lines[line - 1] ?? "").trim();
1403
- // (1) A direct by-name sink (`node:fs`, `postgres`) - the route imports it itself; chain is the
1404
- // direct edge. (Length-2 chain == the regex scan's existing `[route, specifier]`.)
1405
- if (directSinkSpecifier(specifier) !== undefined) {
1406
- flaggedSpecifiers.add(specifier);
1407
- out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: false });
1408
- continue;
1409
- }
1410
- // (2) A relative local import - try the transitive walk from the resolved dependency. If it reaches
1411
- // a sink, emit the full chain rooted at THIS route's import line.
1412
- if (isRelativeSpecifier(specifier)) {
1413
- const abs = resolve(file, specifier);
1414
- if (abs === undefined) {
1415
- // Unresolvable relative import. If it's the known server-only `../db` convention the regex scan
1416
- // flags directly, fall back to the direct edge (precise resolve impossible - say so via
1417
- // `fallback`). Any other unresolvable relative import we can't assert is server-only - skip it.
1418
- if (SERVER_ONLY.test(specifier)) {
1419
- flaggedSpecifiers.add(specifier);
1420
- out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: true });
1421
- }
1422
- continue;
1423
- }
1424
- // The `.server` / marker sink can be the first hop itself.
1425
- if (SERVER_MODULE_FILE.test(abs)) {
1426
- flaggedSpecifiers.add(specifier);
1427
- out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: false });
1428
- continue;
1429
- }
1430
- const depContent = read(abs);
1431
- if (depContent === undefined)
1432
- continue;
1433
- if (SERVER_ONLY_MARKER_IMPORT.test(depContent)) {
1434
- flaggedSpecifiers.add(specifier);
1435
- out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: false });
1436
- continue;
1437
- }
1438
- // Walk INTO the dependency: build the chain `[depAbs, …]` then re-root it at the route's import.
1439
- const subChain = walkServerOnlyChain(abs, depContent, resolve, read, facts);
1440
- if (subChain !== undefined) {
1441
- flaggedSpecifiers.add(specifier);
1442
- // subChain is `[depAbs, …hops…, sink]`; replace its head (the resolved dep path) with the
1443
- // route's as-written specifier so the chain reads `routes/x → ../data → ../db → node:crypto`.
1444
- const chain = [file, specifier, ...subChain.slice(1)];
1445
- out.push({ file, line, snippet, specifier, chain, fallback: false });
1446
- continue;
1447
- }
1448
- // (3) The relative `../db`-style convention the regex scan flags directly, but where the walk
1449
- // found no deeper sink (e.g. the file wasn't readable past the first hop) - fall back to the
1450
- // direct edge so we still surface the known-server-only convention rather than going silent.
1451
- if (SERVER_ONLY.test(specifier)) {
1452
- flaggedSpecifiers.add(specifier);
1453
- out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: true });
1454
- }
1455
- }
1456
- }
1457
- return out;
1458
- }
1459
- // A route handler returning a raw `Response` (`=> new Response(`, `return Response.json(`, …) makes the
1460
- // typed client infer `data: never` (`Jsonify<Response>` is `never`) - so frontend/backend drift detection
1461
- // silently vanishes for that route. Advisory, not a failure: a raw Response is sometimes intended
1462
- // (redirects, files, streams). Fix is to return a plain object, or declare `{ response: t.… }` to type it.
1463
- const RESPONSE_RETURN = /(?:=>\s*|return\s+)(?:new\s+Response|Response\s*\.\s*json)\s*\(/g;
1464
- /** Scan a backend module (one that calls `server(`) for handlers returning a raw `Response`, which collapses
1465
- * the typed client's `data` to `never`. Pure + line-accurate; returns `[]` for files with no `server(` call. */
1466
- export function scanResponseRoutes(file, content, facts) {
1467
- // Strip comments + template literals first: a commented-out or doc-example `return new Response(`
1468
- // must not raise a spurious advisory. Lengths are preserved, so lineAt + the raw-line snippet align.
1469
- const code = stripComments(content);
1470
- if (!/(?<![.\w])server\s*\(/.test(code))
1471
- return [];
1472
- const out = [];
1473
- const lines = content.split("\n");
1474
- RESPONSE_RETURN.lastIndex = 0;
1475
- for (let m = RESPONSE_RETURN.exec(code); m !== null; m = RESPONSE_RETURN.exec(code)) {
1476
- if (facts !== undefined) {
1477
- const source = facts.parse(file, content);
1478
- // A parseable AST rejects `return new Response(` text that only lives in a normal string. On
1479
- // malformed source we keep the lexical candidate; the typecheck gate will report the syntax error.
1480
- if (source !== undefined && facts.isResponseSyntaxAt(source, m.index) !== true)
1481
- continue;
1482
- }
1483
- const line = lineAt(content, m.index);
1484
- // A `// nifra-expect raw-response` pragma marks an intentional raw Response (a file/redirect that can't
1485
- // be a typed route), silencing this advisory. Honor it on the return line itself, OR on the line above
1486
- // ONLY when that line is a standalone comment - so a TRAILING pragma on one route's return line can't
1487
- // leak down and suppress the very next route's genuine advisory. `stripComments` blanks the pragma in
1488
- // `code` (so it never affects the match); the raw `lines` still carry it.
1489
- const thisLine = lines[line - 1] ?? "";
1490
- const aboveLine = lines[line - 2] ?? "";
1491
- const aboveSuppresses = aboveLine.trimStart().startsWith("//") && aboveLine.includes(RAW_RESPONSE_PRAGMA);
1492
- if (thisLine.includes(RAW_RESPONSE_PRAGMA) || aboveSuppresses)
1493
- continue;
1494
- out.push({ file, line, snippet: thisLine.trim() });
1495
- }
1496
- return out;
1497
- }
1498
- /** The opt-out pragma that suppresses the {@link scanResponseRoutes} advisory for an intentional raw Response. */
1499
- const RAW_RESPONSE_PRAGMA = "nifra-expect raw-response";
1500
- /** Walk the project's `.ts`/`.tsx` source (skipping deps and build output), calling `visit` per file.
1501
- * Exported so `nifra doctor` ({@link ./doctor.ts}) scans the same source surface as `nifra check`.
1502
- *
1503
- * `includeTests` widens the surface to `*.test.ts`/`*.spec.ts`, which the default excludes. It exists
1504
- * for `nifra doctor`: `tsc` compiles tests, so a test importing a package no manifest declares is a
1505
- * build that a clean install cannot produce - the exact false confidence doctor is for. */
1506
- /**
1507
- * Paths git considers ignored under `cwd`, in ONE batched `git check-ignore` call - so `.gitignore`
1508
- * (root + nested + the global excludesfile) is honoured, not just the built-in {@link IGNORED} list. This
1509
- * is what keeps the scan out of generated/build trees that live under a repo but aren't source: a monorepo
1510
- * that gitignores, say, `builder/projects/` (238 generated apps) would otherwise be walked in full - 40k+
1511
- * findings, a 50 MB+ result that overwhelms the caller. Returns an EMPTY set when there's no git / not a
1512
- * repo / git errors, so the scan simply degrades to the {@link IGNORED} regex - never throws, never blocks.
1513
- */
1514
- async function gitIgnored(cwd, rels) {
1515
- if (rels.length === 0)
1516
- return new Set();
1517
- try {
1518
- const proc = Bun.spawn(["git", "check-ignore", "--stdin"], {
1519
- cwd,
1520
- stdin: "pipe",
1521
- stdout: "pipe",
1522
- stderr: "ignore",
1523
- });
1524
- proc.stdin.write(`${rels.join("\n")}\n`);
1525
- await proc.stdin.end();
1526
- // git echoes back each ignored path exactly as fed on stdin (one per line); exit 1 = none matched.
1527
- const out = await new Response(proc.stdout).text();
1528
- await proc.exited;
1529
- return new Set(out
1530
- .split("\n")
1531
- .map((s) => s.trim())
1532
- .filter(Boolean));
1533
- }
1534
- catch {
1535
- return new Set();
1536
- }
1537
- }
1538
- export async function walkSource(cwd, visit, opts = {}) {
1539
- const skip = opts.includeTests === true ? IGNORED_DIR : IGNORED;
1540
- // List candidates first (cheap - no reads), drop the built-in ignores, then exclude gitignored paths in
1541
- // one batch before the (expensive) reads. So a gitignored generated/build tree is never read or scanned.
1542
- const rels = [];
1543
- for await (const rel of new Glob("**/*.{ts,tsx,mts,cts}").scan({ cwd, dot: false })) {
1544
- if (!skip.test(rel))
1545
- rels.push(rel);
1546
- }
1547
- const ignored = await gitIgnored(cwd, rels);
1548
- for (const rel of rels) {
1549
- if (ignored.has(rel))
1550
- continue;
1551
- visit(rel, await Bun.file(join(cwd, rel)).text());
1552
- }
1553
- }
1554
- const bySite = (a, b) => a.file.localeCompare(b.file) || a.line - b.line;
1555
- /** Collect own-API `fetch()` findings across the project. */
1556
- export async function scanProject(cwd) {
1557
- const out = [];
1558
- await walkSource(cwd, (rel, content) => out.push(...scanFetchText(rel, content)));
1559
- return out.sort(bySite);
1560
- }
1561
- // #7 - server-manifest drift. `server-manifest.ts` is a COMMITTED generated file (it bakes the route
1562
- // list + client-entry hash for a disk-less worker). If `routes/` changes but the manifest isn't
1563
- // regenerated, the worker serves a stale route table - a silent edge break that no other check catches.
1564
- // We diff each committed manifest's imported route files against the live `routes/` tree.
1565
- // The marker comment `generateServerManifest` emits at the top of the file - identifies a generated
1566
- // manifest unambiguously (so a user file merely named `server-manifest.ts` isn't mistaken for one).
1567
- const GENERATED_MARKER = "GENERATED by @nifrajs/web generateServerManifest";
1568
- // The first route-import specifier's prefix up to `routes/` (e.g. `./`, `../`, `./src/`) - used to
1569
- // locate the routes dir relative to the manifest, and to strip to route-relative keys.
1570
- const ROUTES_PREFIX = /["'](\.{1,2}(?:\/[^"'/]+)*?\/routes\/)[^"']+["']/;
1571
- // Route file extensions discovery recognises (mirrors `@nifrajs/web/fs`'s filter).
1572
- const ROUTE_FILE_EXT = /\.(tsx|jsx|svelte|vue|mdx)$/;
1573
- /**
1574
- * Scan the project for committed, generated `server-manifest.ts` files and report any that have drifted
1575
- * from the live `routes/` tree. For each, the routes dir is derived from the manifest's own import
1576
- * prefix (so a manifest that imports `../routes/x` is checked against the sibling `routes/`), its route
1577
- * imports are parsed (`parseManifestRouteFiles`), and the set is diffed against the actual route files
1578
- * on disk. A clean manifest yields no finding. Returns one finding per drifted manifest. Never throws on
1579
- * a per-manifest miss (an unreadable routes dir is simply skipped).
1580
- */
1581
- export async function scanServerManifestDrift(cwd) {
1582
- const { parseManifestRouteFiles, diffManifestRoutes, isManifestInSync } = await import("@nifrajs/web/build");
1583
- const findings = [];
1584
- for await (const rel of new Glob("**/server-manifest.ts").scan({ cwd, dot: false })) {
1585
- if (IGNORED.test(rel))
1586
- continue;
1587
- const source = await Bun.file(join(cwd, rel)).text();
1588
- if (!source.includes(GENERATED_MARKER))
1589
- continue; // not a generated manifest - skip
1590
- const prefixMatch = ROUTES_PREFIX.exec(source);
1591
- if (prefixMatch?.[1] === undefined)
1592
- continue; // no `routes/` imports → nothing to diff (empty app)
1593
- const routesPrefix = prefixMatch[1];
1594
- const manifestFiles = parseManifestRouteFiles(source, routesPrefix);
1595
- // The routes dir sits at `<manifest dir>/<prefix>` - resolve it relative to the manifest file.
1596
- const manifestDir = rel.includes("/") ? rel.slice(0, rel.lastIndexOf("/")) : "";
1597
- const routesDir = join(cwd, manifestDir, routesPrefix);
1598
- let discovered;
1599
- try {
1600
- discovered = (await Array.fromAsync(new Glob("**/*.{tsx,jsx,svelte,vue,mdx}").scan({ cwd: routesDir })))
1601
- .map((f) => f.replaceAll("\\", "/"))
1602
- .filter((f) => ROUTE_FILE_EXT.test(f));
1603
- }
1604
- catch {
1605
- continue; // routes dir gone/unreadable - not a drift we can assess
1606
- }
1607
- const drift = diffManifestRoutes(manifestFiles, discovered);
1608
- if (!isManifestInSync(drift)) {
1609
- findings.push({ file: rel, missing: drift.missing, extra: drift.extra });
1610
- }
1611
- }
1612
- return findings.sort((a, b) => a.file.localeCompare(b.file));
1613
- }
1614
28
  /**
1615
29
  * Find the project's `tsc` the way module resolution would: `node_modules/typescript/bin/tsc` in
1616
30
  * cwd, then each parent directory up to the filesystem root. A workspace package in a monorepo has
@@ -1681,12 +95,7 @@ async function typecheck(cwd, signal) {
1681
95
  };
1682
96
  }
1683
97
  // `src/x.tsx(12,5): error TS2322: <message>` → one structured diagnostic.
1684
- const TSC_LINE = /^(.+?)\((\d+),\d+\):\s*(?:error|warning)\s+TS\d+:\s*(.+)$/;
1685
98
  const OVERRIDE_SEVERITIES = ["error", "warn", "info", "off"];
1686
- /** Load `nifra.check.json` if present. Fail-open + total: absent → empty; malformed JSON → empty plus a
1687
- * parse `error`; an invalid entry → skipped plus a `warnings` note the caller surfaces as a `warning`
1688
- * diagnostic (never throws, never blocks the gate). Mount entries are normalized to bare path prefixes
1689
- * (`/auth/**` and `/auth/` both become `/auth`). */
1690
99
  async function loadCheckConfig(cwd) {
1691
100
  const path = join(cwd, "nifra.check.json");
1692
101
  const empty = { externalMounts: [], rules: {} };
@@ -1748,138 +157,6 @@ async function loadCheckConfig(cwd) {
1748
157
  };
1749
158
  }
1750
159
  }
1751
- const UNTYPED_CLIENT_HINT = 'client("…") without a type argument - write client<typeof app>("…") (or client(contract, url)) so the compiler can catch drift';
1752
- const FETCH_HINT = "hand-rolled fetch() to your own API - call it through client<typeof app> (from @nifrajs/client) so the compiler catches drift";
1753
- const SERVER_IMPORT_HINT = "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";
1754
- const RESPONSE_ROUTE_HINT = "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";
1755
- const PIPELINE_DOC_HINT = "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.";
1756
- const UNDECLARED_DEP_HINT = "imported package is not declared in package.json dependencies - run bun add to declare it";
1757
- const SQL_COMPILER_MISSING_HINT = "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`";
1758
- const SQL_INTERPOLATION_EXAMPLE = "$" + "{value}";
1759
- 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`;
1760
- const MANIFEST_DRIFT_HINT = "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";
1761
- const TRUST_MANIFEST_DRIFT_HINT = "nifra.manifest.json is missing, invalid, or out of sync - run `nifra manifest emit`, review it, and commit the regenerated trust artifact";
1762
- const CAPABILITY_HINT = "effect/capability assurance failed - align the route declaration with approved adapter provenance; never bypass an owned effect seam";
1763
- function oneLineDiff(file, line, before, after) {
1764
- return `--- ${file}:${line}\n+++ ${file}:${line}\n@@\n-${before}\n+${after}`;
1765
- }
1766
- function untypedClientSuggestion(f) {
1767
- const replacement = f.snippet.replace(/(?<![.\w])client\s*\(/, "client<typeof app>(");
1768
- return replacement === f.snippet
1769
- ? {
1770
- kind: "manual",
1771
- title: "Add the app type argument to the client factory",
1772
- steps: [
1773
- 'Change `client("...")` to `client<typeof app>("...")`.',
1774
- "Make sure the backend app type is imported or otherwise in scope.",
1775
- ],
1776
- }
1777
- : {
1778
- kind: "edit",
1779
- title: "Insert `<typeof app>` into the client factory call",
1780
- diff: oneLineDiff(f.file, f.line, f.snippet, replacement),
1781
- steps: ["Make sure the backend app type is imported or otherwise in scope."],
1782
- };
1783
- }
1784
- function routeKey(method, path) {
1785
- return `${method.toUpperCase()} ${path}`;
1786
- }
1787
- function typedClientCall(method, path) {
1788
- const segs = path.split("/").filter((seg) => seg !== "");
1789
- let chain = "api";
1790
- if (segs.length === 0)
1791
- chain += ".index";
1792
- else {
1793
- for (const seg of segs) {
1794
- chain += IDENT.test(seg) ? `.${seg}` : `[${JSON.stringify(seg)}]`;
1795
- }
1796
- }
1797
- return `${chain}.${method.toLowerCase()}()`;
1798
- }
1799
- function staticRouteMap(routes) {
1800
- const out = new Map();
1801
- for (const route of routes) {
1802
- if (route.path.includes(":") || route.path.includes("*"))
1803
- continue;
1804
- const key = routeKey(route.method, route.path);
1805
- const bucket = out.get(key);
1806
- if (bucket === undefined)
1807
- out.set(key, [route]);
1808
- else
1809
- bucket.push(route);
1810
- }
1811
- return out;
1812
- }
1813
- function ownFetchEditSuggestion(f, routes) {
1814
- const call = parseSimpleFetchCall(f.snippet);
1815
- if (call === undefined || !SIMPLE_REWRITE_METHODS.has(call.method))
1816
- return undefined;
1817
- const matches = routes.get(routeKey(call.method, call.path));
1818
- if (matches === undefined || matches.length !== 1)
1819
- return undefined;
1820
- const replacementCall = typedClientCall(call.method, call.path);
1821
- const replacement = `${f.snippet.slice(0, call.start)}${replacementCall}${f.snippet.slice(call.end)}`;
1822
- if (replacement === f.snippet)
1823
- return undefined;
1824
- const route = matches[0];
1825
- if (route === undefined)
1826
- return undefined;
1827
- return {
1828
- kind: "edit",
1829
- title: "Rewrite simple own-API fetch to the typed nifra client",
1830
- diff: oneLineDiff(f.file, f.line, f.snippet, replacement),
1831
- steps: [
1832
- `Matched ${route.method} ${route.path} at ${route.file}:${route.line}.`,
1833
- "Use an in-scope typed client named `api` (`client<typeof app>(baseUrl)` or the route loader/action `api`).",
1834
- "Update downstream `Response` handling to branch on `{ ok, data, error }` if this variable is used later.",
1835
- ],
1836
- };
1837
- }
1838
- function ownFetchSuggestion(f, routes) {
1839
- const exact = ownFetchEditSuggestion(f, routes);
1840
- if (exact !== undefined)
1841
- return exact;
1842
- return {
1843
- kind: "manual",
1844
- title: "Replace own-API fetch with the typed nifra client",
1845
- steps: [
1846
- "Call `nifra_routes` or read `nifra://routes` for the exact typed-client call form.",
1847
- "Create `const api = client<typeof app>(baseUrl)` from `@nifrajs/client`.",
1848
- "Replace the relative `fetch()` call with the generated `api...get/post/...` call and branch on `{ ok, data, error }`.",
1849
- ],
1850
- };
1851
- }
1852
- function serverImportSuggestion(specifier, chain, fallback) {
1853
- const sink = chain[chain.length - 1] ?? specifier;
1854
- // Surface the resolved chain in the fix steps so the agent sees the full path (`route → ../data →
1855
- // ../db → node:crypto`) and which module to cut - not just the route's own top-level import.
1856
- const chainStep = chain.length > 2
1857
- ? fallback
1858
- ? `Server-only code reaches this route through \`${chain.join(" → ")}\` (the deeper chain couldn't be resolved precisely - trace it from \`${specifier}\`).`
1859
- : `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.`
1860
- : undefined;
1861
- return {
1862
- kind: "manual",
1863
- title: "Move server-only code behind the route server boundary",
1864
- steps: [
1865
- ...(chainStep !== undefined ? [chainStep] : []),
1866
- `Remove the top-level \`import … from "${specifier}"\` from this route module (it's bundled for the browser).`,
1867
- "Access backend/data work through the route `loader`/`action` context (`api`, `env`, or project server context).",
1868
- `If a direct module import is unavoidable, lazy-load it (\`await import("${specifier}")\`) inside the server-only loader/action path.`,
1869
- ],
1870
- };
1871
- }
1872
- function responseRouteSuggestion() {
1873
- return {
1874
- kind: "manual",
1875
- title: "Preserve typed-client response inference",
1876
- steps: [
1877
- "Prefer returning a plain object from JSON routes; nifra serializes it for you.",
1878
- "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`.",
1879
- "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.",
1880
- ],
1881
- };
1882
- }
1883
160
  /** Build the rule input from one source walk and the other check-wide scans. */
1884
161
  async function buildProjectScan(cwd, opts) {
1885
162
  const fetches = [];
@@ -1954,625 +231,10 @@ async function buildProjectScan(cwd, opts) {
1954
231
  }
1955
232
  /** Run the three checks and assemble a structured, machine-readable result. The single source the CLI
1956
233
  * report, `--json`, and the MCP tool all render from. */
234
+ /** Run the scan, freeze its facts, and project them into the public diagnostic result. */
1957
235
  export async function collectCheckResult(cwd, opts = {}) {
1958
236
  const scan = await buildProjectScan(cwd, opts);
1959
- const factsSeed = scan.facts;
1960
- const { fetches, untypedClients, removedImports, responseRoutes, interpolatedSql } = factsSeed.sourceFindings;
1961
- const staticRoutes = factsSeed.routes;
1962
- const serverImports = factsSeed.importGraph;
1963
- const { doctor: dr, manifestDrift } = factsSeed.packages;
1964
- const { checkConfig } = factsSeed.policies;
1965
- const tc = scan.typecheck;
1966
- const sqlCompiler = scan.sqlCompiler;
1967
- const checkConfigError = scan.checkConfigError;
1968
- const checkConfigWarnings = scan.checkConfigWarnings;
1969
- const diagnostics = [];
1970
- const structuredExtras = [];
1971
- if (checkConfigError !== undefined) {
1972
- diagnostics.push({
1973
- rule: "check-config",
1974
- severity: "warning",
1975
- file: "nifra.check.json",
1976
- message: `nifra.check.json could not be parsed (${checkConfigError}) - its external-mount allowlist was ignored`,
1977
- fix: "Fix the JSON syntax in nifra.check.json",
1978
- });
1979
- }
1980
- for (const warning of checkConfigWarnings) {
1981
- diagnostics.push({
1982
- rule: "check-config",
1983
- severity: "warning",
1984
- file: "nifra.check.json",
1985
- message: `nifra.check.json: ${warning}`,
1986
- fix: "Fix the entry in nifra.check.json",
1987
- });
1988
- }
1989
- if (tc.missingTypeScript === true) {
1990
- // A tsconfig with no reachable `typescript` install is a broken gate, not a benign skip: the
1991
- // contract check the project asked for (by having a tsconfig) silently didn't run. Fail closed.
1992
- diagnostics.push({
1993
- rule: "typecheck",
1994
- severity: "error",
1995
- file: "tsconfig.json",
1996
- message: "tsconfig.json is present but no `typescript` install was found from this directory upward - the typecheck gate did NOT run",
1997
- fix: "bun add -d typescript",
1998
- suggestion: {
1999
- kind: "command",
2000
- title: "Install TypeScript so the contract gate can run",
2001
- command: ["bun", "add", "-d", "typescript"],
2002
- },
2003
- });
2004
- }
2005
- if (tc.ran && !tc.ok) {
2006
- const lines = (tc.output ?? "").split("\n");
2007
- let matched = false;
2008
- for (const l of lines) {
2009
- const m = TSC_LINE.exec(l.trim());
2010
- if (m) {
2011
- matched = true;
2012
- diagnostics.push({
2013
- rule: "typecheck",
2014
- severity: "error",
2015
- file: m[1],
2016
- line: Number(m[2]),
2017
- message: m[3],
2018
- suggestion: {
2019
- kind: "manual",
2020
- title: "Fix the TypeScript contract error",
2021
- steps: [
2022
- "Open the reported file and line.",
2023
- "Align the handler, route schema, or typed-client call with the compiler error.",
2024
- "Run `nifra_check` again after the edit.",
2025
- ],
2026
- },
2027
- });
2028
- }
2029
- }
2030
- if (!matched)
2031
- diagnostics.push({
2032
- rule: "typecheck",
2033
- severity: "error",
2034
- message: tc.output || "typecheck failed",
2035
- suggestion: {
2036
- kind: "manual",
2037
- title: "Fix the TypeScript contract error",
2038
- steps: ["Run `tsc --noEmit` locally for the full compiler output."],
2039
- },
2040
- });
2041
- }
2042
- const routes = staticRouteMap(staticRoutes);
2043
- for (const f of [...fetches].sort(bySite)) {
2044
- diagnostics.push({
2045
- rule: "typed-client",
2046
- severity: "error",
2047
- file: f.file,
2048
- line: f.line,
2049
- message: `${f.snippet} - ${FETCH_HINT}`,
2050
- fix: FETCH_HINT,
2051
- suggestion: ownFetchSuggestion(f, routes),
2052
- });
2053
- }
2054
- for (const f of [...removedImports].sort(bySite)) {
2055
- const entry = REMOVED_IMPORTS.find((candidate) => f.snippet.includes(`"${candidate.specifier}`) ||
2056
- f.snippet.includes(`'${candidate.specifier}`));
2057
- diagnostics.push({
2058
- rule: "removed-import",
2059
- severity: "error",
2060
- file: f.file,
2061
- line: f.line,
2062
- message: `${f.snippet} - removed in nifra ${entry?.since ?? "2.0"}: ${entry?.replacement ?? "see the changelog"}`,
2063
- fix: entry?.replacement ?? "see the changelog",
2064
- });
2065
- }
2066
- for (const f of [...untypedClients].sort(bySite)) {
2067
- diagnostics.push({
2068
- rule: "untyped-client",
2069
- severity: "error",
2070
- file: f.file,
2071
- line: f.line,
2072
- message: `${f.snippet} - ${UNTYPED_CLIENT_HINT}`,
2073
- fix: UNTYPED_CLIENT_HINT,
2074
- suggestion: untypedClientSuggestion(f),
2075
- });
2076
- }
2077
- for (const f of [...serverImports].sort(bySite)) {
2078
- // #4.4: the FULL transitive chain the import-resolution walk found - `route → ../data → ../db →
2079
- // node:crypto`, matching the build leak-guard's depth - instead of just the direct edge. The chain's
2080
- // tail is the actual server-only sink; the head is the route. When a precise resolve wasn't possible
2081
- // (a bare pkg / path alias), `fallback` is set and the chain degrades to the honest direct edge.
2082
- const chain = f.chain;
2083
- const sink = chain[chain.length - 1] ?? f.specifier;
2084
- diagnostics.push({
2085
- rule: "server-only-import",
2086
- severity: "error",
2087
- file: f.file,
2088
- line: f.line,
2089
- 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}`,
2090
- fix: SERVER_IMPORT_HINT,
2091
- chain,
2092
- suggestion: serverImportSuggestion(f.specifier, chain, f.fallback),
2093
- });
2094
- }
2095
- if (sqlCompiler === undefined) {
2096
- // A security rule that could not run must say so. Silence here is indistinguishable from a clean
2097
- // result, and this is the one rule whose clean result means "no SQL injection was found".
2098
- diagnostics.push({
2099
- rule: "interpolated-sql",
2100
- severity: "warning",
2101
- message: SQL_COMPILER_MISSING_HINT,
2102
- fix: SQL_COMPILER_MISSING_HINT,
2103
- suggestion: {
2104
- kind: "command",
2105
- title: "Install TypeScript so the SQL rule can run",
2106
- command: ["bun add -d typescript"],
2107
- },
2108
- });
2109
- }
2110
- for (const f of [...interpolatedSql].sort(bySite)) {
2111
- diagnostics.push({
2112
- rule: "interpolated-sql",
2113
- severity: "error",
2114
- file: f.file,
2115
- line: f.line,
2116
- message: `${f.snippet} - ${INTERPOLATED_SQL_HINT}`,
2117
- fix: "bind the value as a parameter instead of interpolating it into the statement",
2118
- suggestion: {
2119
- kind: "manual",
2120
- title: "Bind the value instead of interpolating it",
2121
- steps: [
2122
- "Replace the interpolation with a placeholder your driver understands (`?` for SQLite/MySQL, `$1` for Postgres).",
2123
- "Pass the value as an argument alongside the statement, so the driver binds it.",
2124
- `Or switch to the driver's tagged template (sql\`… ${SQL_INTERPOLATION_EXAMPLE} …\`): the tag receives substitutions separately and binds them.`,
2125
- "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.",
2126
- "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.",
2127
- ],
2128
- },
2129
- });
2130
- }
2131
- // Advisory - surfaced but NOT folded into `ok`, so it never fails the gate (a raw Response is valid).
2132
- for (const f of [...responseRoutes].sort(bySite)) {
2133
- diagnostics.push({
2134
- rule: "response-route",
2135
- severity: "warning",
2136
- file: f.file,
2137
- line: f.line,
2138
- message: `${f.snippet} - ${RESPONSE_ROUTE_HINT}`,
2139
- fix: RESPONSE_ROUTE_HINT,
2140
- suggestion: responseRouteSuggestion(),
2141
- });
2142
- }
2143
- if (dr.ran) {
2144
- for (const f of dr.findings) {
2145
- diagnostics.push({
2146
- rule: "undeclared-dependency",
2147
- severity: "error",
2148
- file: f.file,
2149
- line: f.line,
2150
- message: `imports ${f.package} which is not declared in package.json - ${UNDECLARED_DEP_HINT}`,
2151
- fix: `add ${f.package} to package.json dependencies`,
2152
- suggestion: {
2153
- kind: "command",
2154
- title: `Declare ${f.package} in package.json`,
2155
- command: ["bun", "add", f.package],
2156
- },
2157
- });
2158
- }
2159
- for (const finding of dr.duplicateInstalls) {
2160
- const copies = finding.copies.map((copy) => `${copy.version} at ${copy.path}`).join("; ");
2161
- diagnostics.push({
2162
- rule: "duplicate-install",
2163
- severity: "error",
2164
- message: `${finding.package} identity preflight found ${finding.cause} (${copies}) - ${finding.explanation}`,
2165
- fix: finding.remediation,
2166
- suggestion: {
2167
- kind: "manual",
2168
- title: `Deduplicate ${finding.package}`,
2169
- steps: [
2170
- "Align workspace dependency and peer ranges on one compatible version.",
2171
- "Remove stale nested installs and run the package manager from the workspace root.",
2172
- "Run `nifra doctor` again; do not suppress same-version duplicate paths.",
2173
- ],
2174
- },
2175
- });
2176
- }
2177
- // Advisory (never fails the gate): while actively editing a linked package its dist is always
2178
- // momentarily behind. The finding earns its keep when a dev server is about to start against it -
2179
- // Bun reads live `src` while Vite's SSR runner reads the artifact, so a stale one 500s inside
2180
- // framework/shared-package code and reads exactly like an upstream regression.
2181
- for (const f of dr.staleDists) {
2182
- diagnostics.push({
2183
- rule: "stale-workspace-dist",
2184
- severity: "warning",
2185
- message: f.missing
2186
- ? `${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 ${f.package}`
2187
- : `${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 ${f.package}`,
2188
- fix: `rebuild ${f.package}`,
2189
- evidence: [f.package, f.distFile, f.sourceFile],
2190
- suggestion: {
2191
- kind: "manual",
2192
- title: `Rebuild ${f.package}`,
2193
- steps: [
2194
- `Run the package's build (usually \`bun run build\` in its directory) so ${f.distFile} matches its source again.`,
2195
- "Only workspace-linked installs drift; npm tarballs are immutable and never flagged.",
2196
- ],
2197
- },
2198
- });
2199
- }
2200
- }
2201
- // The two-pipeline rule, read from the config as TEXT (doctor collects it - see ./pipeline-report.ts).
2202
- // `loadApp` already refuses a misplaced plugin, but only when something loads the app; a check that
2203
- // never executes project code, and CI that never starts a dev server, would otherwise meet these for
2204
- // the first time as a production server that built cleanly and died at startup.
2205
- for (const f of dr.pipeline?.findings ?? []) {
2206
- diagnostics.push({
2207
- rule: "pipeline",
2208
- severity: f.severity,
2209
- file: f.file,
2210
- ...(f.line !== undefined ? { line: f.line } : {}),
2211
- message: f.message,
2212
- fix: f.fix,
2213
- suggestion: { kind: "manual", title: f.fix, steps: [PIPELINE_DOC_HINT] },
2214
- });
2215
- }
2216
- // #7: a committed server-manifest.ts that drifted from routes/ - name the exact missing/extra routes.
2217
- for (const f of manifestDrift) {
2218
- const parts = [];
2219
- if (f.missing.length > 0)
2220
- parts.push(`missing from manifest: ${f.missing.join(", ")}`);
2221
- if (f.extra.length > 0)
2222
- parts.push(`stale in manifest: ${f.extra.join(", ")}`);
2223
- diagnostics.push({
2224
- rule: "server-manifest-drift",
2225
- severity: "error",
2226
- file: f.file,
2227
- message: `${f.file} drifted from routes/ (${parts.join("; ")}) - ${MANIFEST_DRIFT_HINT}`,
2228
- fix: MANIFEST_DRIFT_HINT,
2229
- evidence: [...f.missing, ...f.extra],
2230
- suggestion: {
2231
- kind: "manual",
2232
- title: "Regenerate the committed server manifest",
2233
- steps: [
2234
- "Re-run your build (`nifra build --target <t>` or your build script) - it regenerates server-manifest.ts from the current routes/.",
2235
- "Commit the updated server-manifest.ts.",
2236
- ],
2237
- },
2238
- });
2239
- }
2240
- // G+B+D+F: when the project opts into capability assurance, `nifra check` becomes the static
2241
- // provenance firewall as well as the typed-contract gate. Loading is explicit/config-owned; projects
2242
- // without nifra.assurance.ts retain the historical scan and hot path unchanged.
2243
- const provided = opts.assurance;
2244
- let applicationRulePacks = [];
2245
- let loadedPolicy = provided;
2246
- let loadedCapability = provided?.capability;
2247
- const assuranceConfigPath = join(cwd, "nifra.assurance.ts");
2248
- if (provided !== undefined ? provided.present : existsSync(assuranceConfigPath)) {
2249
- try {
2250
- // Either the shared reflection from `collectProjectVerification` (no second pass) or, on the
2251
- // standalone path, loaded + reflected here. Both branches yield the same config + evidence.
2252
- let config;
2253
- let project;
2254
- let routeAssurance;
2255
- let evidence;
2256
- if (provided !== undefined) {
2257
- // A load/evaluate failure travels as `provided.error`; re-throwing lands it in the same
2258
- // capability-config diagnostic the standalone catch produces.
2259
- if (provided.error !== undefined)
2260
- throw provided.error;
2261
- config = provided.config;
2262
- project = provided.capability;
2263
- routeAssurance = provided.routeAssurance;
2264
- evidence = provided.evidence;
2265
- }
2266
- else {
2267
- const { loadAssuranceConfig } = await import("./assure.js");
2268
- config = await loadAssuranceConfig(cwd);
2269
- if (config.capabilities !== undefined) {
2270
- const { collectCapabilityProjectReport } = await import("./capabilities-tool.js");
2271
- project = await collectCapabilityProjectReport(cwd, config.source, config.capabilities);
2272
- }
2273
- loadedPolicy = {
2274
- present: true,
2275
- config,
2276
- ...(routeAssurance === undefined ? {} : { routeAssurance }),
2277
- ...(project === undefined ? {} : { capability: project }),
2278
- ...(evidence === undefined ? {} : { evidence }),
2279
- };
2280
- loadedCapability = project;
2281
- }
2282
- applicationRulePacks = parseRulePacks(config.rulePacks);
2283
- const capabilityReport = project?.report;
2284
- if (config.capabilities !== undefined && project !== undefined) {
2285
- for (const finding of project.report.findings) {
2286
- const violation = finding.code === "forbidden-effect-import"
2287
- ? project.violations.find((candidate) => candidate.method === finding.method && candidate.path === finding.path)
2288
- : undefined;
2289
- const truncation = finding.code === "provenance-truncated"
2290
- ? project.truncations.find((candidate) => candidate.method === finding.method && candidate.path === finding.path)
2291
- : undefined;
2292
- // An unmatched seam is a policy defect, not a route defect: the fix is in the policy file,
2293
- // so it gets its own steps instead of the route-side provenance guidance.
2294
- const seamFix = finding.code === "unmatched-provenance-seam"
2295
- ? "Write the seam exactly as the code imports it, or delete the rule."
2296
- : undefined;
2297
- diagnostics.push({
2298
- rule: "capability-assurance",
2299
- severity: "error",
2300
- ...(violation !== undefined
2301
- ? { file: violation.module, chain: violation.chain }
2302
- : truncation !== undefined
2303
- ? { chain: truncation.chain }
2304
- : {}),
2305
- message: `${finding.message}${seamFix === undefined ? ` - ${CAPABILITY_HINT}` : ""}`,
2306
- fix: seamFix ?? CAPABILITY_HINT,
2307
- suggestion: seamFix === undefined
2308
- ? {
2309
- kind: "manual",
2310
- title: "Restore declared effect provenance",
2311
- steps: [
2312
- "Route effectful work through an import listed in capabilities.provenance.imports.",
2313
- "Declare the exact capability token on the route; do not widen unrelated routes in the same file.",
2314
- "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.",
2315
- "Run `nifra capabilities snapshot` only after assurance passes, then review the lockfile diff.",
2316
- ],
2317
- }
2318
- : {
2319
- kind: "manual",
2320
- title: "Point the provenance rule at a module that exists",
2321
- steps: [
2322
- "Copy the specifier from the import statement itself - it is matched as written, with no extension or index resolution.",
2323
- "Use a trailing `/*` when the seam is a directory of modules (`@myorg/db/*`).",
2324
- "For a routeModules entry, give the project-relative path of the file that implements the route.",
2325
- "Delete the rule if the seam it governed is gone; leaving it in place proves nothing.",
2326
- ],
2327
- },
2328
- });
2329
- }
2330
- }
2331
- if (config.manifest !== undefined) {
2332
- const { buildNifraManifest, parseNifraManifest, serializeNifraManifest } = await import("@nifrajs/core/manifest");
2333
- const assurance = routeAssurance ??
2334
- (await import("@nifrajs/core/assurance")).evaluateRouteAssurance(config.source, config.policy, {
2335
- ...(config.capabilities !== undefined
2336
- ? { definitions: config.capabilities.definitions }
2337
- : {}),
2338
- });
2339
- evidence ??= snapshotProjectEvidence(config.source, {
2340
- assurance,
2341
- ...(capabilityReport !== undefined ? { capabilities: capabilityReport } : {}),
2342
- });
2343
- const path = resolve(cwd, config.manifest.path ?? "nifra.manifest.json");
2344
- let message;
2345
- if (!assurance.ok || (capabilityReport !== undefined && !capabilityReport.ok)) {
2346
- message =
2347
- "the configured assurance policy is failing, so a trusted manifest cannot be built";
2348
- }
2349
- else if (!existsSync(path)) {
2350
- message = "the configured trust manifest is missing";
2351
- }
2352
- else {
2353
- try {
2354
- const current = await buildNifraManifest({
2355
- evidence,
2356
- assurance,
2357
- ...(capabilityReport !== undefined ? { capabilities: capabilityReport } : {}),
2358
- });
2359
- const storedText = await Bun.file(path).text();
2360
- const stored = await parseNifraManifest(storedText, path);
2361
- const expectedText = `${serializeNifraManifest(current)}\n`;
2362
- if (storedText !== expectedText || stored.contentHash !== current.contentHash) {
2363
- message = "the configured trust manifest does not match live route reflection";
2364
- }
2365
- }
2366
- catch (error) {
2367
- message = `the configured trust manifest is invalid: ${error instanceof Error ? error.message : String(error)}`;
2368
- }
2369
- }
2370
- if (message !== undefined) {
2371
- diagnostics.push({
2372
- rule: "manifest-drift",
2373
- severity: "error",
2374
- file: path,
2375
- message: `${message} - ${TRUST_MANIFEST_DRIFT_HINT}`,
2376
- fix: TRUST_MANIFEST_DRIFT_HINT,
2377
- suggestion: {
2378
- kind: "command",
2379
- title: "Regenerate the signed-manifest input artifact",
2380
- command: ["nifra", "manifest", "emit"],
2381
- steps: [
2382
- "Review the route, assurance, capability, and classification delta before committing it.",
2383
- ],
2384
- },
2385
- });
2386
- }
2387
- }
2388
- }
2389
- catch (err) {
2390
- loadedPolicy = { present: true, error: err };
2391
- diagnostics.push({
2392
- rule: "capability-config",
2393
- severity: "error",
2394
- file: "nifra.assurance.ts",
2395
- message: `capability assurance config could not be evaluated: ${err instanceof Error ? err.message : String(err)}`,
2396
- suggestion: {
2397
- kind: "manual",
2398
- title: "Repair the assurance config",
2399
- steps: [
2400
- "Ensure nifra.assurance.ts default-exports defineAssuranceConfig({ source, policy, capabilities }).",
2401
- "Fix configuration/import errors; the provenance firewall fails closed when its policy cannot load.",
2402
- ],
2403
- },
2404
- });
2405
- }
2406
- }
2407
- try {
2408
- const { checkContractsLock } = await import("./contracts.js");
2409
- if (!existsSync(join(cwd, "backend.ts")) && !existsSync(join(cwd, "contracts.lock.json"))) {
2410
- throw new Error("contract source not configured");
2411
- }
2412
- const contract = await checkContractsLock(cwd);
2413
- if (!contract.present) {
2414
- structuredExtras.push(diagnostic({
2415
- code: "NF-K001",
2416
- severity: "info",
2417
- message: "no contract lock; run `nifra contracts snapshot` to enable drift detection",
2418
- verify: "nifra contracts snapshot",
2419
- }));
2420
- }
2421
- else {
2422
- if (contract.vacuous) {
2423
- // Every route hashes to the empty-schema digest, so the lock exists but the drift rule guards
2424
- // nothing: it can only ever compare "no schema" to "no schema". Say so, or a green contract check
2425
- // reads as proof of a stable contract when there is no contract to be stable.
2426
- structuredExtras.push(diagnostic({
2427
- code: "NF-K001",
2428
- severity: "info",
2429
- message: "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.",
2430
- verify: "nifra check --lints-only",
2431
- }));
2432
- }
2433
- for (const finding of contract.diagnostics) {
2434
- diagnostics.push({
2435
- rule: "contract-drift",
2436
- severity: "error",
2437
- code: "NF-K001",
2438
- ...(finding.route !== undefined ? { evidence: [finding.route] } : {}),
2439
- message: finding.message,
2440
- fix: "run `nifra contracts snapshot` after reviewing the contract change",
2441
- verify: "nifra check --lints-only",
2442
- });
2443
- }
2444
- }
2445
- }
2446
- catch (error) {
2447
- if (error instanceof Error && error.message === "contract source not configured") {
2448
- // API-only source scans have no contract surface to snapshot.
2449
- }
2450
- else {
2451
- diagnostics.push({
2452
- rule: "contract-drift",
2453
- severity: "error",
2454
- code: "NF-K001",
2455
- message: `contract lock could not be checked: ${error instanceof Error ? error.message : String(error)}`,
2456
- verify: "nifra contracts snapshot",
2457
- });
2458
- }
2459
- }
2460
- const projectFacts = freezeProjectFacts({
2461
- ...factsSeed,
2462
- policies: {
2463
- ...factsSeed.policies,
2464
- ...(loadedPolicy === undefined ? {} : { assurance: loadedPolicy }),
2465
- ...(loadedCapability === undefined ? {} : { capability: loadedCapability }),
2466
- rulePacks: applicationRulePacks,
2467
- },
2468
- }, diagnostics);
2469
- const ruleContext = {
2470
- root: cwd,
2471
- sources: projectFacts.source,
2472
- project: projectFacts,
2473
- };
2474
- const structuredDiagnostics = [...structuredExtras];
2475
- let registryDiagnostics = [];
2476
- try {
2477
- registryDiagnostics = await runRuleRegistry(ruleContext, [...legacyRules, ...securityRules, ...routeRules], projectFacts.policies.rulePacks);
2478
- structuredDiagnostics.push(...registryDiagnostics);
2479
- }
2480
- catch (error) {
2481
- registryDiagnostics = [
2482
- diagnostic({
2483
- code: "NF-C017",
2484
- severity: "error",
2485
- message: `rule registry failed closed: ${error instanceof Error ? error.message : String(error)}`,
2486
- fix: { recipe: "rule-pack.repair", command: "nifra check --lints-only" },
2487
- verify: "nifra check --lints-only",
2488
- }),
2489
- ];
2490
- structuredDiagnostics.push(...registryDiagnostics);
2491
- }
2492
- for (const item of registryDiagnostics) {
2493
- // The legacy adapters publish stable structured findings; the human compatibility view already
2494
- // contains their original rule names. Security rules and application packs still need a legacy-view
2495
- // row because they have no pre-registry diagnostic.
2496
- if (legacyRules.some((rule) => rule.code === item.code))
2497
- continue;
2498
- diagnostics.push({
2499
- rule: item.code,
2500
- severity: item.severity === "error" ? "error" : "warning",
2501
- code: item.code,
2502
- ...(item.file !== undefined ? { file: item.file } : {}),
2503
- ...(item.line !== undefined ? { line: item.line } : {}),
2504
- message: item.message,
2505
- ...(item.evidence !== undefined ? { evidence: item.evidence } : {}),
2506
- ...(item.verify !== undefined ? { verify: item.verify } : {}),
2507
- });
2508
- }
2509
- // Per-rule overrides from `nifra.check.json` `rules`, applied centrally to BOTH diagnostic views
2510
- // before `ok` is computed - never inside individual rules, so no rule can dodge (or double-apply)
2511
- // them. A key matches the legacy rule name or the stable NF- code.
2512
- const overrideFor = (...keys) => {
2513
- for (const key of keys) {
2514
- if (key !== undefined && checkConfig.rules[key] !== undefined)
2515
- return checkConfig.rules[key];
2516
- }
2517
- return undefined;
2518
- };
2519
- const ignoreGlobs = new Map();
2520
- const dropped = (override, file) => {
2521
- if (override.severity === "off")
2522
- return true;
2523
- if (override.ignore === undefined || file === undefined)
2524
- return false;
2525
- let globs = ignoreGlobs.get(override);
2526
- if (globs === undefined) {
2527
- globs = override.ignore.map((pattern) => new Glob(pattern));
2528
- ignoreGlobs.set(override, globs);
2529
- }
2530
- return globs.some((glob) => glob.match(file));
2531
- };
2532
- const codeToLegacy = new Map(Object.entries(LEGACY_RULE_CODES).map(([name, code]) => [code, name]));
2533
- const finalDiagnostics = diagnostics.flatMap((d) => {
2534
- const override = overrideFor(d.rule, d.code, LEGACY_RULE_CODES[d.rule]);
2535
- if (override === undefined)
2536
- return [d];
2537
- if (dropped(override, d.file))
2538
- return [];
2539
- // "off" is fully handled by `dropped` above - only real severities reach the retag.
2540
- if (override.severity === undefined || override.severity === "off")
2541
- return [d];
2542
- return [{ ...d, severity: override.severity === "warn" ? "warning" : override.severity }];
2543
- });
2544
- const finalStructured = structuredDiagnostics.flatMap((d) => {
2545
- const override = overrideFor(d.code, codeToLegacy.get(d.code));
2546
- if (override === undefined)
2547
- return [d];
2548
- if (dropped(override, d.file))
2549
- return [];
2550
- if (override.severity === undefined || override.severity === "off")
2551
- return [d];
2552
- return [Object.freeze({ ...d, severity: override.severity })];
2553
- });
2554
- // Cap the diagnostics when asked (the MCP path), so a project with thousands of findings can't return a
2555
- // message that breaks the stdio transport. `ok` reflects the FULL set - truncation never flips it.
2556
- const total = finalDiagnostics.length;
2557
- const max = opts.maxDiagnostics;
2558
- const shown = max !== undefined && total > max ? finalDiagnostics.slice(0, max) : finalDiagnostics;
2559
- const result = {
2560
- ok: !finalDiagnostics.some((diagnostic) => diagnostic.severity === "error"),
2561
- typecheck: tc.ran ? (tc.ok ? "pass" : "fail") : "skipped",
2562
- ...(!tc.ran && tc.note !== undefined ? { typecheckNote: tc.note } : {}),
2563
- diagnostics: shown,
2564
- ...(dr.pipeline !== undefined ? { pipeline: dr.pipeline } : {}),
2565
- ...(checkConfig.externalMounts.length > 0
2566
- ? { externalMounts: checkConfig.externalMounts }
2567
- : {}),
2568
- ...(Object.keys(checkConfig.rules).length > 0 ? { ruleOverrides: checkConfig.rules } : {}),
2569
- ...(shown.length < total ? { truncated: { shown: shown.length, total } } : {}),
2570
- };
2571
- Object.defineProperty(result, "structuredDiagnostics", {
2572
- value: finalStructured.slice(0, shown.length + structuredExtras.length),
2573
- enumerable: false,
2574
- });
2575
- return result;
237
+ return collectCheckDiagnostics(cwd, scan, opts);
2576
238
  }
2577
239
  /** The named rule sections of the human report, in print order. A rule absent from this list is NOT
2578
240
  * dropped: {@link renderCheckReport} renders every remaining diagnostic in a generic section keyed by