@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
package/src/check-scan.ts CHANGED
@@ -6,7 +6,8 @@
6
6
  */
7
7
 
8
8
  import { readFileSync, realpathSync } from "node:fs"
9
- import { dirname, isAbsolute, join, resolve, sep } from "node:path"
9
+ import { dirname, isAbsolute, join, posix, resolve, sep } from "node:path"
10
+ import { isServerPackageSpecifier } from "@nifrajs/web/zones"
10
11
  import { Glob } from "bun"
11
12
  import type * as TSApi from "typescript"
12
13
  import { codeUnitOrder } from "./internal/code-unit-order.ts"
@@ -17,19 +18,43 @@ import {
17
18
  type TypeScriptApi,
18
19
  type TypeScriptSourceIndex,
19
20
  } from "./internal/typescript-import.ts"
20
- import { commentBlockMarkerReason } from "./rules/comment-markers.ts"
21
+ import { commentBlockExpectation } from "./rules/comment-markers.ts"
21
22
 
22
23
  export interface SourceFinding {
23
24
  readonly file: string
24
25
  readonly line: number
25
26
  readonly snippet: string
26
27
  }
28
+ /** An interpolated-SQL site, with the reason a `nifra-expect NF-C006: <reason>` comment gives for it. */
29
+ export interface SqlFinding extends SourceFinding {
30
+ readonly expected?: string
31
+ }
27
32
  /** A server-only-import finding, carrying the offending module specifier on top of the base location so
28
33
  * the diagnostic can show the import chain `routeFile → specifier` (the direct edge the regex scan sees). */
29
34
  export interface ServerImportFinding extends SourceFinding {
30
35
  /** The server-only module specifier the route top-level-imports (e.g. `pg`, `node:fs`, `../db`). */
31
36
  readonly specifier: string
32
37
  }
38
+ /** A hand-rolled call to the app's own API, with the `// nifra-expect NF-C002` comment covering it. */
39
+ export interface TypedClientFinding extends SourceFinding {
40
+ /** Present when such a comment covers the call. `reason` is `undefined` when it gives none after
41
+ * its colon, and then it silences nothing. */
42
+ readonly expected?: { readonly reason: string | undefined }
43
+ }
44
+
45
+ /** What marks a hand-rolled own-API call as intended: a streamed POST, a foreign handler's endpoint,
46
+ * a static file. Only the code form; a reason after the colon is required. */
47
+ const TYPED_CLIENT_EXPECTATION = ["NF-C002"]
48
+
49
+ function typedClientFinding(
50
+ file: string,
51
+ lines: readonly string[],
52
+ line: number,
53
+ ): TypedClientFinding {
54
+ const finding = { file, line, snippet: (lines[line - 1] ?? "").trim() }
55
+ const expected = commentBlockExpectation(lines, line, TYPED_CLIENT_EXPECTATION)
56
+ return expected === undefined ? finding : { ...finding, expected }
57
+ }
33
58
 
34
59
  // `fetch( <ws> ('|"|`) / (not /)` - a string/template arg starting with a single `/` is a relative URL,
35
60
  // i.e. same-origin = this app's own API. `(?<![.\w])` skips `.fetch(` (a method) and `prefetch(`; the
@@ -38,6 +63,7 @@ export interface ServerImportFinding extends SourceFinding {
38
63
  const GLOBAL_FETCH_CALL = /(?<![.\w])fetch\s*\(/g
39
64
  const FETCH_CALL = /(?<![.\w])fetch\s*\(/g
40
65
  const HTTP_VERBS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"])
66
+ const SOCKET_VERB: ReadonlySet<string> = new Set(["WS"])
41
67
  export const SIMPLE_REWRITE_METHODS = new Set(["GET", "DELETE", "HEAD", "OPTIONS"])
42
68
 
43
69
  // Don't scan deps, build output, or generated client entries. `dist(-<runtime>)?` also covers
@@ -82,10 +108,57 @@ function isBackendModule(file: string): boolean {
82
108
  )
83
109
  }
84
110
 
85
- // Module specifiers that must never be VALUE-imported into a route module: node:/bun: builtins, common
86
- // DB drivers/ORМ server entrypoints, and the conventional `./db` module the scaffold generates.
87
- const SERVER_ONLY =
88
- /^(?: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?)?$/
111
+ // Node builtins that have no browser counterpart, imported bare. Bare `events`, `buffer`, `path`, `util`
112
+ // and the like stay out: each also names an npm polyfill a browser bundle may legitimately carry.
113
+ const SERVER_BUILTINS: ReadonlySet<string> = new Set([
114
+ "async_hooks",
115
+ "child_process",
116
+ "cluster",
117
+ "crypto",
118
+ "dgram",
119
+ "diagnostics_channel",
120
+ "dns",
121
+ "dns/promises",
122
+ "fs",
123
+ "fs/promises",
124
+ "http",
125
+ "http2",
126
+ "https",
127
+ "inspector",
128
+ "module",
129
+ "net",
130
+ "os",
131
+ "perf_hooks",
132
+ "readline",
133
+ "readline/promises",
134
+ "repl",
135
+ "tls",
136
+ "trace_events",
137
+ "tty",
138
+ "v8",
139
+ "vm",
140
+ "wasi",
141
+ "worker_threads",
142
+ ])
143
+ // The conventional `./db` module the scaffold generates.
144
+ const DB_MODULE = /^(?:\.\.?\/)+db(?:\.[cm]?[jt]sx?)?$/
145
+
146
+ /**
147
+ * Whether a specifier names server-only code by name, so the chain ends there: a `node:`/`bun:`
148
+ * module, a bare builtin with no browser counterpart, or a package the browser build refuses
149
+ * (`SERVER_PACKAGES` and the driver entries of `@nifrajs/web/zones`).
150
+ */
151
+ function isServerOnlySink(specifier: string): boolean {
152
+ return (
153
+ /^(?:node:|bun:)/.test(specifier) ||
154
+ SERVER_BUILTINS.has(specifier) ||
155
+ isServerPackageSpecifier(specifier)
156
+ )
157
+ }
158
+
159
+ /** A specifier no route module may value-import: a {@link isServerOnlySink} name, or the `./db` module. */
160
+ const isServerOnly = (specifier: string): boolean =>
161
+ isServerOnlySink(specifier) || DB_MODULE.test(specifier)
89
162
 
90
163
  // A static, non-type import with a string specifier, or a re-export (`export … from "x"`), which
91
164
  // pulls its module into the bundle the same way. `import type …` and `export type …` are erased at
@@ -103,6 +176,10 @@ const DYNAMIC_IMPORT = /(?<![\w$.])import\s*\(\s*(["'`])([^"'`$\\\r\n]+)\1\s*[,)
103
176
  // Without a parser: `typeof import("x")` and `import("x").Name` are type positions, erased at build.
104
177
  // A runtime `import("x")` is a promise, so the only members read straight off it are its methods.
105
178
  const TYPE_QUERY_BEFORE = /\btypeof\s*$/
179
+ // `require("x")` and `import x = require("x")` pull a module into a bundle as `import` does; the
180
+ // `import type x = require("x")` form is erased.
181
+ const REQUIRE_CALL = /(?<![\w$.])require\s*\(\s*(["'])([^"'\\\r\n]+)\1\s*\)/g
182
+ const TYPE_ALIAS_BEFORE = /\bimport\s+type\s+[\w$]+\s*=\s*$/
106
183
  const TYPE_QUALIFIER_AFTER = /^\s*\.\s*(?!(?:then|catch|finally)\b)[A-Za-z_$]/
107
184
 
108
185
  const ROUTE_REGISTRATION_DQ = /\.([A-Za-z]+)\s*\(\s*"((?:\\.|[^"\\])*)"/g
@@ -122,6 +199,17 @@ export interface StaticRouteFinding extends SourceFinding {
122
199
  readonly path: string
123
200
  }
124
201
 
202
+ /** A route registration whose path argument the static scan cannot read, with that argument's text. */
203
+ export interface UnresolvedRoutePathFinding extends SourceFinding {
204
+ readonly method: string
205
+ readonly expression: string
206
+ }
207
+
208
+ // `.get(NAME, …)` or `` .get(`…`, …) ``: a path that is not a quoted literal. The comma is required:
209
+ // a registration always has a handler after its path, while `map.get(key)` has nothing.
210
+ const ROUTE_ARGUMENT = /\.([A-Za-z]+)\s*\(\s*([A-Za-z_$][\w$]*|`(?:\\.|[^`\\])*`)\s*,/g
211
+ const NAMED_IMPORTS = /(?<![\w$.])import\s*\{([^}]*)\}\s*from\s*(["'])([^"'\r\n]+)\2/g
212
+
125
213
  interface SimpleFetchCall {
126
214
  readonly path: string
127
215
  readonly method: string
@@ -264,28 +352,73 @@ export function parseSimpleFetchCall(snippet: string): SimpleFetchCall | undefin
264
352
  return { path, method, start: match.index, end: close + 1 }
265
353
  }
266
354
 
267
- function isPotentialBackendSource(code: string): boolean {
355
+ const SERVER_CONSTRUCTION = /(?<![.\w])server\s*\(|new\s+Hono(?:<[^>]+>)?\s*\(/
356
+
357
+ function isPotentialBackendSource(
358
+ code: string,
359
+ file?: string,
360
+ project?: ReadonlyMap<string, string>,
361
+ ): boolean {
268
362
  return (
269
363
  code.includes("@nifrajs/core") ||
270
- /(?<![.\w])server\s*\(/.test(code) ||
364
+ SERVER_CONSTRUCTION.test(code) ||
271
365
  /(?:from\s*|import\s*)["']hono["']/.test(code) ||
272
- /new\s+Hono(?:<[^>]+>)?\s*\(/.test(code)
366
+ (file !== undefined && project !== undefined && callsLocalServerFactory(code, file, project))
273
367
  )
274
368
  }
275
369
 
370
+ /**
371
+ * A module that builds its server through a local helper - `newServer().get(...)` with `newServer`
372
+ * imported from a project file that calls `server()` - names neither `@nifrajs/core` nor `server(`
373
+ * itself. One import hop is followed: the imported module must construct a server, so a helper module
374
+ * that merely mentions `@nifrajs/core` (a schema re-export) does not turn every importer into a
375
+ * backend and its `client.get("/x")` calls into routes.
376
+ */
377
+ function callsLocalServerFactory(
378
+ code: string,
379
+ file: string,
380
+ project: ReadonlyMap<string, string>,
381
+ ): boolean {
382
+ for (const [local, binding] of namedImports(code)) {
383
+ if (!new RegExp(`(?<![.\\w$])${local.replaceAll("$", "\\$")}\\s*\\(`).test(code)) continue
384
+ const target = projectModule(project, file, binding.specifier)
385
+ if (target !== undefined && SERVER_CONSTRUCTION.test(stripComments(project.get(target) ?? "")))
386
+ return true
387
+ }
388
+ return false
389
+ }
390
+
391
+ /**
392
+ * Literal route registrations in a file the backend-source gate turned away, so a route the scan
393
+ * could not cover can say where it probably lives. Nothing here is treated as a route.
394
+ */
395
+ export function scanSkippedRouteText(
396
+ file: string,
397
+ content: string,
398
+ project?: ReadonlyMap<string, string>,
399
+ ): StaticRouteFinding[] {
400
+ const code = stripComments(content)
401
+ if (isPotentialBackendSource(code, file, project)) return []
402
+ return [
403
+ ...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_DQ),
404
+ ...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_SQ),
405
+ ]
406
+ }
407
+
276
408
  function scanRoutePattern(
277
409
  file: string,
278
410
  content: string,
279
411
  code: string,
280
412
  pattern: RegExp,
281
413
  facts?: SourceFacts,
414
+ verbs: ReadonlySet<string> = HTTP_VERBS,
282
415
  ): StaticRouteFinding[] {
283
416
  const out: StaticRouteFinding[] = []
284
417
  const lines = content.split("\n")
285
418
  pattern.lastIndex = 0
286
419
  for (let m = pattern.exec(code); m !== null; m = pattern.exec(code)) {
287
420
  const method = (m[1] ?? "").toUpperCase()
288
- if (!HTTP_VERBS.has(method)) continue
421
+ if (!verbs.has(method)) continue
289
422
  const path = parseQuotedLiteral(
290
423
  `${pattern === ROUTE_REGISTRATION_DQ ? '"' : "'"}${m[2] ?? ""}${pattern === ROUTE_REGISTRATION_DQ ? '"' : "'"}`,
291
424
  )
@@ -306,25 +439,94 @@ function scanRoutePattern(
306
439
  return out
307
440
  }
308
441
 
442
+ // Punctuators after which a `/` opens a regex literal rather than dividing. A `)` `]` or `}` is read
443
+ // as the end of an operand, so `/` after it divides - the rare statement-leading regex after a block
444
+ // is the cost of not tracking parser context.
445
+ const REGEX_AFTER = new Set([
446
+ "(",
447
+ ",",
448
+ "=",
449
+ ":",
450
+ "[",
451
+ "!",
452
+ "&",
453
+ "|",
454
+ "?",
455
+ "{",
456
+ ";",
457
+ "+",
458
+ "-",
459
+ "*",
460
+ "%",
461
+ "<",
462
+ ">",
463
+ "~",
464
+ "^",
465
+ ])
466
+ const REGEX_AFTER_WORD = new Set([
467
+ "return",
468
+ "typeof",
469
+ "case",
470
+ "do",
471
+ "else",
472
+ "in",
473
+ "of",
474
+ "new",
475
+ "delete",
476
+ "void",
477
+ "throw",
478
+ "yield",
479
+ "await",
480
+ "instanceof",
481
+ ])
482
+ const isWordChar = (c: string | undefined): boolean => c !== undefined && /[\w$]/.test(c)
483
+
484
+ /** Where a regex literal opened by the `/` at `i` ends (after its flags), or `undefined` when the
485
+ * literal does not close on its line - then the slash was division after all. */
486
+ function regexLiteralEnd(src: string, i: number): number | undefined {
487
+ const n = src.length
488
+ let j = i + 1
489
+ let inClass = false
490
+ while (j < n) {
491
+ const c = src[j]
492
+ if (c === "\n" || c === "\r") return undefined
493
+ if (c === "\\") {
494
+ j += 2
495
+ continue
496
+ }
497
+ if (inClass) {
498
+ if (c === "]") inClass = false
499
+ } else if (c === "[") {
500
+ inClass = true
501
+ } else if (c === "/") {
502
+ break
503
+ }
504
+ j++
505
+ }
506
+ if (j >= n) return undefined
507
+ j++
508
+ while (j < n && isWordChar(src[j])) j++
509
+ return j
510
+ }
511
+
309
512
  /**
310
- * Blank, with spaces (newlines preserved, so every byte offset - and line number - is unchanged):
311
- * - `//` line comments and block comments;
312
- * - the CONTENTS of backtick template literals - code-as-text (doc `CodeBlock` examples, code
313
- * generators), never a real statement to lint.
314
- * Single/double-quoted strings are KEPT (a real import/Response specifier lives in one). A small
315
- * char-state machine, not a full lexer: it doesn't model regex literals, so a regex containing a quote
316
- * could mis-skip - in practice the constructs these scanners look for sit before any such regex.
317
- * Shared by the source scanners here and by `nifra doctor` ({@link ./doctor.ts}).
513
+ * Blank comments, template-literal contents and regex-literal contents (and, with `quoted: "blank"`,
514
+ * single/double-quoted strings and every delimiter) while preserving every offset and newline.
515
+ * A small lexer, not a parser: a `/` opens a regex where an operand may start - after a punctuator,
516
+ * an operator keyword, or at the start of the file - so a quote inside a regex no longer derails
517
+ * the string state that follows.
318
518
  */
319
- export function stripComments(src: string): string {
519
+ function maskSource(src: string, quoted: "keep" | "blank"): string {
320
520
  const out = src.split("")
321
521
  const n = src.length
322
- let i = 0
323
522
  const blank = (a: number, b: number): void => {
324
523
  for (let k = a; k < b; k++) if (out[k] !== "\n") out[k] = " "
325
524
  }
525
+ // The last significant code token: a punctuator character, or a whole identifier/keyword.
526
+ let previous = ""
527
+ let i = 0
326
528
  while (i < n) {
327
- const c = src[i]
529
+ const c = src.charAt(i)
328
530
  const d = src[i + 1]
329
531
  if (c === "/" && d === "/") {
330
532
  let j = i + 2
@@ -343,62 +545,66 @@ export function stripComments(src: string): string {
343
545
  if (src[j] === "\\") j++
344
546
  j++
345
547
  }
346
- blank(i + 1, j) // keep the backticks, blank the code-as-text inside
548
+ if (quoted === "keep")
549
+ blank(i + 1, j) // keep the backticks, blank the code-as-text inside
550
+ else blank(i, Math.min(j + 1, n))
347
551
  i = j + 1
552
+ previous = "`"
348
553
  } else if (c === "'" || c === '"') {
349
554
  let j = i + 1
350
555
  while (j < n && src[j] !== c) {
351
556
  if (src[j] === "\\") j++ // skip the escaped char
352
557
  j++
353
558
  }
559
+ if (quoted === "blank") blank(i, Math.min(j + 1, n))
354
560
  i = j + 1
561
+ previous = c
562
+ } else if (
563
+ c === "/" &&
564
+ (previous === "" || REGEX_AFTER.has(previous) || REGEX_AFTER_WORD.has(previous))
565
+ ) {
566
+ const end = regexLiteralEnd(src, i)
567
+ if (end === undefined) {
568
+ previous = c
569
+ i++
570
+ } else {
571
+ if (quoted === "keep") blank(i + 1, end)
572
+ else blank(i, end)
573
+ previous = "/regex"
574
+ i = end
575
+ }
576
+ } else if (isWordChar(c)) {
577
+ let j = i + 1
578
+ while (j < n && isWordChar(src[j])) j++
579
+ previous = src.slice(i, j)
580
+ i = j
355
581
  } else {
582
+ if (!/\s/.test(c)) previous = c
356
583
  i++
357
584
  }
358
585
  }
359
586
  return out.join("")
360
587
  }
361
588
 
362
- /** Blank comments and every quoted/template literal while preserving offsets. Unlike
589
+ /**
590
+ * Blank, with spaces (newlines preserved, so every byte offset - and line number - is unchanged):
591
+ * - `//` line comments and block comments;
592
+ * - the CONTENTS of backtick template literals - code-as-text (doc `CodeBlock` examples, code
593
+ * generators), never a real statement to lint.
594
+ * Single/double-quoted strings are KEPT (a real import/Response specifier lives in one); a regex
595
+ * literal keeps its slashes and loses its contents, so a quote inside one cannot open a string.
596
+ * Shared by the source scanners here and by `nifra doctor` ({@link ./doctor.ts}).
597
+ */
598
+ export function stripComments(src: string): string {
599
+ return maskSource(src, "keep")
600
+ }
601
+
602
+ /** Blank comments and every quoted/template/regex literal while preserving offsets. Unlike
363
603
  * {@link stripComments}, this is a code-position mask: scanners use it to find a call expression in
364
604
  * executable code, then inspect the original source for the call's literal argument. That prevents
365
605
  * documentation strings such as `const example = 'client("/")'` from becoming diagnostics. */
366
606
  export function codePositionMask(src: string): string {
367
- const out = src.split("")
368
- const n = src.length
369
- let i = 0
370
- const blank = (a: number, b: number): void => {
371
- for (let k = a; k < b; k++) if (out[k] !== "\n") out[k] = " "
372
- }
373
- while (i < n) {
374
- const c = src[i]
375
- const d = src[i + 1]
376
- if (c === "/" && d === "/") {
377
- let j = i + 2
378
- while (j < n && src[j] !== "\n") j++
379
- blank(i, j)
380
- i = j
381
- } else if (c === "/" && d === "*") {
382
- let j = i + 2
383
- while (j < n && !(src[j] === "*" && src[j + 1] === "/")) j++
384
- j = Math.min(j + 2, n)
385
- blank(i, j)
386
- i = j
387
- } else if (c === "'" || c === '"' || c === "`") {
388
- const quote = c
389
- let j = i + 1
390
- while (j < n && src[j] !== quote) {
391
- if (src[j] === "\\") j++
392
- j++
393
- }
394
- j = Math.min(j + 1, n)
395
- blank(i, j)
396
- i = j
397
- } else {
398
- i++
399
- }
400
- }
401
- return out.join("")
607
+ return maskSource(src, "blank")
402
608
  }
403
609
 
404
610
  const firstArgumentIndex = (content: string, callEnd: number): number => {
@@ -502,8 +708,8 @@ export function scanFetchText(
502
708
  file: string,
503
709
  content: string,
504
710
  externalMounts: readonly string[] = [],
505
- ): SourceFinding[] {
506
- const out: SourceFinding[] = []
711
+ ): TypedClientFinding[] {
712
+ const out: TypedClientFinding[] = []
507
713
  const lines = content.split("\n")
508
714
  const code = codePositionMask(content)
509
715
  GLOBAL_FETCH_CALL.lastIndex = 0
@@ -516,8 +722,7 @@ export function scanFetchText(
516
722
  if (hasLiteralNifraDataHeader(content, openParen)) continue
517
723
  if (externalMounts.length > 0 && matchesExternalMount(content, argument + 1, externalMounts))
518
724
  continue
519
- const line = lineAt(content, m.index)
520
- out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() })
725
+ out.push(typedClientFinding(file, lines, lineAt(content, m.index)))
521
726
  }
522
727
  return out
523
728
  }
@@ -540,8 +745,8 @@ export function scanStreamText(
540
745
  file: string,
541
746
  content: string,
542
747
  externalMounts: readonly string[] = [],
543
- ): SourceFinding[] {
544
- const out: SourceFinding[] = []
748
+ ): TypedClientFinding[] {
749
+ const out: TypedClientFinding[] = []
545
750
  const lines = content.split("\n")
546
751
  const code = codePositionMask(content)
547
752
  STREAM_CONSTRUCT.lastIndex = 0
@@ -552,8 +757,7 @@ export function scanStreamText(
552
757
  if (content[argument + 1] !== "/" || content[argument + 2] === "/") continue
553
758
  if (externalMounts.length > 0 && matchesExternalMount(content, argument + 1, externalMounts))
554
759
  continue
555
- const line = lineAt(content, m.index)
556
- out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() })
760
+ out.push(typedClientFinding(file, lines, lineAt(content, m.index)))
557
761
  }
558
762
  return out
559
763
  }
@@ -563,13 +767,210 @@ export function scanStaticRouteText(
563
767
  file: string,
564
768
  content: string,
565
769
  facts?: SourceFacts,
770
+ /** Project files by forward-slash relative path, to resolve a path constant imported from one. */
771
+ project?: ReadonlyMap<string, string>,
566
772
  ): StaticRouteFinding[] {
567
773
  const code = stripComments(content)
568
- if (!isPotentialBackendSource(code)) return []
774
+ if (!isPotentialBackendSource(code, file, project)) return []
569
775
  return [
570
776
  ...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_DQ, facts),
571
777
  ...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_SQ, facts),
572
778
  ...(code.includes(METHODS_SUBPATH) ? scanMethodRoutes(file, content, code, facts) : []),
779
+ ...scanRouteArguments(file, content, facts, project).routes,
780
+ ].sort(bySite)
781
+ }
782
+
783
+ /** A module-level `const`: its value when the initializer is one string literal, else `undefined`. */
784
+ interface ModuleConstant {
785
+ readonly exported: boolean
786
+ readonly value: string | undefined
787
+ }
788
+
789
+ /** The end of the quoted literal opening `text`, or -1 when it does not close on this line. */
790
+ function literalEnd(text: string): number {
791
+ const quote = text[0]
792
+ for (let i = 1; i < text.length; i++) {
793
+ if (text[i] === "\\") i++
794
+ else if (text[i] === quote) return i + 1
795
+ }
796
+ return -1
797
+ }
798
+
799
+ /** The index after the whitespace run at `from`. */
800
+ function skipSpace(text: string, from: number): number {
801
+ let i = from
802
+ while (i < text.length && (text[i] === " " || text[i] === "\t")) i++
803
+ return i
804
+ }
805
+
806
+ /**
807
+ * Read `[export ]const NAME[: T] = <init>` written at column 0, so a function-local `const`, which is
808
+ * indented, never stands in for a module-level one. The shape is read from the comment-stripped
809
+ * line; the literal itself from the source line, since stripping blanks template contents.
810
+ */
811
+ function moduleConstantOn(code: string, source: string): [string, ModuleConstant] | undefined {
812
+ const exported = code.startsWith("export ")
813
+ let i = exported ? skipSpace(code, 7) : 0
814
+ if (!code.startsWith("const ", i)) return undefined
815
+ i = skipSpace(code, i + 6)
816
+ const name = /^[A-Za-z_$][\w$]*/.exec(code.slice(i))?.[0]
817
+ if (name === undefined) return undefined
818
+ i = skipSpace(code, i + name.length)
819
+ if (code[i] === ":") i = code.indexOf("=", i)
820
+ if (i === -1 || code[i] !== "=" || code[i + 1] === "=") return undefined
821
+ i = skipSpace(code, i + 1)
822
+ const opener = code[i]
823
+ const end = opener === '"' || opener === "'" || opener === "`" ? literalEnd(source.slice(i)) : -1
824
+ const tail = end === -1 ? undefined : code.slice(i + end).trim()
825
+ const literal = source.slice(i, i + end)
826
+ const value =
827
+ tail === undefined || !["", ";", "as const", "as const;"].includes(tail)
828
+ ? undefined
829
+ : opener === "`"
830
+ ? /[$\\]/.test(literal)
831
+ ? undefined
832
+ : literal.slice(1, -1)
833
+ : parseQuotedLiteral(literal)
834
+ return [name, { exported, value }]
835
+ }
836
+
837
+ function moduleConstants(code: string, source: string): ReadonlyMap<string, ModuleConstant> {
838
+ const constants = new Map<string, ModuleConstant>()
839
+ const sourceLines = source.split("\n")
840
+ code.split("\n").forEach((line, index) => {
841
+ const constant = moduleConstantOn(line, sourceLines[index] ?? "")
842
+ if (constant !== undefined) constants.set(constant[0], constant[1])
843
+ })
844
+ return constants
845
+ }
846
+
847
+ /** Local name → the export it imports and from where, for value imports only. */
848
+ function namedImports(
849
+ code: string,
850
+ ): ReadonlyMap<string, { readonly specifier: string; readonly name: string }> {
851
+ const imports = new Map<string, { readonly specifier: string; readonly name: string }>()
852
+ NAMED_IMPORTS.lastIndex = 0
853
+ for (let m = NAMED_IMPORTS.exec(code); m !== null; m = NAMED_IMPORTS.exec(code)) {
854
+ if (/\btype\s*$/.test(code.slice(Math.max(0, m.index - 12), m.index + 6))) continue
855
+ for (const element of (m[1] ?? "").split(",")) {
856
+ const parts = element.trim().split(/\s+/)
857
+ if (parts[0] === "type" || parts[0] === undefined || parts[0] === "") continue
858
+ const local = parts[1] === "as" ? parts[2] : parts[0]
859
+ if (local !== undefined) imports.set(local, { specifier: m[3] ?? "", name: parts[0] })
860
+ }
861
+ }
862
+ return imports
863
+ }
864
+
865
+ /** The project file a relative import names, tried the way a TypeScript-aware bundler resolves it. */
866
+ function projectModule(
867
+ project: ReadonlyMap<string, string>,
868
+ fromFile: string,
869
+ specifier: string,
870
+ ): string | undefined {
871
+ if (!specifier.startsWith("./") && !specifier.startsWith("../")) return undefined
872
+ const base = posix.normalize(posix.join(posix.dirname(fromFile.replaceAll("\\", "/")), specifier))
873
+ const stem = base.replace(/\.(?:m?js|jsx)$/, "")
874
+ return [base, `${stem}.ts`, `${stem}.tsx`, `${stem}.mts`, `${base}/index.ts`].find((candidate) =>
875
+ project.has(candidate),
876
+ )
877
+ }
878
+
879
+ /**
880
+ * Route registrations whose path is a name or a template rather than a quoted literal. A name bound
881
+ * to a module-level string `const`, here or imported from a project file, and a template with no
882
+ * substitution resolve to routes; any other template, and a module-level or imported name nifra
883
+ * cannot read, is unresolved. A name bound anywhere else (a parameter, a local) is not reported:
884
+ * nothing marks such a call as a route rather than a `map.get(key, fallback)`.
885
+ */
886
+ function scanRouteArguments(
887
+ file: string,
888
+ content: string,
889
+ facts: SourceFacts | undefined,
890
+ project: ReadonlyMap<string, string> | undefined,
891
+ verbs: ReadonlySet<string> = HTTP_VERBS,
892
+ ): { routes: StaticRouteFinding[]; unresolved: UnresolvedRoutePathFinding[] } {
893
+ const routes: StaticRouteFinding[] = []
894
+ const unresolved: UnresolvedRoutePathFinding[] = []
895
+ const code = stripComments(content)
896
+ if (!isPotentialBackendSource(code, file, project)) return { routes, unresolved }
897
+ const lines = content.split("\n")
898
+ const constants = moduleConstants(code, content)
899
+ const imports = namedImports(code)
900
+ const importedFiles = new Map<string, ReadonlyMap<string, ModuleConstant>>()
901
+ const imported = (specifier: string, name: string): ModuleConstant | undefined => {
902
+ const target = project === undefined ? undefined : projectModule(project, file, specifier)
903
+ if (target === undefined) return undefined
904
+ let found = importedFiles.get(target)
905
+ if (found === undefined) {
906
+ const text = project?.get(target) ?? ""
907
+ found = moduleConstants(stripComments(text), text)
908
+ importedFiles.set(target, found)
909
+ }
910
+ const constant = found.get(name)
911
+ return constant?.exported === true ? constant : undefined
912
+ }
913
+ ROUTE_ARGUMENT.lastIndex = 0
914
+ for (let m = ROUTE_ARGUMENT.exec(code); m !== null; m = ROUTE_ARGUMENT.exec(code)) {
915
+ const method = (m[1] ?? "").toUpperCase()
916
+ if (!verbs.has(method)) continue
917
+ const at = m.index + m[0].lastIndexOf(m[2] ?? "")
918
+ const argument = content.slice(at, at + (m[2] ?? "").length)
919
+ let path: string | undefined
920
+ if (argument.startsWith("`")) {
921
+ path = /[$\\]/.test(argument) ? undefined : argument.slice(1, -1)
922
+ } else {
923
+ const binding = imports.get(argument)
924
+ const constant =
925
+ binding === undefined ? constants.get(argument) : imported(binding.specifier, binding.name)
926
+ if (binding === undefined && constant === undefined) continue
927
+ path = constant?.value
928
+ }
929
+ // A name that reads as something other than a path (`settings.get(KEY, fallback)`) is no route.
930
+ if (path !== undefined && (!path.startsWith("/") || path.startsWith("//"))) continue
931
+ if (facts !== undefined) {
932
+ const source = facts.parse(file, content)
933
+ if (
934
+ source !== undefined &&
935
+ facts.isRouteArgumentAt(source, m.index, method, argument) !== true
936
+ )
937
+ continue
938
+ }
939
+ const line = lineAt(content, m.index)
940
+ const snippet = (lines[line - 1] ?? "").trim()
941
+ if (path !== undefined) routes.push({ file, line, snippet, method, path })
942
+ else unresolved.push({ file, line, snippet, method, expression: argument.slice(0, 120) })
943
+ }
944
+ return { routes, unresolved }
945
+ }
946
+
947
+ /** Route registrations whose path the static scan cannot resolve - see {@link scanRouteArguments}. */
948
+ export function scanUnresolvedRoutePaths(
949
+ file: string,
950
+ content: string,
951
+ project?: ReadonlyMap<string, string>,
952
+ facts?: SourceFacts,
953
+ ): UnresolvedRoutePathFinding[] {
954
+ return [
955
+ ...scanRouteArguments(file, content, facts, project).unresolved,
956
+ ...scanRouteArguments(file, content, facts, project, SOCKET_VERB).unresolved,
957
+ ].sort(bySite)
958
+ }
959
+
960
+ /** `app.ws("/path", ...)` registrations, as routes under the pseudo-method `"WS"` reflection uses. */
961
+ export function scanStaticSocketText(
962
+ file: string,
963
+ content: string,
964
+ facts?: SourceFacts,
965
+ /** Project files by forward-slash relative path, to resolve a path constant imported from one. */
966
+ project?: ReadonlyMap<string, string>,
967
+ ): StaticRouteFinding[] {
968
+ const code = stripComments(content)
969
+ if (!isPotentialBackendSource(code, file, project)) return []
970
+ return [
971
+ ...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_DQ, facts, SOCKET_VERB),
972
+ ...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_SQ, facts, SOCKET_VERB),
973
+ ...scanRouteArguments(file, content, facts, project, SOCKET_VERB).routes,
573
974
  ].sort(bySite)
574
975
  }
575
976
 
@@ -660,6 +1061,25 @@ const SQL_METHODS = new Set([
660
1061
  const SQL_KEYWORD =
661
1062
  /\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
662
1063
  const ALWAYS_UNSAFE = new Set(["unsafe", "$queryRawUnsafe", "$executeRawUnsafe", "raw"])
1064
+ /** Driver calls that take a statement under names that are everyday words elsewhere (`map.get`,
1065
+ * `client.get("/users/update")`): node-sqlite3's `all`/`get`/`each` and pg-promise's result-shaping
1066
+ * calls. One of these, and any bare call, is judged by whether its text starts like a statement. */
1067
+ const DRIVER_READ_METHODS = new Set([
1068
+ "all",
1069
+ "get",
1070
+ "each",
1071
+ "any",
1072
+ "one",
1073
+ "none",
1074
+ "many",
1075
+ "oneOrNone",
1076
+ "manyOrNone",
1077
+ "result",
1078
+ "multi",
1079
+ "multiResult",
1080
+ ])
1081
+ const STATEMENT_START =
1082
+ /^\s*\(?\s*(?:select|insert|update|delete|with|replace|merge|create|drop|alter|truncate|pragma)\s/i
663
1083
 
664
1084
  interface SqlExpressionShape {
665
1085
  readonly staticText: string
@@ -952,11 +1372,12 @@ const CONST_RESOLUTION_DEPTH = 5
952
1372
  * data. Pure syntax, no type checker - the same honest limit the rule already documents. What
953
1373
  * resolves: string/number literals, no-substitution templates, templates whose every span resolves,
954
1374
  * ternaries with both branches resolvable (BOTH branch texts are returned, so a hostile keyword in
955
- * either still feeds the keyword scan), `as`/`satisfies` wrappers, a module-scope `const` whose
956
- * initializer resolves (recursively, depth-capped), and - when `scope.imports` is configured - such a
957
- * `const` imported by name from another in-project module. Everything else - `let`, a parameter, a
958
- * call, a member access, a shadowed name, a bare-specifier import - stays unresolved and the
959
- * interpolation is flagged exactly as before.
1375
+ * either still feeds the keyword scan), `as`/`satisfies` wrappers, a `const` whose initializer
1376
+ * resolves (the nearest binding: function or block scope, then module scope; recursively,
1377
+ * depth-capped), `.join(sep?)` over an array {@link staticArrayItems} proves, and - when
1378
+ * `scope.imports` is configured - a module `const` imported by name from another in-project module.
1379
+ * Everything else - `let`, a parameter, any other call, a member access, a shadowed name, a
1380
+ * bare-specifier import - stays unresolved and the interpolation is flagged exactly as before.
960
1381
  */
961
1382
  function resolveStaticSqlText(
962
1383
  ts: TypeScriptApi,
@@ -976,7 +1397,9 @@ function resolveStaticSqlText(
976
1397
  if (ts.isTemplateExpression(node)) {
977
1398
  let text = node.head.text
978
1399
  for (const span of node.templateSpans) {
979
- const part = resolveStaticSqlText(ts, span.expression, scope, depth + 1)
1400
+ const part =
1401
+ placeholderNumber(ts, text, span.expression, depth) ??
1402
+ resolveStaticSqlText(ts, span.expression, scope, depth + 1)
980
1403
  if (part === undefined) return undefined
981
1404
  text += part + span.literal.text
982
1405
  }
@@ -988,7 +1411,11 @@ function resolveStaticSqlText(
988
1411
  if (whenTrue === undefined || whenFalse === undefined) return undefined
989
1412
  return `${whenTrue} ${whenFalse}`
990
1413
  }
1414
+ if (ts.isCallExpression(node)) return resolveStaticJoin(ts, node, scope, depth)
991
1415
  if (ts.isIdentifier(node)) {
1416
+ const local = localConstInitializer(ts, node)
1417
+ if (local === "shadow") return undefined
1418
+ if (local !== undefined) return resolveStaticSqlText(ts, local, scope, depth + 1)
992
1419
  const initializer = scope.module.consts.get(node.text)
993
1420
  const edge = initializer === undefined ? scope.module.imports.get(node.text) : undefined
994
1421
  // Membership first: `isShadowedAt` walks a subtree, and most identifiers in a query are neither a
@@ -1017,76 +1444,553 @@ function resolveStaticSqlText(
1017
1444
  return undefined
1018
1445
  }
1019
1446
 
1020
- /** A template's contribution to the shape: resolved span texts fold into `staticText` (so the
1021
- * keyword scan still sees what a `const` carries); any unresolvable span keeps it `dynamic`. */
1022
- function templateShape(
1447
+ /**
1448
+ * The initializer of the `const` a function or block scope binds `node` to, `"shadow"` when the
1449
+ * nearest such binding is anything else (a parameter, `let`, `var`, destructuring), or `undefined`
1450
+ * when no scope below the module binds it - the module-scope lookup applies then.
1451
+ */
1452
+ function localConstInitializer(
1023
1453
  ts: TypeScriptApi,
1024
- template: TSApi.TemplateExpression,
1025
- scope: SqlScope | undefined,
1026
- ): { staticText: string; dynamic: boolean } {
1027
- let staticText = template.head.text
1028
- let dynamic = false
1029
- for (const span of template.templateSpans) {
1030
- const resolved =
1031
- scope === undefined ? undefined : resolveStaticSqlText(ts, span.expression, scope, 0)
1032
- if (resolved === undefined) dynamic = true
1033
- else staticText += resolved
1034
- staticText += span.literal.text
1454
+ node: TSApi.Identifier,
1455
+ ): TSApi.Expression | "shadow" | undefined {
1456
+ const source = node.getSourceFile()
1457
+ for (let scope: TSApi.Node | undefined = node.parent; scope !== undefined; scope = scope.parent) {
1458
+ if (ts.isSourceFile(scope)) return undefined
1459
+ const binding = directBinding(ts, source, scope, node.text)
1460
+ if (binding === "shadow") return "shadow"
1461
+ if (binding !== undefined) return binding.constant ? binding.initializer : "shadow"
1035
1462
  }
1036
- return { staticText, dynamic }
1463
+ return undefined
1037
1464
  }
1038
1465
 
1039
- /** A same-file binding the argument resolver proved holds one expression: a `const`, or a `let` the
1040
- * whole file never reassigns. `"shadow"` marks a nearer binding of the name we can NOT read through (a
1041
- * parameter, `var`, destructured, uninitialized, reassigned `let`) - it stops the outward walk so a
1042
- * farther, resolvable const of the same name is never read in its place. */
1043
- type ResolvableBinding = { readonly initializer: TSApi.Expression } | "shadow"
1466
+ const unwrapExpression = (ts: TypeScriptApi, node: TSApi.Expression): TSApi.Expression => {
1467
+ let current = node
1468
+ while (
1469
+ ts.isParenthesizedExpression(current) ||
1470
+ ts.isAsExpression(current) ||
1471
+ ts.isSatisfiesExpression(current)
1472
+ ) {
1473
+ current = current.expression
1474
+ }
1475
+ return current
1476
+ }
1044
1477
 
1045
- /** Whether `name` is written to anywhere in the file - a plain, compound, or `++`/`--` assignment to a
1046
- * bare identifier of that name. A `let` that is is over-cautiously refused (it might be reassigned from
1047
- * request data); a `const` never needs this. Over-refusal only ever leaves an interpolation flagged as
1048
- * before, never the reverse. */
1049
- function isReassignedInFile(ts: TypeScriptApi, source: TSApi.SourceFile, name: string): boolean {
1050
- let found = false
1478
+ /** `Object.freeze([...])` with `Object` unshadowed: the one array form no later code can change. */
1479
+ function frozenArrayLiteral(
1480
+ ts: TypeScriptApi,
1481
+ node: TSApi.Expression,
1482
+ ): TSApi.ArrayLiteralExpression | undefined {
1483
+ const call = unwrapExpression(ts, node)
1484
+ if (!ts.isCallExpression(call) || call.arguments.length !== 1) return undefined
1485
+ const callee = call.expression
1486
+ if (
1487
+ !ts.isPropertyAccessExpression(callee) ||
1488
+ callee.name.text !== "freeze" ||
1489
+ !ts.isIdentifier(callee.expression) ||
1490
+ callee.expression.text !== "Object" ||
1491
+ isShadowedAt(ts, callee.expression, "Object")
1492
+ ) {
1493
+ return undefined
1494
+ }
1495
+ const array = unwrapExpression(ts, call.arguments[0]!)
1496
+ return ts.isArrayLiteralExpression(array) ? array : undefined
1497
+ }
1498
+
1499
+ const ARRAY_READS: ReadonlySet<string> = new Set(["join", "includes", "indexOf"])
1500
+
1501
+ /**
1502
+ * Whether every use of `name` in `source` other than its declaration is a call of `.join(`,
1503
+ * `.includes(` or `.indexOf(`, or a `.length` read: then nothing pushes to, sorts, exports or hands
1504
+ * away the array, so it still holds its literal elements. With `pushed`, a `.push(...)` is accepted
1505
+ * too and its arguments collected, for the caller to prove (the `where.push(...)` builder). A
1506
+ * same-named binding elsewhere only adds uses, so the answer can only err toward refusing.
1507
+ */
1508
+ function onlyReadsArray(
1509
+ ts: TypeScriptApi,
1510
+ source: TSApi.SourceFile,
1511
+ name: string,
1512
+ declaration: TSApi.Node,
1513
+ pushed?: TSApi.Expression[],
1514
+ ): boolean {
1515
+ let ok = true
1051
1516
  const visit = (node: TSApi.Node): void => {
1052
- if (found) return
1053
- if (
1054
- ts.isBinaryExpression(node) &&
1055
- node.operatorToken.kind >= ts.SyntaxKind.FirstAssignment &&
1056
- node.operatorToken.kind <= ts.SyntaxKind.LastAssignment &&
1057
- ts.isIdentifier(node.left) &&
1058
- node.left.text === name
1059
- ) {
1060
- found = true
1061
- return
1062
- }
1063
- if (
1064
- (ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) &&
1065
- (node.operator === ts.SyntaxKind.PlusPlusToken ||
1066
- node.operator === ts.SyntaxKind.MinusMinusToken) &&
1067
- ts.isIdentifier(node.operand) &&
1068
- node.operand.text === name
1069
- ) {
1070
- found = true
1517
+ if (!ok) return
1518
+ if (ts.isIdentifier(node) && node.text === name && node !== declaration) {
1519
+ const access = node.parent
1520
+ const call =
1521
+ ts.isPropertyAccessExpression(access) &&
1522
+ access.expression === node &&
1523
+ ts.isCallExpression(access.parent) &&
1524
+ access.parent.expression === access
1525
+ ? access.parent
1526
+ : undefined
1527
+ if (
1528
+ pushed !== undefined &&
1529
+ call !== undefined &&
1530
+ ts.isPropertyAccessExpression(access) &&
1531
+ access.name.text === "push"
1532
+ ) {
1533
+ if (call.arguments.some((argument) => ts.isSpreadElement(argument))) ok = false
1534
+ else pushed.push(...call.arguments)
1535
+ // An argument can mention the array again; those uses are checked as well.
1536
+ for (const argument of call.arguments) visit(argument)
1537
+ return
1538
+ }
1539
+ const read =
1540
+ ts.isPropertyAccessExpression(access) &&
1541
+ access.expression === node &&
1542
+ (access.name.text === "length"
1543
+ ? !isWrittenTo(ts, access)
1544
+ : ARRAY_READS.has(access.name.text) && call !== undefined)
1545
+ if (!read) ok = false
1071
1546
  return
1072
1547
  }
1073
1548
  ts.forEachChild(node, visit)
1074
1549
  }
1075
1550
  visit(source)
1076
- return found
1551
+ return ok
1077
1552
  }
1078
1553
 
1079
- /** Does `binding` bind `name` (as a plain identifier or nested inside a destructuring pattern)? Mirrors
1080
- * the pattern walk in {@link isShadowedAt}. */
1081
- function bindingNameCovers(ts: TypeScriptApi, binding: TSApi.BindingName, name: string): boolean {
1082
- return ts.isIdentifier(binding)
1083
- ? binding.text === name
1084
- : binding.elements.some(
1085
- (element) =>
1086
- !ts.isOmittedExpression(element) &&
1087
- (ts.isIdentifier(element.name)
1088
- ? element.name.text === name
1089
- : bindingNameCovers(ts, element.name, name)),
1554
+ /** `node` as the target of an assignment, `++`/`--`, or `delete`. */
1555
+ function isWrittenTo(ts: TypeScriptApi, node: TSApi.Node): boolean {
1556
+ const parent = node.parent
1557
+ if (
1558
+ ts.isBinaryExpression(parent) &&
1559
+ parent.left === node &&
1560
+ parent.operatorToken.kind >= ts.SyntaxKind.FirstAssignment &&
1561
+ parent.operatorToken.kind <= ts.SyntaxKind.LastAssignment
1562
+ ) {
1563
+ return true
1564
+ }
1565
+ if (ts.isPrefixUnaryExpression(parent) || ts.isPostfixUnaryExpression(parent)) {
1566
+ return (
1567
+ parent.operator === ts.SyntaxKind.PlusPlusToken ||
1568
+ parent.operator === ts.SyntaxKind.MinusMinusToken
1569
+ )
1570
+ }
1571
+ return ts.isDeleteExpression(parent)
1572
+ }
1573
+
1574
+ /**
1575
+ * The element texts of an array `receiver` names, when the array provably still holds them: an
1576
+ * inline literal, `Object.freeze([...])` anywhere it is bound or imported from, or a `const` array
1577
+ * literal bound in this file, not exported, and only ever read ({@link onlyReadsArray}). `as const`
1578
+ * is type-only and proves nothing.
1579
+ */
1580
+ function staticArrayItems(
1581
+ ts: TypeScriptApi,
1582
+ receiver: TSApi.Expression,
1583
+ scope: SqlScope,
1584
+ depth: number,
1585
+ ): readonly string[] | undefined {
1586
+ const node = unwrapExpression(ts, receiver)
1587
+ let array = ts.isArrayLiteralExpression(node) ? node : frozenArrayLiteral(ts, node)
1588
+ let itemScope = scope
1589
+ const pushed: TSApi.Expression[] = []
1590
+ if (array === undefined && ts.isIdentifier(node)) {
1591
+ const local = localConstInitializer(ts, node)
1592
+ if (local === "shadow") return undefined
1593
+ let initializer = local
1594
+ let exported = false
1595
+ if (initializer === undefined && !isShadowedAt(ts, node, node.text)) {
1596
+ initializer = scope.module.consts.get(node.text)
1597
+ exported = [...scope.module.localExports.values()].includes(node.text)
1598
+ const edge = initializer === undefined ? scope.module.imports.get(node.text) : undefined
1599
+ if (edge !== undefined && scope.imports !== undefined) {
1600
+ const target = loadSqlModuleScope(ts, scope.imports, scope.module.id, edge.specifier)
1601
+ const found =
1602
+ target === undefined
1603
+ ? undefined
1604
+ : resolveExportedConst(ts, scope.imports, target, edge.exported, 1, new Set())
1605
+ if (found === undefined) return undefined
1606
+ // Another module's array is accepted only frozen: its own file may mutate it.
1607
+ array = frozenArrayLiteral(ts, found.expression)
1608
+ itemScope = { module: found.scope, imports: scope.imports }
1609
+ }
1610
+ }
1611
+ if (array === undefined && initializer !== undefined) {
1612
+ array = frozenArrayLiteral(ts, initializer)
1613
+ const literal = unwrapExpression(ts, initializer)
1614
+ if (
1615
+ array === undefined &&
1616
+ !exported &&
1617
+ ts.isArrayLiteralExpression(literal) &&
1618
+ ts.isVariableDeclaration(initializer.parent) &&
1619
+ onlyReadsArray(ts, node.getSourceFile(), node.text, initializer.parent.name, pushed)
1620
+ ) {
1621
+ array = literal
1622
+ }
1623
+ }
1624
+ }
1625
+ if (array === undefined) return undefined
1626
+ const items: string[] = []
1627
+ // What the array may hold: its literal elements, and anything pushed onto it in any order.
1628
+ for (const element of [...array.elements, ...pushed]) {
1629
+ if (ts.isSpreadElement(element) || ts.isOmittedExpression(element)) return undefined
1630
+ const text = resolveStaticSqlText(ts, element, itemScope, depth + 1)
1631
+ if (text === undefined) return undefined
1632
+ items.push(text)
1633
+ }
1634
+ return items
1635
+ }
1636
+
1637
+ /** `items.join(sep?)` over a {@link staticArrayItems} array with a literal or absent separator. */
1638
+ function resolveStaticJoin(
1639
+ ts: TypeScriptApi,
1640
+ call: TSApi.CallExpression,
1641
+ scope: SqlScope,
1642
+ depth: number,
1643
+ ): string | undefined {
1644
+ const callee = call.expression
1645
+ if (
1646
+ !ts.isPropertyAccessExpression(callee) ||
1647
+ callee.questionDotToken !== undefined ||
1648
+ callee.name.text !== "join" ||
1649
+ call.arguments.length > 1
1650
+ ) {
1651
+ return undefined
1652
+ }
1653
+ const separatorArg = call.arguments[0]
1654
+ const separator =
1655
+ separatorArg === undefined
1656
+ ? ","
1657
+ : ts.isStringLiteral(separatorArg) || ts.isNoSubstitutionTemplateLiteral(separatorArg)
1658
+ ? separatorArg.text
1659
+ : undefined
1660
+ if (separator === undefined) return undefined
1661
+ const items =
1662
+ staticArrayItems(ts, callee.expression, scope, depth + 1) ??
1663
+ mappedItem(ts, callee.expression, scope, depth + 1)
1664
+ return items?.join(separator)
1665
+ }
1666
+
1667
+ /**
1668
+ * `xs.map(fn)`, `xs.flatMap(fn)` or `Array.from(src, fn)` whose callback returns text that resolves
1669
+ * for every element - `(_, i) => \`($${i * 2 + 1}, $${i * 2 + 2})\``, the batch `VALUES` idiom. How
1670
+ * many items there are is runtime data, what each one says is not, so one item stands for all.
1671
+ */
1672
+ function mappedItem(
1673
+ ts: TypeScriptApi,
1674
+ receiver: TSApi.Expression,
1675
+ scope: SqlScope,
1676
+ depth: number,
1677
+ ): readonly string[] | undefined {
1678
+ const call = unwrapExpression(ts, receiver)
1679
+ if (!ts.isCallExpression(call) || !ts.isPropertyAccessExpression(call.expression))
1680
+ return undefined
1681
+ // Only these return what the callback returns; `filter` and the rest hand back the elements.
1682
+ const method = call.expression.name.text
1683
+ const argument = indexCallbackArgument(ts, call)
1684
+ if (argument === undefined || !(method === "map" || method === "flatMap" || argument === 1))
1685
+ return undefined
1686
+ const fn = call.arguments[argument]
1687
+ if (fn === undefined || !(ts.isArrowFunction(fn) || ts.isFunctionExpression(fn))) return undefined
1688
+ const only =
1689
+ ts.isBlock(fn.body) && fn.body.statements.length === 1 ? fn.body.statements[0] : undefined
1690
+ const body = ts.isBlock(fn.body)
1691
+ ? only !== undefined && ts.isReturnStatement(only)
1692
+ ? only.expression
1693
+ : undefined
1694
+ : fn.body
1695
+ const text = body === undefined ? undefined : resolveStaticSqlText(ts, body, scope, depth + 1)
1696
+ return text === undefined ? undefined : [text]
1697
+ }
1698
+
1699
+ /** Array methods whose callback receives the element's index, and where in the callback it is. */
1700
+ const INDEX_PARAMETER: ReadonlyMap<string, number> = new Map([
1701
+ ["map", 1],
1702
+ ["flatMap", 1],
1703
+ ["forEach", 1],
1704
+ ["filter", 1],
1705
+ ["some", 1],
1706
+ ["every", 1],
1707
+ ["find", 1],
1708
+ ["findIndex", 1],
1709
+ ["reduce", 2],
1710
+ ["reduceRight", 2],
1711
+ ])
1712
+
1713
+ /** Which argument of `call` is a callback handed an array index: the first for `xs.map(fn)` and its
1714
+ * kin, the second for an unshadowed `Array.from(src, fn)`. */
1715
+ function indexCallbackArgument(ts: TypeScriptApi, call: TSApi.CallExpression): number | undefined {
1716
+ const callee = call.expression
1717
+ if (!ts.isPropertyAccessExpression(callee) || callee.questionDotToken !== undefined)
1718
+ return undefined
1719
+ if (INDEX_PARAMETER.has(callee.name.text)) return 0
1720
+ return callee.name.text === "from" &&
1721
+ ts.isIdentifier(callee.expression) &&
1722
+ callee.expression.text === "Array" &&
1723
+ !isShadowedAt(ts, callee.expression, "Array")
1724
+ ? 1
1725
+ : undefined
1726
+ }
1727
+
1728
+ /**
1729
+ * `"1"` for a span written right after a `$` whose expression provably evaluates to a number: a
1730
+ * Postgres placeholder index (`$${i + 1}`), which binds a parameter and can carry no SQL. Any string
1731
+ * there could, so anything not proved numeric stays unresolved.
1732
+ */
1733
+ function placeholderNumber(
1734
+ ts: TypeScriptApi,
1735
+ before: string,
1736
+ node: TSApi.Expression,
1737
+ depth: number,
1738
+ ): string | undefined {
1739
+ return before.endsWith("$") && isNumericExpression(ts, node, depth) ? "1" : undefined
1740
+ }
1741
+
1742
+ const NUMERIC_OPERATORS = (ts: TypeScriptApi): ReadonlySet<TSApi.SyntaxKind> =>
1743
+ new Set([
1744
+ ts.SyntaxKind.MinusToken,
1745
+ ts.SyntaxKind.AsteriskToken,
1746
+ ts.SyntaxKind.AsteriskAsteriskToken,
1747
+ ts.SyntaxKind.SlashToken,
1748
+ ts.SyntaxKind.PercentToken,
1749
+ ts.SyntaxKind.AmpersandToken,
1750
+ ts.SyntaxKind.BarToken,
1751
+ ts.SyntaxKind.CaretToken,
1752
+ ts.SyntaxKind.LessThanLessThanToken,
1753
+ ts.SyntaxKind.GreaterThanGreaterThanToken,
1754
+ ts.SyntaxKind.GreaterThanGreaterThanGreaterThanToken,
1755
+ ])
1756
+ const NUMBER_FUNCTIONS: ReadonlySet<string> = new Set(["Number", "parseInt", "parseFloat"])
1757
+
1758
+ /**
1759
+ * Whether an expression evaluates to a number whatever its inputs hold. Every arithmetic operator but
1760
+ * `+` converts its operands, so `i * 2` is a number even if `i` were a string; `+` is one only when
1761
+ * both sides are. A name proves it when it is a callback's array index, or a `const`, never-reassigned
1762
+ * `let` or `for` counter bound to a number and only ever updated by arithmetic. Also `xs.push(...)`
1763
+ * (the new length), `Number`/`parseInt`/`parseFloat`/`Math.*` calls, and `.length` of an array a
1764
+ * nearby binding builds from a literal. A type annotation proves nothing: it does not run.
1765
+ */
1766
+ function isNumericExpression(ts: TypeScriptApi, node: TSApi.Expression, depth: number): boolean {
1767
+ if (depth > CONST_RESOLUTION_DEPTH) return false
1768
+ const expression = unwrapExpression(ts, node)
1769
+ if (ts.isNumericLiteral(expression)) return true
1770
+ if (ts.isPrefixUnaryExpression(expression))
1771
+ return expression.operator !== ts.SyntaxKind.ExclamationToken
1772
+ if (ts.isPostfixUnaryExpression(expression)) return true
1773
+ if (ts.isBinaryExpression(expression)) {
1774
+ const operator = expression.operatorToken.kind
1775
+ if (operator === ts.SyntaxKind.PlusToken) {
1776
+ return (
1777
+ isNumericExpression(ts, expression.left, depth + 1) &&
1778
+ isNumericExpression(ts, expression.right, depth + 1)
1779
+ )
1780
+ }
1781
+ return NUMERIC_OPERATORS(ts).has(operator)
1782
+ }
1783
+ if (ts.isIdentifier(expression)) return isNumericName(ts, expression, depth)
1784
+ if (ts.isCallExpression(expression)) {
1785
+ const callee = expression.expression
1786
+ if (ts.isIdentifier(callee))
1787
+ return NUMBER_FUNCTIONS.has(callee.text) && !isShadowedAt(ts, callee, callee.text)
1788
+ if (!ts.isPropertyAccessExpression(callee) || callee.questionDotToken !== undefined)
1789
+ return false
1790
+ if (callee.name.text === "push") return true
1791
+ return (
1792
+ ts.isIdentifier(callee.expression) &&
1793
+ callee.expression.text === "Math" &&
1794
+ !isShadowedAt(ts, callee.expression, "Math")
1795
+ )
1796
+ }
1797
+ if (
1798
+ ts.isPropertyAccessExpression(expression) &&
1799
+ expression.name.text === "length" &&
1800
+ ts.isIdentifier(expression.expression)
1801
+ ) {
1802
+ const binding = nameBinding(ts, expression.expression)
1803
+ return (
1804
+ binding.kind === "value" &&
1805
+ ts.isArrayLiteralExpression(unwrapExpression(ts, binding.initializer))
1806
+ )
1807
+ }
1808
+ return false
1809
+ }
1810
+
1811
+ /** How the nearest scope binds a name: a readable value ({@link directBinding}), a parameter, a `let`
1812
+ * counter a `for` statement declares and updates, or nothing this walk can read. */
1813
+ type NameBinding =
1814
+ | { readonly kind: "value"; readonly initializer: TSApi.Expression }
1815
+ | {
1816
+ readonly kind: "parameter"
1817
+ readonly fn: TSApi.SignatureDeclaration
1818
+ readonly position: number
1819
+ }
1820
+ | { readonly kind: "counter"; readonly initializer: TSApi.Expression }
1821
+ | { readonly kind: "unknown" }
1822
+
1823
+ function nameBinding(ts: TypeScriptApi, node: TSApi.Identifier): NameBinding {
1824
+ const source = node.getSourceFile()
1825
+ for (let scope: TSApi.Node | undefined = node.parent; scope !== undefined; scope = scope.parent) {
1826
+ if (ts.isFunctionLike(scope)) {
1827
+ const position = scope.parameters.findIndex((p) => bindingNameCovers(ts, p.name, node.text))
1828
+ if (position !== -1) {
1829
+ return ts.isIdentifier(scope.parameters[position]?.name ?? node)
1830
+ ? { kind: "parameter", fn: scope, position }
1831
+ : { kind: "unknown" }
1832
+ }
1833
+ }
1834
+ const binding = directBinding(ts, source, scope, node.text)
1835
+ if (binding === "shadow") {
1836
+ // `for (let i = 0; ...; i++)`: the one reassigned `let` worth reading, checked by the caller.
1837
+ const list = ts.isForStatement(scope) ? scope.initializer : undefined
1838
+ const declaration =
1839
+ list !== undefined && ts.isVariableDeclarationList(list)
1840
+ ? list.declarations.find((d) => ts.isIdentifier(d.name) && d.name.text === node.text)
1841
+ : undefined
1842
+ return declaration?.initializer === undefined
1843
+ ? { kind: "unknown" }
1844
+ : { kind: "counter", initializer: declaration.initializer }
1845
+ }
1846
+ if (binding !== undefined) return { kind: "value", initializer: binding.initializer }
1847
+ if (ts.isSourceFile(scope)) break
1848
+ }
1849
+ return { kind: "unknown" }
1850
+ }
1851
+
1852
+ function isNumericName(ts: TypeScriptApi, node: TSApi.Identifier, depth: number): boolean {
1853
+ const source = node.getSourceFile()
1854
+ const binding = nameBinding(ts, node)
1855
+ if (binding.kind === "value") return isNumericExpression(ts, binding.initializer, depth + 1)
1856
+ if (binding.kind === "counter") {
1857
+ return (
1858
+ isNumericExpression(ts, binding.initializer, depth + 1) &&
1859
+ onlyNumericWrites(ts, source, node.text, depth)
1860
+ )
1861
+ }
1862
+ if (binding.kind !== "parameter") return false
1863
+ // An array callback's index parameter, never reassigned.
1864
+ const call = binding.fn.parent
1865
+ if (!ts.isCallExpression(call) || !ts.isPropertyAccessExpression(call.expression)) return false
1866
+ const argument = indexCallbackArgument(ts, call)
1867
+ const position = argument === 1 ? 1 : INDEX_PARAMETER.get(call.expression.name.text)
1868
+ return (
1869
+ argument !== undefined &&
1870
+ call.arguments[argument] === binding.fn &&
1871
+ binding.position === position &&
1872
+ !isReassignedInFile(ts, source, node.text)
1873
+ )
1874
+ }
1875
+
1876
+ /** Compound assignments that always store a number. */
1877
+ const NUMERIC_ASSIGNMENTS = (ts: TypeScriptApi): ReadonlySet<TSApi.SyntaxKind> =>
1878
+ new Set([
1879
+ ts.SyntaxKind.MinusEqualsToken,
1880
+ ts.SyntaxKind.AsteriskEqualsToken,
1881
+ ts.SyntaxKind.AsteriskAsteriskEqualsToken,
1882
+ ts.SyntaxKind.SlashEqualsToken,
1883
+ ts.SyntaxKind.PercentEqualsToken,
1884
+ ts.SyntaxKind.AmpersandEqualsToken,
1885
+ ts.SyntaxKind.BarEqualsToken,
1886
+ ts.SyntaxKind.CaretEqualsToken,
1887
+ ts.SyntaxKind.LessThanLessThanEqualsToken,
1888
+ ts.SyntaxKind.GreaterThanGreaterThanEqualsToken,
1889
+ ts.SyntaxKind.GreaterThanGreaterThanGreaterThanEqualsToken,
1890
+ ])
1891
+
1892
+ /** Every write to `name` in the file is `++`/`--`, or an assignment that stores a number. */
1893
+ function onlyNumericWrites(
1894
+ ts: TypeScriptApi,
1895
+ source: TSApi.SourceFile,
1896
+ name: string,
1897
+ depth: number,
1898
+ ): boolean {
1899
+ let ok = true
1900
+ const visit = (node: TSApi.Node): void => {
1901
+ if (!ok) return
1902
+ if (ts.isIdentifier(node) && node.text === name && isWrittenTo(ts, node)) {
1903
+ const parent = node.parent
1904
+ if (ts.isBinaryExpression(parent)) {
1905
+ const operator = parent.operatorToken.kind
1906
+ ok =
1907
+ operator === ts.SyntaxKind.EqualsToken || operator === ts.SyntaxKind.PlusEqualsToken
1908
+ ? isNumericExpression(ts, parent.right, depth + 1)
1909
+ : NUMERIC_ASSIGNMENTS(ts).has(operator)
1910
+ } else ok = !ts.isDeleteExpression(parent)
1911
+ return
1912
+ }
1913
+ ts.forEachChild(node, visit)
1914
+ }
1915
+ visit(source)
1916
+ return ok
1917
+ }
1918
+
1919
+ /** A template's contribution to the shape: resolved span texts fold into `staticText` (so the
1920
+ * keyword scan still sees what a `const` carries); any unresolvable span keeps it `dynamic`. */
1921
+ function templateShape(
1922
+ ts: TypeScriptApi,
1923
+ template: TSApi.TemplateExpression,
1924
+ scope: SqlScope | undefined,
1925
+ ): { staticText: string; dynamic: boolean } {
1926
+ let staticText = template.head.text
1927
+ let dynamic = false
1928
+ for (const span of template.templateSpans) {
1929
+ const resolved =
1930
+ scope === undefined
1931
+ ? undefined
1932
+ : (placeholderNumber(ts, staticText, span.expression, 0) ??
1933
+ resolveStaticSqlText(ts, span.expression, scope, 0))
1934
+ if (resolved === undefined) dynamic = true
1935
+ else staticText += resolved
1936
+ staticText += span.literal.text
1937
+ }
1938
+ return { staticText, dynamic }
1939
+ }
1940
+
1941
+ /** A same-file binding the argument resolver proved holds one expression: a `const`, or a `let` the
1942
+ * whole file never reassigns. `"shadow"` marks a nearer binding of the name we can NOT read through (a
1943
+ * parameter, `var`, destructured, uninitialized, reassigned `let`) - it stops the outward walk so a
1944
+ * farther, resolvable const of the same name is never read in its place. */
1945
+ type ResolvableBinding =
1946
+ | { readonly initializer: TSApi.Expression; readonly constant: boolean }
1947
+ | "shadow"
1948
+
1949
+ /** Whether `name` is written to anywhere in the file - a plain, compound, or `++`/`--` assignment to a
1950
+ * bare identifier of that name. A `let` that is is over-cautiously refused (it might be reassigned from
1951
+ * request data); a `const` never needs this. Over-refusal only ever leaves an interpolation flagged as
1952
+ * before, never the reverse. */
1953
+ function isReassignedInFile(ts: TypeScriptApi, source: TSApi.SourceFile, name: string): boolean {
1954
+ let found = false
1955
+ const visit = (node: TSApi.Node): void => {
1956
+ if (found) return
1957
+ if (
1958
+ ts.isBinaryExpression(node) &&
1959
+ node.operatorToken.kind >= ts.SyntaxKind.FirstAssignment &&
1960
+ node.operatorToken.kind <= ts.SyntaxKind.LastAssignment &&
1961
+ ts.isIdentifier(node.left) &&
1962
+ node.left.text === name
1963
+ ) {
1964
+ found = true
1965
+ return
1966
+ }
1967
+ if (
1968
+ (ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) &&
1969
+ (node.operator === ts.SyntaxKind.PlusPlusToken ||
1970
+ node.operator === ts.SyntaxKind.MinusMinusToken) &&
1971
+ ts.isIdentifier(node.operand) &&
1972
+ node.operand.text === name
1973
+ ) {
1974
+ found = true
1975
+ return
1976
+ }
1977
+ ts.forEachChild(node, visit)
1978
+ }
1979
+ visit(source)
1980
+ return found
1981
+ }
1982
+
1983
+ /** Does `binding` bind `name` (as a plain identifier or nested inside a destructuring pattern)? Mirrors
1984
+ * the pattern walk in {@link isShadowedAt}. */
1985
+ function bindingNameCovers(ts: TypeScriptApi, binding: TSApi.BindingName, name: string): boolean {
1986
+ return ts.isIdentifier(binding)
1987
+ ? binding.text === name
1988
+ : binding.elements.some(
1989
+ (element) =>
1990
+ !ts.isOmittedExpression(element) &&
1991
+ (ts.isIdentifier(element.name)
1992
+ ? element.name.text === name
1993
+ : bindingNameCovers(ts, element.name, name)),
1090
1994
  )
1091
1995
  }
1092
1996
 
@@ -1109,33 +2013,15 @@ function directBinding(
1109
2013
  const declared = scope.variableDeclaration
1110
2014
  if (declared !== undefined && bindingNameCovers(ts, declared.name, name)) return "shadow"
1111
2015
  }
1112
- const statements: readonly TSApi.Statement[] | undefined = ts.isSourceFile(scope)
1113
- ? scope.statements
1114
- : ts.isBlock(scope) || ts.isModuleBlock(scope)
1115
- ? scope.statements
1116
- : ts.isCaseClause(scope) || ts.isDefaultClause(scope)
1117
- ? scope.statements
1118
- : undefined
1119
- const lists: TSApi.VariableDeclarationList[] = []
1120
- if (statements !== undefined) {
1121
- for (const statement of statements) {
1122
- if (ts.isVariableStatement(statement)) lists.push(statement.declarationList)
1123
- if (
1124
- (ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) &&
1125
- statement.name?.text === name
1126
- ) {
1127
- return "shadow"
1128
- }
2016
+ for (const statement of scopeStatements(ts, scope) ?? []) {
2017
+ if (
2018
+ (ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) &&
2019
+ statement.name?.text === name
2020
+ ) {
2021
+ return "shadow"
1129
2022
  }
1130
2023
  }
1131
- if (
1132
- (ts.isForStatement(scope) || ts.isForInStatement(scope) || ts.isForOfStatement(scope)) &&
1133
- scope.initializer !== undefined &&
1134
- ts.isVariableDeclarationList(scope.initializer)
1135
- ) {
1136
- lists.push(scope.initializer)
1137
- }
1138
- for (const list of lists) {
2024
+ for (const list of scopeDeclarationLists(ts, scope)) {
1139
2025
  for (const declaration of list.declarations) {
1140
2026
  if (!bindingNameCovers(ts, declaration.name, name)) continue
1141
2027
  // A destructured or uninitialized binding, or a `var` (hoisting + reassignment risk), is not a
@@ -1144,57 +2030,306 @@ function directBinding(
1144
2030
  return "shadow"
1145
2031
  const isConst = (list.flags & ts.NodeFlags.Const) !== 0
1146
2032
  const isLet = (list.flags & ts.NodeFlags.Let) !== 0
1147
- if (isConst) return { initializer: declaration.initializer }
2033
+ if (isConst) return { initializer: declaration.initializer, constant: true }
1148
2034
  if (isLet && !isReassignedInFile(ts, source, name))
1149
- return { initializer: declaration.initializer }
2035
+ return { initializer: declaration.initializer, constant: false }
1150
2036
  return "shadow"
1151
2037
  }
1152
2038
  }
1153
2039
  return undefined
1154
2040
  }
1155
2041
 
2042
+ function scopeStatements(
2043
+ ts: TypeScriptApi,
2044
+ scope: TSApi.Node,
2045
+ ): readonly TSApi.Statement[] | undefined {
2046
+ return ts.isSourceFile(scope) ||
2047
+ ts.isBlock(scope) ||
2048
+ ts.isModuleBlock(scope) ||
2049
+ ts.isCaseClause(scope) ||
2050
+ ts.isDefaultClause(scope)
2051
+ ? scope.statements
2052
+ : undefined
2053
+ }
2054
+
2055
+ /** The `var`/`let`/`const` lists a scope node declares directly: its statements' and a `for` head's. */
2056
+ function scopeDeclarationLists(
2057
+ ts: TypeScriptApi,
2058
+ scope: TSApi.Node,
2059
+ ): TSApi.VariableDeclarationList[] {
2060
+ const lists: TSApi.VariableDeclarationList[] = []
2061
+ for (const statement of scopeStatements(ts, scope) ?? []) {
2062
+ if (ts.isVariableStatement(statement)) lists.push(statement.declarationList)
2063
+ }
2064
+ if (
2065
+ (ts.isForStatement(scope) || ts.isForInStatement(scope) || ts.isForOfStatement(scope)) &&
2066
+ scope.initializer !== undefined &&
2067
+ ts.isVariableDeclarationList(scope.initializer)
2068
+ ) {
2069
+ lists.push(scope.initializer)
2070
+ }
2071
+ return lists
2072
+ }
2073
+
2074
+ /** The `var` or `let` a scope declares `name` with by a plain identifier, if any. */
2075
+ function mutableDeclaration(
2076
+ ts: TypeScriptApi,
2077
+ scope: TSApi.Node,
2078
+ name: string,
2079
+ ): TSApi.VariableDeclaration | undefined {
2080
+ for (const list of scopeDeclarationLists(ts, scope)) {
2081
+ if ((list.flags & ts.NodeFlags.Const) !== 0) continue
2082
+ for (const declaration of list.declarations) {
2083
+ if (ts.isIdentifier(declaration.name) && declaration.name.text === name) return declaration
2084
+ }
2085
+ }
2086
+ return undefined
2087
+ }
2088
+
2089
+ interface SqlWrite {
2090
+ readonly value: TSApi.Expression
2091
+ /** `+=`: the value is glued onto what the variable already holds. */
2092
+ readonly appends: boolean
2093
+ }
2094
+
1156
2095
  /**
1157
- * Resolve a query-call ARGUMENT identifier to the expression it was initialized from, so a statement
1158
- * parked in a variable is scanned as if it had been written inline at the call. This is the counterpart
1159
- * to {@link resolveStaticSqlText}: that folds a const's text INTO a template's shape; this unfolds the
1160
- * whole statement back out of the variable it was lifted into. Without it, ANY interpolated-SQL finding
1161
- * could be silenced, invisibly, by hoisting the statement to `const q = …` first.
2096
+ * Every value a statement variable may hold where the query reads it, so a statement parked in a
2097
+ * variable is scanned as if it had been written inline at the call. This is the counterpart to
2098
+ * {@link resolveStaticSqlText}: that folds a const's text INTO a template's shape; this unfolds the
2099
+ * statement back out of the variable it was lifted into. Without it, ANY interpolated-SQL finding
2100
+ * could be silenced, invisibly, by hoisting the statement to `const q = …` first, or by building it
2101
+ * in steps (`let q = "SELECT …"; q += …`).
1162
2102
  *
1163
2103
  * Same-file only. Nearest enclosing declaration wins, so a shadowing local is read and never a
1164
- * same-named module const. Reads through a `const`, or a `let` the file never reassigns, at function or
1165
- * module scope; transitive through an identifier-valued initializer; depth-capped. A parameter, an
1166
- * imported or destructured binding, a reassigned `let`, a `var`, or a name with no same-file
1167
- * declaration returns `undefined` - which leaves the argument scanned as itself (unflagged unless it is
1168
- * a module const that resolves to static text: exactly the pre-feature behavior).
2104
+ * same-named module const. A `const`, or a `let` the file never reassigns, holds its initializer. A
2105
+ * `var` or reassigned `let` holds its initializer and every value assigned to the name anywhere in
2106
+ * the function (or module) that declares it: over-collected rather than scope-simulated, which only
2107
+ * ever adds a value to check. `key` names the binding, so a value that reads the variable it is
2108
+ * assigned to is recognised. A parameter, an imported or destructured binding, or a name with no
2109
+ * same-file declaration returns `undefined` - which leaves the argument scanned as itself.
1169
2110
  */
1170
- function resolveArgumentInitializer(
2111
+ function variableWrites(
1171
2112
  ts: TypeScriptApi,
1172
2113
  node: TSApi.Identifier,
1173
- depth: number,
1174
- ): TSApi.Expression | undefined {
1175
- if (depth > CONST_RESOLUTION_DEPTH) return undefined
2114
+ ): { readonly key: TSApi.Node; readonly writes: readonly SqlWrite[] } | undefined {
1176
2115
  const source = node.getSourceFile()
1177
2116
  const name = node.text
1178
2117
  for (let scope: TSApi.Node | undefined = node.parent; scope !== undefined; scope = scope.parent) {
1179
2118
  const binding = directBinding(ts, source, scope, name)
1180
- if (binding === "shadow") return undefined
2119
+ if (binding === "shadow") {
2120
+ const declaration = mutableDeclaration(ts, scope, name)
2121
+ if (declaration === undefined) return undefined
2122
+ let owner: TSApi.Node = declaration
2123
+ while (!ts.isFunctionLike(owner) && !ts.isSourceFile(owner)) owner = owner.parent
2124
+ const writes = writesTo(ts, owner, name)
2125
+ return {
2126
+ key: declaration,
2127
+ writes:
2128
+ declaration.initializer === undefined
2129
+ ? writes
2130
+ : [{ value: declaration.initializer, appends: false }, ...writes],
2131
+ }
2132
+ }
1181
2133
  if (binding !== undefined) {
1182
- let init = binding.initializer
1183
- while (ts.isParenthesizedExpression(init)) init = init.expression
1184
- return ts.isIdentifier(init) ? resolveArgumentInitializer(ts, init, depth + 1) : init
2134
+ return { key: binding.initializer, writes: [{ value: binding.initializer, appends: false }] }
1185
2135
  }
1186
2136
  if (ts.isSourceFile(scope)) break
1187
2137
  }
1188
2138
  return undefined
1189
2139
  }
1190
2140
 
1191
- /** Describe only the first argument's syntax; comments/strings elsewhere cannot influence the result. */
2141
+ /** The values assigned to `name` under `root`. A numeric compound assignment stores no text. */
2142
+ function writesTo(ts: TypeScriptApi, root: TSApi.Node, name: string): SqlWrite[] {
2143
+ const writes: SqlWrite[] = []
2144
+ const visit = (node: TSApi.Node): void => {
2145
+ if (ts.isIdentifier(node) && node.text === name) {
2146
+ const parent = node.parent
2147
+ if (ts.isBinaryExpression(parent) && parent.left === node) {
2148
+ const operator = parent.operatorToken.kind
2149
+ if (operator === ts.SyntaxKind.PlusEqualsToken) {
2150
+ writes.push({ value: parent.right, appends: true })
2151
+ } else if (
2152
+ operator === ts.SyntaxKind.EqualsToken ||
2153
+ operator === ts.SyntaxKind.QuestionQuestionEqualsToken ||
2154
+ operator === ts.SyntaxKind.BarBarEqualsToken ||
2155
+ operator === ts.SyntaxKind.AmpersandAmpersandEqualsToken
2156
+ ) {
2157
+ writes.push({ value: parent.right, appends: false })
2158
+ }
2159
+ }
2160
+ }
2161
+ ts.forEachChild(node, visit)
2162
+ }
2163
+ visit(root)
2164
+ return writes
2165
+ }
2166
+
2167
+ function mentionsName(ts: TypeScriptApi, node: TSApi.Node, name: string): boolean {
2168
+ if (ts.isIdentifier(node)) return node.text === name
2169
+ return ts.forEachChild(node, (child) => mentionsName(ts, child, name) || undefined) === true
2170
+ }
2171
+
2172
+ const UNKNOWN_SQL: SqlExpressionShape = {
2173
+ staticText: "",
2174
+ dynamic: true,
2175
+ concatenated: false,
2176
+ literal: false,
2177
+ parameterizedTag: false,
2178
+ }
2179
+
2180
+ function textShape(text: string): SqlExpressionShape {
2181
+ return {
2182
+ staticText: text,
2183
+ dynamic: false,
2184
+ concatenated: false,
2185
+ literal: true,
2186
+ parameterizedTag: false,
2187
+ }
2188
+ }
2189
+
2190
+ function joinShapes(shapes: readonly SqlExpressionShape[]): SqlExpressionShape {
2191
+ return {
2192
+ staticText: shapes.map((shape) => shape.staticText).join(""),
2193
+ // As with `+`: text glued onto a binding tag's output un-parameterizes it.
2194
+ dynamic: shapes.some((shape) => shape.dynamic || shape.parameterizedTag),
2195
+ concatenated: true,
2196
+ literal: false,
2197
+ parameterizedTag: false,
2198
+ }
2199
+ }
2200
+
2201
+ /** Orders a variable's candidate statements by how surely {@link scanInterpolatedSql} would flag
2202
+ * them: dynamic SQL text, then dynamic glued text, then any dynamic text; outside a binding tag
2203
+ * before inside one. */
2204
+ function sqlRisk(shape: SqlExpressionShape): number {
2205
+ const dynamic = !shape.dynamic
2206
+ ? 0
2207
+ : SQL_KEYWORD.test(shape.staticText) || STATEMENT_START.test(shape.staticText)
2208
+ ? 3
2209
+ : shape.concatenated
2210
+ ? 2
2211
+ : 1
2212
+ return dynamic * 2 + (shape.parameterizedTag ? 0 : 1)
2213
+ }
2214
+
2215
+ /**
2216
+ * The riskiest statement a variable may hold. Each value that replaces the text is one candidate.
2217
+ * The values that build on it (`+=`, or `q = q + more`) are glued onto every replacing value at
2218
+ * once, as a builder that takes each branch would, so a static `SELECT … WHERE 1=1` followed by an
2219
+ * interpolated `AND …` reads as the one statement it becomes.
2220
+ */
2221
+ function writtenShape(
2222
+ ts: TypeScriptApi,
2223
+ name: string,
2224
+ writes: readonly SqlWrite[],
2225
+ scope: SqlScope | undefined,
2226
+ trail: readonly TSApi.Node[],
2227
+ ): SqlExpressionShape {
2228
+ const replacing: SqlExpressionShape[] = []
2229
+ const building: SqlExpressionShape[] = []
2230
+ for (const write of writes) {
2231
+ const shape = sqlExpressionShape(ts, write.value, scope, trail)
2232
+ if (write.appends || mentionsName(ts, write.value, name)) building.push(shape)
2233
+ else replacing.push(shape)
2234
+ }
2235
+ const candidates =
2236
+ building.length === 0 ? replacing : [...replacing, joinShapes([...replacing, ...building])]
2237
+ let worst: SqlExpressionShape | undefined
2238
+ for (const candidate of candidates) {
2239
+ if (worst === undefined || sqlRisk(candidate) > sqlRisk(worst)) worst = candidate
2240
+ }
2241
+ return worst ?? UNKNOWN_SQL
2242
+ }
2243
+
2244
+ const TEXT_METHODS = new Set([
2245
+ "toString",
2246
+ "valueOf",
2247
+ "trim",
2248
+ "trimStart",
2249
+ "trimEnd",
2250
+ "toLowerCase",
2251
+ "toUpperCase",
2252
+ ])
2253
+
2254
+ /** The value a text-preserving call returns: `String(x)`, `x.toString()`, `x.trim()`. */
2255
+ function passedThrough(
2256
+ ts: TypeScriptApi,
2257
+ call: TSApi.CallExpression,
2258
+ ): TSApi.Expression | undefined {
2259
+ const callee = call.expression
2260
+ if (ts.isIdentifier(callee) && callee.text === "String" && call.arguments.length === 1) {
2261
+ return call.arguments[0]
2262
+ }
2263
+ if (
2264
+ ts.isPropertyAccessExpression(callee) &&
2265
+ TEXT_METHODS.has(callee.name.text) &&
2266
+ call.arguments.length === 0
2267
+ ) {
2268
+ return callee.expression
2269
+ }
2270
+ return undefined
2271
+ }
2272
+
2273
+ /** The parts `[a, b].join(sep)` and `a.concat(b)` glue together, a separator as its text. */
2274
+ function joinedParts(
2275
+ ts: TypeScriptApi,
2276
+ call: TSApi.CallExpression,
2277
+ ): readonly (TSApi.Expression | string)[] | undefined {
2278
+ const callee = call.expression
2279
+ if (!ts.isPropertyAccessExpression(callee)) return undefined
2280
+ if (callee.name.text === "concat") return [callee.expression, ...call.arguments]
2281
+ const items = unwrapExpression(ts, callee.expression)
2282
+ if (callee.name.text !== "join" || !ts.isArrayLiteralExpression(items)) return undefined
2283
+ const separator = call.arguments[0]
2284
+ const glue: TSApi.Expression | string =
2285
+ separator === undefined ? "," : ts.isStringLiteralLike(separator) ? separator.text : separator
2286
+ const parts: (TSApi.Expression | string)[] = []
2287
+ items.elements.forEach((element, index) => {
2288
+ if (index > 0) parts.push(glue)
2289
+ if (ts.isSpreadElement(element)) parts.push(element.expression)
2290
+ else if (!ts.isOmittedExpression(element)) parts.push(element)
2291
+ })
2292
+ return parts
2293
+ }
2294
+
2295
+ /** Whether the `sql` a tag names is a function this file defines (`const sql = (s, ...v) => …`),
2296
+ * which is plain interpolation whatever its name. A parameter, an import, or a driver instance
2297
+ * (`const sql = postgres(url)`) keeps the name's trust. */
2298
+ function definesSqlFunction(ts: TypeScriptApi, tag: TSApi.Identifier): boolean {
2299
+ for (let scope: TSApi.Node | undefined = tag.parent; scope !== undefined; scope = scope.parent) {
2300
+ if (
2301
+ ts.isFunctionLike(scope) &&
2302
+ scope.parameters.some((parameter) => bindingNameCovers(ts, parameter.name, tag.text))
2303
+ ) {
2304
+ return false
2305
+ }
2306
+ for (const statement of scopeStatements(ts, scope) ?? []) {
2307
+ if (ts.isFunctionDeclaration(statement) && statement.name?.text === tag.text) return true
2308
+ }
2309
+ for (const list of scopeDeclarationLists(ts, scope)) {
2310
+ for (const declaration of list.declarations) {
2311
+ if (!ts.isIdentifier(declaration.name) || declaration.name.text !== tag.text) continue
2312
+ const value =
2313
+ declaration.initializer === undefined
2314
+ ? undefined
2315
+ : unwrapExpression(ts, declaration.initializer)
2316
+ return value !== undefined && (ts.isArrowFunction(value) || ts.isFunctionExpression(value))
2317
+ }
2318
+ }
2319
+ }
2320
+ return false
2321
+ }
2322
+
2323
+ /** Describe only the statement's syntax; comments/strings elsewhere cannot influence the result.
2324
+ * `trail` holds the variables being read through, innermost last. */
1192
2325
  function sqlExpressionShape(
1193
2326
  ts: TypeScriptApi,
1194
- node: TSApi.Expression,
2327
+ expression: TSApi.Expression,
1195
2328
  scope?: SqlScope,
2329
+ trail: readonly TSApi.Node[] = [],
1196
2330
  ): SqlExpressionShape {
1197
- if (ts.isParenthesizedExpression(node)) return sqlExpressionShape(ts, node.expression, scope)
2331
+ let node = unwrapExpression(ts, expression)
2332
+ while (ts.isNonNullExpression(node)) node = unwrapExpression(ts, node.expression)
1198
2333
  if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
1199
2334
  return {
1200
2335
  staticText: node.text,
@@ -1207,7 +2342,7 @@ function sqlExpressionShape(
1207
2342
  if (ts.isTaggedTemplateExpression(node)) {
1208
2343
  const tag = node.tag
1209
2344
  const parameterizedTag =
1210
- (ts.isIdentifier(tag) && tag.text === "sql") ||
2345
+ (ts.isIdentifier(tag) && tag.text === "sql" && !definesSqlFunction(ts, tag)) ||
1211
2346
  (ts.isPropertyAccessExpression(tag) && tag.getText() === "Prisma.sql")
1212
2347
  const template = node.template
1213
2348
  const templated = ts.isTemplateExpression(template)
@@ -1217,8 +2352,9 @@ function sqlExpressionShape(
1217
2352
  // Only the ecosystem's binding tags are trusted, by NAME - which is the honest limit of a scanner
1218
2353
  // that reads syntax and runs no type checker. `sql` is what postgres.js, drizzle, slonik and Bun's
1219
2354
  // own driver all call theirs, and `Prisma.sql` is Prisma's; taking those at their word is a
1220
- // deliberate trade, and it is worth stating what it costs: a no-op function named `sql` in the
1221
- // same file is trusted too, so this rule finds mistakes, not an adversary who reads it first.
2355
+ // deliberate trade, and it is worth stating what it costs: a `sql` this file defines as a
2356
+ // function is read as the interpolation it is, but one imported from anywhere is trusted, so
2357
+ // this rule finds mistakes, not an adversary who reads it first.
1222
2358
  //
1223
2359
  // Trusting anything else widens that for no gain. This list briefly also held `sqlIdentifiers`,
1224
2360
  // a name invented for Nifra's own adapters - a second forgeable name that no user has a
@@ -1240,14 +2376,17 @@ function sqlExpressionShape(
1240
2376
  return {
1241
2377
  staticText: templated.staticText,
1242
2378
  dynamic: templated.dynamic,
1243
- concatenated: false,
2379
+ // `${a} ${b}` writes no text of its own; it only glues values together.
2380
+ concatenated: [node.head, ...node.templateSpans.map((span) => span.literal)].every(
2381
+ (part) => part.text.trim() === "",
2382
+ ),
1244
2383
  literal: false,
1245
2384
  parameterizedTag: false,
1246
2385
  }
1247
2386
  }
1248
2387
  if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) {
1249
- const left = sqlExpressionShape(ts, node.left, scope)
1250
- const right = sqlExpressionShape(ts, node.right, scope)
2388
+ const left = sqlExpressionShape(ts, node.left, scope, trail)
2389
+ const right = sqlExpressionShape(ts, node.right, scope, trail)
1251
2390
  return {
1252
2391
  staticText: left.staticText + right.staticText,
1253
2392
  // A resolved const operand is static text, not a dynamic part - so the old `!literal` test
@@ -1276,55 +2415,163 @@ function sqlExpressionShape(
1276
2415
  }
1277
2416
  }
1278
2417
  }
1279
- // A bare name that did not resolve to compile-time text. If it is a same-file binding we can prove
1280
- // holds one expression, scan THAT expression's shape - so a statement hoisted into `const q = …` is
2418
+ if (ts.isCallExpression(node)) {
2419
+ const inner = passedThrough(ts, node)
2420
+ if (inner !== undefined)
2421
+ return { ...sqlExpressionShape(ts, inner, scope, trail), literal: false }
2422
+ const parts = joinedParts(ts, node)
2423
+ if (parts !== undefined) {
2424
+ return joinShapes(
2425
+ parts.map((part) =>
2426
+ typeof part === "string" ? textShape(part) : sqlExpressionShape(ts, part, scope, trail),
2427
+ ),
2428
+ )
2429
+ }
2430
+ }
2431
+ // A bare name that did not resolve to compile-time text. If it is a same-file binding, scan the
2432
+ // shapes of what it may hold - so a statement hoisted into `const q = …` or built up in a `let` is
1281
2433
  // read exactly as if it were written inline at the call, closing the "extract a variable to launder
1282
2434
  // the finding" hole. `literal` is forced false: the value arrived through a variable, which is the
1283
2435
  // property the `unsafe`/`$queryRawUnsafe` hatch rule keys on.
1284
2436
  if (ts.isIdentifier(node)) {
1285
- const initializer = resolveArgumentInitializer(ts, node, 0)
1286
- if (initializer !== undefined) {
1287
- return { ...sqlExpressionShape(ts, initializer, scope), literal: false }
2437
+ const read = variableWrites(ts, node)
2438
+ // Read inside one of its own writes (`q = q + more`), the variable is the text the other writes
2439
+ // already account for.
2440
+ if (read !== undefined && trail.includes(read.key)) return { ...textShape(""), literal: false }
2441
+ if (read !== undefined && trail.length <= CONST_RESOLUTION_DEPTH) {
2442
+ const written = writtenShape(ts, node.text, read.writes, scope, [...trail, read.key])
2443
+ return { ...written, literal: false }
1288
2444
  }
1289
2445
  }
1290
- return {
1291
- staticText: "",
1292
- dynamic: true,
1293
- concatenated: false,
1294
- literal: false,
1295
- parameterizedTag: false,
2446
+ return UNKNOWN_SQL
2447
+ }
2448
+
2449
+ interface SqlSink {
2450
+ readonly method: string
2451
+ /** `undefined` for a bare call: `const { query } = db` and then `query(…)`. */
2452
+ readonly receiver: string | undefined
2453
+ }
2454
+
2455
+ function sqlMethod(ts: TypeScriptApi, call: TSApi.CallExpression): SqlSink | undefined {
2456
+ const sink = (method: string, receiver: TSApi.Expression | undefined): SqlSink | undefined =>
2457
+ SQL_METHODS.has(method) || DRIVER_READ_METHODS.has(method)
2458
+ ? { method, receiver: receiver?.getText() }
2459
+ : undefined
2460
+ const callee = call.expression
2461
+ if (ts.isIdentifier(callee)) return sink(callee.text, undefined)
2462
+ if (ts.isPropertyAccessExpression(callee)) return sink(callee.name.text, callee.expression)
2463
+ if (ts.isElementAccessExpression(callee) && ts.isStringLiteralLike(callee.argumentExpression)) {
2464
+ return sink(callee.argumentExpression.text, callee.expression)
1296
2465
  }
2466
+ return undefined
1297
2467
  }
1298
2468
 
1299
- function sqlMethod(
2469
+ const SQL_RECEIVER = /(?:^|\.)(?:db|sql|prisma|drizzle|database|conn|connection|client|tx)$/i
2470
+
2471
+ /** Whether a statement is SQL assembled from runtime values, judged by where it is sent. */
2472
+ function isAssembledSql(sink: SqlSink, shape: SqlExpressionShape): boolean {
2473
+ if (!shape.dynamic) return false
2474
+ if (sink.receiver === undefined || DRIVER_READ_METHODS.has(sink.method)) {
2475
+ return STATEMENT_START.test(shape.staticText)
2476
+ }
2477
+ return (
2478
+ SQL_KEYWORD.test(shape.staticText) || (shape.concatenated && SQL_RECEIVER.test(sink.receiver))
2479
+ )
2480
+ }
2481
+
2482
+ /** The statement a call passes: the argument itself, or the `text`/`sql` of a config object
2483
+ * (`{ text, values }` in pg, `{ sql, values }` in mysql2). */
2484
+ function statementArgument(
1300
2485
  ts: TypeScriptApi,
1301
- call: TSApi.CallExpression,
1302
- ): { method: string; receiver: string } | undefined {
1303
- if (ts.isPropertyAccessExpression(call.expression)) {
1304
- const method = call.expression.name.text
1305
- return SQL_METHODS.has(method)
1306
- ? { method, receiver: call.expression.expression.getText() }
1307
- : undefined
2486
+ argument: TSApi.Expression,
2487
+ ): TSApi.Expression | undefined {
2488
+ const node = unwrapExpression(ts, argument)
2489
+ if (!ts.isObjectLiteralExpression(node)) return argument
2490
+ for (const property of node.properties) {
2491
+ if (ts.isShorthandPropertyAssignment(property) && STATEMENT_KEYS.has(property.name.text)) {
2492
+ return property.name
2493
+ }
2494
+ if (
2495
+ ts.isPropertyAssignment(property) &&
2496
+ (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) &&
2497
+ STATEMENT_KEYS.has(property.name.text)
2498
+ ) {
2499
+ return property.initializer
2500
+ }
2501
+ }
2502
+ return undefined
2503
+ }
2504
+
2505
+ const STATEMENT_KEYS = new Set(["text", "sql"])
2506
+
2507
+ interface StatementHelper {
2508
+ readonly position: number
2509
+ readonly sink: SqlSink
2510
+ }
2511
+
2512
+ /** Same-file functions that send one of their parameters as a statement - `run(db, q)` calling
2513
+ * `db.query(q)` - by name, with the parameter's position and the sink it reaches. A call to one is
2514
+ * scanned as a call to that sink. One level deep. */
2515
+ function statementHelpers(
2516
+ ts: TypeScriptApi,
2517
+ source: TSApi.SourceFile,
2518
+ ): ReadonlyMap<string, StatementHelper> {
2519
+ const helpers = new Map<string, StatementHelper>()
2520
+ const visit = (node: TSApi.Node): void => {
2521
+ const sink = ts.isCallExpression(node) ? sqlMethod(ts, node) : undefined
2522
+ const passed = sink !== undefined && ts.isCallExpression(node) ? node.arguments[0] : undefined
2523
+ const argument = passed === undefined ? undefined : statementArgument(ts, passed)
2524
+ const statement = argument === undefined ? undefined : unwrapExpression(ts, argument)
2525
+ if (sink !== undefined && statement !== undefined && ts.isIdentifier(statement)) {
2526
+ const binding = nameBinding(ts, statement)
2527
+ const name = binding.kind === "parameter" ? functionName(ts, binding.fn) : undefined
2528
+ if (binding.kind === "parameter" && name !== undefined && !helpers.has(name)) {
2529
+ helpers.set(name, { position: binding.position, sink })
2530
+ }
2531
+ }
2532
+ ts.forEachChild(node, visit)
1308
2533
  }
2534
+ visit(source)
2535
+ return helpers
2536
+ }
2537
+
2538
+ function functionName(ts: TypeScriptApi, fn: TSApi.SignatureDeclaration): string | undefined {
2539
+ if (ts.isFunctionDeclaration(fn)) return fn.name?.text
1309
2540
  if (
1310
- ts.isElementAccessExpression(call.expression) &&
1311
- call.expression.argumentExpression !== undefined &&
1312
- ts.isStringLiteral(call.expression.argumentExpression)
2541
+ (ts.isArrowFunction(fn) || ts.isFunctionExpression(fn)) &&
2542
+ ts.isVariableDeclaration(fn.parent) &&
2543
+ ts.isIdentifier(fn.parent.name)
1313
2544
  ) {
1314
- const method = call.expression.argumentExpression.text
1315
- return SQL_METHODS.has(method)
1316
- ? { method, receiver: call.expression.expression.getText() }
1317
- : undefined
2545
+ return fn.parent.name.text
1318
2546
  }
1319
2547
  return undefined
1320
2548
  }
1321
2549
 
1322
- const SQL_RECEIVER = /(?:^|\.)(?:db|sql|prisma|drizzle|database|conn|connection|client|tx)$/i
2550
+ /** `content` with every string, template and regex literal blanked, line breaks kept. */
2551
+ function literalsBlanked(ts: TypeScriptApi, source: TSApi.SourceFile, content: string): string {
2552
+ const out = content.split("")
2553
+ const visit = (node: TSApi.Node): void => {
2554
+ if (
2555
+ ts.isStringLiteralLike(node) ||
2556
+ ts.isTemplateHead(node) ||
2557
+ ts.isTemplateMiddle(node) ||
2558
+ ts.isTemplateTail(node) ||
2559
+ ts.isRegularExpressionLiteral(node) ||
2560
+ ts.isJsxText(node)
2561
+ ) {
2562
+ for (let i = node.getStart(source); i < node.end; i++) if (out[i] !== "\n") out[i] = " "
2563
+ return
2564
+ }
2565
+ ts.forEachChild(node, visit)
2566
+ }
2567
+ visit(source)
2568
+ return out.join("")
2569
+ }
1323
2570
 
1324
- /** The opt-out pragma that silences one interpolated-SQL finding. Written `// nifra-expect sql-dynamic:
1325
- * <reason>` on the flagged line or in the comment block directly above it; the reason after the colon
1326
- * is mandatory (see {@link commentBlockMarkerReason}). Scoped to the single statement it sits on. */
1327
- const SQL_DYNAMIC_PRAGMA = "nifra-expect sql-dynamic"
2571
+ /** What silences one interpolated-SQL finding: `// nifra-expect NF-C006: <reason>` (or the
2572
+ * `sql-dynamic` slug) on the flagged line or in the comment block directly above it. The reason after
2573
+ * the colon is mandatory (see {@link commentBlockExpectation}). Scoped to the single statement it sits on. */
2574
+ const SQL_DYNAMIC_EXPECTATION = ["NF-C006", "sql-dynamic"]
1328
2575
 
1329
2576
  /**
1330
2577
  * Scan one file's text for SQL assembled by interpolation. Pure + line-accurate.
@@ -1339,7 +2586,17 @@ export function scanInterpolatedSql(
1339
2586
  ts: TypeScriptApi,
1340
2587
  imports?: SqlImports,
1341
2588
  ): SourceFinding[] {
1342
- const out: SourceFinding[] = []
2589
+ return scanSqlSites(file, content, ts, imports).filter((site) => site.expected === undefined)
2590
+ }
2591
+
2592
+ /** {@link scanInterpolatedSql}, keeping the sites an expectation comment silences, with its reason. */
2593
+ export function scanSqlSites(
2594
+ file: string,
2595
+ content: string,
2596
+ ts: TypeScriptApi,
2597
+ imports?: SqlImports,
2598
+ ): SqlFinding[] {
2599
+ const out: SqlFinding[] = []
1343
2600
  const lines = content.split("\n")
1344
2601
  // Malformed source is already a typecheck failure. Do not turn parser recovery nodes into a second,
1345
2602
  // misleading SQL diagnostic (notably an unterminated template recovered as an interpolation).
@@ -1349,27 +2606,37 @@ export function scanInterpolatedSql(
1349
2606
  // text cannot carry request data, so the shared-projection idiom (`const COLS = "id, name"` …
1350
2607
  // `${COLS}`) stays quiet while mutable, parameter, shadowed, and unprovable names flag as before.
1351
2608
  const scope: SqlScope = { module: sqlModuleScope(ts, file, source), imports }
2609
+ const helpers = statementHelpers(ts, source)
2610
+ // A marker counts only in a comment: one inside a string the call passes is data, not a reason.
2611
+ let markerLines: readonly string[] | undefined
1352
2612
  const visit = (node: TSApi.Node): void => {
1353
2613
  if (ts.isCallExpression(node)) {
1354
- const sink = sqlMethod(ts, node)
1355
- const argument = node.arguments[0]
2614
+ const helper = ts.isIdentifier(node.expression)
2615
+ ? helpers.get(node.expression.text)
2616
+ : undefined
2617
+ const sink = helper?.sink ?? sqlMethod(ts, node)
2618
+ const passed = node.arguments[helper?.position ?? 0]
2619
+ const argument = passed === undefined ? undefined : statementArgument(ts, passed)
1356
2620
  if (sink !== undefined && argument !== undefined) {
1357
2621
  const shape = sqlExpressionShape(ts, argument, scope)
2622
+ // A helper's own hatch call already answers for passing it a variable.
1358
2623
  const unsafeEscape =
1359
- ALWAYS_UNSAFE.has(sink.method) && !shape.literal && !shape.parameterizedTag
1360
- const assembledSql =
1361
- shape.dynamic &&
1362
- (SQL_KEYWORD.test(shape.staticText) ||
1363
- (shape.concatenated && SQL_RECEIVER.test(sink.receiver)))
1364
- if (unsafeEscape || assembledSql) {
2624
+ helper === undefined &&
2625
+ sink.receiver !== undefined &&
2626
+ ALWAYS_UNSAFE.has(sink.method) &&
2627
+ !shape.literal &&
2628
+ !shape.parameterizedTag
2629
+ if (unsafeEscape || isAssembledSql(sink, shape)) {
1365
2630
  const index = node.getStart(source)
1366
2631
  const line = source.getLineAndCharacterOfPosition(index).line + 1
1367
- // Sanctioned escape hatch for genuinely-dynamic-but-bound SQL (batch VALUES placeholder
1368
- // generation, an allowlisted identifier). The reason is mandatory: a bare `// nifra-expect
1369
- // sql-dynamic` with no reason after the colon does NOT silence, so the hatch always leaves a
2632
+ // Sanctioned escape hatch for genuinely-dynamic-but-bound SQL (an allowlisted identifier,
2633
+ // placeholders numbered from data the scan cannot prove numeric). The reason is mandatory: a bare `// nifra-expect
2634
+ // NF-C006` with no reason after the colon does NOT silence, so the hatch always leaves a
1370
2635
  // greppable audit trail instead of becoming a second laundering trick.
1371
- const silenced = commentBlockMarkerReason(lines, line, SQL_DYNAMIC_PRAGMA) !== undefined
1372
- if (!silenced) out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() })
2636
+ markerLines ??= literalsBlanked(ts, source, content).split("\n")
2637
+ const reason = commentBlockExpectation(markerLines, line, SQL_DYNAMIC_EXPECTATION)?.reason
2638
+ const site = { file, line, snippet: (lines[line - 1] ?? "").trim() }
2639
+ out.push(reason === undefined ? site : { ...site, expected: reason })
1373
2640
  }
1374
2641
  }
1375
2642
  }
@@ -1656,7 +2923,7 @@ export function scanServerOnlyImports(
1656
2923
  // Inline `import { type X } from "…"` is erased just like `import type` and is not an edge; a parse
1657
2924
  // failure keeps the lexical edge so the security rule fails closed.
1658
2925
  for (const { specifier, index } of importEdges(content, facts, file)) {
1659
- if (!SERVER_ONLY.test(specifier)) continue
2926
+ if (!isServerOnly(specifier)) continue
1660
2927
  const line = lineAt(content, index)
1661
2928
  out.push({ file, line, snippet: (lines[line - 1] ?? "").trim(), specifier })
1662
2929
  }
@@ -1673,23 +2940,24 @@ export function scanServerOnlyImports(
1673
2940
  // that can't be precisely resolved (a bare pkg, a tsconfig path alias) falls back to the direct edge.
1674
2941
  // ---------------------------------------------------------------------------------------------------
1675
2942
 
1676
- // Server-only specifiers that are TERMINAL sinks - a bare `node:`/`bun:` builtin or a known server-only
1677
- // npm package. These are never local source we can walk into, so the chain ends here. (Same vocabulary
1678
- // as SERVER_ONLY, minus the relative `../db` arm - a relative `db` module IS local source we resolve.)
1679
- const SERVER_ONLY_SINK =
1680
- /^(?:node:|bun:)|^(?:postgres|pg|mysql2|ioredis|redis|better-sqlite3|mongodb|@libsql\/client)$|^drizzle-orm\/(?:node-postgres|postgres-js|bun-sqlite|libsql|mysql2|pglite)\b/
1681
2943
  // The retired `*.server` convention. Builds refuse such a module in browser code rather than empty it.
1682
2944
  const LEGACY_SERVER_FILE = /\.server(\.[cm]?[jt]sx?)?$/
1683
2945
  // The explicit poison-import marker (`@nifrajs/web/backend-only`, or its retired `server-only` name) - a
1684
2946
  // module opting into the client-leak guard. A resolved file carrying this side-effect import is a sink.
1685
- const BACKEND_ONLY_MARKER_IMPORT = /import\s+["']@nifrajs\/web\/(?:backend|server)-only["']/
1686
- // Depth/visited caps keep the walk linear + cycle-safe. A route's server-only dependency sits within a
1687
- // few hops in practice; the bound stops a pathological graph from blowing up the per-file scan.
1688
- const TRANSITIVE_MAX_DEPTH = 8
1689
- const TRANSITIVE_MAX_VISITED = 200
1690
-
1691
- /** A relative module specifier (`./x`, `../y`) - the only kind we resolve + walk into (a bare specifier
1692
- * is either a sink we recognise by name or a third-party dep we don't follow into node_modules). */
2947
+ const BACKEND_ONLY_MARKER_IMPORT = /import\s+["']@nifrajs\/web\/(?:backend|server)-only["']/g
2948
+
2949
+ /** Whether `content` imports the backend-only marker in code. A mention in a comment or a string,
2950
+ * such as a note on how to opt in, marks nothing. */
2951
+ function hasBackendOnlyMarker(content: string): boolean {
2952
+ if (!content.includes("-only")) return false
2953
+ const positions = codePositionMask(content)
2954
+ for (const match of content.matchAll(BACKEND_ONLY_MARKER_IMPORT)) {
2955
+ if (positions[match.index] !== " ") return true
2956
+ }
2957
+ return false
2958
+ }
2959
+
2960
+ /** A relative module specifier (`./x`, `../y`) - the only kind the SQL import lookup follows. */
1693
2961
  const isRelativeSpecifier = (spec: string): boolean =>
1694
2962
  spec.startsWith("./") || spec.startsWith("../")
1695
2963
 
@@ -1715,7 +2983,8 @@ function staticImportEdges(
1715
2983
  for (let m = re.exec(code); m !== null; m = re.exec(code)) {
1716
2984
  if (positions[m.index] === " ") continue
1717
2985
  if (m[1] === undefined) continue
1718
- if (facts !== undefined && file !== undefined) {
2986
+ // Only a `type` modifier can erase a clause the pattern matched, so other clauses skip the parse.
2987
+ if (facts !== undefined && file !== undefined && /\btype\b/.test(m[0])) {
1719
2988
  const source = facts.parse(file, content)
1720
2989
  if (source !== undefined && facts.isValueImportAt(source, m.index, m[1]) === false) continue
1721
2990
  }
@@ -1761,27 +3030,49 @@ function dynamicImportEdges(
1761
3030
  return edges
1762
3031
  }
1763
3032
 
1764
- /** Every import a module's runtime code makes - static and literal dynamic - in source order. Pure. */
3033
+ /** The literal `require("x")` calls in executable code. Pure. */
3034
+ function requireEdges(
3035
+ content: string,
3036
+ positions: string,
3037
+ ): Array<{ specifier: string; index: number }> {
3038
+ const edges: Array<{ specifier: string; index: number }> = []
3039
+ const re = new RegExp(REQUIRE_CALL.source, REQUIRE_CALL.flags)
3040
+ for (let m = re.exec(content); m !== null; m = re.exec(content)) {
3041
+ if (positions[m.index] === " ") continue // inside a comment or a string
3042
+ const specifier = m[2]
3043
+ if (specifier === undefined) continue
3044
+ if (TYPE_ALIAS_BEFORE.test(content.slice(Math.max(0, m.index - 80), m.index))) continue
3045
+ edges.push({ specifier, index: m.index })
3046
+ }
3047
+ return edges
3048
+ }
3049
+
3050
+ /** Every import a module's runtime code makes - static, literal dynamic and `require` - in source
3051
+ * order. Pure. */
1765
3052
  function importEdges(
1766
3053
  content: string,
1767
3054
  facts?: SourceFacts,
1768
3055
  file?: string,
1769
3056
  ): Array<{ specifier: string; index: number }> {
1770
- // Without an `import(` there is nothing for the dynamic pass to find; skip its regex.
1771
3057
  const positions = codePositionMask(content)
1772
3058
  const edges = staticImportEdges(content, facts, file, positions)
1773
- if (!content.includes("import(") && !/import\s+\(/.test(content)) return edges
1774
- return [...edges, ...dynamicImportEdges(content, facts, file, positions)].sort(
1775
- (a, b) => a.index - b.index,
1776
- )
3059
+ // Without an `import(` or a `require` there is nothing for the other passes to find; skip them.
3060
+ const dynamic = content.includes("import(") || /import\s+\(/.test(content)
3061
+ const required = content.includes("require")
3062
+ if (!dynamic && !required) return edges
3063
+ if (dynamic) edges.push(...dynamicImportEdges(content, facts, file, positions))
3064
+ if (required) edges.push(...requireEdges(content, positions))
3065
+ return edges.sort((a, b) => a.index - b.index)
1777
3066
  }
1778
3067
 
1779
- /** The runtime imports of a module with the line each sits on, in source order. Pure. */
3068
+ /** The runtime imports of a module with the line each sits on, in source order. With `facts`, an
3069
+ * import the compiler erases (`import { type X }`) is none. Pure. */
1780
3070
  export function importSites(
1781
3071
  content: string,
1782
3072
  file?: string,
3073
+ facts?: SourceFacts,
1783
3074
  ): Array<{ readonly specifier: string; readonly line: number }> {
1784
- return importEdges(content, undefined, file).map(({ specifier, index }) => ({
3075
+ return importEdges(content, facts, file).map(({ specifier, index }) => ({
1785
3076
  specifier,
1786
3077
  line: lineAt(content, index),
1787
3078
  }))
@@ -1796,7 +3087,7 @@ export function parseImports(content: string, facts?: SourceFacts, file?: string
1796
3087
  * server-only package), or `undefined` if it isn't a by-name sink. Pure - the label is the specifier
1797
3088
  * itself (it's already the actionable name). */
1798
3089
  export function directSinkSpecifier(spec: string): string | undefined {
1799
- return SERVER_ONLY_SINK.test(spec) ? spec : undefined
3090
+ return isServerOnlySink(spec) ? spec : undefined
1800
3091
  }
1801
3092
 
1802
3093
  /** One transitive server-only finding: the route module, the route's offending top-level import (the
@@ -1805,28 +3096,150 @@ export interface TransitiveServerImportFinding extends ServerImportFinding {
1805
3096
  /** `[routeFile, ...as-written specifiers…, sink]` - the shortest path the walk found. Length 2 means
1806
3097
  * the route imports the sink directly (same as the regex scan's direct edge). */
1807
3098
  readonly chain: readonly string[]
3099
+ /** The project files the chain runs through, as `resolve` named them: the route, each module the
3100
+ * walk entered, and the sink when it is a project file. A change to any of them can make the chain. */
3101
+ readonly modules: readonly string[]
1808
3102
  /** True when a precise transitive resolve wasn't possible for the first hop (a bare pkg / path alias),
1809
3103
  * so the chain is the honest direct edge rather than a fabricated deeper path. */
1810
3104
  readonly fallback: boolean
1811
3105
  }
1812
3106
 
1813
- /** A resolver from `(fromFile, specifier)` to an absolute module path, or `undefined` when it can't be
1814
- * resolved precisely (bare pkg, path alias, missing file). Abstracted so the BFS is unit-testable with a
1815
- * fake graph (no real fs). The production resolver wraps `Bun.resolveSync`. */
3107
+ /** A resolver from `(fromFile, specifier)` to a project module path, or `undefined` when it names no
3108
+ * project file (a package, a missing file). Abstracted so the BFS is unit-testable with a fake graph (no
3109
+ * real fs). The production resolver follows relative paths, tsconfig `paths` and package.json `imports`. */
1816
3110
  export type ModuleResolver = (fromFile: string, specifier: string) => string | undefined
1817
3111
  /** Reads a resolved module's source, or `undefined` if unreadable. Abstracted for the same reason. */
1818
3112
  export type ModuleReader = (absPath: string) => string | undefined
1819
3113
 
3114
+ /** What a server-only chain search reads the project through. */
3115
+ export interface ServerOnlyChainSource {
3116
+ readonly resolve: ModuleResolver
3117
+ readonly read: ModuleReader
3118
+ readonly facts?: SourceFacts | undefined
3119
+ readonly componentReceipts?: ReadonlySet<string>
3120
+ /** Whether a bare specifier `resolve` leaves unanswered names an installed package that declares
3121
+ * `"nifra": { "environment": "backend" }`, which the browser build refuses. */
3122
+ readonly backendPackage?: (fromFile: string, specifier: string) => boolean
3123
+ }
3124
+
3125
+ /** A chain from a module to a sink: `[start, ...as-written specifiers, sink]`, and the project files
3126
+ * it runs through (see {@link TransitiveServerImportFinding.modules}). */
3127
+ interface ChainWalk {
3128
+ readonly chain: readonly string[]
3129
+ readonly modules: readonly string[]
3130
+ }
3131
+
3132
+ /** The chain searches of one check, sharing what they learn about each module. */
3133
+ interface ChainGraph {
3134
+ /** The shortest chain from `start` to a sink. */
3135
+ walk(start: string, content: string): ChainWalk | undefined
3136
+ read(abs: string): string | undefined
3137
+ resolve(from: string, specifier: string): string | undefined
3138
+ backendPackage(from: string, specifier: string): boolean
3139
+ readonly componentReceipts: ReadonlySet<string>
3140
+ readonly facts: SourceFacts | undefined
3141
+ }
3142
+
1820
3143
  /**
1821
- * BFS the LOCAL module graph from a route file for the SHORTEST import chain that reaches a server-only
1822
- * sink, returning `[routeFile, …as-written specifiers…, sink]` or `undefined` if none is reachable. A
1823
- * node's outgoing edges are its static and literal dynamic imports; an edge is followed only when it's a RELATIVE specifier
1824
- * that `resolve` maps to a readable local file (so the walk never descends into node_modules or chases an
1825
- * unresolvable alias). At each node, a by-name sink import (`node:fs`, `postgres`) OR a resolved
1826
- * `server-only`-marked dependency terminates the chain; a `*.server` dependency is never entered, since
1827
- * the client build empties it. Bounded by depth + a visited set, so
1828
- * it's linear and cycle-free. `routeFile`/`routeContent` seed the walk; `resolve`/`read` supply the graph
1829
- * - pure given those, so it's unit-testable with a fake graph.
3144
+ * Every module is read, parsed and resolved at most once, and the chain from a module is searched at
3145
+ * most once, however many routes reach it: per-route searches repeated all of that for every route
3146
+ * that shared a module, which made a check quadratic in the size of a shared graph.
3147
+ */
3148
+ function chainGraph(source: ServerOnlyChainSource): ChainGraph {
3149
+ const { facts } = source
3150
+ const componentReceipts = source.componentReceipts ?? new Set<string>()
3151
+ const contents = new Map<string, string | undefined>()
3152
+ const imports = new Map<string, readonly string[]>()
3153
+ const resolutions = new Map<string, string | undefined>()
3154
+ const walks = new Map<string, ChainWalk | undefined>()
3155
+ const packages = new Map<string, boolean>()
3156
+ const read = (abs: string): string | undefined => {
3157
+ if (!contents.has(abs)) contents.set(abs, source.read(abs))
3158
+ return contents.get(abs)
3159
+ }
3160
+ const resolve = (from: string, specifier: string): string | undefined => {
3161
+ const key = `${from}\0${specifier}`
3162
+ if (!resolutions.has(key)) resolutions.set(key, source.resolve(from, specifier))
3163
+ return resolutions.get(key)
3164
+ }
3165
+ const backendPackage = (from: string, specifier: string): boolean => {
3166
+ if (source.backendPackage === undefined || /^[./#]/.test(specifier)) return false
3167
+ const key = `${from}\0${specifier}`
3168
+ let found = packages.get(key)
3169
+ if (found === undefined) {
3170
+ found = source.backendPackage(from, specifier)
3171
+ packages.set(key, found)
3172
+ }
3173
+ return found
3174
+ }
3175
+ const importsOf = (abs: string, content: string): readonly string[] => {
3176
+ let found = imports.get(abs)
3177
+ if (found === undefined) {
3178
+ found = parseImports(content, facts, abs)
3179
+ imports.set(abs, found)
3180
+ }
3181
+ return found
3182
+ }
3183
+ /**
3184
+ * Breadth-first, so the first sink found is the nearest. A node's edges are its static and literal
3185
+ * dynamic imports; an edge is followed only into a project file `resolve` names, an alias included,
3186
+ * never into node_modules. A by-name sink import (`node:fs`, `postgres`), backend code, or a module
3187
+ * importing the `backend-only` marker ends the chain; a `*.fn.ts` module ships as its stub and is not
3188
+ * entered. The visited set keeps it cycle-free; there is no depth or size cap to cut a chain short.
3189
+ */
3190
+ const search = (start: string, startContent: string): ChainWalk | undefined => {
3191
+ interface Node extends ChainWalk {
3192
+ readonly abs: string
3193
+ readonly content: string
3194
+ }
3195
+ const seen = new Set<string>([start])
3196
+ let frontier: Node[] = [{ abs: start, content: startContent, chain: [start], modules: [start] }]
3197
+ while (frontier.length > 0) {
3198
+ const next: Node[] = []
3199
+ for (const node of frontier) {
3200
+ const { chain, modules } = node
3201
+ for (const spec of importsOf(node.abs, node.content)) {
3202
+ const sink = directSinkSpecifier(spec)
3203
+ if (sink !== undefined) return { chain: [...chain, sink], modules }
3204
+ const abs = resolve(node.abs, spec)
3205
+ if (abs === undefined) {
3206
+ if (backendPackage(node.abs, spec)) return { chain: [...chain, spec], modules }
3207
+ continue
3208
+ }
3209
+ if (seen.has(abs)) continue
3210
+ if (componentReceipts.has(abs)) continue
3211
+ if (isBackendModule(abs)) return { chain: [...chain, spec], modules: [...modules, abs] }
3212
+ if (SERVER_FN_FILE.test(abs)) continue
3213
+ const content = read(abs)
3214
+ if (content === undefined) continue
3215
+ if (hasBackendOnlyMarker(content)) {
3216
+ return { chain: [...chain, spec], modules: [...modules, abs] }
3217
+ }
3218
+ seen.add(abs)
3219
+ next.push({ abs, content, chain: [...chain, spec], modules: [...modules, abs] })
3220
+ }
3221
+ }
3222
+ frontier = next
3223
+ }
3224
+ return undefined
3225
+ }
3226
+ return {
3227
+ walk(start, content) {
3228
+ if (!walks.has(start)) walks.set(start, search(start, content))
3229
+ return walks.get(start)
3230
+ },
3231
+ read,
3232
+ resolve,
3233
+ backendPackage,
3234
+ componentReceipts,
3235
+ facts,
3236
+ }
3237
+ }
3238
+
3239
+ /**
3240
+ * The shortest import chain from `routeFile` to a server-only sink, `[routeFile, ...as-written
3241
+ * specifiers, sink]`, or `undefined` when none is reachable. Pure given `resolve`/`read`, so a fake
3242
+ * graph tests it.
1830
3243
  */
1831
3244
  export function walkServerOnlyChain(
1832
3245
  routeFile: string,
@@ -1834,49 +3247,25 @@ export function walkServerOnlyChain(
1834
3247
  resolve: ModuleResolver,
1835
3248
  read: ModuleReader,
1836
3249
  facts?: SourceFacts,
3250
+ componentReceipts: ReadonlySet<string> = new Set(),
1837
3251
  ): readonly string[] | undefined {
1838
- // Frontier nodes carry the absolute file to inspect, the source to scan, the display chain so far
1839
- // (route + the as-written specifier of each hop), and the file the imports resolve relative to.
1840
- interface Node {
1841
- readonly abs: string
1842
- readonly content: string
1843
- readonly chain: readonly string[]
1844
- readonly depth: number
1845
- }
1846
- const seen = new Set<string>([routeFile])
1847
- let frontier: Node[] = [{ abs: routeFile, content: routeContent, chain: [routeFile], depth: 0 }]
1848
- let visited = 0
1849
- while (frontier.length > 0) {
1850
- const next: Node[] = []
1851
- for (const node of frontier) {
1852
- if (node.depth >= TRANSITIVE_MAX_DEPTH) continue
1853
- for (const spec of parseImports(node.content, facts, node.abs)) {
1854
- // (a) A by-name sink (builtin / known server-only pkg) → the chain ends here (shortest first,
1855
- // since BFS reaches the nearest sink before any deeper one).
1856
- const sink = directSinkSpecifier(spec)
1857
- if (sink !== undefined) return [...node.chain, sink]
1858
- // (b) Only relative specifiers are local source we resolve + walk into. A bare third-party
1859
- // specifier that isn't a known sink is a leaf - don't follow it into node_modules.
1860
- if (!isRelativeSpecifier(spec)) continue
1861
- const abs = resolve(node.abs, spec)
1862
- if (abs === undefined || seen.has(abs)) continue
1863
- // (c) Backend code is a sink: the browser build refuses it. A `*.fn.ts` module ships as its
1864
- // generated stub, so its imports never reach the browser and the walk does not enter it.
1865
- if (isBackendModule(abs)) return [...node.chain, spec]
1866
- if (SERVER_FN_FILE.test(abs)) continue
1867
- const content = read(abs)
1868
- if (content === undefined) continue // unreadable → can't walk; treat as a leaf
1869
- // (d) A resolved module that opts into the `backend-only` marker is a sink too.
1870
- if (BACKEND_ONLY_MARKER_IMPORT.test(content)) return [...node.chain, spec]
1871
- if (visited >= TRANSITIVE_MAX_VISITED) continue
1872
- visited++
1873
- seen.add(abs)
1874
- next.push({ abs, content, chain: [...node.chain, spec], depth: node.depth + 1 })
1875
- }
1876
- }
1877
- frontier = next
1878
- }
1879
- return undefined
3252
+ return chainGraph({
3253
+ resolve,
3254
+ read,
3255
+ facts,
3256
+ componentReceipts,
3257
+ }).walk(routeFile, routeContent)?.chain
3258
+ }
3259
+
3260
+ /**
3261
+ * The server-only chain finder for one check: called per browser module, it returns that module's
3262
+ * findings, reusing every module and chain an earlier call already searched.
3263
+ */
3264
+ export function createServerOnlyChains(
3265
+ source: ServerOnlyChainSource,
3266
+ ): (file: string, content: string, browserRoot?: boolean) => TransitiveServerImportFinding[] {
3267
+ const graph = chainGraph(source)
3268
+ return (file, content, browserRoot = false) => serverOnlyChains(graph, file, content, browserRoot)
1880
3269
  }
1881
3270
 
1882
3271
  /**
@@ -1885,7 +3274,9 @@ export function walkServerOnlyChain(
1885
3274
  * finding carries the full chain; otherwise - when the first hop is itself a direct by-name sink the
1886
3275
  * regex scan already flags, or when a relative import can't be resolved - it falls back to the DIRECT
1887
3276
  * edge (`fallback: true`), never a fabricated chain. Returns one finding per offending top-level import,
1888
- * de-duplicated to the shortest chain per first-hop specifier. Non-route files yield `[]`.
3277
+ * de-duplicated to the shortest chain per first-hop specifier. A file that is not browser source
3278
+ * yields `[]`, unless `browserRoot` says the browser bundle loads it anyway (an app's `clientModule`).
3279
+ * One check should use {@link createServerOnlyChains}, which shares the work across modules.
1889
3280
  */
1890
3281
  export function resolveServerOnlyChains(
1891
3282
  file: string,
@@ -1893,71 +3284,76 @@ export function resolveServerOnlyChains(
1893
3284
  resolve: ModuleResolver,
1894
3285
  read: ModuleReader,
1895
3286
  facts?: SourceFacts,
3287
+ componentReceipts: ReadonlySet<string> = new Set(),
3288
+ browserRoot = false,
1896
3289
  ): TransitiveServerImportFinding[] {
1897
- if (!isBrowserSource(file)) return []
3290
+ return createServerOnlyChains({
3291
+ resolve,
3292
+ read,
3293
+ facts,
3294
+ componentReceipts,
3295
+ })(file, content, browserRoot)
3296
+ }
3297
+
3298
+ function serverOnlyChains(
3299
+ graph: ChainGraph,
3300
+ file: string,
3301
+ content: string,
3302
+ browserRoot: boolean,
3303
+ ): TransitiveServerImportFinding[] {
3304
+ if (!browserRoot && !isBrowserSource(file)) return []
1898
3305
  const lines = content.split("\n")
1899
3306
  const out: TransitiveServerImportFinding[] = []
1900
3307
  const flaggedSpecifiers = new Set<string>()
1901
- // Collect the route's import edges up front (fresh-regex scan) so the per-edge logic below can call
1902
- // the REENTRANT transitive walk without corrupting a shared regex's `lastIndex` (the walk also scans
1903
- // imports). Driving `STATIC_IMPORT.exec` here directly would restart this loop forever.
1904
- for (const { specifier, index } of importEdges(content, facts, file)) {
3308
+ for (const { specifier, index } of importEdges(content, graph.facts, file)) {
1905
3309
  if (flaggedSpecifiers.has(specifier)) continue
1906
3310
  const line = lineAt(content, index)
1907
3311
  const snippet = (lines[line - 1] ?? "").trim()
1908
- // (1) A direct by-name sink (`node:fs`, `postgres`) - the route imports it itself; chain is the
1909
- // direct edge. (Length-2 chain == the regex scan's existing `[route, specifier]`.)
1910
- if (directSinkSpecifier(specifier) !== undefined) {
3312
+ const flag = (
3313
+ chain: readonly string[],
3314
+ fallback: boolean,
3315
+ modules: readonly string[] = [file],
3316
+ ): void => {
1911
3317
  flaggedSpecifiers.add(specifier)
1912
- out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: false })
3318
+ out.push({ file, line, snippet, specifier, chain, modules, fallback })
3319
+ }
3320
+ // (1) A direct by-name sink (`node:fs`, `postgres`): the route imports it itself.
3321
+ if (directSinkSpecifier(specifier) !== undefined) {
3322
+ flag([file, specifier], false)
1913
3323
  continue
1914
3324
  }
1915
- // (2) A relative local import - try the transitive walk from the resolved dependency. If it reaches
1916
- // a sink, emit the full chain rooted at THIS route's import line.
1917
- if (isRelativeSpecifier(specifier)) {
1918
- const abs = resolve(file, specifier)
1919
- if (abs === undefined) {
1920
- // Unresolvable relative import. If it's the known server-only `../db` convention the regex scan
1921
- // flags directly, fall back to the direct edge (precise resolve impossible - say so via
1922
- // `fallback`). Any other unresolvable relative import we can't assert is server-only - skip it.
1923
- if (SERVER_ONLY.test(specifier)) {
1924
- flaggedSpecifiers.add(specifier)
1925
- out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: true })
1926
- }
1927
- continue
1928
- }
1929
- // A backend first hop and a marked module are sinks; a `*.fn.ts` hop ships as its stub.
1930
- if (isBackendModule(abs)) {
1931
- flaggedSpecifiers.add(specifier)
1932
- out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: false })
1933
- continue
1934
- }
1935
- if (SERVER_FN_FILE.test(abs)) continue
1936
- const depContent = read(abs)
1937
- if (depContent === undefined) continue
1938
- if (BACKEND_ONLY_MARKER_IMPORT.test(depContent)) {
1939
- flaggedSpecifiers.add(specifier)
1940
- out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: false })
1941
- continue
1942
- }
1943
- // Walk INTO the dependency: build the chain `[depAbs, …]` then re-root it at the route's import.
1944
- const subChain = walkServerOnlyChain(abs, depContent, resolve, read, facts)
1945
- if (subChain !== undefined) {
1946
- flaggedSpecifiers.add(specifier)
1947
- // subChain is `[depAbs, …hops…, sink]`; replace its head (the resolved dep path) with the
1948
- // route's as-written specifier so the chain reads `routes/x → ../data → ../db → node:crypto`.
1949
- const chain = [file, specifier, ...subChain.slice(1)]
1950
- out.push({ file, line, snippet, specifier, chain, fallback: false })
1951
- continue
1952
- }
1953
- // (3) The relative `../db`-style convention the regex scan flags directly, but where the walk
1954
- // found no deeper sink (e.g. the file wasn't readable past the first hop) - fall back to the
1955
- // direct edge so we still surface the known-server-only convention rather than going silent.
1956
- if (SERVER_ONLY.test(specifier)) {
1957
- flaggedSpecifiers.add(specifier)
1958
- out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: true })
1959
- }
3325
+ // (2) A project import, relative or through an alias: walk from the resolved dependency and, if
3326
+ // it reaches a sink, report the full chain rooted at THIS route's import line.
3327
+ const abs = graph.resolve(file, specifier)
3328
+ if (abs === undefined) {
3329
+ // An installed package that declares itself backend is a sink. Any other name no project file
3330
+ // answers to can't be called server-only, except the `../db` convention, reported as the direct
3331
+ // edge with `fallback` saying the resolve was impossible.
3332
+ if (graph.backendPackage(file, specifier)) flag([file, specifier], false)
3333
+ else if (isServerOnly(specifier)) flag([file, specifier], true)
3334
+ continue
3335
+ }
3336
+ if (graph.componentReceipts.has(abs)) continue
3337
+ // A backend first hop and a marked module are sinks; a `*.fn.ts` hop ships as its stub.
3338
+ if (isBackendModule(abs)) {
3339
+ flag([file, specifier], false, [file, abs])
3340
+ continue
1960
3341
  }
3342
+ if (SERVER_FN_FILE.test(abs)) continue
3343
+ const depContent = graph.read(abs)
3344
+ if (depContent === undefined) continue
3345
+ if (hasBackendOnlyMarker(depContent)) {
3346
+ flag([file, specifier], false, [file, abs])
3347
+ continue
3348
+ }
3349
+ const sub = graph.walk(abs, depContent)
3350
+ if (sub !== undefined) {
3351
+ // `sub` starts at the resolved dependency; the route names it by its as-written specifier.
3352
+ flag([file, specifier, ...sub.chain.slice(1)], false, [file, ...sub.modules])
3353
+ continue
3354
+ }
3355
+ // (3) The `../db` convention with no deeper sink found: still surface the direct edge.
3356
+ if (isServerOnly(specifier)) flag([file, specifier], true)
1961
3357
  }
1962
3358
  return out
1963
3359
  }
@@ -1990,23 +3386,19 @@ export function scanResponseRoutes(
1990
3386
  if (source !== undefined && facts.isResponseSyntaxAt(source, m.index) !== true) continue
1991
3387
  }
1992
3388
  const line = lineAt(content, m.index)
1993
- // A `// nifra-expect raw-response` pragma marks an intentional raw Response (a file/redirect that can't
1994
- // be a typed route), silencing this advisory. Honor it on the return line itself, OR on the line above
1995
- // ONLY when that line is a standalone comment - so a TRAILING pragma on one route's return line can't
1996
- // leak down and suppress the very next route's genuine advisory. `stripComments` blanks the pragma in
1997
- // `code` (so it never affects the match); the raw `lines` still carry it.
1998
- const thisLine = lines[line - 1] ?? ""
1999
- const aboveLine = lines[line - 2] ?? ""
2000
- const aboveSuppresses =
2001
- aboveLine.trimStart().startsWith("//") && aboveLine.includes(RAW_RESPONSE_PRAGMA)
2002
- if (thisLine.includes(RAW_RESPONSE_PRAGMA) || aboveSuppresses) continue
2003
- out.push({ file, line, snippet: thisLine.trim() })
3389
+ // A `// nifra-expect NF-C007` comment (or the `raw-response` slug) marks an intentional raw Response
3390
+ // (a file/redirect that can't be a typed route), silencing this advisory with or without a reason.
3391
+ // It counts on the return line or in the comment block directly above, so a TRAILING comment on one
3392
+ // route's return line can't leak down onto the next route. `stripComments` blanks it in `code` (so it
3393
+ // never affects the match); the raw `lines` still carry it.
3394
+ if (commentBlockExpectation(lines, line, RAW_RESPONSE_EXPECTATION) !== undefined) continue
3395
+ out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() })
2004
3396
  }
2005
3397
  return out
2006
3398
  }
2007
3399
 
2008
- /** The opt-out pragma that suppresses the {@link scanResponseRoutes} advisory for an intentional raw Response. */
2009
- const RAW_RESPONSE_PRAGMA = "nifra-expect raw-response"
3400
+ /** What suppresses the {@link scanResponseRoutes} advisory for an intentional raw Response. */
3401
+ const RAW_RESPONSE_EXPECTATION = ["NF-C007", "raw-response"]
2010
3402
 
2011
3403
  /** Walk the project's `.ts`/`.tsx` source (skipping deps and build output), calling `visit` per file.
2012
3404
  * Exported so `nifra doctor` ({@link ./doctor.ts}) scans the same source surface as `nifra check`.
@@ -2054,22 +3446,69 @@ export async function walkSource(
2054
3446
  readonly includeTests?: boolean
2055
3447
  /** Additional project-relative paths to exclude from this scan. */
2056
3448
  readonly ignore?: (relativePath: string) => boolean
3449
+ /**
3450
+ * Also visit the other files a build loads: every `.jsx`, `.svelte` and `.vue` file, and `.js`
3451
+ * files in a zone. A `.svelte`/`.vue` file is visited as {@link componentScripts}.
3452
+ */
3453
+ readonly scripts?: boolean
2057
3454
  } = {},
2058
3455
  ): Promise<void> {
2059
3456
  const skip = opts.includeTests === true ? IGNORED_DIR : IGNORED
3457
+ const pattern =
3458
+ opts.scripts === true
3459
+ ? "**/*.{ts,tsx,mts,cts,jsx,js,mjs,cjs,svelte,vue}"
3460
+ : "**/*.{ts,tsx,mts,cts}"
2060
3461
  // List candidates first (cheap - no reads), drop the built-in ignores, then exclude gitignored paths in
2061
3462
  // one batch before the (expensive) reads. So a gitignored generated/build tree is never read or scanned.
2062
3463
  const rels: string[] = []
2063
- for await (const rawRel of new Glob("**/*.{ts,tsx,mts,cts}").scan({ cwd, dot: false })) {
3464
+ for await (const rawRel of new Glob(pattern).scan({ cwd, dot: false })) {
2064
3465
  const rel = normalizeProjectPath(rawRel)
3466
+ // A `.js` file outside a zone is tooling (a bundler config, a script, a vendored asset), not app code.
3467
+ if (PLAIN_SCRIPT_FILE.test(rel) && !ZONED_PATH.test(rel)) continue
2065
3468
  if (!skip.test(rel) && (opts.ignore === undefined || !opts.ignore(rel))) rels.push(rel)
2066
3469
  }
2067
3470
  const ignored = await gitIgnored(cwd, rels)
2068
3471
  // Glob order is the filesystem's, so findings would come out in a different order on each platform.
2069
3472
  for (const rel of rels.sort(codeUnitOrder)) {
2070
3473
  if (ignored.has(rel)) continue
2071
- visit(rel, await Bun.file(join(cwd, rel)).text())
3474
+ const text = await Bun.file(join(cwd, rel)).text()
3475
+ visit(rel, COMPONENT_FILE.test(rel) ? componentScripts(text) : text)
3476
+ }
3477
+ }
3478
+
3479
+ const PLAIN_SCRIPT_FILE = /\.[cm]?js$/
3480
+ const ZONED_PATH =
3481
+ /(^|\/)(?:routes|frontend|shared|backend)\/|\.(?:frontend|shared|backend|fn)\.[^/]+$/
3482
+ /** A single-file component: its `<script>` blocks are the code a build compiles. */
3483
+ export const COMPONENT_FILE = /\.(?:svelte|vue)$/
3484
+
3485
+ /**
3486
+ * A `.svelte` or `.vue` file as the source scanners read it: the text of its `<script>` blocks where
3487
+ * they stand, and every other character blanked to a space with line breaks kept, so a finding's line
3488
+ * and column are the file's own. An unclosed `<script>` runs to the end of the file. Pure.
3489
+ */
3490
+ export function componentScripts(text: string): string {
3491
+ const blank = (part: string): string => part.replace(/[^\n]/g, " ")
3492
+ let out = ""
3493
+ let at = 0
3494
+ for (;;) {
3495
+ const open = text.indexOf("<script", at)
3496
+ if (open === -1) break
3497
+ const close = text.indexOf(">", open)
3498
+ if (close === -1) break
3499
+ // `<scripts>` or `<script-x>` is another element.
3500
+ if (!/[\s>]/.test(text[open + 7] ?? "")) {
3501
+ out += blank(text.slice(at, close + 1))
3502
+ at = close + 1
3503
+ continue
3504
+ }
3505
+ const end = text.indexOf("</script", close + 1)
3506
+ const stop = end === -1 ? text.length : end
3507
+ out += blank(text.slice(at, close + 1)) + text.slice(close + 1, stop)
3508
+ at = stop
3509
+ if (end === -1) break
2072
3510
  }
3511
+ return out + blank(text.slice(at))
2073
3512
  }
2074
3513
 
2075
3514
  const bySite = (a: SourceFinding, b: SourceFinding): number =>