@nifrajs/cli 4.0.2 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (323) hide show
  1. package/README.md +3 -0
  2. package/dist/agent-files.d.ts +107 -0
  3. package/dist/agent-files.d.ts.map +1 -0
  4. package/dist/agent-files.js +162 -0
  5. package/dist/agent-files.js.map +1 -0
  6. package/dist/app-files.d.ts +6 -0
  7. package/dist/app-files.d.ts.map +1 -1
  8. package/dist/app-files.js +27 -2
  9. package/dist/app-files.js.map +1 -1
  10. package/dist/assure-hydration.d.ts.map +1 -1
  11. package/dist/assure-hydration.js +7 -2
  12. package/dist/assure-hydration.js.map +1 -1
  13. package/dist/capabilities-tool.d.ts.map +1 -1
  14. package/dist/capabilities-tool.js +13 -3
  15. package/dist/capabilities-tool.js.map +1 -1
  16. package/dist/check-diagnostics.d.ts +20 -2
  17. package/dist/check-diagnostics.d.ts.map +1 -1
  18. package/dist/check-diagnostics.js +54 -7
  19. package/dist/check-diagnostics.js.map +1 -1
  20. package/dist/check-scan.d.ts +75 -22
  21. package/dist/check-scan.d.ts.map +1 -1
  22. package/dist/check-scan.js +1475 -310
  23. package/dist/check-scan.js.map +1 -1
  24. package/dist/check.d.ts +2 -2
  25. package/dist/check.d.ts.map +1 -1
  26. package/dist/check.js +192 -65
  27. package/dist/check.js.map +1 -1
  28. package/dist/cli.d.ts +7 -0
  29. package/dist/cli.d.ts.map +1 -1
  30. package/dist/cli.js +112 -5
  31. package/dist/cli.js.map +1 -1
  32. package/dist/client-module.d.ts +18 -0
  33. package/dist/client-module.d.ts.map +1 -0
  34. package/dist/client-module.js +49 -0
  35. package/dist/client-module.js.map +1 -0
  36. package/dist/command-catalog.d.ts +16 -5
  37. package/dist/command-catalog.d.ts.map +1 -1
  38. package/dist/command-catalog.js +133 -29
  39. package/dist/command-catalog.js.map +1 -1
  40. package/dist/component-check.d.ts +13 -0
  41. package/dist/component-check.d.ts.map +1 -0
  42. package/dist/component-check.js +59 -0
  43. package/dist/component-check.js.map +1 -0
  44. package/dist/contract-proof.d.ts +1 -1
  45. package/dist/contract-proof.d.ts.map +1 -1
  46. package/dist/contract-proof.js +2 -24
  47. package/dist/contract-proof.js.map +1 -1
  48. package/dist/contracts.d.ts +19 -0
  49. package/dist/contracts.d.ts.map +1 -1
  50. package/dist/contracts.js +50 -3
  51. package/dist/contracts.js.map +1 -1
  52. package/dist/db-tool.d.ts.map +1 -1
  53. package/dist/db-tool.js +4 -3
  54. package/dist/db-tool.js.map +1 -1
  55. package/dist/dev-bun-config.d.ts +5 -1
  56. package/dist/dev-bun-config.d.ts.map +1 -1
  57. package/dist/dev-bun-config.js +44 -11
  58. package/dist/dev-bun-config.js.map +1 -1
  59. package/dist/diagnostics.d.ts +3 -0
  60. package/dist/diagnostics.d.ts.map +1 -1
  61. package/dist/diagnostics.js.map +1 -1
  62. package/dist/diff-tool.d.ts +9 -8
  63. package/dist/diff-tool.d.ts.map +1 -1
  64. package/dist/diff-tool.js +28 -24
  65. package/dist/diff-tool.js.map +1 -1
  66. package/dist/doctor.d.ts +13 -0
  67. package/dist/doctor.d.ts.map +1 -1
  68. package/dist/doctor.js +23 -15
  69. package/dist/doctor.js.map +1 -1
  70. package/dist/env-file.d.ts +6 -0
  71. package/dist/env-file.d.ts.map +1 -1
  72. package/dist/env-file.js +6 -0
  73. package/dist/env-file.js.map +1 -1
  74. package/dist/fix-recipes.d.ts.map +1 -1
  75. package/dist/fix-recipes.js +21 -2
  76. package/dist/fix-recipes.js.map +1 -1
  77. package/dist/hand-built-manifest.d.ts +27 -0
  78. package/dist/hand-built-manifest.d.ts.map +1 -0
  79. package/dist/hand-built-manifest.js +124 -0
  80. package/dist/hand-built-manifest.js.map +1 -0
  81. package/dist/hooks-report.d.ts +23 -0
  82. package/dist/hooks-report.d.ts.map +1 -0
  83. package/dist/hooks-report.js +104 -0
  84. package/dist/hooks-report.js.map +1 -0
  85. package/dist/init-agents.d.ts +2 -2
  86. package/dist/init-agents.d.ts.map +1 -1
  87. package/dist/init-agents.js +17 -11
  88. package/dist/init-agents.js.map +1 -1
  89. package/dist/internal/nifra-project.d.ts +41 -0
  90. package/dist/internal/nifra-project.d.ts.map +1 -0
  91. package/dist/internal/nifra-project.js +183 -0
  92. package/dist/internal/nifra-project.js.map +1 -0
  93. package/dist/internal/project-specifiers.d.ts +16 -0
  94. package/dist/internal/project-specifiers.d.ts.map +1 -0
  95. package/dist/internal/project-specifiers.js +396 -0
  96. package/dist/internal/project-specifiers.js.map +1 -0
  97. package/dist/internal/script-kind.d.ts.map +1 -1
  98. package/dist/internal/script-kind.js +2 -1
  99. package/dist/internal/script-kind.js.map +1 -1
  100. package/dist/internal/source-facts.d.ts +2 -0
  101. package/dist/internal/source-facts.d.ts.map +1 -1
  102. package/dist/internal/source-facts.js +11 -0
  103. package/dist/internal/source-facts.js.map +1 -1
  104. package/dist/internal/typescript-import.d.ts +1 -1
  105. package/dist/internal/typescript-import.d.ts.map +1 -1
  106. package/dist/internal/typescript-import.js +5 -3
  107. package/dist/internal/typescript-import.js.map +1 -1
  108. package/dist/introspect.d.ts +6 -6
  109. package/dist/introspect.d.ts.map +1 -1
  110. package/dist/introspect.js +55 -24
  111. package/dist/introspect.js.map +1 -1
  112. package/dist/levels-tool.d.ts.map +1 -1
  113. package/dist/levels-tool.js +93 -15
  114. package/dist/levels-tool.js.map +1 -1
  115. package/dist/load.d.ts +31 -8
  116. package/dist/load.d.ts.map +1 -1
  117. package/dist/load.js +37 -7
  118. package/dist/load.js.map +1 -1
  119. package/dist/mcp-context.d.ts +5 -5
  120. package/dist/mcp-context.d.ts.map +1 -1
  121. package/dist/mcp-context.js +2 -2
  122. package/dist/mcp-context.js.map +1 -1
  123. package/dist/mcp-exec.d.ts +12 -4
  124. package/dist/mcp-exec.d.ts.map +1 -1
  125. package/dist/mcp-exec.js +50 -13
  126. package/dist/mcp-exec.js.map +1 -1
  127. package/dist/mcp-http.js +1 -1
  128. package/dist/mcp-io.d.ts +17 -0
  129. package/dist/mcp-io.d.ts.map +1 -1
  130. package/dist/mcp-io.js +40 -0
  131. package/dist/mcp-io.js.map +1 -1
  132. package/dist/mcp-isolate.d.ts.map +1 -1
  133. package/dist/mcp-isolate.js +3 -2
  134. package/dist/mcp-isolate.js.map +1 -1
  135. package/dist/mcp-project-child.js +1 -1
  136. package/dist/mcp-project-child.js.map +1 -1
  137. package/dist/mcp-root.d.ts.map +1 -1
  138. package/dist/mcp-root.js +50 -10
  139. package/dist/mcp-root.js.map +1 -1
  140. package/dist/mcp-ws.d.ts.map +1 -1
  141. package/dist/mcp-ws.js +3 -1
  142. package/dist/mcp-ws.js.map +1 -1
  143. package/dist/mcp.d.ts.map +1 -1
  144. package/dist/mcp.js +8 -2
  145. package/dist/mcp.js.map +1 -1
  146. package/dist/migrate-layout.d.ts +7 -0
  147. package/dist/migrate-layout.d.ts.map +1 -1
  148. package/dist/migrate-layout.js +778 -64
  149. package/dist/migrate-layout.js.map +1 -1
  150. package/dist/output-schemas.d.ts +94 -0
  151. package/dist/output-schemas.d.ts.map +1 -0
  152. package/dist/output-schemas.js +864 -0
  153. package/dist/output-schemas.js.map +1 -0
  154. package/dist/port.d.ts.map +1 -1
  155. package/dist/port.js +3 -2
  156. package/dist/port.js.map +1 -1
  157. package/dist/project-facts.d.ts +15 -4
  158. package/dist/project-facts.d.ts.map +1 -1
  159. package/dist/project-facts.js +1 -0
  160. package/dist/project-facts.js.map +1 -1
  161. package/dist/recipes/5.0.0.d.ts +11 -0
  162. package/dist/recipes/5.0.0.d.ts.map +1 -0
  163. package/dist/recipes/5.0.0.js +34 -0
  164. package/dist/recipes/5.0.0.js.map +1 -0
  165. package/dist/recipes/index.d.ts +7 -0
  166. package/dist/recipes/index.d.ts.map +1 -1
  167. package/dist/recipes/index.js +2 -0
  168. package/dist/recipes/index.js.map +1 -1
  169. package/dist/replay.d.ts.map +1 -1
  170. package/dist/replay.js +12 -3
  171. package/dist/replay.js.map +1 -1
  172. package/dist/review.d.ts +3 -1
  173. package/dist/review.d.ts.map +1 -1
  174. package/dist/review.js +38 -7
  175. package/dist/review.js.map +1 -1
  176. package/dist/rules/codes.d.ts +10 -0
  177. package/dist/rules/codes.d.ts.map +1 -1
  178. package/dist/rules/codes.js +10 -0
  179. package/dist/rules/codes.js.map +1 -1
  180. package/dist/rules/comment-markers.d.ts +16 -7
  181. package/dist/rules/comment-markers.d.ts.map +1 -1
  182. package/dist/rules/comment-markers.js +58 -19
  183. package/dist/rules/comment-markers.js.map +1 -1
  184. package/dist/rules/contract-visibility.d.ts +3 -0
  185. package/dist/rules/contract-visibility.d.ts.map +1 -0
  186. package/dist/rules/contract-visibility.js +337 -0
  187. package/dist/rules/contract-visibility.js.map +1 -0
  188. package/dist/rules/data-guard.d.ts +7 -0
  189. package/dist/rules/data-guard.d.ts.map +1 -1
  190. package/dist/rules/data-guard.js +319 -37
  191. package/dist/rules/data-guard.js.map +1 -1
  192. package/dist/rules/layout.d.ts +4 -0
  193. package/dist/rules/layout.d.ts.map +1 -0
  194. package/dist/rules/layout.js +19 -0
  195. package/dist/rules/layout.js.map +1 -0
  196. package/dist/rules/legacy.d.ts.map +1 -1
  197. package/dist/rules/legacy.js +158 -43
  198. package/dist/rules/legacy.js.map +1 -1
  199. package/dist/rules/pages.d.ts +24 -0
  200. package/dist/rules/pages.d.ts.map +1 -1
  201. package/dist/rules/pages.js +221 -3
  202. package/dist/rules/pages.js.map +1 -1
  203. package/dist/rules/routes.d.ts +6 -0
  204. package/dist/rules/routes.d.ts.map +1 -1
  205. package/dist/rules/routes.js +111 -16
  206. package/dist/rules/routes.js.map +1 -1
  207. package/dist/rules/secrets.js +2 -2
  208. package/dist/rules/secrets.js.map +1 -1
  209. package/dist/rules/security.d.ts +12 -1
  210. package/dist/rules/security.d.ts.map +1 -1
  211. package/dist/rules/security.js +281 -68
  212. package/dist/rules/security.js.map +1 -1
  213. package/dist/rules/zones.d.ts +13 -0
  214. package/dist/rules/zones.d.ts.map +1 -1
  215. package/dist/rules/zones.js +192 -40
  216. package/dist/rules/zones.js.map +1 -1
  217. package/dist/scaffold.d.ts.map +1 -1
  218. package/dist/scaffold.js +6 -4
  219. package/dist/scaffold.js.map +1 -1
  220. package/dist/sdk.d.ts.map +1 -1
  221. package/dist/sdk.js +14 -5
  222. package/dist/sdk.js.map +1 -1
  223. package/dist/stylex-migrate.d.ts.map +1 -1
  224. package/dist/stylex-migrate.js +163 -7
  225. package/dist/stylex-migrate.js.map +1 -1
  226. package/dist/test-tool.d.ts.map +1 -1
  227. package/dist/test-tool.js +12 -3
  228. package/dist/test-tool.js.map +1 -1
  229. package/dist/typecheck-coverage.d.ts +20 -0
  230. package/dist/typecheck-coverage.d.ts.map +1 -0
  231. package/dist/typecheck-coverage.js +129 -0
  232. package/dist/typecheck-coverage.js.map +1 -0
  233. package/dist/upgrade.d.ts +69 -0
  234. package/dist/upgrade.d.ts.map +1 -1
  235. package/dist/upgrade.js +420 -77
  236. package/dist/upgrade.js.map +1 -1
  237. package/dist/verification-plan.d.ts.map +1 -1
  238. package/dist/verification-plan.js +11 -0
  239. package/dist/verification-plan.js.map +1 -1
  240. package/dist/version-drift.d.ts +22 -0
  241. package/dist/version-drift.d.ts.map +1 -0
  242. package/dist/version-drift.js +42 -0
  243. package/dist/version-drift.js.map +1 -0
  244. package/dist/web-app-options.d.ts +1 -1
  245. package/dist/web-app-options.d.ts.map +1 -1
  246. package/dist/web-app-options.js +1 -1
  247. package/dist/web-app-options.js.map +1 -1
  248. package/dist/work-graph.d.ts.map +1 -1
  249. package/dist/work-graph.js +20 -8
  250. package/dist/work-graph.js.map +1 -1
  251. package/docs/examples.json +54 -6
  252. package/docs/llms-full.txt +1539 -138
  253. package/docs/types.json +1456 -206
  254. package/package.json +11 -12
  255. package/src/agent-files.ts +203 -0
  256. package/src/app-files.ts +34 -4
  257. package/src/assure-hydration.ts +15 -2
  258. package/src/capabilities-tool.ts +15 -3
  259. package/src/check-diagnostics.ts +73 -9
  260. package/src/check-scan.ts +1773 -381
  261. package/src/check.ts +237 -76
  262. package/src/cli.ts +141 -5
  263. package/src/client-module.ts +61 -0
  264. package/src/command-catalog.ts +166 -37
  265. package/src/component-check.ts +79 -0
  266. package/src/contract-proof.ts +3 -25
  267. package/src/contracts.ts +60 -3
  268. package/src/db-tool.ts +5 -2
  269. package/src/dev-bun-config.ts +51 -10
  270. package/src/diagnostics.ts +3 -0
  271. package/src/diff-tool.ts +33 -26
  272. package/src/doctor.ts +43 -21
  273. package/src/env-file.ts +7 -0
  274. package/src/fix-recipes.ts +25 -2
  275. package/src/hand-built-manifest.ts +144 -0
  276. package/src/hooks-report.ts +135 -0
  277. package/src/init-agents.ts +17 -11
  278. package/src/internal/nifra-project.ts +191 -0
  279. package/src/internal/project-specifiers.ts +424 -0
  280. package/src/internal/script-kind.ts +2 -1
  281. package/src/internal/source-facts.ts +25 -0
  282. package/src/internal/typescript-import.ts +10 -3
  283. package/src/introspect.ts +65 -27
  284. package/src/levels-tool.ts +117 -20
  285. package/src/load.ts +78 -14
  286. package/src/mcp-context.ts +7 -7
  287. package/src/mcp-exec.ts +60 -17
  288. package/src/mcp-http.ts +1 -1
  289. package/src/mcp-io.ts +41 -0
  290. package/src/mcp-isolate.ts +9 -2
  291. package/src/mcp-project-child.ts +1 -1
  292. package/src/mcp-root.ts +58 -8
  293. package/src/mcp-ws.ts +4 -2
  294. package/src/mcp.ts +8 -3
  295. package/src/migrate-layout.ts +885 -63
  296. package/src/output-schemas.ts +1084 -0
  297. package/src/port.ts +3 -2
  298. package/src/project-facts.ts +17 -3
  299. package/src/recipes/5.0.0.ts +35 -0
  300. package/src/recipes/index.ts +10 -0
  301. package/src/replay.ts +14 -3
  302. package/src/review.ts +39 -10
  303. package/src/rules/codes.ts +10 -0
  304. package/src/rules/comment-markers.ts +57 -21
  305. package/src/rules/contract-visibility.ts +383 -0
  306. package/src/rules/data-guard.ts +351 -42
  307. package/src/rules/layout.ts +22 -0
  308. package/src/rules/legacy.ts +184 -51
  309. package/src/rules/pages.ts +238 -3
  310. package/src/rules/routes.ts +124 -16
  311. package/src/rules/secrets.ts +2 -2
  312. package/src/rules/security.ts +352 -71
  313. package/src/rules/zones.ts +205 -38
  314. package/src/scaffold.ts +6 -4
  315. package/src/sdk.ts +14 -5
  316. package/src/stylex-migrate.ts +134 -7
  317. package/src/test-tool.ts +18 -3
  318. package/src/typecheck-coverage.ts +134 -0
  319. package/src/upgrade.ts +487 -71
  320. package/src/verification-plan.ts +24 -0
  321. package/src/version-drift.ts +67 -0
  322. package/src/web-app-options.ts +2 -2
  323. 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
@@ -279,13 +367,14 @@ function scanRoutePattern(
279
367
  code: string,
280
368
  pattern: RegExp,
281
369
  facts?: SourceFacts,
370
+ verbs: ReadonlySet<string> = HTTP_VERBS,
282
371
  ): StaticRouteFinding[] {
283
372
  const out: StaticRouteFinding[] = []
284
373
  const lines = content.split("\n")
285
374
  pattern.lastIndex = 0
286
375
  for (let m = pattern.exec(code); m !== null; m = pattern.exec(code)) {
287
376
  const method = (m[1] ?? "").toUpperCase()
288
- if (!HTTP_VERBS.has(method)) continue
377
+ if (!verbs.has(method)) continue
289
378
  const path = parseQuotedLiteral(
290
379
  `${pattern === ROUTE_REGISTRATION_DQ ? '"' : "'"}${m[2] ?? ""}${pattern === ROUTE_REGISTRATION_DQ ? '"' : "'"}`,
291
380
  )
@@ -306,25 +395,94 @@ function scanRoutePattern(
306
395
  return out
307
396
  }
308
397
 
398
+ // Punctuators after which a `/` opens a regex literal rather than dividing. A `)` `]` or `}` is read
399
+ // as the end of an operand, so `/` after it divides - the rare statement-leading regex after a block
400
+ // is the cost of not tracking parser context.
401
+ const REGEX_AFTER = new Set([
402
+ "(",
403
+ ",",
404
+ "=",
405
+ ":",
406
+ "[",
407
+ "!",
408
+ "&",
409
+ "|",
410
+ "?",
411
+ "{",
412
+ ";",
413
+ "+",
414
+ "-",
415
+ "*",
416
+ "%",
417
+ "<",
418
+ ">",
419
+ "~",
420
+ "^",
421
+ ])
422
+ const REGEX_AFTER_WORD = new Set([
423
+ "return",
424
+ "typeof",
425
+ "case",
426
+ "do",
427
+ "else",
428
+ "in",
429
+ "of",
430
+ "new",
431
+ "delete",
432
+ "void",
433
+ "throw",
434
+ "yield",
435
+ "await",
436
+ "instanceof",
437
+ ])
438
+ const isWordChar = (c: string | undefined): boolean => c !== undefined && /[\w$]/.test(c)
439
+
440
+ /** Where a regex literal opened by the `/` at `i` ends (after its flags), or `undefined` when the
441
+ * literal does not close on its line - then the slash was division after all. */
442
+ function regexLiteralEnd(src: string, i: number): number | undefined {
443
+ const n = src.length
444
+ let j = i + 1
445
+ let inClass = false
446
+ while (j < n) {
447
+ const c = src[j]
448
+ if (c === "\n" || c === "\r") return undefined
449
+ if (c === "\\") {
450
+ j += 2
451
+ continue
452
+ }
453
+ if (inClass) {
454
+ if (c === "]") inClass = false
455
+ } else if (c === "[") {
456
+ inClass = true
457
+ } else if (c === "/") {
458
+ break
459
+ }
460
+ j++
461
+ }
462
+ if (j >= n) return undefined
463
+ j++
464
+ while (j < n && isWordChar(src[j])) j++
465
+ return j
466
+ }
467
+
309
468
  /**
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}).
469
+ * Blank comments, template-literal contents and regex-literal contents (and, with `quoted: "blank"`,
470
+ * single/double-quoted strings and every delimiter) while preserving every offset and newline.
471
+ * A small lexer, not a parser: a `/` opens a regex where an operand may start - after a punctuator,
472
+ * an operator keyword, or at the start of the file - so a quote inside a regex no longer derails
473
+ * the string state that follows.
318
474
  */
319
- export function stripComments(src: string): string {
475
+ function maskSource(src: string, quoted: "keep" | "blank"): string {
320
476
  const out = src.split("")
321
477
  const n = src.length
322
- let i = 0
323
478
  const blank = (a: number, b: number): void => {
324
479
  for (let k = a; k < b; k++) if (out[k] !== "\n") out[k] = " "
325
480
  }
481
+ // The last significant code token: a punctuator character, or a whole identifier/keyword.
482
+ let previous = ""
483
+ let i = 0
326
484
  while (i < n) {
327
- const c = src[i]
485
+ const c = src.charAt(i)
328
486
  const d = src[i + 1]
329
487
  if (c === "/" && d === "/") {
330
488
  let j = i + 2
@@ -343,62 +501,66 @@ export function stripComments(src: string): string {
343
501
  if (src[j] === "\\") j++
344
502
  j++
345
503
  }
346
- blank(i + 1, j) // keep the backticks, blank the code-as-text inside
504
+ if (quoted === "keep")
505
+ blank(i + 1, j) // keep the backticks, blank the code-as-text inside
506
+ else blank(i, Math.min(j + 1, n))
347
507
  i = j + 1
508
+ previous = "`"
348
509
  } else if (c === "'" || c === '"') {
349
510
  let j = i + 1
350
511
  while (j < n && src[j] !== c) {
351
512
  if (src[j] === "\\") j++ // skip the escaped char
352
513
  j++
353
514
  }
515
+ if (quoted === "blank") blank(i, Math.min(j + 1, n))
354
516
  i = j + 1
517
+ previous = c
518
+ } else if (
519
+ c === "/" &&
520
+ (previous === "" || REGEX_AFTER.has(previous) || REGEX_AFTER_WORD.has(previous))
521
+ ) {
522
+ const end = regexLiteralEnd(src, i)
523
+ if (end === undefined) {
524
+ previous = c
525
+ i++
526
+ } else {
527
+ if (quoted === "keep") blank(i + 1, end)
528
+ else blank(i, end)
529
+ previous = "/regex"
530
+ i = end
531
+ }
532
+ } else if (isWordChar(c)) {
533
+ let j = i + 1
534
+ while (j < n && isWordChar(src[j])) j++
535
+ previous = src.slice(i, j)
536
+ i = j
355
537
  } else {
538
+ if (!/\s/.test(c)) previous = c
356
539
  i++
357
540
  }
358
541
  }
359
542
  return out.join("")
360
543
  }
361
544
 
362
- /** Blank comments and every quoted/template literal while preserving offsets. Unlike
545
+ /**
546
+ * Blank, with spaces (newlines preserved, so every byte offset - and line number - is unchanged):
547
+ * - `//` line comments and block comments;
548
+ * - the CONTENTS of backtick template literals - code-as-text (doc `CodeBlock` examples, code
549
+ * generators), never a real statement to lint.
550
+ * Single/double-quoted strings are KEPT (a real import/Response specifier lives in one); a regex
551
+ * literal keeps its slashes and loses its contents, so a quote inside one cannot open a string.
552
+ * Shared by the source scanners here and by `nifra doctor` ({@link ./doctor.ts}).
553
+ */
554
+ export function stripComments(src: string): string {
555
+ return maskSource(src, "keep")
556
+ }
557
+
558
+ /** Blank comments and every quoted/template/regex literal while preserving offsets. Unlike
363
559
  * {@link stripComments}, this is a code-position mask: scanners use it to find a call expression in
364
560
  * executable code, then inspect the original source for the call's literal argument. That prevents
365
561
  * documentation strings such as `const example = 'client("/")'` from becoming diagnostics. */
366
562
  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("")
563
+ return maskSource(src, "blank")
402
564
  }
403
565
 
404
566
  const firstArgumentIndex = (content: string, callEnd: number): number => {
@@ -502,8 +664,8 @@ export function scanFetchText(
502
664
  file: string,
503
665
  content: string,
504
666
  externalMounts: readonly string[] = [],
505
- ): SourceFinding[] {
506
- const out: SourceFinding[] = []
667
+ ): TypedClientFinding[] {
668
+ const out: TypedClientFinding[] = []
507
669
  const lines = content.split("\n")
508
670
  const code = codePositionMask(content)
509
671
  GLOBAL_FETCH_CALL.lastIndex = 0
@@ -516,8 +678,7 @@ export function scanFetchText(
516
678
  if (hasLiteralNifraDataHeader(content, openParen)) continue
517
679
  if (externalMounts.length > 0 && matchesExternalMount(content, argument + 1, externalMounts))
518
680
  continue
519
- const line = lineAt(content, m.index)
520
- out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() })
681
+ out.push(typedClientFinding(file, lines, lineAt(content, m.index)))
521
682
  }
522
683
  return out
523
684
  }
@@ -540,8 +701,8 @@ export function scanStreamText(
540
701
  file: string,
541
702
  content: string,
542
703
  externalMounts: readonly string[] = [],
543
- ): SourceFinding[] {
544
- const out: SourceFinding[] = []
704
+ ): TypedClientFinding[] {
705
+ const out: TypedClientFinding[] = []
545
706
  const lines = content.split("\n")
546
707
  const code = codePositionMask(content)
547
708
  STREAM_CONSTRUCT.lastIndex = 0
@@ -552,8 +713,7 @@ export function scanStreamText(
552
713
  if (content[argument + 1] !== "/" || content[argument + 2] === "/") continue
553
714
  if (externalMounts.length > 0 && matchesExternalMount(content, argument + 1, externalMounts))
554
715
  continue
555
- const line = lineAt(content, m.index)
556
- out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() })
716
+ out.push(typedClientFinding(file, lines, lineAt(content, m.index)))
557
717
  }
558
718
  return out
559
719
  }
@@ -563,6 +723,8 @@ export function scanStaticRouteText(
563
723
  file: string,
564
724
  content: string,
565
725
  facts?: SourceFacts,
726
+ /** Project files by forward-slash relative path, to resolve a path constant imported from one. */
727
+ project?: ReadonlyMap<string, string>,
566
728
  ): StaticRouteFinding[] {
567
729
  const code = stripComments(content)
568
730
  if (!isPotentialBackendSource(code)) return []
@@ -570,6 +732,201 @@ export function scanStaticRouteText(
570
732
  ...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_DQ, facts),
571
733
  ...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_SQ, facts),
572
734
  ...(code.includes(METHODS_SUBPATH) ? scanMethodRoutes(file, content, code, facts) : []),
735
+ ...scanRouteArguments(file, content, facts, project).routes,
736
+ ].sort(bySite)
737
+ }
738
+
739
+ /** A module-level `const`: its value when the initializer is one string literal, else `undefined`. */
740
+ interface ModuleConstant {
741
+ readonly exported: boolean
742
+ readonly value: string | undefined
743
+ }
744
+
745
+ /** The end of the quoted literal opening `text`, or -1 when it does not close on this line. */
746
+ function literalEnd(text: string): number {
747
+ const quote = text[0]
748
+ for (let i = 1; i < text.length; i++) {
749
+ if (text[i] === "\\") i++
750
+ else if (text[i] === quote) return i + 1
751
+ }
752
+ return -1
753
+ }
754
+
755
+ /** The index after the whitespace run at `from`. */
756
+ function skipSpace(text: string, from: number): number {
757
+ let i = from
758
+ while (i < text.length && (text[i] === " " || text[i] === "\t")) i++
759
+ return i
760
+ }
761
+
762
+ /**
763
+ * Read `[export ]const NAME[: T] = <init>` written at column 0, so a function-local `const`, which is
764
+ * indented, never stands in for a module-level one. The shape is read from the comment-stripped
765
+ * line; the literal itself from the source line, since stripping blanks template contents.
766
+ */
767
+ function moduleConstantOn(code: string, source: string): [string, ModuleConstant] | undefined {
768
+ const exported = code.startsWith("export ")
769
+ let i = exported ? skipSpace(code, 7) : 0
770
+ if (!code.startsWith("const ", i)) return undefined
771
+ i = skipSpace(code, i + 6)
772
+ const name = /^[A-Za-z_$][\w$]*/.exec(code.slice(i))?.[0]
773
+ if (name === undefined) return undefined
774
+ i = skipSpace(code, i + name.length)
775
+ if (code[i] === ":") i = code.indexOf("=", i)
776
+ if (i === -1 || code[i] !== "=" || code[i + 1] === "=") return undefined
777
+ i = skipSpace(code, i + 1)
778
+ const opener = code[i]
779
+ const end = opener === '"' || opener === "'" || opener === "`" ? literalEnd(source.slice(i)) : -1
780
+ const tail = end === -1 ? undefined : code.slice(i + end).trim()
781
+ const literal = source.slice(i, i + end)
782
+ const value =
783
+ tail === undefined || !["", ";", "as const", "as const;"].includes(tail)
784
+ ? undefined
785
+ : opener === "`"
786
+ ? /[$\\]/.test(literal)
787
+ ? undefined
788
+ : literal.slice(1, -1)
789
+ : parseQuotedLiteral(literal)
790
+ return [name, { exported, value }]
791
+ }
792
+
793
+ function moduleConstants(code: string, source: string): ReadonlyMap<string, ModuleConstant> {
794
+ const constants = new Map<string, ModuleConstant>()
795
+ const sourceLines = source.split("\n")
796
+ code.split("\n").forEach((line, index) => {
797
+ const constant = moduleConstantOn(line, sourceLines[index] ?? "")
798
+ if (constant !== undefined) constants.set(constant[0], constant[1])
799
+ })
800
+ return constants
801
+ }
802
+
803
+ /** Local name → the export it imports and from where, for value imports only. */
804
+ function namedImports(
805
+ code: string,
806
+ ): ReadonlyMap<string, { readonly specifier: string; readonly name: string }> {
807
+ const imports = new Map<string, { readonly specifier: string; readonly name: string }>()
808
+ NAMED_IMPORTS.lastIndex = 0
809
+ for (let m = NAMED_IMPORTS.exec(code); m !== null; m = NAMED_IMPORTS.exec(code)) {
810
+ if (/\btype\s*$/.test(code.slice(Math.max(0, m.index - 12), m.index + 6))) continue
811
+ for (const element of (m[1] ?? "").split(",")) {
812
+ const parts = element.trim().split(/\s+/)
813
+ if (parts[0] === "type" || parts[0] === undefined || parts[0] === "") continue
814
+ const local = parts[1] === "as" ? parts[2] : parts[0]
815
+ if (local !== undefined) imports.set(local, { specifier: m[3] ?? "", name: parts[0] })
816
+ }
817
+ }
818
+ return imports
819
+ }
820
+
821
+ /** The project file a relative import names, tried the way a TypeScript-aware bundler resolves it. */
822
+ function projectModule(
823
+ project: ReadonlyMap<string, string>,
824
+ fromFile: string,
825
+ specifier: string,
826
+ ): string | undefined {
827
+ if (!specifier.startsWith("./") && !specifier.startsWith("../")) return undefined
828
+ const base = posix.normalize(posix.join(posix.dirname(fromFile.replaceAll("\\", "/")), specifier))
829
+ const stem = base.replace(/\.(?:m?js|jsx)$/, "")
830
+ return [base, `${stem}.ts`, `${stem}.tsx`, `${stem}.mts`, `${base}/index.ts`].find((candidate) =>
831
+ project.has(candidate),
832
+ )
833
+ }
834
+
835
+ /**
836
+ * Route registrations whose path is a name or a template rather than a quoted literal. A name bound
837
+ * to a module-level string `const`, here or imported from a project file, and a template with no
838
+ * substitution resolve to routes; any other template, and a module-level or imported name nifra
839
+ * cannot read, is unresolved. A name bound anywhere else (a parameter, a local) is not reported:
840
+ * nothing marks such a call as a route rather than a `map.get(key, fallback)`.
841
+ */
842
+ function scanRouteArguments(
843
+ file: string,
844
+ content: string,
845
+ facts: SourceFacts | undefined,
846
+ project: ReadonlyMap<string, string> | undefined,
847
+ verbs: ReadonlySet<string> = HTTP_VERBS,
848
+ ): { routes: StaticRouteFinding[]; unresolved: UnresolvedRoutePathFinding[] } {
849
+ const routes: StaticRouteFinding[] = []
850
+ const unresolved: UnresolvedRoutePathFinding[] = []
851
+ const code = stripComments(content)
852
+ if (!isPotentialBackendSource(code)) return { routes, unresolved }
853
+ const lines = content.split("\n")
854
+ const constants = moduleConstants(code, content)
855
+ const imports = namedImports(code)
856
+ const importedFiles = new Map<string, ReadonlyMap<string, ModuleConstant>>()
857
+ const imported = (specifier: string, name: string): ModuleConstant | undefined => {
858
+ const target = project === undefined ? undefined : projectModule(project, file, specifier)
859
+ if (target === undefined) return undefined
860
+ let found = importedFiles.get(target)
861
+ if (found === undefined) {
862
+ const text = project?.get(target) ?? ""
863
+ found = moduleConstants(stripComments(text), text)
864
+ importedFiles.set(target, found)
865
+ }
866
+ const constant = found.get(name)
867
+ return constant?.exported === true ? constant : undefined
868
+ }
869
+ ROUTE_ARGUMENT.lastIndex = 0
870
+ for (let m = ROUTE_ARGUMENT.exec(code); m !== null; m = ROUTE_ARGUMENT.exec(code)) {
871
+ const method = (m[1] ?? "").toUpperCase()
872
+ if (!verbs.has(method)) continue
873
+ const at = m.index + m[0].lastIndexOf(m[2] ?? "")
874
+ const argument = content.slice(at, at + (m[2] ?? "").length)
875
+ let path: string | undefined
876
+ if (argument.startsWith("`")) {
877
+ path = /[$\\]/.test(argument) ? undefined : argument.slice(1, -1)
878
+ } else {
879
+ const binding = imports.get(argument)
880
+ const constant =
881
+ binding === undefined ? constants.get(argument) : imported(binding.specifier, binding.name)
882
+ if (binding === undefined && constant === undefined) continue
883
+ path = constant?.value
884
+ }
885
+ // A name that reads as something other than a path (`settings.get(KEY, fallback)`) is no route.
886
+ if (path !== undefined && (!path.startsWith("/") || path.startsWith("//"))) continue
887
+ if (facts !== undefined) {
888
+ const source = facts.parse(file, content)
889
+ if (
890
+ source !== undefined &&
891
+ facts.isRouteArgumentAt(source, m.index, method, argument) !== true
892
+ )
893
+ continue
894
+ }
895
+ const line = lineAt(content, m.index)
896
+ const snippet = (lines[line - 1] ?? "").trim()
897
+ if (path !== undefined) routes.push({ file, line, snippet, method, path })
898
+ else unresolved.push({ file, line, snippet, method, expression: argument.slice(0, 120) })
899
+ }
900
+ return { routes, unresolved }
901
+ }
902
+
903
+ /** Route registrations whose path the static scan cannot resolve - see {@link scanRouteArguments}. */
904
+ export function scanUnresolvedRoutePaths(
905
+ file: string,
906
+ content: string,
907
+ project?: ReadonlyMap<string, string>,
908
+ facts?: SourceFacts,
909
+ ): UnresolvedRoutePathFinding[] {
910
+ return [
911
+ ...scanRouteArguments(file, content, facts, project).unresolved,
912
+ ...scanRouteArguments(file, content, facts, project, SOCKET_VERB).unresolved,
913
+ ].sort(bySite)
914
+ }
915
+
916
+ /** `app.ws("/path", ...)` registrations, as routes under the pseudo-method `"WS"` reflection uses. */
917
+ export function scanStaticSocketText(
918
+ file: string,
919
+ content: string,
920
+ facts?: SourceFacts,
921
+ /** Project files by forward-slash relative path, to resolve a path constant imported from one. */
922
+ project?: ReadonlyMap<string, string>,
923
+ ): StaticRouteFinding[] {
924
+ const code = stripComments(content)
925
+ if (!isPotentialBackendSource(code)) return []
926
+ return [
927
+ ...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_DQ, facts, SOCKET_VERB),
928
+ ...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_SQ, facts, SOCKET_VERB),
929
+ ...scanRouteArguments(file, content, facts, project, SOCKET_VERB).routes,
573
930
  ].sort(bySite)
574
931
  }
575
932
 
@@ -660,6 +1017,25 @@ const SQL_METHODS = new Set([
660
1017
  const SQL_KEYWORD =
661
1018
  /\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
1019
  const ALWAYS_UNSAFE = new Set(["unsafe", "$queryRawUnsafe", "$executeRawUnsafe", "raw"])
1020
+ /** Driver calls that take a statement under names that are everyday words elsewhere (`map.get`,
1021
+ * `client.get("/users/update")`): node-sqlite3's `all`/`get`/`each` and pg-promise's result-shaping
1022
+ * calls. One of these, and any bare call, is judged by whether its text starts like a statement. */
1023
+ const DRIVER_READ_METHODS = new Set([
1024
+ "all",
1025
+ "get",
1026
+ "each",
1027
+ "any",
1028
+ "one",
1029
+ "none",
1030
+ "many",
1031
+ "oneOrNone",
1032
+ "manyOrNone",
1033
+ "result",
1034
+ "multi",
1035
+ "multiResult",
1036
+ ])
1037
+ const STATEMENT_START =
1038
+ /^\s*\(?\s*(?:select|insert|update|delete|with|replace|merge|create|drop|alter|truncate|pragma)\s/i
663
1039
 
664
1040
  interface SqlExpressionShape {
665
1041
  readonly staticText: string
@@ -952,11 +1328,12 @@ const CONST_RESOLUTION_DEPTH = 5
952
1328
  * data. Pure syntax, no type checker - the same honest limit the rule already documents. What
953
1329
  * resolves: string/number literals, no-substitution templates, templates whose every span resolves,
954
1330
  * 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.
1331
+ * either still feeds the keyword scan), `as`/`satisfies` wrappers, a `const` whose initializer
1332
+ * resolves (the nearest binding: function or block scope, then module scope; recursively,
1333
+ * depth-capped), `.join(sep?)` over an array {@link staticArrayItems} proves, and - when
1334
+ * `scope.imports` is configured - a module `const` imported by name from another in-project module.
1335
+ * Everything else - `let`, a parameter, any other call, a member access, a shadowed name, a
1336
+ * bare-specifier import - stays unresolved and the interpolation is flagged exactly as before.
960
1337
  */
961
1338
  function resolveStaticSqlText(
962
1339
  ts: TypeScriptApi,
@@ -976,7 +1353,9 @@ function resolveStaticSqlText(
976
1353
  if (ts.isTemplateExpression(node)) {
977
1354
  let text = node.head.text
978
1355
  for (const span of node.templateSpans) {
979
- const part = resolveStaticSqlText(ts, span.expression, scope, depth + 1)
1356
+ const part =
1357
+ placeholderNumber(ts, text, span.expression, depth) ??
1358
+ resolveStaticSqlText(ts, span.expression, scope, depth + 1)
980
1359
  if (part === undefined) return undefined
981
1360
  text += part + span.literal.text
982
1361
  }
@@ -988,7 +1367,11 @@ function resolveStaticSqlText(
988
1367
  if (whenTrue === undefined || whenFalse === undefined) return undefined
989
1368
  return `${whenTrue} ${whenFalse}`
990
1369
  }
1370
+ if (ts.isCallExpression(node)) return resolveStaticJoin(ts, node, scope, depth)
991
1371
  if (ts.isIdentifier(node)) {
1372
+ const local = localConstInitializer(ts, node)
1373
+ if (local === "shadow") return undefined
1374
+ if (local !== undefined) return resolveStaticSqlText(ts, local, scope, depth + 1)
992
1375
  const initializer = scope.module.consts.get(node.text)
993
1376
  const edge = initializer === undefined ? scope.module.imports.get(node.text) : undefined
994
1377
  // Membership first: `isShadowedAt` walks a subtree, and most identifiers in a query are neither a
@@ -1017,90 +1400,567 @@ function resolveStaticSqlText(
1017
1400
  return undefined
1018
1401
  }
1019
1402
 
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(
1403
+ /**
1404
+ * The initializer of the `const` a function or block scope binds `node` to, `"shadow"` when the
1405
+ * nearest such binding is anything else (a parameter, `let`, `var`, destructuring), or `undefined`
1406
+ * when no scope below the module binds it - the module-scope lookup applies then.
1407
+ */
1408
+ function localConstInitializer(
1023
1409
  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
1410
+ node: TSApi.Identifier,
1411
+ ): TSApi.Expression | "shadow" | undefined {
1412
+ const source = node.getSourceFile()
1413
+ for (let scope: TSApi.Node | undefined = node.parent; scope !== undefined; scope = scope.parent) {
1414
+ if (ts.isSourceFile(scope)) return undefined
1415
+ const binding = directBinding(ts, source, scope, node.text)
1416
+ if (binding === "shadow") return "shadow"
1417
+ if (binding !== undefined) return binding.constant ? binding.initializer : "shadow"
1035
1418
  }
1036
- return { staticText, dynamic }
1419
+ return undefined
1037
1420
  }
1038
1421
 
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"
1422
+ const unwrapExpression = (ts: TypeScriptApi, node: TSApi.Expression): TSApi.Expression => {
1423
+ let current = node
1424
+ while (
1425
+ ts.isParenthesizedExpression(current) ||
1426
+ ts.isAsExpression(current) ||
1427
+ ts.isSatisfiesExpression(current)
1428
+ ) {
1429
+ current = current.expression
1430
+ }
1431
+ return current
1432
+ }
1044
1433
 
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
1434
+ /** `Object.freeze([...])` with `Object` unshadowed: the one array form no later code can change. */
1435
+ function frozenArrayLiteral(
1436
+ ts: TypeScriptApi,
1437
+ node: TSApi.Expression,
1438
+ ): TSApi.ArrayLiteralExpression | undefined {
1439
+ const call = unwrapExpression(ts, node)
1440
+ if (!ts.isCallExpression(call) || call.arguments.length !== 1) return undefined
1441
+ const callee = call.expression
1442
+ if (
1443
+ !ts.isPropertyAccessExpression(callee) ||
1444
+ callee.name.text !== "freeze" ||
1445
+ !ts.isIdentifier(callee.expression) ||
1446
+ callee.expression.text !== "Object" ||
1447
+ isShadowedAt(ts, callee.expression, "Object")
1448
+ ) {
1449
+ return undefined
1450
+ }
1451
+ const array = unwrapExpression(ts, call.arguments[0]!)
1452
+ return ts.isArrayLiteralExpression(array) ? array : undefined
1453
+ }
1454
+
1455
+ const ARRAY_READS: ReadonlySet<string> = new Set(["join", "includes", "indexOf"])
1456
+
1457
+ /**
1458
+ * Whether every use of `name` in `source` other than its declaration is a call of `.join(`,
1459
+ * `.includes(` or `.indexOf(`, or a `.length` read: then nothing pushes to, sorts, exports or hands
1460
+ * away the array, so it still holds its literal elements. With `pushed`, a `.push(...)` is accepted
1461
+ * too and its arguments collected, for the caller to prove (the `where.push(...)` builder). A
1462
+ * same-named binding elsewhere only adds uses, so the answer can only err toward refusing.
1463
+ */
1464
+ function onlyReadsArray(
1465
+ ts: TypeScriptApi,
1466
+ source: TSApi.SourceFile,
1467
+ name: string,
1468
+ declaration: TSApi.Node,
1469
+ pushed?: TSApi.Expression[],
1470
+ ): boolean {
1471
+ let ok = true
1051
1472
  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
1473
+ if (!ok) return
1474
+ if (ts.isIdentifier(node) && node.text === name && node !== declaration) {
1475
+ const access = node.parent
1476
+ const call =
1477
+ ts.isPropertyAccessExpression(access) &&
1478
+ access.expression === node &&
1479
+ ts.isCallExpression(access.parent) &&
1480
+ access.parent.expression === access
1481
+ ? access.parent
1482
+ : undefined
1483
+ if (
1484
+ pushed !== undefined &&
1485
+ call !== undefined &&
1486
+ ts.isPropertyAccessExpression(access) &&
1487
+ access.name.text === "push"
1488
+ ) {
1489
+ if (call.arguments.some((argument) => ts.isSpreadElement(argument))) ok = false
1490
+ else pushed.push(...call.arguments)
1491
+ // An argument can mention the array again; those uses are checked as well.
1492
+ for (const argument of call.arguments) visit(argument)
1493
+ return
1494
+ }
1495
+ const read =
1496
+ ts.isPropertyAccessExpression(access) &&
1497
+ access.expression === node &&
1498
+ (access.name.text === "length"
1499
+ ? !isWrittenTo(ts, access)
1500
+ : ARRAY_READS.has(access.name.text) && call !== undefined)
1501
+ if (!read) ok = false
1071
1502
  return
1072
1503
  }
1073
1504
  ts.forEachChild(node, visit)
1074
1505
  }
1075
1506
  visit(source)
1076
- return found
1507
+ return ok
1077
1508
  }
1078
1509
 
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)),
1090
- )
1510
+ /** `node` as the target of an assignment, `++`/`--`, or `delete`. */
1511
+ function isWrittenTo(ts: TypeScriptApi, node: TSApi.Node): boolean {
1512
+ const parent = node.parent
1513
+ if (
1514
+ ts.isBinaryExpression(parent) &&
1515
+ parent.left === node &&
1516
+ parent.operatorToken.kind >= ts.SyntaxKind.FirstAssignment &&
1517
+ parent.operatorToken.kind <= ts.SyntaxKind.LastAssignment
1518
+ ) {
1519
+ return true
1520
+ }
1521
+ if (ts.isPrefixUnaryExpression(parent) || ts.isPostfixUnaryExpression(parent)) {
1522
+ return (
1523
+ parent.operator === ts.SyntaxKind.PlusPlusToken ||
1524
+ parent.operator === ts.SyntaxKind.MinusMinusToken
1525
+ )
1526
+ }
1527
+ return ts.isDeleteExpression(parent)
1091
1528
  }
1092
1529
 
1093
- /** Inspect the bindings a single scope node introduces DIRECTLY (not nested) for `name`: parameters and
1094
- * catch variables of a function/catch, `var`/`let`/`const` and function/class declarations of a
1095
- * statement list. Returns the readable initializer, `"shadow"` for a form we cannot read through, or
1096
- * `undefined` when this scope does not bind the name at all. */
1097
- function directBinding(
1530
+ /**
1531
+ * The element texts of an array `receiver` names, when the array provably still holds them: an
1532
+ * inline literal, `Object.freeze([...])` anywhere it is bound or imported from, or a `const` array
1533
+ * literal bound in this file, not exported, and only ever read ({@link onlyReadsArray}). `as const`
1534
+ * is type-only and proves nothing.
1535
+ */
1536
+ function staticArrayItems(
1098
1537
  ts: TypeScriptApi,
1099
- source: TSApi.SourceFile,
1100
- scope: TSApi.Node,
1101
- name: string,
1102
- ): ResolvableBinding | undefined {
1103
- if (ts.isFunctionLike(scope)) {
1538
+ receiver: TSApi.Expression,
1539
+ scope: SqlScope,
1540
+ depth: number,
1541
+ ): readonly string[] | undefined {
1542
+ const node = unwrapExpression(ts, receiver)
1543
+ let array = ts.isArrayLiteralExpression(node) ? node : frozenArrayLiteral(ts, node)
1544
+ let itemScope = scope
1545
+ const pushed: TSApi.Expression[] = []
1546
+ if (array === undefined && ts.isIdentifier(node)) {
1547
+ const local = localConstInitializer(ts, node)
1548
+ if (local === "shadow") return undefined
1549
+ let initializer = local
1550
+ let exported = false
1551
+ if (initializer === undefined && !isShadowedAt(ts, node, node.text)) {
1552
+ initializer = scope.module.consts.get(node.text)
1553
+ exported = [...scope.module.localExports.values()].includes(node.text)
1554
+ const edge = initializer === undefined ? scope.module.imports.get(node.text) : undefined
1555
+ if (edge !== undefined && scope.imports !== undefined) {
1556
+ const target = loadSqlModuleScope(ts, scope.imports, scope.module.id, edge.specifier)
1557
+ const found =
1558
+ target === undefined
1559
+ ? undefined
1560
+ : resolveExportedConst(ts, scope.imports, target, edge.exported, 1, new Set())
1561
+ if (found === undefined) return undefined
1562
+ // Another module's array is accepted only frozen: its own file may mutate it.
1563
+ array = frozenArrayLiteral(ts, found.expression)
1564
+ itemScope = { module: found.scope, imports: scope.imports }
1565
+ }
1566
+ }
1567
+ if (array === undefined && initializer !== undefined) {
1568
+ array = frozenArrayLiteral(ts, initializer)
1569
+ const literal = unwrapExpression(ts, initializer)
1570
+ if (
1571
+ array === undefined &&
1572
+ !exported &&
1573
+ ts.isArrayLiteralExpression(literal) &&
1574
+ ts.isVariableDeclaration(initializer.parent) &&
1575
+ onlyReadsArray(ts, node.getSourceFile(), node.text, initializer.parent.name, pushed)
1576
+ ) {
1577
+ array = literal
1578
+ }
1579
+ }
1580
+ }
1581
+ if (array === undefined) return undefined
1582
+ const items: string[] = []
1583
+ // What the array may hold: its literal elements, and anything pushed onto it in any order.
1584
+ for (const element of [...array.elements, ...pushed]) {
1585
+ if (ts.isSpreadElement(element) || ts.isOmittedExpression(element)) return undefined
1586
+ const text = resolveStaticSqlText(ts, element, itemScope, depth + 1)
1587
+ if (text === undefined) return undefined
1588
+ items.push(text)
1589
+ }
1590
+ return items
1591
+ }
1592
+
1593
+ /** `items.join(sep?)` over a {@link staticArrayItems} array with a literal or absent separator. */
1594
+ function resolveStaticJoin(
1595
+ ts: TypeScriptApi,
1596
+ call: TSApi.CallExpression,
1597
+ scope: SqlScope,
1598
+ depth: number,
1599
+ ): string | undefined {
1600
+ const callee = call.expression
1601
+ if (
1602
+ !ts.isPropertyAccessExpression(callee) ||
1603
+ callee.questionDotToken !== undefined ||
1604
+ callee.name.text !== "join" ||
1605
+ call.arguments.length > 1
1606
+ ) {
1607
+ return undefined
1608
+ }
1609
+ const separatorArg = call.arguments[0]
1610
+ const separator =
1611
+ separatorArg === undefined
1612
+ ? ","
1613
+ : ts.isStringLiteral(separatorArg) || ts.isNoSubstitutionTemplateLiteral(separatorArg)
1614
+ ? separatorArg.text
1615
+ : undefined
1616
+ if (separator === undefined) return undefined
1617
+ const items =
1618
+ staticArrayItems(ts, callee.expression, scope, depth + 1) ??
1619
+ mappedItem(ts, callee.expression, scope, depth + 1)
1620
+ return items?.join(separator)
1621
+ }
1622
+
1623
+ /**
1624
+ * `xs.map(fn)`, `xs.flatMap(fn)` or `Array.from(src, fn)` whose callback returns text that resolves
1625
+ * for every element - `(_, i) => \`($${i * 2 + 1}, $${i * 2 + 2})\``, the batch `VALUES` idiom. How
1626
+ * many items there are is runtime data, what each one says is not, so one item stands for all.
1627
+ */
1628
+ function mappedItem(
1629
+ ts: TypeScriptApi,
1630
+ receiver: TSApi.Expression,
1631
+ scope: SqlScope,
1632
+ depth: number,
1633
+ ): readonly string[] | undefined {
1634
+ const call = unwrapExpression(ts, receiver)
1635
+ if (!ts.isCallExpression(call) || !ts.isPropertyAccessExpression(call.expression))
1636
+ return undefined
1637
+ // Only these return what the callback returns; `filter` and the rest hand back the elements.
1638
+ const method = call.expression.name.text
1639
+ const argument = indexCallbackArgument(ts, call)
1640
+ if (argument === undefined || !(method === "map" || method === "flatMap" || argument === 1))
1641
+ return undefined
1642
+ const fn = call.arguments[argument]
1643
+ if (fn === undefined || !(ts.isArrowFunction(fn) || ts.isFunctionExpression(fn))) return undefined
1644
+ const only =
1645
+ ts.isBlock(fn.body) && fn.body.statements.length === 1 ? fn.body.statements[0] : undefined
1646
+ const body = ts.isBlock(fn.body)
1647
+ ? only !== undefined && ts.isReturnStatement(only)
1648
+ ? only.expression
1649
+ : undefined
1650
+ : fn.body
1651
+ const text = body === undefined ? undefined : resolveStaticSqlText(ts, body, scope, depth + 1)
1652
+ return text === undefined ? undefined : [text]
1653
+ }
1654
+
1655
+ /** Array methods whose callback receives the element's index, and where in the callback it is. */
1656
+ const INDEX_PARAMETER: ReadonlyMap<string, number> = new Map([
1657
+ ["map", 1],
1658
+ ["flatMap", 1],
1659
+ ["forEach", 1],
1660
+ ["filter", 1],
1661
+ ["some", 1],
1662
+ ["every", 1],
1663
+ ["find", 1],
1664
+ ["findIndex", 1],
1665
+ ["reduce", 2],
1666
+ ["reduceRight", 2],
1667
+ ])
1668
+
1669
+ /** Which argument of `call` is a callback handed an array index: the first for `xs.map(fn)` and its
1670
+ * kin, the second for an unshadowed `Array.from(src, fn)`. */
1671
+ function indexCallbackArgument(ts: TypeScriptApi, call: TSApi.CallExpression): number | undefined {
1672
+ const callee = call.expression
1673
+ if (!ts.isPropertyAccessExpression(callee) || callee.questionDotToken !== undefined)
1674
+ return undefined
1675
+ if (INDEX_PARAMETER.has(callee.name.text)) return 0
1676
+ return callee.name.text === "from" &&
1677
+ ts.isIdentifier(callee.expression) &&
1678
+ callee.expression.text === "Array" &&
1679
+ !isShadowedAt(ts, callee.expression, "Array")
1680
+ ? 1
1681
+ : undefined
1682
+ }
1683
+
1684
+ /**
1685
+ * `"1"` for a span written right after a `$` whose expression provably evaluates to a number: a
1686
+ * Postgres placeholder index (`$${i + 1}`), which binds a parameter and can carry no SQL. Any string
1687
+ * there could, so anything not proved numeric stays unresolved.
1688
+ */
1689
+ function placeholderNumber(
1690
+ ts: TypeScriptApi,
1691
+ before: string,
1692
+ node: TSApi.Expression,
1693
+ depth: number,
1694
+ ): string | undefined {
1695
+ return before.endsWith("$") && isNumericExpression(ts, node, depth) ? "1" : undefined
1696
+ }
1697
+
1698
+ const NUMERIC_OPERATORS = (ts: TypeScriptApi): ReadonlySet<TSApi.SyntaxKind> =>
1699
+ new Set([
1700
+ ts.SyntaxKind.MinusToken,
1701
+ ts.SyntaxKind.AsteriskToken,
1702
+ ts.SyntaxKind.AsteriskAsteriskToken,
1703
+ ts.SyntaxKind.SlashToken,
1704
+ ts.SyntaxKind.PercentToken,
1705
+ ts.SyntaxKind.AmpersandToken,
1706
+ ts.SyntaxKind.BarToken,
1707
+ ts.SyntaxKind.CaretToken,
1708
+ ts.SyntaxKind.LessThanLessThanToken,
1709
+ ts.SyntaxKind.GreaterThanGreaterThanToken,
1710
+ ts.SyntaxKind.GreaterThanGreaterThanGreaterThanToken,
1711
+ ])
1712
+ const NUMBER_FUNCTIONS: ReadonlySet<string> = new Set(["Number", "parseInt", "parseFloat"])
1713
+
1714
+ /**
1715
+ * Whether an expression evaluates to a number whatever its inputs hold. Every arithmetic operator but
1716
+ * `+` converts its operands, so `i * 2` is a number even if `i` were a string; `+` is one only when
1717
+ * both sides are. A name proves it when it is a callback's array index, or a `const`, never-reassigned
1718
+ * `let` or `for` counter bound to a number and only ever updated by arithmetic. Also `xs.push(...)`
1719
+ * (the new length), `Number`/`parseInt`/`parseFloat`/`Math.*` calls, and `.length` of an array a
1720
+ * nearby binding builds from a literal. A type annotation proves nothing: it does not run.
1721
+ */
1722
+ function isNumericExpression(ts: TypeScriptApi, node: TSApi.Expression, depth: number): boolean {
1723
+ if (depth > CONST_RESOLUTION_DEPTH) return false
1724
+ const expression = unwrapExpression(ts, node)
1725
+ if (ts.isNumericLiteral(expression)) return true
1726
+ if (ts.isPrefixUnaryExpression(expression))
1727
+ return expression.operator !== ts.SyntaxKind.ExclamationToken
1728
+ if (ts.isPostfixUnaryExpression(expression)) return true
1729
+ if (ts.isBinaryExpression(expression)) {
1730
+ const operator = expression.operatorToken.kind
1731
+ if (operator === ts.SyntaxKind.PlusToken) {
1732
+ return (
1733
+ isNumericExpression(ts, expression.left, depth + 1) &&
1734
+ isNumericExpression(ts, expression.right, depth + 1)
1735
+ )
1736
+ }
1737
+ return NUMERIC_OPERATORS(ts).has(operator)
1738
+ }
1739
+ if (ts.isIdentifier(expression)) return isNumericName(ts, expression, depth)
1740
+ if (ts.isCallExpression(expression)) {
1741
+ const callee = expression.expression
1742
+ if (ts.isIdentifier(callee))
1743
+ return NUMBER_FUNCTIONS.has(callee.text) && !isShadowedAt(ts, callee, callee.text)
1744
+ if (!ts.isPropertyAccessExpression(callee) || callee.questionDotToken !== undefined)
1745
+ return false
1746
+ if (callee.name.text === "push") return true
1747
+ return (
1748
+ ts.isIdentifier(callee.expression) &&
1749
+ callee.expression.text === "Math" &&
1750
+ !isShadowedAt(ts, callee.expression, "Math")
1751
+ )
1752
+ }
1753
+ if (
1754
+ ts.isPropertyAccessExpression(expression) &&
1755
+ expression.name.text === "length" &&
1756
+ ts.isIdentifier(expression.expression)
1757
+ ) {
1758
+ const binding = nameBinding(ts, expression.expression)
1759
+ return (
1760
+ binding.kind === "value" &&
1761
+ ts.isArrayLiteralExpression(unwrapExpression(ts, binding.initializer))
1762
+ )
1763
+ }
1764
+ return false
1765
+ }
1766
+
1767
+ /** How the nearest scope binds a name: a readable value ({@link directBinding}), a parameter, a `let`
1768
+ * counter a `for` statement declares and updates, or nothing this walk can read. */
1769
+ type NameBinding =
1770
+ | { readonly kind: "value"; readonly initializer: TSApi.Expression }
1771
+ | {
1772
+ readonly kind: "parameter"
1773
+ readonly fn: TSApi.SignatureDeclaration
1774
+ readonly position: number
1775
+ }
1776
+ | { readonly kind: "counter"; readonly initializer: TSApi.Expression }
1777
+ | { readonly kind: "unknown" }
1778
+
1779
+ function nameBinding(ts: TypeScriptApi, node: TSApi.Identifier): NameBinding {
1780
+ const source = node.getSourceFile()
1781
+ for (let scope: TSApi.Node | undefined = node.parent; scope !== undefined; scope = scope.parent) {
1782
+ if (ts.isFunctionLike(scope)) {
1783
+ const position = scope.parameters.findIndex((p) => bindingNameCovers(ts, p.name, node.text))
1784
+ if (position !== -1) {
1785
+ return ts.isIdentifier(scope.parameters[position]?.name ?? node)
1786
+ ? { kind: "parameter", fn: scope, position }
1787
+ : { kind: "unknown" }
1788
+ }
1789
+ }
1790
+ const binding = directBinding(ts, source, scope, node.text)
1791
+ if (binding === "shadow") {
1792
+ // `for (let i = 0; ...; i++)`: the one reassigned `let` worth reading, checked by the caller.
1793
+ const list = ts.isForStatement(scope) ? scope.initializer : undefined
1794
+ const declaration =
1795
+ list !== undefined && ts.isVariableDeclarationList(list)
1796
+ ? list.declarations.find((d) => ts.isIdentifier(d.name) && d.name.text === node.text)
1797
+ : undefined
1798
+ return declaration?.initializer === undefined
1799
+ ? { kind: "unknown" }
1800
+ : { kind: "counter", initializer: declaration.initializer }
1801
+ }
1802
+ if (binding !== undefined) return { kind: "value", initializer: binding.initializer }
1803
+ if (ts.isSourceFile(scope)) break
1804
+ }
1805
+ return { kind: "unknown" }
1806
+ }
1807
+
1808
+ function isNumericName(ts: TypeScriptApi, node: TSApi.Identifier, depth: number): boolean {
1809
+ const source = node.getSourceFile()
1810
+ const binding = nameBinding(ts, node)
1811
+ if (binding.kind === "value") return isNumericExpression(ts, binding.initializer, depth + 1)
1812
+ if (binding.kind === "counter") {
1813
+ return (
1814
+ isNumericExpression(ts, binding.initializer, depth + 1) &&
1815
+ onlyNumericWrites(ts, source, node.text, depth)
1816
+ )
1817
+ }
1818
+ if (binding.kind !== "parameter") return false
1819
+ // An array callback's index parameter, never reassigned.
1820
+ const call = binding.fn.parent
1821
+ if (!ts.isCallExpression(call) || !ts.isPropertyAccessExpression(call.expression)) return false
1822
+ const argument = indexCallbackArgument(ts, call)
1823
+ const position = argument === 1 ? 1 : INDEX_PARAMETER.get(call.expression.name.text)
1824
+ return (
1825
+ argument !== undefined &&
1826
+ call.arguments[argument] === binding.fn &&
1827
+ binding.position === position &&
1828
+ !isReassignedInFile(ts, source, node.text)
1829
+ )
1830
+ }
1831
+
1832
+ /** Compound assignments that always store a number. */
1833
+ const NUMERIC_ASSIGNMENTS = (ts: TypeScriptApi): ReadonlySet<TSApi.SyntaxKind> =>
1834
+ new Set([
1835
+ ts.SyntaxKind.MinusEqualsToken,
1836
+ ts.SyntaxKind.AsteriskEqualsToken,
1837
+ ts.SyntaxKind.AsteriskAsteriskEqualsToken,
1838
+ ts.SyntaxKind.SlashEqualsToken,
1839
+ ts.SyntaxKind.PercentEqualsToken,
1840
+ ts.SyntaxKind.AmpersandEqualsToken,
1841
+ ts.SyntaxKind.BarEqualsToken,
1842
+ ts.SyntaxKind.CaretEqualsToken,
1843
+ ts.SyntaxKind.LessThanLessThanEqualsToken,
1844
+ ts.SyntaxKind.GreaterThanGreaterThanEqualsToken,
1845
+ ts.SyntaxKind.GreaterThanGreaterThanGreaterThanEqualsToken,
1846
+ ])
1847
+
1848
+ /** Every write to `name` in the file is `++`/`--`, or an assignment that stores a number. */
1849
+ function onlyNumericWrites(
1850
+ ts: TypeScriptApi,
1851
+ source: TSApi.SourceFile,
1852
+ name: string,
1853
+ depth: number,
1854
+ ): boolean {
1855
+ let ok = true
1856
+ const visit = (node: TSApi.Node): void => {
1857
+ if (!ok) return
1858
+ if (ts.isIdentifier(node) && node.text === name && isWrittenTo(ts, node)) {
1859
+ const parent = node.parent
1860
+ if (ts.isBinaryExpression(parent)) {
1861
+ const operator = parent.operatorToken.kind
1862
+ ok =
1863
+ operator === ts.SyntaxKind.EqualsToken || operator === ts.SyntaxKind.PlusEqualsToken
1864
+ ? isNumericExpression(ts, parent.right, depth + 1)
1865
+ : NUMERIC_ASSIGNMENTS(ts).has(operator)
1866
+ } else ok = !ts.isDeleteExpression(parent)
1867
+ return
1868
+ }
1869
+ ts.forEachChild(node, visit)
1870
+ }
1871
+ visit(source)
1872
+ return ok
1873
+ }
1874
+
1875
+ /** A template's contribution to the shape: resolved span texts fold into `staticText` (so the
1876
+ * keyword scan still sees what a `const` carries); any unresolvable span keeps it `dynamic`. */
1877
+ function templateShape(
1878
+ ts: TypeScriptApi,
1879
+ template: TSApi.TemplateExpression,
1880
+ scope: SqlScope | undefined,
1881
+ ): { staticText: string; dynamic: boolean } {
1882
+ let staticText = template.head.text
1883
+ let dynamic = false
1884
+ for (const span of template.templateSpans) {
1885
+ const resolved =
1886
+ scope === undefined
1887
+ ? undefined
1888
+ : (placeholderNumber(ts, staticText, span.expression, 0) ??
1889
+ resolveStaticSqlText(ts, span.expression, scope, 0))
1890
+ if (resolved === undefined) dynamic = true
1891
+ else staticText += resolved
1892
+ staticText += span.literal.text
1893
+ }
1894
+ return { staticText, dynamic }
1895
+ }
1896
+
1897
+ /** A same-file binding the argument resolver proved holds one expression: a `const`, or a `let` the
1898
+ * whole file never reassigns. `"shadow"` marks a nearer binding of the name we can NOT read through (a
1899
+ * parameter, `var`, destructured, uninitialized, reassigned `let`) - it stops the outward walk so a
1900
+ * farther, resolvable const of the same name is never read in its place. */
1901
+ type ResolvableBinding =
1902
+ | { readonly initializer: TSApi.Expression; readonly constant: boolean }
1903
+ | "shadow"
1904
+
1905
+ /** Whether `name` is written to anywhere in the file - a plain, compound, or `++`/`--` assignment to a
1906
+ * bare identifier of that name. A `let` that is is over-cautiously refused (it might be reassigned from
1907
+ * request data); a `const` never needs this. Over-refusal only ever leaves an interpolation flagged as
1908
+ * before, never the reverse. */
1909
+ function isReassignedInFile(ts: TypeScriptApi, source: TSApi.SourceFile, name: string): boolean {
1910
+ let found = false
1911
+ const visit = (node: TSApi.Node): void => {
1912
+ if (found) return
1913
+ if (
1914
+ ts.isBinaryExpression(node) &&
1915
+ node.operatorToken.kind >= ts.SyntaxKind.FirstAssignment &&
1916
+ node.operatorToken.kind <= ts.SyntaxKind.LastAssignment &&
1917
+ ts.isIdentifier(node.left) &&
1918
+ node.left.text === name
1919
+ ) {
1920
+ found = true
1921
+ return
1922
+ }
1923
+ if (
1924
+ (ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) &&
1925
+ (node.operator === ts.SyntaxKind.PlusPlusToken ||
1926
+ node.operator === ts.SyntaxKind.MinusMinusToken) &&
1927
+ ts.isIdentifier(node.operand) &&
1928
+ node.operand.text === name
1929
+ ) {
1930
+ found = true
1931
+ return
1932
+ }
1933
+ ts.forEachChild(node, visit)
1934
+ }
1935
+ visit(source)
1936
+ return found
1937
+ }
1938
+
1939
+ /** Does `binding` bind `name` (as a plain identifier or nested inside a destructuring pattern)? Mirrors
1940
+ * the pattern walk in {@link isShadowedAt}. */
1941
+ function bindingNameCovers(ts: TypeScriptApi, binding: TSApi.BindingName, name: string): boolean {
1942
+ return ts.isIdentifier(binding)
1943
+ ? binding.text === name
1944
+ : binding.elements.some(
1945
+ (element) =>
1946
+ !ts.isOmittedExpression(element) &&
1947
+ (ts.isIdentifier(element.name)
1948
+ ? element.name.text === name
1949
+ : bindingNameCovers(ts, element.name, name)),
1950
+ )
1951
+ }
1952
+
1953
+ /** Inspect the bindings a single scope node introduces DIRECTLY (not nested) for `name`: parameters and
1954
+ * catch variables of a function/catch, `var`/`let`/`const` and function/class declarations of a
1955
+ * statement list. Returns the readable initializer, `"shadow"` for a form we cannot read through, or
1956
+ * `undefined` when this scope does not bind the name at all. */
1957
+ function directBinding(
1958
+ ts: TypeScriptApi,
1959
+ source: TSApi.SourceFile,
1960
+ scope: TSApi.Node,
1961
+ name: string,
1962
+ ): ResolvableBinding | undefined {
1963
+ if (ts.isFunctionLike(scope)) {
1104
1964
  for (const parameter of scope.parameters) {
1105
1965
  if (bindingNameCovers(ts, parameter.name, name)) return "shadow"
1106
1966
  }
@@ -1109,33 +1969,15 @@ function directBinding(
1109
1969
  const declared = scope.variableDeclaration
1110
1970
  if (declared !== undefined && bindingNameCovers(ts, declared.name, name)) return "shadow"
1111
1971
  }
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
- }
1972
+ for (const statement of scopeStatements(ts, scope) ?? []) {
1973
+ if (
1974
+ (ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) &&
1975
+ statement.name?.text === name
1976
+ ) {
1977
+ return "shadow"
1129
1978
  }
1130
1979
  }
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) {
1980
+ for (const list of scopeDeclarationLists(ts, scope)) {
1139
1981
  for (const declaration of list.declarations) {
1140
1982
  if (!bindingNameCovers(ts, declaration.name, name)) continue
1141
1983
  // A destructured or uninitialized binding, or a `var` (hoisting + reassignment risk), is not a
@@ -1144,57 +1986,306 @@ function directBinding(
1144
1986
  return "shadow"
1145
1987
  const isConst = (list.flags & ts.NodeFlags.Const) !== 0
1146
1988
  const isLet = (list.flags & ts.NodeFlags.Let) !== 0
1147
- if (isConst) return { initializer: declaration.initializer }
1989
+ if (isConst) return { initializer: declaration.initializer, constant: true }
1148
1990
  if (isLet && !isReassignedInFile(ts, source, name))
1149
- return { initializer: declaration.initializer }
1991
+ return { initializer: declaration.initializer, constant: false }
1150
1992
  return "shadow"
1151
1993
  }
1152
1994
  }
1153
1995
  return undefined
1154
1996
  }
1155
1997
 
1998
+ function scopeStatements(
1999
+ ts: TypeScriptApi,
2000
+ scope: TSApi.Node,
2001
+ ): readonly TSApi.Statement[] | undefined {
2002
+ return ts.isSourceFile(scope) ||
2003
+ ts.isBlock(scope) ||
2004
+ ts.isModuleBlock(scope) ||
2005
+ ts.isCaseClause(scope) ||
2006
+ ts.isDefaultClause(scope)
2007
+ ? scope.statements
2008
+ : undefined
2009
+ }
2010
+
2011
+ /** The `var`/`let`/`const` lists a scope node declares directly: its statements' and a `for` head's. */
2012
+ function scopeDeclarationLists(
2013
+ ts: TypeScriptApi,
2014
+ scope: TSApi.Node,
2015
+ ): TSApi.VariableDeclarationList[] {
2016
+ const lists: TSApi.VariableDeclarationList[] = []
2017
+ for (const statement of scopeStatements(ts, scope) ?? []) {
2018
+ if (ts.isVariableStatement(statement)) lists.push(statement.declarationList)
2019
+ }
2020
+ if (
2021
+ (ts.isForStatement(scope) || ts.isForInStatement(scope) || ts.isForOfStatement(scope)) &&
2022
+ scope.initializer !== undefined &&
2023
+ ts.isVariableDeclarationList(scope.initializer)
2024
+ ) {
2025
+ lists.push(scope.initializer)
2026
+ }
2027
+ return lists
2028
+ }
2029
+
2030
+ /** The `var` or `let` a scope declares `name` with by a plain identifier, if any. */
2031
+ function mutableDeclaration(
2032
+ ts: TypeScriptApi,
2033
+ scope: TSApi.Node,
2034
+ name: string,
2035
+ ): TSApi.VariableDeclaration | undefined {
2036
+ for (const list of scopeDeclarationLists(ts, scope)) {
2037
+ if ((list.flags & ts.NodeFlags.Const) !== 0) continue
2038
+ for (const declaration of list.declarations) {
2039
+ if (ts.isIdentifier(declaration.name) && declaration.name.text === name) return declaration
2040
+ }
2041
+ }
2042
+ return undefined
2043
+ }
2044
+
2045
+ interface SqlWrite {
2046
+ readonly value: TSApi.Expression
2047
+ /** `+=`: the value is glued onto what the variable already holds. */
2048
+ readonly appends: boolean
2049
+ }
2050
+
1156
2051
  /**
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.
2052
+ * Every value a statement variable may hold where the query reads it, so a statement parked in a
2053
+ * variable is scanned as if it had been written inline at the call. This is the counterpart to
2054
+ * {@link resolveStaticSqlText}: that folds a const's text INTO a template's shape; this unfolds the
2055
+ * statement back out of the variable it was lifted into. Without it, ANY interpolated-SQL finding
2056
+ * could be silenced, invisibly, by hoisting the statement to `const q = …` first, or by building it
2057
+ * in steps (`let q = "SELECT …"; q += …`).
1162
2058
  *
1163
2059
  * 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).
2060
+ * same-named module const. A `const`, or a `let` the file never reassigns, holds its initializer. A
2061
+ * `var` or reassigned `let` holds its initializer and every value assigned to the name anywhere in
2062
+ * the function (or module) that declares it: over-collected rather than scope-simulated, which only
2063
+ * ever adds a value to check. `key` names the binding, so a value that reads the variable it is
2064
+ * assigned to is recognised. A parameter, an imported or destructured binding, or a name with no
2065
+ * same-file declaration returns `undefined` - which leaves the argument scanned as itself.
1169
2066
  */
1170
- function resolveArgumentInitializer(
2067
+ function variableWrites(
1171
2068
  ts: TypeScriptApi,
1172
2069
  node: TSApi.Identifier,
1173
- depth: number,
1174
- ): TSApi.Expression | undefined {
1175
- if (depth > CONST_RESOLUTION_DEPTH) return undefined
2070
+ ): { readonly key: TSApi.Node; readonly writes: readonly SqlWrite[] } | undefined {
1176
2071
  const source = node.getSourceFile()
1177
2072
  const name = node.text
1178
2073
  for (let scope: TSApi.Node | undefined = node.parent; scope !== undefined; scope = scope.parent) {
1179
2074
  const binding = directBinding(ts, source, scope, name)
1180
- if (binding === "shadow") return undefined
2075
+ if (binding === "shadow") {
2076
+ const declaration = mutableDeclaration(ts, scope, name)
2077
+ if (declaration === undefined) return undefined
2078
+ let owner: TSApi.Node = declaration
2079
+ while (!ts.isFunctionLike(owner) && !ts.isSourceFile(owner)) owner = owner.parent
2080
+ const writes = writesTo(ts, owner, name)
2081
+ return {
2082
+ key: declaration,
2083
+ writes:
2084
+ declaration.initializer === undefined
2085
+ ? writes
2086
+ : [{ value: declaration.initializer, appends: false }, ...writes],
2087
+ }
2088
+ }
1181
2089
  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
2090
+ return { key: binding.initializer, writes: [{ value: binding.initializer, appends: false }] }
1185
2091
  }
1186
2092
  if (ts.isSourceFile(scope)) break
1187
2093
  }
1188
2094
  return undefined
1189
2095
  }
1190
2096
 
1191
- /** Describe only the first argument's syntax; comments/strings elsewhere cannot influence the result. */
2097
+ /** The values assigned to `name` under `root`. A numeric compound assignment stores no text. */
2098
+ function writesTo(ts: TypeScriptApi, root: TSApi.Node, name: string): SqlWrite[] {
2099
+ const writes: SqlWrite[] = []
2100
+ const visit = (node: TSApi.Node): void => {
2101
+ if (ts.isIdentifier(node) && node.text === name) {
2102
+ const parent = node.parent
2103
+ if (ts.isBinaryExpression(parent) && parent.left === node) {
2104
+ const operator = parent.operatorToken.kind
2105
+ if (operator === ts.SyntaxKind.PlusEqualsToken) {
2106
+ writes.push({ value: parent.right, appends: true })
2107
+ } else if (
2108
+ operator === ts.SyntaxKind.EqualsToken ||
2109
+ operator === ts.SyntaxKind.QuestionQuestionEqualsToken ||
2110
+ operator === ts.SyntaxKind.BarBarEqualsToken ||
2111
+ operator === ts.SyntaxKind.AmpersandAmpersandEqualsToken
2112
+ ) {
2113
+ writes.push({ value: parent.right, appends: false })
2114
+ }
2115
+ }
2116
+ }
2117
+ ts.forEachChild(node, visit)
2118
+ }
2119
+ visit(root)
2120
+ return writes
2121
+ }
2122
+
2123
+ function mentionsName(ts: TypeScriptApi, node: TSApi.Node, name: string): boolean {
2124
+ if (ts.isIdentifier(node)) return node.text === name
2125
+ return ts.forEachChild(node, (child) => mentionsName(ts, child, name) || undefined) === true
2126
+ }
2127
+
2128
+ const UNKNOWN_SQL: SqlExpressionShape = {
2129
+ staticText: "",
2130
+ dynamic: true,
2131
+ concatenated: false,
2132
+ literal: false,
2133
+ parameterizedTag: false,
2134
+ }
2135
+
2136
+ function textShape(text: string): SqlExpressionShape {
2137
+ return {
2138
+ staticText: text,
2139
+ dynamic: false,
2140
+ concatenated: false,
2141
+ literal: true,
2142
+ parameterizedTag: false,
2143
+ }
2144
+ }
2145
+
2146
+ function joinShapes(shapes: readonly SqlExpressionShape[]): SqlExpressionShape {
2147
+ return {
2148
+ staticText: shapes.map((shape) => shape.staticText).join(""),
2149
+ // As with `+`: text glued onto a binding tag's output un-parameterizes it.
2150
+ dynamic: shapes.some((shape) => shape.dynamic || shape.parameterizedTag),
2151
+ concatenated: true,
2152
+ literal: false,
2153
+ parameterizedTag: false,
2154
+ }
2155
+ }
2156
+
2157
+ /** Orders a variable's candidate statements by how surely {@link scanInterpolatedSql} would flag
2158
+ * them: dynamic SQL text, then dynamic glued text, then any dynamic text; outside a binding tag
2159
+ * before inside one. */
2160
+ function sqlRisk(shape: SqlExpressionShape): number {
2161
+ const dynamic = !shape.dynamic
2162
+ ? 0
2163
+ : SQL_KEYWORD.test(shape.staticText) || STATEMENT_START.test(shape.staticText)
2164
+ ? 3
2165
+ : shape.concatenated
2166
+ ? 2
2167
+ : 1
2168
+ return dynamic * 2 + (shape.parameterizedTag ? 0 : 1)
2169
+ }
2170
+
2171
+ /**
2172
+ * The riskiest statement a variable may hold. Each value that replaces the text is one candidate.
2173
+ * The values that build on it (`+=`, or `q = q + more`) are glued onto every replacing value at
2174
+ * once, as a builder that takes each branch would, so a static `SELECT … WHERE 1=1` followed by an
2175
+ * interpolated `AND …` reads as the one statement it becomes.
2176
+ */
2177
+ function writtenShape(
2178
+ ts: TypeScriptApi,
2179
+ name: string,
2180
+ writes: readonly SqlWrite[],
2181
+ scope: SqlScope | undefined,
2182
+ trail: readonly TSApi.Node[],
2183
+ ): SqlExpressionShape {
2184
+ const replacing: SqlExpressionShape[] = []
2185
+ const building: SqlExpressionShape[] = []
2186
+ for (const write of writes) {
2187
+ const shape = sqlExpressionShape(ts, write.value, scope, trail)
2188
+ if (write.appends || mentionsName(ts, write.value, name)) building.push(shape)
2189
+ else replacing.push(shape)
2190
+ }
2191
+ const candidates =
2192
+ building.length === 0 ? replacing : [...replacing, joinShapes([...replacing, ...building])]
2193
+ let worst: SqlExpressionShape | undefined
2194
+ for (const candidate of candidates) {
2195
+ if (worst === undefined || sqlRisk(candidate) > sqlRisk(worst)) worst = candidate
2196
+ }
2197
+ return worst ?? UNKNOWN_SQL
2198
+ }
2199
+
2200
+ const TEXT_METHODS = new Set([
2201
+ "toString",
2202
+ "valueOf",
2203
+ "trim",
2204
+ "trimStart",
2205
+ "trimEnd",
2206
+ "toLowerCase",
2207
+ "toUpperCase",
2208
+ ])
2209
+
2210
+ /** The value a text-preserving call returns: `String(x)`, `x.toString()`, `x.trim()`. */
2211
+ function passedThrough(
2212
+ ts: TypeScriptApi,
2213
+ call: TSApi.CallExpression,
2214
+ ): TSApi.Expression | undefined {
2215
+ const callee = call.expression
2216
+ if (ts.isIdentifier(callee) && callee.text === "String" && call.arguments.length === 1) {
2217
+ return call.arguments[0]
2218
+ }
2219
+ if (
2220
+ ts.isPropertyAccessExpression(callee) &&
2221
+ TEXT_METHODS.has(callee.name.text) &&
2222
+ call.arguments.length === 0
2223
+ ) {
2224
+ return callee.expression
2225
+ }
2226
+ return undefined
2227
+ }
2228
+
2229
+ /** The parts `[a, b].join(sep)` and `a.concat(b)` glue together, a separator as its text. */
2230
+ function joinedParts(
2231
+ ts: TypeScriptApi,
2232
+ call: TSApi.CallExpression,
2233
+ ): readonly (TSApi.Expression | string)[] | undefined {
2234
+ const callee = call.expression
2235
+ if (!ts.isPropertyAccessExpression(callee)) return undefined
2236
+ if (callee.name.text === "concat") return [callee.expression, ...call.arguments]
2237
+ const items = unwrapExpression(ts, callee.expression)
2238
+ if (callee.name.text !== "join" || !ts.isArrayLiteralExpression(items)) return undefined
2239
+ const separator = call.arguments[0]
2240
+ const glue: TSApi.Expression | string =
2241
+ separator === undefined ? "," : ts.isStringLiteralLike(separator) ? separator.text : separator
2242
+ const parts: (TSApi.Expression | string)[] = []
2243
+ items.elements.forEach((element, index) => {
2244
+ if (index > 0) parts.push(glue)
2245
+ if (ts.isSpreadElement(element)) parts.push(element.expression)
2246
+ else if (!ts.isOmittedExpression(element)) parts.push(element)
2247
+ })
2248
+ return parts
2249
+ }
2250
+
2251
+ /** Whether the `sql` a tag names is a function this file defines (`const sql = (s, ...v) => …`),
2252
+ * which is plain interpolation whatever its name. A parameter, an import, or a driver instance
2253
+ * (`const sql = postgres(url)`) keeps the name's trust. */
2254
+ function definesSqlFunction(ts: TypeScriptApi, tag: TSApi.Identifier): boolean {
2255
+ for (let scope: TSApi.Node | undefined = tag.parent; scope !== undefined; scope = scope.parent) {
2256
+ if (
2257
+ ts.isFunctionLike(scope) &&
2258
+ scope.parameters.some((parameter) => bindingNameCovers(ts, parameter.name, tag.text))
2259
+ ) {
2260
+ return false
2261
+ }
2262
+ for (const statement of scopeStatements(ts, scope) ?? []) {
2263
+ if (ts.isFunctionDeclaration(statement) && statement.name?.text === tag.text) return true
2264
+ }
2265
+ for (const list of scopeDeclarationLists(ts, scope)) {
2266
+ for (const declaration of list.declarations) {
2267
+ if (!ts.isIdentifier(declaration.name) || declaration.name.text !== tag.text) continue
2268
+ const value =
2269
+ declaration.initializer === undefined
2270
+ ? undefined
2271
+ : unwrapExpression(ts, declaration.initializer)
2272
+ return value !== undefined && (ts.isArrowFunction(value) || ts.isFunctionExpression(value))
2273
+ }
2274
+ }
2275
+ }
2276
+ return false
2277
+ }
2278
+
2279
+ /** Describe only the statement's syntax; comments/strings elsewhere cannot influence the result.
2280
+ * `trail` holds the variables being read through, innermost last. */
1192
2281
  function sqlExpressionShape(
1193
2282
  ts: TypeScriptApi,
1194
- node: TSApi.Expression,
2283
+ expression: TSApi.Expression,
1195
2284
  scope?: SqlScope,
2285
+ trail: readonly TSApi.Node[] = [],
1196
2286
  ): SqlExpressionShape {
1197
- if (ts.isParenthesizedExpression(node)) return sqlExpressionShape(ts, node.expression, scope)
2287
+ let node = unwrapExpression(ts, expression)
2288
+ while (ts.isNonNullExpression(node)) node = unwrapExpression(ts, node.expression)
1198
2289
  if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
1199
2290
  return {
1200
2291
  staticText: node.text,
@@ -1207,7 +2298,7 @@ function sqlExpressionShape(
1207
2298
  if (ts.isTaggedTemplateExpression(node)) {
1208
2299
  const tag = node.tag
1209
2300
  const parameterizedTag =
1210
- (ts.isIdentifier(tag) && tag.text === "sql") ||
2301
+ (ts.isIdentifier(tag) && tag.text === "sql" && !definesSqlFunction(ts, tag)) ||
1211
2302
  (ts.isPropertyAccessExpression(tag) && tag.getText() === "Prisma.sql")
1212
2303
  const template = node.template
1213
2304
  const templated = ts.isTemplateExpression(template)
@@ -1217,8 +2308,9 @@ function sqlExpressionShape(
1217
2308
  // Only the ecosystem's binding tags are trusted, by NAME - which is the honest limit of a scanner
1218
2309
  // that reads syntax and runs no type checker. `sql` is what postgres.js, drizzle, slonik and Bun's
1219
2310
  // 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.
2311
+ // deliberate trade, and it is worth stating what it costs: a `sql` this file defines as a
2312
+ // function is read as the interpolation it is, but one imported from anywhere is trusted, so
2313
+ // this rule finds mistakes, not an adversary who reads it first.
1222
2314
  //
1223
2315
  // Trusting anything else widens that for no gain. This list briefly also held `sqlIdentifiers`,
1224
2316
  // a name invented for Nifra's own adapters - a second forgeable name that no user has a
@@ -1240,14 +2332,17 @@ function sqlExpressionShape(
1240
2332
  return {
1241
2333
  staticText: templated.staticText,
1242
2334
  dynamic: templated.dynamic,
1243
- concatenated: false,
2335
+ // `${a} ${b}` writes no text of its own; it only glues values together.
2336
+ concatenated: [node.head, ...node.templateSpans.map((span) => span.literal)].every(
2337
+ (part) => part.text.trim() === "",
2338
+ ),
1244
2339
  literal: false,
1245
2340
  parameterizedTag: false,
1246
2341
  }
1247
2342
  }
1248
2343
  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)
2344
+ const left = sqlExpressionShape(ts, node.left, scope, trail)
2345
+ const right = sqlExpressionShape(ts, node.right, scope, trail)
1251
2346
  return {
1252
2347
  staticText: left.staticText + right.staticText,
1253
2348
  // A resolved const operand is static text, not a dynamic part - so the old `!literal` test
@@ -1276,55 +2371,163 @@ function sqlExpressionShape(
1276
2371
  }
1277
2372
  }
1278
2373
  }
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
2374
+ if (ts.isCallExpression(node)) {
2375
+ const inner = passedThrough(ts, node)
2376
+ if (inner !== undefined)
2377
+ return { ...sqlExpressionShape(ts, inner, scope, trail), literal: false }
2378
+ const parts = joinedParts(ts, node)
2379
+ if (parts !== undefined) {
2380
+ return joinShapes(
2381
+ parts.map((part) =>
2382
+ typeof part === "string" ? textShape(part) : sqlExpressionShape(ts, part, scope, trail),
2383
+ ),
2384
+ )
2385
+ }
2386
+ }
2387
+ // A bare name that did not resolve to compile-time text. If it is a same-file binding, scan the
2388
+ // shapes of what it may hold - so a statement hoisted into `const q = …` or built up in a `let` is
1281
2389
  // read exactly as if it were written inline at the call, closing the "extract a variable to launder
1282
2390
  // the finding" hole. `literal` is forced false: the value arrived through a variable, which is the
1283
2391
  // property the `unsafe`/`$queryRawUnsafe` hatch rule keys on.
1284
2392
  if (ts.isIdentifier(node)) {
1285
- const initializer = resolveArgumentInitializer(ts, node, 0)
1286
- if (initializer !== undefined) {
1287
- return { ...sqlExpressionShape(ts, initializer, scope), literal: false }
2393
+ const read = variableWrites(ts, node)
2394
+ // Read inside one of its own writes (`q = q + more`), the variable is the text the other writes
2395
+ // already account for.
2396
+ if (read !== undefined && trail.includes(read.key)) return { ...textShape(""), literal: false }
2397
+ if (read !== undefined && trail.length <= CONST_RESOLUTION_DEPTH) {
2398
+ const written = writtenShape(ts, node.text, read.writes, scope, [...trail, read.key])
2399
+ return { ...written, literal: false }
1288
2400
  }
1289
2401
  }
1290
- return {
1291
- staticText: "",
1292
- dynamic: true,
1293
- concatenated: false,
1294
- literal: false,
1295
- parameterizedTag: false,
2402
+ return UNKNOWN_SQL
2403
+ }
2404
+
2405
+ interface SqlSink {
2406
+ readonly method: string
2407
+ /** `undefined` for a bare call: `const { query } = db` and then `query(…)`. */
2408
+ readonly receiver: string | undefined
2409
+ }
2410
+
2411
+ function sqlMethod(ts: TypeScriptApi, call: TSApi.CallExpression): SqlSink | undefined {
2412
+ const sink = (method: string, receiver: TSApi.Expression | undefined): SqlSink | undefined =>
2413
+ SQL_METHODS.has(method) || DRIVER_READ_METHODS.has(method)
2414
+ ? { method, receiver: receiver?.getText() }
2415
+ : undefined
2416
+ const callee = call.expression
2417
+ if (ts.isIdentifier(callee)) return sink(callee.text, undefined)
2418
+ if (ts.isPropertyAccessExpression(callee)) return sink(callee.name.text, callee.expression)
2419
+ if (ts.isElementAccessExpression(callee) && ts.isStringLiteralLike(callee.argumentExpression)) {
2420
+ return sink(callee.argumentExpression.text, callee.expression)
2421
+ }
2422
+ return undefined
2423
+ }
2424
+
2425
+ const SQL_RECEIVER = /(?:^|\.)(?:db|sql|prisma|drizzle|database|conn|connection|client|tx)$/i
2426
+
2427
+ /** Whether a statement is SQL assembled from runtime values, judged by where it is sent. */
2428
+ function isAssembledSql(sink: SqlSink, shape: SqlExpressionShape): boolean {
2429
+ if (!shape.dynamic) return false
2430
+ if (sink.receiver === undefined || DRIVER_READ_METHODS.has(sink.method)) {
2431
+ return STATEMENT_START.test(shape.staticText)
1296
2432
  }
2433
+ return (
2434
+ SQL_KEYWORD.test(shape.staticText) || (shape.concatenated && SQL_RECEIVER.test(sink.receiver))
2435
+ )
1297
2436
  }
1298
2437
 
1299
- function sqlMethod(
2438
+ /** The statement a call passes: the argument itself, or the `text`/`sql` of a config object
2439
+ * (`{ text, values }` in pg, `{ sql, values }` in mysql2). */
2440
+ function statementArgument(
1300
2441
  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
2442
+ argument: TSApi.Expression,
2443
+ ): TSApi.Expression | undefined {
2444
+ const node = unwrapExpression(ts, argument)
2445
+ if (!ts.isObjectLiteralExpression(node)) return argument
2446
+ for (const property of node.properties) {
2447
+ if (ts.isShorthandPropertyAssignment(property) && STATEMENT_KEYS.has(property.name.text)) {
2448
+ return property.name
2449
+ }
2450
+ if (
2451
+ ts.isPropertyAssignment(property) &&
2452
+ (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) &&
2453
+ STATEMENT_KEYS.has(property.name.text)
2454
+ ) {
2455
+ return property.initializer
2456
+ }
2457
+ }
2458
+ return undefined
2459
+ }
2460
+
2461
+ const STATEMENT_KEYS = new Set(["text", "sql"])
2462
+
2463
+ interface StatementHelper {
2464
+ readonly position: number
2465
+ readonly sink: SqlSink
2466
+ }
2467
+
2468
+ /** Same-file functions that send one of their parameters as a statement - `run(db, q)` calling
2469
+ * `db.query(q)` - by name, with the parameter's position and the sink it reaches. A call to one is
2470
+ * scanned as a call to that sink. One level deep. */
2471
+ function statementHelpers(
2472
+ ts: TypeScriptApi,
2473
+ source: TSApi.SourceFile,
2474
+ ): ReadonlyMap<string, StatementHelper> {
2475
+ const helpers = new Map<string, StatementHelper>()
2476
+ const visit = (node: TSApi.Node): void => {
2477
+ const sink = ts.isCallExpression(node) ? sqlMethod(ts, node) : undefined
2478
+ const passed = sink !== undefined && ts.isCallExpression(node) ? node.arguments[0] : undefined
2479
+ const argument = passed === undefined ? undefined : statementArgument(ts, passed)
2480
+ const statement = argument === undefined ? undefined : unwrapExpression(ts, argument)
2481
+ if (sink !== undefined && statement !== undefined && ts.isIdentifier(statement)) {
2482
+ const binding = nameBinding(ts, statement)
2483
+ const name = binding.kind === "parameter" ? functionName(ts, binding.fn) : undefined
2484
+ if (binding.kind === "parameter" && name !== undefined && !helpers.has(name)) {
2485
+ helpers.set(name, { position: binding.position, sink })
2486
+ }
2487
+ }
2488
+ ts.forEachChild(node, visit)
1308
2489
  }
2490
+ visit(source)
2491
+ return helpers
2492
+ }
2493
+
2494
+ function functionName(ts: TypeScriptApi, fn: TSApi.SignatureDeclaration): string | undefined {
2495
+ if (ts.isFunctionDeclaration(fn)) return fn.name?.text
1309
2496
  if (
1310
- ts.isElementAccessExpression(call.expression) &&
1311
- call.expression.argumentExpression !== undefined &&
1312
- ts.isStringLiteral(call.expression.argumentExpression)
2497
+ (ts.isArrowFunction(fn) || ts.isFunctionExpression(fn)) &&
2498
+ ts.isVariableDeclaration(fn.parent) &&
2499
+ ts.isIdentifier(fn.parent.name)
1313
2500
  ) {
1314
- const method = call.expression.argumentExpression.text
1315
- return SQL_METHODS.has(method)
1316
- ? { method, receiver: call.expression.expression.getText() }
1317
- : undefined
2501
+ return fn.parent.name.text
1318
2502
  }
1319
2503
  return undefined
1320
2504
  }
1321
2505
 
1322
- const SQL_RECEIVER = /(?:^|\.)(?:db|sql|prisma|drizzle|database|conn|connection|client|tx)$/i
2506
+ /** `content` with every string, template and regex literal blanked, line breaks kept. */
2507
+ function literalsBlanked(ts: TypeScriptApi, source: TSApi.SourceFile, content: string): string {
2508
+ const out = content.split("")
2509
+ const visit = (node: TSApi.Node): void => {
2510
+ if (
2511
+ ts.isStringLiteralLike(node) ||
2512
+ ts.isTemplateHead(node) ||
2513
+ ts.isTemplateMiddle(node) ||
2514
+ ts.isTemplateTail(node) ||
2515
+ ts.isRegularExpressionLiteral(node) ||
2516
+ ts.isJsxText(node)
2517
+ ) {
2518
+ for (let i = node.getStart(source); i < node.end; i++) if (out[i] !== "\n") out[i] = " "
2519
+ return
2520
+ }
2521
+ ts.forEachChild(node, visit)
2522
+ }
2523
+ visit(source)
2524
+ return out.join("")
2525
+ }
1323
2526
 
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"
2527
+ /** What silences one interpolated-SQL finding: `// nifra-expect NF-C006: <reason>` (or the
2528
+ * `sql-dynamic` slug) on the flagged line or in the comment block directly above it. The reason after
2529
+ * the colon is mandatory (see {@link commentBlockExpectation}). Scoped to the single statement it sits on. */
2530
+ const SQL_DYNAMIC_EXPECTATION = ["NF-C006", "sql-dynamic"]
1328
2531
 
1329
2532
  /**
1330
2533
  * Scan one file's text for SQL assembled by interpolation. Pure + line-accurate.
@@ -1339,7 +2542,17 @@ export function scanInterpolatedSql(
1339
2542
  ts: TypeScriptApi,
1340
2543
  imports?: SqlImports,
1341
2544
  ): SourceFinding[] {
1342
- const out: SourceFinding[] = []
2545
+ return scanSqlSites(file, content, ts, imports).filter((site) => site.expected === undefined)
2546
+ }
2547
+
2548
+ /** {@link scanInterpolatedSql}, keeping the sites an expectation comment silences, with its reason. */
2549
+ export function scanSqlSites(
2550
+ file: string,
2551
+ content: string,
2552
+ ts: TypeScriptApi,
2553
+ imports?: SqlImports,
2554
+ ): SqlFinding[] {
2555
+ const out: SqlFinding[] = []
1343
2556
  const lines = content.split("\n")
1344
2557
  // Malformed source is already a typecheck failure. Do not turn parser recovery nodes into a second,
1345
2558
  // misleading SQL diagnostic (notably an unterminated template recovered as an interpolation).
@@ -1349,27 +2562,37 @@ export function scanInterpolatedSql(
1349
2562
  // text cannot carry request data, so the shared-projection idiom (`const COLS = "id, name"` …
1350
2563
  // `${COLS}`) stays quiet while mutable, parameter, shadowed, and unprovable names flag as before.
1351
2564
  const scope: SqlScope = { module: sqlModuleScope(ts, file, source), imports }
2565
+ const helpers = statementHelpers(ts, source)
2566
+ // A marker counts only in a comment: one inside a string the call passes is data, not a reason.
2567
+ let markerLines: readonly string[] | undefined
1352
2568
  const visit = (node: TSApi.Node): void => {
1353
2569
  if (ts.isCallExpression(node)) {
1354
- const sink = sqlMethod(ts, node)
1355
- const argument = node.arguments[0]
2570
+ const helper = ts.isIdentifier(node.expression)
2571
+ ? helpers.get(node.expression.text)
2572
+ : undefined
2573
+ const sink = helper?.sink ?? sqlMethod(ts, node)
2574
+ const passed = node.arguments[helper?.position ?? 0]
2575
+ const argument = passed === undefined ? undefined : statementArgument(ts, passed)
1356
2576
  if (sink !== undefined && argument !== undefined) {
1357
2577
  const shape = sqlExpressionShape(ts, argument, scope)
2578
+ // A helper's own hatch call already answers for passing it a variable.
1358
2579
  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) {
2580
+ helper === undefined &&
2581
+ sink.receiver !== undefined &&
2582
+ ALWAYS_UNSAFE.has(sink.method) &&
2583
+ !shape.literal &&
2584
+ !shape.parameterizedTag
2585
+ if (unsafeEscape || isAssembledSql(sink, shape)) {
1365
2586
  const index = node.getStart(source)
1366
2587
  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
2588
+ // Sanctioned escape hatch for genuinely-dynamic-but-bound SQL (an allowlisted identifier,
2589
+ // placeholders numbered from data the scan cannot prove numeric). The reason is mandatory: a bare `// nifra-expect
2590
+ // NF-C006` with no reason after the colon does NOT silence, so the hatch always leaves a
1370
2591
  // 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() })
2592
+ markerLines ??= literalsBlanked(ts, source, content).split("\n")
2593
+ const reason = commentBlockExpectation(markerLines, line, SQL_DYNAMIC_EXPECTATION)?.reason
2594
+ const site = { file, line, snippet: (lines[line - 1] ?? "").trim() }
2595
+ out.push(reason === undefined ? site : { ...site, expected: reason })
1373
2596
  }
1374
2597
  }
1375
2598
  }
@@ -1656,7 +2879,7 @@ export function scanServerOnlyImports(
1656
2879
  // Inline `import { type X } from "…"` is erased just like `import type` and is not an edge; a parse
1657
2880
  // failure keeps the lexical edge so the security rule fails closed.
1658
2881
  for (const { specifier, index } of importEdges(content, facts, file)) {
1659
- if (!SERVER_ONLY.test(specifier)) continue
2882
+ if (!isServerOnly(specifier)) continue
1660
2883
  const line = lineAt(content, index)
1661
2884
  out.push({ file, line, snippet: (lines[line - 1] ?? "").trim(), specifier })
1662
2885
  }
@@ -1673,23 +2896,24 @@ export function scanServerOnlyImports(
1673
2896
  // that can't be precisely resolved (a bare pkg, a tsconfig path alias) falls back to the direct edge.
1674
2897
  // ---------------------------------------------------------------------------------------------------
1675
2898
 
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
2899
  // The retired `*.server` convention. Builds refuse such a module in browser code rather than empty it.
1682
2900
  const LEGACY_SERVER_FILE = /\.server(\.[cm]?[jt]sx?)?$/
1683
2901
  // The explicit poison-import marker (`@nifrajs/web/backend-only`, or its retired `server-only` name) - a
1684
2902
  // 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). */
2903
+ const BACKEND_ONLY_MARKER_IMPORT = /import\s+["']@nifrajs\/web\/(?:backend|server)-only["']/g
2904
+
2905
+ /** Whether `content` imports the backend-only marker in code. A mention in a comment or a string,
2906
+ * such as a note on how to opt in, marks nothing. */
2907
+ function hasBackendOnlyMarker(content: string): boolean {
2908
+ if (!content.includes("-only")) return false
2909
+ const positions = codePositionMask(content)
2910
+ for (const match of content.matchAll(BACKEND_ONLY_MARKER_IMPORT)) {
2911
+ if (positions[match.index] !== " ") return true
2912
+ }
2913
+ return false
2914
+ }
2915
+
2916
+ /** A relative module specifier (`./x`, `../y`) - the only kind the SQL import lookup follows. */
1693
2917
  const isRelativeSpecifier = (spec: string): boolean =>
1694
2918
  spec.startsWith("./") || spec.startsWith("../")
1695
2919
 
@@ -1761,19 +2985,39 @@ function dynamicImportEdges(
1761
2985
  return edges
1762
2986
  }
1763
2987
 
1764
- /** Every import a module's runtime code makes - static and literal dynamic - in source order. Pure. */
2988
+ /** The literal `require("x")` calls in executable code. Pure. */
2989
+ function requireEdges(
2990
+ content: string,
2991
+ positions: string,
2992
+ ): Array<{ specifier: string; index: number }> {
2993
+ const edges: Array<{ specifier: string; index: number }> = []
2994
+ const re = new RegExp(REQUIRE_CALL.source, REQUIRE_CALL.flags)
2995
+ for (let m = re.exec(content); m !== null; m = re.exec(content)) {
2996
+ if (positions[m.index] === " ") continue // inside a comment or a string
2997
+ const specifier = m[2]
2998
+ if (specifier === undefined) continue
2999
+ if (TYPE_ALIAS_BEFORE.test(content.slice(Math.max(0, m.index - 80), m.index))) continue
3000
+ edges.push({ specifier, index: m.index })
3001
+ }
3002
+ return edges
3003
+ }
3004
+
3005
+ /** Every import a module's runtime code makes - static, literal dynamic and `require` - in source
3006
+ * order. Pure. */
1765
3007
  function importEdges(
1766
3008
  content: string,
1767
3009
  facts?: SourceFacts,
1768
3010
  file?: string,
1769
3011
  ): Array<{ specifier: string; index: number }> {
1770
- // Without an `import(` there is nothing for the dynamic pass to find; skip its regex.
1771
3012
  const positions = codePositionMask(content)
1772
3013
  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
- )
3014
+ // Without an `import(` or a `require` there is nothing for the other passes to find; skip them.
3015
+ const dynamic = content.includes("import(") || /import\s+\(/.test(content)
3016
+ const required = content.includes("require")
3017
+ if (!dynamic && !required) return edges
3018
+ if (dynamic) edges.push(...dynamicImportEdges(content, facts, file, positions))
3019
+ if (required) edges.push(...requireEdges(content, positions))
3020
+ return edges.sort((a, b) => a.index - b.index)
1777
3021
  }
1778
3022
 
1779
3023
  /** The runtime imports of a module with the line each sits on, in source order. Pure. */
@@ -1796,7 +3040,7 @@ export function parseImports(content: string, facts?: SourceFacts, file?: string
1796
3040
  * server-only package), or `undefined` if it isn't a by-name sink. Pure - the label is the specifier
1797
3041
  * itself (it's already the actionable name). */
1798
3042
  export function directSinkSpecifier(spec: string): string | undefined {
1799
- return SERVER_ONLY_SINK.test(spec) ? spec : undefined
3043
+ return isServerOnlySink(spec) ? spec : undefined
1800
3044
  }
1801
3045
 
1802
3046
  /** One transitive server-only finding: the route module, the route's offending top-level import (the
@@ -1805,28 +3049,150 @@ export interface TransitiveServerImportFinding extends ServerImportFinding {
1805
3049
  /** `[routeFile, ...as-written specifiers…, sink]` - the shortest path the walk found. Length 2 means
1806
3050
  * the route imports the sink directly (same as the regex scan's direct edge). */
1807
3051
  readonly chain: readonly string[]
3052
+ /** The project files the chain runs through, as `resolve` named them: the route, each module the
3053
+ * walk entered, and the sink when it is a project file. A change to any of them can make the chain. */
3054
+ readonly modules: readonly string[]
1808
3055
  /** True when a precise transitive resolve wasn't possible for the first hop (a bare pkg / path alias),
1809
3056
  * so the chain is the honest direct edge rather than a fabricated deeper path. */
1810
3057
  readonly fallback: boolean
1811
3058
  }
1812
3059
 
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`. */
3060
+ /** A resolver from `(fromFile, specifier)` to a project module path, or `undefined` when it names no
3061
+ * project file (a package, a missing file). Abstracted so the BFS is unit-testable with a fake graph (no
3062
+ * real fs). The production resolver follows relative paths, tsconfig `paths` and package.json `imports`. */
1816
3063
  export type ModuleResolver = (fromFile: string, specifier: string) => string | undefined
1817
3064
  /** Reads a resolved module's source, or `undefined` if unreadable. Abstracted for the same reason. */
1818
3065
  export type ModuleReader = (absPath: string) => string | undefined
1819
3066
 
3067
+ /** What a server-only chain search reads the project through. */
3068
+ export interface ServerOnlyChainSource {
3069
+ readonly resolve: ModuleResolver
3070
+ readonly read: ModuleReader
3071
+ readonly facts?: SourceFacts | undefined
3072
+ readonly componentReceipts?: ReadonlySet<string>
3073
+ /** Whether a bare specifier `resolve` leaves unanswered names an installed package that declares
3074
+ * `"nifra": { "environment": "backend" }`, which the browser build refuses. */
3075
+ readonly backendPackage?: (fromFile: string, specifier: string) => boolean
3076
+ }
3077
+
3078
+ /** A chain from a module to a sink: `[start, ...as-written specifiers, sink]`, and the project files
3079
+ * it runs through (see {@link TransitiveServerImportFinding.modules}). */
3080
+ interface ChainWalk {
3081
+ readonly chain: readonly string[]
3082
+ readonly modules: readonly string[]
3083
+ }
3084
+
3085
+ /** The chain searches of one check, sharing what they learn about each module. */
3086
+ interface ChainGraph {
3087
+ /** The shortest chain from `start` to a sink. */
3088
+ walk(start: string, content: string): ChainWalk | undefined
3089
+ read(abs: string): string | undefined
3090
+ resolve(from: string, specifier: string): string | undefined
3091
+ backendPackage(from: string, specifier: string): boolean
3092
+ readonly componentReceipts: ReadonlySet<string>
3093
+ readonly facts: SourceFacts | undefined
3094
+ }
3095
+
1820
3096
  /**
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.
3097
+ * Every module is read, parsed and resolved at most once, and the chain from a module is searched at
3098
+ * most once, however many routes reach it: per-route searches repeated all of that for every route
3099
+ * that shared a module, which made a check quadratic in the size of a shared graph.
3100
+ */
3101
+ function chainGraph(source: ServerOnlyChainSource): ChainGraph {
3102
+ const { facts } = source
3103
+ const componentReceipts = source.componentReceipts ?? new Set<string>()
3104
+ const contents = new Map<string, string | undefined>()
3105
+ const imports = new Map<string, readonly string[]>()
3106
+ const resolutions = new Map<string, string | undefined>()
3107
+ const walks = new Map<string, ChainWalk | undefined>()
3108
+ const packages = new Map<string, boolean>()
3109
+ const read = (abs: string): string | undefined => {
3110
+ if (!contents.has(abs)) contents.set(abs, source.read(abs))
3111
+ return contents.get(abs)
3112
+ }
3113
+ const resolve = (from: string, specifier: string): string | undefined => {
3114
+ const key = `${from}\0${specifier}`
3115
+ if (!resolutions.has(key)) resolutions.set(key, source.resolve(from, specifier))
3116
+ return resolutions.get(key)
3117
+ }
3118
+ const backendPackage = (from: string, specifier: string): boolean => {
3119
+ if (source.backendPackage === undefined || /^[./#]/.test(specifier)) return false
3120
+ const key = `${from}\0${specifier}`
3121
+ let found = packages.get(key)
3122
+ if (found === undefined) {
3123
+ found = source.backendPackage(from, specifier)
3124
+ packages.set(key, found)
3125
+ }
3126
+ return found
3127
+ }
3128
+ const importsOf = (abs: string, content: string): readonly string[] => {
3129
+ let found = imports.get(abs)
3130
+ if (found === undefined) {
3131
+ found = parseImports(content, facts, abs)
3132
+ imports.set(abs, found)
3133
+ }
3134
+ return found
3135
+ }
3136
+ /**
3137
+ * Breadth-first, so the first sink found is the nearest. A node's edges are its static and literal
3138
+ * dynamic imports; an edge is followed only into a project file `resolve` names, an alias included,
3139
+ * never into node_modules. A by-name sink import (`node:fs`, `postgres`), backend code, or a module
3140
+ * importing the `backend-only` marker ends the chain; a `*.fn.ts` module ships as its stub and is not
3141
+ * entered. The visited set keeps it cycle-free; there is no depth or size cap to cut a chain short.
3142
+ */
3143
+ const search = (start: string, startContent: string): ChainWalk | undefined => {
3144
+ interface Node extends ChainWalk {
3145
+ readonly abs: string
3146
+ readonly content: string
3147
+ }
3148
+ const seen = new Set<string>([start])
3149
+ let frontier: Node[] = [{ abs: start, content: startContent, chain: [start], modules: [start] }]
3150
+ while (frontier.length > 0) {
3151
+ const next: Node[] = []
3152
+ for (const node of frontier) {
3153
+ const { chain, modules } = node
3154
+ for (const spec of importsOf(node.abs, node.content)) {
3155
+ const sink = directSinkSpecifier(spec)
3156
+ if (sink !== undefined) return { chain: [...chain, sink], modules }
3157
+ const abs = resolve(node.abs, spec)
3158
+ if (abs === undefined) {
3159
+ if (backendPackage(node.abs, spec)) return { chain: [...chain, spec], modules }
3160
+ continue
3161
+ }
3162
+ if (seen.has(abs)) continue
3163
+ if (componentReceipts.has(abs)) continue
3164
+ if (isBackendModule(abs)) return { chain: [...chain, spec], modules: [...modules, abs] }
3165
+ if (SERVER_FN_FILE.test(abs)) continue
3166
+ const content = read(abs)
3167
+ if (content === undefined) continue
3168
+ if (hasBackendOnlyMarker(content)) {
3169
+ return { chain: [...chain, spec], modules: [...modules, abs] }
3170
+ }
3171
+ seen.add(abs)
3172
+ next.push({ abs, content, chain: [...chain, spec], modules: [...modules, abs] })
3173
+ }
3174
+ }
3175
+ frontier = next
3176
+ }
3177
+ return undefined
3178
+ }
3179
+ return {
3180
+ walk(start, content) {
3181
+ if (!walks.has(start)) walks.set(start, search(start, content))
3182
+ return walks.get(start)
3183
+ },
3184
+ read,
3185
+ resolve,
3186
+ backendPackage,
3187
+ componentReceipts,
3188
+ facts,
3189
+ }
3190
+ }
3191
+
3192
+ /**
3193
+ * The shortest import chain from `routeFile` to a server-only sink, `[routeFile, ...as-written
3194
+ * specifiers, sink]`, or `undefined` when none is reachable. Pure given `resolve`/`read`, so a fake
3195
+ * graph tests it.
1830
3196
  */
1831
3197
  export function walkServerOnlyChain(
1832
3198
  routeFile: string,
@@ -1834,49 +3200,25 @@ export function walkServerOnlyChain(
1834
3200
  resolve: ModuleResolver,
1835
3201
  read: ModuleReader,
1836
3202
  facts?: SourceFacts,
3203
+ componentReceipts: ReadonlySet<string> = new Set(),
1837
3204
  ): 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
3205
+ return chainGraph({
3206
+ resolve,
3207
+ read,
3208
+ facts,
3209
+ componentReceipts,
3210
+ }).walk(routeFile, routeContent)?.chain
3211
+ }
3212
+
3213
+ /**
3214
+ * The server-only chain finder for one check: called per browser module, it returns that module's
3215
+ * findings, reusing every module and chain an earlier call already searched.
3216
+ */
3217
+ export function createServerOnlyChains(
3218
+ source: ServerOnlyChainSource,
3219
+ ): (file: string, content: string, browserRoot?: boolean) => TransitiveServerImportFinding[] {
3220
+ const graph = chainGraph(source)
3221
+ return (file, content, browserRoot = false) => serverOnlyChains(graph, file, content, browserRoot)
1880
3222
  }
1881
3223
 
1882
3224
  /**
@@ -1885,7 +3227,9 @@ export function walkServerOnlyChain(
1885
3227
  * finding carries the full chain; otherwise - when the first hop is itself a direct by-name sink the
1886
3228
  * regex scan already flags, or when a relative import can't be resolved - it falls back to the DIRECT
1887
3229
  * 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 `[]`.
3230
+ * de-duplicated to the shortest chain per first-hop specifier. A file that is not browser source
3231
+ * yields `[]`, unless `browserRoot` says the browser bundle loads it anyway (an app's `clientModule`).
3232
+ * One check should use {@link createServerOnlyChains}, which shares the work across modules.
1889
3233
  */
1890
3234
  export function resolveServerOnlyChains(
1891
3235
  file: string,
@@ -1893,71 +3237,76 @@ export function resolveServerOnlyChains(
1893
3237
  resolve: ModuleResolver,
1894
3238
  read: ModuleReader,
1895
3239
  facts?: SourceFacts,
3240
+ componentReceipts: ReadonlySet<string> = new Set(),
3241
+ browserRoot = false,
1896
3242
  ): TransitiveServerImportFinding[] {
1897
- if (!isBrowserSource(file)) return []
3243
+ return createServerOnlyChains({
3244
+ resolve,
3245
+ read,
3246
+ facts,
3247
+ componentReceipts,
3248
+ })(file, content, browserRoot)
3249
+ }
3250
+
3251
+ function serverOnlyChains(
3252
+ graph: ChainGraph,
3253
+ file: string,
3254
+ content: string,
3255
+ browserRoot: boolean,
3256
+ ): TransitiveServerImportFinding[] {
3257
+ if (!browserRoot && !isBrowserSource(file)) return []
1898
3258
  const lines = content.split("\n")
1899
3259
  const out: TransitiveServerImportFinding[] = []
1900
3260
  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)) {
3261
+ for (const { specifier, index } of importEdges(content, graph.facts, file)) {
1905
3262
  if (flaggedSpecifiers.has(specifier)) continue
1906
3263
  const line = lineAt(content, index)
1907
3264
  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) {
3265
+ const flag = (
3266
+ chain: readonly string[],
3267
+ fallback: boolean,
3268
+ modules: readonly string[] = [file],
3269
+ ): void => {
1911
3270
  flaggedSpecifiers.add(specifier)
1912
- out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: false })
3271
+ out.push({ file, line, snippet, specifier, chain, modules, fallback })
3272
+ }
3273
+ // (1) A direct by-name sink (`node:fs`, `postgres`): the route imports it itself.
3274
+ if (directSinkSpecifier(specifier) !== undefined) {
3275
+ flag([file, specifier], false)
1913
3276
  continue
1914
3277
  }
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
- }
3278
+ // (2) A project import, relative or through an alias: walk from the resolved dependency and, if
3279
+ // it reaches a sink, report the full chain rooted at THIS route's import line.
3280
+ const abs = graph.resolve(file, specifier)
3281
+ if (abs === undefined) {
3282
+ // An installed package that declares itself backend is a sink. Any other name no project file
3283
+ // answers to can't be called server-only, except the `../db` convention, reported as the direct
3284
+ // edge with `fallback` saying the resolve was impossible.
3285
+ if (graph.backendPackage(file, specifier)) flag([file, specifier], false)
3286
+ else if (isServerOnly(specifier)) flag([file, specifier], true)
3287
+ continue
3288
+ }
3289
+ if (graph.componentReceipts.has(abs)) continue
3290
+ // A backend first hop and a marked module are sinks; a `*.fn.ts` hop ships as its stub.
3291
+ if (isBackendModule(abs)) {
3292
+ flag([file, specifier], false, [file, abs])
3293
+ continue
1960
3294
  }
3295
+ if (SERVER_FN_FILE.test(abs)) continue
3296
+ const depContent = graph.read(abs)
3297
+ if (depContent === undefined) continue
3298
+ if (hasBackendOnlyMarker(depContent)) {
3299
+ flag([file, specifier], false, [file, abs])
3300
+ continue
3301
+ }
3302
+ const sub = graph.walk(abs, depContent)
3303
+ if (sub !== undefined) {
3304
+ // `sub` starts at the resolved dependency; the route names it by its as-written specifier.
3305
+ flag([file, specifier, ...sub.chain.slice(1)], false, [file, ...sub.modules])
3306
+ continue
3307
+ }
3308
+ // (3) The `../db` convention with no deeper sink found: still surface the direct edge.
3309
+ if (isServerOnly(specifier)) flag([file, specifier], true)
1961
3310
  }
1962
3311
  return out
1963
3312
  }
@@ -1990,23 +3339,19 @@ export function scanResponseRoutes(
1990
3339
  if (source !== undefined && facts.isResponseSyntaxAt(source, m.index) !== true) continue
1991
3340
  }
1992
3341
  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() })
3342
+ // A `// nifra-expect NF-C007` comment (or the `raw-response` slug) marks an intentional raw Response
3343
+ // (a file/redirect that can't be a typed route), silencing this advisory with or without a reason.
3344
+ // It counts on the return line or in the comment block directly above, so a TRAILING comment on one
3345
+ // route's return line can't leak down onto the next route. `stripComments` blanks it in `code` (so it
3346
+ // never affects the match); the raw `lines` still carry it.
3347
+ if (commentBlockExpectation(lines, line, RAW_RESPONSE_EXPECTATION) !== undefined) continue
3348
+ out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() })
2004
3349
  }
2005
3350
  return out
2006
3351
  }
2007
3352
 
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"
3353
+ /** What suppresses the {@link scanResponseRoutes} advisory for an intentional raw Response. */
3354
+ const RAW_RESPONSE_EXPECTATION = ["NF-C007", "raw-response"]
2010
3355
 
2011
3356
  /** Walk the project's `.ts`/`.tsx` source (skipping deps and build output), calling `visit` per file.
2012
3357
  * Exported so `nifra doctor` ({@link ./doctor.ts}) scans the same source surface as `nifra check`.
@@ -2054,22 +3399,69 @@ export async function walkSource(
2054
3399
  readonly includeTests?: boolean
2055
3400
  /** Additional project-relative paths to exclude from this scan. */
2056
3401
  readonly ignore?: (relativePath: string) => boolean
3402
+ /**
3403
+ * Also visit the other files a build loads: every `.jsx`, `.svelte` and `.vue` file, and `.js`
3404
+ * files in a zone. A `.svelte`/`.vue` file is visited as {@link componentScripts}.
3405
+ */
3406
+ readonly scripts?: boolean
2057
3407
  } = {},
2058
3408
  ): Promise<void> {
2059
3409
  const skip = opts.includeTests === true ? IGNORED_DIR : IGNORED
3410
+ const pattern =
3411
+ opts.scripts === true
3412
+ ? "**/*.{ts,tsx,mts,cts,jsx,js,mjs,cjs,svelte,vue}"
3413
+ : "**/*.{ts,tsx,mts,cts}"
2060
3414
  // List candidates first (cheap - no reads), drop the built-in ignores, then exclude gitignored paths in
2061
3415
  // one batch before the (expensive) reads. So a gitignored generated/build tree is never read or scanned.
2062
3416
  const rels: string[] = []
2063
- for await (const rawRel of new Glob("**/*.{ts,tsx,mts,cts}").scan({ cwd, dot: false })) {
3417
+ for await (const rawRel of new Glob(pattern).scan({ cwd, dot: false })) {
2064
3418
  const rel = normalizeProjectPath(rawRel)
3419
+ // A `.js` file outside a zone is tooling (a bundler config, a script, a vendored asset), not app code.
3420
+ if (PLAIN_SCRIPT_FILE.test(rel) && !ZONED_PATH.test(rel)) continue
2065
3421
  if (!skip.test(rel) && (opts.ignore === undefined || !opts.ignore(rel))) rels.push(rel)
2066
3422
  }
2067
3423
  const ignored = await gitIgnored(cwd, rels)
2068
3424
  // Glob order is the filesystem's, so findings would come out in a different order on each platform.
2069
3425
  for (const rel of rels.sort(codeUnitOrder)) {
2070
3426
  if (ignored.has(rel)) continue
2071
- visit(rel, await Bun.file(join(cwd, rel)).text())
3427
+ const text = await Bun.file(join(cwd, rel)).text()
3428
+ visit(rel, COMPONENT_FILE.test(rel) ? componentScripts(text) : text)
3429
+ }
3430
+ }
3431
+
3432
+ const PLAIN_SCRIPT_FILE = /\.[cm]?js$/
3433
+ const ZONED_PATH =
3434
+ /(^|\/)(?:routes|frontend|shared|backend)\/|\.(?:frontend|shared|backend|fn)\.[^/]+$/
3435
+ /** A single-file component: its `<script>` blocks are the code a build compiles. */
3436
+ export const COMPONENT_FILE = /\.(?:svelte|vue)$/
3437
+
3438
+ /**
3439
+ * A `.svelte` or `.vue` file as the source scanners read it: the text of its `<script>` blocks where
3440
+ * they stand, and every other character blanked to a space with line breaks kept, so a finding's line
3441
+ * and column are the file's own. An unclosed `<script>` runs to the end of the file. Pure.
3442
+ */
3443
+ export function componentScripts(text: string): string {
3444
+ const blank = (part: string): string => part.replace(/[^\n]/g, " ")
3445
+ let out = ""
3446
+ let at = 0
3447
+ for (;;) {
3448
+ const open = text.indexOf("<script", at)
3449
+ if (open === -1) break
3450
+ const close = text.indexOf(">", open)
3451
+ if (close === -1) break
3452
+ // `<scripts>` or `<script-x>` is another element.
3453
+ if (!/[\s>]/.test(text[open + 7] ?? "")) {
3454
+ out += blank(text.slice(at, close + 1))
3455
+ at = close + 1
3456
+ continue
3457
+ }
3458
+ const end = text.indexOf("</script", close + 1)
3459
+ const stop = end === -1 ? text.length : end
3460
+ out += blank(text.slice(at, close + 1)) + text.slice(close + 1, stop)
3461
+ at = stop
3462
+ if (end === -1) break
2072
3463
  }
3464
+ return out + blank(text.slice(at))
2073
3465
  }
2074
3466
 
2075
3467
  const bySite = (a: SourceFinding, b: SourceFinding): number =>