@nifrajs/cli 4.0.0 → 5.0.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 (332) hide show
  1. package/README.md +3 -0
  2. package/dist/agent-files.d.ts +107 -0
  3. package/dist/agent-files.d.ts.map +1 -0
  4. package/dist/agent-files.js +162 -0
  5. package/dist/agent-files.js.map +1 -0
  6. package/dist/app-files.d.ts +6 -0
  7. package/dist/app-files.d.ts.map +1 -1
  8. package/dist/app-files.js +27 -2
  9. package/dist/app-files.js.map +1 -1
  10. package/dist/assure-hydration.d.ts.map +1 -1
  11. package/dist/assure-hydration.js +7 -2
  12. package/dist/assure-hydration.js.map +1 -1
  13. package/dist/capabilities-tool.d.ts.map +1 -1
  14. package/dist/capabilities-tool.js +13 -3
  15. package/dist/capabilities-tool.js.map +1 -1
  16. package/dist/check-diagnostics.d.ts +20 -2
  17. package/dist/check-diagnostics.d.ts.map +1 -1
  18. package/dist/check-diagnostics.js +54 -7
  19. package/dist/check-diagnostics.js.map +1 -1
  20. package/dist/check-scan.d.ts +75 -22
  21. package/dist/check-scan.d.ts.map +1 -1
  22. package/dist/check-scan.js +1475 -310
  23. package/dist/check-scan.js.map +1 -1
  24. package/dist/check.d.ts +2 -2
  25. package/dist/check.d.ts.map +1 -1
  26. package/dist/check.js +192 -65
  27. package/dist/check.js.map +1 -1
  28. package/dist/cli.d.ts +7 -0
  29. package/dist/cli.d.ts.map +1 -1
  30. package/dist/cli.js +112 -5
  31. package/dist/cli.js.map +1 -1
  32. package/dist/client-module.d.ts +18 -0
  33. package/dist/client-module.d.ts.map +1 -0
  34. package/dist/client-module.js +49 -0
  35. package/dist/client-module.js.map +1 -0
  36. package/dist/command-catalog.d.ts +21 -5
  37. package/dist/command-catalog.d.ts.map +1 -1
  38. package/dist/command-catalog.js +138 -38
  39. package/dist/command-catalog.js.map +1 -1
  40. package/dist/component-check.d.ts +13 -0
  41. package/dist/component-check.d.ts.map +1 -0
  42. package/dist/component-check.js +59 -0
  43. package/dist/component-check.js.map +1 -0
  44. package/dist/contract-proof.d.ts +1 -1
  45. package/dist/contract-proof.d.ts.map +1 -1
  46. package/dist/contract-proof.js +2 -24
  47. package/dist/contract-proof.js.map +1 -1
  48. package/dist/contracts.d.ts +19 -0
  49. package/dist/contracts.d.ts.map +1 -1
  50. package/dist/contracts.js +50 -3
  51. package/dist/contracts.js.map +1 -1
  52. package/dist/db-tool.d.ts.map +1 -1
  53. package/dist/db-tool.js +4 -3
  54. package/dist/db-tool.js.map +1 -1
  55. package/dist/dev-bun-config.d.ts +5 -1
  56. package/dist/dev-bun-config.d.ts.map +1 -1
  57. package/dist/dev-bun-config.js +44 -11
  58. package/dist/dev-bun-config.js.map +1 -1
  59. package/dist/diagnostics.d.ts +3 -0
  60. package/dist/diagnostics.d.ts.map +1 -1
  61. package/dist/diagnostics.js.map +1 -1
  62. package/dist/diff-tool.d.ts +9 -8
  63. package/dist/diff-tool.d.ts.map +1 -1
  64. package/dist/diff-tool.js +28 -24
  65. package/dist/diff-tool.js.map +1 -1
  66. package/dist/doctor.d.ts +13 -0
  67. package/dist/doctor.d.ts.map +1 -1
  68. package/dist/doctor.js +23 -15
  69. package/dist/doctor.js.map +1 -1
  70. package/dist/env-file.d.ts +6 -0
  71. package/dist/env-file.d.ts.map +1 -1
  72. package/dist/env-file.js +6 -0
  73. package/dist/env-file.js.map +1 -1
  74. package/dist/fix-recipes.d.ts.map +1 -1
  75. package/dist/fix-recipes.js +28 -3
  76. package/dist/fix-recipes.js.map +1 -1
  77. package/dist/hand-built-manifest.d.ts +27 -0
  78. package/dist/hand-built-manifest.d.ts.map +1 -0
  79. package/dist/hand-built-manifest.js +124 -0
  80. package/dist/hand-built-manifest.js.map +1 -0
  81. package/dist/hooks-report.d.ts +23 -0
  82. package/dist/hooks-report.d.ts.map +1 -0
  83. package/dist/hooks-report.js +104 -0
  84. package/dist/hooks-report.js.map +1 -0
  85. package/dist/init-agents.d.ts +2 -2
  86. package/dist/init-agents.d.ts.map +1 -1
  87. package/dist/init-agents.js +17 -11
  88. package/dist/init-agents.js.map +1 -1
  89. package/dist/internal/nifra-project.d.ts +41 -0
  90. package/dist/internal/nifra-project.d.ts.map +1 -0
  91. package/dist/internal/nifra-project.js +183 -0
  92. package/dist/internal/nifra-project.js.map +1 -0
  93. package/dist/internal/project-specifiers.d.ts +16 -0
  94. package/dist/internal/project-specifiers.d.ts.map +1 -0
  95. package/dist/internal/project-specifiers.js +396 -0
  96. package/dist/internal/project-specifiers.js.map +1 -0
  97. package/dist/internal/script-kind.d.ts.map +1 -1
  98. package/dist/internal/script-kind.js +2 -1
  99. package/dist/internal/script-kind.js.map +1 -1
  100. package/dist/internal/source-facts.d.ts +2 -0
  101. package/dist/internal/source-facts.d.ts.map +1 -1
  102. package/dist/internal/source-facts.js +11 -0
  103. package/dist/internal/source-facts.js.map +1 -1
  104. package/dist/internal/typescript-import.d.ts +1 -1
  105. package/dist/internal/typescript-import.d.ts.map +1 -1
  106. package/dist/internal/typescript-import.js +5 -3
  107. package/dist/internal/typescript-import.js.map +1 -1
  108. package/dist/introspect.d.ts +6 -6
  109. package/dist/introspect.d.ts.map +1 -1
  110. package/dist/introspect.js +55 -24
  111. package/dist/introspect.js.map +1 -1
  112. package/dist/levels-tool.d.ts.map +1 -1
  113. package/dist/levels-tool.js +93 -15
  114. package/dist/levels-tool.js.map +1 -1
  115. package/dist/load.d.ts +31 -8
  116. package/dist/load.d.ts.map +1 -1
  117. package/dist/load.js +37 -7
  118. package/dist/load.js.map +1 -1
  119. package/dist/mcp-context.d.ts +5 -5
  120. package/dist/mcp-context.d.ts.map +1 -1
  121. package/dist/mcp-context.js +2 -2
  122. package/dist/mcp-context.js.map +1 -1
  123. package/dist/mcp-exec.d.ts +12 -4
  124. package/dist/mcp-exec.d.ts.map +1 -1
  125. package/dist/mcp-exec.js +50 -13
  126. package/dist/mcp-exec.js.map +1 -1
  127. package/dist/mcp-http.js +1 -1
  128. package/dist/mcp-io.d.ts +17 -0
  129. package/dist/mcp-io.d.ts.map +1 -1
  130. package/dist/mcp-io.js +40 -0
  131. package/dist/mcp-io.js.map +1 -1
  132. package/dist/mcp-isolate.d.ts.map +1 -1
  133. package/dist/mcp-isolate.js +3 -2
  134. package/dist/mcp-isolate.js.map +1 -1
  135. package/dist/mcp-project-child.js +1 -1
  136. package/dist/mcp-project-child.js.map +1 -1
  137. package/dist/mcp-root.d.ts.map +1 -1
  138. package/dist/mcp-root.js +50 -10
  139. package/dist/mcp-root.js.map +1 -1
  140. package/dist/mcp-ws.d.ts.map +1 -1
  141. package/dist/mcp-ws.js +3 -1
  142. package/dist/mcp-ws.js.map +1 -1
  143. package/dist/mcp.d.ts.map +1 -1
  144. package/dist/mcp.js +8 -2
  145. package/dist/mcp.js.map +1 -1
  146. package/dist/migrate-layout.d.ts +7 -0
  147. package/dist/migrate-layout.d.ts.map +1 -1
  148. package/dist/migrate-layout.js +958 -71
  149. package/dist/migrate-layout.js.map +1 -1
  150. package/dist/output-schemas.d.ts +94 -0
  151. package/dist/output-schemas.d.ts.map +1 -0
  152. package/dist/output-schemas.js +864 -0
  153. package/dist/output-schemas.js.map +1 -0
  154. package/dist/port.d.ts.map +1 -1
  155. package/dist/port.js +3 -2
  156. package/dist/port.js.map +1 -1
  157. package/dist/project-facts.d.ts +15 -4
  158. package/dist/project-facts.d.ts.map +1 -1
  159. package/dist/project-facts.js +1 -0
  160. package/dist/project-facts.js.map +1 -1
  161. package/dist/recipes/4.0.0.d.ts.map +1 -1
  162. package/dist/recipes/4.0.0.js +2 -0
  163. package/dist/recipes/4.0.0.js.map +1 -1
  164. package/dist/recipes/5.0.0.d.ts +11 -0
  165. package/dist/recipes/5.0.0.d.ts.map +1 -0
  166. package/dist/recipes/5.0.0.js +34 -0
  167. package/dist/recipes/5.0.0.js.map +1 -0
  168. package/dist/recipes/index.d.ts +7 -0
  169. package/dist/recipes/index.d.ts.map +1 -1
  170. package/dist/recipes/index.js +2 -0
  171. package/dist/recipes/index.js.map +1 -1
  172. package/dist/replay.d.ts.map +1 -1
  173. package/dist/replay.js +12 -3
  174. package/dist/replay.js.map +1 -1
  175. package/dist/review.d.ts +3 -1
  176. package/dist/review.d.ts.map +1 -1
  177. package/dist/review.js +38 -7
  178. package/dist/review.js.map +1 -1
  179. package/dist/rules/codes.d.ts +10 -0
  180. package/dist/rules/codes.d.ts.map +1 -1
  181. package/dist/rules/codes.js +10 -0
  182. package/dist/rules/codes.js.map +1 -1
  183. package/dist/rules/comment-markers.d.ts +16 -7
  184. package/dist/rules/comment-markers.d.ts.map +1 -1
  185. package/dist/rules/comment-markers.js +58 -19
  186. package/dist/rules/comment-markers.js.map +1 -1
  187. package/dist/rules/contract-visibility.d.ts +3 -0
  188. package/dist/rules/contract-visibility.d.ts.map +1 -0
  189. package/dist/rules/contract-visibility.js +337 -0
  190. package/dist/rules/contract-visibility.js.map +1 -0
  191. package/dist/rules/data-guard.d.ts +7 -0
  192. package/dist/rules/data-guard.d.ts.map +1 -1
  193. package/dist/rules/data-guard.js +319 -37
  194. package/dist/rules/data-guard.js.map +1 -1
  195. package/dist/rules/layout.d.ts +4 -0
  196. package/dist/rules/layout.d.ts.map +1 -0
  197. package/dist/rules/layout.js +19 -0
  198. package/dist/rules/layout.js.map +1 -0
  199. package/dist/rules/legacy.d.ts.map +1 -1
  200. package/dist/rules/legacy.js +158 -43
  201. package/dist/rules/legacy.js.map +1 -1
  202. package/dist/rules/pages.d.ts +24 -0
  203. package/dist/rules/pages.d.ts.map +1 -1
  204. package/dist/rules/pages.js +221 -3
  205. package/dist/rules/pages.js.map +1 -1
  206. package/dist/rules/routes.d.ts +6 -0
  207. package/dist/rules/routes.d.ts.map +1 -1
  208. package/dist/rules/routes.js +111 -16
  209. package/dist/rules/routes.js.map +1 -1
  210. package/dist/rules/secrets.js +2 -2
  211. package/dist/rules/secrets.js.map +1 -1
  212. package/dist/rules/security.d.ts +12 -1
  213. package/dist/rules/security.d.ts.map +1 -1
  214. package/dist/rules/security.js +281 -68
  215. package/dist/rules/security.js.map +1 -1
  216. package/dist/rules/zones.d.ts +13 -0
  217. package/dist/rules/zones.d.ts.map +1 -1
  218. package/dist/rules/zones.js +192 -40
  219. package/dist/rules/zones.js.map +1 -1
  220. package/dist/scaffold.d.ts.map +1 -1
  221. package/dist/scaffold.js +6 -4
  222. package/dist/scaffold.js.map +1 -1
  223. package/dist/sdk.d.ts.map +1 -1
  224. package/dist/sdk.js +14 -5
  225. package/dist/sdk.js.map +1 -1
  226. package/dist/stylex-migrate.d.ts.map +1 -1
  227. package/dist/stylex-migrate.js +163 -7
  228. package/dist/stylex-migrate.js.map +1 -1
  229. package/dist/sync-manifest.d.ts +10 -2
  230. package/dist/sync-manifest.d.ts.map +1 -1
  231. package/dist/sync-manifest.js +42 -18
  232. package/dist/sync-manifest.js.map +1 -1
  233. package/dist/test-tool.d.ts.map +1 -1
  234. package/dist/test-tool.js +12 -3
  235. package/dist/test-tool.js.map +1 -1
  236. package/dist/typecheck-coverage.d.ts +20 -0
  237. package/dist/typecheck-coverage.d.ts.map +1 -0
  238. package/dist/typecheck-coverage.js +129 -0
  239. package/dist/typecheck-coverage.js.map +1 -0
  240. package/dist/upgrade.d.ts +69 -0
  241. package/dist/upgrade.d.ts.map +1 -1
  242. package/dist/upgrade.js +420 -77
  243. package/dist/upgrade.js.map +1 -1
  244. package/dist/verification-plan.d.ts.map +1 -1
  245. package/dist/verification-plan.js +11 -0
  246. package/dist/verification-plan.js.map +1 -1
  247. package/dist/version-drift.d.ts +22 -0
  248. package/dist/version-drift.d.ts.map +1 -0
  249. package/dist/version-drift.js +42 -0
  250. package/dist/version-drift.js.map +1 -0
  251. package/dist/web-app-options.d.ts +1 -1
  252. package/dist/web-app-options.d.ts.map +1 -1
  253. package/dist/web-app-options.js +1 -1
  254. package/dist/web-app-options.js.map +1 -1
  255. package/dist/work-graph.d.ts.map +1 -1
  256. package/dist/work-graph.js +20 -8
  257. package/dist/work-graph.js.map +1 -1
  258. package/docs/examples.json +54 -6
  259. package/docs/llms-full.txt +1539 -138
  260. package/docs/types.json +1456 -206
  261. package/package.json +11 -12
  262. package/src/agent-files.ts +203 -0
  263. package/src/app-files.ts +34 -4
  264. package/src/assure-hydration.ts +15 -2
  265. package/src/capabilities-tool.ts +15 -3
  266. package/src/check-diagnostics.ts +73 -9
  267. package/src/check-scan.ts +1773 -381
  268. package/src/check.ts +237 -76
  269. package/src/cli.ts +141 -5
  270. package/src/client-module.ts +61 -0
  271. package/src/command-catalog.ts +181 -47
  272. package/src/component-check.ts +79 -0
  273. package/src/contract-proof.ts +3 -25
  274. package/src/contracts.ts +60 -3
  275. package/src/db-tool.ts +5 -2
  276. package/src/dev-bun-config.ts +51 -10
  277. package/src/diagnostics.ts +3 -0
  278. package/src/diff-tool.ts +33 -26
  279. package/src/doctor.ts +43 -21
  280. package/src/env-file.ts +7 -0
  281. package/src/fix-recipes.ts +34 -3
  282. package/src/hand-built-manifest.ts +144 -0
  283. package/src/hooks-report.ts +135 -0
  284. package/src/init-agents.ts +17 -11
  285. package/src/internal/nifra-project.ts +191 -0
  286. package/src/internal/project-specifiers.ts +424 -0
  287. package/src/internal/script-kind.ts +2 -1
  288. package/src/internal/source-facts.ts +25 -0
  289. package/src/internal/typescript-import.ts +10 -3
  290. package/src/introspect.ts +65 -27
  291. package/src/levels-tool.ts +117 -20
  292. package/src/load.ts +78 -14
  293. package/src/mcp-context.ts +7 -7
  294. package/src/mcp-exec.ts +60 -17
  295. package/src/mcp-http.ts +1 -1
  296. package/src/mcp-io.ts +41 -0
  297. package/src/mcp-isolate.ts +9 -2
  298. package/src/mcp-project-child.ts +1 -1
  299. package/src/mcp-root.ts +58 -8
  300. package/src/mcp-ws.ts +4 -2
  301. package/src/mcp.ts +8 -3
  302. package/src/migrate-layout.ts +1109 -70
  303. package/src/output-schemas.ts +1084 -0
  304. package/src/port.ts +3 -2
  305. package/src/project-facts.ts +17 -3
  306. package/src/recipes/4.0.0.ts +2 -0
  307. package/src/recipes/5.0.0.ts +35 -0
  308. package/src/recipes/index.ts +10 -0
  309. package/src/replay.ts +14 -3
  310. package/src/review.ts +39 -10
  311. package/src/rules/codes.ts +10 -0
  312. package/src/rules/comment-markers.ts +57 -21
  313. package/src/rules/contract-visibility.ts +383 -0
  314. package/src/rules/data-guard.ts +351 -42
  315. package/src/rules/layout.ts +22 -0
  316. package/src/rules/legacy.ts +184 -51
  317. package/src/rules/pages.ts +238 -3
  318. package/src/rules/routes.ts +124 -16
  319. package/src/rules/secrets.ts +2 -2
  320. package/src/rules/security.ts +352 -71
  321. package/src/rules/zones.ts +205 -38
  322. package/src/scaffold.ts +6 -4
  323. package/src/sdk.ts +14 -5
  324. package/src/stylex-migrate.ts +134 -7
  325. package/src/sync-manifest.ts +48 -18
  326. package/src/test-tool.ts +18 -3
  327. package/src/typecheck-coverage.ts +134 -0
  328. package/src/upgrade.ts +487 -71
  329. package/src/verification-plan.ts +24 -0
  330. package/src/version-drift.ts +67 -0
  331. package/src/web-app-options.ts +2 -2
  332. package/src/work-graph.ts +17 -12
@@ -5,12 +5,21 @@
5
5
  * source text plus injected module/type resolvers, which keeps the scanner seam independently testable.
6
6
  */
7
7
  import { readFileSync, realpathSync } from "node:fs";
8
- import { dirname, isAbsolute, join, resolve, sep } from "node:path";
8
+ import { dirname, isAbsolute, join, posix, resolve, sep } from "node:path";
9
+ import { isServerPackageSpecifier } from "@nifrajs/web/zones";
9
10
  import { Glob } from "bun";
10
11
  import { codeUnitOrder } from "./internal/code-unit-order.js";
11
12
  import { scriptKindOf } from "./internal/script-kind.js";
12
13
  import { loadProjectTypeScript, } from "./internal/typescript-import.js";
13
- import { commentBlockMarkerReason } from "./rules/comment-markers.js";
14
+ import { commentBlockExpectation } from "./rules/comment-markers.js";
15
+ /** What marks a hand-rolled own-API call as intended: a streamed POST, a foreign handler's endpoint,
16
+ * a static file. Only the code form; a reason after the colon is required. */
17
+ const TYPED_CLIENT_EXPECTATION = ["NF-C002"];
18
+ function typedClientFinding(file, lines, line) {
19
+ const finding = { file, line, snippet: (lines[line - 1] ?? "").trim() };
20
+ const expected = commentBlockExpectation(lines, line, TYPED_CLIENT_EXPECTATION);
21
+ return expected === undefined ? finding : { ...finding, expected };
22
+ }
14
23
  // `fetch( <ws> ('|"|`) / (not /)` - a string/template arg starting with a single `/` is a relative URL,
15
24
  // i.e. same-origin = this app's own API. `(?<![.\w])` skips `.fetch(` (a method) and `prefetch(`; the
16
25
  // `(?!\/)` skips protocol-relative `//host` (an external origin). A variable arg (`fetch(url)`) is left
@@ -18,6 +27,7 @@ import { commentBlockMarkerReason } from "./rules/comment-markers.js";
18
27
  const GLOBAL_FETCH_CALL = /(?<![.\w])fetch\s*\(/g;
19
28
  const FETCH_CALL = /(?<![.\w])fetch\s*\(/g;
20
29
  const HTTP_VERBS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]);
30
+ const SOCKET_VERB = new Set(["WS"]);
21
31
  export const SIMPLE_REWRITE_METHODS = new Set(["GET", "DELETE", "HEAD", "OPTIONS"]);
22
32
  // Don't scan deps, build output, or generated client entries. `dist(-<runtime>)?` also covers
23
33
  // per-runtime output dirs (dist-bun/dist-node/dist-deno/dist-vercel). Never source, for any scan.
@@ -56,9 +66,52 @@ function isBackendModule(file) {
56
66
  LEGACY_SERVER_FILE.test(path) ||
57
67
  (BACKEND_FOLDER.test(path) && !BROWSER_SUFFIX.test(path)));
58
68
  }
59
- // Module specifiers that must never be VALUE-imported into a route module: node:/bun: builtins, common
60
- // DB drivers/ORМ server entrypoints, and the conventional `./db` module the scaffold generates.
61
- 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?)?$/;
69
+ // Node builtins that have no browser counterpart, imported bare. Bare `events`, `buffer`, `path`, `util`
70
+ // and the like stay out: each also names an npm polyfill a browser bundle may legitimately carry.
71
+ const SERVER_BUILTINS = new Set([
72
+ "async_hooks",
73
+ "child_process",
74
+ "cluster",
75
+ "crypto",
76
+ "dgram",
77
+ "diagnostics_channel",
78
+ "dns",
79
+ "dns/promises",
80
+ "fs",
81
+ "fs/promises",
82
+ "http",
83
+ "http2",
84
+ "https",
85
+ "inspector",
86
+ "module",
87
+ "net",
88
+ "os",
89
+ "perf_hooks",
90
+ "readline",
91
+ "readline/promises",
92
+ "repl",
93
+ "tls",
94
+ "trace_events",
95
+ "tty",
96
+ "v8",
97
+ "vm",
98
+ "wasi",
99
+ "worker_threads",
100
+ ]);
101
+ // The conventional `./db` module the scaffold generates.
102
+ const DB_MODULE = /^(?:\.\.?\/)+db(?:\.[cm]?[jt]sx?)?$/;
103
+ /**
104
+ * Whether a specifier names server-only code by name, so the chain ends there: a `node:`/`bun:`
105
+ * module, a bare builtin with no browser counterpart, or a package the browser build refuses
106
+ * (`SERVER_PACKAGES` and the driver entries of `@nifrajs/web/zones`).
107
+ */
108
+ function isServerOnlySink(specifier) {
109
+ return (/^(?:node:|bun:)/.test(specifier) ||
110
+ SERVER_BUILTINS.has(specifier) ||
111
+ isServerPackageSpecifier(specifier));
112
+ }
113
+ /** A specifier no route module may value-import: a {@link isServerOnlySink} name, or the `./db` module. */
114
+ const isServerOnly = (specifier) => isServerOnlySink(specifier) || DB_MODULE.test(specifier);
62
115
  // A static, non-type import with a string specifier, or a re-export (`export … from "x"`), which
63
116
  // pulls its module into the bundle the same way. `import type …` and `export type …` are erased at
64
117
  // build, so they're safe and skipped. Dynamic `import(…)` has `(` right after `import`, so `import\s+`
@@ -74,6 +127,10 @@ const DYNAMIC_IMPORT = /(?<![\w$.])import\s*\(\s*(["'`])([^"'`$\\\r\n]+)\1\s*[,)
74
127
  // Without a parser: `typeof import("x")` and `import("x").Name` are type positions, erased at build.
75
128
  // A runtime `import("x")` is a promise, so the only members read straight off it are its methods.
76
129
  const TYPE_QUERY_BEFORE = /\btypeof\s*$/;
130
+ // `require("x")` and `import x = require("x")` pull a module into a bundle as `import` does; the
131
+ // `import type x = require("x")` form is erased.
132
+ const REQUIRE_CALL = /(?<![\w$.])require\s*\(\s*(["'])([^"'\\\r\n]+)\1\s*\)/g;
133
+ const TYPE_ALIAS_BEFORE = /\bimport\s+type\s+[\w$]+\s*=\s*$/;
77
134
  const TYPE_QUALIFIER_AFTER = /^\s*\.\s*(?!(?:then|catch|finally)\b)[A-Za-z_$]/;
78
135
  const ROUTE_REGISTRATION_DQ = /\.([A-Za-z]+)\s*\(\s*"((?:\\.|[^"\\])*)"/g;
79
136
  const ROUTE_REGISTRATION_SQ = /\.([A-Za-z]+)\s*\(\s*'((?:\\.|[^'\\])*)'/g;
@@ -85,6 +142,10 @@ const ALL_ROUTES = /(?<![\w$.])all\s*\(\s*(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\]
85
142
  const METHOD_ROUTES = /(?<![\w$.])method\s*\(\s*(\[[^\]]*\]|"[^"\\]*"|'[^'\\]*')\s*,\s*(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)')/g;
86
143
  const METHOD_NAME_LITERAL = /(["'])([A-Za-z][A-Za-z0-9-]{0,31})\1/g;
87
144
  export const IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
145
+ // `.get(NAME, …)` or `` .get(`…`, …) ``: a path that is not a quoted literal. The comma is required:
146
+ // a registration always has a handler after its path, while `map.get(key)` has nothing.
147
+ const ROUTE_ARGUMENT = /\.([A-Za-z]+)\s*\(\s*([A-Za-z_$][\w$]*|`(?:\\.|[^`\\])*`)\s*,/g;
148
+ const NAMED_IMPORTS = /(?<![\w$.])import\s*\{([^}]*)\}\s*from\s*(["'])([^"'\r\n]+)\2/g;
88
149
  /** Line number (1-based) of a match index within `content`. */
89
150
  const lineAt = (content, index) => content.slice(0, index).split("\n").length;
90
151
  function parseQuotedLiteral(raw) {
@@ -239,13 +300,13 @@ function isPotentialBackendSource(code) {
239
300
  /(?:from\s*|import\s*)["']hono["']/.test(code) ||
240
301
  /new\s+Hono(?:<[^>]+>)?\s*\(/.test(code));
241
302
  }
242
- function scanRoutePattern(file, content, code, pattern, facts) {
303
+ function scanRoutePattern(file, content, code, pattern, facts, verbs = HTTP_VERBS) {
243
304
  const out = [];
244
305
  const lines = content.split("\n");
245
306
  pattern.lastIndex = 0;
246
307
  for (let m = pattern.exec(code); m !== null; m = pattern.exec(code)) {
247
308
  const method = (m[1] ?? "").toUpperCase();
248
- if (!HTTP_VERBS.has(method))
309
+ if (!verbs.has(method))
249
310
  continue;
250
311
  const path = parseQuotedLiteral(`${pattern === ROUTE_REGISTRATION_DQ ? '"' : "'"}${m[2] ?? ""}${pattern === ROUTE_REGISTRATION_DQ ? '"' : "'"}`);
251
312
  if (path === undefined || !path.startsWith("/") || path.startsWith("//"))
@@ -263,27 +324,100 @@ function scanRoutePattern(file, content, code, pattern, facts) {
263
324
  }
264
325
  return out;
265
326
  }
327
+ // Punctuators after which a `/` opens a regex literal rather than dividing. A `)` `]` or `}` is read
328
+ // as the end of an operand, so `/` after it divides - the rare statement-leading regex after a block
329
+ // is the cost of not tracking parser context.
330
+ const REGEX_AFTER = new Set([
331
+ "(",
332
+ ",",
333
+ "=",
334
+ ":",
335
+ "[",
336
+ "!",
337
+ "&",
338
+ "|",
339
+ "?",
340
+ "{",
341
+ ";",
342
+ "+",
343
+ "-",
344
+ "*",
345
+ "%",
346
+ "<",
347
+ ">",
348
+ "~",
349
+ "^",
350
+ ]);
351
+ const REGEX_AFTER_WORD = new Set([
352
+ "return",
353
+ "typeof",
354
+ "case",
355
+ "do",
356
+ "else",
357
+ "in",
358
+ "of",
359
+ "new",
360
+ "delete",
361
+ "void",
362
+ "throw",
363
+ "yield",
364
+ "await",
365
+ "instanceof",
366
+ ]);
367
+ const isWordChar = (c) => c !== undefined && /[\w$]/.test(c);
368
+ /** Where a regex literal opened by the `/` at `i` ends (after its flags), or `undefined` when the
369
+ * literal does not close on its line - then the slash was division after all. */
370
+ function regexLiteralEnd(src, i) {
371
+ const n = src.length;
372
+ let j = i + 1;
373
+ let inClass = false;
374
+ while (j < n) {
375
+ const c = src[j];
376
+ if (c === "\n" || c === "\r")
377
+ return undefined;
378
+ if (c === "\\") {
379
+ j += 2;
380
+ continue;
381
+ }
382
+ if (inClass) {
383
+ if (c === "]")
384
+ inClass = false;
385
+ }
386
+ else if (c === "[") {
387
+ inClass = true;
388
+ }
389
+ else if (c === "/") {
390
+ break;
391
+ }
392
+ j++;
393
+ }
394
+ if (j >= n)
395
+ return undefined;
396
+ j++;
397
+ while (j < n && isWordChar(src[j]))
398
+ j++;
399
+ return j;
400
+ }
266
401
  /**
267
- * Blank, with spaces (newlines preserved, so every byte offset - and line number - is unchanged):
268
- * - `//` line comments and block comments;
269
- * - the CONTENTS of backtick template literals - code-as-text (doc `CodeBlock` examples, code
270
- * generators), never a real statement to lint.
271
- * Single/double-quoted strings are KEPT (a real import/Response specifier lives in one). A small
272
- * char-state machine, not a full lexer: it doesn't model regex literals, so a regex containing a quote
273
- * could mis-skip - in practice the constructs these scanners look for sit before any such regex.
274
- * Shared by the source scanners here and by `nifra doctor` ({@link ./doctor.ts}).
402
+ * Blank comments, template-literal contents and regex-literal contents (and, with `quoted: "blank"`,
403
+ * single/double-quoted strings and every delimiter) while preserving every offset and newline.
404
+ * A small lexer, not a parser: a `/` opens a regex where an operand may start - after a punctuator,
405
+ * an operator keyword, or at the start of the file - so a quote inside a regex no longer derails
406
+ * the string state that follows.
275
407
  */
276
- export function stripComments(src) {
408
+ function maskSource(src, quoted) {
277
409
  const out = src.split("");
278
410
  const n = src.length;
279
- let i = 0;
280
411
  const blank = (a, b) => {
281
412
  for (let k = a; k < b; k++)
282
413
  if (out[k] !== "\n")
283
414
  out[k] = " ";
284
415
  };
416
+ // The last significant code token: a punctuator character, or a whole identifier/keyword.
417
+ let previous = "";
418
+ let i = 0;
285
419
  while (i < n) {
286
- const c = src[i];
420
+ const c = src.charAt(i);
287
421
  const d = src[i + 1];
288
422
  if (c === "/" && d === "/") {
289
423
  let j = i + 2;
@@ -307,8 +441,12 @@ export function stripComments(src) {
307
441
  j++;
308
442
  j++;
309
443
  }
310
- blank(i + 1, j); // keep the backticks, blank the code-as-text inside
444
+ if (quoted === "keep")
445
+ blank(i + 1, j); // keep the backticks, blank the code-as-text inside
446
+ else
447
+ blank(i, Math.min(j + 1, n));
311
448
  i = j + 1;
449
+ previous = "`";
312
450
  }
313
451
  else if (c === "'" || c === '"') {
314
452
  let j = i + 1;
@@ -317,63 +455,61 @@ export function stripComments(src) {
317
455
  j++; // skip the escaped char
318
456
  j++;
319
457
  }
458
+ if (quoted === "blank")
459
+ blank(i, Math.min(j + 1, n));
320
460
  i = j + 1;
461
+ previous = c;
321
462
  }
322
- else {
323
- i++;
324
- }
325
- }
326
- return out.join("");
327
- }
328
- /** Blank comments and every quoted/template literal while preserving offsets. Unlike
329
- * {@link stripComments}, this is a code-position mask: scanners use it to find a call expression in
330
- * executable code, then inspect the original source for the call's literal argument. That prevents
331
- * documentation strings such as `const example = 'client("/")'` from becoming diagnostics. */
332
- export function codePositionMask(src) {
333
- const out = src.split("");
334
- const n = src.length;
335
- let i = 0;
336
- const blank = (a, b) => {
337
- for (let k = a; k < b; k++)
338
- if (out[k] !== "\n")
339
- out[k] = " ";
340
- };
341
- while (i < n) {
342
- const c = src[i];
343
- const d = src[i + 1];
344
- if (c === "/" && d === "/") {
345
- let j = i + 2;
346
- while (j < n && src[j] !== "\n")
347
- j++;
348
- blank(i, j);
349
- i = j;
350
- }
351
- else if (c === "/" && d === "*") {
352
- let j = i + 2;
353
- while (j < n && !(src[j] === "*" && src[j + 1] === "/"))
354
- j++;
355
- j = Math.min(j + 2, n);
356
- blank(i, j);
357
- i = j;
463
+ else if (c === "/" &&
464
+ (previous === "" || REGEX_AFTER.has(previous) || REGEX_AFTER_WORD.has(previous))) {
465
+ const end = regexLiteralEnd(src, i);
466
+ if (end === undefined) {
467
+ previous = c;
468
+ i++;
469
+ }
470
+ else {
471
+ if (quoted === "keep")
472
+ blank(i + 1, end);
473
+ else
474
+ blank(i, end);
475
+ previous = "/regex";
476
+ i = end;
477
+ }
358
478
  }
359
- else if (c === "'" || c === '"' || c === "`") {
360
- const quote = c;
479
+ else if (isWordChar(c)) {
361
480
  let j = i + 1;
362
- while (j < n && src[j] !== quote) {
363
- if (src[j] === "\\")
364
- j++;
481
+ while (j < n && isWordChar(src[j]))
365
482
  j++;
366
- }
367
- j = Math.min(j + 1, n);
368
- blank(i, j);
483
+ previous = src.slice(i, j);
369
484
  i = j;
370
485
  }
371
486
  else {
487
+ if (!/\s/.test(c))
488
+ previous = c;
372
489
  i++;
373
490
  }
374
491
  }
375
492
  return out.join("");
376
493
  }
494
+ /**
495
+ * Blank, with spaces (newlines preserved, so every byte offset - and line number - is unchanged):
496
+ * - `//` line comments and block comments;
497
+ * - the CONTENTS of backtick template literals - code-as-text (doc `CodeBlock` examples, code
498
+ * generators), never a real statement to lint.
499
+ * Single/double-quoted strings are KEPT (a real import/Response specifier lives in one); a regex
500
+ * literal keeps its slashes and loses its contents, so a quote inside one cannot open a string.
501
+ * Shared by the source scanners here and by `nifra doctor` ({@link ./doctor.ts}).
502
+ */
503
+ export function stripComments(src) {
504
+ return maskSource(src, "keep");
505
+ }
506
+ /** Blank comments and every quoted/template/regex literal while preserving offsets. Unlike
507
+ * {@link stripComments}, this is a code-position mask: scanners use it to find a call expression in
508
+ * executable code, then inspect the original source for the call's literal argument. That prevents
509
+ * documentation strings such as `const example = 'client("/")'` from becoming diagnostics. */
510
+ export function codePositionMask(src) {
511
+ return maskSource(src, "blank");
512
+ }
377
513
  const firstArgumentIndex = (content, callEnd) => {
378
514
  let index = callEnd;
379
515
  while (/\s/.test(content[index] ?? ""))
@@ -499,8 +635,7 @@ export function scanFetchText(file, content, externalMounts = []) {
499
635
  continue;
500
636
  if (externalMounts.length > 0 && matchesExternalMount(content, argument + 1, externalMounts))
501
637
  continue;
502
- const line = lineAt(content, m.index);
503
- out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() });
638
+ out.push(typedClientFinding(file, lines, lineAt(content, m.index)));
504
639
  }
505
640
  return out;
506
641
  }
@@ -531,13 +666,14 @@ export function scanStreamText(file, content, externalMounts = []) {
531
666
  continue;
532
667
  if (externalMounts.length > 0 && matchesExternalMount(content, argument + 1, externalMounts))
533
668
  continue;
534
- const line = lineAt(content, m.index);
535
- out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() });
669
+ out.push(typedClientFinding(file, lines, lineAt(content, m.index)));
536
670
  }
537
671
  return out;
538
672
  }
539
673
  /** Statically collect simple Nifra route registrations from source, without importing app code. */
540
- export function scanStaticRouteText(file, content, facts) {
674
+ export function scanStaticRouteText(file, content, facts,
675
+ /** Project files by forward-slash relative path, to resolve a path constant imported from one. */
676
+ project) {
541
677
  const code = stripComments(content);
542
678
  if (!isPotentialBackendSource(code))
543
679
  return [];
@@ -545,6 +681,180 @@ export function scanStaticRouteText(file, content, facts) {
545
681
  ...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_DQ, facts),
546
682
  ...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_SQ, facts),
547
683
  ...(code.includes(METHODS_SUBPATH) ? scanMethodRoutes(file, content, code, facts) : []),
684
+ ...scanRouteArguments(file, content, facts, project).routes,
685
+ ].sort(bySite);
686
+ }
687
+ /** The end of the quoted literal opening `text`, or -1 when it does not close on this line. */
688
+ function literalEnd(text) {
689
+ const quote = text[0];
690
+ for (let i = 1; i < text.length; i++) {
691
+ if (text[i] === "\\")
692
+ i++;
693
+ else if (text[i] === quote)
694
+ return i + 1;
695
+ }
696
+ return -1;
697
+ }
698
+ /** The index after the whitespace run at `from`. */
699
+ function skipSpace(text, from) {
700
+ let i = from;
701
+ while (i < text.length && (text[i] === " " || text[i] === "\t"))
702
+ i++;
703
+ return i;
704
+ }
705
+ /**
706
+ * Read `[export ]const NAME[: T] = <init>` written at column 0, so a function-local `const`, which is
707
+ * indented, never stands in for a module-level one. The shape is read from the comment-stripped
708
+ * line; the literal itself from the source line, since stripping blanks template contents.
709
+ */
710
+ function moduleConstantOn(code, source) {
711
+ const exported = code.startsWith("export ");
712
+ let i = exported ? skipSpace(code, 7) : 0;
713
+ if (!code.startsWith("const ", i))
714
+ return undefined;
715
+ i = skipSpace(code, i + 6);
716
+ const name = /^[A-Za-z_$][\w$]*/.exec(code.slice(i))?.[0];
717
+ if (name === undefined)
718
+ return undefined;
719
+ i = skipSpace(code, i + name.length);
720
+ if (code[i] === ":")
721
+ i = code.indexOf("=", i);
722
+ if (i === -1 || code[i] !== "=" || code[i + 1] === "=")
723
+ return undefined;
724
+ i = skipSpace(code, i + 1);
725
+ const opener = code[i];
726
+ const end = opener === '"' || opener === "'" || opener === "`" ? literalEnd(source.slice(i)) : -1;
727
+ const tail = end === -1 ? undefined : code.slice(i + end).trim();
728
+ const literal = source.slice(i, i + end);
729
+ const value = tail === undefined || !["", ";", "as const", "as const;"].includes(tail)
730
+ ? undefined
731
+ : opener === "`"
732
+ ? /[$\\]/.test(literal)
733
+ ? undefined
734
+ : literal.slice(1, -1)
735
+ : parseQuotedLiteral(literal);
736
+ return [name, { exported, value }];
737
+ }
738
+ function moduleConstants(code, source) {
739
+ const constants = new Map();
740
+ const sourceLines = source.split("\n");
741
+ code.split("\n").forEach((line, index) => {
742
+ const constant = moduleConstantOn(line, sourceLines[index] ?? "");
743
+ if (constant !== undefined)
744
+ constants.set(constant[0], constant[1]);
745
+ });
746
+ return constants;
747
+ }
748
+ /** Local name → the export it imports and from where, for value imports only. */
749
+ function namedImports(code) {
750
+ const imports = new Map();
751
+ NAMED_IMPORTS.lastIndex = 0;
752
+ for (let m = NAMED_IMPORTS.exec(code); m !== null; m = NAMED_IMPORTS.exec(code)) {
753
+ if (/\btype\s*$/.test(code.slice(Math.max(0, m.index - 12), m.index + 6)))
754
+ continue;
755
+ for (const element of (m[1] ?? "").split(",")) {
756
+ const parts = element.trim().split(/\s+/);
757
+ if (parts[0] === "type" || parts[0] === undefined || parts[0] === "")
758
+ continue;
759
+ const local = parts[1] === "as" ? parts[2] : parts[0];
760
+ if (local !== undefined)
761
+ imports.set(local, { specifier: m[3] ?? "", name: parts[0] });
762
+ }
763
+ }
764
+ return imports;
765
+ }
766
+ /** The project file a relative import names, tried the way a TypeScript-aware bundler resolves it. */
767
+ function projectModule(project, fromFile, specifier) {
768
+ if (!specifier.startsWith("./") && !specifier.startsWith("../"))
769
+ return undefined;
770
+ const base = posix.normalize(posix.join(posix.dirname(fromFile.replaceAll("\\", "/")), specifier));
771
+ const stem = base.replace(/\.(?:m?js|jsx)$/, "");
772
+ return [base, `${stem}.ts`, `${stem}.tsx`, `${stem}.mts`, `${base}/index.ts`].find((candidate) => project.has(candidate));
773
+ }
774
+ /**
775
+ * Route registrations whose path is a name or a template rather than a quoted literal. A name bound
776
+ * to a module-level string `const`, here or imported from a project file, and a template with no
777
+ * substitution resolve to routes; any other template, and a module-level or imported name nifra
778
+ * cannot read, is unresolved. A name bound anywhere else (a parameter, a local) is not reported:
779
+ * nothing marks such a call as a route rather than a `map.get(key, fallback)`.
780
+ */
781
+ function scanRouteArguments(file, content, facts, project, verbs = HTTP_VERBS) {
782
+ const routes = [];
783
+ const unresolved = [];
784
+ const code = stripComments(content);
785
+ if (!isPotentialBackendSource(code))
786
+ return { routes, unresolved };
787
+ const lines = content.split("\n");
788
+ const constants = moduleConstants(code, content);
789
+ const imports = namedImports(code);
790
+ const importedFiles = new Map();
791
+ const imported = (specifier, name) => {
792
+ const target = project === undefined ? undefined : projectModule(project, file, specifier);
793
+ if (target === undefined)
794
+ return undefined;
795
+ let found = importedFiles.get(target);
796
+ if (found === undefined) {
797
+ const text = project?.get(target) ?? "";
798
+ found = moduleConstants(stripComments(text), text);
799
+ importedFiles.set(target, found);
800
+ }
801
+ const constant = found.get(name);
802
+ return constant?.exported === true ? constant : undefined;
803
+ };
804
+ ROUTE_ARGUMENT.lastIndex = 0;
805
+ for (let m = ROUTE_ARGUMENT.exec(code); m !== null; m = ROUTE_ARGUMENT.exec(code)) {
806
+ const method = (m[1] ?? "").toUpperCase();
807
+ if (!verbs.has(method))
808
+ continue;
809
+ const at = m.index + m[0].lastIndexOf(m[2] ?? "");
810
+ const argument = content.slice(at, at + (m[2] ?? "").length);
811
+ let path;
812
+ if (argument.startsWith("`")) {
813
+ path = /[$\\]/.test(argument) ? undefined : argument.slice(1, -1);
814
+ }
815
+ else {
816
+ const binding = imports.get(argument);
817
+ const constant = binding === undefined ? constants.get(argument) : imported(binding.specifier, binding.name);
818
+ if (binding === undefined && constant === undefined)
819
+ continue;
820
+ path = constant?.value;
821
+ }
822
+ // A name that reads as something other than a path (`settings.get(KEY, fallback)`) is no route.
823
+ if (path !== undefined && (!path.startsWith("/") || path.startsWith("//")))
824
+ continue;
825
+ if (facts !== undefined) {
826
+ const source = facts.parse(file, content);
827
+ if (source !== undefined &&
828
+ facts.isRouteArgumentAt(source, m.index, method, argument) !== true)
829
+ continue;
830
+ }
831
+ const line = lineAt(content, m.index);
832
+ const snippet = (lines[line - 1] ?? "").trim();
833
+ if (path !== undefined)
834
+ routes.push({ file, line, snippet, method, path });
835
+ else
836
+ unresolved.push({ file, line, snippet, method, expression: argument.slice(0, 120) });
837
+ }
838
+ return { routes, unresolved };
839
+ }
840
+ /** Route registrations whose path the static scan cannot resolve - see {@link scanRouteArguments}. */
841
+ export function scanUnresolvedRoutePaths(file, content, project, facts) {
842
+ return [
843
+ ...scanRouteArguments(file, content, facts, project).unresolved,
844
+ ...scanRouteArguments(file, content, facts, project, SOCKET_VERB).unresolved,
845
+ ].sort(bySite);
846
+ }
847
+ /** `app.ws("/path", ...)` registrations, as routes under the pseudo-method `"WS"` reflection uses. */
848
+ export function scanStaticSocketText(file, content, facts,
849
+ /** Project files by forward-slash relative path, to resolve a path constant imported from one. */
850
+ project) {
851
+ const code = stripComments(content);
852
+ if (!isPotentialBackendSource(code))
853
+ return [];
854
+ return [
855
+ ...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_DQ, facts, SOCKET_VERB),
856
+ ...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_SQ, facts, SOCKET_VERB),
857
+ ...scanRouteArguments(file, content, facts, project, SOCKET_VERB).routes,
548
858
  ].sort(bySite);
549
859
  }
550
860
  /** Routes from `all()` and `method()`, one per literal method name; a computed name is not guessed. */
@@ -615,6 +925,24 @@ const SQL_METHODS = new Set([
615
925
  ]);
616
926
  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;
617
927
  const ALWAYS_UNSAFE = new Set(["unsafe", "$queryRawUnsafe", "$executeRawUnsafe", "raw"]);
928
+ /** Driver calls that take a statement under names that are everyday words elsewhere (`map.get`,
929
+ * `client.get("/users/update")`): node-sqlite3's `all`/`get`/`each` and pg-promise's result-shaping
930
+ * calls. One of these, and any bare call, is judged by whether its text starts like a statement. */
931
+ const DRIVER_READ_METHODS = new Set([
932
+ "all",
933
+ "get",
934
+ "each",
935
+ "any",
936
+ "one",
937
+ "none",
938
+ "many",
939
+ "oneOrNone",
940
+ "manyOrNone",
941
+ "result",
942
+ "multi",
943
+ "multiResult",
944
+ ]);
945
+ const STATEMENT_START = /^\s*\(?\s*(?:select|insert|update|delete|with|replace|merge|create|drop|alter|truncate|pragma)\s/i;
618
946
  /** Read one module's top-level `const` bindings and its import/export edges. Pure syntax - no checker,
619
947
  * no filesystem. Destructured names, `let`, `var`, and anything nested stay out on purpose: a
620
948
  * module-scope `const` is the only form that provably cannot carry request data. */
@@ -817,11 +1145,12 @@ const CONST_RESOLUTION_DEPTH = 5;
817
1145
  * data. Pure syntax, no type checker - the same honest limit the rule already documents. What
818
1146
  * resolves: string/number literals, no-substitution templates, templates whose every span resolves,
819
1147
  * ternaries with both branches resolvable (BOTH branch texts are returned, so a hostile keyword in
820
- * either still feeds the keyword scan), `as`/`satisfies` wrappers, a module-scope `const` whose
821
- * initializer resolves (recursively, depth-capped), and - when `scope.imports` is configured - such a
822
- * `const` imported by name from another in-project module. Everything else - `let`, a parameter, a
823
- * call, a member access, a shadowed name, a bare-specifier import - stays unresolved and the
824
- * interpolation is flagged exactly as before.
1148
+ * either still feeds the keyword scan), `as`/`satisfies` wrappers, a `const` whose initializer
1149
+ * resolves (the nearest binding: function or block scope, then module scope; recursively,
1150
+ * depth-capped), `.join(sep?)` over an array {@link staticArrayItems} proves, and - when
1151
+ * `scope.imports` is configured - a module `const` imported by name from another in-project module.
1152
+ * Everything else - `let`, a parameter, any other call, a member access, a shadowed name, a
1153
+ * bare-specifier import - stays unresolved and the interpolation is flagged exactly as before.
825
1154
  */
826
1155
  function resolveStaticSqlText(ts, node, scope, depth) {
827
1156
  if (depth > CONST_RESOLUTION_DEPTH)
@@ -839,7 +1168,8 @@ function resolveStaticSqlText(ts, node, scope, depth) {
839
1168
  if (ts.isTemplateExpression(node)) {
840
1169
  let text = node.head.text;
841
1170
  for (const span of node.templateSpans) {
842
- const part = resolveStaticSqlText(ts, span.expression, scope, depth + 1);
1171
+ const part = placeholderNumber(ts, text, span.expression, depth) ??
1172
+ resolveStaticSqlText(ts, span.expression, scope, depth + 1);
843
1173
  if (part === undefined)
844
1174
  return undefined;
845
1175
  text += part + span.literal.text;
@@ -853,7 +1183,14 @@ function resolveStaticSqlText(ts, node, scope, depth) {
853
1183
  return undefined;
854
1184
  return `${whenTrue} ${whenFalse}`;
855
1185
  }
1186
+ if (ts.isCallExpression(node))
1187
+ return resolveStaticJoin(ts, node, scope, depth);
856
1188
  if (ts.isIdentifier(node)) {
1189
+ const local = localConstInitializer(ts, node);
1190
+ if (local === "shadow")
1191
+ return undefined;
1192
+ if (local !== undefined)
1193
+ return resolveStaticSqlText(ts, local, scope, depth + 1);
857
1194
  const initializer = scope.module.consts.get(node.text);
858
1195
  const edge = initializer === undefined ? scope.module.imports.get(node.text) : undefined;
859
1196
  // Membership first: `isShadowedAt` walks a subtree, and most identifiers in a query are neither a
@@ -882,13 +1219,416 @@ function resolveStaticSqlText(ts, node, scope, depth) {
882
1219
  }
883
1220
  return undefined;
884
1221
  }
1222
+ /**
1223
+ * The initializer of the `const` a function or block scope binds `node` to, `"shadow"` when the
1224
+ * nearest such binding is anything else (a parameter, `let`, `var`, destructuring), or `undefined`
1225
+ * when no scope below the module binds it - the module-scope lookup applies then.
1226
+ */
1227
+ function localConstInitializer(ts, node) {
1228
+ const source = node.getSourceFile();
1229
+ for (let scope = node.parent; scope !== undefined; scope = scope.parent) {
1230
+ if (ts.isSourceFile(scope))
1231
+ return undefined;
1232
+ const binding = directBinding(ts, source, scope, node.text);
1233
+ if (binding === "shadow")
1234
+ return "shadow";
1235
+ if (binding !== undefined)
1236
+ return binding.constant ? binding.initializer : "shadow";
1237
+ }
1238
+ return undefined;
1239
+ }
1240
+ const unwrapExpression = (ts, node) => {
1241
+ let current = node;
1242
+ while (ts.isParenthesizedExpression(current) ||
1243
+ ts.isAsExpression(current) ||
1244
+ ts.isSatisfiesExpression(current)) {
1245
+ current = current.expression;
1246
+ }
1247
+ return current;
1248
+ };
1249
+ /** `Object.freeze([...])` with `Object` unshadowed: the one array form no later code can change. */
1250
+ function frozenArrayLiteral(ts, node) {
1251
+ const call = unwrapExpression(ts, node);
1252
+ if (!ts.isCallExpression(call) || call.arguments.length !== 1)
1253
+ return undefined;
1254
+ const callee = call.expression;
1255
+ if (!ts.isPropertyAccessExpression(callee) ||
1256
+ callee.name.text !== "freeze" ||
1257
+ !ts.isIdentifier(callee.expression) ||
1258
+ callee.expression.text !== "Object" ||
1259
+ isShadowedAt(ts, callee.expression, "Object")) {
1260
+ return undefined;
1261
+ }
1262
+ const array = unwrapExpression(ts, call.arguments[0]);
1263
+ return ts.isArrayLiteralExpression(array) ? array : undefined;
1264
+ }
1265
+ const ARRAY_READS = new Set(["join", "includes", "indexOf"]);
1266
+ /**
1267
+ * Whether every use of `name` in `source` other than its declaration is a call of `.join(`,
1268
+ * `.includes(` or `.indexOf(`, or a `.length` read: then nothing pushes to, sorts, exports or hands
1269
+ * away the array, so it still holds its literal elements. With `pushed`, a `.push(...)` is accepted
1270
+ * too and its arguments collected, for the caller to prove (the `where.push(...)` builder). A
1271
+ * same-named binding elsewhere only adds uses, so the answer can only err toward refusing.
1272
+ */
1273
+ function onlyReadsArray(ts, source, name, declaration, pushed) {
1274
+ let ok = true;
1275
+ const visit = (node) => {
1276
+ if (!ok)
1277
+ return;
1278
+ if (ts.isIdentifier(node) && node.text === name && node !== declaration) {
1279
+ const access = node.parent;
1280
+ const call = ts.isPropertyAccessExpression(access) &&
1281
+ access.expression === node &&
1282
+ ts.isCallExpression(access.parent) &&
1283
+ access.parent.expression === access
1284
+ ? access.parent
1285
+ : undefined;
1286
+ if (pushed !== undefined &&
1287
+ call !== undefined &&
1288
+ ts.isPropertyAccessExpression(access) &&
1289
+ access.name.text === "push") {
1290
+ if (call.arguments.some((argument) => ts.isSpreadElement(argument)))
1291
+ ok = false;
1292
+ else
1293
+ pushed.push(...call.arguments);
1294
+ // An argument can mention the array again; those uses are checked as well.
1295
+ for (const argument of call.arguments)
1296
+ visit(argument);
1297
+ return;
1298
+ }
1299
+ const read = ts.isPropertyAccessExpression(access) &&
1300
+ access.expression === node &&
1301
+ (access.name.text === "length"
1302
+ ? !isWrittenTo(ts, access)
1303
+ : ARRAY_READS.has(access.name.text) && call !== undefined);
1304
+ if (!read)
1305
+ ok = false;
1306
+ return;
1307
+ }
1308
+ ts.forEachChild(node, visit);
1309
+ };
1310
+ visit(source);
1311
+ return ok;
1312
+ }
1313
+ /** `node` as the target of an assignment, `++`/`--`, or `delete`. */
1314
+ function isWrittenTo(ts, node) {
1315
+ const parent = node.parent;
1316
+ if (ts.isBinaryExpression(parent) &&
1317
+ parent.left === node &&
1318
+ parent.operatorToken.kind >= ts.SyntaxKind.FirstAssignment &&
1319
+ parent.operatorToken.kind <= ts.SyntaxKind.LastAssignment) {
1320
+ return true;
1321
+ }
1322
+ if (ts.isPrefixUnaryExpression(parent) || ts.isPostfixUnaryExpression(parent)) {
1323
+ return (parent.operator === ts.SyntaxKind.PlusPlusToken ||
1324
+ parent.operator === ts.SyntaxKind.MinusMinusToken);
1325
+ }
1326
+ return ts.isDeleteExpression(parent);
1327
+ }
1328
+ /**
1329
+ * The element texts of an array `receiver` names, when the array provably still holds them: an
1330
+ * inline literal, `Object.freeze([...])` anywhere it is bound or imported from, or a `const` array
1331
+ * literal bound in this file, not exported, and only ever read ({@link onlyReadsArray}). `as const`
1332
+ * is type-only and proves nothing.
1333
+ */
1334
+ function staticArrayItems(ts, receiver, scope, depth) {
1335
+ const node = unwrapExpression(ts, receiver);
1336
+ let array = ts.isArrayLiteralExpression(node) ? node : frozenArrayLiteral(ts, node);
1337
+ let itemScope = scope;
1338
+ const pushed = [];
1339
+ if (array === undefined && ts.isIdentifier(node)) {
1340
+ const local = localConstInitializer(ts, node);
1341
+ if (local === "shadow")
1342
+ return undefined;
1343
+ let initializer = local;
1344
+ let exported = false;
1345
+ if (initializer === undefined && !isShadowedAt(ts, node, node.text)) {
1346
+ initializer = scope.module.consts.get(node.text);
1347
+ exported = [...scope.module.localExports.values()].includes(node.text);
1348
+ const edge = initializer === undefined ? scope.module.imports.get(node.text) : undefined;
1349
+ if (edge !== undefined && scope.imports !== undefined) {
1350
+ const target = loadSqlModuleScope(ts, scope.imports, scope.module.id, edge.specifier);
1351
+ const found = target === undefined
1352
+ ? undefined
1353
+ : resolveExportedConst(ts, scope.imports, target, edge.exported, 1, new Set());
1354
+ if (found === undefined)
1355
+ return undefined;
1356
+ // Another module's array is accepted only frozen: its own file may mutate it.
1357
+ array = frozenArrayLiteral(ts, found.expression);
1358
+ itemScope = { module: found.scope, imports: scope.imports };
1359
+ }
1360
+ }
1361
+ if (array === undefined && initializer !== undefined) {
1362
+ array = frozenArrayLiteral(ts, initializer);
1363
+ const literal = unwrapExpression(ts, initializer);
1364
+ if (array === undefined &&
1365
+ !exported &&
1366
+ ts.isArrayLiteralExpression(literal) &&
1367
+ ts.isVariableDeclaration(initializer.parent) &&
1368
+ onlyReadsArray(ts, node.getSourceFile(), node.text, initializer.parent.name, pushed)) {
1369
+ array = literal;
1370
+ }
1371
+ }
1372
+ }
1373
+ if (array === undefined)
1374
+ return undefined;
1375
+ const items = [];
1376
+ // What the array may hold: its literal elements, and anything pushed onto it in any order.
1377
+ for (const element of [...array.elements, ...pushed]) {
1378
+ if (ts.isSpreadElement(element) || ts.isOmittedExpression(element))
1379
+ return undefined;
1380
+ const text = resolveStaticSqlText(ts, element, itemScope, depth + 1);
1381
+ if (text === undefined)
1382
+ return undefined;
1383
+ items.push(text);
1384
+ }
1385
+ return items;
1386
+ }
1387
+ /** `items.join(sep?)` over a {@link staticArrayItems} array with a literal or absent separator. */
1388
+ function resolveStaticJoin(ts, call, scope, depth) {
1389
+ const callee = call.expression;
1390
+ if (!ts.isPropertyAccessExpression(callee) ||
1391
+ callee.questionDotToken !== undefined ||
1392
+ callee.name.text !== "join" ||
1393
+ call.arguments.length > 1) {
1394
+ return undefined;
1395
+ }
1396
+ const separatorArg = call.arguments[0];
1397
+ const separator = separatorArg === undefined
1398
+ ? ","
1399
+ : ts.isStringLiteral(separatorArg) || ts.isNoSubstitutionTemplateLiteral(separatorArg)
1400
+ ? separatorArg.text
1401
+ : undefined;
1402
+ if (separator === undefined)
1403
+ return undefined;
1404
+ const items = staticArrayItems(ts, callee.expression, scope, depth + 1) ??
1405
+ mappedItem(ts, callee.expression, scope, depth + 1);
1406
+ return items?.join(separator);
1407
+ }
1408
+ /**
1409
+ * `xs.map(fn)`, `xs.flatMap(fn)` or `Array.from(src, fn)` whose callback returns text that resolves
1410
+ * for every element - `(_, i) => \`($${i * 2 + 1}, $${i * 2 + 2})\``, the batch `VALUES` idiom. How
1411
+ * many items there are is runtime data, what each one says is not, so one item stands for all.
1412
+ */
1413
+ function mappedItem(ts, receiver, scope, depth) {
1414
+ const call = unwrapExpression(ts, receiver);
1415
+ if (!ts.isCallExpression(call) || !ts.isPropertyAccessExpression(call.expression))
1416
+ return undefined;
1417
+ // Only these return what the callback returns; `filter` and the rest hand back the elements.
1418
+ const method = call.expression.name.text;
1419
+ const argument = indexCallbackArgument(ts, call);
1420
+ if (argument === undefined || !(method === "map" || method === "flatMap" || argument === 1))
1421
+ return undefined;
1422
+ const fn = call.arguments[argument];
1423
+ if (fn === undefined || !(ts.isArrowFunction(fn) || ts.isFunctionExpression(fn)))
1424
+ return undefined;
1425
+ const only = ts.isBlock(fn.body) && fn.body.statements.length === 1 ? fn.body.statements[0] : undefined;
1426
+ const body = ts.isBlock(fn.body)
1427
+ ? only !== undefined && ts.isReturnStatement(only)
1428
+ ? only.expression
1429
+ : undefined
1430
+ : fn.body;
1431
+ const text = body === undefined ? undefined : resolveStaticSqlText(ts, body, scope, depth + 1);
1432
+ return text === undefined ? undefined : [text];
1433
+ }
1434
+ /** Array methods whose callback receives the element's index, and where in the callback it is. */
1435
+ const INDEX_PARAMETER = new Map([
1436
+ ["map", 1],
1437
+ ["flatMap", 1],
1438
+ ["forEach", 1],
1439
+ ["filter", 1],
1440
+ ["some", 1],
1441
+ ["every", 1],
1442
+ ["find", 1],
1443
+ ["findIndex", 1],
1444
+ ["reduce", 2],
1445
+ ["reduceRight", 2],
1446
+ ]);
1447
+ /** Which argument of `call` is a callback handed an array index: the first for `xs.map(fn)` and its
1448
+ * kin, the second for an unshadowed `Array.from(src, fn)`. */
1449
+ function indexCallbackArgument(ts, call) {
1450
+ const callee = call.expression;
1451
+ if (!ts.isPropertyAccessExpression(callee) || callee.questionDotToken !== undefined)
1452
+ return undefined;
1453
+ if (INDEX_PARAMETER.has(callee.name.text))
1454
+ return 0;
1455
+ return callee.name.text === "from" &&
1456
+ ts.isIdentifier(callee.expression) &&
1457
+ callee.expression.text === "Array" &&
1458
+ !isShadowedAt(ts, callee.expression, "Array")
1459
+ ? 1
1460
+ : undefined;
1461
+ }
1462
+ /**
1463
+ * `"1"` for a span written right after a `$` whose expression provably evaluates to a number: a
1464
+ * Postgres placeholder index (`$${i + 1}`), which binds a parameter and can carry no SQL. Any string
1465
+ * there could, so anything not proved numeric stays unresolved.
1466
+ */
1467
+ function placeholderNumber(ts, before, node, depth) {
1468
+ return before.endsWith("$") && isNumericExpression(ts, node, depth) ? "1" : undefined;
1469
+ }
1470
+ const NUMERIC_OPERATORS = (ts) => new Set([
1471
+ ts.SyntaxKind.MinusToken,
1472
+ ts.SyntaxKind.AsteriskToken,
1473
+ ts.SyntaxKind.AsteriskAsteriskToken,
1474
+ ts.SyntaxKind.SlashToken,
1475
+ ts.SyntaxKind.PercentToken,
1476
+ ts.SyntaxKind.AmpersandToken,
1477
+ ts.SyntaxKind.BarToken,
1478
+ ts.SyntaxKind.CaretToken,
1479
+ ts.SyntaxKind.LessThanLessThanToken,
1480
+ ts.SyntaxKind.GreaterThanGreaterThanToken,
1481
+ ts.SyntaxKind.GreaterThanGreaterThanGreaterThanToken,
1482
+ ]);
1483
+ const NUMBER_FUNCTIONS = new Set(["Number", "parseInt", "parseFloat"]);
1484
+ /**
1485
+ * Whether an expression evaluates to a number whatever its inputs hold. Every arithmetic operator but
1486
+ * `+` converts its operands, so `i * 2` is a number even if `i` were a string; `+` is one only when
1487
+ * both sides are. A name proves it when it is a callback's array index, or a `const`, never-reassigned
1488
+ * `let` or `for` counter bound to a number and only ever updated by arithmetic. Also `xs.push(...)`
1489
+ * (the new length), `Number`/`parseInt`/`parseFloat`/`Math.*` calls, and `.length` of an array a
1490
+ * nearby binding builds from a literal. A type annotation proves nothing: it does not run.
1491
+ */
1492
+ function isNumericExpression(ts, node, depth) {
1493
+ if (depth > CONST_RESOLUTION_DEPTH)
1494
+ return false;
1495
+ const expression = unwrapExpression(ts, node);
1496
+ if (ts.isNumericLiteral(expression))
1497
+ return true;
1498
+ if (ts.isPrefixUnaryExpression(expression))
1499
+ return expression.operator !== ts.SyntaxKind.ExclamationToken;
1500
+ if (ts.isPostfixUnaryExpression(expression))
1501
+ return true;
1502
+ if (ts.isBinaryExpression(expression)) {
1503
+ const operator = expression.operatorToken.kind;
1504
+ if (operator === ts.SyntaxKind.PlusToken) {
1505
+ return (isNumericExpression(ts, expression.left, depth + 1) &&
1506
+ isNumericExpression(ts, expression.right, depth + 1));
1507
+ }
1508
+ return NUMERIC_OPERATORS(ts).has(operator);
1509
+ }
1510
+ if (ts.isIdentifier(expression))
1511
+ return isNumericName(ts, expression, depth);
1512
+ if (ts.isCallExpression(expression)) {
1513
+ const callee = expression.expression;
1514
+ if (ts.isIdentifier(callee))
1515
+ return NUMBER_FUNCTIONS.has(callee.text) && !isShadowedAt(ts, callee, callee.text);
1516
+ if (!ts.isPropertyAccessExpression(callee) || callee.questionDotToken !== undefined)
1517
+ return false;
1518
+ if (callee.name.text === "push")
1519
+ return true;
1520
+ return (ts.isIdentifier(callee.expression) &&
1521
+ callee.expression.text === "Math" &&
1522
+ !isShadowedAt(ts, callee.expression, "Math"));
1523
+ }
1524
+ if (ts.isPropertyAccessExpression(expression) &&
1525
+ expression.name.text === "length" &&
1526
+ ts.isIdentifier(expression.expression)) {
1527
+ const binding = nameBinding(ts, expression.expression);
1528
+ return (binding.kind === "value" &&
1529
+ ts.isArrayLiteralExpression(unwrapExpression(ts, binding.initializer)));
1530
+ }
1531
+ return false;
1532
+ }
1533
+ function nameBinding(ts, node) {
1534
+ const source = node.getSourceFile();
1535
+ for (let scope = node.parent; scope !== undefined; scope = scope.parent) {
1536
+ if (ts.isFunctionLike(scope)) {
1537
+ const position = scope.parameters.findIndex((p) => bindingNameCovers(ts, p.name, node.text));
1538
+ if (position !== -1) {
1539
+ return ts.isIdentifier(scope.parameters[position]?.name ?? node)
1540
+ ? { kind: "parameter", fn: scope, position }
1541
+ : { kind: "unknown" };
1542
+ }
1543
+ }
1544
+ const binding = directBinding(ts, source, scope, node.text);
1545
+ if (binding === "shadow") {
1546
+ // `for (let i = 0; ...; i++)`: the one reassigned `let` worth reading, checked by the caller.
1547
+ const list = ts.isForStatement(scope) ? scope.initializer : undefined;
1548
+ const declaration = list !== undefined && ts.isVariableDeclarationList(list)
1549
+ ? list.declarations.find((d) => ts.isIdentifier(d.name) && d.name.text === node.text)
1550
+ : undefined;
1551
+ return declaration?.initializer === undefined
1552
+ ? { kind: "unknown" }
1553
+ : { kind: "counter", initializer: declaration.initializer };
1554
+ }
1555
+ if (binding !== undefined)
1556
+ return { kind: "value", initializer: binding.initializer };
1557
+ if (ts.isSourceFile(scope))
1558
+ break;
1559
+ }
1560
+ return { kind: "unknown" };
1561
+ }
1562
+ function isNumericName(ts, node, depth) {
1563
+ const source = node.getSourceFile();
1564
+ const binding = nameBinding(ts, node);
1565
+ if (binding.kind === "value")
1566
+ return isNumericExpression(ts, binding.initializer, depth + 1);
1567
+ if (binding.kind === "counter") {
1568
+ return (isNumericExpression(ts, binding.initializer, depth + 1) &&
1569
+ onlyNumericWrites(ts, source, node.text, depth));
1570
+ }
1571
+ if (binding.kind !== "parameter")
1572
+ return false;
1573
+ // An array callback's index parameter, never reassigned.
1574
+ const call = binding.fn.parent;
1575
+ if (!ts.isCallExpression(call) || !ts.isPropertyAccessExpression(call.expression))
1576
+ return false;
1577
+ const argument = indexCallbackArgument(ts, call);
1578
+ const position = argument === 1 ? 1 : INDEX_PARAMETER.get(call.expression.name.text);
1579
+ return (argument !== undefined &&
1580
+ call.arguments[argument] === binding.fn &&
1581
+ binding.position === position &&
1582
+ !isReassignedInFile(ts, source, node.text));
1583
+ }
1584
+ /** Compound assignments that always store a number. */
1585
+ const NUMERIC_ASSIGNMENTS = (ts) => new Set([
1586
+ ts.SyntaxKind.MinusEqualsToken,
1587
+ ts.SyntaxKind.AsteriskEqualsToken,
1588
+ ts.SyntaxKind.AsteriskAsteriskEqualsToken,
1589
+ ts.SyntaxKind.SlashEqualsToken,
1590
+ ts.SyntaxKind.PercentEqualsToken,
1591
+ ts.SyntaxKind.AmpersandEqualsToken,
1592
+ ts.SyntaxKind.BarEqualsToken,
1593
+ ts.SyntaxKind.CaretEqualsToken,
1594
+ ts.SyntaxKind.LessThanLessThanEqualsToken,
1595
+ ts.SyntaxKind.GreaterThanGreaterThanEqualsToken,
1596
+ ts.SyntaxKind.GreaterThanGreaterThanGreaterThanEqualsToken,
1597
+ ]);
1598
+ /** Every write to `name` in the file is `++`/`--`, or an assignment that stores a number. */
1599
+ function onlyNumericWrites(ts, source, name, depth) {
1600
+ let ok = true;
1601
+ const visit = (node) => {
1602
+ if (!ok)
1603
+ return;
1604
+ if (ts.isIdentifier(node) && node.text === name && isWrittenTo(ts, node)) {
1605
+ const parent = node.parent;
1606
+ if (ts.isBinaryExpression(parent)) {
1607
+ const operator = parent.operatorToken.kind;
1608
+ ok =
1609
+ operator === ts.SyntaxKind.EqualsToken || operator === ts.SyntaxKind.PlusEqualsToken
1610
+ ? isNumericExpression(ts, parent.right, depth + 1)
1611
+ : NUMERIC_ASSIGNMENTS(ts).has(operator);
1612
+ }
1613
+ else
1614
+ ok = !ts.isDeleteExpression(parent);
1615
+ return;
1616
+ }
1617
+ ts.forEachChild(node, visit);
1618
+ };
1619
+ visit(source);
1620
+ return ok;
1621
+ }
885
1622
  /** A template's contribution to the shape: resolved span texts fold into `staticText` (so the
886
1623
  * keyword scan still sees what a `const` carries); any unresolvable span keeps it `dynamic`. */
887
1624
  function templateShape(ts, template, scope) {
888
1625
  let staticText = template.head.text;
889
1626
  let dynamic = false;
890
1627
  for (const span of template.templateSpans) {
891
- const resolved = scope === undefined ? undefined : resolveStaticSqlText(ts, span.expression, scope, 0);
1628
+ const resolved = scope === undefined
1629
+ ? undefined
1630
+ : (placeholderNumber(ts, staticText, span.expression, 0) ??
1631
+ resolveStaticSqlText(ts, span.expression, scope, 0));
892
1632
  if (resolved === undefined)
893
1633
  dynamic = true;
894
1634
  else
@@ -953,30 +1693,13 @@ function directBinding(ts, source, scope, name) {
953
1693
  if (declared !== undefined && bindingNameCovers(ts, declared.name, name))
954
1694
  return "shadow";
955
1695
  }
956
- const statements = ts.isSourceFile(scope)
957
- ? scope.statements
958
- : ts.isBlock(scope) || ts.isModuleBlock(scope)
959
- ? scope.statements
960
- : ts.isCaseClause(scope) || ts.isDefaultClause(scope)
961
- ? scope.statements
962
- : undefined;
963
- const lists = [];
964
- if (statements !== undefined) {
965
- for (const statement of statements) {
966
- if (ts.isVariableStatement(statement))
967
- lists.push(statement.declarationList);
968
- if ((ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) &&
969
- statement.name?.text === name) {
970
- return "shadow";
971
- }
1696
+ for (const statement of scopeStatements(ts, scope) ?? []) {
1697
+ if ((ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) &&
1698
+ statement.name?.text === name) {
1699
+ return "shadow";
972
1700
  }
973
1701
  }
974
- if ((ts.isForStatement(scope) || ts.isForInStatement(scope) || ts.isForOfStatement(scope)) &&
975
- scope.initializer !== undefined &&
976
- ts.isVariableDeclarationList(scope.initializer)) {
977
- lists.push(scope.initializer);
978
- }
979
- for (const list of lists) {
1702
+ for (const list of scopeDeclarationLists(ts, scope)) {
980
1703
  for (const declaration of list.declarations) {
981
1704
  if (!bindingNameCovers(ts, declaration.name, name))
982
1705
  continue;
@@ -987,52 +1710,262 @@ function directBinding(ts, source, scope, name) {
987
1710
  const isConst = (list.flags & ts.NodeFlags.Const) !== 0;
988
1711
  const isLet = (list.flags & ts.NodeFlags.Let) !== 0;
989
1712
  if (isConst)
990
- return { initializer: declaration.initializer };
1713
+ return { initializer: declaration.initializer, constant: true };
991
1714
  if (isLet && !isReassignedInFile(ts, source, name))
992
- return { initializer: declaration.initializer };
1715
+ return { initializer: declaration.initializer, constant: false };
993
1716
  return "shadow";
994
1717
  }
995
1718
  }
996
1719
  return undefined;
997
1720
  }
1721
+ function scopeStatements(ts, scope) {
1722
+ return ts.isSourceFile(scope) ||
1723
+ ts.isBlock(scope) ||
1724
+ ts.isModuleBlock(scope) ||
1725
+ ts.isCaseClause(scope) ||
1726
+ ts.isDefaultClause(scope)
1727
+ ? scope.statements
1728
+ : undefined;
1729
+ }
1730
+ /** The `var`/`let`/`const` lists a scope node declares directly: its statements' and a `for` head's. */
1731
+ function scopeDeclarationLists(ts, scope) {
1732
+ const lists = [];
1733
+ for (const statement of scopeStatements(ts, scope) ?? []) {
1734
+ if (ts.isVariableStatement(statement))
1735
+ lists.push(statement.declarationList);
1736
+ }
1737
+ if ((ts.isForStatement(scope) || ts.isForInStatement(scope) || ts.isForOfStatement(scope)) &&
1738
+ scope.initializer !== undefined &&
1739
+ ts.isVariableDeclarationList(scope.initializer)) {
1740
+ lists.push(scope.initializer);
1741
+ }
1742
+ return lists;
1743
+ }
1744
+ /** The `var` or `let` a scope declares `name` with by a plain identifier, if any. */
1745
+ function mutableDeclaration(ts, scope, name) {
1746
+ for (const list of scopeDeclarationLists(ts, scope)) {
1747
+ if ((list.flags & ts.NodeFlags.Const) !== 0)
1748
+ continue;
1749
+ for (const declaration of list.declarations) {
1750
+ if (ts.isIdentifier(declaration.name) && declaration.name.text === name)
1751
+ return declaration;
1752
+ }
1753
+ }
1754
+ return undefined;
1755
+ }
998
1756
  /**
999
- * Resolve a query-call ARGUMENT identifier to the expression it was initialized from, so a statement
1000
- * parked in a variable is scanned as if it had been written inline at the call. This is the counterpart
1001
- * to {@link resolveStaticSqlText}: that folds a const's text INTO a template's shape; this unfolds the
1002
- * whole statement back out of the variable it was lifted into. Without it, ANY interpolated-SQL finding
1003
- * could be silenced, invisibly, by hoisting the statement to `const q = …` first.
1757
+ * Every value a statement variable may hold where the query reads it, so a statement parked in a
1758
+ * variable is scanned as if it had been written inline at the call. This is the counterpart to
1759
+ * {@link resolveStaticSqlText}: that folds a const's text INTO a template's shape; this unfolds the
1760
+ * statement back out of the variable it was lifted into. Without it, ANY interpolated-SQL finding
1761
+ * could be silenced, invisibly, by hoisting the statement to `const q = …` first, or by building it
1762
+ * in steps (`let q = "SELECT …"; q += …`).
1004
1763
  *
1005
1764
  * Same-file only. Nearest enclosing declaration wins, so a shadowing local is read and never a
1006
- * same-named module const. Reads through a `const`, or a `let` the file never reassigns, at function or
1007
- * module scope; transitive through an identifier-valued initializer; depth-capped. A parameter, an
1008
- * imported or destructured binding, a reassigned `let`, a `var`, or a name with no same-file
1009
- * declaration returns `undefined` - which leaves the argument scanned as itself (unflagged unless it is
1010
- * a module const that resolves to static text: exactly the pre-feature behavior).
1765
+ * same-named module const. A `const`, or a `let` the file never reassigns, holds its initializer. A
1766
+ * `var` or reassigned `let` holds its initializer and every value assigned to the name anywhere in
1767
+ * the function (or module) that declares it: over-collected rather than scope-simulated, which only
1768
+ * ever adds a value to check. `key` names the binding, so a value that reads the variable it is
1769
+ * assigned to is recognised. A parameter, an imported or destructured binding, or a name with no
1770
+ * same-file declaration returns `undefined` - which leaves the argument scanned as itself.
1011
1771
  */
1012
- function resolveArgumentInitializer(ts, node, depth) {
1013
- if (depth > CONST_RESOLUTION_DEPTH)
1014
- return undefined;
1772
+ function variableWrites(ts, node) {
1015
1773
  const source = node.getSourceFile();
1016
1774
  const name = node.text;
1017
1775
  for (let scope = node.parent; scope !== undefined; scope = scope.parent) {
1018
1776
  const binding = directBinding(ts, source, scope, name);
1019
- if (binding === "shadow")
1020
- return undefined;
1777
+ if (binding === "shadow") {
1778
+ const declaration = mutableDeclaration(ts, scope, name);
1779
+ if (declaration === undefined)
1780
+ return undefined;
1781
+ let owner = declaration;
1782
+ while (!ts.isFunctionLike(owner) && !ts.isSourceFile(owner))
1783
+ owner = owner.parent;
1784
+ const writes = writesTo(ts, owner, name);
1785
+ return {
1786
+ key: declaration,
1787
+ writes: declaration.initializer === undefined
1788
+ ? writes
1789
+ : [{ value: declaration.initializer, appends: false }, ...writes],
1790
+ };
1791
+ }
1021
1792
  if (binding !== undefined) {
1022
- let init = binding.initializer;
1023
- while (ts.isParenthesizedExpression(init))
1024
- init = init.expression;
1025
- return ts.isIdentifier(init) ? resolveArgumentInitializer(ts, init, depth + 1) : init;
1793
+ return { key: binding.initializer, writes: [{ value: binding.initializer, appends: false }] };
1026
1794
  }
1027
1795
  if (ts.isSourceFile(scope))
1028
1796
  break;
1029
1797
  }
1030
1798
  return undefined;
1031
1799
  }
1032
- /** Describe only the first argument's syntax; comments/strings elsewhere cannot influence the result. */
1033
- function sqlExpressionShape(ts, node, scope) {
1034
- if (ts.isParenthesizedExpression(node))
1035
- return sqlExpressionShape(ts, node.expression, scope);
1800
+ /** The values assigned to `name` under `root`. A numeric compound assignment stores no text. */
1801
+ function writesTo(ts, root, name) {
1802
+ const writes = [];
1803
+ const visit = (node) => {
1804
+ if (ts.isIdentifier(node) && node.text === name) {
1805
+ const parent = node.parent;
1806
+ if (ts.isBinaryExpression(parent) && parent.left === node) {
1807
+ const operator = parent.operatorToken.kind;
1808
+ if (operator === ts.SyntaxKind.PlusEqualsToken) {
1809
+ writes.push({ value: parent.right, appends: true });
1810
+ }
1811
+ else if (operator === ts.SyntaxKind.EqualsToken ||
1812
+ operator === ts.SyntaxKind.QuestionQuestionEqualsToken ||
1813
+ operator === ts.SyntaxKind.BarBarEqualsToken ||
1814
+ operator === ts.SyntaxKind.AmpersandAmpersandEqualsToken) {
1815
+ writes.push({ value: parent.right, appends: false });
1816
+ }
1817
+ }
1818
+ }
1819
+ ts.forEachChild(node, visit);
1820
+ };
1821
+ visit(root);
1822
+ return writes;
1823
+ }
1824
+ function mentionsName(ts, node, name) {
1825
+ if (ts.isIdentifier(node))
1826
+ return node.text === name;
1827
+ return ts.forEachChild(node, (child) => mentionsName(ts, child, name) || undefined) === true;
1828
+ }
1829
+ const UNKNOWN_SQL = {
1830
+ staticText: "",
1831
+ dynamic: true,
1832
+ concatenated: false,
1833
+ literal: false,
1834
+ parameterizedTag: false,
1835
+ };
1836
+ function textShape(text) {
1837
+ return {
1838
+ staticText: text,
1839
+ dynamic: false,
1840
+ concatenated: false,
1841
+ literal: true,
1842
+ parameterizedTag: false,
1843
+ };
1844
+ }
1845
+ function joinShapes(shapes) {
1846
+ return {
1847
+ staticText: shapes.map((shape) => shape.staticText).join(""),
1848
+ // As with `+`: text glued onto a binding tag's output un-parameterizes it.
1849
+ dynamic: shapes.some((shape) => shape.dynamic || shape.parameterizedTag),
1850
+ concatenated: true,
1851
+ literal: false,
1852
+ parameterizedTag: false,
1853
+ };
1854
+ }
1855
+ /** Orders a variable's candidate statements by how surely {@link scanInterpolatedSql} would flag
1856
+ * them: dynamic SQL text, then dynamic glued text, then any dynamic text; outside a binding tag
1857
+ * before inside one. */
1858
+ function sqlRisk(shape) {
1859
+ const dynamic = !shape.dynamic
1860
+ ? 0
1861
+ : SQL_KEYWORD.test(shape.staticText) || STATEMENT_START.test(shape.staticText)
1862
+ ? 3
1863
+ : shape.concatenated
1864
+ ? 2
1865
+ : 1;
1866
+ return dynamic * 2 + (shape.parameterizedTag ? 0 : 1);
1867
+ }
1868
+ /**
1869
+ * The riskiest statement a variable may hold. Each value that replaces the text is one candidate.
1870
+ * The values that build on it (`+=`, or `q = q + more`) are glued onto every replacing value at
1871
+ * once, as a builder that takes each branch would, so a static `SELECT … WHERE 1=1` followed by an
1872
+ * interpolated `AND …` reads as the one statement it becomes.
1873
+ */
1874
+ function writtenShape(ts, name, writes, scope, trail) {
1875
+ const replacing = [];
1876
+ const building = [];
1877
+ for (const write of writes) {
1878
+ const shape = sqlExpressionShape(ts, write.value, scope, trail);
1879
+ if (write.appends || mentionsName(ts, write.value, name))
1880
+ building.push(shape);
1881
+ else
1882
+ replacing.push(shape);
1883
+ }
1884
+ const candidates = building.length === 0 ? replacing : [...replacing, joinShapes([...replacing, ...building])];
1885
+ let worst;
1886
+ for (const candidate of candidates) {
1887
+ if (worst === undefined || sqlRisk(candidate) > sqlRisk(worst))
1888
+ worst = candidate;
1889
+ }
1890
+ return worst ?? UNKNOWN_SQL;
1891
+ }
1892
+ const TEXT_METHODS = new Set([
1893
+ "toString",
1894
+ "valueOf",
1895
+ "trim",
1896
+ "trimStart",
1897
+ "trimEnd",
1898
+ "toLowerCase",
1899
+ "toUpperCase",
1900
+ ]);
1901
+ /** The value a text-preserving call returns: `String(x)`, `x.toString()`, `x.trim()`. */
1902
+ function passedThrough(ts, call) {
1903
+ const callee = call.expression;
1904
+ if (ts.isIdentifier(callee) && callee.text === "String" && call.arguments.length === 1) {
1905
+ return call.arguments[0];
1906
+ }
1907
+ if (ts.isPropertyAccessExpression(callee) &&
1908
+ TEXT_METHODS.has(callee.name.text) &&
1909
+ call.arguments.length === 0) {
1910
+ return callee.expression;
1911
+ }
1912
+ return undefined;
1913
+ }
1914
+ /** The parts `[a, b].join(sep)` and `a.concat(b)` glue together, a separator as its text. */
1915
+ function joinedParts(ts, call) {
1916
+ const callee = call.expression;
1917
+ if (!ts.isPropertyAccessExpression(callee))
1918
+ return undefined;
1919
+ if (callee.name.text === "concat")
1920
+ return [callee.expression, ...call.arguments];
1921
+ const items = unwrapExpression(ts, callee.expression);
1922
+ if (callee.name.text !== "join" || !ts.isArrayLiteralExpression(items))
1923
+ return undefined;
1924
+ const separator = call.arguments[0];
1925
+ const glue = separator === undefined ? "," : ts.isStringLiteralLike(separator) ? separator.text : separator;
1926
+ const parts = [];
1927
+ items.elements.forEach((element, index) => {
1928
+ if (index > 0)
1929
+ parts.push(glue);
1930
+ if (ts.isSpreadElement(element))
1931
+ parts.push(element.expression);
1932
+ else if (!ts.isOmittedExpression(element))
1933
+ parts.push(element);
1934
+ });
1935
+ return parts;
1936
+ }
1937
+ /** Whether the `sql` a tag names is a function this file defines (`const sql = (s, ...v) => …`),
1938
+ * which is plain interpolation whatever its name. A parameter, an import, or a driver instance
1939
+ * (`const sql = postgres(url)`) keeps the name's trust. */
1940
+ function definesSqlFunction(ts, tag) {
1941
+ for (let scope = tag.parent; scope !== undefined; scope = scope.parent) {
1942
+ if (ts.isFunctionLike(scope) &&
1943
+ scope.parameters.some((parameter) => bindingNameCovers(ts, parameter.name, tag.text))) {
1944
+ return false;
1945
+ }
1946
+ for (const statement of scopeStatements(ts, scope) ?? []) {
1947
+ if (ts.isFunctionDeclaration(statement) && statement.name?.text === tag.text)
1948
+ return true;
1949
+ }
1950
+ for (const list of scopeDeclarationLists(ts, scope)) {
1951
+ for (const declaration of list.declarations) {
1952
+ if (!ts.isIdentifier(declaration.name) || declaration.name.text !== tag.text)
1953
+ continue;
1954
+ const value = declaration.initializer === undefined
1955
+ ? undefined
1956
+ : unwrapExpression(ts, declaration.initializer);
1957
+ return value !== undefined && (ts.isArrowFunction(value) || ts.isFunctionExpression(value));
1958
+ }
1959
+ }
1960
+ }
1961
+ return false;
1962
+ }
1963
+ /** Describe only the statement's syntax; comments/strings elsewhere cannot influence the result.
1964
+ * `trail` holds the variables being read through, innermost last. */
1965
+ function sqlExpressionShape(ts, expression, scope, trail = []) {
1966
+ let node = unwrapExpression(ts, expression);
1967
+ while (ts.isNonNullExpression(node))
1968
+ node = unwrapExpression(ts, node.expression);
1036
1969
  if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
1037
1970
  return {
1038
1971
  staticText: node.text,
@@ -1044,7 +1977,7 @@ function sqlExpressionShape(ts, node, scope) {
1044
1977
  }
1045
1978
  if (ts.isTaggedTemplateExpression(node)) {
1046
1979
  const tag = node.tag;
1047
- const parameterizedTag = (ts.isIdentifier(tag) && tag.text === "sql") ||
1980
+ const parameterizedTag = (ts.isIdentifier(tag) && tag.text === "sql" && !definesSqlFunction(ts, tag)) ||
1048
1981
  (ts.isPropertyAccessExpression(tag) && tag.getText() === "Prisma.sql");
1049
1982
  const template = node.template;
1050
1983
  const templated = ts.isTemplateExpression(template)
@@ -1054,8 +1987,9 @@ function sqlExpressionShape(ts, node, scope) {
1054
1987
  // Only the ecosystem's binding tags are trusted, by NAME - which is the honest limit of a scanner
1055
1988
  // that reads syntax and runs no type checker. `sql` is what postgres.js, drizzle, slonik and Bun's
1056
1989
  // own driver all call theirs, and `Prisma.sql` is Prisma's; taking those at their word is a
1057
- // deliberate trade, and it is worth stating what it costs: a no-op function named `sql` in the
1058
- // same file is trusted too, so this rule finds mistakes, not an adversary who reads it first.
1990
+ // deliberate trade, and it is worth stating what it costs: a `sql` this file defines as a
1991
+ // function is read as the interpolation it is, but one imported from anywhere is trusted, so
1992
+ // this rule finds mistakes, not an adversary who reads it first.
1059
1993
  //
1060
1994
  // Trusting anything else widens that for no gain. This list briefly also held `sqlIdentifiers`,
1061
1995
  // a name invented for Nifra's own adapters - a second forgeable name that no user has a
@@ -1077,14 +2011,15 @@ function sqlExpressionShape(ts, node, scope) {
1077
2011
  return {
1078
2012
  staticText: templated.staticText,
1079
2013
  dynamic: templated.dynamic,
1080
- concatenated: false,
2014
+ // `${a} ${b}` writes no text of its own; it only glues values together.
2015
+ concatenated: [node.head, ...node.templateSpans.map((span) => span.literal)].every((part) => part.text.trim() === ""),
1081
2016
  literal: false,
1082
2017
  parameterizedTag: false,
1083
2018
  };
1084
2019
  }
1085
2020
  if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) {
1086
- const left = sqlExpressionShape(ts, node.left, scope);
1087
- const right = sqlExpressionShape(ts, node.right, scope);
2021
+ const left = sqlExpressionShape(ts, node.left, scope, trail);
2022
+ const right = sqlExpressionShape(ts, node.right, scope, trail);
1088
2023
  return {
1089
2024
  staticText: left.staticText + right.staticText,
1090
2025
  // A resolved const operand is static text, not a dynamic part - so the old `!literal` test
@@ -1113,47 +2048,132 @@ function sqlExpressionShape(ts, node, scope) {
1113
2048
  };
1114
2049
  }
1115
2050
  }
1116
- // A bare name that did not resolve to compile-time text. If it is a same-file binding we can prove
1117
- // holds one expression, scan THAT expression's shape - so a statement hoisted into `const q = …` is
2051
+ if (ts.isCallExpression(node)) {
2052
+ const inner = passedThrough(ts, node);
2053
+ if (inner !== undefined)
2054
+ return { ...sqlExpressionShape(ts, inner, scope, trail), literal: false };
2055
+ const parts = joinedParts(ts, node);
2056
+ if (parts !== undefined) {
2057
+ return joinShapes(parts.map((part) => typeof part === "string" ? textShape(part) : sqlExpressionShape(ts, part, scope, trail)));
2058
+ }
2059
+ }
2060
+ // A bare name that did not resolve to compile-time text. If it is a same-file binding, scan the
2061
+ // shapes of what it may hold - so a statement hoisted into `const q = …` or built up in a `let` is
1118
2062
  // read exactly as if it were written inline at the call, closing the "extract a variable to launder
1119
2063
  // the finding" hole. `literal` is forced false: the value arrived through a variable, which is the
1120
2064
  // property the `unsafe`/`$queryRawUnsafe` hatch rule keys on.
1121
2065
  if (ts.isIdentifier(node)) {
1122
- const initializer = resolveArgumentInitializer(ts, node, 0);
1123
- if (initializer !== undefined) {
1124
- return { ...sqlExpressionShape(ts, initializer, scope), literal: false };
2066
+ const read = variableWrites(ts, node);
2067
+ // Read inside one of its own writes (`q = q + more`), the variable is the text the other writes
2068
+ // already account for.
2069
+ if (read !== undefined && trail.includes(read.key))
2070
+ return { ...textShape(""), literal: false };
2071
+ if (read !== undefined && trail.length <= CONST_RESOLUTION_DEPTH) {
2072
+ const written = writtenShape(ts, node.text, read.writes, scope, [...trail, read.key]);
2073
+ return { ...written, literal: false };
1125
2074
  }
1126
2075
  }
1127
- return {
1128
- staticText: "",
1129
- dynamic: true,
1130
- concatenated: false,
1131
- literal: false,
1132
- parameterizedTag: false,
1133
- };
2076
+ return UNKNOWN_SQL;
1134
2077
  }
1135
2078
  function sqlMethod(ts, call) {
1136
- if (ts.isPropertyAccessExpression(call.expression)) {
1137
- const method = call.expression.name.text;
1138
- return SQL_METHODS.has(method)
1139
- ? { method, receiver: call.expression.expression.getText() }
1140
- : undefined;
1141
- }
1142
- if (ts.isElementAccessExpression(call.expression) &&
1143
- call.expression.argumentExpression !== undefined &&
1144
- ts.isStringLiteral(call.expression.argumentExpression)) {
1145
- const method = call.expression.argumentExpression.text;
1146
- return SQL_METHODS.has(method)
1147
- ? { method, receiver: call.expression.expression.getText() }
1148
- : undefined;
2079
+ const sink = (method, receiver) => SQL_METHODS.has(method) || DRIVER_READ_METHODS.has(method)
2080
+ ? { method, receiver: receiver?.getText() }
2081
+ : undefined;
2082
+ const callee = call.expression;
2083
+ if (ts.isIdentifier(callee))
2084
+ return sink(callee.text, undefined);
2085
+ if (ts.isPropertyAccessExpression(callee))
2086
+ return sink(callee.name.text, callee.expression);
2087
+ if (ts.isElementAccessExpression(callee) && ts.isStringLiteralLike(callee.argumentExpression)) {
2088
+ return sink(callee.argumentExpression.text, callee.expression);
1149
2089
  }
1150
2090
  return undefined;
1151
2091
  }
1152
2092
  const SQL_RECEIVER = /(?:^|\.)(?:db|sql|prisma|drizzle|database|conn|connection|client|tx)$/i;
1153
- /** The opt-out pragma that silences one interpolated-SQL finding. Written `// nifra-expect sql-dynamic:
1154
- * <reason>` on the flagged line or in the comment block directly above it; the reason after the colon
1155
- * is mandatory (see {@link commentBlockMarkerReason}). Scoped to the single statement it sits on. */
1156
- const SQL_DYNAMIC_PRAGMA = "nifra-expect sql-dynamic";
2093
+ /** Whether a statement is SQL assembled from runtime values, judged by where it is sent. */
2094
+ function isAssembledSql(sink, shape) {
2095
+ if (!shape.dynamic)
2096
+ return false;
2097
+ if (sink.receiver === undefined || DRIVER_READ_METHODS.has(sink.method)) {
2098
+ return STATEMENT_START.test(shape.staticText);
2099
+ }
2100
+ return (SQL_KEYWORD.test(shape.staticText) || (shape.concatenated && SQL_RECEIVER.test(sink.receiver)));
2101
+ }
2102
+ /** The statement a call passes: the argument itself, or the `text`/`sql` of a config object
2103
+ * (`{ text, values }` in pg, `{ sql, values }` in mysql2). */
2104
+ function statementArgument(ts, argument) {
2105
+ const node = unwrapExpression(ts, argument);
2106
+ if (!ts.isObjectLiteralExpression(node))
2107
+ return argument;
2108
+ for (const property of node.properties) {
2109
+ if (ts.isShorthandPropertyAssignment(property) && STATEMENT_KEYS.has(property.name.text)) {
2110
+ return property.name;
2111
+ }
2112
+ if (ts.isPropertyAssignment(property) &&
2113
+ (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) &&
2114
+ STATEMENT_KEYS.has(property.name.text)) {
2115
+ return property.initializer;
2116
+ }
2117
+ }
2118
+ return undefined;
2119
+ }
2120
+ const STATEMENT_KEYS = new Set(["text", "sql"]);
2121
+ /** Same-file functions that send one of their parameters as a statement - `run(db, q)` calling
2122
+ * `db.query(q)` - by name, with the parameter's position and the sink it reaches. A call to one is
2123
+ * scanned as a call to that sink. One level deep. */
2124
+ function statementHelpers(ts, source) {
2125
+ const helpers = new Map();
2126
+ const visit = (node) => {
2127
+ const sink = ts.isCallExpression(node) ? sqlMethod(ts, node) : undefined;
2128
+ const passed = sink !== undefined && ts.isCallExpression(node) ? node.arguments[0] : undefined;
2129
+ const argument = passed === undefined ? undefined : statementArgument(ts, passed);
2130
+ const statement = argument === undefined ? undefined : unwrapExpression(ts, argument);
2131
+ if (sink !== undefined && statement !== undefined && ts.isIdentifier(statement)) {
2132
+ const binding = nameBinding(ts, statement);
2133
+ const name = binding.kind === "parameter" ? functionName(ts, binding.fn) : undefined;
2134
+ if (binding.kind === "parameter" && name !== undefined && !helpers.has(name)) {
2135
+ helpers.set(name, { position: binding.position, sink });
2136
+ }
2137
+ }
2138
+ ts.forEachChild(node, visit);
2139
+ };
2140
+ visit(source);
2141
+ return helpers;
2142
+ }
2143
+ function functionName(ts, fn) {
2144
+ if (ts.isFunctionDeclaration(fn))
2145
+ return fn.name?.text;
2146
+ if ((ts.isArrowFunction(fn) || ts.isFunctionExpression(fn)) &&
2147
+ ts.isVariableDeclaration(fn.parent) &&
2148
+ ts.isIdentifier(fn.parent.name)) {
2149
+ return fn.parent.name.text;
2150
+ }
2151
+ return undefined;
2152
+ }
2153
+ /** `content` with every string, template and regex literal blanked, line breaks kept. */
2154
+ function literalsBlanked(ts, source, content) {
2155
+ const out = content.split("");
2156
+ const visit = (node) => {
2157
+ if (ts.isStringLiteralLike(node) ||
2158
+ ts.isTemplateHead(node) ||
2159
+ ts.isTemplateMiddle(node) ||
2160
+ ts.isTemplateTail(node) ||
2161
+ ts.isRegularExpressionLiteral(node) ||
2162
+ ts.isJsxText(node)) {
2163
+ for (let i = node.getStart(source); i < node.end; i++)
2164
+ if (out[i] !== "\n")
2165
+ out[i] = " ";
2166
+ return;
2167
+ }
2168
+ ts.forEachChild(node, visit);
2169
+ };
2170
+ visit(source);
2171
+ return out.join("");
2172
+ }
2173
+ /** What silences one interpolated-SQL finding: `// nifra-expect NF-C006: <reason>` (or the
2174
+ * `sql-dynamic` slug) on the flagged line or in the comment block directly above it. The reason after
2175
+ * the colon is mandatory (see {@link commentBlockExpectation}). Scoped to the single statement it sits on. */
2176
+ const SQL_DYNAMIC_EXPECTATION = ["NF-C006", "sql-dynamic"];
1157
2177
  /**
1158
2178
  * Scan one file's text for SQL assembled by interpolation. Pure + line-accurate.
1159
2179
  *
@@ -1162,6 +2182,10 @@ const SQL_DYNAMIC_PRAGMA = "nifra-expect sql-dynamic";
1162
2182
  * to offer gets. With it, only what {@link createProjectSqlImports} will hand back is followed.
1163
2183
  */
1164
2184
  export function scanInterpolatedSql(file, content, ts, imports) {
2185
+ return scanSqlSites(file, content, ts, imports).filter((site) => site.expected === undefined);
2186
+ }
2187
+ /** {@link scanInterpolatedSql}, keeping the sites an expectation comment silences, with its reason. */
2188
+ export function scanSqlSites(file, content, ts, imports) {
1165
2189
  const out = [];
1166
2190
  const lines = content.split("\n");
1167
2191
  // Malformed source is already a typecheck failure. Do not turn parser recovery nodes into a second,
@@ -1173,26 +2197,36 @@ export function scanInterpolatedSql(file, content, ts, imports) {
1173
2197
  // text cannot carry request data, so the shared-projection idiom (`const COLS = "id, name"` …
1174
2198
  // `${COLS}`) stays quiet while mutable, parameter, shadowed, and unprovable names flag as before.
1175
2199
  const scope = { module: sqlModuleScope(ts, file, source), imports };
2200
+ const helpers = statementHelpers(ts, source);
2201
+ // A marker counts only in a comment: one inside a string the call passes is data, not a reason.
2202
+ let markerLines;
1176
2203
  const visit = (node) => {
1177
2204
  if (ts.isCallExpression(node)) {
1178
- const sink = sqlMethod(ts, node);
1179
- const argument = node.arguments[0];
2205
+ const helper = ts.isIdentifier(node.expression)
2206
+ ? helpers.get(node.expression.text)
2207
+ : undefined;
2208
+ const sink = helper?.sink ?? sqlMethod(ts, node);
2209
+ const passed = node.arguments[helper?.position ?? 0];
2210
+ const argument = passed === undefined ? undefined : statementArgument(ts, passed);
1180
2211
  if (sink !== undefined && argument !== undefined) {
1181
2212
  const shape = sqlExpressionShape(ts, argument, scope);
1182
- const unsafeEscape = ALWAYS_UNSAFE.has(sink.method) && !shape.literal && !shape.parameterizedTag;
1183
- const assembledSql = shape.dynamic &&
1184
- (SQL_KEYWORD.test(shape.staticText) ||
1185
- (shape.concatenated && SQL_RECEIVER.test(sink.receiver)));
1186
- if (unsafeEscape || assembledSql) {
2213
+ // A helper's own hatch call already answers for passing it a variable.
2214
+ const unsafeEscape = helper === undefined &&
2215
+ sink.receiver !== undefined &&
2216
+ ALWAYS_UNSAFE.has(sink.method) &&
2217
+ !shape.literal &&
2218
+ !shape.parameterizedTag;
2219
+ if (unsafeEscape || isAssembledSql(sink, shape)) {
1187
2220
  const index = node.getStart(source);
1188
2221
  const line = source.getLineAndCharacterOfPosition(index).line + 1;
1189
- // Sanctioned escape hatch for genuinely-dynamic-but-bound SQL (batch VALUES placeholder
1190
- // generation, an allowlisted identifier). The reason is mandatory: a bare `// nifra-expect
1191
- // sql-dynamic` with no reason after the colon does NOT silence, so the hatch always leaves a
2222
+ // Sanctioned escape hatch for genuinely-dynamic-but-bound SQL (an allowlisted identifier,
2223
+ // placeholders numbered from data the scan cannot prove numeric). The reason is mandatory: a bare `// nifra-expect
2224
+ // NF-C006` with no reason after the colon does NOT silence, so the hatch always leaves a
1192
2225
  // greppable audit trail instead of becoming a second laundering trick.
1193
- const silenced = commentBlockMarkerReason(lines, line, SQL_DYNAMIC_PRAGMA) !== undefined;
1194
- if (!silenced)
1195
- out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() });
2226
+ markerLines ??= literalsBlanked(ts, source, content).split("\n");
2227
+ const reason = commentBlockExpectation(markerLines, line, SQL_DYNAMIC_EXPECTATION)?.reason;
2228
+ const site = { file, line, snippet: (lines[line - 1] ?? "").trim() };
2229
+ out.push(reason === undefined ? site : { ...site, expected: reason });
1196
2230
  }
1197
2231
  }
1198
2232
  }
@@ -1442,7 +2476,7 @@ export function scanServerOnlyImports(file, content, facts) {
1442
2476
  // Inline `import { type X } from "…"` is erased just like `import type` and is not an edge; a parse
1443
2477
  // failure keeps the lexical edge so the security rule fails closed.
1444
2478
  for (const { specifier, index } of importEdges(content, facts, file)) {
1445
- if (!SERVER_ONLY.test(specifier))
2479
+ if (!isServerOnly(specifier))
1446
2480
  continue;
1447
2481
  const line = lineAt(content, index);
1448
2482
  out.push({ file, line, snippet: (lines[line - 1] ?? "").trim(), specifier });
@@ -1458,21 +2492,24 @@ export function scanServerOnlyImports(file, content, facts) {
1458
2492
  // local module graph, and build the shortest chain to a server-only SINK. It's best-effort: an import
1459
2493
  // that can't be precisely resolved (a bare pkg, a tsconfig path alias) falls back to the direct edge.
1460
2494
  // ---------------------------------------------------------------------------------------------------
1461
- // Server-only specifiers that are TERMINAL sinks - a bare `node:`/`bun:` builtin or a known server-only
1462
- // npm package. These are never local source we can walk into, so the chain ends here. (Same vocabulary
1463
- // as SERVER_ONLY, minus the relative `../db` arm - a relative `db` module IS local source we resolve.)
1464
- 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/;
1465
2495
  // The retired `*.server` convention. Builds refuse such a module in browser code rather than empty it.
1466
2496
  const LEGACY_SERVER_FILE = /\.server(\.[cm]?[jt]sx?)?$/;
1467
2497
  // The explicit poison-import marker (`@nifrajs/web/backend-only`, or its retired `server-only` name) - a
1468
2498
  // module opting into the client-leak guard. A resolved file carrying this side-effect import is a sink.
1469
- const BACKEND_ONLY_MARKER_IMPORT = /import\s+["']@nifrajs\/web\/(?:backend|server)-only["']/;
1470
- // Depth/visited caps keep the walk linear + cycle-safe. A route's server-only dependency sits within a
1471
- // few hops in practice; the bound stops a pathological graph from blowing up the per-file scan.
1472
- const TRANSITIVE_MAX_DEPTH = 8;
1473
- const TRANSITIVE_MAX_VISITED = 200;
1474
- /** A relative module specifier (`./x`, `../y`) - the only kind we resolve + walk into (a bare specifier
1475
- * is either a sink we recognise by name or a third-party dep we don't follow into node_modules). */
2499
+ const BACKEND_ONLY_MARKER_IMPORT = /import\s+["']@nifrajs\/web\/(?:backend|server)-only["']/g;
2500
+ /** Whether `content` imports the backend-only marker in code. A mention in a comment or a string,
2501
+ * such as a note on how to opt in, marks nothing. */
2502
+ function hasBackendOnlyMarker(content) {
2503
+ if (!content.includes("-only"))
2504
+ return false;
2505
+ const positions = codePositionMask(content);
2506
+ for (const match of content.matchAll(BACKEND_ONLY_MARKER_IMPORT)) {
2507
+ if (positions[match.index] !== " ")
2508
+ return true;
2509
+ }
2510
+ return false;
2511
+ }
2512
+ /** A relative module specifier (`./x`, `../y`) - the only kind the SQL import lookup follows. */
1476
2513
  const isRelativeSpecifier = (spec) => spec.startsWith("./") || spec.startsWith("../");
1477
2514
  // A FRESH copy of the static-import regex per scan. The transitive walk is REENTRANT - the outer
1478
2515
  // per-route loop and the inner BFS both scan imports - and a single shared global-flag regex carries
@@ -1534,14 +2571,37 @@ function dynamicImportEdges(content, facts, file, positions = codePositionMask(c
1534
2571
  }
1535
2572
  return edges;
1536
2573
  }
1537
- /** Every import a module's runtime code makes - static and literal dynamic - in source order. Pure. */
2574
+ /** The literal `require("x")` calls in executable code. Pure. */
2575
+ function requireEdges(content, positions) {
2576
+ const edges = [];
2577
+ const re = new RegExp(REQUIRE_CALL.source, REQUIRE_CALL.flags);
2578
+ for (let m = re.exec(content); m !== null; m = re.exec(content)) {
2579
+ if (positions[m.index] === " ")
2580
+ continue; // inside a comment or a string
2581
+ const specifier = m[2];
2582
+ if (specifier === undefined)
2583
+ continue;
2584
+ if (TYPE_ALIAS_BEFORE.test(content.slice(Math.max(0, m.index - 80), m.index)))
2585
+ continue;
2586
+ edges.push({ specifier, index: m.index });
2587
+ }
2588
+ return edges;
2589
+ }
2590
+ /** Every import a module's runtime code makes - static, literal dynamic and `require` - in source
2591
+ * order. Pure. */
1538
2592
  function importEdges(content, facts, file) {
1539
- // Without an `import(` there is nothing for the dynamic pass to find; skip its regex.
1540
2593
  const positions = codePositionMask(content);
1541
2594
  const edges = staticImportEdges(content, facts, file, positions);
1542
- if (!content.includes("import(") && !/import\s+\(/.test(content))
2595
+ // Without an `import(` or a `require` there is nothing for the other passes to find; skip them.
2596
+ const dynamic = content.includes("import(") || /import\s+\(/.test(content);
2597
+ const required = content.includes("require");
2598
+ if (!dynamic && !required)
1543
2599
  return edges;
1544
- return [...edges, ...dynamicImportEdges(content, facts, file, positions)].sort((a, b) => a.index - b.index);
2600
+ if (dynamic)
2601
+ edges.push(...dynamicImportEdges(content, facts, file, positions));
2602
+ if (required)
2603
+ edges.push(...requireEdges(content, positions));
2604
+ return edges.sort((a, b) => a.index - b.index);
1545
2605
  }
1546
2606
  /** The runtime imports of a module with the line each sits on, in source order. Pure. */
1547
2607
  export function importSites(content, file) {
@@ -1558,63 +2618,130 @@ export function parseImports(content, facts, file) {
1558
2618
  * server-only package), or `undefined` if it isn't a by-name sink. Pure - the label is the specifier
1559
2619
  * itself (it's already the actionable name). */
1560
2620
  export function directSinkSpecifier(spec) {
1561
- return SERVER_ONLY_SINK.test(spec) ? spec : undefined;
2621
+ return isServerOnlySink(spec) ? spec : undefined;
1562
2622
  }
1563
2623
  /**
1564
- * BFS the LOCAL module graph from a route file for the SHORTEST import chain that reaches a server-only
1565
- * sink, returning `[routeFile, …as-written specifiers…, sink]` or `undefined` if none is reachable. A
1566
- * node's outgoing edges are its static and literal dynamic imports; an edge is followed only when it's a RELATIVE specifier
1567
- * that `resolve` maps to a readable local file (so the walk never descends into node_modules or chases an
1568
- * unresolvable alias). At each node, a by-name sink import (`node:fs`, `postgres`) OR a resolved
1569
- * `server-only`-marked dependency terminates the chain; a `*.server` dependency is never entered, since
1570
- * the client build empties it. Bounded by depth + a visited set, so
1571
- * it's linear and cycle-free. `routeFile`/`routeContent` seed the walk; `resolve`/`read` supply the graph
1572
- * - pure given those, so it's unit-testable with a fake graph.
2624
+ * Every module is read, parsed and resolved at most once, and the chain from a module is searched at
2625
+ * most once, however many routes reach it: per-route searches repeated all of that for every route
2626
+ * that shared a module, which made a check quadratic in the size of a shared graph.
1573
2627
  */
1574
- export function walkServerOnlyChain(routeFile, routeContent, resolve, read, facts) {
1575
- const seen = new Set([routeFile]);
1576
- let frontier = [{ abs: routeFile, content: routeContent, chain: [routeFile], depth: 0 }];
1577
- let visited = 0;
1578
- while (frontier.length > 0) {
1579
- const next = [];
1580
- for (const node of frontier) {
1581
- if (node.depth >= TRANSITIVE_MAX_DEPTH)
1582
- continue;
1583
- for (const spec of parseImports(node.content, facts, node.abs)) {
1584
- // (a) A by-name sink (builtin / known server-only pkg) → the chain ends here (shortest first,
1585
- // since BFS reaches the nearest sink before any deeper one).
1586
- const sink = directSinkSpecifier(spec);
1587
- if (sink !== undefined)
1588
- return [...node.chain, sink];
1589
- // (b) Only relative specifiers are local source we resolve + walk into. A bare third-party
1590
- // specifier that isn't a known sink is a leaf - don't follow it into node_modules.
1591
- if (!isRelativeSpecifier(spec))
1592
- continue;
1593
- const abs = resolve(node.abs, spec);
1594
- if (abs === undefined || seen.has(abs))
1595
- continue;
1596
- // (c) Backend code is a sink: the browser build refuses it. A `*.fn.ts` module ships as its
1597
- // generated stub, so its imports never reach the browser and the walk does not enter it.
1598
- if (isBackendModule(abs))
1599
- return [...node.chain, spec];
1600
- if (SERVER_FN_FILE.test(abs))
1601
- continue;
1602
- const content = read(abs);
1603
- if (content === undefined)
1604
- continue; // unreadable → can't walk; treat as a leaf
1605
- // (d) A resolved module that opts into the `backend-only` marker is a sink too.
1606
- if (BACKEND_ONLY_MARKER_IMPORT.test(content))
1607
- return [...node.chain, spec];
1608
- if (visited >= TRANSITIVE_MAX_VISITED)
1609
- continue;
1610
- visited++;
1611
- seen.add(abs);
1612
- next.push({ abs, content, chain: [...node.chain, spec], depth: node.depth + 1 });
2628
+ function chainGraph(source) {
2629
+ const { facts } = source;
2630
+ const componentReceipts = source.componentReceipts ?? new Set();
2631
+ const contents = new Map();
2632
+ const imports = new Map();
2633
+ const resolutions = new Map();
2634
+ const walks = new Map();
2635
+ const packages = new Map();
2636
+ const read = (abs) => {
2637
+ if (!contents.has(abs))
2638
+ contents.set(abs, source.read(abs));
2639
+ return contents.get(abs);
2640
+ };
2641
+ const resolve = (from, specifier) => {
2642
+ const key = `${from}\0${specifier}`;
2643
+ if (!resolutions.has(key))
2644
+ resolutions.set(key, source.resolve(from, specifier));
2645
+ return resolutions.get(key);
2646
+ };
2647
+ const backendPackage = (from, specifier) => {
2648
+ if (source.backendPackage === undefined || /^[./#]/.test(specifier))
2649
+ return false;
2650
+ const key = `${from}\0${specifier}`;
2651
+ let found = packages.get(key);
2652
+ if (found === undefined) {
2653
+ found = source.backendPackage(from, specifier);
2654
+ packages.set(key, found);
2655
+ }
2656
+ return found;
2657
+ };
2658
+ const importsOf = (abs, content) => {
2659
+ let found = imports.get(abs);
2660
+ if (found === undefined) {
2661
+ found = parseImports(content, facts, abs);
2662
+ imports.set(abs, found);
2663
+ }
2664
+ return found;
2665
+ };
2666
+ /**
2667
+ * Breadth-first, so the first sink found is the nearest. A node's edges are its static and literal
2668
+ * dynamic imports; an edge is followed only into a project file `resolve` names, an alias included,
2669
+ * never into node_modules. A by-name sink import (`node:fs`, `postgres`), backend code, or a module
2670
+ * importing the `backend-only` marker ends the chain; a `*.fn.ts` module ships as its stub and is not
2671
+ * entered. The visited set keeps it cycle-free; there is no depth or size cap to cut a chain short.
2672
+ */
2673
+ const search = (start, startContent) => {
2674
+ const seen = new Set([start]);
2675
+ let frontier = [{ abs: start, content: startContent, chain: [start], modules: [start] }];
2676
+ while (frontier.length > 0) {
2677
+ const next = [];
2678
+ for (const node of frontier) {
2679
+ const { chain, modules } = node;
2680
+ for (const spec of importsOf(node.abs, node.content)) {
2681
+ const sink = directSinkSpecifier(spec);
2682
+ if (sink !== undefined)
2683
+ return { chain: [...chain, sink], modules };
2684
+ const abs = resolve(node.abs, spec);
2685
+ if (abs === undefined) {
2686
+ if (backendPackage(node.abs, spec))
2687
+ return { chain: [...chain, spec], modules };
2688
+ continue;
2689
+ }
2690
+ if (seen.has(abs))
2691
+ continue;
2692
+ if (componentReceipts.has(abs))
2693
+ continue;
2694
+ if (isBackendModule(abs))
2695
+ return { chain: [...chain, spec], modules: [...modules, abs] };
2696
+ if (SERVER_FN_FILE.test(abs))
2697
+ continue;
2698
+ const content = read(abs);
2699
+ if (content === undefined)
2700
+ continue;
2701
+ if (hasBackendOnlyMarker(content)) {
2702
+ return { chain: [...chain, spec], modules: [...modules, abs] };
2703
+ }
2704
+ seen.add(abs);
2705
+ next.push({ abs, content, chain: [...chain, spec], modules: [...modules, abs] });
2706
+ }
1613
2707
  }
2708
+ frontier = next;
1614
2709
  }
1615
- frontier = next;
1616
- }
1617
- return undefined;
2710
+ return undefined;
2711
+ };
2712
+ return {
2713
+ walk(start, content) {
2714
+ if (!walks.has(start))
2715
+ walks.set(start, search(start, content));
2716
+ return walks.get(start);
2717
+ },
2718
+ read,
2719
+ resolve,
2720
+ backendPackage,
2721
+ componentReceipts,
2722
+ facts,
2723
+ };
2724
+ }
2725
+ /**
2726
+ * The shortest import chain from `routeFile` to a server-only sink, `[routeFile, ...as-written
2727
+ * specifiers, sink]`, or `undefined` when none is reachable. Pure given `resolve`/`read`, so a fake
2728
+ * graph tests it.
2729
+ */
2730
+ export function walkServerOnlyChain(routeFile, routeContent, resolve, read, facts, componentReceipts = new Set()) {
2731
+ return chainGraph({
2732
+ resolve,
2733
+ read,
2734
+ facts,
2735
+ componentReceipts,
2736
+ }).walk(routeFile, routeContent)?.chain;
2737
+ }
2738
+ /**
2739
+ * The server-only chain finder for one check: called per browser module, it returns that module's
2740
+ * findings, reusing every module and chain an earlier call already searched.
2741
+ */
2742
+ export function createServerOnlyChains(source) {
2743
+ const graph = chainGraph(source);
2744
+ return (file, content, browserRoot = false) => serverOnlyChains(graph, file, content, browserRoot);
1618
2745
  }
1619
2746
  /**
1620
2747
  * Resolve the FULL transitive server-only chain(s) for a route module, given a real fs-backed resolver +
@@ -1622,77 +2749,76 @@ export function walkServerOnlyChain(routeFile, routeContent, resolve, read, fact
1622
2749
  * finding carries the full chain; otherwise - when the first hop is itself a direct by-name sink the
1623
2750
  * regex scan already flags, or when a relative import can't be resolved - it falls back to the DIRECT
1624
2751
  * edge (`fallback: true`), never a fabricated chain. Returns one finding per offending top-level import,
1625
- * de-duplicated to the shortest chain per first-hop specifier. Non-route files yield `[]`.
2752
+ * de-duplicated to the shortest chain per first-hop specifier. A file that is not browser source
2753
+ * yields `[]`, unless `browserRoot` says the browser bundle loads it anyway (an app's `clientModule`).
2754
+ * One check should use {@link createServerOnlyChains}, which shares the work across modules.
1626
2755
  */
1627
- export function resolveServerOnlyChains(file, content, resolve, read, facts) {
1628
- if (!isBrowserSource(file))
2756
+ export function resolveServerOnlyChains(file, content, resolve, read, facts, componentReceipts = new Set(), browserRoot = false) {
2757
+ return createServerOnlyChains({
2758
+ resolve,
2759
+ read,
2760
+ facts,
2761
+ componentReceipts,
2762
+ })(file, content, browserRoot);
2763
+ }
2764
+ function serverOnlyChains(graph, file, content, browserRoot) {
2765
+ if (!browserRoot && !isBrowserSource(file))
1629
2766
  return [];
1630
2767
  const lines = content.split("\n");
1631
2768
  const out = [];
1632
2769
  const flaggedSpecifiers = new Set();
1633
- // Collect the route's import edges up front (fresh-regex scan) so the per-edge logic below can call
1634
- // the REENTRANT transitive walk without corrupting a shared regex's `lastIndex` (the walk also scans
1635
- // imports). Driving `STATIC_IMPORT.exec` here directly would restart this loop forever.
1636
- for (const { specifier, index } of importEdges(content, facts, file)) {
2770
+ for (const { specifier, index } of importEdges(content, graph.facts, file)) {
1637
2771
  if (flaggedSpecifiers.has(specifier))
1638
2772
  continue;
1639
2773
  const line = lineAt(content, index);
1640
2774
  const snippet = (lines[line - 1] ?? "").trim();
1641
- // (1) A direct by-name sink (`node:fs`, `postgres`) - the route imports it itself; chain is the
1642
- // direct edge. (Length-2 chain == the regex scan's existing `[route, specifier]`.)
1643
- if (directSinkSpecifier(specifier) !== undefined) {
2775
+ const flag = (chain, fallback, modules = [file]) => {
1644
2776
  flaggedSpecifiers.add(specifier);
1645
- out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: false });
2777
+ out.push({ file, line, snippet, specifier, chain, modules, fallback });
2778
+ };
2779
+ // (1) A direct by-name sink (`node:fs`, `postgres`): the route imports it itself.
2780
+ if (directSinkSpecifier(specifier) !== undefined) {
2781
+ flag([file, specifier], false);
1646
2782
  continue;
1647
2783
  }
1648
- // (2) A relative local import - try the transitive walk from the resolved dependency. If it reaches
1649
- // a sink, emit the full chain rooted at THIS route's import line.
1650
- if (isRelativeSpecifier(specifier)) {
1651
- const abs = resolve(file, specifier);
1652
- if (abs === undefined) {
1653
- // Unresolvable relative import. If it's the known server-only `../db` convention the regex scan
1654
- // flags directly, fall back to the direct edge (precise resolve impossible - say so via
1655
- // `fallback`). Any other unresolvable relative import we can't assert is server-only - skip it.
1656
- if (SERVER_ONLY.test(specifier)) {
1657
- flaggedSpecifiers.add(specifier);
1658
- out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: true });
1659
- }
1660
- continue;
1661
- }
1662
- // A backend first hop and a marked module are sinks; a `*.fn.ts` hop ships as its stub.
1663
- if (isBackendModule(abs)) {
1664
- flaggedSpecifiers.add(specifier);
1665
- out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: false });
1666
- continue;
1667
- }
1668
- if (SERVER_FN_FILE.test(abs))
1669
- continue;
1670
- const depContent = read(abs);
1671
- if (depContent === undefined)
1672
- continue;
1673
- if (BACKEND_ONLY_MARKER_IMPORT.test(depContent)) {
1674
- flaggedSpecifiers.add(specifier);
1675
- out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: false });
1676
- continue;
1677
- }
1678
- // Walk INTO the dependency: build the chain `[depAbs, …]` then re-root it at the route's import.
1679
- const subChain = walkServerOnlyChain(abs, depContent, resolve, read, facts);
1680
- if (subChain !== undefined) {
1681
- flaggedSpecifiers.add(specifier);
1682
- // subChain is `[depAbs, …hops…, sink]`; replace its head (the resolved dep path) with the
1683
- // route's as-written specifier so the chain reads `routes/x → ../data → ../db → node:crypto`.
1684
- const chain = [file, specifier, ...subChain.slice(1)];
1685
- out.push({ file, line, snippet, specifier, chain, fallback: false });
1686
- continue;
1687
- }
1688
- // (3) The relative `../db`-style convention the regex scan flags directly, but where the walk
1689
- // found no deeper sink (e.g. the file wasn't readable past the first hop) - fall back to the
1690
- // direct edge so we still surface the known-server-only convention rather than going silent.
1691
- if (SERVER_ONLY.test(specifier)) {
1692
- flaggedSpecifiers.add(specifier);
1693
- out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: true });
1694
- }
2784
+ // (2) A project import, relative or through an alias: walk from the resolved dependency and, if
2785
+ // it reaches a sink, report the full chain rooted at THIS route's import line.
2786
+ const abs = graph.resolve(file, specifier);
2787
+ if (abs === undefined) {
2788
+ // An installed package that declares itself backend is a sink. Any other name no project file
2789
+ // answers to can't be called server-only, except the `../db` convention, reported as the direct
2790
+ // edge with `fallback` saying the resolve was impossible.
2791
+ if (graph.backendPackage(file, specifier))
2792
+ flag([file, specifier], false);
2793
+ else if (isServerOnly(specifier))
2794
+ flag([file, specifier], true);
2795
+ continue;
2796
+ }
2797
+ if (graph.componentReceipts.has(abs))
2798
+ continue;
2799
+ // A backend first hop and a marked module are sinks; a `*.fn.ts` hop ships as its stub.
2800
+ if (isBackendModule(abs)) {
2801
+ flag([file, specifier], false, [file, abs]);
2802
+ continue;
2803
+ }
2804
+ if (SERVER_FN_FILE.test(abs))
2805
+ continue;
2806
+ const depContent = graph.read(abs);
2807
+ if (depContent === undefined)
2808
+ continue;
2809
+ if (hasBackendOnlyMarker(depContent)) {
2810
+ flag([file, specifier], false, [file, abs]);
2811
+ continue;
2812
+ }
2813
+ const sub = graph.walk(abs, depContent);
2814
+ if (sub !== undefined) {
2815
+ // `sub` starts at the resolved dependency; the route names it by its as-written specifier.
2816
+ flag([file, specifier, ...sub.chain.slice(1)], false, [file, ...sub.modules]);
2817
+ continue;
1695
2818
  }
2819
+ // (3) The `../db` convention with no deeper sink found: still surface the direct edge.
2820
+ if (isServerOnly(specifier))
2821
+ flag([file, specifier], true);
1696
2822
  }
1697
2823
  return out;
1698
2824
  }
@@ -1721,22 +2847,19 @@ export function scanResponseRoutes(file, content, facts) {
1721
2847
  continue;
1722
2848
  }
1723
2849
  const line = lineAt(content, m.index);
1724
- // A `// nifra-expect raw-response` pragma marks an intentional raw Response (a file/redirect that can't
1725
- // be a typed route), silencing this advisory. Honor it on the return line itself, OR on the line above
1726
- // ONLY when that line is a standalone comment - so a TRAILING pragma on one route's return line can't
1727
- // leak down and suppress the very next route's genuine advisory. `stripComments` blanks the pragma in
1728
- // `code` (so it never affects the match); the raw `lines` still carry it.
1729
- const thisLine = lines[line - 1] ?? "";
1730
- const aboveLine = lines[line - 2] ?? "";
1731
- const aboveSuppresses = aboveLine.trimStart().startsWith("//") && aboveLine.includes(RAW_RESPONSE_PRAGMA);
1732
- if (thisLine.includes(RAW_RESPONSE_PRAGMA) || aboveSuppresses)
2850
+ // A `// nifra-expect NF-C007` comment (or the `raw-response` slug) marks an intentional raw Response
2851
+ // (a file/redirect that can't be a typed route), silencing this advisory with or without a reason.
2852
+ // It counts on the return line or in the comment block directly above, so a TRAILING comment on one
2853
+ // route's return line can't leak down onto the next route. `stripComments` blanks it in `code` (so it
2854
+ // never affects the match); the raw `lines` still carry it.
2855
+ if (commentBlockExpectation(lines, line, RAW_RESPONSE_EXPECTATION) !== undefined)
1733
2856
  continue;
1734
- out.push({ file, line, snippet: thisLine.trim() });
2857
+ out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() });
1735
2858
  }
1736
2859
  return out;
1737
2860
  }
1738
- /** The opt-out pragma that suppresses the {@link scanResponseRoutes} advisory for an intentional raw Response. */
1739
- const RAW_RESPONSE_PRAGMA = "nifra-expect raw-response";
2861
+ /** What suppresses the {@link scanResponseRoutes} advisory for an intentional raw Response. */
2862
+ const RAW_RESPONSE_EXPECTATION = ["NF-C007", "raw-response"];
1740
2863
  /** Walk the project's `.ts`/`.tsx` source (skipping deps and build output), calling `visit` per file.
1741
2864
  * Exported so `nifra doctor` ({@link ./doctor.ts}) scans the same source surface as `nifra check`.
1742
2865
  *
@@ -1777,11 +2900,17 @@ async function gitIgnored(cwd, rels) {
1777
2900
  }
1778
2901
  export async function walkSource(cwd, visit, opts = {}) {
1779
2902
  const skip = opts.includeTests === true ? IGNORED_DIR : IGNORED;
2903
+ const pattern = opts.scripts === true
2904
+ ? "**/*.{ts,tsx,mts,cts,jsx,js,mjs,cjs,svelte,vue}"
2905
+ : "**/*.{ts,tsx,mts,cts}";
1780
2906
  // List candidates first (cheap - no reads), drop the built-in ignores, then exclude gitignored paths in
1781
2907
  // one batch before the (expensive) reads. So a gitignored generated/build tree is never read or scanned.
1782
2908
  const rels = [];
1783
- for await (const rawRel of new Glob("**/*.{ts,tsx,mts,cts}").scan({ cwd, dot: false })) {
2909
+ for await (const rawRel of new Glob(pattern).scan({ cwd, dot: false })) {
1784
2910
  const rel = normalizeProjectPath(rawRel);
2911
+ // A `.js` file outside a zone is tooling (a bundler config, a script, a vendored asset), not app code.
2912
+ if (PLAIN_SCRIPT_FILE.test(rel) && !ZONED_PATH.test(rel))
2913
+ continue;
1785
2914
  if (!skip.test(rel) && (opts.ignore === undefined || !opts.ignore(rel)))
1786
2915
  rels.push(rel);
1787
2916
  }
@@ -1790,8 +2919,44 @@ export async function walkSource(cwd, visit, opts = {}) {
1790
2919
  for (const rel of rels.sort(codeUnitOrder)) {
1791
2920
  if (ignored.has(rel))
1792
2921
  continue;
1793
- visit(rel, await Bun.file(join(cwd, rel)).text());
2922
+ const text = await Bun.file(join(cwd, rel)).text();
2923
+ visit(rel, COMPONENT_FILE.test(rel) ? componentScripts(text) : text);
2924
+ }
2925
+ }
2926
+ const PLAIN_SCRIPT_FILE = /\.[cm]?js$/;
2927
+ const ZONED_PATH = /(^|\/)(?:routes|frontend|shared|backend)\/|\.(?:frontend|shared|backend|fn)\.[^/]+$/;
2928
+ /** A single-file component: its `<script>` blocks are the code a build compiles. */
2929
+ export const COMPONENT_FILE = /\.(?:svelte|vue)$/;
2930
+ /**
2931
+ * A `.svelte` or `.vue` file as the source scanners read it: the text of its `<script>` blocks where
2932
+ * they stand, and every other character blanked to a space with line breaks kept, so a finding's line
2933
+ * and column are the file's own. An unclosed `<script>` runs to the end of the file. Pure.
2934
+ */
2935
+ export function componentScripts(text) {
2936
+ const blank = (part) => part.replace(/[^\n]/g, " ");
2937
+ let out = "";
2938
+ let at = 0;
2939
+ for (;;) {
2940
+ const open = text.indexOf("<script", at);
2941
+ if (open === -1)
2942
+ break;
2943
+ const close = text.indexOf(">", open);
2944
+ if (close === -1)
2945
+ break;
2946
+ // `<scripts>` or `<script-x>` is another element.
2947
+ if (!/[\s>]/.test(text[open + 7] ?? "")) {
2948
+ out += blank(text.slice(at, close + 1));
2949
+ at = close + 1;
2950
+ continue;
2951
+ }
2952
+ const end = text.indexOf("</script", close + 1);
2953
+ const stop = end === -1 ? text.length : end;
2954
+ out += blank(text.slice(at, close + 1)) + text.slice(close + 1, stop);
2955
+ at = stop;
2956
+ if (end === -1)
2957
+ break;
1794
2958
  }
2959
+ return out + blank(text.slice(at));
1795
2960
  }
1796
2961
  const bySite = (a, b) => codeUnitOrder(a.file, b.file) || a.line - b.line;
1797
2962
  /** Collect own-API `fetch()` findings across the project. */