@nifrajs/cli 4.0.2 → 5.1.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 (364) hide show
  1. package/README.md +10 -2
  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 +9 -2
  12. package/dist/assure-hydration.js.map +1 -1
  13. package/dist/assure.d.ts +12 -2
  14. package/dist/assure.d.ts.map +1 -1
  15. package/dist/assure.js +59 -8
  16. package/dist/assure.js.map +1 -1
  17. package/dist/capabilities-tool.d.ts.map +1 -1
  18. package/dist/capabilities-tool.js +36 -9
  19. package/dist/capabilities-tool.js.map +1 -1
  20. package/dist/check-diagnostics.d.ts +22 -2
  21. package/dist/check-diagnostics.d.ts.map +1 -1
  22. package/dist/check-diagnostics.js +54 -7
  23. package/dist/check-diagnostics.js.map +1 -1
  24. package/dist/check-scan.d.ts +83 -24
  25. package/dist/check-scan.d.ts.map +1 -1
  26. package/dist/check-scan.js +1516 -318
  27. package/dist/check-scan.js.map +1 -1
  28. package/dist/check.d.ts +2 -2
  29. package/dist/check.d.ts.map +1 -1
  30. package/dist/check.js +203 -70
  31. package/dist/check.js.map +1 -1
  32. package/dist/cli.d.ts +9 -0
  33. package/dist/cli.d.ts.map +1 -1
  34. package/dist/cli.js +215 -50
  35. package/dist/cli.js.map +1 -1
  36. package/dist/client-module.d.ts +20 -0
  37. package/dist/client-module.d.ts.map +1 -0
  38. package/dist/client-module.js +73 -0
  39. package/dist/client-module.js.map +1 -0
  40. package/dist/command-catalog.d.ts +24 -5
  41. package/dist/command-catalog.d.ts.map +1 -1
  42. package/dist/command-catalog.js +194 -45
  43. package/dist/command-catalog.js.map +1 -1
  44. package/dist/component-check.d.ts +13 -0
  45. package/dist/component-check.d.ts.map +1 -0
  46. package/dist/component-check.js +59 -0
  47. package/dist/component-check.js.map +1 -0
  48. package/dist/contract-proof.d.ts +1 -1
  49. package/dist/contract-proof.d.ts.map +1 -1
  50. package/dist/contract-proof.js +2 -24
  51. package/dist/contract-proof.js.map +1 -1
  52. package/dist/contracts.d.ts +19 -0
  53. package/dist/contracts.d.ts.map +1 -1
  54. package/dist/contracts.js +50 -3
  55. package/dist/contracts.js.map +1 -1
  56. package/dist/db-tool.d.ts.map +1 -1
  57. package/dist/db-tool.js +4 -3
  58. package/dist/db-tool.js.map +1 -1
  59. package/dist/dev-bun-config.d.ts +5 -1
  60. package/dist/dev-bun-config.d.ts.map +1 -1
  61. package/dist/dev-bun-config.js +44 -11
  62. package/dist/dev-bun-config.js.map +1 -1
  63. package/dist/dev-feed-tool.d.ts +18 -1
  64. package/dist/dev-feed-tool.d.ts.map +1 -1
  65. package/dist/dev-feed-tool.js +93 -0
  66. package/dist/dev-feed-tool.js.map +1 -1
  67. package/dist/diagnostics.d.ts +3 -0
  68. package/dist/diagnostics.d.ts.map +1 -1
  69. package/dist/diagnostics.js.map +1 -1
  70. package/dist/diff-tool.d.ts +9 -8
  71. package/dist/diff-tool.d.ts.map +1 -1
  72. package/dist/diff-tool.js +28 -24
  73. package/dist/diff-tool.js.map +1 -1
  74. package/dist/doctor.d.ts +13 -0
  75. package/dist/doctor.d.ts.map +1 -1
  76. package/dist/doctor.js +23 -15
  77. package/dist/doctor.js.map +1 -1
  78. package/dist/env-file.d.ts +6 -0
  79. package/dist/env-file.d.ts.map +1 -1
  80. package/dist/env-file.js +6 -0
  81. package/dist/env-file.js.map +1 -1
  82. package/dist/fix-recipes.d.ts +2 -0
  83. package/dist/fix-recipes.d.ts.map +1 -1
  84. package/dist/fix-recipes.js +102 -2
  85. package/dist/fix-recipes.js.map +1 -1
  86. package/dist/hand-built-manifest.d.ts +27 -0
  87. package/dist/hand-built-manifest.d.ts.map +1 -0
  88. package/dist/hand-built-manifest.js +124 -0
  89. package/dist/hand-built-manifest.js.map +1 -0
  90. package/dist/hooks-report.d.ts +23 -0
  91. package/dist/hooks-report.d.ts.map +1 -0
  92. package/dist/hooks-report.js +104 -0
  93. package/dist/hooks-report.js.map +1 -0
  94. package/dist/init-agents.d.ts +18 -2
  95. package/dist/init-agents.d.ts.map +1 -1
  96. package/dist/init-agents.js +20 -13
  97. package/dist/init-agents.js.map +1 -1
  98. package/dist/internal/config-paths.d.ts +16 -0
  99. package/dist/internal/config-paths.d.ts.map +1 -0
  100. package/dist/internal/config-paths.js +124 -0
  101. package/dist/internal/config-paths.js.map +1 -0
  102. package/dist/internal/installed-package.d.ts +4 -0
  103. package/dist/internal/installed-package.d.ts.map +1 -0
  104. package/dist/internal/installed-package.js +17 -0
  105. package/dist/internal/installed-package.js.map +1 -0
  106. package/dist/internal/nifra-project.d.ts +41 -0
  107. package/dist/internal/nifra-project.d.ts.map +1 -0
  108. package/dist/internal/nifra-project.js +183 -0
  109. package/dist/internal/nifra-project.js.map +1 -0
  110. package/dist/internal/project-specifiers.d.ts +29 -0
  111. package/dist/internal/project-specifiers.d.ts.map +1 -0
  112. package/dist/internal/project-specifiers.js +435 -0
  113. package/dist/internal/project-specifiers.js.map +1 -0
  114. package/dist/internal/script-kind.d.ts.map +1 -1
  115. package/dist/internal/script-kind.js +2 -1
  116. package/dist/internal/script-kind.js.map +1 -1
  117. package/dist/internal/source-facts.d.ts +8 -1
  118. package/dist/internal/source-facts.d.ts.map +1 -1
  119. package/dist/internal/source-facts.js +22 -5
  120. package/dist/internal/source-facts.js.map +1 -1
  121. package/dist/internal/typescript-import.d.ts +1 -1
  122. package/dist/internal/typescript-import.d.ts.map +1 -1
  123. package/dist/internal/typescript-import.js +10 -6
  124. package/dist/internal/typescript-import.js.map +1 -1
  125. package/dist/introspect.d.ts +15 -6
  126. package/dist/introspect.d.ts.map +1 -1
  127. package/dist/introspect.js +87 -25
  128. package/dist/introspect.js.map +1 -1
  129. package/dist/levels-tool.d.ts.map +1 -1
  130. package/dist/levels-tool.js +98 -23
  131. package/dist/levels-tool.js.map +1 -1
  132. package/dist/load.d.ts +33 -10
  133. package/dist/load.d.ts.map +1 -1
  134. package/dist/load.js +60 -10
  135. package/dist/load.js.map +1 -1
  136. package/dist/mcp-context.d.ts +5 -5
  137. package/dist/mcp-context.d.ts.map +1 -1
  138. package/dist/mcp-context.js +8 -23
  139. package/dist/mcp-context.js.map +1 -1
  140. package/dist/mcp-delegate.d.ts +2 -2
  141. package/dist/mcp-delegate.d.ts.map +1 -1
  142. package/dist/mcp-delegate.js +19 -7
  143. package/dist/mcp-delegate.js.map +1 -1
  144. package/dist/mcp-exec.d.ts +13 -4
  145. package/dist/mcp-exec.d.ts.map +1 -1
  146. package/dist/mcp-exec.js +56 -14
  147. package/dist/mcp-exec.js.map +1 -1
  148. package/dist/mcp-http.js +1 -1
  149. package/dist/mcp-io.d.ts +30 -0
  150. package/dist/mcp-io.d.ts.map +1 -1
  151. package/dist/mcp-io.js +63 -0
  152. package/dist/mcp-io.js.map +1 -1
  153. package/dist/mcp-isolate.d.ts.map +1 -1
  154. package/dist/mcp-isolate.js +3 -2
  155. package/dist/mcp-isolate.js.map +1 -1
  156. package/dist/mcp-path.d.ts +2 -1
  157. package/dist/mcp-path.d.ts.map +1 -1
  158. package/dist/mcp-path.js +9 -24
  159. package/dist/mcp-path.js.map +1 -1
  160. package/dist/mcp-project-child.js +1 -1
  161. package/dist/mcp-project-child.js.map +1 -1
  162. package/dist/mcp-root.d.ts.map +1 -1
  163. package/dist/mcp-root.js +50 -10
  164. package/dist/mcp-root.js.map +1 -1
  165. package/dist/mcp-ws.d.ts.map +1 -1
  166. package/dist/mcp-ws.js +3 -1
  167. package/dist/mcp-ws.js.map +1 -1
  168. package/dist/mcp.d.ts.map +1 -1
  169. package/dist/mcp.js +48 -19
  170. package/dist/mcp.js.map +1 -1
  171. package/dist/migrate-layout.d.ts +47 -1
  172. package/dist/migrate-layout.d.ts.map +1 -1
  173. package/dist/migrate-layout.js +1972 -170
  174. package/dist/migrate-layout.js.map +1 -1
  175. package/dist/output-schemas.d.ts +128 -0
  176. package/dist/output-schemas.d.ts.map +1 -0
  177. package/dist/output-schemas.js +975 -0
  178. package/dist/output-schemas.js.map +1 -0
  179. package/dist/port.d.ts.map +1 -1
  180. package/dist/port.js +3 -2
  181. package/dist/port.js.map +1 -1
  182. package/dist/project-facts.d.ts +15 -4
  183. package/dist/project-facts.d.ts.map +1 -1
  184. package/dist/project-facts.js +1 -0
  185. package/dist/project-facts.js.map +1 -1
  186. package/dist/project-path.d.ts +6 -0
  187. package/dist/project-path.d.ts.map +1 -1
  188. package/dist/project-path.js +48 -14
  189. package/dist/project-path.js.map +1 -1
  190. package/dist/recipes/5.0.0.d.ts +11 -0
  191. package/dist/recipes/5.0.0.d.ts.map +1 -0
  192. package/dist/recipes/5.0.0.js +34 -0
  193. package/dist/recipes/5.0.0.js.map +1 -0
  194. package/dist/recipes/index.d.ts +7 -0
  195. package/dist/recipes/index.d.ts.map +1 -1
  196. package/dist/recipes/index.js +2 -0
  197. package/dist/recipes/index.js.map +1 -1
  198. package/dist/release-verification.d.ts +5 -0
  199. package/dist/release-verification.d.ts.map +1 -1
  200. package/dist/release-verification.js +14 -0
  201. package/dist/release-verification.js.map +1 -1
  202. package/dist/replay.d.ts.map +1 -1
  203. package/dist/replay.js +12 -3
  204. package/dist/replay.js.map +1 -1
  205. package/dist/review.d.ts +3 -1
  206. package/dist/review.d.ts.map +1 -1
  207. package/dist/review.js +43 -7
  208. package/dist/review.js.map +1 -1
  209. package/dist/rules/codes.d.ts +12 -0
  210. package/dist/rules/codes.d.ts.map +1 -1
  211. package/dist/rules/codes.js +12 -0
  212. package/dist/rules/codes.js.map +1 -1
  213. package/dist/rules/comment-markers.d.ts +16 -7
  214. package/dist/rules/comment-markers.d.ts.map +1 -1
  215. package/dist/rules/comment-markers.js +58 -19
  216. package/dist/rules/comment-markers.js.map +1 -1
  217. package/dist/rules/contract-visibility.d.ts +3 -0
  218. package/dist/rules/contract-visibility.d.ts.map +1 -0
  219. package/dist/rules/contract-visibility.js +337 -0
  220. package/dist/rules/contract-visibility.js.map +1 -0
  221. package/dist/rules/data-guard.d.ts +13 -0
  222. package/dist/rules/data-guard.d.ts.map +1 -1
  223. package/dist/rules/data-guard.js +356 -37
  224. package/dist/rules/data-guard.js.map +1 -1
  225. package/dist/rules/layout.d.ts +4 -0
  226. package/dist/rules/layout.d.ts.map +1 -0
  227. package/dist/rules/layout.js +19 -0
  228. package/dist/rules/layout.js.map +1 -0
  229. package/dist/rules/legacy.d.ts.map +1 -1
  230. package/dist/rules/legacy.js +178 -44
  231. package/dist/rules/legacy.js.map +1 -1
  232. package/dist/rules/pages.d.ts +50 -0
  233. package/dist/rules/pages.d.ts.map +1 -1
  234. package/dist/rules/pages.js +436 -4
  235. package/dist/rules/pages.js.map +1 -1
  236. package/dist/rules/routes.d.ts +6 -0
  237. package/dist/rules/routes.d.ts.map +1 -1
  238. package/dist/rules/routes.js +111 -16
  239. package/dist/rules/routes.js.map +1 -1
  240. package/dist/rules/secrets.js +2 -2
  241. package/dist/rules/secrets.js.map +1 -1
  242. package/dist/rules/security.d.ts +13 -1
  243. package/dist/rules/security.d.ts.map +1 -1
  244. package/dist/rules/security.js +313 -69
  245. package/dist/rules/security.js.map +1 -1
  246. package/dist/rules/zones.d.ts +13 -0
  247. package/dist/rules/zones.d.ts.map +1 -1
  248. package/dist/rules/zones.js +218 -44
  249. package/dist/rules/zones.js.map +1 -1
  250. package/dist/scaffold.d.ts.map +1 -1
  251. package/dist/scaffold.js +6 -4
  252. package/dist/scaffold.js.map +1 -1
  253. package/dist/sdk.d.ts.map +1 -1
  254. package/dist/sdk.js +14 -5
  255. package/dist/sdk.js.map +1 -1
  256. package/dist/stylex-migrate.d.ts.map +1 -1
  257. package/dist/stylex-migrate.js +290 -24
  258. package/dist/stylex-migrate.js.map +1 -1
  259. package/dist/test-tool.d.ts.map +1 -1
  260. package/dist/test-tool.js +6 -3
  261. package/dist/test-tool.js.map +1 -1
  262. package/dist/typecheck-coverage.d.ts +20 -0
  263. package/dist/typecheck-coverage.d.ts.map +1 -0
  264. package/dist/typecheck-coverage.js +129 -0
  265. package/dist/typecheck-coverage.js.map +1 -0
  266. package/dist/upgrade.d.ts +84 -0
  267. package/dist/upgrade.d.ts.map +1 -1
  268. package/dist/upgrade.js +498 -78
  269. package/dist/upgrade.js.map +1 -1
  270. package/dist/verification-plan.d.ts.map +1 -1
  271. package/dist/verification-plan.js +16 -2
  272. package/dist/verification-plan.js.map +1 -1
  273. package/dist/version-drift.d.ts +22 -0
  274. package/dist/version-drift.d.ts.map +1 -0
  275. package/dist/version-drift.js +94 -0
  276. package/dist/version-drift.js.map +1 -0
  277. package/dist/web-app-options.d.ts +1 -1
  278. package/dist/web-app-options.d.ts.map +1 -1
  279. package/dist/web-app-options.js +1 -1
  280. package/dist/web-app-options.js.map +1 -1
  281. package/dist/work-graph.d.ts.map +1 -1
  282. package/dist/work-graph.js +20 -8
  283. package/dist/work-graph.js.map +1 -1
  284. package/docs/examples.json +55 -7
  285. package/docs/llms-full.txt +2043 -222
  286. package/docs/types.json +1573 -246
  287. package/package.json +11 -12
  288. package/src/agent-files.ts +203 -0
  289. package/src/app-files.ts +34 -4
  290. package/src/assure-hydration.ts +18 -2
  291. package/src/assure.ts +88 -10
  292. package/src/capabilities-tool.ts +47 -9
  293. package/src/check-diagnostics.ts +75 -9
  294. package/src/check-scan.ts +1816 -377
  295. package/src/check.ts +258 -81
  296. package/src/cli.ts +242 -37
  297. package/src/client-module.ts +97 -0
  298. package/src/command-catalog.ts +237 -52
  299. package/src/component-check.ts +79 -0
  300. package/src/contract-proof.ts +3 -25
  301. package/src/contracts.ts +60 -3
  302. package/src/db-tool.ts +5 -2
  303. package/src/dev-bun-config.ts +51 -10
  304. package/src/dev-feed-tool.ts +112 -1
  305. package/src/diagnostics.ts +3 -0
  306. package/src/diff-tool.ts +33 -26
  307. package/src/doctor.ts +43 -21
  308. package/src/env-file.ts +7 -0
  309. package/src/fix-recipes.ts +116 -2
  310. package/src/hand-built-manifest.ts +144 -0
  311. package/src/hooks-report.ts +135 -0
  312. package/src/init-agents.ts +21 -14
  313. package/src/internal/config-paths.ts +154 -0
  314. package/src/internal/installed-package.ts +15 -0
  315. package/src/internal/nifra-project.ts +191 -0
  316. package/src/internal/project-specifiers.ts +477 -0
  317. package/src/internal/script-kind.ts +2 -1
  318. package/src/internal/source-facts.ts +48 -5
  319. package/src/internal/typescript-import.ts +15 -6
  320. package/src/introspect.ts +104 -28
  321. package/src/levels-tool.ts +124 -29
  322. package/src/load.ts +107 -17
  323. package/src/mcp-context.ts +12 -25
  324. package/src/mcp-delegate.ts +22 -10
  325. package/src/mcp-exec.ts +68 -18
  326. package/src/mcp-http.ts +1 -1
  327. package/src/mcp-io.ts +74 -0
  328. package/src/mcp-isolate.ts +9 -2
  329. package/src/mcp-path.ts +8 -22
  330. package/src/mcp-project-child.ts +1 -1
  331. package/src/mcp-root.ts +58 -8
  332. package/src/mcp-ws.ts +4 -2
  333. package/src/mcp.ts +56 -22
  334. package/src/migrate-layout.ts +2257 -176
  335. package/src/output-schemas.ts +1240 -0
  336. package/src/port.ts +3 -2
  337. package/src/project-facts.ts +17 -3
  338. package/src/project-path.ts +44 -13
  339. package/src/recipes/5.0.0.ts +35 -0
  340. package/src/recipes/index.ts +10 -0
  341. package/src/release-verification.ts +20 -0
  342. package/src/replay.ts +14 -3
  343. package/src/review.ts +44 -10
  344. package/src/rules/codes.ts +12 -0
  345. package/src/rules/comment-markers.ts +57 -21
  346. package/src/rules/contract-visibility.ts +383 -0
  347. package/src/rules/data-guard.ts +391 -42
  348. package/src/rules/layout.ts +22 -0
  349. package/src/rules/legacy.ts +206 -52
  350. package/src/rules/pages.ts +481 -4
  351. package/src/rules/routes.ts +124 -16
  352. package/src/rules/secrets.ts +2 -2
  353. package/src/rules/security.ts +391 -72
  354. package/src/rules/zones.ts +232 -42
  355. package/src/scaffold.ts +6 -4
  356. package/src/sdk.ts +14 -5
  357. package/src/stylex-migrate.ts +251 -24
  358. package/src/test-tool.ts +12 -3
  359. package/src/typecheck-coverage.ts +134 -0
  360. package/src/upgrade.ts +576 -72
  361. package/src/verification-plan.ts +33 -2
  362. package/src/version-drift.ts +118 -0
  363. package/src/web-app-options.ts +2 -2
  364. 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) {
@@ -233,19 +294,50 @@ export function parseSimpleFetchCall(snippet) {
233
294
  return undefined;
234
295
  return { path, method, start: match.index, end: close + 1 };
235
296
  }
236
- function isPotentialBackendSource(code) {
297
+ const SERVER_CONSTRUCTION = /(?<![.\w])server\s*\(|new\s+Hono(?:<[^>]+>)?\s*\(/;
298
+ function isPotentialBackendSource(code, file, project) {
237
299
  return (code.includes("@nifrajs/core") ||
238
- /(?<![.\w])server\s*\(/.test(code) ||
300
+ SERVER_CONSTRUCTION.test(code) ||
239
301
  /(?:from\s*|import\s*)["']hono["']/.test(code) ||
240
- /new\s+Hono(?:<[^>]+>)?\s*\(/.test(code));
302
+ (file !== undefined && project !== undefined && callsLocalServerFactory(code, file, project)));
241
303
  }
242
- function scanRoutePattern(file, content, code, pattern, facts) {
304
+ /**
305
+ * A module that builds its server through a local helper - `newServer().get(...)` with `newServer`
306
+ * imported from a project file that calls `server()` - names neither `@nifrajs/core` nor `server(`
307
+ * itself. One import hop is followed: the imported module must construct a server, so a helper module
308
+ * that merely mentions `@nifrajs/core` (a schema re-export) does not turn every importer into a
309
+ * backend and its `client.get("/x")` calls into routes.
310
+ */
311
+ function callsLocalServerFactory(code, file, project) {
312
+ for (const [local, binding] of namedImports(code)) {
313
+ if (!new RegExp(`(?<![.\\w$])${local.replaceAll("$", "\\$")}\\s*\\(`).test(code))
314
+ continue;
315
+ const target = projectModule(project, file, binding.specifier);
316
+ if (target !== undefined && SERVER_CONSTRUCTION.test(stripComments(project.get(target) ?? "")))
317
+ return true;
318
+ }
319
+ return false;
320
+ }
321
+ /**
322
+ * Literal route registrations in a file the backend-source gate turned away, so a route the scan
323
+ * could not cover can say where it probably lives. Nothing here is treated as a route.
324
+ */
325
+ export function scanSkippedRouteText(file, content, project) {
326
+ const code = stripComments(content);
327
+ if (isPotentialBackendSource(code, file, project))
328
+ return [];
329
+ return [
330
+ ...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_DQ),
331
+ ...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_SQ),
332
+ ];
333
+ }
334
+ function scanRoutePattern(file, content, code, pattern, facts, verbs = HTTP_VERBS) {
243
335
  const out = [];
244
336
  const lines = content.split("\n");
245
337
  pattern.lastIndex = 0;
246
338
  for (let m = pattern.exec(code); m !== null; m = pattern.exec(code)) {
247
339
  const method = (m[1] ?? "").toUpperCase();
248
- if (!HTTP_VERBS.has(method))
340
+ if (!verbs.has(method))
249
341
  continue;
250
342
  const path = parseQuotedLiteral(`${pattern === ROUTE_REGISTRATION_DQ ? '"' : "'"}${m[2] ?? ""}${pattern === ROUTE_REGISTRATION_DQ ? '"' : "'"}`);
251
343
  if (path === undefined || !path.startsWith("/") || path.startsWith("//"))
@@ -263,27 +355,100 @@ function scanRoutePattern(file, content, code, pattern, facts) {
263
355
  }
264
356
  return out;
265
357
  }
358
+ // Punctuators after which a `/` opens a regex literal rather than dividing. A `)` `]` or `}` is read
359
+ // as the end of an operand, so `/` after it divides - the rare statement-leading regex after a block
360
+ // is the cost of not tracking parser context.
361
+ const REGEX_AFTER = new Set([
362
+ "(",
363
+ ",",
364
+ "=",
365
+ ":",
366
+ "[",
367
+ "!",
368
+ "&",
369
+ "|",
370
+ "?",
371
+ "{",
372
+ ";",
373
+ "+",
374
+ "-",
375
+ "*",
376
+ "%",
377
+ "<",
378
+ ">",
379
+ "~",
380
+ "^",
381
+ ]);
382
+ const REGEX_AFTER_WORD = new Set([
383
+ "return",
384
+ "typeof",
385
+ "case",
386
+ "do",
387
+ "else",
388
+ "in",
389
+ "of",
390
+ "new",
391
+ "delete",
392
+ "void",
393
+ "throw",
394
+ "yield",
395
+ "await",
396
+ "instanceof",
397
+ ]);
398
+ const isWordChar = (c) => c !== undefined && /[\w$]/.test(c);
399
+ /** Where a regex literal opened by the `/` at `i` ends (after its flags), or `undefined` when the
400
+ * literal does not close on its line - then the slash was division after all. */
401
+ function regexLiteralEnd(src, i) {
402
+ const n = src.length;
403
+ let j = i + 1;
404
+ let inClass = false;
405
+ while (j < n) {
406
+ const c = src[j];
407
+ if (c === "\n" || c === "\r")
408
+ return undefined;
409
+ if (c === "\\") {
410
+ j += 2;
411
+ continue;
412
+ }
413
+ if (inClass) {
414
+ if (c === "]")
415
+ inClass = false;
416
+ }
417
+ else if (c === "[") {
418
+ inClass = true;
419
+ }
420
+ else if (c === "/") {
421
+ break;
422
+ }
423
+ j++;
424
+ }
425
+ if (j >= n)
426
+ return undefined;
427
+ j++;
428
+ while (j < n && isWordChar(src[j]))
429
+ j++;
430
+ return j;
431
+ }
266
432
  /**
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}).
433
+ * Blank comments, template-literal contents and regex-literal contents (and, with `quoted: "blank"`,
434
+ * single/double-quoted strings and every delimiter) while preserving every offset and newline.
435
+ * A small lexer, not a parser: a `/` opens a regex where an operand may start - after a punctuator,
436
+ * an operator keyword, or at the start of the file - so a quote inside a regex no longer derails
437
+ * the string state that follows.
275
438
  */
276
- export function stripComments(src) {
439
+ function maskSource(src, quoted) {
277
440
  const out = src.split("");
278
441
  const n = src.length;
279
- let i = 0;
280
442
  const blank = (a, b) => {
281
443
  for (let k = a; k < b; k++)
282
444
  if (out[k] !== "\n")
283
445
  out[k] = " ";
284
446
  };
447
+ // The last significant code token: a punctuator character, or a whole identifier/keyword.
448
+ let previous = "";
449
+ let i = 0;
285
450
  while (i < n) {
286
- const c = src[i];
451
+ const c = src.charAt(i);
287
452
  const d = src[i + 1];
288
453
  if (c === "/" && d === "/") {
289
454
  let j = i + 2;
@@ -307,8 +472,12 @@ export function stripComments(src) {
307
472
  j++;
308
473
  j++;
309
474
  }
310
- blank(i + 1, j); // keep the backticks, blank the code-as-text inside
475
+ if (quoted === "keep")
476
+ blank(i + 1, j); // keep the backticks, blank the code-as-text inside
477
+ else
478
+ blank(i, Math.min(j + 1, n));
311
479
  i = j + 1;
480
+ previous = "`";
312
481
  }
313
482
  else if (c === "'" || c === '"') {
314
483
  let j = i + 1;
@@ -317,63 +486,61 @@ export function stripComments(src) {
317
486
  j++; // skip the escaped char
318
487
  j++;
319
488
  }
489
+ if (quoted === "blank")
490
+ blank(i, Math.min(j + 1, n));
320
491
  i = j + 1;
492
+ previous = c;
321
493
  }
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;
494
+ else if (c === "/" &&
495
+ (previous === "" || REGEX_AFTER.has(previous) || REGEX_AFTER_WORD.has(previous))) {
496
+ const end = regexLiteralEnd(src, i);
497
+ if (end === undefined) {
498
+ previous = c;
499
+ i++;
500
+ }
501
+ else {
502
+ if (quoted === "keep")
503
+ blank(i + 1, end);
504
+ else
505
+ blank(i, end);
506
+ previous = "/regex";
507
+ i = end;
508
+ }
358
509
  }
359
- else if (c === "'" || c === '"' || c === "`") {
360
- const quote = c;
510
+ else if (isWordChar(c)) {
361
511
  let j = i + 1;
362
- while (j < n && src[j] !== quote) {
363
- if (src[j] === "\\")
364
- j++;
512
+ while (j < n && isWordChar(src[j]))
365
513
  j++;
366
- }
367
- j = Math.min(j + 1, n);
368
- blank(i, j);
514
+ previous = src.slice(i, j);
369
515
  i = j;
370
516
  }
371
517
  else {
518
+ if (!/\s/.test(c))
519
+ previous = c;
372
520
  i++;
373
521
  }
374
522
  }
375
523
  return out.join("");
376
524
  }
525
+ /**
526
+ * Blank, with spaces (newlines preserved, so every byte offset - and line number - is unchanged):
527
+ * - `//` line comments and block comments;
528
+ * - the CONTENTS of backtick template literals - code-as-text (doc `CodeBlock` examples, code
529
+ * generators), never a real statement to lint.
530
+ * Single/double-quoted strings are KEPT (a real import/Response specifier lives in one); a regex
531
+ * literal keeps its slashes and loses its contents, so a quote inside one cannot open a string.
532
+ * Shared by the source scanners here and by `nifra doctor` ({@link ./doctor.ts}).
533
+ */
534
+ export function stripComments(src) {
535
+ return maskSource(src, "keep");
536
+ }
537
+ /** Blank comments and every quoted/template/regex literal while preserving offsets. Unlike
538
+ * {@link stripComments}, this is a code-position mask: scanners use it to find a call expression in
539
+ * executable code, then inspect the original source for the call's literal argument. That prevents
540
+ * documentation strings such as `const example = 'client("/")'` from becoming diagnostics. */
541
+ export function codePositionMask(src) {
542
+ return maskSource(src, "blank");
543
+ }
377
544
  const firstArgumentIndex = (content, callEnd) => {
378
545
  let index = callEnd;
379
546
  while (/\s/.test(content[index] ?? ""))
@@ -499,8 +666,7 @@ export function scanFetchText(file, content, externalMounts = []) {
499
666
  continue;
500
667
  if (externalMounts.length > 0 && matchesExternalMount(content, argument + 1, externalMounts))
501
668
  continue;
502
- const line = lineAt(content, m.index);
503
- out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() });
669
+ out.push(typedClientFinding(file, lines, lineAt(content, m.index)));
504
670
  }
505
671
  return out;
506
672
  }
@@ -531,20 +697,195 @@ export function scanStreamText(file, content, externalMounts = []) {
531
697
  continue;
532
698
  if (externalMounts.length > 0 && matchesExternalMount(content, argument + 1, externalMounts))
533
699
  continue;
534
- const line = lineAt(content, m.index);
535
- out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() });
700
+ out.push(typedClientFinding(file, lines, lineAt(content, m.index)));
536
701
  }
537
702
  return out;
538
703
  }
539
704
  /** Statically collect simple Nifra route registrations from source, without importing app code. */
540
- export function scanStaticRouteText(file, content, facts) {
705
+ export function scanStaticRouteText(file, content, facts,
706
+ /** Project files by forward-slash relative path, to resolve a path constant imported from one. */
707
+ project) {
541
708
  const code = stripComments(content);
542
- if (!isPotentialBackendSource(code))
709
+ if (!isPotentialBackendSource(code, file, project))
543
710
  return [];
544
711
  return [
545
712
  ...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_DQ, facts),
546
713
  ...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_SQ, facts),
547
714
  ...(code.includes(METHODS_SUBPATH) ? scanMethodRoutes(file, content, code, facts) : []),
715
+ ...scanRouteArguments(file, content, facts, project).routes,
716
+ ].sort(bySite);
717
+ }
718
+ /** The end of the quoted literal opening `text`, or -1 when it does not close on this line. */
719
+ function literalEnd(text) {
720
+ const quote = text[0];
721
+ for (let i = 1; i < text.length; i++) {
722
+ if (text[i] === "\\")
723
+ i++;
724
+ else if (text[i] === quote)
725
+ return i + 1;
726
+ }
727
+ return -1;
728
+ }
729
+ /** The index after the whitespace run at `from`. */
730
+ function skipSpace(text, from) {
731
+ let i = from;
732
+ while (i < text.length && (text[i] === " " || text[i] === "\t"))
733
+ i++;
734
+ return i;
735
+ }
736
+ /**
737
+ * Read `[export ]const NAME[: T] = <init>` written at column 0, so a function-local `const`, which is
738
+ * indented, never stands in for a module-level one. The shape is read from the comment-stripped
739
+ * line; the literal itself from the source line, since stripping blanks template contents.
740
+ */
741
+ function moduleConstantOn(code, source) {
742
+ const exported = code.startsWith("export ");
743
+ let i = exported ? skipSpace(code, 7) : 0;
744
+ if (!code.startsWith("const ", i))
745
+ return undefined;
746
+ i = skipSpace(code, i + 6);
747
+ const name = /^[A-Za-z_$][\w$]*/.exec(code.slice(i))?.[0];
748
+ if (name === undefined)
749
+ return undefined;
750
+ i = skipSpace(code, i + name.length);
751
+ if (code[i] === ":")
752
+ i = code.indexOf("=", i);
753
+ if (i === -1 || code[i] !== "=" || code[i + 1] === "=")
754
+ return undefined;
755
+ i = skipSpace(code, i + 1);
756
+ const opener = code[i];
757
+ const end = opener === '"' || opener === "'" || opener === "`" ? literalEnd(source.slice(i)) : -1;
758
+ const tail = end === -1 ? undefined : code.slice(i + end).trim();
759
+ const literal = source.slice(i, i + end);
760
+ const value = tail === undefined || !["", ";", "as const", "as const;"].includes(tail)
761
+ ? undefined
762
+ : opener === "`"
763
+ ? /[$\\]/.test(literal)
764
+ ? undefined
765
+ : literal.slice(1, -1)
766
+ : parseQuotedLiteral(literal);
767
+ return [name, { exported, value }];
768
+ }
769
+ function moduleConstants(code, source) {
770
+ const constants = new Map();
771
+ const sourceLines = source.split("\n");
772
+ code.split("\n").forEach((line, index) => {
773
+ const constant = moduleConstantOn(line, sourceLines[index] ?? "");
774
+ if (constant !== undefined)
775
+ constants.set(constant[0], constant[1]);
776
+ });
777
+ return constants;
778
+ }
779
+ /** Local name → the export it imports and from where, for value imports only. */
780
+ function namedImports(code) {
781
+ const imports = new Map();
782
+ NAMED_IMPORTS.lastIndex = 0;
783
+ for (let m = NAMED_IMPORTS.exec(code); m !== null; m = NAMED_IMPORTS.exec(code)) {
784
+ if (/\btype\s*$/.test(code.slice(Math.max(0, m.index - 12), m.index + 6)))
785
+ continue;
786
+ for (const element of (m[1] ?? "").split(",")) {
787
+ const parts = element.trim().split(/\s+/);
788
+ if (parts[0] === "type" || parts[0] === undefined || parts[0] === "")
789
+ continue;
790
+ const local = parts[1] === "as" ? parts[2] : parts[0];
791
+ if (local !== undefined)
792
+ imports.set(local, { specifier: m[3] ?? "", name: parts[0] });
793
+ }
794
+ }
795
+ return imports;
796
+ }
797
+ /** The project file a relative import names, tried the way a TypeScript-aware bundler resolves it. */
798
+ function projectModule(project, fromFile, specifier) {
799
+ if (!specifier.startsWith("./") && !specifier.startsWith("../"))
800
+ return undefined;
801
+ const base = posix.normalize(posix.join(posix.dirname(fromFile.replaceAll("\\", "/")), specifier));
802
+ const stem = base.replace(/\.(?:m?js|jsx)$/, "");
803
+ return [base, `${stem}.ts`, `${stem}.tsx`, `${stem}.mts`, `${base}/index.ts`].find((candidate) => project.has(candidate));
804
+ }
805
+ /**
806
+ * Route registrations whose path is a name or a template rather than a quoted literal. A name bound
807
+ * to a module-level string `const`, here or imported from a project file, and a template with no
808
+ * substitution resolve to routes; any other template, and a module-level or imported name nifra
809
+ * cannot read, is unresolved. A name bound anywhere else (a parameter, a local) is not reported:
810
+ * nothing marks such a call as a route rather than a `map.get(key, fallback)`.
811
+ */
812
+ function scanRouteArguments(file, content, facts, project, verbs = HTTP_VERBS) {
813
+ const routes = [];
814
+ const unresolved = [];
815
+ const code = stripComments(content);
816
+ if (!isPotentialBackendSource(code, file, project))
817
+ return { routes, unresolved };
818
+ const lines = content.split("\n");
819
+ const constants = moduleConstants(code, content);
820
+ const imports = namedImports(code);
821
+ const importedFiles = new Map();
822
+ const imported = (specifier, name) => {
823
+ const target = project === undefined ? undefined : projectModule(project, file, specifier);
824
+ if (target === undefined)
825
+ return undefined;
826
+ let found = importedFiles.get(target);
827
+ if (found === undefined) {
828
+ const text = project?.get(target) ?? "";
829
+ found = moduleConstants(stripComments(text), text);
830
+ importedFiles.set(target, found);
831
+ }
832
+ const constant = found.get(name);
833
+ return constant?.exported === true ? constant : undefined;
834
+ };
835
+ ROUTE_ARGUMENT.lastIndex = 0;
836
+ for (let m = ROUTE_ARGUMENT.exec(code); m !== null; m = ROUTE_ARGUMENT.exec(code)) {
837
+ const method = (m[1] ?? "").toUpperCase();
838
+ if (!verbs.has(method))
839
+ continue;
840
+ const at = m.index + m[0].lastIndexOf(m[2] ?? "");
841
+ const argument = content.slice(at, at + (m[2] ?? "").length);
842
+ let path;
843
+ if (argument.startsWith("`")) {
844
+ path = /[$\\]/.test(argument) ? undefined : argument.slice(1, -1);
845
+ }
846
+ else {
847
+ const binding = imports.get(argument);
848
+ const constant = binding === undefined ? constants.get(argument) : imported(binding.specifier, binding.name);
849
+ if (binding === undefined && constant === undefined)
850
+ continue;
851
+ path = constant?.value;
852
+ }
853
+ // A name that reads as something other than a path (`settings.get(KEY, fallback)`) is no route.
854
+ if (path !== undefined && (!path.startsWith("/") || path.startsWith("//")))
855
+ continue;
856
+ if (facts !== undefined) {
857
+ const source = facts.parse(file, content);
858
+ if (source !== undefined &&
859
+ facts.isRouteArgumentAt(source, m.index, method, argument) !== true)
860
+ continue;
861
+ }
862
+ const line = lineAt(content, m.index);
863
+ const snippet = (lines[line - 1] ?? "").trim();
864
+ if (path !== undefined)
865
+ routes.push({ file, line, snippet, method, path });
866
+ else
867
+ unresolved.push({ file, line, snippet, method, expression: argument.slice(0, 120) });
868
+ }
869
+ return { routes, unresolved };
870
+ }
871
+ /** Route registrations whose path the static scan cannot resolve - see {@link scanRouteArguments}. */
872
+ export function scanUnresolvedRoutePaths(file, content, project, facts) {
873
+ return [
874
+ ...scanRouteArguments(file, content, facts, project).unresolved,
875
+ ...scanRouteArguments(file, content, facts, project, SOCKET_VERB).unresolved,
876
+ ].sort(bySite);
877
+ }
878
+ /** `app.ws("/path", ...)` registrations, as routes under the pseudo-method `"WS"` reflection uses. */
879
+ export function scanStaticSocketText(file, content, facts,
880
+ /** Project files by forward-slash relative path, to resolve a path constant imported from one. */
881
+ project) {
882
+ const code = stripComments(content);
883
+ if (!isPotentialBackendSource(code, file, project))
884
+ return [];
885
+ return [
886
+ ...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_DQ, facts, SOCKET_VERB),
887
+ ...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_SQ, facts, SOCKET_VERB),
888
+ ...scanRouteArguments(file, content, facts, project, SOCKET_VERB).routes,
548
889
  ].sort(bySite);
549
890
  }
550
891
  /** Routes from `all()` and `method()`, one per literal method name; a computed name is not guessed. */
@@ -615,6 +956,24 @@ const SQL_METHODS = new Set([
615
956
  ]);
616
957
  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
958
  const ALWAYS_UNSAFE = new Set(["unsafe", "$queryRawUnsafe", "$executeRawUnsafe", "raw"]);
959
+ /** Driver calls that take a statement under names that are everyday words elsewhere (`map.get`,
960
+ * `client.get("/users/update")`): node-sqlite3's `all`/`get`/`each` and pg-promise's result-shaping
961
+ * calls. One of these, and any bare call, is judged by whether its text starts like a statement. */
962
+ const DRIVER_READ_METHODS = new Set([
963
+ "all",
964
+ "get",
965
+ "each",
966
+ "any",
967
+ "one",
968
+ "none",
969
+ "many",
970
+ "oneOrNone",
971
+ "manyOrNone",
972
+ "result",
973
+ "multi",
974
+ "multiResult",
975
+ ]);
976
+ const STATEMENT_START = /^\s*\(?\s*(?:select|insert|update|delete|with|replace|merge|create|drop|alter|truncate|pragma)\s/i;
618
977
  /** Read one module's top-level `const` bindings and its import/export edges. Pure syntax - no checker,
619
978
  * no filesystem. Destructured names, `let`, `var`, and anything nested stay out on purpose: a
620
979
  * module-scope `const` is the only form that provably cannot carry request data. */
@@ -817,11 +1176,12 @@ const CONST_RESOLUTION_DEPTH = 5;
817
1176
  * data. Pure syntax, no type checker - the same honest limit the rule already documents. What
818
1177
  * resolves: string/number literals, no-substitution templates, templates whose every span resolves,
819
1178
  * 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.
1179
+ * either still feeds the keyword scan), `as`/`satisfies` wrappers, a `const` whose initializer
1180
+ * resolves (the nearest binding: function or block scope, then module scope; recursively,
1181
+ * depth-capped), `.join(sep?)` over an array {@link staticArrayItems} proves, and - when
1182
+ * `scope.imports` is configured - a module `const` imported by name from another in-project module.
1183
+ * Everything else - `let`, a parameter, any other call, a member access, a shadowed name, a
1184
+ * bare-specifier import - stays unresolved and the interpolation is flagged exactly as before.
825
1185
  */
826
1186
  function resolveStaticSqlText(ts, node, scope, depth) {
827
1187
  if (depth > CONST_RESOLUTION_DEPTH)
@@ -839,7 +1199,8 @@ function resolveStaticSqlText(ts, node, scope, depth) {
839
1199
  if (ts.isTemplateExpression(node)) {
840
1200
  let text = node.head.text;
841
1201
  for (const span of node.templateSpans) {
842
- const part = resolveStaticSqlText(ts, span.expression, scope, depth + 1);
1202
+ const part = placeholderNumber(ts, text, span.expression, depth) ??
1203
+ resolveStaticSqlText(ts, span.expression, scope, depth + 1);
843
1204
  if (part === undefined)
844
1205
  return undefined;
845
1206
  text += part + span.literal.text;
@@ -853,7 +1214,14 @@ function resolveStaticSqlText(ts, node, scope, depth) {
853
1214
  return undefined;
854
1215
  return `${whenTrue} ${whenFalse}`;
855
1216
  }
1217
+ if (ts.isCallExpression(node))
1218
+ return resolveStaticJoin(ts, node, scope, depth);
856
1219
  if (ts.isIdentifier(node)) {
1220
+ const local = localConstInitializer(ts, node);
1221
+ if (local === "shadow")
1222
+ return undefined;
1223
+ if (local !== undefined)
1224
+ return resolveStaticSqlText(ts, local, scope, depth + 1);
857
1225
  const initializer = scope.module.consts.get(node.text);
858
1226
  const edge = initializer === undefined ? scope.module.imports.get(node.text) : undefined;
859
1227
  // Membership first: `isShadowedAt` walks a subtree, and most identifiers in a query are neither a
@@ -882,13 +1250,416 @@ function resolveStaticSqlText(ts, node, scope, depth) {
882
1250
  }
883
1251
  return undefined;
884
1252
  }
1253
+ /**
1254
+ * The initializer of the `const` a function or block scope binds `node` to, `"shadow"` when the
1255
+ * nearest such binding is anything else (a parameter, `let`, `var`, destructuring), or `undefined`
1256
+ * when no scope below the module binds it - the module-scope lookup applies then.
1257
+ */
1258
+ function localConstInitializer(ts, node) {
1259
+ const source = node.getSourceFile();
1260
+ for (let scope = node.parent; scope !== undefined; scope = scope.parent) {
1261
+ if (ts.isSourceFile(scope))
1262
+ return undefined;
1263
+ const binding = directBinding(ts, source, scope, node.text);
1264
+ if (binding === "shadow")
1265
+ return "shadow";
1266
+ if (binding !== undefined)
1267
+ return binding.constant ? binding.initializer : "shadow";
1268
+ }
1269
+ return undefined;
1270
+ }
1271
+ const unwrapExpression = (ts, node) => {
1272
+ let current = node;
1273
+ while (ts.isParenthesizedExpression(current) ||
1274
+ ts.isAsExpression(current) ||
1275
+ ts.isSatisfiesExpression(current)) {
1276
+ current = current.expression;
1277
+ }
1278
+ return current;
1279
+ };
1280
+ /** `Object.freeze([...])` with `Object` unshadowed: the one array form no later code can change. */
1281
+ function frozenArrayLiteral(ts, node) {
1282
+ const call = unwrapExpression(ts, node);
1283
+ if (!ts.isCallExpression(call) || call.arguments.length !== 1)
1284
+ return undefined;
1285
+ const callee = call.expression;
1286
+ if (!ts.isPropertyAccessExpression(callee) ||
1287
+ callee.name.text !== "freeze" ||
1288
+ !ts.isIdentifier(callee.expression) ||
1289
+ callee.expression.text !== "Object" ||
1290
+ isShadowedAt(ts, callee.expression, "Object")) {
1291
+ return undefined;
1292
+ }
1293
+ const array = unwrapExpression(ts, call.arguments[0]);
1294
+ return ts.isArrayLiteralExpression(array) ? array : undefined;
1295
+ }
1296
+ const ARRAY_READS = new Set(["join", "includes", "indexOf"]);
1297
+ /**
1298
+ * Whether every use of `name` in `source` other than its declaration is a call of `.join(`,
1299
+ * `.includes(` or `.indexOf(`, or a `.length` read: then nothing pushes to, sorts, exports or hands
1300
+ * away the array, so it still holds its literal elements. With `pushed`, a `.push(...)` is accepted
1301
+ * too and its arguments collected, for the caller to prove (the `where.push(...)` builder). A
1302
+ * same-named binding elsewhere only adds uses, so the answer can only err toward refusing.
1303
+ */
1304
+ function onlyReadsArray(ts, source, name, declaration, pushed) {
1305
+ let ok = true;
1306
+ const visit = (node) => {
1307
+ if (!ok)
1308
+ return;
1309
+ if (ts.isIdentifier(node) && node.text === name && node !== declaration) {
1310
+ const access = node.parent;
1311
+ const call = ts.isPropertyAccessExpression(access) &&
1312
+ access.expression === node &&
1313
+ ts.isCallExpression(access.parent) &&
1314
+ access.parent.expression === access
1315
+ ? access.parent
1316
+ : undefined;
1317
+ if (pushed !== undefined &&
1318
+ call !== undefined &&
1319
+ ts.isPropertyAccessExpression(access) &&
1320
+ access.name.text === "push") {
1321
+ if (call.arguments.some((argument) => ts.isSpreadElement(argument)))
1322
+ ok = false;
1323
+ else
1324
+ pushed.push(...call.arguments);
1325
+ // An argument can mention the array again; those uses are checked as well.
1326
+ for (const argument of call.arguments)
1327
+ visit(argument);
1328
+ return;
1329
+ }
1330
+ const read = ts.isPropertyAccessExpression(access) &&
1331
+ access.expression === node &&
1332
+ (access.name.text === "length"
1333
+ ? !isWrittenTo(ts, access)
1334
+ : ARRAY_READS.has(access.name.text) && call !== undefined);
1335
+ if (!read)
1336
+ ok = false;
1337
+ return;
1338
+ }
1339
+ ts.forEachChild(node, visit);
1340
+ };
1341
+ visit(source);
1342
+ return ok;
1343
+ }
1344
+ /** `node` as the target of an assignment, `++`/`--`, or `delete`. */
1345
+ function isWrittenTo(ts, node) {
1346
+ const parent = node.parent;
1347
+ if (ts.isBinaryExpression(parent) &&
1348
+ parent.left === node &&
1349
+ parent.operatorToken.kind >= ts.SyntaxKind.FirstAssignment &&
1350
+ parent.operatorToken.kind <= ts.SyntaxKind.LastAssignment) {
1351
+ return true;
1352
+ }
1353
+ if (ts.isPrefixUnaryExpression(parent) || ts.isPostfixUnaryExpression(parent)) {
1354
+ return (parent.operator === ts.SyntaxKind.PlusPlusToken ||
1355
+ parent.operator === ts.SyntaxKind.MinusMinusToken);
1356
+ }
1357
+ return ts.isDeleteExpression(parent);
1358
+ }
1359
+ /**
1360
+ * The element texts of an array `receiver` names, when the array provably still holds them: an
1361
+ * inline literal, `Object.freeze([...])` anywhere it is bound or imported from, or a `const` array
1362
+ * literal bound in this file, not exported, and only ever read ({@link onlyReadsArray}). `as const`
1363
+ * is type-only and proves nothing.
1364
+ */
1365
+ function staticArrayItems(ts, receiver, scope, depth) {
1366
+ const node = unwrapExpression(ts, receiver);
1367
+ let array = ts.isArrayLiteralExpression(node) ? node : frozenArrayLiteral(ts, node);
1368
+ let itemScope = scope;
1369
+ const pushed = [];
1370
+ if (array === undefined && ts.isIdentifier(node)) {
1371
+ const local = localConstInitializer(ts, node);
1372
+ if (local === "shadow")
1373
+ return undefined;
1374
+ let initializer = local;
1375
+ let exported = false;
1376
+ if (initializer === undefined && !isShadowedAt(ts, node, node.text)) {
1377
+ initializer = scope.module.consts.get(node.text);
1378
+ exported = [...scope.module.localExports.values()].includes(node.text);
1379
+ const edge = initializer === undefined ? scope.module.imports.get(node.text) : undefined;
1380
+ if (edge !== undefined && scope.imports !== undefined) {
1381
+ const target = loadSqlModuleScope(ts, scope.imports, scope.module.id, edge.specifier);
1382
+ const found = target === undefined
1383
+ ? undefined
1384
+ : resolveExportedConst(ts, scope.imports, target, edge.exported, 1, new Set());
1385
+ if (found === undefined)
1386
+ return undefined;
1387
+ // Another module's array is accepted only frozen: its own file may mutate it.
1388
+ array = frozenArrayLiteral(ts, found.expression);
1389
+ itemScope = { module: found.scope, imports: scope.imports };
1390
+ }
1391
+ }
1392
+ if (array === undefined && initializer !== undefined) {
1393
+ array = frozenArrayLiteral(ts, initializer);
1394
+ const literal = unwrapExpression(ts, initializer);
1395
+ if (array === undefined &&
1396
+ !exported &&
1397
+ ts.isArrayLiteralExpression(literal) &&
1398
+ ts.isVariableDeclaration(initializer.parent) &&
1399
+ onlyReadsArray(ts, node.getSourceFile(), node.text, initializer.parent.name, pushed)) {
1400
+ array = literal;
1401
+ }
1402
+ }
1403
+ }
1404
+ if (array === undefined)
1405
+ return undefined;
1406
+ const items = [];
1407
+ // What the array may hold: its literal elements, and anything pushed onto it in any order.
1408
+ for (const element of [...array.elements, ...pushed]) {
1409
+ if (ts.isSpreadElement(element) || ts.isOmittedExpression(element))
1410
+ return undefined;
1411
+ const text = resolveStaticSqlText(ts, element, itemScope, depth + 1);
1412
+ if (text === undefined)
1413
+ return undefined;
1414
+ items.push(text);
1415
+ }
1416
+ return items;
1417
+ }
1418
+ /** `items.join(sep?)` over a {@link staticArrayItems} array with a literal or absent separator. */
1419
+ function resolveStaticJoin(ts, call, scope, depth) {
1420
+ const callee = call.expression;
1421
+ if (!ts.isPropertyAccessExpression(callee) ||
1422
+ callee.questionDotToken !== undefined ||
1423
+ callee.name.text !== "join" ||
1424
+ call.arguments.length > 1) {
1425
+ return undefined;
1426
+ }
1427
+ const separatorArg = call.arguments[0];
1428
+ const separator = separatorArg === undefined
1429
+ ? ","
1430
+ : ts.isStringLiteral(separatorArg) || ts.isNoSubstitutionTemplateLiteral(separatorArg)
1431
+ ? separatorArg.text
1432
+ : undefined;
1433
+ if (separator === undefined)
1434
+ return undefined;
1435
+ const items = staticArrayItems(ts, callee.expression, scope, depth + 1) ??
1436
+ mappedItem(ts, callee.expression, scope, depth + 1);
1437
+ return items?.join(separator);
1438
+ }
1439
+ /**
1440
+ * `xs.map(fn)`, `xs.flatMap(fn)` or `Array.from(src, fn)` whose callback returns text that resolves
1441
+ * for every element - `(_, i) => \`($${i * 2 + 1}, $${i * 2 + 2})\``, the batch `VALUES` idiom. How
1442
+ * many items there are is runtime data, what each one says is not, so one item stands for all.
1443
+ */
1444
+ function mappedItem(ts, receiver, scope, depth) {
1445
+ const call = unwrapExpression(ts, receiver);
1446
+ if (!ts.isCallExpression(call) || !ts.isPropertyAccessExpression(call.expression))
1447
+ return undefined;
1448
+ // Only these return what the callback returns; `filter` and the rest hand back the elements.
1449
+ const method = call.expression.name.text;
1450
+ const argument = indexCallbackArgument(ts, call);
1451
+ if (argument === undefined || !(method === "map" || method === "flatMap" || argument === 1))
1452
+ return undefined;
1453
+ const fn = call.arguments[argument];
1454
+ if (fn === undefined || !(ts.isArrowFunction(fn) || ts.isFunctionExpression(fn)))
1455
+ return undefined;
1456
+ const only = ts.isBlock(fn.body) && fn.body.statements.length === 1 ? fn.body.statements[0] : undefined;
1457
+ const body = ts.isBlock(fn.body)
1458
+ ? only !== undefined && ts.isReturnStatement(only)
1459
+ ? only.expression
1460
+ : undefined
1461
+ : fn.body;
1462
+ const text = body === undefined ? undefined : resolveStaticSqlText(ts, body, scope, depth + 1);
1463
+ return text === undefined ? undefined : [text];
1464
+ }
1465
+ /** Array methods whose callback receives the element's index, and where in the callback it is. */
1466
+ const INDEX_PARAMETER = new Map([
1467
+ ["map", 1],
1468
+ ["flatMap", 1],
1469
+ ["forEach", 1],
1470
+ ["filter", 1],
1471
+ ["some", 1],
1472
+ ["every", 1],
1473
+ ["find", 1],
1474
+ ["findIndex", 1],
1475
+ ["reduce", 2],
1476
+ ["reduceRight", 2],
1477
+ ]);
1478
+ /** Which argument of `call` is a callback handed an array index: the first for `xs.map(fn)` and its
1479
+ * kin, the second for an unshadowed `Array.from(src, fn)`. */
1480
+ function indexCallbackArgument(ts, call) {
1481
+ const callee = call.expression;
1482
+ if (!ts.isPropertyAccessExpression(callee) || callee.questionDotToken !== undefined)
1483
+ return undefined;
1484
+ if (INDEX_PARAMETER.has(callee.name.text))
1485
+ return 0;
1486
+ return callee.name.text === "from" &&
1487
+ ts.isIdentifier(callee.expression) &&
1488
+ callee.expression.text === "Array" &&
1489
+ !isShadowedAt(ts, callee.expression, "Array")
1490
+ ? 1
1491
+ : undefined;
1492
+ }
1493
+ /**
1494
+ * `"1"` for a span written right after a `$` whose expression provably evaluates to a number: a
1495
+ * Postgres placeholder index (`$${i + 1}`), which binds a parameter and can carry no SQL. Any string
1496
+ * there could, so anything not proved numeric stays unresolved.
1497
+ */
1498
+ function placeholderNumber(ts, before, node, depth) {
1499
+ return before.endsWith("$") && isNumericExpression(ts, node, depth) ? "1" : undefined;
1500
+ }
1501
+ const NUMERIC_OPERATORS = (ts) => new Set([
1502
+ ts.SyntaxKind.MinusToken,
1503
+ ts.SyntaxKind.AsteriskToken,
1504
+ ts.SyntaxKind.AsteriskAsteriskToken,
1505
+ ts.SyntaxKind.SlashToken,
1506
+ ts.SyntaxKind.PercentToken,
1507
+ ts.SyntaxKind.AmpersandToken,
1508
+ ts.SyntaxKind.BarToken,
1509
+ ts.SyntaxKind.CaretToken,
1510
+ ts.SyntaxKind.LessThanLessThanToken,
1511
+ ts.SyntaxKind.GreaterThanGreaterThanToken,
1512
+ ts.SyntaxKind.GreaterThanGreaterThanGreaterThanToken,
1513
+ ]);
1514
+ const NUMBER_FUNCTIONS = new Set(["Number", "parseInt", "parseFloat"]);
1515
+ /**
1516
+ * Whether an expression evaluates to a number whatever its inputs hold. Every arithmetic operator but
1517
+ * `+` converts its operands, so `i * 2` is a number even if `i` were a string; `+` is one only when
1518
+ * both sides are. A name proves it when it is a callback's array index, or a `const`, never-reassigned
1519
+ * `let` or `for` counter bound to a number and only ever updated by arithmetic. Also `xs.push(...)`
1520
+ * (the new length), `Number`/`parseInt`/`parseFloat`/`Math.*` calls, and `.length` of an array a
1521
+ * nearby binding builds from a literal. A type annotation proves nothing: it does not run.
1522
+ */
1523
+ function isNumericExpression(ts, node, depth) {
1524
+ if (depth > CONST_RESOLUTION_DEPTH)
1525
+ return false;
1526
+ const expression = unwrapExpression(ts, node);
1527
+ if (ts.isNumericLiteral(expression))
1528
+ return true;
1529
+ if (ts.isPrefixUnaryExpression(expression))
1530
+ return expression.operator !== ts.SyntaxKind.ExclamationToken;
1531
+ if (ts.isPostfixUnaryExpression(expression))
1532
+ return true;
1533
+ if (ts.isBinaryExpression(expression)) {
1534
+ const operator = expression.operatorToken.kind;
1535
+ if (operator === ts.SyntaxKind.PlusToken) {
1536
+ return (isNumericExpression(ts, expression.left, depth + 1) &&
1537
+ isNumericExpression(ts, expression.right, depth + 1));
1538
+ }
1539
+ return NUMERIC_OPERATORS(ts).has(operator);
1540
+ }
1541
+ if (ts.isIdentifier(expression))
1542
+ return isNumericName(ts, expression, depth);
1543
+ if (ts.isCallExpression(expression)) {
1544
+ const callee = expression.expression;
1545
+ if (ts.isIdentifier(callee))
1546
+ return NUMBER_FUNCTIONS.has(callee.text) && !isShadowedAt(ts, callee, callee.text);
1547
+ if (!ts.isPropertyAccessExpression(callee) || callee.questionDotToken !== undefined)
1548
+ return false;
1549
+ if (callee.name.text === "push")
1550
+ return true;
1551
+ return (ts.isIdentifier(callee.expression) &&
1552
+ callee.expression.text === "Math" &&
1553
+ !isShadowedAt(ts, callee.expression, "Math"));
1554
+ }
1555
+ if (ts.isPropertyAccessExpression(expression) &&
1556
+ expression.name.text === "length" &&
1557
+ ts.isIdentifier(expression.expression)) {
1558
+ const binding = nameBinding(ts, expression.expression);
1559
+ return (binding.kind === "value" &&
1560
+ ts.isArrayLiteralExpression(unwrapExpression(ts, binding.initializer)));
1561
+ }
1562
+ return false;
1563
+ }
1564
+ function nameBinding(ts, node) {
1565
+ const source = node.getSourceFile();
1566
+ for (let scope = node.parent; scope !== undefined; scope = scope.parent) {
1567
+ if (ts.isFunctionLike(scope)) {
1568
+ const position = scope.parameters.findIndex((p) => bindingNameCovers(ts, p.name, node.text));
1569
+ if (position !== -1) {
1570
+ return ts.isIdentifier(scope.parameters[position]?.name ?? node)
1571
+ ? { kind: "parameter", fn: scope, position }
1572
+ : { kind: "unknown" };
1573
+ }
1574
+ }
1575
+ const binding = directBinding(ts, source, scope, node.text);
1576
+ if (binding === "shadow") {
1577
+ // `for (let i = 0; ...; i++)`: the one reassigned `let` worth reading, checked by the caller.
1578
+ const list = ts.isForStatement(scope) ? scope.initializer : undefined;
1579
+ const declaration = list !== undefined && ts.isVariableDeclarationList(list)
1580
+ ? list.declarations.find((d) => ts.isIdentifier(d.name) && d.name.text === node.text)
1581
+ : undefined;
1582
+ return declaration?.initializer === undefined
1583
+ ? { kind: "unknown" }
1584
+ : { kind: "counter", initializer: declaration.initializer };
1585
+ }
1586
+ if (binding !== undefined)
1587
+ return { kind: "value", initializer: binding.initializer };
1588
+ if (ts.isSourceFile(scope))
1589
+ break;
1590
+ }
1591
+ return { kind: "unknown" };
1592
+ }
1593
+ function isNumericName(ts, node, depth) {
1594
+ const source = node.getSourceFile();
1595
+ const binding = nameBinding(ts, node);
1596
+ if (binding.kind === "value")
1597
+ return isNumericExpression(ts, binding.initializer, depth + 1);
1598
+ if (binding.kind === "counter") {
1599
+ return (isNumericExpression(ts, binding.initializer, depth + 1) &&
1600
+ onlyNumericWrites(ts, source, node.text, depth));
1601
+ }
1602
+ if (binding.kind !== "parameter")
1603
+ return false;
1604
+ // An array callback's index parameter, never reassigned.
1605
+ const call = binding.fn.parent;
1606
+ if (!ts.isCallExpression(call) || !ts.isPropertyAccessExpression(call.expression))
1607
+ return false;
1608
+ const argument = indexCallbackArgument(ts, call);
1609
+ const position = argument === 1 ? 1 : INDEX_PARAMETER.get(call.expression.name.text);
1610
+ return (argument !== undefined &&
1611
+ call.arguments[argument] === binding.fn &&
1612
+ binding.position === position &&
1613
+ !isReassignedInFile(ts, source, node.text));
1614
+ }
1615
+ /** Compound assignments that always store a number. */
1616
+ const NUMERIC_ASSIGNMENTS = (ts) => new Set([
1617
+ ts.SyntaxKind.MinusEqualsToken,
1618
+ ts.SyntaxKind.AsteriskEqualsToken,
1619
+ ts.SyntaxKind.AsteriskAsteriskEqualsToken,
1620
+ ts.SyntaxKind.SlashEqualsToken,
1621
+ ts.SyntaxKind.PercentEqualsToken,
1622
+ ts.SyntaxKind.AmpersandEqualsToken,
1623
+ ts.SyntaxKind.BarEqualsToken,
1624
+ ts.SyntaxKind.CaretEqualsToken,
1625
+ ts.SyntaxKind.LessThanLessThanEqualsToken,
1626
+ ts.SyntaxKind.GreaterThanGreaterThanEqualsToken,
1627
+ ts.SyntaxKind.GreaterThanGreaterThanGreaterThanEqualsToken,
1628
+ ]);
1629
+ /** Every write to `name` in the file is `++`/`--`, or an assignment that stores a number. */
1630
+ function onlyNumericWrites(ts, source, name, depth) {
1631
+ let ok = true;
1632
+ const visit = (node) => {
1633
+ if (!ok)
1634
+ return;
1635
+ if (ts.isIdentifier(node) && node.text === name && isWrittenTo(ts, node)) {
1636
+ const parent = node.parent;
1637
+ if (ts.isBinaryExpression(parent)) {
1638
+ const operator = parent.operatorToken.kind;
1639
+ ok =
1640
+ operator === ts.SyntaxKind.EqualsToken || operator === ts.SyntaxKind.PlusEqualsToken
1641
+ ? isNumericExpression(ts, parent.right, depth + 1)
1642
+ : NUMERIC_ASSIGNMENTS(ts).has(operator);
1643
+ }
1644
+ else
1645
+ ok = !ts.isDeleteExpression(parent);
1646
+ return;
1647
+ }
1648
+ ts.forEachChild(node, visit);
1649
+ };
1650
+ visit(source);
1651
+ return ok;
1652
+ }
885
1653
  /** A template's contribution to the shape: resolved span texts fold into `staticText` (so the
886
1654
  * keyword scan still sees what a `const` carries); any unresolvable span keeps it `dynamic`. */
887
1655
  function templateShape(ts, template, scope) {
888
1656
  let staticText = template.head.text;
889
1657
  let dynamic = false;
890
1658
  for (const span of template.templateSpans) {
891
- const resolved = scope === undefined ? undefined : resolveStaticSqlText(ts, span.expression, scope, 0);
1659
+ const resolved = scope === undefined
1660
+ ? undefined
1661
+ : (placeholderNumber(ts, staticText, span.expression, 0) ??
1662
+ resolveStaticSqlText(ts, span.expression, scope, 0));
892
1663
  if (resolved === undefined)
893
1664
  dynamic = true;
894
1665
  else
@@ -953,30 +1724,13 @@ function directBinding(ts, source, scope, name) {
953
1724
  if (declared !== undefined && bindingNameCovers(ts, declared.name, name))
954
1725
  return "shadow";
955
1726
  }
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
- }
1727
+ for (const statement of scopeStatements(ts, scope) ?? []) {
1728
+ if ((ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) &&
1729
+ statement.name?.text === name) {
1730
+ return "shadow";
972
1731
  }
973
1732
  }
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) {
1733
+ for (const list of scopeDeclarationLists(ts, scope)) {
980
1734
  for (const declaration of list.declarations) {
981
1735
  if (!bindingNameCovers(ts, declaration.name, name))
982
1736
  continue;
@@ -987,52 +1741,262 @@ function directBinding(ts, source, scope, name) {
987
1741
  const isConst = (list.flags & ts.NodeFlags.Const) !== 0;
988
1742
  const isLet = (list.flags & ts.NodeFlags.Let) !== 0;
989
1743
  if (isConst)
990
- return { initializer: declaration.initializer };
1744
+ return { initializer: declaration.initializer, constant: true };
991
1745
  if (isLet && !isReassignedInFile(ts, source, name))
992
- return { initializer: declaration.initializer };
1746
+ return { initializer: declaration.initializer, constant: false };
993
1747
  return "shadow";
994
1748
  }
995
1749
  }
996
1750
  return undefined;
997
1751
  }
1752
+ function scopeStatements(ts, scope) {
1753
+ return ts.isSourceFile(scope) ||
1754
+ ts.isBlock(scope) ||
1755
+ ts.isModuleBlock(scope) ||
1756
+ ts.isCaseClause(scope) ||
1757
+ ts.isDefaultClause(scope)
1758
+ ? scope.statements
1759
+ : undefined;
1760
+ }
1761
+ /** The `var`/`let`/`const` lists a scope node declares directly: its statements' and a `for` head's. */
1762
+ function scopeDeclarationLists(ts, scope) {
1763
+ const lists = [];
1764
+ for (const statement of scopeStatements(ts, scope) ?? []) {
1765
+ if (ts.isVariableStatement(statement))
1766
+ lists.push(statement.declarationList);
1767
+ }
1768
+ if ((ts.isForStatement(scope) || ts.isForInStatement(scope) || ts.isForOfStatement(scope)) &&
1769
+ scope.initializer !== undefined &&
1770
+ ts.isVariableDeclarationList(scope.initializer)) {
1771
+ lists.push(scope.initializer);
1772
+ }
1773
+ return lists;
1774
+ }
1775
+ /** The `var` or `let` a scope declares `name` with by a plain identifier, if any. */
1776
+ function mutableDeclaration(ts, scope, name) {
1777
+ for (const list of scopeDeclarationLists(ts, scope)) {
1778
+ if ((list.flags & ts.NodeFlags.Const) !== 0)
1779
+ continue;
1780
+ for (const declaration of list.declarations) {
1781
+ if (ts.isIdentifier(declaration.name) && declaration.name.text === name)
1782
+ return declaration;
1783
+ }
1784
+ }
1785
+ return undefined;
1786
+ }
998
1787
  /**
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.
1788
+ * Every value a statement variable may hold where the query reads it, so a statement parked in a
1789
+ * variable is scanned as if it had been written inline at the call. This is the counterpart to
1790
+ * {@link resolveStaticSqlText}: that folds a const's text INTO a template's shape; this unfolds the
1791
+ * statement back out of the variable it was lifted into. Without it, ANY interpolated-SQL finding
1792
+ * could be silenced, invisibly, by hoisting the statement to `const q = …` first, or by building it
1793
+ * in steps (`let q = "SELECT …"; q += …`).
1004
1794
  *
1005
1795
  * 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).
1796
+ * same-named module const. A `const`, or a `let` the file never reassigns, holds its initializer. A
1797
+ * `var` or reassigned `let` holds its initializer and every value assigned to the name anywhere in
1798
+ * the function (or module) that declares it: over-collected rather than scope-simulated, which only
1799
+ * ever adds a value to check. `key` names the binding, so a value that reads the variable it is
1800
+ * assigned to is recognised. A parameter, an imported or destructured binding, or a name with no
1801
+ * same-file declaration returns `undefined` - which leaves the argument scanned as itself.
1011
1802
  */
1012
- function resolveArgumentInitializer(ts, node, depth) {
1013
- if (depth > CONST_RESOLUTION_DEPTH)
1014
- return undefined;
1803
+ function variableWrites(ts, node) {
1015
1804
  const source = node.getSourceFile();
1016
1805
  const name = node.text;
1017
1806
  for (let scope = node.parent; scope !== undefined; scope = scope.parent) {
1018
1807
  const binding = directBinding(ts, source, scope, name);
1019
- if (binding === "shadow")
1020
- return undefined;
1808
+ if (binding === "shadow") {
1809
+ const declaration = mutableDeclaration(ts, scope, name);
1810
+ if (declaration === undefined)
1811
+ return undefined;
1812
+ let owner = declaration;
1813
+ while (!ts.isFunctionLike(owner) && !ts.isSourceFile(owner))
1814
+ owner = owner.parent;
1815
+ const writes = writesTo(ts, owner, name);
1816
+ return {
1817
+ key: declaration,
1818
+ writes: declaration.initializer === undefined
1819
+ ? writes
1820
+ : [{ value: declaration.initializer, appends: false }, ...writes],
1821
+ };
1822
+ }
1021
1823
  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;
1824
+ return { key: binding.initializer, writes: [{ value: binding.initializer, appends: false }] };
1026
1825
  }
1027
1826
  if (ts.isSourceFile(scope))
1028
1827
  break;
1029
1828
  }
1030
1829
  return undefined;
1031
1830
  }
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);
1831
+ /** The values assigned to `name` under `root`. A numeric compound assignment stores no text. */
1832
+ function writesTo(ts, root, name) {
1833
+ const writes = [];
1834
+ const visit = (node) => {
1835
+ if (ts.isIdentifier(node) && node.text === name) {
1836
+ const parent = node.parent;
1837
+ if (ts.isBinaryExpression(parent) && parent.left === node) {
1838
+ const operator = parent.operatorToken.kind;
1839
+ if (operator === ts.SyntaxKind.PlusEqualsToken) {
1840
+ writes.push({ value: parent.right, appends: true });
1841
+ }
1842
+ else if (operator === ts.SyntaxKind.EqualsToken ||
1843
+ operator === ts.SyntaxKind.QuestionQuestionEqualsToken ||
1844
+ operator === ts.SyntaxKind.BarBarEqualsToken ||
1845
+ operator === ts.SyntaxKind.AmpersandAmpersandEqualsToken) {
1846
+ writes.push({ value: parent.right, appends: false });
1847
+ }
1848
+ }
1849
+ }
1850
+ ts.forEachChild(node, visit);
1851
+ };
1852
+ visit(root);
1853
+ return writes;
1854
+ }
1855
+ function mentionsName(ts, node, name) {
1856
+ if (ts.isIdentifier(node))
1857
+ return node.text === name;
1858
+ return ts.forEachChild(node, (child) => mentionsName(ts, child, name) || undefined) === true;
1859
+ }
1860
+ const UNKNOWN_SQL = {
1861
+ staticText: "",
1862
+ dynamic: true,
1863
+ concatenated: false,
1864
+ literal: false,
1865
+ parameterizedTag: false,
1866
+ };
1867
+ function textShape(text) {
1868
+ return {
1869
+ staticText: text,
1870
+ dynamic: false,
1871
+ concatenated: false,
1872
+ literal: true,
1873
+ parameterizedTag: false,
1874
+ };
1875
+ }
1876
+ function joinShapes(shapes) {
1877
+ return {
1878
+ staticText: shapes.map((shape) => shape.staticText).join(""),
1879
+ // As with `+`: text glued onto a binding tag's output un-parameterizes it.
1880
+ dynamic: shapes.some((shape) => shape.dynamic || shape.parameterizedTag),
1881
+ concatenated: true,
1882
+ literal: false,
1883
+ parameterizedTag: false,
1884
+ };
1885
+ }
1886
+ /** Orders a variable's candidate statements by how surely {@link scanInterpolatedSql} would flag
1887
+ * them: dynamic SQL text, then dynamic glued text, then any dynamic text; outside a binding tag
1888
+ * before inside one. */
1889
+ function sqlRisk(shape) {
1890
+ const dynamic = !shape.dynamic
1891
+ ? 0
1892
+ : SQL_KEYWORD.test(shape.staticText) || STATEMENT_START.test(shape.staticText)
1893
+ ? 3
1894
+ : shape.concatenated
1895
+ ? 2
1896
+ : 1;
1897
+ return dynamic * 2 + (shape.parameterizedTag ? 0 : 1);
1898
+ }
1899
+ /**
1900
+ * The riskiest statement a variable may hold. Each value that replaces the text is one candidate.
1901
+ * The values that build on it (`+=`, or `q = q + more`) are glued onto every replacing value at
1902
+ * once, as a builder that takes each branch would, so a static `SELECT … WHERE 1=1` followed by an
1903
+ * interpolated `AND …` reads as the one statement it becomes.
1904
+ */
1905
+ function writtenShape(ts, name, writes, scope, trail) {
1906
+ const replacing = [];
1907
+ const building = [];
1908
+ for (const write of writes) {
1909
+ const shape = sqlExpressionShape(ts, write.value, scope, trail);
1910
+ if (write.appends || mentionsName(ts, write.value, name))
1911
+ building.push(shape);
1912
+ else
1913
+ replacing.push(shape);
1914
+ }
1915
+ const candidates = building.length === 0 ? replacing : [...replacing, joinShapes([...replacing, ...building])];
1916
+ let worst;
1917
+ for (const candidate of candidates) {
1918
+ if (worst === undefined || sqlRisk(candidate) > sqlRisk(worst))
1919
+ worst = candidate;
1920
+ }
1921
+ return worst ?? UNKNOWN_SQL;
1922
+ }
1923
+ const TEXT_METHODS = new Set([
1924
+ "toString",
1925
+ "valueOf",
1926
+ "trim",
1927
+ "trimStart",
1928
+ "trimEnd",
1929
+ "toLowerCase",
1930
+ "toUpperCase",
1931
+ ]);
1932
+ /** The value a text-preserving call returns: `String(x)`, `x.toString()`, `x.trim()`. */
1933
+ function passedThrough(ts, call) {
1934
+ const callee = call.expression;
1935
+ if (ts.isIdentifier(callee) && callee.text === "String" && call.arguments.length === 1) {
1936
+ return call.arguments[0];
1937
+ }
1938
+ if (ts.isPropertyAccessExpression(callee) &&
1939
+ TEXT_METHODS.has(callee.name.text) &&
1940
+ call.arguments.length === 0) {
1941
+ return callee.expression;
1942
+ }
1943
+ return undefined;
1944
+ }
1945
+ /** The parts `[a, b].join(sep)` and `a.concat(b)` glue together, a separator as its text. */
1946
+ function joinedParts(ts, call) {
1947
+ const callee = call.expression;
1948
+ if (!ts.isPropertyAccessExpression(callee))
1949
+ return undefined;
1950
+ if (callee.name.text === "concat")
1951
+ return [callee.expression, ...call.arguments];
1952
+ const items = unwrapExpression(ts, callee.expression);
1953
+ if (callee.name.text !== "join" || !ts.isArrayLiteralExpression(items))
1954
+ return undefined;
1955
+ const separator = call.arguments[0];
1956
+ const glue = separator === undefined ? "," : ts.isStringLiteralLike(separator) ? separator.text : separator;
1957
+ const parts = [];
1958
+ items.elements.forEach((element, index) => {
1959
+ if (index > 0)
1960
+ parts.push(glue);
1961
+ if (ts.isSpreadElement(element))
1962
+ parts.push(element.expression);
1963
+ else if (!ts.isOmittedExpression(element))
1964
+ parts.push(element);
1965
+ });
1966
+ return parts;
1967
+ }
1968
+ /** Whether the `sql` a tag names is a function this file defines (`const sql = (s, ...v) => …`),
1969
+ * which is plain interpolation whatever its name. A parameter, an import, or a driver instance
1970
+ * (`const sql = postgres(url)`) keeps the name's trust. */
1971
+ function definesSqlFunction(ts, tag) {
1972
+ for (let scope = tag.parent; scope !== undefined; scope = scope.parent) {
1973
+ if (ts.isFunctionLike(scope) &&
1974
+ scope.parameters.some((parameter) => bindingNameCovers(ts, parameter.name, tag.text))) {
1975
+ return false;
1976
+ }
1977
+ for (const statement of scopeStatements(ts, scope) ?? []) {
1978
+ if (ts.isFunctionDeclaration(statement) && statement.name?.text === tag.text)
1979
+ return true;
1980
+ }
1981
+ for (const list of scopeDeclarationLists(ts, scope)) {
1982
+ for (const declaration of list.declarations) {
1983
+ if (!ts.isIdentifier(declaration.name) || declaration.name.text !== tag.text)
1984
+ continue;
1985
+ const value = declaration.initializer === undefined
1986
+ ? undefined
1987
+ : unwrapExpression(ts, declaration.initializer);
1988
+ return value !== undefined && (ts.isArrowFunction(value) || ts.isFunctionExpression(value));
1989
+ }
1990
+ }
1991
+ }
1992
+ return false;
1993
+ }
1994
+ /** Describe only the statement's syntax; comments/strings elsewhere cannot influence the result.
1995
+ * `trail` holds the variables being read through, innermost last. */
1996
+ function sqlExpressionShape(ts, expression, scope, trail = []) {
1997
+ let node = unwrapExpression(ts, expression);
1998
+ while (ts.isNonNullExpression(node))
1999
+ node = unwrapExpression(ts, node.expression);
1036
2000
  if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
1037
2001
  return {
1038
2002
  staticText: node.text,
@@ -1044,7 +2008,7 @@ function sqlExpressionShape(ts, node, scope) {
1044
2008
  }
1045
2009
  if (ts.isTaggedTemplateExpression(node)) {
1046
2010
  const tag = node.tag;
1047
- const parameterizedTag = (ts.isIdentifier(tag) && tag.text === "sql") ||
2011
+ const parameterizedTag = (ts.isIdentifier(tag) && tag.text === "sql" && !definesSqlFunction(ts, tag)) ||
1048
2012
  (ts.isPropertyAccessExpression(tag) && tag.getText() === "Prisma.sql");
1049
2013
  const template = node.template;
1050
2014
  const templated = ts.isTemplateExpression(template)
@@ -1054,8 +2018,9 @@ function sqlExpressionShape(ts, node, scope) {
1054
2018
  // Only the ecosystem's binding tags are trusted, by NAME - which is the honest limit of a scanner
1055
2019
  // that reads syntax and runs no type checker. `sql` is what postgres.js, drizzle, slonik and Bun's
1056
2020
  // 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.
2021
+ // deliberate trade, and it is worth stating what it costs: a `sql` this file defines as a
2022
+ // function is read as the interpolation it is, but one imported from anywhere is trusted, so
2023
+ // this rule finds mistakes, not an adversary who reads it first.
1059
2024
  //
1060
2025
  // Trusting anything else widens that for no gain. This list briefly also held `sqlIdentifiers`,
1061
2026
  // a name invented for Nifra's own adapters - a second forgeable name that no user has a
@@ -1077,14 +2042,15 @@ function sqlExpressionShape(ts, node, scope) {
1077
2042
  return {
1078
2043
  staticText: templated.staticText,
1079
2044
  dynamic: templated.dynamic,
1080
- concatenated: false,
2045
+ // `${a} ${b}` writes no text of its own; it only glues values together.
2046
+ concatenated: [node.head, ...node.templateSpans.map((span) => span.literal)].every((part) => part.text.trim() === ""),
1081
2047
  literal: false,
1082
2048
  parameterizedTag: false,
1083
2049
  };
1084
2050
  }
1085
2051
  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);
2052
+ const left = sqlExpressionShape(ts, node.left, scope, trail);
2053
+ const right = sqlExpressionShape(ts, node.right, scope, trail);
1088
2054
  return {
1089
2055
  staticText: left.staticText + right.staticText,
1090
2056
  // A resolved const operand is static text, not a dynamic part - so the old `!literal` test
@@ -1113,47 +2079,132 @@ function sqlExpressionShape(ts, node, scope) {
1113
2079
  };
1114
2080
  }
1115
2081
  }
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
2082
+ if (ts.isCallExpression(node)) {
2083
+ const inner = passedThrough(ts, node);
2084
+ if (inner !== undefined)
2085
+ return { ...sqlExpressionShape(ts, inner, scope, trail), literal: false };
2086
+ const parts = joinedParts(ts, node);
2087
+ if (parts !== undefined) {
2088
+ return joinShapes(parts.map((part) => typeof part === "string" ? textShape(part) : sqlExpressionShape(ts, part, scope, trail)));
2089
+ }
2090
+ }
2091
+ // A bare name that did not resolve to compile-time text. If it is a same-file binding, scan the
2092
+ // shapes of what it may hold - so a statement hoisted into `const q = …` or built up in a `let` is
1118
2093
  // read exactly as if it were written inline at the call, closing the "extract a variable to launder
1119
2094
  // the finding" hole. `literal` is forced false: the value arrived through a variable, which is the
1120
2095
  // property the `unsafe`/`$queryRawUnsafe` hatch rule keys on.
1121
2096
  if (ts.isIdentifier(node)) {
1122
- const initializer = resolveArgumentInitializer(ts, node, 0);
1123
- if (initializer !== undefined) {
1124
- return { ...sqlExpressionShape(ts, initializer, scope), literal: false };
2097
+ const read = variableWrites(ts, node);
2098
+ // Read inside one of its own writes (`q = q + more`), the variable is the text the other writes
2099
+ // already account for.
2100
+ if (read !== undefined && trail.includes(read.key))
2101
+ return { ...textShape(""), literal: false };
2102
+ if (read !== undefined && trail.length <= CONST_RESOLUTION_DEPTH) {
2103
+ const written = writtenShape(ts, node.text, read.writes, scope, [...trail, read.key]);
2104
+ return { ...written, literal: false };
1125
2105
  }
1126
2106
  }
1127
- return {
1128
- staticText: "",
1129
- dynamic: true,
1130
- concatenated: false,
1131
- literal: false,
1132
- parameterizedTag: false,
1133
- };
2107
+ return UNKNOWN_SQL;
1134
2108
  }
1135
2109
  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;
2110
+ const sink = (method, receiver) => SQL_METHODS.has(method) || DRIVER_READ_METHODS.has(method)
2111
+ ? { method, receiver: receiver?.getText() }
2112
+ : undefined;
2113
+ const callee = call.expression;
2114
+ if (ts.isIdentifier(callee))
2115
+ return sink(callee.text, undefined);
2116
+ if (ts.isPropertyAccessExpression(callee))
2117
+ return sink(callee.name.text, callee.expression);
2118
+ if (ts.isElementAccessExpression(callee) && ts.isStringLiteralLike(callee.argumentExpression)) {
2119
+ return sink(callee.argumentExpression.text, callee.expression);
1149
2120
  }
1150
2121
  return undefined;
1151
2122
  }
1152
2123
  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";
2124
+ /** Whether a statement is SQL assembled from runtime values, judged by where it is sent. */
2125
+ function isAssembledSql(sink, shape) {
2126
+ if (!shape.dynamic)
2127
+ return false;
2128
+ if (sink.receiver === undefined || DRIVER_READ_METHODS.has(sink.method)) {
2129
+ return STATEMENT_START.test(shape.staticText);
2130
+ }
2131
+ return (SQL_KEYWORD.test(shape.staticText) || (shape.concatenated && SQL_RECEIVER.test(sink.receiver)));
2132
+ }
2133
+ /** The statement a call passes: the argument itself, or the `text`/`sql` of a config object
2134
+ * (`{ text, values }` in pg, `{ sql, values }` in mysql2). */
2135
+ function statementArgument(ts, argument) {
2136
+ const node = unwrapExpression(ts, argument);
2137
+ if (!ts.isObjectLiteralExpression(node))
2138
+ return argument;
2139
+ for (const property of node.properties) {
2140
+ if (ts.isShorthandPropertyAssignment(property) && STATEMENT_KEYS.has(property.name.text)) {
2141
+ return property.name;
2142
+ }
2143
+ if (ts.isPropertyAssignment(property) &&
2144
+ (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) &&
2145
+ STATEMENT_KEYS.has(property.name.text)) {
2146
+ return property.initializer;
2147
+ }
2148
+ }
2149
+ return undefined;
2150
+ }
2151
+ const STATEMENT_KEYS = new Set(["text", "sql"]);
2152
+ /** Same-file functions that send one of their parameters as a statement - `run(db, q)` calling
2153
+ * `db.query(q)` - by name, with the parameter's position and the sink it reaches. A call to one is
2154
+ * scanned as a call to that sink. One level deep. */
2155
+ function statementHelpers(ts, source) {
2156
+ const helpers = new Map();
2157
+ const visit = (node) => {
2158
+ const sink = ts.isCallExpression(node) ? sqlMethod(ts, node) : undefined;
2159
+ const passed = sink !== undefined && ts.isCallExpression(node) ? node.arguments[0] : undefined;
2160
+ const argument = passed === undefined ? undefined : statementArgument(ts, passed);
2161
+ const statement = argument === undefined ? undefined : unwrapExpression(ts, argument);
2162
+ if (sink !== undefined && statement !== undefined && ts.isIdentifier(statement)) {
2163
+ const binding = nameBinding(ts, statement);
2164
+ const name = binding.kind === "parameter" ? functionName(ts, binding.fn) : undefined;
2165
+ if (binding.kind === "parameter" && name !== undefined && !helpers.has(name)) {
2166
+ helpers.set(name, { position: binding.position, sink });
2167
+ }
2168
+ }
2169
+ ts.forEachChild(node, visit);
2170
+ };
2171
+ visit(source);
2172
+ return helpers;
2173
+ }
2174
+ function functionName(ts, fn) {
2175
+ if (ts.isFunctionDeclaration(fn))
2176
+ return fn.name?.text;
2177
+ if ((ts.isArrowFunction(fn) || ts.isFunctionExpression(fn)) &&
2178
+ ts.isVariableDeclaration(fn.parent) &&
2179
+ ts.isIdentifier(fn.parent.name)) {
2180
+ return fn.parent.name.text;
2181
+ }
2182
+ return undefined;
2183
+ }
2184
+ /** `content` with every string, template and regex literal blanked, line breaks kept. */
2185
+ function literalsBlanked(ts, source, content) {
2186
+ const out = content.split("");
2187
+ const visit = (node) => {
2188
+ if (ts.isStringLiteralLike(node) ||
2189
+ ts.isTemplateHead(node) ||
2190
+ ts.isTemplateMiddle(node) ||
2191
+ ts.isTemplateTail(node) ||
2192
+ ts.isRegularExpressionLiteral(node) ||
2193
+ ts.isJsxText(node)) {
2194
+ for (let i = node.getStart(source); i < node.end; i++)
2195
+ if (out[i] !== "\n")
2196
+ out[i] = " ";
2197
+ return;
2198
+ }
2199
+ ts.forEachChild(node, visit);
2200
+ };
2201
+ visit(source);
2202
+ return out.join("");
2203
+ }
2204
+ /** What silences one interpolated-SQL finding: `// nifra-expect NF-C006: <reason>` (or the
2205
+ * `sql-dynamic` slug) on the flagged line or in the comment block directly above it. The reason after
2206
+ * the colon is mandatory (see {@link commentBlockExpectation}). Scoped to the single statement it sits on. */
2207
+ const SQL_DYNAMIC_EXPECTATION = ["NF-C006", "sql-dynamic"];
1157
2208
  /**
1158
2209
  * Scan one file's text for SQL assembled by interpolation. Pure + line-accurate.
1159
2210
  *
@@ -1162,6 +2213,10 @@ const SQL_DYNAMIC_PRAGMA = "nifra-expect sql-dynamic";
1162
2213
  * to offer gets. With it, only what {@link createProjectSqlImports} will hand back is followed.
1163
2214
  */
1164
2215
  export function scanInterpolatedSql(file, content, ts, imports) {
2216
+ return scanSqlSites(file, content, ts, imports).filter((site) => site.expected === undefined);
2217
+ }
2218
+ /** {@link scanInterpolatedSql}, keeping the sites an expectation comment silences, with its reason. */
2219
+ export function scanSqlSites(file, content, ts, imports) {
1165
2220
  const out = [];
1166
2221
  const lines = content.split("\n");
1167
2222
  // Malformed source is already a typecheck failure. Do not turn parser recovery nodes into a second,
@@ -1173,26 +2228,36 @@ export function scanInterpolatedSql(file, content, ts, imports) {
1173
2228
  // text cannot carry request data, so the shared-projection idiom (`const COLS = "id, name"` …
1174
2229
  // `${COLS}`) stays quiet while mutable, parameter, shadowed, and unprovable names flag as before.
1175
2230
  const scope = { module: sqlModuleScope(ts, file, source), imports };
2231
+ const helpers = statementHelpers(ts, source);
2232
+ // A marker counts only in a comment: one inside a string the call passes is data, not a reason.
2233
+ let markerLines;
1176
2234
  const visit = (node) => {
1177
2235
  if (ts.isCallExpression(node)) {
1178
- const sink = sqlMethod(ts, node);
1179
- const argument = node.arguments[0];
2236
+ const helper = ts.isIdentifier(node.expression)
2237
+ ? helpers.get(node.expression.text)
2238
+ : undefined;
2239
+ const sink = helper?.sink ?? sqlMethod(ts, node);
2240
+ const passed = node.arguments[helper?.position ?? 0];
2241
+ const argument = passed === undefined ? undefined : statementArgument(ts, passed);
1180
2242
  if (sink !== undefined && argument !== undefined) {
1181
2243
  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) {
2244
+ // A helper's own hatch call already answers for passing it a variable.
2245
+ const unsafeEscape = helper === undefined &&
2246
+ sink.receiver !== undefined &&
2247
+ ALWAYS_UNSAFE.has(sink.method) &&
2248
+ !shape.literal &&
2249
+ !shape.parameterizedTag;
2250
+ if (unsafeEscape || isAssembledSql(sink, shape)) {
1187
2251
  const index = node.getStart(source);
1188
2252
  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
2253
+ // Sanctioned escape hatch for genuinely-dynamic-but-bound SQL (an allowlisted identifier,
2254
+ // placeholders numbered from data the scan cannot prove numeric). The reason is mandatory: a bare `// nifra-expect
2255
+ // NF-C006` with no reason after the colon does NOT silence, so the hatch always leaves a
1192
2256
  // 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() });
2257
+ markerLines ??= literalsBlanked(ts, source, content).split("\n");
2258
+ const reason = commentBlockExpectation(markerLines, line, SQL_DYNAMIC_EXPECTATION)?.reason;
2259
+ const site = { file, line, snippet: (lines[line - 1] ?? "").trim() };
2260
+ out.push(reason === undefined ? site : { ...site, expected: reason });
1196
2261
  }
1197
2262
  }
1198
2263
  }
@@ -1442,7 +2507,7 @@ export function scanServerOnlyImports(file, content, facts) {
1442
2507
  // Inline `import { type X } from "…"` is erased just like `import type` and is not an edge; a parse
1443
2508
  // failure keeps the lexical edge so the security rule fails closed.
1444
2509
  for (const { specifier, index } of importEdges(content, facts, file)) {
1445
- if (!SERVER_ONLY.test(specifier))
2510
+ if (!isServerOnly(specifier))
1446
2511
  continue;
1447
2512
  const line = lineAt(content, index);
1448
2513
  out.push({ file, line, snippet: (lines[line - 1] ?? "").trim(), specifier });
@@ -1458,21 +2523,24 @@ export function scanServerOnlyImports(file, content, facts) {
1458
2523
  // local module graph, and build the shortest chain to a server-only SINK. It's best-effort: an import
1459
2524
  // that can't be precisely resolved (a bare pkg, a tsconfig path alias) falls back to the direct edge.
1460
2525
  // ---------------------------------------------------------------------------------------------------
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
2526
  // The retired `*.server` convention. Builds refuse such a module in browser code rather than empty it.
1466
2527
  const LEGACY_SERVER_FILE = /\.server(\.[cm]?[jt]sx?)?$/;
1467
2528
  // The explicit poison-import marker (`@nifrajs/web/backend-only`, or its retired `server-only` name) - a
1468
2529
  // 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). */
2530
+ const BACKEND_ONLY_MARKER_IMPORT = /import\s+["']@nifrajs\/web\/(?:backend|server)-only["']/g;
2531
+ /** Whether `content` imports the backend-only marker in code. A mention in a comment or a string,
2532
+ * such as a note on how to opt in, marks nothing. */
2533
+ function hasBackendOnlyMarker(content) {
2534
+ if (!content.includes("-only"))
2535
+ return false;
2536
+ const positions = codePositionMask(content);
2537
+ for (const match of content.matchAll(BACKEND_ONLY_MARKER_IMPORT)) {
2538
+ if (positions[match.index] !== " ")
2539
+ return true;
2540
+ }
2541
+ return false;
2542
+ }
2543
+ /** A relative module specifier (`./x`, `../y`) - the only kind the SQL import lookup follows. */
1476
2544
  const isRelativeSpecifier = (spec) => spec.startsWith("./") || spec.startsWith("../");
1477
2545
  // A FRESH copy of the static-import regex per scan. The transitive walk is REENTRANT - the outer
1478
2546
  // per-route loop and the inner BFS both scan imports - and a single shared global-flag regex carries
@@ -1492,7 +2560,8 @@ function staticImportEdges(content, facts, file, positions = codePositionMask(co
1492
2560
  continue;
1493
2561
  if (m[1] === undefined)
1494
2562
  continue;
1495
- if (facts !== undefined && file !== undefined) {
2563
+ // Only a `type` modifier can erase a clause the pattern matched, so other clauses skip the parse.
2564
+ if (facts !== undefined && file !== undefined && /\btype\b/.test(m[0])) {
1496
2565
  const source = facts.parse(file, content);
1497
2566
  if (source !== undefined && facts.isValueImportAt(source, m.index, m[1]) === false)
1498
2567
  continue;
@@ -1534,18 +2603,42 @@ function dynamicImportEdges(content, facts, file, positions = codePositionMask(c
1534
2603
  }
1535
2604
  return edges;
1536
2605
  }
1537
- /** Every import a module's runtime code makes - static and literal dynamic - in source order. Pure. */
2606
+ /** The literal `require("x")` calls in executable code. Pure. */
2607
+ function requireEdges(content, positions) {
2608
+ const edges = [];
2609
+ const re = new RegExp(REQUIRE_CALL.source, REQUIRE_CALL.flags);
2610
+ for (let m = re.exec(content); m !== null; m = re.exec(content)) {
2611
+ if (positions[m.index] === " ")
2612
+ continue; // inside a comment or a string
2613
+ const specifier = m[2];
2614
+ if (specifier === undefined)
2615
+ continue;
2616
+ if (TYPE_ALIAS_BEFORE.test(content.slice(Math.max(0, m.index - 80), m.index)))
2617
+ continue;
2618
+ edges.push({ specifier, index: m.index });
2619
+ }
2620
+ return edges;
2621
+ }
2622
+ /** Every import a module's runtime code makes - static, literal dynamic and `require` - in source
2623
+ * order. Pure. */
1538
2624
  function importEdges(content, facts, file) {
1539
- // Without an `import(` there is nothing for the dynamic pass to find; skip its regex.
1540
2625
  const positions = codePositionMask(content);
1541
2626
  const edges = staticImportEdges(content, facts, file, positions);
1542
- if (!content.includes("import(") && !/import\s+\(/.test(content))
2627
+ // Without an `import(` or a `require` there is nothing for the other passes to find; skip them.
2628
+ const dynamic = content.includes("import(") || /import\s+\(/.test(content);
2629
+ const required = content.includes("require");
2630
+ if (!dynamic && !required)
1543
2631
  return edges;
1544
- return [...edges, ...dynamicImportEdges(content, facts, file, positions)].sort((a, b) => a.index - b.index);
2632
+ if (dynamic)
2633
+ edges.push(...dynamicImportEdges(content, facts, file, positions));
2634
+ if (required)
2635
+ edges.push(...requireEdges(content, positions));
2636
+ return edges.sort((a, b) => a.index - b.index);
1545
2637
  }
1546
- /** The runtime imports of a module with the line each sits on, in source order. Pure. */
1547
- export function importSites(content, file) {
1548
- return importEdges(content, undefined, file).map(({ specifier, index }) => ({
2638
+ /** The runtime imports of a module with the line each sits on, in source order. With `facts`, an
2639
+ * import the compiler erases (`import { type X }`) is none. Pure. */
2640
+ export function importSites(content, file, facts) {
2641
+ return importEdges(content, facts, file).map(({ specifier, index }) => ({
1549
2642
  specifier,
1550
2643
  line: lineAt(content, index),
1551
2644
  }));
@@ -1558,63 +2651,130 @@ export function parseImports(content, facts, file) {
1558
2651
  * server-only package), or `undefined` if it isn't a by-name sink. Pure - the label is the specifier
1559
2652
  * itself (it's already the actionable name). */
1560
2653
  export function directSinkSpecifier(spec) {
1561
- return SERVER_ONLY_SINK.test(spec) ? spec : undefined;
2654
+ return isServerOnlySink(spec) ? spec : undefined;
1562
2655
  }
1563
2656
  /**
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.
2657
+ * Every module is read, parsed and resolved at most once, and the chain from a module is searched at
2658
+ * most once, however many routes reach it: per-route searches repeated all of that for every route
2659
+ * that shared a module, which made a check quadratic in the size of a shared graph.
1573
2660
  */
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 });
2661
+ function chainGraph(source) {
2662
+ const { facts } = source;
2663
+ const componentReceipts = source.componentReceipts ?? new Set();
2664
+ const contents = new Map();
2665
+ const imports = new Map();
2666
+ const resolutions = new Map();
2667
+ const walks = new Map();
2668
+ const packages = new Map();
2669
+ const read = (abs) => {
2670
+ if (!contents.has(abs))
2671
+ contents.set(abs, source.read(abs));
2672
+ return contents.get(abs);
2673
+ };
2674
+ const resolve = (from, specifier) => {
2675
+ const key = `${from}\0${specifier}`;
2676
+ if (!resolutions.has(key))
2677
+ resolutions.set(key, source.resolve(from, specifier));
2678
+ return resolutions.get(key);
2679
+ };
2680
+ const backendPackage = (from, specifier) => {
2681
+ if (source.backendPackage === undefined || /^[./#]/.test(specifier))
2682
+ return false;
2683
+ const key = `${from}\0${specifier}`;
2684
+ let found = packages.get(key);
2685
+ if (found === undefined) {
2686
+ found = source.backendPackage(from, specifier);
2687
+ packages.set(key, found);
2688
+ }
2689
+ return found;
2690
+ };
2691
+ const importsOf = (abs, content) => {
2692
+ let found = imports.get(abs);
2693
+ if (found === undefined) {
2694
+ found = parseImports(content, facts, abs);
2695
+ imports.set(abs, found);
2696
+ }
2697
+ return found;
2698
+ };
2699
+ /**
2700
+ * Breadth-first, so the first sink found is the nearest. A node's edges are its static and literal
2701
+ * dynamic imports; an edge is followed only into a project file `resolve` names, an alias included,
2702
+ * never into node_modules. A by-name sink import (`node:fs`, `postgres`), backend code, or a module
2703
+ * importing the `backend-only` marker ends the chain; a `*.fn.ts` module ships as its stub and is not
2704
+ * entered. The visited set keeps it cycle-free; there is no depth or size cap to cut a chain short.
2705
+ */
2706
+ const search = (start, startContent) => {
2707
+ const seen = new Set([start]);
2708
+ let frontier = [{ abs: start, content: startContent, chain: [start], modules: [start] }];
2709
+ while (frontier.length > 0) {
2710
+ const next = [];
2711
+ for (const node of frontier) {
2712
+ const { chain, modules } = node;
2713
+ for (const spec of importsOf(node.abs, node.content)) {
2714
+ const sink = directSinkSpecifier(spec);
2715
+ if (sink !== undefined)
2716
+ return { chain: [...chain, sink], modules };
2717
+ const abs = resolve(node.abs, spec);
2718
+ if (abs === undefined) {
2719
+ if (backendPackage(node.abs, spec))
2720
+ return { chain: [...chain, spec], modules };
2721
+ continue;
2722
+ }
2723
+ if (seen.has(abs))
2724
+ continue;
2725
+ if (componentReceipts.has(abs))
2726
+ continue;
2727
+ if (isBackendModule(abs))
2728
+ return { chain: [...chain, spec], modules: [...modules, abs] };
2729
+ if (SERVER_FN_FILE.test(abs))
2730
+ continue;
2731
+ const content = read(abs);
2732
+ if (content === undefined)
2733
+ continue;
2734
+ if (hasBackendOnlyMarker(content)) {
2735
+ return { chain: [...chain, spec], modules: [...modules, abs] };
2736
+ }
2737
+ seen.add(abs);
2738
+ next.push({ abs, content, chain: [...chain, spec], modules: [...modules, abs] });
2739
+ }
1613
2740
  }
2741
+ frontier = next;
1614
2742
  }
1615
- frontier = next;
1616
- }
1617
- return undefined;
2743
+ return undefined;
2744
+ };
2745
+ return {
2746
+ walk(start, content) {
2747
+ if (!walks.has(start))
2748
+ walks.set(start, search(start, content));
2749
+ return walks.get(start);
2750
+ },
2751
+ read,
2752
+ resolve,
2753
+ backendPackage,
2754
+ componentReceipts,
2755
+ facts,
2756
+ };
2757
+ }
2758
+ /**
2759
+ * The shortest import chain from `routeFile` to a server-only sink, `[routeFile, ...as-written
2760
+ * specifiers, sink]`, or `undefined` when none is reachable. Pure given `resolve`/`read`, so a fake
2761
+ * graph tests it.
2762
+ */
2763
+ export function walkServerOnlyChain(routeFile, routeContent, resolve, read, facts, componentReceipts = new Set()) {
2764
+ return chainGraph({
2765
+ resolve,
2766
+ read,
2767
+ facts,
2768
+ componentReceipts,
2769
+ }).walk(routeFile, routeContent)?.chain;
2770
+ }
2771
+ /**
2772
+ * The server-only chain finder for one check: called per browser module, it returns that module's
2773
+ * findings, reusing every module and chain an earlier call already searched.
2774
+ */
2775
+ export function createServerOnlyChains(source) {
2776
+ const graph = chainGraph(source);
2777
+ return (file, content, browserRoot = false) => serverOnlyChains(graph, file, content, browserRoot);
1618
2778
  }
1619
2779
  /**
1620
2780
  * Resolve the FULL transitive server-only chain(s) for a route module, given a real fs-backed resolver +
@@ -1622,77 +2782,76 @@ export function walkServerOnlyChain(routeFile, routeContent, resolve, read, fact
1622
2782
  * finding carries the full chain; otherwise - when the first hop is itself a direct by-name sink the
1623
2783
  * regex scan already flags, or when a relative import can't be resolved - it falls back to the DIRECT
1624
2784
  * 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 `[]`.
2785
+ * de-duplicated to the shortest chain per first-hop specifier. A file that is not browser source
2786
+ * yields `[]`, unless `browserRoot` says the browser bundle loads it anyway (an app's `clientModule`).
2787
+ * One check should use {@link createServerOnlyChains}, which shares the work across modules.
1626
2788
  */
1627
- export function resolveServerOnlyChains(file, content, resolve, read, facts) {
1628
- if (!isBrowserSource(file))
2789
+ export function resolveServerOnlyChains(file, content, resolve, read, facts, componentReceipts = new Set(), browserRoot = false) {
2790
+ return createServerOnlyChains({
2791
+ resolve,
2792
+ read,
2793
+ facts,
2794
+ componentReceipts,
2795
+ })(file, content, browserRoot);
2796
+ }
2797
+ function serverOnlyChains(graph, file, content, browserRoot) {
2798
+ if (!browserRoot && !isBrowserSource(file))
1629
2799
  return [];
1630
2800
  const lines = content.split("\n");
1631
2801
  const out = [];
1632
2802
  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)) {
2803
+ for (const { specifier, index } of importEdges(content, graph.facts, file)) {
1637
2804
  if (flaggedSpecifiers.has(specifier))
1638
2805
  continue;
1639
2806
  const line = lineAt(content, index);
1640
2807
  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) {
2808
+ const flag = (chain, fallback, modules = [file]) => {
1644
2809
  flaggedSpecifiers.add(specifier);
1645
- out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: false });
2810
+ out.push({ file, line, snippet, specifier, chain, modules, fallback });
2811
+ };
2812
+ // (1) A direct by-name sink (`node:fs`, `postgres`): the route imports it itself.
2813
+ if (directSinkSpecifier(specifier) !== undefined) {
2814
+ flag([file, specifier], false);
1646
2815
  continue;
1647
2816
  }
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
- }
2817
+ // (2) A project import, relative or through an alias: walk from the resolved dependency and, if
2818
+ // it reaches a sink, report the full chain rooted at THIS route's import line.
2819
+ const abs = graph.resolve(file, specifier);
2820
+ if (abs === undefined) {
2821
+ // An installed package that declares itself backend is a sink. Any other name no project file
2822
+ // answers to can't be called server-only, except the `../db` convention, reported as the direct
2823
+ // edge with `fallback` saying the resolve was impossible.
2824
+ if (graph.backendPackage(file, specifier))
2825
+ flag([file, specifier], false);
2826
+ else if (isServerOnly(specifier))
2827
+ flag([file, specifier], true);
2828
+ continue;
2829
+ }
2830
+ if (graph.componentReceipts.has(abs))
2831
+ continue;
2832
+ // A backend first hop and a marked module are sinks; a `*.fn.ts` hop ships as its stub.
2833
+ if (isBackendModule(abs)) {
2834
+ flag([file, specifier], false, [file, abs]);
2835
+ continue;
2836
+ }
2837
+ if (SERVER_FN_FILE.test(abs))
2838
+ continue;
2839
+ const depContent = graph.read(abs);
2840
+ if (depContent === undefined)
2841
+ continue;
2842
+ if (hasBackendOnlyMarker(depContent)) {
2843
+ flag([file, specifier], false, [file, abs]);
2844
+ continue;
2845
+ }
2846
+ const sub = graph.walk(abs, depContent);
2847
+ if (sub !== undefined) {
2848
+ // `sub` starts at the resolved dependency; the route names it by its as-written specifier.
2849
+ flag([file, specifier, ...sub.chain.slice(1)], false, [file, ...sub.modules]);
2850
+ continue;
1695
2851
  }
2852
+ // (3) The `../db` convention with no deeper sink found: still surface the direct edge.
2853
+ if (isServerOnly(specifier))
2854
+ flag([file, specifier], true);
1696
2855
  }
1697
2856
  return out;
1698
2857
  }
@@ -1721,22 +2880,19 @@ export function scanResponseRoutes(file, content, facts) {
1721
2880
  continue;
1722
2881
  }
1723
2882
  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)
2883
+ // A `// nifra-expect NF-C007` comment (or the `raw-response` slug) marks an intentional raw Response
2884
+ // (a file/redirect that can't be a typed route), silencing this advisory with or without a reason.
2885
+ // It counts on the return line or in the comment block directly above, so a TRAILING comment on one
2886
+ // route's return line can't leak down onto the next route. `stripComments` blanks it in `code` (so it
2887
+ // never affects the match); the raw `lines` still carry it.
2888
+ if (commentBlockExpectation(lines, line, RAW_RESPONSE_EXPECTATION) !== undefined)
1733
2889
  continue;
1734
- out.push({ file, line, snippet: thisLine.trim() });
2890
+ out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() });
1735
2891
  }
1736
2892
  return out;
1737
2893
  }
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";
2894
+ /** What suppresses the {@link scanResponseRoutes} advisory for an intentional raw Response. */
2895
+ const RAW_RESPONSE_EXPECTATION = ["NF-C007", "raw-response"];
1740
2896
  /** Walk the project's `.ts`/`.tsx` source (skipping deps and build output), calling `visit` per file.
1741
2897
  * Exported so `nifra doctor` ({@link ./doctor.ts}) scans the same source surface as `nifra check`.
1742
2898
  *
@@ -1777,11 +2933,17 @@ async function gitIgnored(cwd, rels) {
1777
2933
  }
1778
2934
  export async function walkSource(cwd, visit, opts = {}) {
1779
2935
  const skip = opts.includeTests === true ? IGNORED_DIR : IGNORED;
2936
+ const pattern = opts.scripts === true
2937
+ ? "**/*.{ts,tsx,mts,cts,jsx,js,mjs,cjs,svelte,vue}"
2938
+ : "**/*.{ts,tsx,mts,cts}";
1780
2939
  // List candidates first (cheap - no reads), drop the built-in ignores, then exclude gitignored paths in
1781
2940
  // one batch before the (expensive) reads. So a gitignored generated/build tree is never read or scanned.
1782
2941
  const rels = [];
1783
- for await (const rawRel of new Glob("**/*.{ts,tsx,mts,cts}").scan({ cwd, dot: false })) {
2942
+ for await (const rawRel of new Glob(pattern).scan({ cwd, dot: false })) {
1784
2943
  const rel = normalizeProjectPath(rawRel);
2944
+ // A `.js` file outside a zone is tooling (a bundler config, a script, a vendored asset), not app code.
2945
+ if (PLAIN_SCRIPT_FILE.test(rel) && !ZONED_PATH.test(rel))
2946
+ continue;
1785
2947
  if (!skip.test(rel) && (opts.ignore === undefined || !opts.ignore(rel)))
1786
2948
  rels.push(rel);
1787
2949
  }
@@ -1790,8 +2952,44 @@ export async function walkSource(cwd, visit, opts = {}) {
1790
2952
  for (const rel of rels.sort(codeUnitOrder)) {
1791
2953
  if (ignored.has(rel))
1792
2954
  continue;
1793
- visit(rel, await Bun.file(join(cwd, rel)).text());
2955
+ const text = await Bun.file(join(cwd, rel)).text();
2956
+ visit(rel, COMPONENT_FILE.test(rel) ? componentScripts(text) : text);
2957
+ }
2958
+ }
2959
+ const PLAIN_SCRIPT_FILE = /\.[cm]?js$/;
2960
+ const ZONED_PATH = /(^|\/)(?:routes|frontend|shared|backend)\/|\.(?:frontend|shared|backend|fn)\.[^/]+$/;
2961
+ /** A single-file component: its `<script>` blocks are the code a build compiles. */
2962
+ export const COMPONENT_FILE = /\.(?:svelte|vue)$/;
2963
+ /**
2964
+ * A `.svelte` or `.vue` file as the source scanners read it: the text of its `<script>` blocks where
2965
+ * they stand, and every other character blanked to a space with line breaks kept, so a finding's line
2966
+ * and column are the file's own. An unclosed `<script>` runs to the end of the file. Pure.
2967
+ */
2968
+ export function componentScripts(text) {
2969
+ const blank = (part) => part.replace(/[^\n]/g, " ");
2970
+ let out = "";
2971
+ let at = 0;
2972
+ for (;;) {
2973
+ const open = text.indexOf("<script", at);
2974
+ if (open === -1)
2975
+ break;
2976
+ const close = text.indexOf(">", open);
2977
+ if (close === -1)
2978
+ break;
2979
+ // `<scripts>` or `<script-x>` is another element.
2980
+ if (!/[\s>]/.test(text[open + 7] ?? "")) {
2981
+ out += blank(text.slice(at, close + 1));
2982
+ at = close + 1;
2983
+ continue;
2984
+ }
2985
+ const end = text.indexOf("</script", close + 1);
2986
+ const stop = end === -1 ? text.length : end;
2987
+ out += blank(text.slice(at, close + 1)) + text.slice(close + 1, stop);
2988
+ at = stop;
2989
+ if (end === -1)
2990
+ break;
1794
2991
  }
2992
+ return out + blank(text.slice(at));
1795
2993
  }
1796
2994
  const bySite = (a, b) => codeUnitOrder(a.file, b.file) || a.line - b.line;
1797
2995
  /** Collect own-API `fetch()` findings across the project. */