@nifrajs/cli 3.5.0 → 4.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 (403) hide show
  1. package/README.md +42 -3
  2. package/dist/app-files.d.ts +11 -0
  3. package/dist/app-files.d.ts.map +1 -0
  4. package/dist/app-files.js +34 -0
  5. package/dist/app-files.js.map +1 -0
  6. package/dist/assure-hydration.d.ts +7 -1
  7. package/dist/assure-hydration.d.ts.map +1 -1
  8. package/dist/assure-hydration.js +128 -43
  9. package/dist/assure-hydration.js.map +1 -1
  10. package/dist/assure.d.ts.map +1 -1
  11. package/dist/assure.js +16 -2
  12. package/dist/assure.js.map +1 -1
  13. package/dist/capabilities-tool.d.ts +5 -1
  14. package/dist/capabilities-tool.d.ts.map +1 -1
  15. package/dist/capabilities-tool.js +32 -10
  16. package/dist/capabilities-tool.js.map +1 -1
  17. package/dist/cdn-check.d.ts +41 -0
  18. package/dist/cdn-check.d.ts.map +1 -0
  19. package/dist/cdn-check.js +207 -0
  20. package/dist/cdn-check.js.map +1 -0
  21. package/dist/check-diagnostics.d.ts +4 -0
  22. package/dist/check-diagnostics.d.ts.map +1 -1
  23. package/dist/check-diagnostics.js +13 -0
  24. package/dist/check-diagnostics.js.map +1 -1
  25. package/dist/check-scan.d.ts +49 -7
  26. package/dist/check-scan.d.ts.map +1 -1
  27. package/dist/check-scan.js +291 -62
  28. package/dist/check-scan.js.map +1 -1
  29. package/dist/check.d.ts.map +1 -1
  30. package/dist/check.js +99 -63
  31. package/dist/check.js.map +1 -1
  32. package/dist/cli.d.ts +42 -16
  33. package/dist/cli.d.ts.map +1 -1
  34. package/dist/cli.js +262 -82
  35. package/dist/cli.js.map +1 -1
  36. package/dist/command-catalog.d.ts +57 -5
  37. package/dist/command-catalog.d.ts.map +1 -1
  38. package/dist/command-catalog.js +270 -26
  39. package/dist/command-catalog.js.map +1 -1
  40. package/dist/config-target.d.ts +8 -0
  41. package/dist/config-target.d.ts.map +1 -0
  42. package/dist/config-target.js +45 -0
  43. package/dist/config-target.js.map +1 -0
  44. package/dist/contracts.d.ts.map +1 -1
  45. package/dist/contracts.js +52 -13
  46. package/dist/contracts.js.map +1 -1
  47. package/dist/db-audit.d.ts +37 -0
  48. package/dist/db-audit.d.ts.map +1 -0
  49. package/dist/db-audit.js +99 -0
  50. package/dist/db-audit.js.map +1 -0
  51. package/dist/db-child.d.ts +64 -0
  52. package/dist/db-child.d.ts.map +1 -0
  53. package/dist/db-child.js +213 -0
  54. package/dist/db-child.js.map +1 -0
  55. package/dist/db-config.d.ts +60 -0
  56. package/dist/db-config.d.ts.map +1 -0
  57. package/dist/db-config.js +176 -0
  58. package/dist/db-config.js.map +1 -0
  59. package/dist/db-tool.d.ts +84 -0
  60. package/dist/db-tool.d.ts.map +1 -0
  61. package/dist/db-tool.js +537 -0
  62. package/dist/db-tool.js.map +1 -0
  63. package/dist/dev-bun-config.d.ts +20 -13
  64. package/dist/dev-bun-config.d.ts.map +1 -1
  65. package/dist/dev-bun-config.js +98 -41
  66. package/dist/dev-bun-config.js.map +1 -1
  67. package/dist/dev-feed-tool.d.ts +106 -0
  68. package/dist/dev-feed-tool.d.ts.map +1 -0
  69. package/dist/dev-feed-tool.js +713 -0
  70. package/dist/dev-feed-tool.js.map +1 -0
  71. package/dist/dev-server-client.d.ts +67 -0
  72. package/dist/dev-server-client.d.ts.map +1 -0
  73. package/dist/dev-server-client.js +195 -0
  74. package/dist/dev-server-client.js.map +1 -0
  75. package/dist/diff-tool.d.ts +3 -3
  76. package/dist/diff-tool.d.ts.map +1 -1
  77. package/dist/diff-tool.js +6 -5
  78. package/dist/diff-tool.js.map +1 -1
  79. package/dist/docs-search.d.ts.map +1 -1
  80. package/dist/docs-search.js +12 -4
  81. package/dist/docs-search.js.map +1 -1
  82. package/dist/doctor.d.ts +17 -0
  83. package/dist/doctor.d.ts.map +1 -1
  84. package/dist/doctor.js +76 -15
  85. package/dist/doctor.js.map +1 -1
  86. package/dist/env-file.d.ts +15 -0
  87. package/dist/env-file.d.ts.map +1 -1
  88. package/dist/env-file.js +73 -1
  89. package/dist/env-file.js.map +1 -1
  90. package/dist/fix-recipes.d.ts.map +1 -1
  91. package/dist/fix-recipes.js +41 -21
  92. package/dist/fix-recipes.js.map +1 -1
  93. package/dist/frontend-guidance.js +5 -5
  94. package/dist/frontend-guidance.js.map +1 -1
  95. package/dist/i18n-check.d.ts +16 -0
  96. package/dist/i18n-check.d.ts.map +1 -0
  97. package/dist/i18n-check.js +154 -0
  98. package/dist/i18n-check.js.map +1 -0
  99. package/dist/init-agents.d.ts +49 -13
  100. package/dist/init-agents.d.ts.map +1 -1
  101. package/dist/init-agents.js +239 -34
  102. package/dist/init-agents.js.map +1 -1
  103. package/dist/internal/code-unit-order.d.ts +4 -0
  104. package/dist/internal/code-unit-order.d.ts.map +1 -0
  105. package/dist/internal/code-unit-order.js +6 -0
  106. package/dist/internal/code-unit-order.js.map +1 -0
  107. package/dist/internal/script-kind.d.ts +7 -0
  108. package/dist/internal/script-kind.d.ts.map +1 -0
  109. package/dist/internal/script-kind.js +12 -0
  110. package/dist/internal/script-kind.js.map +1 -0
  111. package/dist/internal/source-facts.d.ts +5 -0
  112. package/dist/internal/source-facts.d.ts.map +1 -1
  113. package/dist/internal/source-facts.js +61 -5
  114. package/dist/internal/source-facts.js.map +1 -1
  115. package/dist/internal/typescript-import.d.ts +49 -24
  116. package/dist/internal/typescript-import.d.ts.map +1 -1
  117. package/dist/internal/typescript-import.js +507 -67
  118. package/dist/internal/typescript-import.js.map +1 -1
  119. package/dist/introspect.d.ts +5 -1
  120. package/dist/introspect.d.ts.map +1 -1
  121. package/dist/introspect.js +92 -27
  122. package/dist/introspect.js.map +1 -1
  123. package/dist/learn.js +3 -3
  124. package/dist/learn.js.map +1 -1
  125. package/dist/levels-tool.d.ts +10 -0
  126. package/dist/levels-tool.d.ts.map +1 -1
  127. package/dist/levels-tool.js +33 -3
  128. package/dist/levels-tool.js.map +1 -1
  129. package/dist/load.d.ts +67 -17
  130. package/dist/load.d.ts.map +1 -1
  131. package/dist/load.js +78 -18
  132. package/dist/load.js.map +1 -1
  133. package/dist/manifest-tool.d.ts.map +1 -1
  134. package/dist/manifest-tool.js +3 -2
  135. package/dist/manifest-tool.js.map +1 -1
  136. package/dist/mcp-app-tool.d.ts.map +1 -1
  137. package/dist/mcp-app-tool.js +2 -0
  138. package/dist/mcp-app-tool.js.map +1 -1
  139. package/dist/mcp-context.js +1 -1
  140. package/dist/mcp-context.js.map +1 -1
  141. package/dist/mcp-delegate.d.ts +57 -0
  142. package/dist/mcp-delegate.d.ts.map +1 -0
  143. package/dist/mcp-delegate.js +130 -0
  144. package/dist/mcp-delegate.js.map +1 -0
  145. package/dist/mcp-docs-tools.d.ts.map +1 -1
  146. package/dist/mcp-docs-tools.js +23 -5
  147. package/dist/mcp-docs-tools.js.map +1 -1
  148. package/dist/mcp-exec.d.ts +2 -0
  149. package/dist/mcp-exec.d.ts.map +1 -1
  150. package/dist/mcp-exec.js +132 -108
  151. package/dist/mcp-exec.js.map +1 -1
  152. package/dist/mcp-http.d.ts.map +1 -1
  153. package/dist/mcp-http.js +17 -5
  154. package/dist/mcp-http.js.map +1 -1
  155. package/dist/mcp-io.d.ts +14 -0
  156. package/dist/mcp-io.d.ts.map +1 -1
  157. package/dist/mcp-io.js +45 -0
  158. package/dist/mcp-io.js.map +1 -1
  159. package/dist/mcp-isolate.d.ts +48 -0
  160. package/dist/mcp-isolate.d.ts.map +1 -0
  161. package/dist/mcp-isolate.js +222 -0
  162. package/dist/mcp-isolate.js.map +1 -0
  163. package/dist/mcp-project-child.d.ts +74 -0
  164. package/dist/mcp-project-child.d.ts.map +1 -0
  165. package/dist/mcp-project-child.js +130 -0
  166. package/dist/mcp-project-child.js.map +1 -0
  167. package/dist/mcp-render.js +2 -2
  168. package/dist/mcp-render.js.map +1 -1
  169. package/dist/mcp-root.d.ts +37 -9
  170. package/dist/mcp-root.d.ts.map +1 -1
  171. package/dist/mcp-root.js +110 -23
  172. package/dist/mcp-root.js.map +1 -1
  173. package/dist/mcp-run.d.ts +18 -1
  174. package/dist/mcp-run.d.ts.map +1 -1
  175. package/dist/mcp-run.js +82 -9
  176. package/dist/mcp-run.js.map +1 -1
  177. package/dist/mcp-ws.js +4 -1
  178. package/dist/mcp-ws.js.map +1 -1
  179. package/dist/mcp.d.ts +16 -3
  180. package/dist/mcp.d.ts.map +1 -1
  181. package/dist/mcp.js +81 -53
  182. package/dist/mcp.js.map +1 -1
  183. package/dist/migrate-layout.d.ts +44 -0
  184. package/dist/migrate-layout.d.ts.map +1 -0
  185. package/dist/migrate-layout.js +982 -0
  186. package/dist/migrate-layout.js.map +1 -0
  187. package/dist/openapi-tool.d.ts +2 -2
  188. package/dist/openapi-tool.d.ts.map +1 -1
  189. package/dist/openapi-tool.js.map +1 -1
  190. package/dist/openapi-types.d.ts +2 -1
  191. package/dist/openapi-types.d.ts.map +1 -1
  192. package/dist/openapi-types.js +240 -12
  193. package/dist/openapi-types.js.map +1 -1
  194. package/dist/pipeline-report.d.ts +3 -2
  195. package/dist/pipeline-report.d.ts.map +1 -1
  196. package/dist/pipeline-report.js +19 -16
  197. package/dist/pipeline-report.js.map +1 -1
  198. package/dist/port.d.ts +5 -4
  199. package/dist/port.d.ts.map +1 -1
  200. package/dist/port.js +46 -29
  201. package/dist/port.js.map +1 -1
  202. package/dist/project-facts.d.ts +5 -0
  203. package/dist/project-facts.d.ts.map +1 -1
  204. package/dist/project-facts.js.map +1 -1
  205. package/dist/recipes/4.0.0.d.ts +9 -0
  206. package/dist/recipes/4.0.0.d.ts.map +1 -0
  207. package/dist/recipes/4.0.0.js +34 -0
  208. package/dist/recipes/4.0.0.js.map +1 -0
  209. package/dist/recipes/index.d.ts.map +1 -1
  210. package/dist/recipes/index.js +2 -0
  211. package/dist/recipes/index.js.map +1 -1
  212. package/dist/release-verification.d.ts +11 -1
  213. package/dist/release-verification.d.ts.map +1 -1
  214. package/dist/release-verification.js +47 -4
  215. package/dist/release-verification.js.map +1 -1
  216. package/dist/review.d.ts.map +1 -1
  217. package/dist/review.js +12 -8
  218. package/dist/review.js.map +1 -1
  219. package/dist/route-types.d.ts +28 -0
  220. package/dist/route-types.d.ts.map +1 -0
  221. package/dist/route-types.js +106 -0
  222. package/dist/route-types.js.map +1 -0
  223. package/dist/rules/codes.d.ts +9 -0
  224. package/dist/rules/codes.d.ts.map +1 -1
  225. package/dist/rules/codes.js +9 -0
  226. package/dist/rules/codes.js.map +1 -1
  227. package/dist/rules/data-guard.d.ts +12 -0
  228. package/dist/rules/data-guard.d.ts.map +1 -0
  229. package/dist/rules/data-guard.js +213 -0
  230. package/dist/rules/data-guard.js.map +1 -0
  231. package/dist/rules/index.d.ts +3 -0
  232. package/dist/rules/index.d.ts.map +1 -1
  233. package/dist/rules/index.js +17 -5
  234. package/dist/rules/index.js.map +1 -1
  235. package/dist/rules/islands.d.ts.map +1 -1
  236. package/dist/rules/islands.js +7 -4
  237. package/dist/rules/islands.js.map +1 -1
  238. package/dist/rules/legacy.d.ts.map +1 -1
  239. package/dist/rules/legacy.js +71 -38
  240. package/dist/rules/legacy.js.map +1 -1
  241. package/dist/rules/nano.d.ts.map +1 -1
  242. package/dist/rules/nano.js +15 -6
  243. package/dist/rules/nano.js.map +1 -1
  244. package/dist/rules/pages.d.ts +15 -0
  245. package/dist/rules/pages.d.ts.map +1 -0
  246. package/dist/rules/pages.js +88 -0
  247. package/dist/rules/pages.js.map +1 -0
  248. package/dist/rules/routes.d.ts +11 -0
  249. package/dist/rules/routes.d.ts.map +1 -1
  250. package/dist/rules/routes.js +180 -2
  251. package/dist/rules/routes.js.map +1 -1
  252. package/dist/rules/secrets.d.ts +10 -0
  253. package/dist/rules/secrets.d.ts.map +1 -0
  254. package/dist/rules/secrets.js +169 -0
  255. package/dist/rules/secrets.js.map +1 -0
  256. package/dist/rules/security.d.ts +7 -0
  257. package/dist/rules/security.d.ts.map +1 -1
  258. package/dist/rules/security.js +171 -65
  259. package/dist/rules/security.js.map +1 -1
  260. package/dist/rules/typescript.d.ts +16 -0
  261. package/dist/rules/typescript.d.ts.map +1 -0
  262. package/dist/rules/typescript.js +59 -0
  263. package/dist/rules/typescript.js.map +1 -0
  264. package/dist/rules/zones.d.ts +17 -0
  265. package/dist/rules/zones.d.ts.map +1 -0
  266. package/dist/rules/zones.js +174 -0
  267. package/dist/rules/zones.js.map +1 -0
  268. package/dist/scaffold.d.ts +25 -12
  269. package/dist/scaffold.d.ts.map +1 -1
  270. package/dist/scaffold.js +132 -87
  271. package/dist/scaffold.js.map +1 -1
  272. package/dist/sdk.d.ts +17 -3
  273. package/dist/sdk.d.ts.map +1 -1
  274. package/dist/sdk.js +941 -153
  275. package/dist/sdk.js.map +1 -1
  276. package/dist/search-terms.d.ts +2 -0
  277. package/dist/search-terms.d.ts.map +1 -1
  278. package/dist/search-terms.js +23 -8
  279. package/dist/search-terms.js.map +1 -1
  280. package/dist/smoke.d.ts +79 -0
  281. package/dist/smoke.d.ts.map +1 -0
  282. package/dist/smoke.js +494 -0
  283. package/dist/smoke.js.map +1 -0
  284. package/dist/sync-manifest.d.ts.map +1 -1
  285. package/dist/sync-manifest.js +2 -1
  286. package/dist/sync-manifest.js.map +1 -1
  287. package/dist/sync-routes.d.ts +4 -3
  288. package/dist/sync-routes.d.ts.map +1 -1
  289. package/dist/sync-routes.js +4 -3
  290. package/dist/sync-routes.js.map +1 -1
  291. package/dist/test-tool.d.ts.map +1 -1
  292. package/dist/test-tool.js +30 -11
  293. package/dist/test-tool.js.map +1 -1
  294. package/dist/types-search.d.ts.map +1 -1
  295. package/dist/types-search.js +11 -5
  296. package/dist/types-search.js.map +1 -1
  297. package/dist/upgrade.d.ts +36 -6
  298. package/dist/upgrade.d.ts.map +1 -1
  299. package/dist/upgrade.js +167 -29
  300. package/dist/upgrade.js.map +1 -1
  301. package/dist/verification-plan.d.ts.map +1 -1
  302. package/dist/verification-plan.js +4 -0
  303. package/dist/verification-plan.js.map +1 -1
  304. package/dist/verification.d.ts.map +1 -1
  305. package/dist/verification.js +45 -12
  306. package/dist/verification.js.map +1 -1
  307. package/dist/web-app-options.d.ts +19 -0
  308. package/dist/web-app-options.d.ts.map +1 -0
  309. package/dist/web-app-options.js +49 -0
  310. package/dist/web-app-options.js.map +1 -0
  311. package/dist/work-graph.d.ts.map +1 -1
  312. package/dist/work-graph.js +29 -13
  313. package/dist/work-graph.js.map +1 -1
  314. package/docs/examples.json +185 -29
  315. package/docs/llms-full.txt +3807 -580
  316. package/docs/types.json +14229 -10939
  317. package/package.json +19 -12
  318. package/src/app-files.ts +40 -0
  319. package/src/assure-hydration.ts +139 -46
  320. package/src/assure.ts +17 -2
  321. package/src/capabilities-tool.ts +35 -9
  322. package/src/cdn-check.ts +268 -0
  323. package/src/check-diagnostics.ts +17 -0
  324. package/src/check-scan.ts +358 -63
  325. package/src/check.ts +97 -62
  326. package/src/cli.ts +296 -87
  327. package/src/command-catalog.ts +374 -38
  328. package/src/config-target.ts +47 -0
  329. package/src/contracts.ts +54 -12
  330. package/src/db-audit.ts +128 -0
  331. package/src/db-child.ts +285 -0
  332. package/src/db-config.ts +224 -0
  333. package/src/db-tool.ts +690 -0
  334. package/src/dev-bun-config.ts +116 -40
  335. package/src/dev-feed-tool.ts +912 -0
  336. package/src/dev-server-client.ts +267 -0
  337. package/src/diff-tool.ts +6 -5
  338. package/src/docs-search.ts +10 -3
  339. package/src/doctor.ts +98 -15
  340. package/src/env-file.ts +78 -1
  341. package/src/fix-recipes.ts +44 -31
  342. package/src/frontend-guidance.ts +5 -5
  343. package/src/i18n-check.ts +179 -0
  344. package/src/init-agents.ts +301 -38
  345. package/src/internal/code-unit-order.ts +5 -0
  346. package/src/internal/script-kind.ts +11 -0
  347. package/src/internal/source-facts.ts +96 -22
  348. package/src/internal/typescript-import.ts +663 -68
  349. package/src/introspect.ts +90 -27
  350. package/src/learn.ts +3 -3
  351. package/src/levels-tool.ts +47 -2
  352. package/src/load.ts +140 -33
  353. package/src/manifest-tool.ts +3 -2
  354. package/src/mcp-app-tool.ts +2 -0
  355. package/src/mcp-context.ts +1 -1
  356. package/src/mcp-delegate.ts +162 -0
  357. package/src/mcp-docs-tools.ts +23 -5
  358. package/src/mcp-exec.ts +160 -132
  359. package/src/mcp-http.ts +19 -5
  360. package/src/mcp-io.ts +57 -0
  361. package/src/mcp-isolate.ts +287 -0
  362. package/src/mcp-project-child.ts +182 -0
  363. package/src/mcp-render.ts +2 -2
  364. package/src/mcp-root.ts +117 -22
  365. package/src/mcp-run.ts +104 -10
  366. package/src/mcp-ws.ts +4 -1
  367. package/src/mcp.ts +106 -64
  368. package/src/migrate-layout.ts +1121 -0
  369. package/src/openapi-tool.ts +7 -3
  370. package/src/openapi-types.ts +315 -13
  371. package/src/pipeline-report.ts +22 -18
  372. package/src/port.ts +53 -30
  373. package/src/project-facts.ts +5 -0
  374. package/src/recipes/4.0.0.ts +35 -0
  375. package/src/recipes/index.ts +2 -0
  376. package/src/release-verification.ts +74 -5
  377. package/src/review.ts +13 -8
  378. package/src/route-types.ts +133 -0
  379. package/src/rules/codes.ts +9 -0
  380. package/src/rules/data-guard.ts +230 -0
  381. package/src/rules/index.ts +18 -5
  382. package/src/rules/islands.ts +12 -4
  383. package/src/rules/legacy.ts +71 -39
  384. package/src/rules/nano.ts +18 -6
  385. package/src/rules/pages.ts +99 -0
  386. package/src/rules/routes.ts +181 -2
  387. package/src/rules/secrets.ts +174 -0
  388. package/src/rules/security.ts +198 -70
  389. package/src/rules/typescript.ts +77 -0
  390. package/src/rules/zones.ts +195 -0
  391. package/src/scaffold.ts +147 -95
  392. package/src/sdk.ts +1215 -158
  393. package/src/search-terms.ts +24 -7
  394. package/src/smoke.ts +607 -0
  395. package/src/sync-manifest.ts +2 -1
  396. package/src/sync-routes.ts +4 -3
  397. package/src/test-tool.ts +32 -11
  398. package/src/types-search.ts +11 -4
  399. package/src/upgrade.ts +205 -25
  400. package/src/verification-plan.ts +13 -0
  401. package/src/verification.ts +50 -12
  402. package/src/web-app-options.ts +57 -0
  403. package/src/work-graph.ts +27 -13
package/src/check-scan.ts CHANGED
@@ -9,8 +9,14 @@ import { readFileSync, realpathSync } from "node:fs"
9
9
  import { dirname, isAbsolute, join, resolve, sep } from "node:path"
10
10
  import { Glob } from "bun"
11
11
  import type * as TSApi from "typescript"
12
+ import { codeUnitOrder } from "./internal/code-unit-order.ts"
13
+ import { scriptKindOf } from "./internal/script-kind.ts"
12
14
  import type { SourceFacts } from "./internal/source-facts.ts"
13
- import { importProjectTypeScript, type TypeScriptApi } from "./internal/typescript-import.ts"
15
+ import {
16
+ loadProjectTypeScript,
17
+ type TypeScriptApi,
18
+ type TypeScriptSourceIndex,
19
+ } from "./internal/typescript-import.ts"
14
20
  import { commentBlockMarkerReason } from "./rules/comment-markers.ts"
15
21
 
16
22
  export interface SourceFinding {
@@ -48,21 +54,67 @@ const IGNORED = new RegExp(`${IGNORED_DIR.source}|${TEST_FILE.source}`)
48
54
  * them stable across runtimes so rules, diagnostics, ignore globs, and import chains have one shape. */
49
55
  const normalizeProjectPath = (path: string): string => path.replaceAll("\\", "/")
50
56
 
51
- // A file under `routes/` - a page module bundled for the browser, where a server-only import is unsafe.
52
- const ROUTE_FILE = /(^|\/)routes\//
57
+ // Browser code by path, mirroring `@nifrajs/web/zones`: a suffix decides first, then the folder. A
58
+ // route's backend half (`x.backend.ts`), `backend/` and `*.fn.ts` run on the server only.
59
+ const BACKEND_SUFFIX = /\.backend\.[cm]?[jt]sx?$/
60
+ const BROWSER_SUFFIX = /\.(?:frontend|shared)\.[^/]+$/
61
+ const BROWSER_FOLDER = /(^|\/)(?:routes|frontend|shared)\//
62
+ const BACKEND_FOLDER = /(^|\/)backend\//
63
+ const SERVER_FN_FILE = /\.fn\.[cm]?[jt]sx?$/
64
+
65
+ /** Whether a project file is browser code: a route's frontend half, `frontend/` or `shared/`. Pure. */
66
+ export function isBrowserSource(file: string): boolean {
67
+ const path = normalizeProjectPath(file)
68
+ if (BACKEND_SUFFIX.test(path) || SERVER_FN_FILE.test(path)) return false
69
+ if (BROWSER_SUFFIX.test(path)) return true
70
+ return BROWSER_FOLDER.test(path) && !BACKEND_FOLDER.test(path)
71
+ }
72
+
73
+ /** Whether a resolved module is backend code a browser import may not reach (a `*.fn.ts` module ships
74
+ * as its generated stub, so it is not). A legacy `*.server` module counts: the build refuses it. */
75
+ function isBackendModule(file: string): boolean {
76
+ const path = normalizeProjectPath(file)
77
+ if (SERVER_FN_FILE.test(path)) return false
78
+ return (
79
+ BACKEND_SUFFIX.test(path) ||
80
+ LEGACY_SERVER_FILE.test(path) ||
81
+ (BACKEND_FOLDER.test(path) && !BROWSER_SUFFIX.test(path))
82
+ )
83
+ }
53
84
 
54
85
  // Module specifiers that must never be VALUE-imported into a route module: node:/bun: builtins, common
55
86
  // DB drivers/ORМ server entrypoints, and the conventional `./db` module the scaffold generates.
56
87
  const SERVER_ONLY =
57
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?)?$/
58
89
 
59
- // A static, non-type import with a string specifier. `import type …` is erased at build, so it's safe
60
- // and skipped. Dynamic `import(…)` (the correct way to lazy-load server code in a loader) has `(` right
61
- // after `import`, so `import\s+` never matches it.
62
- const STATIC_IMPORT = /\bimport\s+(?!type\b)(?:[^'"();]*?\bfrom\s+)?['"]([^'"]+)['"]/g
90
+ // A static, non-type import with a string specifier, or a re-export (`export … from "x"`), which
91
+ // pulls its module into the bundle the same way. `import type …` and `export type …` are erased at
92
+ // build, so they're safe and skipped. Dynamic `import(…)` has `(` right after `import`, so `import\s+`
93
+ // never matches it; it is read by DYNAMIC_IMPORT below.
94
+ // The clause between the keyword and `from` is spelled out (a default binding, then `* as ns` or a
95
+ // `{ … }` list) rather than scanned lazily: a lazy scan restarts at every `export const` line and is
96
+ // quadratic on a long semicolon-free module.
97
+ const STATIC_IMPORT =
98
+ /\b(?:import\s*(?!type\b)(?:(?:[\w$]+\s*,?\s*)?(?:\*\s*(?:as\b\s*[\w$]+\s*)?|\{[^{}'"]*\}\s*)?from\s*)?|export\s*(?!type\b)(?:\*\s*(?:as\b\s*[\w$]+\s*)?|\{[^{}'"]*\}\s*)from\s*)['"]([^'"]+)['"]/g
99
+ // A dynamic `import("x")` whose specifier is one string literal. The client build bundles its target
100
+ // as a lazy chunk, so it reaches the browser bundle like a static import does - also from inside a
101
+ // loader, which ships with the route module. A computed specifier cannot be followed and is skipped.
102
+ const DYNAMIC_IMPORT = /(?<![\w$.])import\s*\(\s*(["'`])([^"'`$\\\r\n]+)\1\s*[,)]/g
103
+ // Without a parser: `typeof import("x")` and `import("x").Name` are type positions, erased at build.
104
+ // A runtime `import("x")` is a promise, so the only members read straight off it are its methods.
105
+ const TYPE_QUERY_BEFORE = /\btypeof\s*$/
106
+ const TYPE_QUALIFIER_AFTER = /^\s*\.\s*(?!(?:then|catch|finally)\b)[A-Za-z_$]/
63
107
 
64
108
  const ROUTE_REGISTRATION_DQ = /\.([A-Za-z]+)\s*\(\s*"((?:\\.|[^"\\])*)"/g
65
109
  const ROUTE_REGISTRATION_SQ = /\.([A-Za-z]+)\s*\(\s*'((?:\\.|[^'\\])*)'/g
110
+ // `all("/path", ...)` and `method("PURGE", "/path", ...)` / `method(["GET", "PURGE"], "/path", ...)`
111
+ // from `@nifrajs/core/methods`. They are free function calls, so the member-call patterns above do
112
+ // not see them.
113
+ const METHODS_SUBPATH = "@nifrajs/core/methods"
114
+ const ALL_ROUTES = /(?<![\w$.])all\s*\(\s*(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)')/g
115
+ const METHOD_ROUTES =
116
+ /(?<![\w$.])method\s*\(\s*(\[[^\]]*\]|"[^"\\]*"|'[^'\\]*')\s*,\s*(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)')/g
117
+ const METHOD_NAME_LITERAL = /(["'])([A-Za-z][A-Za-z0-9-]{0,31})\1/g
66
118
  export const IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/
67
119
 
68
120
  export interface StaticRouteFinding extends SourceFinding {
@@ -517,9 +569,66 @@ export function scanStaticRouteText(
517
569
  return [
518
570
  ...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_DQ, facts),
519
571
  ...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_SQ, facts),
572
+ ...(code.includes(METHODS_SUBPATH) ? scanMethodRoutes(file, content, code, facts) : []),
520
573
  ].sort(bySite)
521
574
  }
522
575
 
576
+ /** Routes from `all()` and `method()`, one per literal method name; a computed name is not guessed. */
577
+ function scanMethodRoutes(
578
+ file: string,
579
+ content: string,
580
+ code: string,
581
+ facts?: SourceFacts,
582
+ ): StaticRouteFinding[] {
583
+ const out: StaticRouteFinding[] = []
584
+ const lines = content.split("\n")
585
+ const collect = (
586
+ index: number,
587
+ callee: string,
588
+ pathIndex: number,
589
+ rawPath: string | undefined,
590
+ quote: string,
591
+ methods: readonly string[],
592
+ ): void => {
593
+ const path = parseQuotedLiteral(`${quote}${rawPath ?? ""}${quote}`)
594
+ if (path === undefined || !path.startsWith("/") || path.startsWith("//")) return
595
+ if (facts !== undefined) {
596
+ const source = facts.parse(file, content)
597
+ // Same contract as a member-call registration: a parsed source is authoritative, an
598
+ // unparseable one keeps the lexical finding.
599
+ if (
600
+ source !== undefined &&
601
+ facts.isFunctionRouteCallAt(source, index, callee, pathIndex, path) !== true
602
+ )
603
+ return
604
+ }
605
+ const line = lineAt(content, index)
606
+ const snippet = (lines[line - 1] ?? "").trim()
607
+ for (const method of methods) out.push({ file, line, snippet, method, path })
608
+ }
609
+
610
+ ALL_ROUTES.lastIndex = 0
611
+ for (let m = ALL_ROUTES.exec(code); m !== null; m = ALL_ROUTES.exec(code)) {
612
+ const double = m[1] !== undefined
613
+ collect(m.index, "all", 0, double ? m[1] : m[2], double ? '"' : "'", [...HTTP_VERBS])
614
+ }
615
+ METHOD_ROUTES.lastIndex = 0
616
+ for (let m = METHOD_ROUTES.exec(code); m !== null; m = METHOD_ROUTES.exec(code)) {
617
+ const names = new Set<string>()
618
+ METHOD_NAME_LITERAL.lastIndex = 0
619
+ for (
620
+ let name = METHOD_NAME_LITERAL.exec(m[1] ?? "");
621
+ name !== null;
622
+ name = METHOD_NAME_LITERAL.exec(m[1] ?? "")
623
+ ) {
624
+ names.add((name[2] ?? "").toUpperCase())
625
+ }
626
+ const double = m[2] !== undefined
627
+ collect(m.index, "method", 1, double ? m[2] : m[3], double ? '"' : "'", [...names])
628
+ }
629
+ return out
630
+ }
631
+
523
632
  /**
524
633
  * SQL built by interpolating a value into the statement text. The value is not a parameter, it is
525
634
  * STATEMENT, so anything the caller controls can end the literal and continue as SQL.
@@ -700,8 +809,15 @@ function parseSqlSource(
700
809
  file: string,
701
810
  content: string,
702
811
  ): TSApi.SourceFile | undefined {
703
- const kind = /\.[cm]?tsx?$/.test(file) ? ts.ScriptKind.TSX : ts.ScriptKind.JS
704
- const source = ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true, kind)
812
+ const kind = scriptKindOf(ts, file)
813
+ let source: TSApi.SourceFile
814
+ try {
815
+ source = ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true, kind)
816
+ } catch {
817
+ // TypeScript 7 parses only the files its session preloaded; an imported module outside that set
818
+ // proves no constant, like one that does not parse.
819
+ return undefined
820
+ }
705
821
  const parseDiagnostics = (
706
822
  source as TSApi.SourceFile & { readonly parseDiagnostics?: readonly TSApi.Diagnostic[] }
707
823
  ).parseDiagnostics
@@ -1272,14 +1388,25 @@ export function scanInterpolatedSql(
1272
1388
  * answer a security rule must never give when it did not run.
1273
1389
  */
1274
1390
  export async function scanProjectSql(cwd: string): Promise<SourceFinding[] | undefined> {
1275
- const ts = await importProjectTypeScript(cwd)
1276
- if (ts === undefined) return undefined
1391
+ const sourceFiles: Array<{ readonly file: string; readonly content: string }> = []
1392
+ await walkSource(cwd, (file, content) => sourceFiles.push({ file, content }))
1393
+ const contents = new Map(sourceFiles.map(({ file, content }) => [file, content]))
1394
+ const sourceIndex: TypeScriptSourceIndex = {
1395
+ files: sourceFiles.map(({ file }) => file),
1396
+ read: (file) => contents.get(file),
1397
+ }
1398
+ const loaded = await loadProjectTypeScript(cwd, undefined, sourceIndex)
1399
+ if (loaded.compiler === undefined) return undefined
1277
1400
  const out: SourceFinding[] = []
1278
1401
  const imports = createProjectSqlImports(cwd)
1279
- await walkSource(cwd, (rel, content) =>
1280
- out.push(...scanInterpolatedSql(rel, content, ts, imports)),
1281
- )
1282
- return out.sort(bySite)
1402
+ try {
1403
+ for (const { file, content } of sourceFiles) {
1404
+ out.push(...scanInterpolatedSql(file, content, loaded.compiler, imports))
1405
+ }
1406
+ return out.sort(bySite)
1407
+ } finally {
1408
+ await loaded.session?.close()
1409
+ }
1283
1410
  }
1284
1411
 
1285
1412
  /** A declaration file. Excluded from cross-module resolution: `declare const X: string` has no
@@ -1378,6 +1505,18 @@ export const REMOVED_IMPORTS: ReadonlyArray<{
1378
1505
  replacement:
1379
1506
  'import from "@nifrajs/core/budget" - the package folded into core and npm `latest` is still 1.13.0, so a `^2` range resolves to nothing',
1380
1507
  },
1508
+ {
1509
+ specifier: "@nifrajs/web/server-only",
1510
+ since: "4.0",
1511
+ replacement:
1512
+ 'import "@nifrajs/web/backend-only" - and keep backend code under backend/ or in a route\'s x.backend.ts, where no browser build reaches it',
1513
+ },
1514
+ {
1515
+ specifier: "@nifrajs/web/plugins/vite-server-only",
1516
+ since: "4.0",
1517
+ replacement:
1518
+ "nothing - buildClientVite and the Vite dev server enforce the frontend/backend zones themselves; for a hand-built Vite config use viteLeakGuard from @nifrajs/web/plugins/vite-leak-guard",
1519
+ },
1381
1520
  {
1382
1521
  specifier: "@nifrajs/core/ws",
1383
1522
  since: "2.0",
@@ -1396,12 +1535,13 @@ function isSideEffectImport(content: string, index: number): boolean {
1396
1535
  return /^import\s*["'`]/.test(content.slice(index, index + 32))
1397
1536
  }
1398
1537
 
1399
- /** Flag an import of a specifier that no longer exists. Pure; matches the exact specifier or a subpath
1400
- * of it, so `@nifrajs/budget/x` is caught alongside `@nifrajs/budget`. */
1538
+ /** Flag an import of a specifier that no longer exists, or of a name that moved to a subpath
1539
+ * ({@link MOVED_EXPORTS}). Pure; matches the exact specifier or a subpath of it, so `@nifrajs/budget/x`
1540
+ * is caught alongside `@nifrajs/budget`. */
1401
1541
  export function scanRemovedImports(file: string, content: string): SourceFinding[] {
1402
1542
  const out: SourceFinding[] = []
1403
1543
  const lines = content.split("\n")
1404
- for (const edge of staticImportEdges(content)) {
1544
+ for (const edge of importEdges(content)) {
1405
1545
  const removed = REMOVED_IMPORTS.find(
1406
1546
  (entry) =>
1407
1547
  edge.specifier === entry.specifier || edge.specifier.startsWith(`${entry.specifier}/`),
@@ -1411,35 +1551,113 @@ export function scanRemovedImports(file: string, content: string): SourceFinding
1411
1551
  const line = lineAt(content, edge.index)
1412
1552
  out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() })
1413
1553
  }
1554
+ // A moved name's snippet is the whole statement on one line: the rule re-reads the site from it.
1555
+ for (const site of movedExportSites(content)) {
1556
+ const snippet = content.slice(site.index, site.end).replace(/\s+/g, " ")
1557
+ out.push({ file, line: lineAt(content, site.index), snippet })
1558
+ }
1559
+ return out
1560
+ }
1561
+
1562
+ /**
1563
+ * Named exports that left a module for one of its subpaths, and where they went.
1564
+ *
1565
+ * Unlike a removed specifier, a moved name does fail the typecheck - but that error names neither the
1566
+ * new home nor a fix. Listing it here gives the diagnostic both, and `nifra fix --code NF-C005`
1567
+ * rewrites the import ({@link rewriteMovedExports}).
1568
+ */
1569
+ export const MOVED_EXPORTS: ReadonlyArray<{
1570
+ readonly from: string
1571
+ readonly names: readonly string[]
1572
+ readonly to: string
1573
+ }> = [
1574
+ // Compiler plugins sit on their own subpath so the adapter root, which every server and edge bundle
1575
+ // links, never pulls in build-time code.
1576
+ { from: "@nifrajs/web-solid", names: ["solidBunPlugin"], to: "@nifrajs/web-solid/plugin" },
1577
+ { from: "@nifrajs/web-svelte", names: ["svelteBunPlugin"], to: "@nifrajs/web-svelte/plugin" },
1578
+ ]
1579
+
1580
+ /** `import { a, b as c } from "x"` and `export { a } from "x"` - the forms a moved name is reached by.
1581
+ * `import type` / `export type` never match, so a type-only import is left to tsc as elsewhere here. */
1582
+ const NAMED_FROM = /\b(import|export)\s*\{([^{}]*)\}\s*from\s*(["'])([^"'\n]+)\3;?/g
1583
+
1584
+ export interface MovedExportSite {
1585
+ /** Statement span in the scanned source, `end` exclusive (a trailing `;` included). */
1586
+ readonly index: number
1587
+ readonly end: number
1588
+ readonly keyword: string
1589
+ readonly quote: string
1590
+ readonly from: string
1591
+ readonly to: string
1592
+ /** Binding texts as written (`a`, `a as b`), split by whether they move. */
1593
+ readonly kept: readonly string[]
1594
+ readonly moved: readonly string[]
1595
+ }
1596
+
1597
+ /** Every import/re-export statement naming a {@link MOVED_EXPORTS} binding, in source order. Pure. */
1598
+ export function movedExportSites(content: string): MovedExportSite[] {
1599
+ const positions = codePositionMask(content)
1600
+ const sites: MovedExportSite[] = []
1601
+ const re = new RegExp(NAMED_FROM.source, NAMED_FROM.flags)
1602
+ for (let m = re.exec(content); m !== null; m = re.exec(content)) {
1603
+ if (positions[m.index] === " ") continue
1604
+ const entry = MOVED_EXPORTS.find((candidate) => candidate.from === m?.[4])
1605
+ if (entry === undefined) continue
1606
+ const bindings = (m[2] ?? "")
1607
+ .split(",")
1608
+ .map((binding) => binding.trim())
1609
+ .filter((binding) => binding !== "")
1610
+ const moves = (binding: string): boolean =>
1611
+ !binding.startsWith("type ") && entry.names.includes(binding.split(/\s+/)[0] ?? "")
1612
+ const moved = bindings.filter(moves)
1613
+ if (moved.length === 0) continue
1614
+ sites.push({
1615
+ index: m.index,
1616
+ end: m.index + m[0].length,
1617
+ keyword: m[1] ?? "import",
1618
+ quote: m[3] ?? '"',
1619
+ from: entry.from,
1620
+ to: entry.to,
1621
+ kept: bindings.filter((binding) => !moves(binding)),
1622
+ moved,
1623
+ })
1624
+ }
1625
+ return sites
1626
+ }
1627
+
1628
+ /** Point every moved binding at the module it moved to, leaving the rest of its statement in place.
1629
+ * Returns `content` unchanged when nothing moved, so applying it twice is a no-op. Pure. */
1630
+ export function rewriteMovedExports(content: string): string {
1631
+ let out = content
1632
+ for (const site of movedExportSites(content).reverse()) {
1633
+ const semicolon = content[site.end - 1] === ";" ? ";" : ""
1634
+ const statement = (bindings: readonly string[], from: string): string =>
1635
+ `${site.keyword} { ${bindings.join(", ")} } from ${site.quote}${from}${site.quote}${semicolon}`
1636
+ const replacement =
1637
+ site.kept.length === 0
1638
+ ? statement(site.moved, site.to)
1639
+ : `${statement(site.kept, site.from)}\n${statement(site.moved, site.to)}`
1640
+ out = out.slice(0, site.index) + replacement + out.slice(site.end)
1641
+ }
1414
1642
  return out
1415
1643
  }
1416
1644
 
1417
- /** Scan a route module for top-level server-only imports. Returns `[]` for non-route files (only
1418
- * `routes/` modules are browser-bundled, so a server-only import elsewhere is fine). Each finding carries
1419
- * the offending `specifier` so the diagnostic can render the `routeFile → specifier` chain. Pure. */
1645
+ /** Scan a browser module for server-only imports, static or a literal dynamic `import()`. Returns `[]`
1646
+ * for server code ({@link isBrowserSource}), where a server-only import is fine. Each finding carries
1647
+ * the offending `specifier` so the diagnostic can render the `file → specifier` chain. Pure. */
1420
1648
  export function scanServerOnlyImports(
1421
1649
  file: string,
1422
1650
  content: string,
1423
1651
  facts?: SourceFacts,
1424
1652
  ): ServerImportFinding[] {
1425
- if (!ROUTE_FILE.test(file)) return []
1653
+ if (!isBrowserSource(file)) return []
1426
1654
  const out: ServerImportFinding[] = []
1427
1655
  const lines = content.split("\n")
1428
- const code = stripComments(content)
1429
- const positions = codePositionMask(content)
1430
- STATIC_IMPORT.lastIndex = 0
1431
- for (let m = STATIC_IMPORT.exec(code); m !== null; m = STATIC_IMPORT.exec(code)) {
1432
- if (positions[m.index] === " ") continue
1433
- const specifier = m[1] ?? ""
1656
+ // Inline `import { type X } from "…"` is erased just like `import type` and is not an edge; a parse
1657
+ // failure keeps the lexical edge so the security rule fails closed.
1658
+ for (const { specifier, index } of importEdges(content, facts, file)) {
1434
1659
  if (!SERVER_ONLY.test(specifier)) continue
1435
- if (facts !== undefined) {
1436
- const source = facts.parse(file, content)
1437
- // Inline `import { type X } from "…"` is erased just like `import type`; don't call it a
1438
- // runtime leak. A parse failure keeps the old lexical finding so the security rule fails closed.
1439
- if (source !== undefined && facts.isValueImportAt(source, m.index, specifier) === false)
1440
- continue
1441
- }
1442
- const line = lineAt(content, m.index)
1660
+ const line = lineAt(content, index)
1443
1661
  out.push({ file, line, snippet: (lines[line - 1] ?? "").trim(), specifier })
1444
1662
  }
1445
1663
  return out
@@ -1460,11 +1678,11 @@ export function scanServerOnlyImports(
1460
1678
  // as SERVER_ONLY, minus the relative `../db` arm - a relative `db` module IS local source we resolve.)
1461
1679
  const SERVER_ONLY_SINK =
1462
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/
1463
- // The `.server` convention: a module named `*.server.ts(x)` is server-only (the client build empties it).
1464
- const SERVER_MODULE_FILE = /\.server(\.[cm]?[jt]sx?)?$/
1465
- // The explicit poison-import marker (`@nifrajs/web/server-only`) - a module opting into the client-leak
1466
- // guard. A resolved file whose source carries this side-effect import is a server-only sink.
1467
- const SERVER_ONLY_MARKER_IMPORT = /import\s+["']@nifrajs\/web\/server-only["']/
1681
+ // The retired `*.server` convention. Builds refuse such a module in browser code rather than empty it.
1682
+ const LEGACY_SERVER_FILE = /\.server(\.[cm]?[jt]sx?)?$/
1683
+ // The explicit poison-import marker (`@nifrajs/web/backend-only`, or its retired `server-only` name) - a
1684
+ // 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["']/
1468
1686
  // Depth/visited caps keep the walk linear + cycle-safe. A route's server-only dependency sits within a
1469
1687
  // few hops in practice; the bound stops a pathological graph from blowing up the per-file scan.
1470
1688
  const TRANSITIVE_MAX_DEPTH = 8
@@ -1489,11 +1707,11 @@ function staticImportEdges(
1489
1707
  content: string,
1490
1708
  facts?: SourceFacts,
1491
1709
  file?: string,
1710
+ positions: string = codePositionMask(content),
1492
1711
  ): Array<{ specifier: string; index: number }> {
1493
1712
  const edges: Array<{ specifier: string; index: number }> = []
1494
1713
  const re = staticImportRegex()
1495
1714
  const code = stripComments(content)
1496
- const positions = codePositionMask(content)
1497
1715
  for (let m = re.exec(code); m !== null; m = re.exec(code)) {
1498
1716
  if (positions[m.index] === " ") continue
1499
1717
  if (m[1] === undefined) continue
@@ -1512,6 +1730,68 @@ export function parseStaticImports(content: string, facts?: SourceFacts, file?:
1512
1730
  return staticImportEdges(content, facts, file).map((e) => e.specifier)
1513
1731
  }
1514
1732
 
1733
+ /** The literal dynamic `import("x")` calls in executable code, skipping type positions. With `facts`
1734
+ * the parser decides; without it (or when it cannot tell) the lexical rule does. Pure. */
1735
+ function dynamicImportEdges(
1736
+ content: string,
1737
+ facts?: SourceFacts,
1738
+ file?: string,
1739
+ positions: string = codePositionMask(content),
1740
+ ): Array<{ specifier: string; index: number }> {
1741
+ const edges: Array<{ specifier: string; index: number }> = []
1742
+ const re = new RegExp(DYNAMIC_IMPORT.source, DYNAMIC_IMPORT.flags)
1743
+ for (let m = re.exec(content); m !== null; m = re.exec(content)) {
1744
+ if (positions[m.index] === " ") continue // inside a comment or a string
1745
+ const specifier = m[2]
1746
+ if (specifier === undefined) continue
1747
+ const source =
1748
+ facts !== undefined && file !== undefined ? facts.parse(file, content) : undefined
1749
+ const verdict =
1750
+ source === undefined || facts === undefined
1751
+ ? undefined
1752
+ : facts.isDynamicImportAt(source, m.index, specifier)
1753
+ if (verdict === false) continue
1754
+ if (verdict === undefined) {
1755
+ if (TYPE_QUERY_BEFORE.test(content.slice(Math.max(0, m.index - 16), m.index))) continue
1756
+ const end = m.index + m[0].length
1757
+ if (m[0].endsWith(")") && TYPE_QUALIFIER_AFTER.test(content.slice(end, end + 64))) continue
1758
+ }
1759
+ edges.push({ specifier, index: m.index })
1760
+ }
1761
+ return edges
1762
+ }
1763
+
1764
+ /** Every import a module's runtime code makes - static and literal dynamic - in source order. Pure. */
1765
+ function importEdges(
1766
+ content: string,
1767
+ facts?: SourceFacts,
1768
+ file?: string,
1769
+ ): Array<{ specifier: string; index: number }> {
1770
+ // Without an `import(` there is nothing for the dynamic pass to find; skip its regex.
1771
+ const positions = codePositionMask(content)
1772
+ 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
+ )
1777
+ }
1778
+
1779
+ /** The runtime imports of a module with the line each sits on, in source order. Pure. */
1780
+ export function importSites(
1781
+ content: string,
1782
+ file?: string,
1783
+ ): Array<{ readonly specifier: string; readonly line: number }> {
1784
+ return importEdges(content, undefined, file).map(({ specifier, index }) => ({
1785
+ specifier,
1786
+ line: lineAt(content, index),
1787
+ }))
1788
+ }
1789
+
1790
+ /** The specifiers {@link importEdges} finds, static and literal dynamic, in source order. Pure. */
1791
+ export function parseImports(content: string, facts?: SourceFacts, file?: string): string[] {
1792
+ return importEdges(content, facts, file).map((e) => e.specifier)
1793
+ }
1794
+
1515
1795
  /** The server-only SINK an import specifier names directly (a `node:`/`bun:` builtin or a known
1516
1796
  * server-only package), or `undefined` if it isn't a by-name sink. Pure - the label is the specifier
1517
1797
  * itself (it's already the actionable name). */
@@ -1540,10 +1820,11 @@ export type ModuleReader = (absPath: string) => string | undefined
1540
1820
  /**
1541
1821
  * BFS the LOCAL module graph from a route file for the SHORTEST import chain that reaches a server-only
1542
1822
  * sink, returning `[routeFile, …as-written specifiers…, sink]` or `undefined` if none is reachable. A
1543
- * node's outgoing edges are its static imports; an edge is followed only when it's a RELATIVE specifier
1823
+ * node's outgoing edges are its static and literal dynamic imports; an edge is followed only when it's a RELATIVE specifier
1544
1824
  * that `resolve` maps to a readable local file (so the walk never descends into node_modules or chases an
1545
1825
  * unresolvable alias). At each node, a by-name sink import (`node:fs`, `postgres`) OR a resolved
1546
- * `*.server` / `server-only`-marked dependency terminates the chain. Bounded by depth + a visited set, so
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
1547
1828
  * it's linear and cycle-free. `routeFile`/`routeContent` seed the walk; `resolve`/`read` supply the graph
1548
1829
  * - pure given those, so it's unit-testable with a fake graph.
1549
1830
  */
@@ -1569,7 +1850,7 @@ export function walkServerOnlyChain(
1569
1850
  const next: Node[] = []
1570
1851
  for (const node of frontier) {
1571
1852
  if (node.depth >= TRANSITIVE_MAX_DEPTH) continue
1572
- for (const spec of parseStaticImports(node.content, facts, node.abs)) {
1853
+ for (const spec of parseImports(node.content, facts, node.abs)) {
1573
1854
  // (a) A by-name sink (builtin / known server-only pkg) → the chain ends here (shortest first,
1574
1855
  // since BFS reaches the nearest sink before any deeper one).
1575
1856
  const sink = directSinkSpecifier(spec)
@@ -1579,13 +1860,14 @@ export function walkServerOnlyChain(
1579
1860
  if (!isRelativeSpecifier(spec)) continue
1580
1861
  const abs = resolve(node.abs, spec)
1581
1862
  if (abs === undefined || seen.has(abs)) continue
1582
- // (c) A resolved `*.server` module is a server-only sink by the `.server` convention - the chain
1583
- // ends at it (named by the as-written specifier).
1584
- if (SERVER_MODULE_FILE.test(abs)) return [...node.chain, spec]
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
1585
1867
  const content = read(abs)
1586
1868
  if (content === undefined) continue // unreadable → can't walk; treat as a leaf
1587
- // (d) A resolved module that opts into the `server-only` marker is a sink too.
1588
- if (SERVER_ONLY_MARKER_IMPORT.test(content)) return [...node.chain, spec]
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]
1589
1871
  if (visited >= TRANSITIVE_MAX_VISITED) continue
1590
1872
  visited++
1591
1873
  seen.add(abs)
@@ -1612,14 +1894,14 @@ export function resolveServerOnlyChains(
1612
1894
  read: ModuleReader,
1613
1895
  facts?: SourceFacts,
1614
1896
  ): TransitiveServerImportFinding[] {
1615
- if (!ROUTE_FILE.test(file)) return []
1897
+ if (!isBrowserSource(file)) return []
1616
1898
  const lines = content.split("\n")
1617
1899
  const out: TransitiveServerImportFinding[] = []
1618
1900
  const flaggedSpecifiers = new Set<string>()
1619
1901
  // Collect the route's import edges up front (fresh-regex scan) so the per-edge logic below can call
1620
1902
  // the REENTRANT transitive walk without corrupting a shared regex's `lastIndex` (the walk also scans
1621
1903
  // imports). Driving `STATIC_IMPORT.exec` here directly would restart this loop forever.
1622
- for (const { specifier, index } of staticImportEdges(content, facts, file)) {
1904
+ for (const { specifier, index } of importEdges(content, facts, file)) {
1623
1905
  if (flaggedSpecifiers.has(specifier)) continue
1624
1906
  const line = lineAt(content, index)
1625
1907
  const snippet = (lines[line - 1] ?? "").trim()
@@ -1644,15 +1926,16 @@ export function resolveServerOnlyChains(
1644
1926
  }
1645
1927
  continue
1646
1928
  }
1647
- // The `.server` / marker sink can be the first hop itself.
1648
- if (SERVER_MODULE_FILE.test(abs)) {
1929
+ // A backend first hop and a marked module are sinks; a `*.fn.ts` hop ships as its stub.
1930
+ if (isBackendModule(abs)) {
1649
1931
  flaggedSpecifiers.add(specifier)
1650
1932
  out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: false })
1651
1933
  continue
1652
1934
  }
1935
+ if (SERVER_FN_FILE.test(abs)) continue
1653
1936
  const depContent = read(abs)
1654
1937
  if (depContent === undefined) continue
1655
- if (SERVER_ONLY_MARKER_IMPORT.test(depContent)) {
1938
+ if (BACKEND_ONLY_MARKER_IMPORT.test(depContent)) {
1656
1939
  flaggedSpecifiers.add(specifier)
1657
1940
  out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: false })
1658
1941
  continue
@@ -1767,7 +2050,11 @@ async function gitIgnored(cwd: string, rels: readonly string[]): Promise<Set<str
1767
2050
  export async function walkSource(
1768
2051
  cwd: string,
1769
2052
  visit: (rel: string, content: string) => void,
1770
- opts: { readonly includeTests?: boolean } = {},
2053
+ opts: {
2054
+ readonly includeTests?: boolean
2055
+ /** Additional project-relative paths to exclude from this scan. */
2056
+ readonly ignore?: (relativePath: string) => boolean
2057
+ } = {},
1771
2058
  ): Promise<void> {
1772
2059
  const skip = opts.includeTests === true ? IGNORED_DIR : IGNORED
1773
2060
  // List candidates first (cheap - no reads), drop the built-in ignores, then exclude gitignored paths in
@@ -1775,17 +2062,18 @@ export async function walkSource(
1775
2062
  const rels: string[] = []
1776
2063
  for await (const rawRel of new Glob("**/*.{ts,tsx,mts,cts}").scan({ cwd, dot: false })) {
1777
2064
  const rel = normalizeProjectPath(rawRel)
1778
- if (!skip.test(rel)) rels.push(rel)
2065
+ if (!skip.test(rel) && (opts.ignore === undefined || !opts.ignore(rel))) rels.push(rel)
1779
2066
  }
1780
2067
  const ignored = await gitIgnored(cwd, rels)
1781
- for (const rel of rels) {
2068
+ // Glob order is the filesystem's, so findings would come out in a different order on each platform.
2069
+ for (const rel of rels.sort(codeUnitOrder)) {
1782
2070
  if (ignored.has(rel)) continue
1783
2071
  visit(rel, await Bun.file(join(cwd, rel)).text())
1784
2072
  }
1785
2073
  }
1786
2074
 
1787
2075
  const bySite = (a: SourceFinding, b: SourceFinding): number =>
1788
- a.file.localeCompare(b.file) || a.line - b.line
2076
+ codeUnitOrder(a.file, b.file) || a.line - b.line
1789
2077
 
1790
2078
  /** Collect own-API `fetch()` findings across the project. */
1791
2079
  export async function scanProject(cwd: string): Promise<SourceFinding[]> {
@@ -1805,8 +2093,11 @@ const GENERATED_MARKER = "GENERATED by @nifrajs/web generateServerManifest"
1805
2093
  // The first route-import specifier's prefix up to `routes/` (e.g. `./`, `../`, `./src/`) - used to
1806
2094
  // locate the routes dir relative to the manifest, and to strip to route-relative keys.
1807
2095
  const ROUTES_PREFIX = /["'](\.{1,2}(?:\/[^"'/]+)*?\/routes\/)[^"']+["']/
1808
- // Route file extensions discovery recognises (mirrors `@nifrajs/web/fs`'s filter).
2096
+ // The files route discovery collects (mirrors `@nifrajs/web/fs`'s filter): route files by extension,
2097
+ // each route's backend half, and `_middleware` modules.
1809
2098
  const ROUTE_FILE_EXT = /\.(tsx|jsx|svelte|vue|mdx)$/
2099
+ const ROUTE_BACKEND_HALF = /\.backend\.(?:[cm]?[jt]s)$/
2100
+ const MIDDLEWARE_FILE = /(?:^|\/)_middleware\.(?:ts|js)$/
1810
2101
 
1811
2102
  export interface ManifestDriftFinding {
1812
2103
  /** The committed server-manifest file (relative to cwd). */
@@ -1845,10 +2136,14 @@ export async function scanServerManifestDrift(cwd: string): Promise<ManifestDrif
1845
2136
  let discovered: string[]
1846
2137
  try {
1847
2138
  discovered = (
1848
- await Array.fromAsync(new Glob("**/*.{tsx,jsx,svelte,vue,mdx}").scan({ cwd: routesDir }))
2139
+ await Array.fromAsync(
2140
+ new Glob("**/*.{tsx,jsx,svelte,vue,mdx,ts,js}").scan({ cwd: routesDir }),
2141
+ )
1849
2142
  )
1850
2143
  .map((f) => f.replaceAll("\\", "/"))
1851
- .filter((f) => ROUTE_FILE_EXT.test(f))
2144
+ .filter(
2145
+ (f) => ROUTE_FILE_EXT.test(f) || ROUTE_BACKEND_HALF.test(f) || MIDDLEWARE_FILE.test(f),
2146
+ )
1852
2147
  } catch {
1853
2148
  continue // routes dir gone/unreadable - not a drift we can assess
1854
2149
  }
@@ -1857,5 +2152,5 @@ export async function scanServerManifestDrift(cwd: string): Promise<ManifestDrif
1857
2152
  findings.push({ file: rel, missing: drift.missing, extra: drift.extra })
1858
2153
  }
1859
2154
  }
1860
- return findings.sort((a, b) => a.file.localeCompare(b.file))
2155
+ return findings.sort((a, b) => codeUnitOrder(a.file, b.file))
1861
2156
  }