@nifrajs/cli 3.1.0 → 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 (158) hide show
  1. package/README.md +10 -0
  2. package/dist/assure-hydration.d.ts.map +1 -1
  3. package/dist/assure-hydration.js +6 -1
  4. package/dist/assure-hydration.js.map +1 -1
  5. package/dist/assure.d.ts.map +1 -1
  6. package/dist/assure.js +3 -2
  7. package/dist/assure.js.map +1 -1
  8. package/dist/check-diagnostics.d.ts +165 -0
  9. package/dist/check-diagnostics.d.ts.map +1 -0
  10. package/dist/check-diagnostics.js +1009 -0
  11. package/dist/check-diagnostics.js.map +1 -0
  12. package/dist/check-scan.d.ts +245 -0
  13. package/dist/check-scan.d.ts.map +1 -0
  14. package/dist/check-scan.js +1599 -0
  15. package/dist/check-scan.js.map +1 -0
  16. package/dist/check.d.ts +4 -324
  17. package/dist/check.d.ts.map +1 -1
  18. package/dist/check.js +8 -2366
  19. package/dist/check.js.map +1 -1
  20. package/dist/cli.d.ts.map +1 -1
  21. package/dist/cli.js +247 -10
  22. package/dist/cli.js.map +1 -1
  23. package/dist/command-catalog.d.ts +21 -1
  24. package/dist/command-catalog.d.ts.map +1 -1
  25. package/dist/command-catalog.js +122 -0
  26. package/dist/command-catalog.js.map +1 -1
  27. package/dist/env-file.js +5 -5
  28. package/dist/env-file.js.map +1 -1
  29. package/dist/frontend-guidance.d.ts +57 -0
  30. package/dist/frontend-guidance.d.ts.map +1 -0
  31. package/dist/frontend-guidance.js +195 -0
  32. package/dist/frontend-guidance.js.map +1 -0
  33. package/dist/levels-tool.d.ts.map +1 -1
  34. package/dist/levels-tool.js +6 -0
  35. package/dist/levels-tool.js.map +1 -1
  36. package/dist/load.d.ts.map +1 -1
  37. package/dist/load.js +4 -1
  38. package/dist/load.js.map +1 -1
  39. package/dist/mcp-context.d.ts +48 -0
  40. package/dist/mcp-context.d.ts.map +1 -0
  41. package/dist/mcp-context.js +215 -0
  42. package/dist/mcp-context.js.map +1 -0
  43. package/dist/mcp-docs-tools.d.ts.map +1 -1
  44. package/dist/mcp-docs-tools.js +31 -0
  45. package/dist/mcp-docs-tools.js.map +1 -1
  46. package/dist/mcp-exec.d.ts +74 -0
  47. package/dist/mcp-exec.d.ts.map +1 -0
  48. package/dist/mcp-exec.js +844 -0
  49. package/dist/mcp-exec.js.map +1 -0
  50. package/dist/mcp-http.js +1 -1
  51. package/dist/mcp-io.d.ts +26 -0
  52. package/dist/mcp-io.d.ts.map +1 -0
  53. package/dist/mcp-io.js +100 -0
  54. package/dist/mcp-io.js.map +1 -0
  55. package/dist/mcp-reflect.d.ts +16 -0
  56. package/dist/mcp-reflect.d.ts.map +1 -0
  57. package/dist/mcp-reflect.js +101 -0
  58. package/dist/mcp-reflect.js.map +1 -0
  59. package/dist/mcp-root.d.ts +7 -0
  60. package/dist/mcp-root.d.ts.map +1 -1
  61. package/dist/mcp-root.js +51 -3
  62. package/dist/mcp-root.js.map +1 -1
  63. package/dist/mcp.d.ts +10 -92
  64. package/dist/mcp.d.ts.map +1 -1
  65. package/dist/mcp.js +13 -1232
  66. package/dist/mcp.js.map +1 -1
  67. package/dist/openapi-tool.d.ts +5 -10
  68. package/dist/openapi-tool.d.ts.map +1 -1
  69. package/dist/openapi-tool.js +21 -2
  70. package/dist/openapi-tool.js.map +1 -1
  71. package/dist/openapi-types.d.ts +21 -0
  72. package/dist/openapi-types.d.ts.map +1 -0
  73. package/dist/openapi-types.js +214 -0
  74. package/dist/openapi-types.js.map +1 -0
  75. package/dist/port.d.ts.map +1 -1
  76. package/dist/port.js +2 -1
  77. package/dist/port.js.map +1 -1
  78. package/dist/project-facts.d.ts +2 -1
  79. package/dist/project-facts.d.ts.map +1 -1
  80. package/dist/project-facts.js.map +1 -1
  81. package/dist/release-verification.d.ts +4 -1
  82. package/dist/release-verification.d.ts.map +1 -1
  83. package/dist/release-verification.js +6 -138
  84. package/dist/release-verification.js.map +1 -1
  85. package/dist/rules/codes.d.ts +4 -0
  86. package/dist/rules/codes.d.ts.map +1 -1
  87. package/dist/rules/codes.js +4 -0
  88. package/dist/rules/codes.js.map +1 -1
  89. package/dist/rules/islands.d.ts +4 -0
  90. package/dist/rules/islands.d.ts.map +1 -0
  91. package/dist/rules/islands.js +155 -0
  92. package/dist/rules/islands.js.map +1 -0
  93. package/dist/rules/nano.d.ts +6 -0
  94. package/dist/rules/nano.d.ts.map +1 -0
  95. package/dist/rules/nano.js +264 -0
  96. package/dist/rules/nano.js.map +1 -0
  97. package/dist/rules/security.d.ts.map +1 -1
  98. package/dist/rules/security.js +38 -4
  99. package/dist/rules/security.js.map +1 -1
  100. package/dist/scaffold.d.ts +12 -6
  101. package/dist/scaffold.d.ts.map +1 -1
  102. package/dist/scaffold.js +149 -10
  103. package/dist/scaffold.js.map +1 -1
  104. package/dist/search-terms.d.ts.map +1 -1
  105. package/dist/search-terms.js +5 -3
  106. package/dist/search-terms.js.map +1 -1
  107. package/dist/stylex-migrate.d.ts +38 -0
  108. package/dist/stylex-migrate.d.ts.map +1 -0
  109. package/dist/stylex-migrate.js +1052 -0
  110. package/dist/stylex-migrate.js.map +1 -0
  111. package/dist/sync-manifest.d.ts.map +1 -1
  112. package/dist/sync-manifest.js +2 -1
  113. package/dist/sync-manifest.js.map +1 -1
  114. package/dist/verification-plan.d.ts +24 -0
  115. package/dist/verification-plan.d.ts.map +1 -0
  116. package/dist/verification-plan.js +116 -0
  117. package/dist/verification-plan.js.map +1 -0
  118. package/dist/work-graph.d.ts.map +1 -1
  119. package/dist/work-graph.js +6 -3
  120. package/dist/work-graph.js.map +1 -1
  121. package/docs/examples.json +36 -0
  122. package/docs/llms-full.txt +1226 -14
  123. package/docs/types.json +4185 -87
  124. package/package.json +9 -9
  125. package/src/assure-hydration.ts +6 -1
  126. package/src/assure.ts +4 -2
  127. package/src/check-diagnostics.ts +1219 -0
  128. package/src/check-scan.ts +1825 -0
  129. package/src/check.ts +47 -2752
  130. package/src/cli.ts +252 -10
  131. package/src/command-catalog.ts +155 -0
  132. package/src/env-file.ts +5 -5
  133. package/src/frontend-guidance.ts +259 -0
  134. package/src/levels-tool.ts +6 -0
  135. package/src/load.ts +4 -1
  136. package/src/mcp-context.ts +266 -0
  137. package/src/mcp-docs-tools.ts +37 -0
  138. package/src/mcp-exec.ts +1040 -0
  139. package/src/mcp-http.ts +1 -1
  140. package/src/mcp-io.ts +118 -0
  141. package/src/mcp-reflect.ts +140 -0
  142. package/src/mcp-root.ts +62 -4
  143. package/src/mcp.ts +61 -1511
  144. package/src/openapi-tool.ts +32 -2
  145. package/src/openapi-types.ts +261 -0
  146. package/src/port.ts +2 -1
  147. package/src/project-facts.ts +2 -4
  148. package/src/release-verification.ts +22 -164
  149. package/src/rules/codes.ts +4 -0
  150. package/src/rules/islands.ts +171 -0
  151. package/src/rules/nano.ts +295 -0
  152. package/src/rules/security.ts +40 -4
  153. package/src/scaffold.ts +169 -9
  154. package/src/search-terms.ts +7 -3
  155. package/src/stylex-migrate.ts +1139 -0
  156. package/src/sync-manifest.ts +2 -1
  157. package/src/verification-plan.ts +242 -0
  158. package/src/work-graph.ts +6 -3
@@ -0,0 +1,1599 @@
1
+ /**
2
+ * Pure source scanners for the nifra verification pipeline.
3
+ *
4
+ * This module deliberately has no CLI, policy, or diagnostic-view dependencies. Its interface is
5
+ * source text plus injected module/type resolvers, which keeps the scanner seam independently testable.
6
+ */
7
+ import { readFileSync, realpathSync } from "node:fs";
8
+ import { dirname, isAbsolute, join, resolve, sep } from "node:path";
9
+ import { Glob } from "bun";
10
+ import { importProjectTypeScript } from "./internal/typescript-import.js";
11
+ import { commentBlockMarkerReason } from "./rules/comment-markers.js";
12
+ // `fetch( <ws> ('|"|`) / (not /)` - a string/template arg starting with a single `/` is a relative URL,
13
+ // i.e. same-origin = this app's own API. `(?<![.\w])` skips `.fetch(` (a method) and `prefetch(`; the
14
+ // `(?!\/)` skips protocol-relative `//host` (an external origin). A variable arg (`fetch(url)`) is left
15
+ // alone on purpose - undecidable from source, and flagging it would punish legitimate external calls.
16
+ const GLOBAL_FETCH_CALL = /(?<![.\w])fetch\s*\(/g;
17
+ const FETCH_CALL = /(?<![.\w])fetch\s*\(/g;
18
+ const HTTP_VERBS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]);
19
+ export const SIMPLE_REWRITE_METHODS = new Set(["GET", "DELETE", "HEAD", "OPTIONS"]);
20
+ // Don't scan deps, build output, or generated client entries. `dist(-<runtime>)?` also covers
21
+ // per-runtime output dirs (dist-bun/dist-node/dist-deno/dist-vercel). Never source, for any scan.
22
+ const IGNORED_DIR = /(^|\/)(node_modules|dist(-[a-z0-9]+)?|build|\.nifra|\.git|\.wrangler|coverage)\//;
23
+ // A test/spec module. Excluded from `nifra check`'s scans, which are about what SHIPS - a test
24
+ // legitimately drives `fetch`, hand-rolls a client, and calls a route directly. It is NOT excluded from
25
+ // `nifra doctor`: tsc typechecks tests, so an import a test declares nowhere is a real broken build.
26
+ const TEST_FILE = /\.(test|spec)\.[cm]?[jt]sx?$/;
27
+ const IGNORED = new RegExp(`${IGNORED_DIR.source}|${TEST_FILE.source}`);
28
+ /** Paths crossing the scanner boundary are project-relative identifiers, not filesystem paths. Keep
29
+ * them stable across runtimes so rules, diagnostics, ignore globs, and import chains have one shape. */
30
+ const normalizeProjectPath = (path) => path.replaceAll("\\", "/");
31
+ // A file under `routes/` - a page module bundled for the browser, where a server-only import is unsafe.
32
+ const ROUTE_FILE = /(^|\/)routes\//;
33
+ // Module specifiers that must never be VALUE-imported into a route module: node:/bun: builtins, common
34
+ // DB drivers/ORМ server entrypoints, and the conventional `./db` module the scaffold generates.
35
+ 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?)?$/;
36
+ // A static, non-type import with a string specifier. `import type …` is erased at build, so it's safe
37
+ // and skipped. Dynamic `import(…)` (the correct way to lazy-load server code in a loader) has `(` right
38
+ // after `import`, so `import\s+` never matches it.
39
+ const STATIC_IMPORT = /\bimport\s+(?!type\b)(?:[^'"();]*?\bfrom\s+)?['"]([^'"]+)['"]/g;
40
+ const ROUTE_REGISTRATION_DQ = /\.([A-Za-z]+)\s*\(\s*"((?:\\.|[^"\\])*)"/g;
41
+ const ROUTE_REGISTRATION_SQ = /\.([A-Za-z]+)\s*\(\s*'((?:\\.|[^'\\])*)'/g;
42
+ export const IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
43
+ /** Line number (1-based) of a match index within `content`. */
44
+ const lineAt = (content, index) => content.slice(0, index).split("\n").length;
45
+ function parseQuotedLiteral(raw) {
46
+ try {
47
+ return JSON.parse(raw);
48
+ }
49
+ catch {
50
+ if (!raw.startsWith("'") || !raw.endsWith("'"))
51
+ return undefined;
52
+ try {
53
+ // Convert the single-quoted body to a JSON string body by scan, handling backslash pairs
54
+ // atomically: `\'` becomes `'`, other escapes pass through untouched, and a bare `"` is
55
+ // escaped. A quote-only `replace` mis-handles content with backslashes (double-escaping
56
+ // an already-escaped quote, or letting a trailing backslash swallow the added escape).
57
+ const inner = raw.slice(1, -1);
58
+ let body = "";
59
+ for (let i = 0; i < inner.length; i++) {
60
+ const ch = inner[i];
61
+ if (ch === "\\" && i + 1 < inner.length) {
62
+ const next = inner[i + 1];
63
+ body += next === "'" ? "'" : ch + next;
64
+ i++;
65
+ }
66
+ else if (ch === '"') {
67
+ body += '\\"';
68
+ }
69
+ else {
70
+ body += ch;
71
+ }
72
+ }
73
+ return JSON.parse(`"${body}"`);
74
+ }
75
+ catch {
76
+ return undefined;
77
+ }
78
+ }
79
+ }
80
+ function findMatchingParen(src, openIndex) {
81
+ let depth = 0;
82
+ let quote;
83
+ for (let i = openIndex; i < src.length; i++) {
84
+ const c = src[i];
85
+ if (quote !== undefined) {
86
+ if (c === "\\") {
87
+ i++;
88
+ continue;
89
+ }
90
+ if (c === quote)
91
+ quote = undefined;
92
+ continue;
93
+ }
94
+ if (c === '"' || c === "'" || c === "`") {
95
+ quote = c;
96
+ continue;
97
+ }
98
+ if (c === "(")
99
+ depth++;
100
+ else if (c === ")") {
101
+ depth--;
102
+ if (depth === 0)
103
+ return i;
104
+ }
105
+ }
106
+ return undefined;
107
+ }
108
+ function splitTopLevelArgs(src) {
109
+ const args = [];
110
+ let start = 0;
111
+ let depth = 0;
112
+ let quote;
113
+ for (let i = 0; i < src.length; i++) {
114
+ const c = src[i];
115
+ if (quote !== undefined) {
116
+ if (c === "\\") {
117
+ i++;
118
+ continue;
119
+ }
120
+ if (c === quote)
121
+ quote = undefined;
122
+ continue;
123
+ }
124
+ if (c === '"' || c === "'" || c === "`") {
125
+ quote = c;
126
+ continue;
127
+ }
128
+ if (c === "(" || c === "{" || c === "[")
129
+ depth++;
130
+ else if (c === ")" || c === "}" || c === "]")
131
+ depth--;
132
+ else if (c === "," && depth === 0) {
133
+ args.push(src.slice(start, i).trim());
134
+ start = i + 1;
135
+ }
136
+ }
137
+ if (quote !== undefined || depth !== 0)
138
+ return undefined;
139
+ const tail = src.slice(start).trim();
140
+ if (tail !== "")
141
+ args.push(tail);
142
+ return args;
143
+ }
144
+ function parseMethodOnlyInit(raw) {
145
+ if (raw === undefined)
146
+ return "GET";
147
+ const init = raw.trim();
148
+ if (!/^\{[\s\S]*\}$/.test(init))
149
+ return undefined;
150
+ const body = init.slice(1, -1).trim();
151
+ if (body === "")
152
+ return "GET";
153
+ const m = /^(?:"method"|'method'|method)\s*:\s*(["'])([A-Za-z]+)\1\s*,?$/.exec(body);
154
+ if (!m)
155
+ return undefined;
156
+ const method = (m[2] ?? "").toUpperCase();
157
+ return HTTP_VERBS.has(method) ? method : undefined;
158
+ }
159
+ export function parseSimpleFetchCall(snippet) {
160
+ FETCH_CALL.lastIndex = 0;
161
+ const matches = [...snippet.matchAll(FETCH_CALL)];
162
+ if (matches.length !== 1)
163
+ return undefined;
164
+ const match = matches[0];
165
+ if (match === undefined || match.index === undefined)
166
+ return undefined;
167
+ const open = match.index + match[0].lastIndexOf("(");
168
+ const close = findMatchingParen(snippet, open);
169
+ if (close === undefined)
170
+ return undefined;
171
+ const args = splitTopLevelArgs(snippet.slice(open + 1, close));
172
+ if (args === undefined || args.length === 0 || args.length > 2)
173
+ return undefined;
174
+ const first = args[0] ?? "";
175
+ const quote = first[0];
176
+ if ((quote !== '"' && quote !== "'") || first[first.length - 1] !== quote)
177
+ return undefined;
178
+ const path = parseQuotedLiteral(first);
179
+ if (path === undefined ||
180
+ !path.startsWith("/") ||
181
+ path.startsWith("//") ||
182
+ path.includes("?") ||
183
+ path.includes("#")) {
184
+ return undefined;
185
+ }
186
+ const method = parseMethodOnlyInit(args[1]);
187
+ if (method === undefined)
188
+ return undefined;
189
+ return { path, method, start: match.index, end: close + 1 };
190
+ }
191
+ function isPotentialBackendSource(code) {
192
+ return (code.includes("@nifrajs/core") ||
193
+ /(?<![.\w])server\s*\(/.test(code) ||
194
+ /(?:from\s*|import\s*)["']hono["']/.test(code) ||
195
+ /new\s+Hono(?:<[^>]+>)?\s*\(/.test(code));
196
+ }
197
+ function scanRoutePattern(file, content, code, pattern, facts) {
198
+ const out = [];
199
+ const lines = content.split("\n");
200
+ pattern.lastIndex = 0;
201
+ for (let m = pattern.exec(code); m !== null; m = pattern.exec(code)) {
202
+ const method = (m[1] ?? "").toUpperCase();
203
+ if (!HTTP_VERBS.has(method))
204
+ continue;
205
+ const path = parseQuotedLiteral(`${pattern === ROUTE_REGISTRATION_DQ ? '"' : "'"}${m[2] ?? ""}${pattern === ROUTE_REGISTRATION_DQ ? '"' : "'"}`);
206
+ if (path === undefined || !path.startsWith("/") || path.startsWith("//"))
207
+ continue;
208
+ if (facts !== undefined) {
209
+ const source = facts.parse(file, content);
210
+ // A parseable source model is authoritative for candidate locations. If parsing is unavailable
211
+ // or the source is malformed, retain the lexical finding and fail closed.
212
+ if (source !== undefined &&
213
+ facts.isRouteRegistrationAt(source, m.index, method, path) !== true)
214
+ continue;
215
+ }
216
+ const line = lineAt(content, m.index);
217
+ out.push({ file, line, snippet: (lines[line - 1] ?? "").trim(), method, path });
218
+ }
219
+ return out;
220
+ }
221
+ /**
222
+ * Blank, with spaces (newlines preserved, so every byte offset - and line number - is unchanged):
223
+ * - `//` line comments and block comments;
224
+ * - the CONTENTS of backtick template literals - code-as-text (doc `CodeBlock` examples, code
225
+ * generators), never a real statement to lint.
226
+ * Single/double-quoted strings are KEPT (a real import/Response specifier lives in one). A small
227
+ * char-state machine, not a full lexer: it doesn't model regex literals, so a regex containing a quote
228
+ * could mis-skip - in practice the constructs these scanners look for sit before any such regex.
229
+ * Shared by the source scanners here and by `nifra doctor` ({@link ./doctor.ts}).
230
+ */
231
+ export function stripComments(src) {
232
+ const out = src.split("");
233
+ const n = src.length;
234
+ let i = 0;
235
+ const blank = (a, b) => {
236
+ for (let k = a; k < b; k++)
237
+ if (out[k] !== "\n")
238
+ out[k] = " ";
239
+ };
240
+ while (i < n) {
241
+ const c = src[i];
242
+ const d = src[i + 1];
243
+ if (c === "/" && d === "/") {
244
+ let j = i + 2;
245
+ while (j < n && src[j] !== "\n")
246
+ j++;
247
+ blank(i, j);
248
+ i = j;
249
+ }
250
+ else if (c === "/" && d === "*") {
251
+ let j = i + 2;
252
+ while (j < n && !(src[j] === "*" && src[j + 1] === "/"))
253
+ j++;
254
+ j = Math.min(j + 2, n);
255
+ blank(i, j);
256
+ i = j;
257
+ }
258
+ else if (c === "`") {
259
+ let j = i + 1;
260
+ while (j < n && src[j] !== "`") {
261
+ if (src[j] === "\\")
262
+ j++;
263
+ j++;
264
+ }
265
+ blank(i + 1, j); // keep the backticks, blank the code-as-text inside
266
+ i = j + 1;
267
+ }
268
+ else if (c === "'" || c === '"') {
269
+ let j = i + 1;
270
+ while (j < n && src[j] !== c) {
271
+ if (src[j] === "\\")
272
+ j++; // skip the escaped char
273
+ j++;
274
+ }
275
+ i = j + 1;
276
+ }
277
+ else {
278
+ i++;
279
+ }
280
+ }
281
+ return out.join("");
282
+ }
283
+ /** Blank comments and every quoted/template literal while preserving offsets. Unlike
284
+ * {@link stripComments}, this is a code-position mask: scanners use it to find a call expression in
285
+ * executable code, then inspect the original source for the call's literal argument. That prevents
286
+ * documentation strings such as `const example = 'client("/")'` from becoming diagnostics. */
287
+ export function codePositionMask(src) {
288
+ const out = src.split("");
289
+ const n = src.length;
290
+ let i = 0;
291
+ const blank = (a, b) => {
292
+ for (let k = a; k < b; k++)
293
+ if (out[k] !== "\n")
294
+ out[k] = " ";
295
+ };
296
+ while (i < n) {
297
+ const c = src[i];
298
+ const d = src[i + 1];
299
+ if (c === "/" && d === "/") {
300
+ let j = i + 2;
301
+ while (j < n && src[j] !== "\n")
302
+ j++;
303
+ blank(i, j);
304
+ i = j;
305
+ }
306
+ else if (c === "/" && d === "*") {
307
+ let j = i + 2;
308
+ while (j < n && !(src[j] === "*" && src[j + 1] === "/"))
309
+ j++;
310
+ j = Math.min(j + 2, n);
311
+ blank(i, j);
312
+ i = j;
313
+ }
314
+ else if (c === "'" || c === '"' || c === "`") {
315
+ const quote = c;
316
+ let j = i + 1;
317
+ while (j < n && src[j] !== quote) {
318
+ if (src[j] === "\\")
319
+ j++;
320
+ j++;
321
+ }
322
+ j = Math.min(j + 1, n);
323
+ blank(i, j);
324
+ i = j;
325
+ }
326
+ else {
327
+ i++;
328
+ }
329
+ }
330
+ return out.join("");
331
+ }
332
+ const firstArgumentIndex = (content, callEnd) => {
333
+ let index = callEnd;
334
+ while (/\s/.test(content[index] ?? ""))
335
+ index++;
336
+ return index;
337
+ };
338
+ // Read the literal URL path of a `fetch("/…")` call starting at `start` (the index of the leading `/`),
339
+ // stopping at the first quote / query / hash / template-expression boundary, and test it against the
340
+ // allowlist. A template like `/auth/${id}` matches on its literal head (`/auth`), so a dynamic sign-in URL
341
+ // is covered too. Match is segment-anchored - `/auth` blesses `/auth` and `/auth/**`, never `/authors`.
342
+ function matchesExternalMount(content, start, mounts) {
343
+ let end = start;
344
+ while (end < content.length) {
345
+ const ch = content[end];
346
+ if (ch === "'" || ch === '"' || ch === "`" || ch === "?" || ch === "#")
347
+ break;
348
+ if (ch === "$" && content[end + 1] === "{")
349
+ break;
350
+ end++;
351
+ }
352
+ const path = content.slice(start, end);
353
+ // A `..` segment means the runtime path escapes the literal prefix (`/auth/../api/admin` resolves to
354
+ // `/api/admin`), so it must NOT be blessed by an `/auth` mount - never let traversal hide a real own-API
355
+ // fetch. Match is otherwise segment-anchored (`/auth` blesses `/auth` and `/auth/**`, never `/authors`).
356
+ try {
357
+ if (path.split("/").some((segment) => decodeURIComponent(segment) === ".."))
358
+ return false;
359
+ }
360
+ catch {
361
+ // A malformed escape is not a prefix we can prove stays inside the external mount.
362
+ return false;
363
+ }
364
+ return mounts.some((prefix) => path === prefix || path.startsWith(`${prefix}/`));
365
+ }
366
+ function findTopLevelPropertyColon(src) {
367
+ let depth = 0;
368
+ let quote;
369
+ for (let i = 0; i < src.length; i++) {
370
+ const c = src[i];
371
+ if (quote !== undefined) {
372
+ if (c === "\\") {
373
+ i++;
374
+ continue;
375
+ }
376
+ if (c === quote)
377
+ quote = undefined;
378
+ continue;
379
+ }
380
+ if (c === '"' || c === "'" || c === "`") {
381
+ quote = c;
382
+ continue;
383
+ }
384
+ if (c === "(" || c === "{" || c === "[")
385
+ depth++;
386
+ else if (c === ")" || c === "}" || c === "]")
387
+ depth--;
388
+ else if (c === ":" && depth === 0)
389
+ return i;
390
+ }
391
+ return undefined;
392
+ }
393
+ function parseStaticPropertyName(raw) {
394
+ const name = raw.trim();
395
+ if (IDENT.test(name))
396
+ return name;
397
+ const quoted = parseQuotedLiteral(name);
398
+ if (quoted !== undefined)
399
+ return quoted;
400
+ if (name.startsWith("[") && name.endsWith("]")) {
401
+ return parseQuotedLiteral(name.slice(1, -1).trim());
402
+ }
403
+ return undefined;
404
+ }
405
+ /** Return a statically named property from a plain object literal. Deliberately does not follow
406
+ * spreads, identifiers, or helper calls: only an inline literal proves this fetch is Nifra's page-data
407
+ * transport rather than an arbitrary same-origin API request. */
408
+ function staticObjectProperty(raw, name) {
409
+ if (raw === undefined)
410
+ return undefined;
411
+ const object = raw.trim();
412
+ if (!object.startsWith("{") || !object.endsWith("}"))
413
+ return undefined;
414
+ const properties = splitTopLevelArgs(object.slice(1, -1));
415
+ if (properties === undefined)
416
+ return undefined;
417
+ for (const property of properties) {
418
+ const colon = findTopLevelPropertyColon(property);
419
+ if (colon === undefined || parseStaticPropertyName(property.slice(0, colon)) !== name)
420
+ continue;
421
+ return property.slice(colon + 1).trim();
422
+ }
423
+ return undefined;
424
+ }
425
+ function hasLiteralNifraDataHeader(content, openParen) {
426
+ const closeParen = findMatchingParen(content, openParen);
427
+ if (closeParen === undefined)
428
+ return false;
429
+ const args = splitTopLevelArgs(content.slice(openParen + 1, closeParen));
430
+ if (args === undefined || args.length !== 2)
431
+ return false;
432
+ const headers = staticObjectProperty(args[1], "headers");
433
+ return staticObjectProperty(headers, "x-nifra-data") !== undefined;
434
+ }
435
+ /** Scan one file's text for hand-rolled own-API `fetch()` calls. Pure + line-accurate. `externalMounts`
436
+ * are intentional non-typed mount prefixes (from `nifra.check.json`, e.g. a mounted better-auth owning
437
+ * `/auth/**`) - a relative fetch into one is deliberate, not drift, so it's skipped. A fetch with an
438
+ * inline literal `x-nifra-data` header is Nifra's page-loader transport, not a backend API call, and is
439
+ * skipped too. */
440
+ export function scanFetchText(file, content, externalMounts = []) {
441
+ const out = [];
442
+ const lines = content.split("\n");
443
+ const code = codePositionMask(content);
444
+ GLOBAL_FETCH_CALL.lastIndex = 0;
445
+ for (let m = GLOBAL_FETCH_CALL.exec(code); m !== null; m = GLOBAL_FETCH_CALL.exec(code)) {
446
+ const argument = firstArgumentIndex(content, m.index + m[0].length);
447
+ const quote = content[argument];
448
+ if (quote !== "'" && quote !== '"' && quote !== "`")
449
+ continue;
450
+ if (content[argument + 1] !== "/" || content[argument + 2] === "/")
451
+ continue;
452
+ const openParen = m.index + m[0].lastIndexOf("(");
453
+ if (hasLiteralNifraDataHeader(content, openParen))
454
+ continue;
455
+ if (externalMounts.length > 0 && matchesExternalMount(content, argument + 1, externalMounts))
456
+ continue;
457
+ const line = lineAt(content, m.index);
458
+ out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() });
459
+ }
460
+ return out;
461
+ }
462
+ /** Statically collect simple Nifra route registrations from source, without importing app code. */
463
+ export function scanStaticRouteText(file, content, facts) {
464
+ const code = stripComments(content);
465
+ if (!isPotentialBackendSource(code))
466
+ return [];
467
+ return [
468
+ ...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_DQ, facts),
469
+ ...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_SQ, facts),
470
+ ].sort(bySite);
471
+ }
472
+ /**
473
+ * SQL built by interpolating a value into the statement text. The value is not a parameter, it is
474
+ * STATEMENT, so anything the caller controls can end the literal and continue as SQL.
475
+ *
476
+ * Two things this deliberately does not flag, because both are safe and flagging them would train
477
+ * people to ignore the rule:
478
+ *
479
+ * - A TAGGED template - ``sql`... ${id} ...` `` in postgres.js, drizzle or kysely. The tag receives the
480
+ * substitutions separately and binds them; that is the parameterised form, not a bypass of it.
481
+ * - A literal with no substitution at all. `db.query("SELECT … WHERE id = ?")` is the shape being
482
+ * argued for.
483
+ *
484
+ * A SQL keyword is required in the literal, so `cache.query(`user:${id}`)` stays quiet. The named
485
+ * escape hatches (`$queryRawUnsafe`, `sql.unsafe`) are flagged on the call alone: their whole purpose
486
+ * is to take a statement as text, and a substitution in one is the exact thing the name warns about.
487
+ */
488
+ const SQL_METHODS = new Set([
489
+ "query",
490
+ "queryRaw",
491
+ "prepare",
492
+ "exec",
493
+ "execute",
494
+ "run",
495
+ "unsafe",
496
+ "$queryRawUnsafe",
497
+ "$executeRawUnsafe",
498
+ "raw",
499
+ ]);
500
+ 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;
501
+ const ALWAYS_UNSAFE = new Set(["unsafe", "$queryRawUnsafe", "$executeRawUnsafe", "raw"]);
502
+ /** Read one module's top-level `const` bindings and its import/export edges. Pure syntax - no checker,
503
+ * no filesystem. Destructured names, `let`, `var`, and anything nested stay out on purpose: a
504
+ * module-scope `const` is the only form that provably cannot carry request data. */
505
+ function sqlModuleScope(ts, id, source) {
506
+ const consts = new Map();
507
+ const imports = new Map();
508
+ const localExports = new Map();
509
+ const reExports = new Map();
510
+ const starExports = [];
511
+ for (const statement of source.statements) {
512
+ if (ts.isVariableStatement(statement)) {
513
+ if ((statement.declarationList.flags & ts.NodeFlags.Const) === 0)
514
+ continue;
515
+ const exported = statement.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) === true;
516
+ for (const declaration of statement.declarationList.declarations) {
517
+ if (!ts.isIdentifier(declaration.name) || declaration.initializer === undefined)
518
+ continue;
519
+ consts.set(declaration.name.text, declaration.initializer);
520
+ if (exported)
521
+ localExports.set(declaration.name.text, declaration.name.text);
522
+ }
523
+ continue;
524
+ }
525
+ if (ts.isImportDeclaration(statement)) {
526
+ const clause = statement.importClause;
527
+ if (clause === undefined || clause.isTypeOnly)
528
+ continue;
529
+ if (!ts.isStringLiteral(statement.moduleSpecifier))
530
+ continue;
531
+ const bindings = clause.namedBindings;
532
+ // Only `import { X }` / `import { X as local }`. A default or namespace import binds a value
533
+ // whose contents this scan cannot prove, so it stays unbound and every use of it flags.
534
+ if (bindings === undefined || !ts.isNamedImports(bindings))
535
+ continue;
536
+ for (const element of bindings.elements) {
537
+ if (element.isTypeOnly)
538
+ continue;
539
+ imports.set(element.name.text, {
540
+ specifier: statement.moduleSpecifier.text,
541
+ exported: element.propertyName?.text ?? element.name.text,
542
+ });
543
+ }
544
+ continue;
545
+ }
546
+ if (!ts.isExportDeclaration(statement) || statement.isTypeOnly)
547
+ continue;
548
+ const from = statement.moduleSpecifier;
549
+ if (from !== undefined && !ts.isStringLiteral(from))
550
+ continue;
551
+ const clause = statement.exportClause;
552
+ if (clause === undefined) {
553
+ // No clause and a specifier is `export * from "./m"`. `export * as ns from "./m"` carries a
554
+ // NamespaceExport clause instead and falls out below, unresolved, which is what we want.
555
+ if (from !== undefined)
556
+ starExports.push(from.text);
557
+ continue;
558
+ }
559
+ if (!ts.isNamedExports(clause))
560
+ continue;
561
+ for (const element of clause.elements) {
562
+ if (element.isTypeOnly)
563
+ continue;
564
+ const local = element.propertyName?.text ?? element.name.text;
565
+ if (from === undefined)
566
+ localExports.set(element.name.text, local);
567
+ else
568
+ reExports.set(element.name.text, { specifier: from.text, exported: local });
569
+ }
570
+ }
571
+ return { id, consts, imports, localExports, reExports, starExports };
572
+ }
573
+ export const createSqlImports = (load) => ({
574
+ load,
575
+ scopes: new Map(),
576
+ });
577
+ /** Parse a module for the SQL scan, or `undefined` when it does not parse. Malformed source is already
578
+ * a typecheck failure; parser recovery nodes are not evidence of anything, so a file that fails to
579
+ * parse contributes no findings and proves no constant. */
580
+ function parseSqlSource(ts, file, content) {
581
+ const kind = /\.[cm]?tsx?$/.test(file) ? ts.ScriptKind.TSX : ts.ScriptKind.JS;
582
+ const source = ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true, kind);
583
+ const parseDiagnostics = source.parseDiagnostics;
584
+ return parseDiagnostics !== undefined && parseDiagnostics.length > 0 ? undefined : source;
585
+ }
586
+ /** Module-boundary hops one name may cross. Barrels are why it is not 1: `route → index → fragments`
587
+ * already spends two, and the third leaves room for one more layer without letting the scan wander the
588
+ * module graph. Past the cap the name is unresolved, so the interpolation flags. */
589
+ const SQL_IMPORT_HOPS = 3;
590
+ /** Load + parse the module a specifier names, through the run's cache. */
591
+ function loadSqlModuleScope(ts, imports, fromModule, specifier) {
592
+ const loaded = imports.load(fromModule, specifier);
593
+ if (loaded === undefined)
594
+ return undefined;
595
+ if (imports.scopes.has(loaded.id))
596
+ return imports.scopes.get(loaded.id);
597
+ const source = parseSqlSource(ts, loaded.id, loaded.content);
598
+ const scope = source === undefined ? undefined : sqlModuleScope(ts, loaded.id, source);
599
+ imports.scopes.set(loaded.id, scope);
600
+ return scope;
601
+ }
602
+ /**
603
+ * Follow an exported name to its `const` initializer, across re-export hops.
604
+ *
605
+ * Handles the three forms a fragments module actually uses: `export const NAME = …`, the two-statement
606
+ * `const NAME = …; export { NAME }` (including the pass-through `import { A } …; export { A }`), and
607
+ * barrels - `export { X } from "./m"` and `export * from "./m"`. Bounded by {@link SQL_IMPORT_HOPS} and
608
+ * a per-chain visited set, so a cyclic barrel terminates instead of recursing.
609
+ *
610
+ * Two `export *` sources providing the same name is a conflict, and the scan refuses rather than
611
+ * picking one: guessing here would silence a real finding.
612
+ */
613
+ function resolveExportedConst(ts, imports, scope, name, hops, seen) {
614
+ if (hops > SQL_IMPORT_HOPS)
615
+ return undefined;
616
+ const key = `${scope.id}#${name}`;
617
+ if (seen.has(key))
618
+ return undefined;
619
+ const nextSeen = new Set(seen).add(key);
620
+ const follow = (edge) => {
621
+ const target = loadSqlModuleScope(ts, imports, scope.id, edge.specifier);
622
+ return target === undefined
623
+ ? undefined
624
+ : resolveExportedConst(ts, imports, target, edge.exported, hops + 1, nextSeen);
625
+ };
626
+ const local = scope.localExports.get(name);
627
+ if (local !== undefined) {
628
+ const initializer = scope.consts.get(local);
629
+ if (initializer !== undefined)
630
+ return { expression: initializer, scope };
631
+ // `import { A } from "./a"; export { A }` - the same pass-through as `export { A } from "./a"`,
632
+ // written in two statements. Anything else the name could be (a `let`, a function, a class) has no
633
+ // initializer in `consts` and no import edge, so it lands on `undefined` and flags.
634
+ const via = scope.imports.get(local);
635
+ return via === undefined ? undefined : follow(via);
636
+ }
637
+ const reExported = scope.reExports.get(name);
638
+ if (reExported !== undefined)
639
+ return follow(reExported);
640
+ let found;
641
+ for (const specifier of scope.starExports) {
642
+ const hit = follow({ specifier, exported: name });
643
+ if (hit === undefined)
644
+ continue;
645
+ if (found !== undefined)
646
+ return undefined;
647
+ found = hit;
648
+ }
649
+ return found;
650
+ }
651
+ /** Whether `name`, read at `at`, might be re-declared by anything between the read and module scope.
652
+ * Conservative on purpose: the OUTERMOST enclosing function/block subtree is scanned for ANY binding
653
+ * of the name (parameter, `var`/`let`/`const`, catch, local function, destructuring element) - so a
654
+ * hoisted `var` in a sibling block, or a binding in a branch that never runs, still refuses
655
+ * resolution. Over-refusal flags a safe line (the pre-feature behavior); under-refusal would resolve
656
+ * the wrong binding, which is why the cheap-and-broad scan wins over scope simulation. A use with no
657
+ * enclosing function or block reads module scope directly and cannot be shadowed. */
658
+ function isShadowedAt(ts, at, name) {
659
+ let scope;
660
+ for (let node = at.parent; node !== undefined; node = node.parent) {
661
+ if (ts.isSourceFile(node))
662
+ break;
663
+ if (ts.isFunctionLike(node) || ts.isBlock(node))
664
+ scope = node;
665
+ }
666
+ if (scope === undefined)
667
+ return false;
668
+ const bindsName = (binding) => ts.isIdentifier(binding)
669
+ ? binding.text === name
670
+ : binding.elements.some((element) => !ts.isOmittedExpression(element) &&
671
+ (ts.isIdentifier(element.name) ? element.name.text === name : bindsName(element.name)));
672
+ let found = false;
673
+ const scan = (node) => {
674
+ if (found)
675
+ return;
676
+ if ((ts.isVariableDeclaration(node) || ts.isParameter(node)) &&
677
+ bindsName(node.name)) {
678
+ found = true;
679
+ return;
680
+ }
681
+ if (ts.isFunctionDeclaration(node) && node.name?.text === name) {
682
+ found = true;
683
+ return;
684
+ }
685
+ ts.forEachChild(node, scan);
686
+ };
687
+ scan(scope);
688
+ return found;
689
+ }
690
+ const CONST_RESOLUTION_DEPTH = 5;
691
+ /**
692
+ * Resolve an interpolated expression to compile-time text, or `undefined` when it can carry runtime
693
+ * data. Pure syntax, no type checker - the same honest limit the rule already documents. What
694
+ * resolves: string/number literals, no-substitution templates, templates whose every span resolves,
695
+ * ternaries with both branches resolvable (BOTH branch texts are returned, so a hostile keyword in
696
+ * either still feeds the keyword scan), `as`/`satisfies` wrappers, a module-scope `const` whose
697
+ * initializer resolves (recursively, depth-capped), and - when `scope.imports` is configured - such a
698
+ * `const` imported by name from another in-project module. Everything else - `let`, a parameter, a
699
+ * call, a member access, a shadowed name, a bare-specifier import - stays unresolved and the
700
+ * interpolation is flagged exactly as before.
701
+ */
702
+ function resolveStaticSqlText(ts, node, scope, depth) {
703
+ if (depth > CONST_RESOLUTION_DEPTH)
704
+ return undefined;
705
+ if (ts.isParenthesizedExpression(node)) {
706
+ return resolveStaticSqlText(ts, node.expression, scope, depth);
707
+ }
708
+ if (ts.isAsExpression(node) || ts.isSatisfiesExpression(node)) {
709
+ return resolveStaticSqlText(ts, node.expression, scope, depth);
710
+ }
711
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node))
712
+ return node.text;
713
+ if (ts.isNumericLiteral(node))
714
+ return node.text;
715
+ if (ts.isTemplateExpression(node)) {
716
+ let text = node.head.text;
717
+ for (const span of node.templateSpans) {
718
+ const part = resolveStaticSqlText(ts, span.expression, scope, depth + 1);
719
+ if (part === undefined)
720
+ return undefined;
721
+ text += part + span.literal.text;
722
+ }
723
+ return text;
724
+ }
725
+ if (ts.isConditionalExpression(node)) {
726
+ const whenTrue = resolveStaticSqlText(ts, node.whenTrue, scope, depth + 1);
727
+ const whenFalse = resolveStaticSqlText(ts, node.whenFalse, scope, depth + 1);
728
+ if (whenTrue === undefined || whenFalse === undefined)
729
+ return undefined;
730
+ return `${whenTrue} ${whenFalse}`;
731
+ }
732
+ if (ts.isIdentifier(node)) {
733
+ const initializer = scope.module.consts.get(node.text);
734
+ const edge = initializer === undefined ? scope.module.imports.get(node.text) : undefined;
735
+ // Membership first: `isShadowedAt` walks a subtree, and most identifiers in a query are neither a
736
+ // module const nor an import binding.
737
+ if (initializer === undefined && edge === undefined)
738
+ return undefined;
739
+ // Shadowing is a property of where the identifier is WRITTEN, so one check is right on both sides
740
+ // of an import hop: an initializer in the imported module sits at module scope with nothing above
741
+ // it to shadow it, while a use inside a function in this file is still refused.
742
+ if (isShadowedAt(ts, node, node.text))
743
+ return undefined;
744
+ if (initializer !== undefined)
745
+ return resolveStaticSqlText(ts, initializer, scope, depth + 1);
746
+ if (edge === undefined || scope.imports === undefined)
747
+ return undefined;
748
+ const target = loadSqlModuleScope(ts, scope.imports, scope.module.id, edge.specifier);
749
+ if (target === undefined)
750
+ return undefined;
751
+ const exported = resolveExportedConst(ts, scope.imports, target, edge.exported, 1, new Set());
752
+ if (exported === undefined)
753
+ return undefined;
754
+ // The imported initializer's own identifiers read THAT module's consts, never this file's. A
755
+ // caller that happens to have its own `const DIR` must not silently supply the fragment's `DIR`,
756
+ // which is the one way cross-module resolution could resolve to text nobody wrote.
757
+ return resolveStaticSqlText(ts, exported.expression, { module: exported.scope, imports: scope.imports }, depth + 1);
758
+ }
759
+ return undefined;
760
+ }
761
+ /** A template's contribution to the shape: resolved span texts fold into `staticText` (so the
762
+ * keyword scan still sees what a `const` carries); any unresolvable span keeps it `dynamic`. */
763
+ function templateShape(ts, template, scope) {
764
+ let staticText = template.head.text;
765
+ let dynamic = false;
766
+ for (const span of template.templateSpans) {
767
+ const resolved = scope === undefined ? undefined : resolveStaticSqlText(ts, span.expression, scope, 0);
768
+ if (resolved === undefined)
769
+ dynamic = true;
770
+ else
771
+ staticText += resolved;
772
+ staticText += span.literal.text;
773
+ }
774
+ return { staticText, dynamic };
775
+ }
776
+ /** Whether `name` is written to anywhere in the file - a plain, compound, or `++`/`--` assignment to a
777
+ * bare identifier of that name. A `let` that is is over-cautiously refused (it might be reassigned from
778
+ * request data); a `const` never needs this. Over-refusal only ever leaves an interpolation flagged as
779
+ * before, never the reverse. */
780
+ function isReassignedInFile(ts, source, name) {
781
+ let found = false;
782
+ const visit = (node) => {
783
+ if (found)
784
+ return;
785
+ if (ts.isBinaryExpression(node) &&
786
+ node.operatorToken.kind >= ts.SyntaxKind.FirstAssignment &&
787
+ node.operatorToken.kind <= ts.SyntaxKind.LastAssignment &&
788
+ ts.isIdentifier(node.left) &&
789
+ node.left.text === name) {
790
+ found = true;
791
+ return;
792
+ }
793
+ if ((ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) &&
794
+ (node.operator === ts.SyntaxKind.PlusPlusToken ||
795
+ node.operator === ts.SyntaxKind.MinusMinusToken) &&
796
+ ts.isIdentifier(node.operand) &&
797
+ node.operand.text === name) {
798
+ found = true;
799
+ return;
800
+ }
801
+ ts.forEachChild(node, visit);
802
+ };
803
+ visit(source);
804
+ return found;
805
+ }
806
+ /** Does `binding` bind `name` (as a plain identifier or nested inside a destructuring pattern)? Mirrors
807
+ * the pattern walk in {@link isShadowedAt}. */
808
+ function bindingNameCovers(ts, binding, name) {
809
+ return ts.isIdentifier(binding)
810
+ ? binding.text === name
811
+ : binding.elements.some((element) => !ts.isOmittedExpression(element) &&
812
+ (ts.isIdentifier(element.name)
813
+ ? element.name.text === name
814
+ : bindingNameCovers(ts, element.name, name)));
815
+ }
816
+ /** Inspect the bindings a single scope node introduces DIRECTLY (not nested) for `name`: parameters and
817
+ * catch variables of a function/catch, `var`/`let`/`const` and function/class declarations of a
818
+ * statement list. Returns the readable initializer, `"shadow"` for a form we cannot read through, or
819
+ * `undefined` when this scope does not bind the name at all. */
820
+ function directBinding(ts, source, scope, name) {
821
+ if (ts.isFunctionLike(scope)) {
822
+ for (const parameter of scope.parameters) {
823
+ if (bindingNameCovers(ts, parameter.name, name))
824
+ return "shadow";
825
+ }
826
+ }
827
+ if (ts.isCatchClause(scope)) {
828
+ const declared = scope.variableDeclaration;
829
+ if (declared !== undefined && bindingNameCovers(ts, declared.name, name))
830
+ return "shadow";
831
+ }
832
+ const statements = ts.isSourceFile(scope)
833
+ ? scope.statements
834
+ : ts.isBlock(scope) || ts.isModuleBlock(scope)
835
+ ? scope.statements
836
+ : ts.isCaseClause(scope) || ts.isDefaultClause(scope)
837
+ ? scope.statements
838
+ : undefined;
839
+ const lists = [];
840
+ if (statements !== undefined) {
841
+ for (const statement of statements) {
842
+ if (ts.isVariableStatement(statement))
843
+ lists.push(statement.declarationList);
844
+ if ((ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) &&
845
+ statement.name?.text === name) {
846
+ return "shadow";
847
+ }
848
+ }
849
+ }
850
+ if ((ts.isForStatement(scope) || ts.isForInStatement(scope) || ts.isForOfStatement(scope)) &&
851
+ scope.initializer !== undefined &&
852
+ ts.isVariableDeclarationList(scope.initializer)) {
853
+ lists.push(scope.initializer);
854
+ }
855
+ for (const list of lists) {
856
+ for (const declaration of list.declarations) {
857
+ if (!bindingNameCovers(ts, declaration.name, name))
858
+ continue;
859
+ // A destructured or uninitialized binding, or a `var` (hoisting + reassignment risk), is not a
860
+ // value we can read one expression out of.
861
+ if (!ts.isIdentifier(declaration.name) || declaration.initializer === undefined)
862
+ return "shadow";
863
+ const isConst = (list.flags & ts.NodeFlags.Const) !== 0;
864
+ const isLet = (list.flags & ts.NodeFlags.Let) !== 0;
865
+ if (isConst)
866
+ return { initializer: declaration.initializer };
867
+ if (isLet && !isReassignedInFile(ts, source, name))
868
+ return { initializer: declaration.initializer };
869
+ return "shadow";
870
+ }
871
+ }
872
+ return undefined;
873
+ }
874
+ /**
875
+ * Resolve a query-call ARGUMENT identifier to the expression it was initialized from, so a statement
876
+ * parked in a variable is scanned as if it had been written inline at the call. This is the counterpart
877
+ * to {@link resolveStaticSqlText}: that folds a const's text INTO a template's shape; this unfolds the
878
+ * whole statement back out of the variable it was lifted into. Without it, ANY interpolated-SQL finding
879
+ * could be silenced, invisibly, by hoisting the statement to `const q = …` first.
880
+ *
881
+ * Same-file only. Nearest enclosing declaration wins, so a shadowing local is read and never a
882
+ * same-named module const. Reads through a `const`, or a `let` the file never reassigns, at function or
883
+ * module scope; transitive through an identifier-valued initializer; depth-capped. A parameter, an
884
+ * imported or destructured binding, a reassigned `let`, a `var`, or a name with no same-file
885
+ * declaration returns `undefined` - which leaves the argument scanned as itself (unflagged unless it is
886
+ * a module const that resolves to static text: exactly the pre-feature behavior).
887
+ */
888
+ function resolveArgumentInitializer(ts, node, depth) {
889
+ if (depth > CONST_RESOLUTION_DEPTH)
890
+ return undefined;
891
+ const source = node.getSourceFile();
892
+ const name = node.text;
893
+ for (let scope = node.parent; scope !== undefined; scope = scope.parent) {
894
+ const binding = directBinding(ts, source, scope, name);
895
+ if (binding === "shadow")
896
+ return undefined;
897
+ if (binding !== undefined) {
898
+ let init = binding.initializer;
899
+ while (ts.isParenthesizedExpression(init))
900
+ init = init.expression;
901
+ return ts.isIdentifier(init) ? resolveArgumentInitializer(ts, init, depth + 1) : init;
902
+ }
903
+ if (ts.isSourceFile(scope))
904
+ break;
905
+ }
906
+ return undefined;
907
+ }
908
+ /** Describe only the first argument's syntax; comments/strings elsewhere cannot influence the result. */
909
+ function sqlExpressionShape(ts, node, scope) {
910
+ if (ts.isParenthesizedExpression(node))
911
+ return sqlExpressionShape(ts, node.expression, scope);
912
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
913
+ return {
914
+ staticText: node.text,
915
+ dynamic: false,
916
+ concatenated: false,
917
+ literal: true,
918
+ parameterizedTag: false,
919
+ };
920
+ }
921
+ if (ts.isTaggedTemplateExpression(node)) {
922
+ const tag = node.tag;
923
+ const parameterizedTag = (ts.isIdentifier(tag) && tag.text === "sql") ||
924
+ (ts.isPropertyAccessExpression(tag) && tag.getText() === "Prisma.sql");
925
+ const template = node.template;
926
+ const templated = ts.isTemplateExpression(template)
927
+ ? templateShape(ts, template, parameterizedTag ? undefined : scope)
928
+ : { staticText: template.text, dynamic: false };
929
+ const staticText = templated.staticText;
930
+ // Only the ecosystem's binding tags are trusted, by NAME - which is the honest limit of a scanner
931
+ // that reads syntax and runs no type checker. `sql` is what postgres.js, drizzle, slonik and Bun's
932
+ // own driver all call theirs, and `Prisma.sql` is Prisma's; taking those at their word is a
933
+ // deliberate trade, and it is worth stating what it costs: a no-op function named `sql` in the
934
+ // same file is trusted too, so this rule finds mistakes, not an adversary who reads it first.
935
+ //
936
+ // Trusting anything else widens that for no gain. This list briefly also held `sqlIdentifiers`,
937
+ // a name invented for Nifra's own adapters - a second forgeable name that no user has a
938
+ // convention for, added so Nifra's source would pass its own rule. Names earn their place here by
939
+ // being what drivers already call the thing, not by being convenient for the framework.
940
+ //
941
+ // Everything else - `String.raw`, an identity tag, a member access that merely ends in `.sql` -
942
+ // is treated as plain interpolation.
943
+ return {
944
+ staticText,
945
+ dynamic: templated.dynamic && !parameterizedTag,
946
+ concatenated: false,
947
+ literal: !ts.isTemplateExpression(template),
948
+ parameterizedTag,
949
+ };
950
+ }
951
+ if (ts.isTemplateExpression(node)) {
952
+ const templated = templateShape(ts, node, scope);
953
+ return {
954
+ staticText: templated.staticText,
955
+ dynamic: templated.dynamic,
956
+ concatenated: false,
957
+ literal: false,
958
+ parameterizedTag: false,
959
+ };
960
+ }
961
+ if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) {
962
+ const left = sqlExpressionShape(ts, node.left, scope);
963
+ const right = sqlExpressionShape(ts, node.right, scope);
964
+ return {
965
+ staticText: left.staticText + right.staticText,
966
+ // A resolved const operand is static text, not a dynamic part - so the old `!literal` test
967
+ // narrows to what it always actually meant: an operand that carries runtime data (dynamic) or
968
+ // a binding tag being concatenated into plain text (which un-parameterizes it).
969
+ dynamic: left.dynamic || right.dynamic || left.parameterizedTag || right.parameterizedTag,
970
+ concatenated: true,
971
+ literal: left.literal && right.literal,
972
+ parameterizedTag: false,
973
+ };
974
+ }
975
+ // A resolvable expression (a module-`const` name, a literal ternary) contributes its compile-time
976
+ // text and stays non-dynamic - this is what lets `"SELECT " + COLS + " FROM t"` and
977
+ // `db.query(`${COLS} FROM t WHERE id = $1`)` pass while a mutable or unprovable name still flags.
978
+ // `literal` stays false: the named escape hatches (`unsafe`, `$queryRawUnsafe`) keep flagging a
979
+ // resolved const, because taking statement text from ANY variable is what their rule warns about.
980
+ if (scope !== undefined) {
981
+ const resolved = resolveStaticSqlText(ts, node, scope, 0);
982
+ if (resolved !== undefined) {
983
+ return {
984
+ staticText: resolved,
985
+ dynamic: false,
986
+ concatenated: false,
987
+ literal: false,
988
+ parameterizedTag: false,
989
+ };
990
+ }
991
+ }
992
+ // A bare name that did not resolve to compile-time text. If it is a same-file binding we can prove
993
+ // holds one expression, scan THAT expression's shape - so a statement hoisted into `const q = …` is
994
+ // read exactly as if it were written inline at the call, closing the "extract a variable to launder
995
+ // the finding" hole. `literal` is forced false: the value arrived through a variable, which is the
996
+ // property the `unsafe`/`$queryRawUnsafe` hatch rule keys on.
997
+ if (ts.isIdentifier(node)) {
998
+ const initializer = resolveArgumentInitializer(ts, node, 0);
999
+ if (initializer !== undefined) {
1000
+ return { ...sqlExpressionShape(ts, initializer, scope), literal: false };
1001
+ }
1002
+ }
1003
+ return {
1004
+ staticText: "",
1005
+ dynamic: true,
1006
+ concatenated: false,
1007
+ literal: false,
1008
+ parameterizedTag: false,
1009
+ };
1010
+ }
1011
+ function sqlMethod(ts, call) {
1012
+ if (ts.isPropertyAccessExpression(call.expression)) {
1013
+ const method = call.expression.name.text;
1014
+ return SQL_METHODS.has(method)
1015
+ ? { method, receiver: call.expression.expression.getText() }
1016
+ : undefined;
1017
+ }
1018
+ if (ts.isElementAccessExpression(call.expression) &&
1019
+ call.expression.argumentExpression !== undefined &&
1020
+ ts.isStringLiteral(call.expression.argumentExpression)) {
1021
+ const method = call.expression.argumentExpression.text;
1022
+ return SQL_METHODS.has(method)
1023
+ ? { method, receiver: call.expression.expression.getText() }
1024
+ : undefined;
1025
+ }
1026
+ return undefined;
1027
+ }
1028
+ const SQL_RECEIVER = /(?:^|\.)(?:db|sql|prisma|drizzle|database|conn|connection|client|tx)$/i;
1029
+ /** The opt-out pragma that silences one interpolated-SQL finding. Written `// nifra-expect sql-dynamic:
1030
+ * <reason>` on the flagged line or in the comment block directly above it; the reason after the colon
1031
+ * is mandatory (see {@link commentBlockMarkerReason}). Scoped to the single statement it sits on. */
1032
+ const SQL_DYNAMIC_PRAGMA = "nifra-expect sql-dynamic";
1033
+ /**
1034
+ * Scan one file's text for SQL assembled by interpolation. Pure + line-accurate.
1035
+ *
1036
+ * `imports` opts into following a fragment `const` into another module. Without it the scan is
1037
+ * same-file only, which is what the unit tests exercise and what any caller that has no module graph
1038
+ * to offer gets. With it, only what {@link createProjectSqlImports} will hand back is followed.
1039
+ */
1040
+ export function scanInterpolatedSql(file, content, ts, imports) {
1041
+ const out = [];
1042
+ const lines = content.split("\n");
1043
+ // Malformed source is already a typecheck failure. Do not turn parser recovery nodes into a second,
1044
+ // misleading SQL diagnostic (notably an unterminated template recovered as an interpolation).
1045
+ const source = parseSqlSource(ts, file, content);
1046
+ if (source === undefined)
1047
+ return out;
1048
+ // Module-scope bindings, read once per file: an interpolated `const` that resolves to compile-time
1049
+ // text cannot carry request data, so the shared-projection idiom (`const COLS = "id, name"` …
1050
+ // `${COLS}`) stays quiet while mutable, parameter, shadowed, and unprovable names flag as before.
1051
+ const scope = { module: sqlModuleScope(ts, file, source), imports };
1052
+ const visit = (node) => {
1053
+ if (ts.isCallExpression(node)) {
1054
+ const sink = sqlMethod(ts, node);
1055
+ const argument = node.arguments[0];
1056
+ if (sink !== undefined && argument !== undefined) {
1057
+ const shape = sqlExpressionShape(ts, argument, scope);
1058
+ const unsafeEscape = ALWAYS_UNSAFE.has(sink.method) && !shape.literal && !shape.parameterizedTag;
1059
+ const assembledSql = shape.dynamic &&
1060
+ (SQL_KEYWORD.test(shape.staticText) ||
1061
+ (shape.concatenated && SQL_RECEIVER.test(sink.receiver)));
1062
+ if (unsafeEscape || assembledSql) {
1063
+ const index = node.getStart(source);
1064
+ const line = source.getLineAndCharacterOfPosition(index).line + 1;
1065
+ // Sanctioned escape hatch for genuinely-dynamic-but-bound SQL (batch VALUES placeholder
1066
+ // generation, an allowlisted identifier). The reason is mandatory: a bare `// nifra-expect
1067
+ // sql-dynamic` with no reason after the colon does NOT silence, so the hatch always leaves a
1068
+ // greppable audit trail instead of becoming a second laundering trick.
1069
+ const silenced = commentBlockMarkerReason(lines, line, SQL_DYNAMIC_PRAGMA) !== undefined;
1070
+ if (!silenced)
1071
+ out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() });
1072
+ }
1073
+ }
1074
+ }
1075
+ ts.forEachChild(node, visit);
1076
+ };
1077
+ visit(source);
1078
+ return out;
1079
+ }
1080
+ /**
1081
+ * Collect interpolated-SQL findings across the project.
1082
+ *
1083
+ * Returns `undefined` - not an empty array - when TypeScript is not installed. The rule parses source
1084
+ * with the compiler, so without it there is nothing to report and, more to the point, no basis for
1085
+ * reporting nothing. An empty result would read as "no interpolated SQL found", which is the one
1086
+ * answer a security rule must never give when it did not run.
1087
+ */
1088
+ export async function scanProjectSql(cwd) {
1089
+ const ts = await importProjectTypeScript(cwd);
1090
+ if (ts === undefined)
1091
+ return undefined;
1092
+ const out = [];
1093
+ const imports = createProjectSqlImports(cwd);
1094
+ await walkSource(cwd, (rel, content) => out.push(...scanInterpolatedSql(rel, content, ts, imports)));
1095
+ return out.sort(bySite);
1096
+ }
1097
+ /** A declaration file. Excluded from cross-module resolution: `declare const X: string` has no
1098
+ * initializer to read, so following one could only ever produce a "proved" the source never wrote. */
1099
+ const SQL_DECLARATION_FILE = /\.d\.[cm]?ts$/;
1100
+ /** The extensions the SQL scan will parse - the same set {@link walkSource} globs. */
1101
+ const SQL_SOURCE_FILE = /\.[cm]?tsx?$/;
1102
+ /**
1103
+ * The on-disk loader for cross-module `const` resolution, scoped to one project.
1104
+ *
1105
+ * Deliberately narrow, because every widening here is a way for a real finding to go quiet:
1106
+ *
1107
+ * - **Relative specifiers only.** A bare specifier names a dependency whose exports can change without
1108
+ * this project changing, so "provably a `const` string" is not a property the project owns.
1109
+ * - **Inside `cwd`, outside the ignored trees.** The resolved path is realpath'd and must still sit
1110
+ * under the project root: a symlink pointing out of the tree is not source this scan reads. The
1111
+ * `node_modules` / `dist` / build exclusions are the same ones the walk applies.
1112
+ * - **Real source only.** A `.d.ts` carries no initializer, and a non-TS file is not parsed.
1113
+ *
1114
+ * A miss on any of those returns `undefined`, which leaves the name unresolved and the interpolation
1115
+ * flagged - the pre-feature answer.
1116
+ */
1117
+ export function createProjectSqlImports(cwd) {
1118
+ const root = (() => {
1119
+ try {
1120
+ return realpathSync(resolve(cwd));
1121
+ }
1122
+ catch {
1123
+ return resolve(cwd);
1124
+ }
1125
+ })();
1126
+ return createSqlImports((fromModule, specifier) => {
1127
+ if (!isRelativeSpecifier(specifier))
1128
+ return undefined;
1129
+ try {
1130
+ // `fromModule` is the cwd-RELATIVE path the walk supplies for an entry file, and ABSOLUTE for
1131
+ // every module reached through a hop (their ids are the resolved paths).
1132
+ const fromAbs = isAbsolute(fromModule) ? fromModule : join(root, fromModule);
1133
+ const resolved = realpathSync(Bun.resolveSync(specifier, dirname(fromAbs)));
1134
+ if (!resolved.startsWith(root + sep))
1135
+ return undefined;
1136
+ const rel = resolved.slice(root.length + 1);
1137
+ if (IGNORED_DIR.test(rel))
1138
+ return undefined;
1139
+ if (SQL_DECLARATION_FILE.test(resolved) || !SQL_SOURCE_FILE.test(resolved))
1140
+ return undefined;
1141
+ return { id: resolved, content: readFileSync(resolved, "utf8") };
1142
+ }
1143
+ catch {
1144
+ return undefined; // unresolvable, unreadable, or outside the project
1145
+ }
1146
+ });
1147
+ }
1148
+ // `client("` / `client('` / `client(\`` - a URL-first call WITHOUT the `<typeof app>` generic:
1149
+ // the compiler has nothing to derive types from, so the anti-drift guarantee silently vanishes.
1150
+ // `client<typeof app>("…")` has `<…>` between the name and `(` so it never matches; the published-
1151
+ // contract form `client(contract, url)` starts with an identifier, not a quote - also unmatched.
1152
+ const CLIENT_CALL = /(?<![.\w])client\s*\(/g;
1153
+ /** Scan one file's text for untyped `client("…")` calls. Pure + line-accurate. */
1154
+ export function scanUntypedClient(file, content) {
1155
+ const out = [];
1156
+ const lines = content.split("\n");
1157
+ const code = codePositionMask(content);
1158
+ CLIENT_CALL.lastIndex = 0;
1159
+ for (let m = CLIENT_CALL.exec(code); m !== null; m = CLIENT_CALL.exec(code)) {
1160
+ const argument = firstArgumentIndex(content, m.index + m[0].length);
1161
+ const quote = content[argument];
1162
+ if (quote !== "'" && quote !== '"' && quote !== "`")
1163
+ continue;
1164
+ const line = lineAt(content, m.index);
1165
+ out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() });
1166
+ }
1167
+ return out;
1168
+ }
1169
+ /**
1170
+ * Specifiers that no longer exist, and what replaced them.
1171
+ *
1172
+ * These are the breaks a typecheck does not catch and a test suite does not either. `@nifrajs/core/ws`
1173
+ * was a side-effect import removed in 2.0 in favour of `.use(websocket())`; a consuming package kept
1174
+ * the import and a 533-test suite stayed green while the app could not boot. `@nifrajs/budget` folded
1175
+ * into core with no npm deprecation, so `bun install` fails workspace-wide with a resolution error
1176
+ * that names neither the cause nor the replacement.
1177
+ *
1178
+ * A lint is the right shape for this: it runs before boot, names the file and line, and states the
1179
+ * replacement - which is what neither the resolver error nor the runtime failure does.
1180
+ */
1181
+ export const REMOVED_IMPORTS = [
1182
+ {
1183
+ specifier: "@nifrajs/budget",
1184
+ since: "2.0",
1185
+ 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',
1186
+ },
1187
+ {
1188
+ specifier: "@nifrajs/core/ws",
1189
+ since: "2.0",
1190
+ sideEffectOnly: true,
1191
+ replacement: 'a bare `import "@nifrajs/core/ws"` no longer installs the runtime - register it explicitly with `.use(websocket())`, importing `websocket` from the same module',
1192
+ },
1193
+ ];
1194
+ /** Whether the import statement at `index` is the bare side-effect form (`import "x"`), rather than
1195
+ * one with bindings (`import { y } from "x"`). */
1196
+ function isSideEffectImport(content, index) {
1197
+ // `staticImportEdges` reports the index of the STATEMENT, not of the specifier, so read forward:
1198
+ // a side-effect import is `import` followed directly by the quoted specifier, with no bindings and
1199
+ // no `from` in between.
1200
+ return /^import\s*["'`]/.test(content.slice(index, index + 32));
1201
+ }
1202
+ /** Flag an import of a specifier that no longer exists. Pure; matches the exact specifier or a subpath
1203
+ * of it, so `@nifrajs/budget/x` is caught alongside `@nifrajs/budget`. */
1204
+ export function scanRemovedImports(file, content) {
1205
+ const out = [];
1206
+ const lines = content.split("\n");
1207
+ for (const edge of staticImportEdges(content)) {
1208
+ const removed = REMOVED_IMPORTS.find((entry) => edge.specifier === entry.specifier || edge.specifier.startsWith(`${entry.specifier}/`));
1209
+ if (removed === undefined)
1210
+ continue;
1211
+ if (removed.sideEffectOnly === true && !isSideEffectImport(content, edge.index))
1212
+ continue;
1213
+ const line = lineAt(content, edge.index);
1214
+ out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() });
1215
+ }
1216
+ return out;
1217
+ }
1218
+ /** Scan a route module for top-level server-only imports. Returns `[]` for non-route files (only
1219
+ * `routes/` modules are browser-bundled, so a server-only import elsewhere is fine). Each finding carries
1220
+ * the offending `specifier` so the diagnostic can render the `routeFile → specifier` chain. Pure. */
1221
+ export function scanServerOnlyImports(file, content, facts) {
1222
+ if (!ROUTE_FILE.test(file))
1223
+ return [];
1224
+ const out = [];
1225
+ const lines = content.split("\n");
1226
+ const code = stripComments(content);
1227
+ const positions = codePositionMask(content);
1228
+ STATIC_IMPORT.lastIndex = 0;
1229
+ for (let m = STATIC_IMPORT.exec(code); m !== null; m = STATIC_IMPORT.exec(code)) {
1230
+ if (positions[m.index] === " ")
1231
+ continue;
1232
+ const specifier = m[1] ?? "";
1233
+ if (!SERVER_ONLY.test(specifier))
1234
+ continue;
1235
+ if (facts !== undefined) {
1236
+ const source = facts.parse(file, content);
1237
+ // Inline `import { type X } from "…"` is erased just like `import type`; don't call it a
1238
+ // runtime leak. A parse failure keeps the old lexical finding so the security rule fails closed.
1239
+ if (source !== undefined && facts.isValueImportAt(source, m.index, specifier) === false)
1240
+ continue;
1241
+ }
1242
+ const line = lineAt(content, m.index);
1243
+ out.push({ file, line, snippet: (lines[line - 1] ?? "").trim(), specifier });
1244
+ }
1245
+ return out;
1246
+ }
1247
+ // ---------------------------------------------------------------------------------------------------
1248
+ // #4.4 - TRANSITIVE import-chain resolution for `server-only-import`. `scanServerOnlyImports` above is a
1249
+ // pure per-file regex scan: it sees only the route's DIRECT `import` line, so it reports the direct edge
1250
+ // `routes/x → ../db`. The build leak-guard, which has the real module graph, reports the full transitive
1251
+ // `route → ../data → ../db → pg`. These helpers give `nifra check` the same depth via a BOUNDED
1252
+ // import-resolution walk: from a flagged route, resolve its local imports (`Bun.resolveSync`), BFS the
1253
+ // local module graph, and build the shortest chain to a server-only SINK. It's best-effort: an import
1254
+ // that can't be precisely resolved (a bare pkg, a tsconfig path alias) falls back to the direct edge.
1255
+ // ---------------------------------------------------------------------------------------------------
1256
+ // Server-only specifiers that are TERMINAL sinks - a bare `node:`/`bun:` builtin or a known server-only
1257
+ // npm package. These are never local source we can walk into, so the chain ends here. (Same vocabulary
1258
+ // as SERVER_ONLY, minus the relative `../db` arm - a relative `db` module IS local source we resolve.)
1259
+ 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/;
1260
+ // The `.server` convention: a module named `*.server.ts(x)` is server-only (the client build empties it).
1261
+ const SERVER_MODULE_FILE = /\.server(\.[cm]?[jt]sx?)?$/;
1262
+ // The explicit poison-import marker (`@nifrajs/web/server-only`) - a module opting into the client-leak
1263
+ // guard. A resolved file whose source carries this side-effect import is a server-only sink.
1264
+ const SERVER_ONLY_MARKER_IMPORT = /import\s+["']@nifrajs\/web\/server-only["']/;
1265
+ // Depth/visited caps keep the walk linear + cycle-safe. A route's server-only dependency sits within a
1266
+ // few hops in practice; the bound stops a pathological graph from blowing up the per-file scan.
1267
+ const TRANSITIVE_MAX_DEPTH = 8;
1268
+ const TRANSITIVE_MAX_VISITED = 200;
1269
+ /** A relative module specifier (`./x`, `../y`) - the only kind we resolve + walk into (a bare specifier
1270
+ * is either a sink we recognise by name or a third-party dep we don't follow into node_modules). */
1271
+ const isRelativeSpecifier = (spec) => spec.startsWith("./") || spec.startsWith("../");
1272
+ // A FRESH copy of the static-import regex per scan. The transitive walk is REENTRANT - the outer
1273
+ // per-route loop and the inner BFS both scan imports - and a single shared global-flag regex carries
1274
+ // `lastIndex` state, so reusing the module-level `STATIC_IMPORT` across nested calls corrupts the outer
1275
+ // loop's position (it restarts forever). A fresh instance per call keeps each scan's state private.
1276
+ const staticImportRegex = () => new RegExp(STATIC_IMPORT.source, STATIC_IMPORT.flags);
1277
+ /** Extract the static, non-type import specifiers from a module's source (the edges to follow/inspect),
1278
+ * each with the match index (for line attribution). Mirrors {@link STATIC_IMPORT}, so `import type` +
1279
+ * dynamic `import()` are already excluded. Uses a fresh regex instance, so it's safe under the reentrant
1280
+ * transitive walk. Pure. */
1281
+ function staticImportEdges(content, facts, file) {
1282
+ const edges = [];
1283
+ const re = staticImportRegex();
1284
+ const code = stripComments(content);
1285
+ const positions = codePositionMask(content);
1286
+ for (let m = re.exec(code); m !== null; m = re.exec(code)) {
1287
+ if (positions[m.index] === " ")
1288
+ continue;
1289
+ if (m[1] === undefined)
1290
+ continue;
1291
+ if (facts !== undefined && file !== undefined) {
1292
+ const source = facts.parse(file, content);
1293
+ if (source !== undefined && facts.isValueImportAt(source, m.index, m[1]) === false)
1294
+ continue;
1295
+ }
1296
+ edges.push({ specifier: m[1], index: m.index });
1297
+ }
1298
+ return edges;
1299
+ }
1300
+ /** Extract the static, non-type import specifiers from a module's source (the edges to follow/inspect).
1301
+ * Mirrors {@link STATIC_IMPORT}, so `import type` + dynamic `import()` are already excluded. Pure. */
1302
+ export function parseStaticImports(content, facts, file) {
1303
+ return staticImportEdges(content, facts, file).map((e) => e.specifier);
1304
+ }
1305
+ /** The server-only SINK an import specifier names directly (a `node:`/`bun:` builtin or a known
1306
+ * server-only package), or `undefined` if it isn't a by-name sink. Pure - the label is the specifier
1307
+ * itself (it's already the actionable name). */
1308
+ export function directSinkSpecifier(spec) {
1309
+ return SERVER_ONLY_SINK.test(spec) ? spec : undefined;
1310
+ }
1311
+ /**
1312
+ * BFS the LOCAL module graph from a route file for the SHORTEST import chain that reaches a server-only
1313
+ * sink, returning `[routeFile, …as-written specifiers…, sink]` or `undefined` if none is reachable. A
1314
+ * node's outgoing edges are its static imports; an edge is followed only when it's a RELATIVE specifier
1315
+ * that `resolve` maps to a readable local file (so the walk never descends into node_modules or chases an
1316
+ * unresolvable alias). At each node, a by-name sink import (`node:fs`, `postgres`) OR a resolved
1317
+ * `*.server` / `server-only`-marked dependency terminates the chain. Bounded by depth + a visited set, so
1318
+ * it's linear and cycle-free. `routeFile`/`routeContent` seed the walk; `resolve`/`read` supply the graph
1319
+ * - pure given those, so it's unit-testable with a fake graph.
1320
+ */
1321
+ export function walkServerOnlyChain(routeFile, routeContent, resolve, read, facts) {
1322
+ const seen = new Set([routeFile]);
1323
+ let frontier = [{ abs: routeFile, content: routeContent, chain: [routeFile], depth: 0 }];
1324
+ let visited = 0;
1325
+ while (frontier.length > 0) {
1326
+ const next = [];
1327
+ for (const node of frontier) {
1328
+ if (node.depth >= TRANSITIVE_MAX_DEPTH)
1329
+ continue;
1330
+ for (const spec of parseStaticImports(node.content, facts, node.abs)) {
1331
+ // (a) A by-name sink (builtin / known server-only pkg) → the chain ends here (shortest first,
1332
+ // since BFS reaches the nearest sink before any deeper one).
1333
+ const sink = directSinkSpecifier(spec);
1334
+ if (sink !== undefined)
1335
+ return [...node.chain, sink];
1336
+ // (b) Only relative specifiers are local source we resolve + walk into. A bare third-party
1337
+ // specifier that isn't a known sink is a leaf - don't follow it into node_modules.
1338
+ if (!isRelativeSpecifier(spec))
1339
+ continue;
1340
+ const abs = resolve(node.abs, spec);
1341
+ if (abs === undefined || seen.has(abs))
1342
+ continue;
1343
+ // (c) A resolved `*.server` module is a server-only sink by the `.server` convention - the chain
1344
+ // ends at it (named by the as-written specifier).
1345
+ if (SERVER_MODULE_FILE.test(abs))
1346
+ return [...node.chain, spec];
1347
+ const content = read(abs);
1348
+ if (content === undefined)
1349
+ continue; // unreadable → can't walk; treat as a leaf
1350
+ // (d) A resolved module that opts into the `server-only` marker is a sink too.
1351
+ if (SERVER_ONLY_MARKER_IMPORT.test(content))
1352
+ return [...node.chain, spec];
1353
+ if (visited >= TRANSITIVE_MAX_VISITED)
1354
+ continue;
1355
+ visited++;
1356
+ seen.add(abs);
1357
+ next.push({ abs, content, chain: [...node.chain, spec], depth: node.depth + 1 });
1358
+ }
1359
+ }
1360
+ frontier = next;
1361
+ }
1362
+ return undefined;
1363
+ }
1364
+ /**
1365
+ * Resolve the FULL transitive server-only chain(s) for a route module, given a real fs-backed resolver +
1366
+ * reader. For each of the route's top-level imports, if a precise transitive walk finds a sink, the
1367
+ * finding carries the full chain; otherwise - when the first hop is itself a direct by-name sink the
1368
+ * regex scan already flags, or when a relative import can't be resolved - it falls back to the DIRECT
1369
+ * edge (`fallback: true`), never a fabricated chain. Returns one finding per offending top-level import,
1370
+ * de-duplicated to the shortest chain per first-hop specifier. Non-route files yield `[]`.
1371
+ */
1372
+ export function resolveServerOnlyChains(file, content, resolve, read, facts) {
1373
+ if (!ROUTE_FILE.test(file))
1374
+ return [];
1375
+ const lines = content.split("\n");
1376
+ const out = [];
1377
+ const flaggedSpecifiers = new Set();
1378
+ // Collect the route's import edges up front (fresh-regex scan) so the per-edge logic below can call
1379
+ // the REENTRANT transitive walk without corrupting a shared regex's `lastIndex` (the walk also scans
1380
+ // imports). Driving `STATIC_IMPORT.exec` here directly would restart this loop forever.
1381
+ for (const { specifier, index } of staticImportEdges(content, facts, file)) {
1382
+ if (flaggedSpecifiers.has(specifier))
1383
+ continue;
1384
+ const line = lineAt(content, index);
1385
+ const snippet = (lines[line - 1] ?? "").trim();
1386
+ // (1) A direct by-name sink (`node:fs`, `postgres`) - the route imports it itself; chain is the
1387
+ // direct edge. (Length-2 chain == the regex scan's existing `[route, specifier]`.)
1388
+ if (directSinkSpecifier(specifier) !== undefined) {
1389
+ flaggedSpecifiers.add(specifier);
1390
+ out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: false });
1391
+ continue;
1392
+ }
1393
+ // (2) A relative local import - try the transitive walk from the resolved dependency. If it reaches
1394
+ // a sink, emit the full chain rooted at THIS route's import line.
1395
+ if (isRelativeSpecifier(specifier)) {
1396
+ const abs = resolve(file, specifier);
1397
+ if (abs === undefined) {
1398
+ // Unresolvable relative import. If it's the known server-only `../db` convention the regex scan
1399
+ // flags directly, fall back to the direct edge (precise resolve impossible - say so via
1400
+ // `fallback`). Any other unresolvable relative import we can't assert is server-only - skip it.
1401
+ if (SERVER_ONLY.test(specifier)) {
1402
+ flaggedSpecifiers.add(specifier);
1403
+ out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: true });
1404
+ }
1405
+ continue;
1406
+ }
1407
+ // The `.server` / marker sink can be the first hop itself.
1408
+ if (SERVER_MODULE_FILE.test(abs)) {
1409
+ flaggedSpecifiers.add(specifier);
1410
+ out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: false });
1411
+ continue;
1412
+ }
1413
+ const depContent = read(abs);
1414
+ if (depContent === undefined)
1415
+ continue;
1416
+ if (SERVER_ONLY_MARKER_IMPORT.test(depContent)) {
1417
+ flaggedSpecifiers.add(specifier);
1418
+ out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: false });
1419
+ continue;
1420
+ }
1421
+ // Walk INTO the dependency: build the chain `[depAbs, …]` then re-root it at the route's import.
1422
+ const subChain = walkServerOnlyChain(abs, depContent, resolve, read, facts);
1423
+ if (subChain !== undefined) {
1424
+ flaggedSpecifiers.add(specifier);
1425
+ // subChain is `[depAbs, …hops…, sink]`; replace its head (the resolved dep path) with the
1426
+ // route's as-written specifier so the chain reads `routes/x → ../data → ../db → node:crypto`.
1427
+ const chain = [file, specifier, ...subChain.slice(1)];
1428
+ out.push({ file, line, snippet, specifier, chain, fallback: false });
1429
+ continue;
1430
+ }
1431
+ // (3) The relative `../db`-style convention the regex scan flags directly, but where the walk
1432
+ // found no deeper sink (e.g. the file wasn't readable past the first hop) - fall back to the
1433
+ // direct edge so we still surface the known-server-only convention rather than going silent.
1434
+ if (SERVER_ONLY.test(specifier)) {
1435
+ flaggedSpecifiers.add(specifier);
1436
+ out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: true });
1437
+ }
1438
+ }
1439
+ }
1440
+ return out;
1441
+ }
1442
+ // A route handler returning a raw `Response` (`=> new Response(`, `return Response.json(`, …) makes the
1443
+ // typed client infer `data: never` (`Jsonify<Response>` is `never`) - so frontend/backend drift detection
1444
+ // silently vanishes for that route. Advisory, not a failure: a raw Response is sometimes intended
1445
+ // (redirects, files, streams). Fix is to return a plain object, or declare `{ response: t.… }` to type it.
1446
+ const RESPONSE_RETURN = /(?:=>\s*|return\s+)(?:new\s+Response|Response\s*\.\s*json)\s*\(/g;
1447
+ /** Scan a backend module (one that calls `server(`) for handlers returning a raw `Response`, which collapses
1448
+ * the typed client's `data` to `never`. Pure + line-accurate; returns `[]` for files with no `server(` call. */
1449
+ export function scanResponseRoutes(file, content, facts) {
1450
+ // Strip comments + template literals first: a commented-out or doc-example `return new Response(`
1451
+ // must not raise a spurious advisory. Lengths are preserved, so lineAt + the raw-line snippet align.
1452
+ const code = stripComments(content);
1453
+ if (!/(?<![.\w])server\s*\(/.test(code))
1454
+ return [];
1455
+ const out = [];
1456
+ const lines = content.split("\n");
1457
+ RESPONSE_RETURN.lastIndex = 0;
1458
+ for (let m = RESPONSE_RETURN.exec(code); m !== null; m = RESPONSE_RETURN.exec(code)) {
1459
+ if (facts !== undefined) {
1460
+ const source = facts.parse(file, content);
1461
+ // A parseable AST rejects `return new Response(` text that only lives in a normal string. On
1462
+ // malformed source we keep the lexical candidate; the typecheck gate will report the syntax error.
1463
+ if (source !== undefined && facts.isResponseSyntaxAt(source, m.index) !== true)
1464
+ continue;
1465
+ }
1466
+ const line = lineAt(content, m.index);
1467
+ // A `// nifra-expect raw-response` pragma marks an intentional raw Response (a file/redirect that can't
1468
+ // be a typed route), silencing this advisory. Honor it on the return line itself, OR on the line above
1469
+ // ONLY when that line is a standalone comment - so a TRAILING pragma on one route's return line can't
1470
+ // leak down and suppress the very next route's genuine advisory. `stripComments` blanks the pragma in
1471
+ // `code` (so it never affects the match); the raw `lines` still carry it.
1472
+ const thisLine = lines[line - 1] ?? "";
1473
+ const aboveLine = lines[line - 2] ?? "";
1474
+ const aboveSuppresses = aboveLine.trimStart().startsWith("//") && aboveLine.includes(RAW_RESPONSE_PRAGMA);
1475
+ if (thisLine.includes(RAW_RESPONSE_PRAGMA) || aboveSuppresses)
1476
+ continue;
1477
+ out.push({ file, line, snippet: thisLine.trim() });
1478
+ }
1479
+ return out;
1480
+ }
1481
+ /** The opt-out pragma that suppresses the {@link scanResponseRoutes} advisory for an intentional raw Response. */
1482
+ const RAW_RESPONSE_PRAGMA = "nifra-expect raw-response";
1483
+ /** Walk the project's `.ts`/`.tsx` source (skipping deps and build output), calling `visit` per file.
1484
+ * Exported so `nifra doctor` ({@link ./doctor.ts}) scans the same source surface as `nifra check`.
1485
+ *
1486
+ * `includeTests` widens the surface to `*.test.ts`/`*.spec.ts`, which the default excludes. It exists
1487
+ * for `nifra doctor`: `tsc` compiles tests, so a test importing a package no manifest declares is a
1488
+ * build that a clean install cannot produce - the exact false confidence doctor is for. */
1489
+ /**
1490
+ * Paths git considers ignored under `cwd`, in ONE batched `git check-ignore` call - so `.gitignore`
1491
+ * (root + nested + the global excludesfile) is honoured, not just the built-in {@link IGNORED} list. This
1492
+ * is what keeps the scan out of generated/build trees that live under a repo but aren't source: a monorepo
1493
+ * that gitignores, say, `builder/projects/` (238 generated apps) would otherwise be walked in full - 40k+
1494
+ * findings, a 50 MB+ result that overwhelms the caller. Returns an EMPTY set when there's no git / not a
1495
+ * repo / git errors, so the scan simply degrades to the {@link IGNORED} regex - never throws, never blocks.
1496
+ */
1497
+ async function gitIgnored(cwd, rels) {
1498
+ if (rels.length === 0)
1499
+ return new Set();
1500
+ try {
1501
+ const proc = Bun.spawn(["git", "check-ignore", "--stdin"], {
1502
+ cwd,
1503
+ stdin: "pipe",
1504
+ stdout: "pipe",
1505
+ stderr: "ignore",
1506
+ });
1507
+ proc.stdin.write(`${rels.join("\n")}\n`);
1508
+ await proc.stdin.end();
1509
+ // git echoes back each ignored path exactly as fed on stdin (one per line); exit 1 = none matched.
1510
+ const out = await new Response(proc.stdout).text();
1511
+ await proc.exited;
1512
+ return new Set(out
1513
+ .split("\n")
1514
+ .map((s) => s.trim())
1515
+ .filter(Boolean));
1516
+ }
1517
+ catch {
1518
+ return new Set();
1519
+ }
1520
+ }
1521
+ export async function walkSource(cwd, visit, opts = {}) {
1522
+ const skip = opts.includeTests === true ? IGNORED_DIR : IGNORED;
1523
+ // List candidates first (cheap - no reads), drop the built-in ignores, then exclude gitignored paths in
1524
+ // one batch before the (expensive) reads. So a gitignored generated/build tree is never read or scanned.
1525
+ const rels = [];
1526
+ for await (const rawRel of new Glob("**/*.{ts,tsx,mts,cts}").scan({ cwd, dot: false })) {
1527
+ const rel = normalizeProjectPath(rawRel);
1528
+ if (!skip.test(rel))
1529
+ rels.push(rel);
1530
+ }
1531
+ const ignored = await gitIgnored(cwd, rels);
1532
+ for (const rel of rels) {
1533
+ if (ignored.has(rel))
1534
+ continue;
1535
+ visit(rel, await Bun.file(join(cwd, rel)).text());
1536
+ }
1537
+ }
1538
+ const bySite = (a, b) => a.file.localeCompare(b.file) || a.line - b.line;
1539
+ /** Collect own-API `fetch()` findings across the project. */
1540
+ export async function scanProject(cwd) {
1541
+ const out = [];
1542
+ await walkSource(cwd, (rel, content) => out.push(...scanFetchText(rel, content)));
1543
+ return out.sort(bySite);
1544
+ }
1545
+ // #7 - server-manifest drift. `server-manifest.ts` is a COMMITTED generated file (it bakes the route
1546
+ // list + client-entry hash for a disk-less worker). If `routes/` changes but the manifest isn't
1547
+ // regenerated, the worker serves a stale route table - a silent edge break that no other check catches.
1548
+ // We diff each committed manifest's imported route files against the live `routes/` tree.
1549
+ // The marker comment `generateServerManifest` emits at the top of the file - identifies a generated
1550
+ // manifest unambiguously (so a user file merely named `server-manifest.ts` isn't mistaken for one).
1551
+ const GENERATED_MARKER = "GENERATED by @nifrajs/web generateServerManifest";
1552
+ // The first route-import specifier's prefix up to `routes/` (e.g. `./`, `../`, `./src/`) - used to
1553
+ // locate the routes dir relative to the manifest, and to strip to route-relative keys.
1554
+ const ROUTES_PREFIX = /["'](\.{1,2}(?:\/[^"'/]+)*?\/routes\/)[^"']+["']/;
1555
+ // Route file extensions discovery recognises (mirrors `@nifrajs/web/fs`'s filter).
1556
+ const ROUTE_FILE_EXT = /\.(tsx|jsx|svelte|vue|mdx)$/;
1557
+ /**
1558
+ * Scan the project for committed, generated `server-manifest.ts` files and report any that have drifted
1559
+ * from the live `routes/` tree. For each, the routes dir is derived from the manifest's own import
1560
+ * prefix (so a manifest that imports `../routes/x` is checked against the sibling `routes/`), its route
1561
+ * imports are parsed (`parseManifestRouteFiles`), and the set is diffed against the actual route files
1562
+ * on disk. A clean manifest yields no finding. Returns one finding per drifted manifest. Never throws on
1563
+ * a per-manifest miss (an unreadable routes dir is simply skipped).
1564
+ */
1565
+ export async function scanServerManifestDrift(cwd) {
1566
+ const { parseManifestRouteFiles, diffManifestRoutes, isManifestInSync } = await import("@nifrajs/web/build");
1567
+ const findings = [];
1568
+ for await (const rawRel of new Glob("**/server-manifest.ts").scan({ cwd, dot: false })) {
1569
+ const rel = normalizeProjectPath(rawRel);
1570
+ if (IGNORED.test(rel))
1571
+ continue;
1572
+ const source = await Bun.file(join(cwd, rel)).text();
1573
+ if (!source.includes(GENERATED_MARKER))
1574
+ continue; // not a generated manifest - skip
1575
+ const prefixMatch = ROUTES_PREFIX.exec(source);
1576
+ if (prefixMatch?.[1] === undefined)
1577
+ continue; // no `routes/` imports → nothing to diff (empty app)
1578
+ const routesPrefix = prefixMatch[1];
1579
+ const manifestFiles = parseManifestRouteFiles(source, routesPrefix);
1580
+ // The routes dir sits at `<manifest dir>/<prefix>` - resolve it relative to the manifest file.
1581
+ const manifestDir = rel.includes("/") ? rel.slice(0, rel.lastIndexOf("/")) : "";
1582
+ const routesDir = join(cwd, manifestDir, routesPrefix);
1583
+ let discovered;
1584
+ try {
1585
+ discovered = (await Array.fromAsync(new Glob("**/*.{tsx,jsx,svelte,vue,mdx}").scan({ cwd: routesDir })))
1586
+ .map((f) => f.replaceAll("\\", "/"))
1587
+ .filter((f) => ROUTE_FILE_EXT.test(f));
1588
+ }
1589
+ catch {
1590
+ continue; // routes dir gone/unreadable - not a drift we can assess
1591
+ }
1592
+ const drift = diffManifestRoutes(manifestFiles, discovered);
1593
+ if (!isManifestInSync(drift)) {
1594
+ findings.push({ file: rel, missing: drift.missing, extra: drift.extra });
1595
+ }
1596
+ }
1597
+ return findings.sort((a, b) => a.file.localeCompare(b.file));
1598
+ }
1599
+ //# sourceMappingURL=check-scan.js.map