@nifrajs/cli 3.3.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 (425) 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 +7 -15
  22. package/dist/check-diagnostics.d.ts.map +1 -1
  23. package/dist/check-diagnostics.js +162 -713
  24. package/dist/check-diagnostics.js.map +1 -1
  25. package/dist/check-scan.d.ts +58 -7
  26. package/dist/check-scan.d.ts.map +1 -1
  27. package/dist/check-scan.js +323 -62
  28. package/dist/check-scan.js.map +1 -1
  29. package/dist/check.d.ts +1 -0
  30. package/dist/check.d.ts.map +1 -1
  31. package/dist/check.js +145 -54
  32. package/dist/check.js.map +1 -1
  33. package/dist/cli.d.ts +42 -16
  34. package/dist/cli.d.ts.map +1 -1
  35. package/dist/cli.js +306 -87
  36. package/dist/cli.js.map +1 -1
  37. package/dist/command-catalog.d.ts +60 -5
  38. package/dist/command-catalog.d.ts.map +1 -1
  39. package/dist/command-catalog.js +298 -33
  40. package/dist/command-catalog.js.map +1 -1
  41. package/dist/config-target.d.ts +8 -0
  42. package/dist/config-target.d.ts.map +1 -0
  43. package/dist/config-target.js +45 -0
  44. package/dist/config-target.js.map +1 -0
  45. package/dist/contracts.d.ts +3 -2
  46. package/dist/contracts.d.ts.map +1 -1
  47. package/dist/contracts.js +56 -16
  48. package/dist/contracts.js.map +1 -1
  49. package/dist/db-audit.d.ts +37 -0
  50. package/dist/db-audit.d.ts.map +1 -0
  51. package/dist/db-audit.js +99 -0
  52. package/dist/db-audit.js.map +1 -0
  53. package/dist/db-child.d.ts +64 -0
  54. package/dist/db-child.d.ts.map +1 -0
  55. package/dist/db-child.js +213 -0
  56. package/dist/db-child.js.map +1 -0
  57. package/dist/db-config.d.ts +60 -0
  58. package/dist/db-config.d.ts.map +1 -0
  59. package/dist/db-config.js +176 -0
  60. package/dist/db-config.js.map +1 -0
  61. package/dist/db-tool.d.ts +84 -0
  62. package/dist/db-tool.d.ts.map +1 -0
  63. package/dist/db-tool.js +537 -0
  64. package/dist/db-tool.js.map +1 -0
  65. package/dist/dev-bun-config.d.ts +20 -13
  66. package/dist/dev-bun-config.d.ts.map +1 -1
  67. package/dist/dev-bun-config.js +98 -41
  68. package/dist/dev-bun-config.js.map +1 -1
  69. package/dist/dev-feed-tool.d.ts +106 -0
  70. package/dist/dev-feed-tool.d.ts.map +1 -0
  71. package/dist/dev-feed-tool.js +713 -0
  72. package/dist/dev-feed-tool.js.map +1 -0
  73. package/dist/dev-server-client.d.ts +67 -0
  74. package/dist/dev-server-client.d.ts.map +1 -0
  75. package/dist/dev-server-client.js +195 -0
  76. package/dist/dev-server-client.js.map +1 -0
  77. package/dist/diagnostics.d.ts +86 -0
  78. package/dist/diagnostics.d.ts.map +1 -1
  79. package/dist/diagnostics.js +85 -0
  80. package/dist/diagnostics.js.map +1 -1
  81. package/dist/diff-tool.d.ts +3 -3
  82. package/dist/diff-tool.d.ts.map +1 -1
  83. package/dist/diff-tool.js +6 -5
  84. package/dist/diff-tool.js.map +1 -1
  85. package/dist/docs-search.d.ts.map +1 -1
  86. package/dist/docs-search.js +12 -4
  87. package/dist/docs-search.js.map +1 -1
  88. package/dist/doctor.d.ts +17 -0
  89. package/dist/doctor.d.ts.map +1 -1
  90. package/dist/doctor.js +76 -15
  91. package/dist/doctor.js.map +1 -1
  92. package/dist/env-file.d.ts +15 -0
  93. package/dist/env-file.d.ts.map +1 -1
  94. package/dist/env-file.js +73 -1
  95. package/dist/env-file.js.map +1 -1
  96. package/dist/fix-recipes.d.ts.map +1 -1
  97. package/dist/fix-recipes.js +41 -21
  98. package/dist/fix-recipes.js.map +1 -1
  99. package/dist/frontend-guidance.js +5 -5
  100. package/dist/frontend-guidance.js.map +1 -1
  101. package/dist/i18n-check.d.ts +16 -0
  102. package/dist/i18n-check.d.ts.map +1 -0
  103. package/dist/i18n-check.js +154 -0
  104. package/dist/i18n-check.js.map +1 -0
  105. package/dist/init-agents.d.ts +49 -13
  106. package/dist/init-agents.d.ts.map +1 -1
  107. package/dist/init-agents.js +239 -34
  108. package/dist/init-agents.js.map +1 -1
  109. package/dist/internal/code-unit-order.d.ts +4 -0
  110. package/dist/internal/code-unit-order.d.ts.map +1 -0
  111. package/dist/internal/code-unit-order.js +6 -0
  112. package/dist/internal/code-unit-order.js.map +1 -0
  113. package/dist/internal/script-kind.d.ts +7 -0
  114. package/dist/internal/script-kind.d.ts.map +1 -0
  115. package/dist/internal/script-kind.js +12 -0
  116. package/dist/internal/script-kind.js.map +1 -0
  117. package/dist/internal/source-facts.d.ts +5 -0
  118. package/dist/internal/source-facts.d.ts.map +1 -1
  119. package/dist/internal/source-facts.js +61 -5
  120. package/dist/internal/source-facts.js.map +1 -1
  121. package/dist/internal/typescript-import.d.ts +49 -24
  122. package/dist/internal/typescript-import.d.ts.map +1 -1
  123. package/dist/internal/typescript-import.js +507 -67
  124. package/dist/internal/typescript-import.js.map +1 -1
  125. package/dist/introspect.d.ts +6 -4
  126. package/dist/introspect.d.ts.map +1 -1
  127. package/dist/introspect.js +93 -74
  128. package/dist/introspect.js.map +1 -1
  129. package/dist/learn.js +3 -3
  130. package/dist/learn.js.map +1 -1
  131. package/dist/levels-tool.d.ts +10 -0
  132. package/dist/levels-tool.d.ts.map +1 -1
  133. package/dist/levels-tool.js +33 -3
  134. package/dist/levels-tool.js.map +1 -1
  135. package/dist/load.d.ts +71 -16
  136. package/dist/load.d.ts.map +1 -1
  137. package/dist/load.js +78 -18
  138. package/dist/load.js.map +1 -1
  139. package/dist/manifest-tool.d.ts.map +1 -1
  140. package/dist/manifest-tool.js +3 -2
  141. package/dist/manifest-tool.js.map +1 -1
  142. package/dist/mcp-app-tool.d.ts.map +1 -1
  143. package/dist/mcp-app-tool.js +2 -0
  144. package/dist/mcp-app-tool.js.map +1 -1
  145. package/dist/mcp-context.d.ts.map +1 -1
  146. package/dist/mcp-context.js +5 -6
  147. package/dist/mcp-context.js.map +1 -1
  148. package/dist/mcp-delegate.d.ts +57 -0
  149. package/dist/mcp-delegate.d.ts.map +1 -0
  150. package/dist/mcp-delegate.js +130 -0
  151. package/dist/mcp-delegate.js.map +1 -0
  152. package/dist/mcp-docs-tools.d.ts.map +1 -1
  153. package/dist/mcp-docs-tools.js +23 -5
  154. package/dist/mcp-docs-tools.js.map +1 -1
  155. package/dist/mcp-exec.d.ts +3 -0
  156. package/dist/mcp-exec.d.ts.map +1 -1
  157. package/dist/mcp-exec.js +263 -131
  158. package/dist/mcp-exec.js.map +1 -1
  159. package/dist/mcp-http.d.ts.map +1 -1
  160. package/dist/mcp-http.js +20 -5
  161. package/dist/mcp-http.js.map +1 -1
  162. package/dist/mcp-io.d.ts +26 -0
  163. package/dist/mcp-io.d.ts.map +1 -1
  164. package/dist/mcp-io.js +113 -0
  165. package/dist/mcp-io.js.map +1 -1
  166. package/dist/mcp-isolate.d.ts +48 -0
  167. package/dist/mcp-isolate.d.ts.map +1 -0
  168. package/dist/mcp-isolate.js +222 -0
  169. package/dist/mcp-isolate.js.map +1 -0
  170. package/dist/mcp-path.d.ts +10 -0
  171. package/dist/mcp-path.d.ts.map +1 -0
  172. package/dist/mcp-path.js +51 -0
  173. package/dist/mcp-path.js.map +1 -0
  174. package/dist/mcp-project-child.d.ts +74 -0
  175. package/dist/mcp-project-child.d.ts.map +1 -0
  176. package/dist/mcp-project-child.js +130 -0
  177. package/dist/mcp-project-child.js.map +1 -0
  178. package/dist/mcp-reflect.d.ts.map +1 -1
  179. package/dist/mcp-reflect.js +2 -2
  180. package/dist/mcp-reflect.js.map +1 -1
  181. package/dist/mcp-render.d.ts.map +1 -1
  182. package/dist/mcp-render.js +64 -45
  183. package/dist/mcp-render.js.map +1 -1
  184. package/dist/mcp-root.d.ts +37 -9
  185. package/dist/mcp-root.d.ts.map +1 -1
  186. package/dist/mcp-root.js +124 -25
  187. package/dist/mcp-root.js.map +1 -1
  188. package/dist/mcp-run.d.ts +18 -1
  189. package/dist/mcp-run.d.ts.map +1 -1
  190. package/dist/mcp-run.js +159 -55
  191. package/dist/mcp-run.js.map +1 -1
  192. package/dist/mcp-ws.js +4 -1
  193. package/dist/mcp-ws.js.map +1 -1
  194. package/dist/mcp.d.ts +18 -5
  195. package/dist/mcp.d.ts.map +1 -1
  196. package/dist/mcp.js +165 -82
  197. package/dist/mcp.js.map +1 -1
  198. package/dist/migrate-layout.d.ts +44 -0
  199. package/dist/migrate-layout.d.ts.map +1 -0
  200. package/dist/migrate-layout.js +982 -0
  201. package/dist/migrate-layout.js.map +1 -0
  202. package/dist/openapi-tool.d.ts +2 -2
  203. package/dist/openapi-tool.d.ts.map +1 -1
  204. package/dist/openapi-tool.js.map +1 -1
  205. package/dist/openapi-types.d.ts +2 -1
  206. package/dist/openapi-types.d.ts.map +1 -1
  207. package/dist/openapi-types.js +240 -12
  208. package/dist/openapi-types.js.map +1 -1
  209. package/dist/pipeline-report.d.ts +3 -2
  210. package/dist/pipeline-report.d.ts.map +1 -1
  211. package/dist/pipeline-report.js +19 -16
  212. package/dist/pipeline-report.js.map +1 -1
  213. package/dist/port.d.ts +5 -4
  214. package/dist/port.d.ts.map +1 -1
  215. package/dist/port.js +46 -29
  216. package/dist/port.js.map +1 -1
  217. package/dist/project-facts.d.ts +28 -4
  218. package/dist/project-facts.d.ts.map +1 -1
  219. package/dist/project-facts.js +12 -2
  220. package/dist/project-facts.js.map +1 -1
  221. package/dist/recipes/4.0.0.d.ts +9 -0
  222. package/dist/recipes/4.0.0.d.ts.map +1 -0
  223. package/dist/recipes/4.0.0.js +34 -0
  224. package/dist/recipes/4.0.0.js.map +1 -0
  225. package/dist/recipes/index.d.ts.map +1 -1
  226. package/dist/recipes/index.js +2 -0
  227. package/dist/recipes/index.js.map +1 -1
  228. package/dist/release-verification.d.ts +11 -1
  229. package/dist/release-verification.d.ts.map +1 -1
  230. package/dist/release-verification.js +47 -4
  231. package/dist/release-verification.js.map +1 -1
  232. package/dist/review.d.ts +21 -0
  233. package/dist/review.d.ts.map +1 -0
  234. package/dist/review.js +823 -0
  235. package/dist/review.js.map +1 -0
  236. package/dist/route-types.d.ts +28 -0
  237. package/dist/route-types.d.ts.map +1 -0
  238. package/dist/route-types.js +106 -0
  239. package/dist/route-types.js.map +1 -0
  240. package/dist/rules/codes.d.ts +9 -0
  241. package/dist/rules/codes.d.ts.map +1 -1
  242. package/dist/rules/codes.js +9 -0
  243. package/dist/rules/codes.js.map +1 -1
  244. package/dist/rules/data-guard.d.ts +12 -0
  245. package/dist/rules/data-guard.d.ts.map +1 -0
  246. package/dist/rules/data-guard.js +213 -0
  247. package/dist/rules/data-guard.js.map +1 -0
  248. package/dist/rules/index.d.ts +3 -0
  249. package/dist/rules/index.d.ts.map +1 -1
  250. package/dist/rules/index.js +17 -5
  251. package/dist/rules/index.js.map +1 -1
  252. package/dist/rules/islands.d.ts.map +1 -1
  253. package/dist/rules/islands.js +7 -4
  254. package/dist/rules/islands.js.map +1 -1
  255. package/dist/rules/legacy.d.ts +3 -1
  256. package/dist/rules/legacy.d.ts.map +1 -1
  257. package/dist/rules/legacy.js +829 -34
  258. package/dist/rules/legacy.js.map +1 -1
  259. package/dist/rules/nano.d.ts.map +1 -1
  260. package/dist/rules/nano.js +15 -6
  261. package/dist/rules/nano.js.map +1 -1
  262. package/dist/rules/pages.d.ts +15 -0
  263. package/dist/rules/pages.d.ts.map +1 -0
  264. package/dist/rules/pages.js +88 -0
  265. package/dist/rules/pages.js.map +1 -0
  266. package/dist/rules/routes.d.ts +11 -0
  267. package/dist/rules/routes.d.ts.map +1 -1
  268. package/dist/rules/routes.js +180 -2
  269. package/dist/rules/routes.js.map +1 -1
  270. package/dist/rules/secrets.d.ts +10 -0
  271. package/dist/rules/secrets.d.ts.map +1 -0
  272. package/dist/rules/secrets.js +169 -0
  273. package/dist/rules/secrets.js.map +1 -0
  274. package/dist/rules/security.d.ts +7 -0
  275. package/dist/rules/security.d.ts.map +1 -1
  276. package/dist/rules/security.js +171 -65
  277. package/dist/rules/security.js.map +1 -1
  278. package/dist/rules/typescript.d.ts +16 -0
  279. package/dist/rules/typescript.d.ts.map +1 -0
  280. package/dist/rules/typescript.js +59 -0
  281. package/dist/rules/typescript.js.map +1 -0
  282. package/dist/rules/zones.d.ts +17 -0
  283. package/dist/rules/zones.d.ts.map +1 -0
  284. package/dist/rules/zones.js +174 -0
  285. package/dist/rules/zones.js.map +1 -0
  286. package/dist/scaffold.d.ts +25 -12
  287. package/dist/scaffold.d.ts.map +1 -1
  288. package/dist/scaffold.js +154 -90
  289. package/dist/scaffold.js.map +1 -1
  290. package/dist/sdk.d.ts +17 -3
  291. package/dist/sdk.d.ts.map +1 -1
  292. package/dist/sdk.js +941 -153
  293. package/dist/sdk.js.map +1 -1
  294. package/dist/search-terms.d.ts +2 -0
  295. package/dist/search-terms.d.ts.map +1 -1
  296. package/dist/search-terms.js +23 -8
  297. package/dist/search-terms.js.map +1 -1
  298. package/dist/smoke.d.ts +79 -0
  299. package/dist/smoke.d.ts.map +1 -0
  300. package/dist/smoke.js +494 -0
  301. package/dist/smoke.js.map +1 -0
  302. package/dist/sync-manifest.d.ts.map +1 -1
  303. package/dist/sync-manifest.js +2 -1
  304. package/dist/sync-manifest.js.map +1 -1
  305. package/dist/sync-routes.d.ts +4 -3
  306. package/dist/sync-routes.d.ts.map +1 -1
  307. package/dist/sync-routes.js +4 -3
  308. package/dist/sync-routes.js.map +1 -1
  309. package/dist/test-tool.d.ts.map +1 -1
  310. package/dist/test-tool.js +30 -11
  311. package/dist/test-tool.js.map +1 -1
  312. package/dist/types-search.d.ts.map +1 -1
  313. package/dist/types-search.js +11 -5
  314. package/dist/types-search.js.map +1 -1
  315. package/dist/upgrade.d.ts +36 -6
  316. package/dist/upgrade.d.ts.map +1 -1
  317. package/dist/upgrade.js +167 -29
  318. package/dist/upgrade.js.map +1 -1
  319. package/dist/verification-plan.d.ts.map +1 -1
  320. package/dist/verification-plan.js +8 -0
  321. package/dist/verification-plan.js.map +1 -1
  322. package/dist/verification.d.ts.map +1 -1
  323. package/dist/verification.js +45 -12
  324. package/dist/verification.js.map +1 -1
  325. package/dist/web-app-options.d.ts +19 -0
  326. package/dist/web-app-options.d.ts.map +1 -0
  327. package/dist/web-app-options.js +49 -0
  328. package/dist/web-app-options.js.map +1 -0
  329. package/dist/work-graph.d.ts +3 -0
  330. package/dist/work-graph.d.ts.map +1 -1
  331. package/dist/work-graph.js +33 -16
  332. package/dist/work-graph.js.map +1 -1
  333. package/docs/examples.json +185 -29
  334. package/docs/llms-full.txt +4123 -548
  335. package/docs/types.json +14391 -10060
  336. package/package.json +19 -11
  337. package/src/app-files.ts +40 -0
  338. package/src/assure-hydration.ts +139 -46
  339. package/src/assure.ts +17 -2
  340. package/src/capabilities-tool.ts +35 -9
  341. package/src/cdn-check.ts +268 -0
  342. package/src/check-diagnostics.ts +199 -797
  343. package/src/check-scan.ts +394 -63
  344. package/src/check.ts +146 -58
  345. package/src/cli.ts +347 -92
  346. package/src/command-catalog.ts +407 -45
  347. package/src/config-target.ts +47 -0
  348. package/src/contracts.ts +66 -15
  349. package/src/db-audit.ts +128 -0
  350. package/src/db-child.ts +285 -0
  351. package/src/db-config.ts +224 -0
  352. package/src/db-tool.ts +690 -0
  353. package/src/dev-bun-config.ts +116 -40
  354. package/src/dev-feed-tool.ts +912 -0
  355. package/src/dev-server-client.ts +267 -0
  356. package/src/diagnostics.ts +179 -0
  357. package/src/diff-tool.ts +6 -5
  358. package/src/docs-search.ts +10 -3
  359. package/src/doctor.ts +98 -15
  360. package/src/env-file.ts +78 -1
  361. package/src/fix-recipes.ts +44 -31
  362. package/src/frontend-guidance.ts +5 -5
  363. package/src/i18n-check.ts +179 -0
  364. package/src/init-agents.ts +301 -38
  365. package/src/internal/code-unit-order.ts +5 -0
  366. package/src/internal/script-kind.ts +11 -0
  367. package/src/internal/source-facts.ts +96 -22
  368. package/src/internal/typescript-import.ts +663 -68
  369. package/src/introspect.ts +93 -82
  370. package/src/learn.ts +3 -3
  371. package/src/levels-tool.ts +47 -2
  372. package/src/load.ts +144 -32
  373. package/src/manifest-tool.ts +3 -2
  374. package/src/mcp-app-tool.ts +2 -0
  375. package/src/mcp-context.ts +5 -7
  376. package/src/mcp-delegate.ts +162 -0
  377. package/src/mcp-docs-tools.ts +23 -5
  378. package/src/mcp-exec.ts +301 -161
  379. package/src/mcp-http.ts +22 -5
  380. package/src/mcp-io.ts +130 -0
  381. package/src/mcp-isolate.ts +287 -0
  382. package/src/mcp-path.ts +50 -0
  383. package/src/mcp-project-child.ts +182 -0
  384. package/src/mcp-reflect.ts +3 -2
  385. package/src/mcp-render.ts +72 -42
  386. package/src/mcp-root.ts +130 -24
  387. package/src/mcp-run.ts +190 -56
  388. package/src/mcp-ws.ts +4 -1
  389. package/src/mcp.ts +193 -91
  390. package/src/migrate-layout.ts +1121 -0
  391. package/src/openapi-tool.ts +7 -3
  392. package/src/openapi-types.ts +315 -13
  393. package/src/pipeline-report.ts +22 -18
  394. package/src/port.ts +53 -30
  395. package/src/project-facts.ts +42 -8
  396. package/src/recipes/4.0.0.ts +35 -0
  397. package/src/recipes/index.ts +2 -0
  398. package/src/release-verification.ts +74 -5
  399. package/src/review.ts +988 -0
  400. package/src/route-types.ts +133 -0
  401. package/src/rules/codes.ts +9 -0
  402. package/src/rules/data-guard.ts +230 -0
  403. package/src/rules/index.ts +18 -5
  404. package/src/rules/islands.ts +12 -4
  405. package/src/rules/legacy.ts +947 -35
  406. package/src/rules/nano.ts +18 -6
  407. package/src/rules/pages.ts +99 -0
  408. package/src/rules/routes.ts +181 -2
  409. package/src/rules/secrets.ts +174 -0
  410. package/src/rules/security.ts +198 -70
  411. package/src/rules/typescript.ts +77 -0
  412. package/src/rules/zones.ts +195 -0
  413. package/src/scaffold.ts +175 -98
  414. package/src/sdk.ts +1215 -158
  415. package/src/search-terms.ts +24 -7
  416. package/src/smoke.ts +607 -0
  417. package/src/sync-manifest.ts +2 -1
  418. package/src/sync-routes.ts +4 -3
  419. package/src/test-tool.ts +32 -11
  420. package/src/types-search.ts +11 -4
  421. package/src/upgrade.ts +205 -25
  422. package/src/verification-plan.ts +27 -0
  423. package/src/verification.ts +50 -12
  424. package/src/web-app-options.ts +57 -0
  425. package/src/work-graph.ts +40 -16
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 {
@@ -470,6 +522,42 @@ export function scanFetchText(
470
522
  return out
471
523
  }
472
524
 
525
+ // `new EventSource(` / `new WebSocket(` - a hand-rolled subscription to this app's own streaming
526
+ // surface. `new` is required so a local binding named `WebSocket` (a wrapper, a mock) stays quiet;
527
+ // The word boundary excludes member calls and identifiers such as `renew WebSocket(`; `new` keeps
528
+ // local wrapper bindings quiet while still matching the platform constructors.
529
+ const STREAM_CONSTRUCT = /(?<![\w$])new\s+(?:EventSource|WebSocket)\s*\(/g
530
+
531
+ /** Scan one file's text for hand-rolled own-API `EventSource`/`WebSocket` constructions. Pure +
532
+ * line-accurate. Same contract as {@link scanFetchText}: only a relative-path first argument
533
+ * (`"/…"` not `"//…"`, no scheme) is this app's API, so absolute `ws(s)://` / `http(s)://` URLs
534
+ * stay quiet exactly like absolute fetch URLs do. `externalMounts` and the relative-URL rule behave
535
+ * identically. A relative `new WebSocket("/…")` throws in browsers (the WS constructor needs an
536
+ * absolute URL), so a hit is either broken or bypassing `api.*.ws()` - either way the typed call
537
+ * (`api.*.subscribe()` for `app.sse()` routes, `api.*.ws()` for `app.ws()` routes) is the fix, and
538
+ * it keeps the compiler catching drift. */
539
+ export function scanStreamText(
540
+ file: string,
541
+ content: string,
542
+ externalMounts: readonly string[] = [],
543
+ ): SourceFinding[] {
544
+ const out: SourceFinding[] = []
545
+ const lines = content.split("\n")
546
+ const code = codePositionMask(content)
547
+ STREAM_CONSTRUCT.lastIndex = 0
548
+ for (let m = STREAM_CONSTRUCT.exec(code); m !== null; m = STREAM_CONSTRUCT.exec(code)) {
549
+ const argument = firstArgumentIndex(content, m.index + m[0].length)
550
+ const quote = content[argument]
551
+ if (quote !== "'" && quote !== '"' && quote !== "`") continue
552
+ if (content[argument + 1] !== "/" || content[argument + 2] === "/") continue
553
+ if (externalMounts.length > 0 && matchesExternalMount(content, argument + 1, externalMounts))
554
+ continue
555
+ const line = lineAt(content, m.index)
556
+ out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() })
557
+ }
558
+ return out
559
+ }
560
+
473
561
  /** Statically collect simple Nifra route registrations from source, without importing app code. */
474
562
  export function scanStaticRouteText(
475
563
  file: string,
@@ -481,9 +569,66 @@ export function scanStaticRouteText(
481
569
  return [
482
570
  ...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_DQ, facts),
483
571
  ...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_SQ, facts),
572
+ ...(code.includes(METHODS_SUBPATH) ? scanMethodRoutes(file, content, code, facts) : []),
484
573
  ].sort(bySite)
485
574
  }
486
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
+
487
632
  /**
488
633
  * SQL built by interpolating a value into the statement text. The value is not a parameter, it is
489
634
  * STATEMENT, so anything the caller controls can end the literal and continue as SQL.
@@ -664,8 +809,15 @@ function parseSqlSource(
664
809
  file: string,
665
810
  content: string,
666
811
  ): TSApi.SourceFile | undefined {
667
- const kind = /\.[cm]?tsx?$/.test(file) ? ts.ScriptKind.TSX : ts.ScriptKind.JS
668
- 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
+ }
669
821
  const parseDiagnostics = (
670
822
  source as TSApi.SourceFile & { readonly parseDiagnostics?: readonly TSApi.Diagnostic[] }
671
823
  ).parseDiagnostics
@@ -1236,14 +1388,25 @@ export function scanInterpolatedSql(
1236
1388
  * answer a security rule must never give when it did not run.
1237
1389
  */
1238
1390
  export async function scanProjectSql(cwd: string): Promise<SourceFinding[] | undefined> {
1239
- const ts = await importProjectTypeScript(cwd)
1240
- 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
1241
1400
  const out: SourceFinding[] = []
1242
1401
  const imports = createProjectSqlImports(cwd)
1243
- await walkSource(cwd, (rel, content) =>
1244
- out.push(...scanInterpolatedSql(rel, content, ts, imports)),
1245
- )
1246
- 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
+ }
1247
1410
  }
1248
1411
 
1249
1412
  /** A declaration file. Excluded from cross-module resolution: `declare const X: string` has no
@@ -1342,6 +1505,18 @@ export const REMOVED_IMPORTS: ReadonlyArray<{
1342
1505
  replacement:
1343
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',
1344
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
+ },
1345
1520
  {
1346
1521
  specifier: "@nifrajs/core/ws",
1347
1522
  since: "2.0",
@@ -1360,12 +1535,13 @@ function isSideEffectImport(content: string, index: number): boolean {
1360
1535
  return /^import\s*["'`]/.test(content.slice(index, index + 32))
1361
1536
  }
1362
1537
 
1363
- /** Flag an import of a specifier that no longer exists. Pure; matches the exact specifier or a subpath
1364
- * 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`. */
1365
1541
  export function scanRemovedImports(file: string, content: string): SourceFinding[] {
1366
1542
  const out: SourceFinding[] = []
1367
1543
  const lines = content.split("\n")
1368
- for (const edge of staticImportEdges(content)) {
1544
+ for (const edge of importEdges(content)) {
1369
1545
  const removed = REMOVED_IMPORTS.find(
1370
1546
  (entry) =>
1371
1547
  edge.specifier === entry.specifier || edge.specifier.startsWith(`${entry.specifier}/`),
@@ -1375,35 +1551,113 @@ export function scanRemovedImports(file: string, content: string): SourceFinding
1375
1551
  const line = lineAt(content, edge.index)
1376
1552
  out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() })
1377
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
+ }
1378
1642
  return out
1379
1643
  }
1380
1644
 
1381
- /** Scan a route module for top-level server-only imports. Returns `[]` for non-route files (only
1382
- * `routes/` modules are browser-bundled, so a server-only import elsewhere is fine). Each finding carries
1383
- * 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. */
1384
1648
  export function scanServerOnlyImports(
1385
1649
  file: string,
1386
1650
  content: string,
1387
1651
  facts?: SourceFacts,
1388
1652
  ): ServerImportFinding[] {
1389
- if (!ROUTE_FILE.test(file)) return []
1653
+ if (!isBrowserSource(file)) return []
1390
1654
  const out: ServerImportFinding[] = []
1391
1655
  const lines = content.split("\n")
1392
- const code = stripComments(content)
1393
- const positions = codePositionMask(content)
1394
- STATIC_IMPORT.lastIndex = 0
1395
- for (let m = STATIC_IMPORT.exec(code); m !== null; m = STATIC_IMPORT.exec(code)) {
1396
- if (positions[m.index] === " ") continue
1397
- 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)) {
1398
1659
  if (!SERVER_ONLY.test(specifier)) continue
1399
- if (facts !== undefined) {
1400
- const source = facts.parse(file, content)
1401
- // Inline `import { type X } from "…"` is erased just like `import type`; don't call it a
1402
- // runtime leak. A parse failure keeps the old lexical finding so the security rule fails closed.
1403
- if (source !== undefined && facts.isValueImportAt(source, m.index, specifier) === false)
1404
- continue
1405
- }
1406
- const line = lineAt(content, m.index)
1660
+ const line = lineAt(content, index)
1407
1661
  out.push({ file, line, snippet: (lines[line - 1] ?? "").trim(), specifier })
1408
1662
  }
1409
1663
  return out
@@ -1424,11 +1678,11 @@ export function scanServerOnlyImports(
1424
1678
  // as SERVER_ONLY, minus the relative `../db` arm - a relative `db` module IS local source we resolve.)
1425
1679
  const SERVER_ONLY_SINK =
1426
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/
1427
- // The `.server` convention: a module named `*.server.ts(x)` is server-only (the client build empties it).
1428
- const SERVER_MODULE_FILE = /\.server(\.[cm]?[jt]sx?)?$/
1429
- // The explicit poison-import marker (`@nifrajs/web/server-only`) - a module opting into the client-leak
1430
- // guard. A resolved file whose source carries this side-effect import is a server-only sink.
1431
- 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["']/
1432
1686
  // Depth/visited caps keep the walk linear + cycle-safe. A route's server-only dependency sits within a
1433
1687
  // few hops in practice; the bound stops a pathological graph from blowing up the per-file scan.
1434
1688
  const TRANSITIVE_MAX_DEPTH = 8
@@ -1453,11 +1707,11 @@ function staticImportEdges(
1453
1707
  content: string,
1454
1708
  facts?: SourceFacts,
1455
1709
  file?: string,
1710
+ positions: string = codePositionMask(content),
1456
1711
  ): Array<{ specifier: string; index: number }> {
1457
1712
  const edges: Array<{ specifier: string; index: number }> = []
1458
1713
  const re = staticImportRegex()
1459
1714
  const code = stripComments(content)
1460
- const positions = codePositionMask(content)
1461
1715
  for (let m = re.exec(code); m !== null; m = re.exec(code)) {
1462
1716
  if (positions[m.index] === " ") continue
1463
1717
  if (m[1] === undefined) continue
@@ -1476,6 +1730,68 @@ export function parseStaticImports(content: string, facts?: SourceFacts, file?:
1476
1730
  return staticImportEdges(content, facts, file).map((e) => e.specifier)
1477
1731
  }
1478
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
+
1479
1795
  /** The server-only SINK an import specifier names directly (a `node:`/`bun:` builtin or a known
1480
1796
  * server-only package), or `undefined` if it isn't a by-name sink. Pure - the label is the specifier
1481
1797
  * itself (it's already the actionable name). */
@@ -1504,10 +1820,11 @@ export type ModuleReader = (absPath: string) => string | undefined
1504
1820
  /**
1505
1821
  * BFS the LOCAL module graph from a route file for the SHORTEST import chain that reaches a server-only
1506
1822
  * sink, returning `[routeFile, …as-written specifiers…, sink]` or `undefined` if none is reachable. A
1507
- * 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
1508
1824
  * that `resolve` maps to a readable local file (so the walk never descends into node_modules or chases an
1509
1825
  * unresolvable alias). At each node, a by-name sink import (`node:fs`, `postgres`) OR a resolved
1510
- * `*.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
1511
1828
  * it's linear and cycle-free. `routeFile`/`routeContent` seed the walk; `resolve`/`read` supply the graph
1512
1829
  * - pure given those, so it's unit-testable with a fake graph.
1513
1830
  */
@@ -1533,7 +1850,7 @@ export function walkServerOnlyChain(
1533
1850
  const next: Node[] = []
1534
1851
  for (const node of frontier) {
1535
1852
  if (node.depth >= TRANSITIVE_MAX_DEPTH) continue
1536
- for (const spec of parseStaticImports(node.content, facts, node.abs)) {
1853
+ for (const spec of parseImports(node.content, facts, node.abs)) {
1537
1854
  // (a) A by-name sink (builtin / known server-only pkg) → the chain ends here (shortest first,
1538
1855
  // since BFS reaches the nearest sink before any deeper one).
1539
1856
  const sink = directSinkSpecifier(spec)
@@ -1543,13 +1860,14 @@ export function walkServerOnlyChain(
1543
1860
  if (!isRelativeSpecifier(spec)) continue
1544
1861
  const abs = resolve(node.abs, spec)
1545
1862
  if (abs === undefined || seen.has(abs)) continue
1546
- // (c) A resolved `*.server` module is a server-only sink by the `.server` convention - the chain
1547
- // ends at it (named by the as-written specifier).
1548
- 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
1549
1867
  const content = read(abs)
1550
1868
  if (content === undefined) continue // unreadable → can't walk; treat as a leaf
1551
- // (d) A resolved module that opts into the `server-only` marker is a sink too.
1552
- 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]
1553
1871
  if (visited >= TRANSITIVE_MAX_VISITED) continue
1554
1872
  visited++
1555
1873
  seen.add(abs)
@@ -1576,14 +1894,14 @@ export function resolveServerOnlyChains(
1576
1894
  read: ModuleReader,
1577
1895
  facts?: SourceFacts,
1578
1896
  ): TransitiveServerImportFinding[] {
1579
- if (!ROUTE_FILE.test(file)) return []
1897
+ if (!isBrowserSource(file)) return []
1580
1898
  const lines = content.split("\n")
1581
1899
  const out: TransitiveServerImportFinding[] = []
1582
1900
  const flaggedSpecifiers = new Set<string>()
1583
1901
  // Collect the route's import edges up front (fresh-regex scan) so the per-edge logic below can call
1584
1902
  // the REENTRANT transitive walk without corrupting a shared regex's `lastIndex` (the walk also scans
1585
1903
  // imports). Driving `STATIC_IMPORT.exec` here directly would restart this loop forever.
1586
- for (const { specifier, index } of staticImportEdges(content, facts, file)) {
1904
+ for (const { specifier, index } of importEdges(content, facts, file)) {
1587
1905
  if (flaggedSpecifiers.has(specifier)) continue
1588
1906
  const line = lineAt(content, index)
1589
1907
  const snippet = (lines[line - 1] ?? "").trim()
@@ -1608,15 +1926,16 @@ export function resolveServerOnlyChains(
1608
1926
  }
1609
1927
  continue
1610
1928
  }
1611
- // The `.server` / marker sink can be the first hop itself.
1612
- 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)) {
1613
1931
  flaggedSpecifiers.add(specifier)
1614
1932
  out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: false })
1615
1933
  continue
1616
1934
  }
1935
+ if (SERVER_FN_FILE.test(abs)) continue
1617
1936
  const depContent = read(abs)
1618
1937
  if (depContent === undefined) continue
1619
- if (SERVER_ONLY_MARKER_IMPORT.test(depContent)) {
1938
+ if (BACKEND_ONLY_MARKER_IMPORT.test(depContent)) {
1620
1939
  flaggedSpecifiers.add(specifier)
1621
1940
  out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: false })
1622
1941
  continue
@@ -1731,7 +2050,11 @@ async function gitIgnored(cwd: string, rels: readonly string[]): Promise<Set<str
1731
2050
  export async function walkSource(
1732
2051
  cwd: string,
1733
2052
  visit: (rel: string, content: string) => void,
1734
- 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
+ } = {},
1735
2058
  ): Promise<void> {
1736
2059
  const skip = opts.includeTests === true ? IGNORED_DIR : IGNORED
1737
2060
  // List candidates first (cheap - no reads), drop the built-in ignores, then exclude gitignored paths in
@@ -1739,17 +2062,18 @@ export async function walkSource(
1739
2062
  const rels: string[] = []
1740
2063
  for await (const rawRel of new Glob("**/*.{ts,tsx,mts,cts}").scan({ cwd, dot: false })) {
1741
2064
  const rel = normalizeProjectPath(rawRel)
1742
- if (!skip.test(rel)) rels.push(rel)
2065
+ if (!skip.test(rel) && (opts.ignore === undefined || !opts.ignore(rel))) rels.push(rel)
1743
2066
  }
1744
2067
  const ignored = await gitIgnored(cwd, rels)
1745
- 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)) {
1746
2070
  if (ignored.has(rel)) continue
1747
2071
  visit(rel, await Bun.file(join(cwd, rel)).text())
1748
2072
  }
1749
2073
  }
1750
2074
 
1751
2075
  const bySite = (a: SourceFinding, b: SourceFinding): number =>
1752
- a.file.localeCompare(b.file) || a.line - b.line
2076
+ codeUnitOrder(a.file, b.file) || a.line - b.line
1753
2077
 
1754
2078
  /** Collect own-API `fetch()` findings across the project. */
1755
2079
  export async function scanProject(cwd: string): Promise<SourceFinding[]> {
@@ -1769,8 +2093,11 @@ const GENERATED_MARKER = "GENERATED by @nifrajs/web generateServerManifest"
1769
2093
  // The first route-import specifier's prefix up to `routes/` (e.g. `./`, `../`, `./src/`) - used to
1770
2094
  // locate the routes dir relative to the manifest, and to strip to route-relative keys.
1771
2095
  const ROUTES_PREFIX = /["'](\.{1,2}(?:\/[^"'/]+)*?\/routes\/)[^"']+["']/
1772
- // 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.
1773
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)$/
1774
2101
 
1775
2102
  export interface ManifestDriftFinding {
1776
2103
  /** The committed server-manifest file (relative to cwd). */
@@ -1809,10 +2136,14 @@ export async function scanServerManifestDrift(cwd: string): Promise<ManifestDrif
1809
2136
  let discovered: string[]
1810
2137
  try {
1811
2138
  discovered = (
1812
- 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
+ )
1813
2142
  )
1814
2143
  .map((f) => f.replaceAll("\\", "/"))
1815
- .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
+ )
1816
2147
  } catch {
1817
2148
  continue // routes dir gone/unreadable - not a drift we can assess
1818
2149
  }
@@ -1821,5 +2152,5 @@ export async function scanServerManifestDrift(cwd: string): Promise<ManifestDrif
1821
2152
  findings.push({ file: rel, missing: drift.missing, extra: drift.extra })
1822
2153
  }
1823
2154
  }
1824
- return findings.sort((a, b) => a.file.localeCompare(b.file))
2155
+ return findings.sort((a, b) => codeUnitOrder(a.file, b.file))
1825
2156
  }