@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
@@ -7,7 +7,9 @@
7
7
  import { readFileSync, realpathSync } from "node:fs";
8
8
  import { dirname, isAbsolute, join, resolve, sep } from "node:path";
9
9
  import { Glob } from "bun";
10
- import { importProjectTypeScript } from "./internal/typescript-import.js";
10
+ import { codeUnitOrder } from "./internal/code-unit-order.js";
11
+ import { scriptKindOf } from "./internal/script-kind.js";
12
+ import { loadProjectTypeScript, } from "./internal/typescript-import.js";
11
13
  import { commentBlockMarkerReason } from "./rules/comment-markers.js";
12
14
  // `fetch( <ws> ('|"|`) / (not /)` - a string/template arg starting with a single `/` is a relative URL,
13
15
  // i.e. same-origin = this app's own API. `(?<![.\w])` skips `.fetch(` (a method) and `prefetch(`; the
@@ -28,17 +30,60 @@ const IGNORED = new RegExp(`${IGNORED_DIR.source}|${TEST_FILE.source}`);
28
30
  /** Paths crossing the scanner boundary are project-relative identifiers, not filesystem paths. Keep
29
31
  * them stable across runtimes so rules, diagnostics, ignore globs, and import chains have one shape. */
30
32
  const normalizeProjectPath = (path) => path.replaceAll("\\", "/");
31
- // A file under `routes/` - a page module bundled for the browser, where a server-only import is unsafe.
32
- const ROUTE_FILE = /(^|\/)routes\//;
33
+ // Browser code by path, mirroring `@nifrajs/web/zones`: a suffix decides first, then the folder. A
34
+ // route's backend half (`x.backend.ts`), `backend/` and `*.fn.ts` run on the server only.
35
+ const BACKEND_SUFFIX = /\.backend\.[cm]?[jt]sx?$/;
36
+ const BROWSER_SUFFIX = /\.(?:frontend|shared)\.[^/]+$/;
37
+ const BROWSER_FOLDER = /(^|\/)(?:routes|frontend|shared)\//;
38
+ const BACKEND_FOLDER = /(^|\/)backend\//;
39
+ const SERVER_FN_FILE = /\.fn\.[cm]?[jt]sx?$/;
40
+ /** Whether a project file is browser code: a route's frontend half, `frontend/` or `shared/`. Pure. */
41
+ export function isBrowserSource(file) {
42
+ const path = normalizeProjectPath(file);
43
+ if (BACKEND_SUFFIX.test(path) || SERVER_FN_FILE.test(path))
44
+ return false;
45
+ if (BROWSER_SUFFIX.test(path))
46
+ return true;
47
+ return BROWSER_FOLDER.test(path) && !BACKEND_FOLDER.test(path);
48
+ }
49
+ /** Whether a resolved module is backend code a browser import may not reach (a `*.fn.ts` module ships
50
+ * as its generated stub, so it is not). A legacy `*.server` module counts: the build refuses it. */
51
+ function isBackendModule(file) {
52
+ const path = normalizeProjectPath(file);
53
+ if (SERVER_FN_FILE.test(path))
54
+ return false;
55
+ return (BACKEND_SUFFIX.test(path) ||
56
+ LEGACY_SERVER_FILE.test(path) ||
57
+ (BACKEND_FOLDER.test(path) && !BROWSER_SUFFIX.test(path)));
58
+ }
33
59
  // Module specifiers that must never be VALUE-imported into a route module: node:/bun: builtins, common
34
60
  // DB drivers/ORМ server entrypoints, and the conventional `./db` module the scaffold generates.
35
61
  const SERVER_ONLY = /^(?:node:|bun:)|^(?:postgres|pg|mysql2|ioredis|redis|better-sqlite3|mongodb|@libsql\/client)$|^drizzle-orm\/(?:node-postgres|postgres-js|bun-sqlite|libsql|mysql2|pglite)\b|^(?:\.\.?\/)+db(?:\.[cm]?[jt]sx?)?$/;
36
- // A static, non-type import with a string specifier. `import type …` is erased at build, so it's safe
37
- // and skipped. Dynamic `import(…)` (the correct way to lazy-load server code in a loader) has `(` right
38
- // after `import`, so `import\s+` never matches it.
39
- const STATIC_IMPORT = /\bimport\s+(?!type\b)(?:[^'"();]*?\bfrom\s+)?['"]([^'"]+)['"]/g;
62
+ // A static, non-type import with a string specifier, or a re-export (`export … from "x"`), which
63
+ // pulls its module into the bundle the same way. `import type …` and `export type …` are erased at
64
+ // build, so they're safe and skipped. Dynamic `import(…)` has `(` right after `import`, so `import\s+`
65
+ // never matches it; it is read by DYNAMIC_IMPORT below.
66
+ // The clause between the keyword and `from` is spelled out (a default binding, then `* as ns` or a
67
+ // `{ … }` list) rather than scanned lazily: a lazy scan restarts at every `export const` line and is
68
+ // quadratic on a long semicolon-free module.
69
+ const STATIC_IMPORT = /\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;
70
+ // A dynamic `import("x")` whose specifier is one string literal. The client build bundles its target
71
+ // as a lazy chunk, so it reaches the browser bundle like a static import does - also from inside a
72
+ // loader, which ships with the route module. A computed specifier cannot be followed and is skipped.
73
+ const DYNAMIC_IMPORT = /(?<![\w$.])import\s*\(\s*(["'`])([^"'`$\\\r\n]+)\1\s*[,)]/g;
74
+ // Without a parser: `typeof import("x")` and `import("x").Name` are type positions, erased at build.
75
+ // A runtime `import("x")` is a promise, so the only members read straight off it are its methods.
76
+ const TYPE_QUERY_BEFORE = /\btypeof\s*$/;
77
+ const TYPE_QUALIFIER_AFTER = /^\s*\.\s*(?!(?:then|catch|finally)\b)[A-Za-z_$]/;
40
78
  const ROUTE_REGISTRATION_DQ = /\.([A-Za-z]+)\s*\(\s*"((?:\\.|[^"\\])*)"/g;
41
79
  const ROUTE_REGISTRATION_SQ = /\.([A-Za-z]+)\s*\(\s*'((?:\\.|[^'\\])*)'/g;
80
+ // `all("/path", ...)` and `method("PURGE", "/path", ...)` / `method(["GET", "PURGE"], "/path", ...)`
81
+ // from `@nifrajs/core/methods`. They are free function calls, so the member-call patterns above do
82
+ // not see them.
83
+ const METHODS_SUBPATH = "@nifrajs/core/methods";
84
+ const ALL_ROUTES = /(?<![\w$.])all\s*\(\s*(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)')/g;
85
+ const METHOD_ROUTES = /(?<![\w$.])method\s*\(\s*(\[[^\]]*\]|"[^"\\]*"|'[^'\\]*')\s*,\s*(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)')/g;
86
+ const METHOD_NAME_LITERAL = /(["'])([A-Za-z][A-Za-z0-9-]{0,31})\1/g;
42
87
  export const IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
43
88
  /** Line number (1-based) of a match index within `content`. */
44
89
  const lineAt = (content, index) => content.slice(0, index).split("\n").length;
@@ -459,6 +504,38 @@ export function scanFetchText(file, content, externalMounts = []) {
459
504
  }
460
505
  return out;
461
506
  }
507
+ // `new EventSource(` / `new WebSocket(` - a hand-rolled subscription to this app's own streaming
508
+ // surface. `new` is required so a local binding named `WebSocket` (a wrapper, a mock) stays quiet;
509
+ // The word boundary excludes member calls and identifiers such as `renew WebSocket(`; `new` keeps
510
+ // local wrapper bindings quiet while still matching the platform constructors.
511
+ const STREAM_CONSTRUCT = /(?<![\w$])new\s+(?:EventSource|WebSocket)\s*\(/g;
512
+ /** Scan one file's text for hand-rolled own-API `EventSource`/`WebSocket` constructions. Pure +
513
+ * line-accurate. Same contract as {@link scanFetchText}: only a relative-path first argument
514
+ * (`"/…"` not `"//…"`, no scheme) is this app's API, so absolute `ws(s)://` / `http(s)://` URLs
515
+ * stay quiet exactly like absolute fetch URLs do. `externalMounts` and the relative-URL rule behave
516
+ * identically. A relative `new WebSocket("/…")` throws in browsers (the WS constructor needs an
517
+ * absolute URL), so a hit is either broken or bypassing `api.*.ws()` - either way the typed call
518
+ * (`api.*.subscribe()` for `app.sse()` routes, `api.*.ws()` for `app.ws()` routes) is the fix, and
519
+ * it keeps the compiler catching drift. */
520
+ export function scanStreamText(file, content, externalMounts = []) {
521
+ const out = [];
522
+ const lines = content.split("\n");
523
+ const code = codePositionMask(content);
524
+ STREAM_CONSTRUCT.lastIndex = 0;
525
+ for (let m = STREAM_CONSTRUCT.exec(code); m !== null; m = STREAM_CONSTRUCT.exec(code)) {
526
+ const argument = firstArgumentIndex(content, m.index + m[0].length);
527
+ const quote = content[argument];
528
+ if (quote !== "'" && quote !== '"' && quote !== "`")
529
+ continue;
530
+ if (content[argument + 1] !== "/" || content[argument + 2] === "/")
531
+ continue;
532
+ if (externalMounts.length > 0 && matchesExternalMount(content, argument + 1, externalMounts))
533
+ continue;
534
+ const line = lineAt(content, m.index);
535
+ out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() });
536
+ }
537
+ return out;
538
+ }
462
539
  /** Statically collect simple Nifra route registrations from source, without importing app code. */
463
540
  export function scanStaticRouteText(file, content, facts) {
464
541
  const code = stripComments(content);
@@ -467,8 +544,47 @@ export function scanStaticRouteText(file, content, facts) {
467
544
  return [
468
545
  ...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_DQ, facts),
469
546
  ...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_SQ, facts),
547
+ ...(code.includes(METHODS_SUBPATH) ? scanMethodRoutes(file, content, code, facts) : []),
470
548
  ].sort(bySite);
471
549
  }
550
+ /** Routes from `all()` and `method()`, one per literal method name; a computed name is not guessed. */
551
+ function scanMethodRoutes(file, content, code, facts) {
552
+ const out = [];
553
+ const lines = content.split("\n");
554
+ const collect = (index, callee, pathIndex, rawPath, quote, methods) => {
555
+ const path = parseQuotedLiteral(`${quote}${rawPath ?? ""}${quote}`);
556
+ if (path === undefined || !path.startsWith("/") || path.startsWith("//"))
557
+ return;
558
+ if (facts !== undefined) {
559
+ const source = facts.parse(file, content);
560
+ // Same contract as a member-call registration: a parsed source is authoritative, an
561
+ // unparseable one keeps the lexical finding.
562
+ if (source !== undefined &&
563
+ facts.isFunctionRouteCallAt(source, index, callee, pathIndex, path) !== true)
564
+ return;
565
+ }
566
+ const line = lineAt(content, index);
567
+ const snippet = (lines[line - 1] ?? "").trim();
568
+ for (const method of methods)
569
+ out.push({ file, line, snippet, method, path });
570
+ };
571
+ ALL_ROUTES.lastIndex = 0;
572
+ for (let m = ALL_ROUTES.exec(code); m !== null; m = ALL_ROUTES.exec(code)) {
573
+ const double = m[1] !== undefined;
574
+ collect(m.index, "all", 0, double ? m[1] : m[2], double ? '"' : "'", [...HTTP_VERBS]);
575
+ }
576
+ METHOD_ROUTES.lastIndex = 0;
577
+ for (let m = METHOD_ROUTES.exec(code); m !== null; m = METHOD_ROUTES.exec(code)) {
578
+ const names = new Set();
579
+ METHOD_NAME_LITERAL.lastIndex = 0;
580
+ for (let name = METHOD_NAME_LITERAL.exec(m[1] ?? ""); name !== null; name = METHOD_NAME_LITERAL.exec(m[1] ?? "")) {
581
+ names.add((name[2] ?? "").toUpperCase());
582
+ }
583
+ const double = m[2] !== undefined;
584
+ collect(m.index, "method", 1, double ? m[2] : m[3], double ? '"' : "'", [...names]);
585
+ }
586
+ return out;
587
+ }
472
588
  /**
473
589
  * SQL built by interpolating a value into the statement text. The value is not a parameter, it is
474
590
  * STATEMENT, so anything the caller controls can end the literal and continue as SQL.
@@ -578,8 +694,16 @@ export const createSqlImports = (load) => ({
578
694
  * a typecheck failure; parser recovery nodes are not evidence of anything, so a file that fails to
579
695
  * parse contributes no findings and proves no constant. */
580
696
  function parseSqlSource(ts, file, content) {
581
- const kind = /\.[cm]?tsx?$/.test(file) ? ts.ScriptKind.TSX : ts.ScriptKind.JS;
582
- const source = ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true, kind);
697
+ const kind = scriptKindOf(ts, file);
698
+ let source;
699
+ try {
700
+ source = ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true, kind);
701
+ }
702
+ catch {
703
+ // TypeScript 7 parses only the files its session preloaded; an imported module outside that set
704
+ // proves no constant, like one that does not parse.
705
+ return undefined;
706
+ }
583
707
  const parseDiagnostics = source.parseDiagnostics;
584
708
  return parseDiagnostics !== undefined && parseDiagnostics.length > 0 ? undefined : source;
585
709
  }
@@ -1086,13 +1210,27 @@ export function scanInterpolatedSql(file, content, ts, imports) {
1086
1210
  * answer a security rule must never give when it did not run.
1087
1211
  */
1088
1212
  export async function scanProjectSql(cwd) {
1089
- const ts = await importProjectTypeScript(cwd);
1090
- if (ts === undefined)
1213
+ const sourceFiles = [];
1214
+ await walkSource(cwd, (file, content) => sourceFiles.push({ file, content }));
1215
+ const contents = new Map(sourceFiles.map(({ file, content }) => [file, content]));
1216
+ const sourceIndex = {
1217
+ files: sourceFiles.map(({ file }) => file),
1218
+ read: (file) => contents.get(file),
1219
+ };
1220
+ const loaded = await loadProjectTypeScript(cwd, undefined, sourceIndex);
1221
+ if (loaded.compiler === undefined)
1091
1222
  return undefined;
1092
1223
  const out = [];
1093
1224
  const imports = createProjectSqlImports(cwd);
1094
- await walkSource(cwd, (rel, content) => out.push(...scanInterpolatedSql(rel, content, ts, imports)));
1095
- return out.sort(bySite);
1225
+ try {
1226
+ for (const { file, content } of sourceFiles) {
1227
+ out.push(...scanInterpolatedSql(file, content, loaded.compiler, imports));
1228
+ }
1229
+ return out.sort(bySite);
1230
+ }
1231
+ finally {
1232
+ await loaded.session?.close();
1233
+ }
1096
1234
  }
1097
1235
  /** A declaration file. Excluded from cross-module resolution: `declare const X: string` has no
1098
1236
  * initializer to read, so following one could only ever produce a "proved" the source never wrote. */
@@ -1184,6 +1322,16 @@ export const REMOVED_IMPORTS = [
1184
1322
  since: "2.0",
1185
1323
  replacement: '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',
1186
1324
  },
1325
+ {
1326
+ specifier: "@nifrajs/web/server-only",
1327
+ since: "4.0",
1328
+ replacement: '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',
1329
+ },
1330
+ {
1331
+ specifier: "@nifrajs/web/plugins/vite-server-only",
1332
+ since: "4.0",
1333
+ replacement: "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",
1334
+ },
1187
1335
  {
1188
1336
  specifier: "@nifrajs/core/ws",
1189
1337
  since: "2.0",
@@ -1199,12 +1347,13 @@ function isSideEffectImport(content, index) {
1199
1347
  // no `from` in between.
1200
1348
  return /^import\s*["'`]/.test(content.slice(index, index + 32));
1201
1349
  }
1202
- /** Flag an import of a specifier that no longer exists. Pure; matches the exact specifier or a subpath
1203
- * of it, so `@nifrajs/budget/x` is caught alongside `@nifrajs/budget`. */
1350
+ /** Flag an import of a specifier that no longer exists, or of a name that moved to a subpath
1351
+ * ({@link MOVED_EXPORTS}). Pure; matches the exact specifier or a subpath of it, so `@nifrajs/budget/x`
1352
+ * is caught alongside `@nifrajs/budget`. */
1204
1353
  export function scanRemovedImports(file, content) {
1205
1354
  const out = [];
1206
1355
  const lines = content.split("\n");
1207
- for (const edge of staticImportEdges(content)) {
1356
+ for (const edge of importEdges(content)) {
1208
1357
  const removed = REMOVED_IMPORTS.find((entry) => edge.specifier === entry.specifier || edge.specifier.startsWith(`${entry.specifier}/`));
1209
1358
  if (removed === undefined)
1210
1359
  continue;
@@ -1213,33 +1362,89 @@ export function scanRemovedImports(file, content) {
1213
1362
  const line = lineAt(content, edge.index);
1214
1363
  out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() });
1215
1364
  }
1365
+ // A moved name's snippet is the whole statement on one line: the rule re-reads the site from it.
1366
+ for (const site of movedExportSites(content)) {
1367
+ const snippet = content.slice(site.index, site.end).replace(/\s+/g, " ");
1368
+ out.push({ file, line: lineAt(content, site.index), snippet });
1369
+ }
1370
+ return out;
1371
+ }
1372
+ /**
1373
+ * Named exports that left a module for one of its subpaths, and where they went.
1374
+ *
1375
+ * Unlike a removed specifier, a moved name does fail the typecheck - but that error names neither the
1376
+ * new home nor a fix. Listing it here gives the diagnostic both, and `nifra fix --code NF-C005`
1377
+ * rewrites the import ({@link rewriteMovedExports}).
1378
+ */
1379
+ export const MOVED_EXPORTS = [
1380
+ // Compiler plugins sit on their own subpath so the adapter root, which every server and edge bundle
1381
+ // links, never pulls in build-time code.
1382
+ { from: "@nifrajs/web-solid", names: ["solidBunPlugin"], to: "@nifrajs/web-solid/plugin" },
1383
+ { from: "@nifrajs/web-svelte", names: ["svelteBunPlugin"], to: "@nifrajs/web-svelte/plugin" },
1384
+ ];
1385
+ /** `import { a, b as c } from "x"` and `export { a } from "x"` - the forms a moved name is reached by.
1386
+ * `import type` / `export type` never match, so a type-only import is left to tsc as elsewhere here. */
1387
+ const NAMED_FROM = /\b(import|export)\s*\{([^{}]*)\}\s*from\s*(["'])([^"'\n]+)\3;?/g;
1388
+ /** Every import/re-export statement naming a {@link MOVED_EXPORTS} binding, in source order. Pure. */
1389
+ export function movedExportSites(content) {
1390
+ const positions = codePositionMask(content);
1391
+ const sites = [];
1392
+ const re = new RegExp(NAMED_FROM.source, NAMED_FROM.flags);
1393
+ for (let m = re.exec(content); m !== null; m = re.exec(content)) {
1394
+ if (positions[m.index] === " ")
1395
+ continue;
1396
+ const entry = MOVED_EXPORTS.find((candidate) => candidate.from === m?.[4]);
1397
+ if (entry === undefined)
1398
+ continue;
1399
+ const bindings = (m[2] ?? "")
1400
+ .split(",")
1401
+ .map((binding) => binding.trim())
1402
+ .filter((binding) => binding !== "");
1403
+ const moves = (binding) => !binding.startsWith("type ") && entry.names.includes(binding.split(/\s+/)[0] ?? "");
1404
+ const moved = bindings.filter(moves);
1405
+ if (moved.length === 0)
1406
+ continue;
1407
+ sites.push({
1408
+ index: m.index,
1409
+ end: m.index + m[0].length,
1410
+ keyword: m[1] ?? "import",
1411
+ quote: m[3] ?? '"',
1412
+ from: entry.from,
1413
+ to: entry.to,
1414
+ kept: bindings.filter((binding) => !moves(binding)),
1415
+ moved,
1416
+ });
1417
+ }
1418
+ return sites;
1419
+ }
1420
+ /** Point every moved binding at the module it moved to, leaving the rest of its statement in place.
1421
+ * Returns `content` unchanged when nothing moved, so applying it twice is a no-op. Pure. */
1422
+ export function rewriteMovedExports(content) {
1423
+ let out = content;
1424
+ for (const site of movedExportSites(content).reverse()) {
1425
+ const semicolon = content[site.end - 1] === ";" ? ";" : "";
1426
+ const statement = (bindings, from) => `${site.keyword} { ${bindings.join(", ")} } from ${site.quote}${from}${site.quote}${semicolon}`;
1427
+ const replacement = site.kept.length === 0
1428
+ ? statement(site.moved, site.to)
1429
+ : `${statement(site.kept, site.from)}\n${statement(site.moved, site.to)}`;
1430
+ out = out.slice(0, site.index) + replacement + out.slice(site.end);
1431
+ }
1216
1432
  return out;
1217
1433
  }
1218
- /** Scan a route module for top-level server-only imports. Returns `[]` for non-route files (only
1219
- * `routes/` modules are browser-bundled, so a server-only import elsewhere is fine). Each finding carries
1220
- * the offending `specifier` so the diagnostic can render the `routeFile → specifier` chain. Pure. */
1434
+ /** Scan a browser module for server-only imports, static or a literal dynamic `import()`. Returns `[]`
1435
+ * for server code ({@link isBrowserSource}), where a server-only import is fine. Each finding carries
1436
+ * the offending `specifier` so the diagnostic can render the `file → specifier` chain. Pure. */
1221
1437
  export function scanServerOnlyImports(file, content, facts) {
1222
- if (!ROUTE_FILE.test(file))
1438
+ if (!isBrowserSource(file))
1223
1439
  return [];
1224
1440
  const out = [];
1225
1441
  const lines = content.split("\n");
1226
- const code = stripComments(content);
1227
- const positions = codePositionMask(content);
1228
- STATIC_IMPORT.lastIndex = 0;
1229
- for (let m = STATIC_IMPORT.exec(code); m !== null; m = STATIC_IMPORT.exec(code)) {
1230
- if (positions[m.index] === " ")
1231
- continue;
1232
- const specifier = m[1] ?? "";
1442
+ // Inline `import { type X } from "…"` is erased just like `import type` and is not an edge; a parse
1443
+ // failure keeps the lexical edge so the security rule fails closed.
1444
+ for (const { specifier, index } of importEdges(content, facts, file)) {
1233
1445
  if (!SERVER_ONLY.test(specifier))
1234
1446
  continue;
1235
- if (facts !== undefined) {
1236
- const source = facts.parse(file, content);
1237
- // Inline `import { type X } from "…"` is erased just like `import type`; don't call it a
1238
- // runtime leak. A parse failure keeps the old lexical finding so the security rule fails closed.
1239
- if (source !== undefined && facts.isValueImportAt(source, m.index, specifier) === false)
1240
- continue;
1241
- }
1242
- const line = lineAt(content, m.index);
1447
+ const line = lineAt(content, index);
1243
1448
  out.push({ file, line, snippet: (lines[line - 1] ?? "").trim(), specifier });
1244
1449
  }
1245
1450
  return out;
@@ -1257,11 +1462,11 @@ export function scanServerOnlyImports(file, content, facts) {
1257
1462
  // npm package. These are never local source we can walk into, so the chain ends here. (Same vocabulary
1258
1463
  // as SERVER_ONLY, minus the relative `../db` arm - a relative `db` module IS local source we resolve.)
1259
1464
  const SERVER_ONLY_SINK = /^(?:node:|bun:)|^(?:postgres|pg|mysql2|ioredis|redis|better-sqlite3|mongodb|@libsql\/client)$|^drizzle-orm\/(?:node-postgres|postgres-js|bun-sqlite|libsql|mysql2|pglite)\b/;
1260
- // The `.server` convention: a module named `*.server.ts(x)` is server-only (the client build empties it).
1261
- const SERVER_MODULE_FILE = /\.server(\.[cm]?[jt]sx?)?$/;
1262
- // The explicit poison-import marker (`@nifrajs/web/server-only`) - a module opting into the client-leak
1263
- // guard. A resolved file whose source carries this side-effect import is a server-only sink.
1264
- const SERVER_ONLY_MARKER_IMPORT = /import\s+["']@nifrajs\/web\/server-only["']/;
1465
+ // The retired `*.server` convention. Builds refuse such a module in browser code rather than empty it.
1466
+ const LEGACY_SERVER_FILE = /\.server(\.[cm]?[jt]sx?)?$/;
1467
+ // The explicit poison-import marker (`@nifrajs/web/backend-only`, or its retired `server-only` name) - a
1468
+ // module opting into the client-leak guard. A resolved file carrying this side-effect import is a sink.
1469
+ const BACKEND_ONLY_MARKER_IMPORT = /import\s+["']@nifrajs\/web\/(?:backend|server)-only["']/;
1265
1470
  // Depth/visited caps keep the walk linear + cycle-safe. A route's server-only dependency sits within a
1266
1471
  // few hops in practice; the bound stops a pathological graph from blowing up the per-file scan.
1267
1472
  const TRANSITIVE_MAX_DEPTH = 8;
@@ -1278,11 +1483,10 @@ const staticImportRegex = () => new RegExp(STATIC_IMPORT.source, STATIC_IMPORT.f
1278
1483
  * each with the match index (for line attribution). Mirrors {@link STATIC_IMPORT}, so `import type` +
1279
1484
  * dynamic `import()` are already excluded. Uses a fresh regex instance, so it's safe under the reentrant
1280
1485
  * transitive walk. Pure. */
1281
- function staticImportEdges(content, facts, file) {
1486
+ function staticImportEdges(content, facts, file, positions = codePositionMask(content)) {
1282
1487
  const edges = [];
1283
1488
  const re = staticImportRegex();
1284
1489
  const code = stripComments(content);
1285
- const positions = codePositionMask(content);
1286
1490
  for (let m = re.exec(code); m !== null; m = re.exec(code)) {
1287
1491
  if (positions[m.index] === " ")
1288
1492
  continue;
@@ -1302,6 +1506,54 @@ function staticImportEdges(content, facts, file) {
1302
1506
  export function parseStaticImports(content, facts, file) {
1303
1507
  return staticImportEdges(content, facts, file).map((e) => e.specifier);
1304
1508
  }
1509
+ /** The literal dynamic `import("x")` calls in executable code, skipping type positions. With `facts`
1510
+ * the parser decides; without it (or when it cannot tell) the lexical rule does. Pure. */
1511
+ function dynamicImportEdges(content, facts, file, positions = codePositionMask(content)) {
1512
+ const edges = [];
1513
+ const re = new RegExp(DYNAMIC_IMPORT.source, DYNAMIC_IMPORT.flags);
1514
+ for (let m = re.exec(content); m !== null; m = re.exec(content)) {
1515
+ if (positions[m.index] === " ")
1516
+ continue; // inside a comment or a string
1517
+ const specifier = m[2];
1518
+ if (specifier === undefined)
1519
+ continue;
1520
+ const source = facts !== undefined && file !== undefined ? facts.parse(file, content) : undefined;
1521
+ const verdict = source === undefined || facts === undefined
1522
+ ? undefined
1523
+ : facts.isDynamicImportAt(source, m.index, specifier);
1524
+ if (verdict === false)
1525
+ continue;
1526
+ if (verdict === undefined) {
1527
+ if (TYPE_QUERY_BEFORE.test(content.slice(Math.max(0, m.index - 16), m.index)))
1528
+ continue;
1529
+ const end = m.index + m[0].length;
1530
+ if (m[0].endsWith(")") && TYPE_QUALIFIER_AFTER.test(content.slice(end, end + 64)))
1531
+ continue;
1532
+ }
1533
+ edges.push({ specifier, index: m.index });
1534
+ }
1535
+ return edges;
1536
+ }
1537
+ /** Every import a module's runtime code makes - static and literal dynamic - in source order. Pure. */
1538
+ function importEdges(content, facts, file) {
1539
+ // Without an `import(` there is nothing for the dynamic pass to find; skip its regex.
1540
+ const positions = codePositionMask(content);
1541
+ const edges = staticImportEdges(content, facts, file, positions);
1542
+ if (!content.includes("import(") && !/import\s+\(/.test(content))
1543
+ return edges;
1544
+ return [...edges, ...dynamicImportEdges(content, facts, file, positions)].sort((a, b) => a.index - b.index);
1545
+ }
1546
+ /** The runtime imports of a module with the line each sits on, in source order. Pure. */
1547
+ export function importSites(content, file) {
1548
+ return importEdges(content, undefined, file).map(({ specifier, index }) => ({
1549
+ specifier,
1550
+ line: lineAt(content, index),
1551
+ }));
1552
+ }
1553
+ /** The specifiers {@link importEdges} finds, static and literal dynamic, in source order. Pure. */
1554
+ export function parseImports(content, facts, file) {
1555
+ return importEdges(content, facts, file).map((e) => e.specifier);
1556
+ }
1305
1557
  /** The server-only SINK an import specifier names directly (a `node:`/`bun:` builtin or a known
1306
1558
  * server-only package), or `undefined` if it isn't a by-name sink. Pure - the label is the specifier
1307
1559
  * itself (it's already the actionable name). */
@@ -1311,10 +1563,11 @@ export function directSinkSpecifier(spec) {
1311
1563
  /**
1312
1564
  * BFS the LOCAL module graph from a route file for the SHORTEST import chain that reaches a server-only
1313
1565
  * sink, returning `[routeFile, …as-written specifiers…, sink]` or `undefined` if none is reachable. A
1314
- * node's outgoing edges are its static imports; an edge is followed only when it's a RELATIVE specifier
1566
+ * node's outgoing edges are its static and literal dynamic imports; an edge is followed only when it's a RELATIVE specifier
1315
1567
  * that `resolve` maps to a readable local file (so the walk never descends into node_modules or chases an
1316
1568
  * unresolvable alias). At each node, a by-name sink import (`node:fs`, `postgres`) OR a resolved
1317
- * `*.server` / `server-only`-marked dependency terminates the chain. Bounded by depth + a visited set, so
1569
+ * `server-only`-marked dependency terminates the chain; a `*.server` dependency is never entered, since
1570
+ * the client build empties it. Bounded by depth + a visited set, so
1318
1571
  * it's linear and cycle-free. `routeFile`/`routeContent` seed the walk; `resolve`/`read` supply the graph
1319
1572
  * - pure given those, so it's unit-testable with a fake graph.
1320
1573
  */
@@ -1327,7 +1580,7 @@ export function walkServerOnlyChain(routeFile, routeContent, resolve, read, fact
1327
1580
  for (const node of frontier) {
1328
1581
  if (node.depth >= TRANSITIVE_MAX_DEPTH)
1329
1582
  continue;
1330
- for (const spec of parseStaticImports(node.content, facts, node.abs)) {
1583
+ for (const spec of parseImports(node.content, facts, node.abs)) {
1331
1584
  // (a) A by-name sink (builtin / known server-only pkg) → the chain ends here (shortest first,
1332
1585
  // since BFS reaches the nearest sink before any deeper one).
1333
1586
  const sink = directSinkSpecifier(spec);
@@ -1340,15 +1593,17 @@ export function walkServerOnlyChain(routeFile, routeContent, resolve, read, fact
1340
1593
  const abs = resolve(node.abs, spec);
1341
1594
  if (abs === undefined || seen.has(abs))
1342
1595
  continue;
1343
- // (c) A resolved `*.server` module is a server-only sink by the `.server` convention - the chain
1344
- // ends at it (named by the as-written specifier).
1345
- if (SERVER_MODULE_FILE.test(abs))
1596
+ // (c) Backend code is a sink: the browser build refuses it. A `*.fn.ts` module ships as its
1597
+ // generated stub, so its imports never reach the browser and the walk does not enter it.
1598
+ if (isBackendModule(abs))
1346
1599
  return [...node.chain, spec];
1600
+ if (SERVER_FN_FILE.test(abs))
1601
+ continue;
1347
1602
  const content = read(abs);
1348
1603
  if (content === undefined)
1349
1604
  continue; // unreadable → can't walk; treat as a leaf
1350
- // (d) A resolved module that opts into the `server-only` marker is a sink too.
1351
- if (SERVER_ONLY_MARKER_IMPORT.test(content))
1605
+ // (d) A resolved module that opts into the `backend-only` marker is a sink too.
1606
+ if (BACKEND_ONLY_MARKER_IMPORT.test(content))
1352
1607
  return [...node.chain, spec];
1353
1608
  if (visited >= TRANSITIVE_MAX_VISITED)
1354
1609
  continue;
@@ -1370,7 +1625,7 @@ export function walkServerOnlyChain(routeFile, routeContent, resolve, read, fact
1370
1625
  * de-duplicated to the shortest chain per first-hop specifier. Non-route files yield `[]`.
1371
1626
  */
1372
1627
  export function resolveServerOnlyChains(file, content, resolve, read, facts) {
1373
- if (!ROUTE_FILE.test(file))
1628
+ if (!isBrowserSource(file))
1374
1629
  return [];
1375
1630
  const lines = content.split("\n");
1376
1631
  const out = [];
@@ -1378,7 +1633,7 @@ export function resolveServerOnlyChains(file, content, resolve, read, facts) {
1378
1633
  // Collect the route's import edges up front (fresh-regex scan) so the per-edge logic below can call
1379
1634
  // the REENTRANT transitive walk without corrupting a shared regex's `lastIndex` (the walk also scans
1380
1635
  // imports). Driving `STATIC_IMPORT.exec` here directly would restart this loop forever.
1381
- for (const { specifier, index } of staticImportEdges(content, facts, file)) {
1636
+ for (const { specifier, index } of importEdges(content, facts, file)) {
1382
1637
  if (flaggedSpecifiers.has(specifier))
1383
1638
  continue;
1384
1639
  const line = lineAt(content, index);
@@ -1404,16 +1659,18 @@ export function resolveServerOnlyChains(file, content, resolve, read, facts) {
1404
1659
  }
1405
1660
  continue;
1406
1661
  }
1407
- // The `.server` / marker sink can be the first hop itself.
1408
- if (SERVER_MODULE_FILE.test(abs)) {
1662
+ // A backend first hop and a marked module are sinks; a `*.fn.ts` hop ships as its stub.
1663
+ if (isBackendModule(abs)) {
1409
1664
  flaggedSpecifiers.add(specifier);
1410
1665
  out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: false });
1411
1666
  continue;
1412
1667
  }
1668
+ if (SERVER_FN_FILE.test(abs))
1669
+ continue;
1413
1670
  const depContent = read(abs);
1414
1671
  if (depContent === undefined)
1415
1672
  continue;
1416
- if (SERVER_ONLY_MARKER_IMPORT.test(depContent)) {
1673
+ if (BACKEND_ONLY_MARKER_IMPORT.test(depContent)) {
1417
1674
  flaggedSpecifiers.add(specifier);
1418
1675
  out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: false });
1419
1676
  continue;
@@ -1525,17 +1782,18 @@ export async function walkSource(cwd, visit, opts = {}) {
1525
1782
  const rels = [];
1526
1783
  for await (const rawRel of new Glob("**/*.{ts,tsx,mts,cts}").scan({ cwd, dot: false })) {
1527
1784
  const rel = normalizeProjectPath(rawRel);
1528
- if (!skip.test(rel))
1785
+ if (!skip.test(rel) && (opts.ignore === undefined || !opts.ignore(rel)))
1529
1786
  rels.push(rel);
1530
1787
  }
1531
1788
  const ignored = await gitIgnored(cwd, rels);
1532
- for (const rel of rels) {
1789
+ // Glob order is the filesystem's, so findings would come out in a different order on each platform.
1790
+ for (const rel of rels.sort(codeUnitOrder)) {
1533
1791
  if (ignored.has(rel))
1534
1792
  continue;
1535
1793
  visit(rel, await Bun.file(join(cwd, rel)).text());
1536
1794
  }
1537
1795
  }
1538
- const bySite = (a, b) => a.file.localeCompare(b.file) || a.line - b.line;
1796
+ const bySite = (a, b) => codeUnitOrder(a.file, b.file) || a.line - b.line;
1539
1797
  /** Collect own-API `fetch()` findings across the project. */
1540
1798
  export async function scanProject(cwd) {
1541
1799
  const out = [];
@@ -1552,8 +1810,11 @@ const GENERATED_MARKER = "GENERATED by @nifrajs/web generateServerManifest";
1552
1810
  // The first route-import specifier's prefix up to `routes/` (e.g. `./`, `../`, `./src/`) - used to
1553
1811
  // locate the routes dir relative to the manifest, and to strip to route-relative keys.
1554
1812
  const ROUTES_PREFIX = /["'](\.{1,2}(?:\/[^"'/]+)*?\/routes\/)[^"']+["']/;
1555
- // Route file extensions discovery recognises (mirrors `@nifrajs/web/fs`'s filter).
1813
+ // The files route discovery collects (mirrors `@nifrajs/web/fs`'s filter): route files by extension,
1814
+ // each route's backend half, and `_middleware` modules.
1556
1815
  const ROUTE_FILE_EXT = /\.(tsx|jsx|svelte|vue|mdx)$/;
1816
+ const ROUTE_BACKEND_HALF = /\.backend\.(?:[cm]?[jt]s)$/;
1817
+ const MIDDLEWARE_FILE = /(?:^|\/)_middleware\.(?:ts|js)$/;
1557
1818
  /**
1558
1819
  * Scan the project for committed, generated `server-manifest.ts` files and report any that have drifted
1559
1820
  * from the live `routes/` tree. For each, the routes dir is derived from the manifest's own import
@@ -1582,9 +1843,9 @@ export async function scanServerManifestDrift(cwd) {
1582
1843
  const routesDir = join(cwd, manifestDir, routesPrefix);
1583
1844
  let discovered;
1584
1845
  try {
1585
- discovered = (await Array.fromAsync(new Glob("**/*.{tsx,jsx,svelte,vue,mdx}").scan({ cwd: routesDir })))
1846
+ discovered = (await Array.fromAsync(new Glob("**/*.{tsx,jsx,svelte,vue,mdx,ts,js}").scan({ cwd: routesDir })))
1586
1847
  .map((f) => f.replaceAll("\\", "/"))
1587
- .filter((f) => ROUTE_FILE_EXT.test(f));
1848
+ .filter((f) => ROUTE_FILE_EXT.test(f) || ROUTE_BACKEND_HALF.test(f) || MIDDLEWARE_FILE.test(f));
1588
1849
  }
1589
1850
  catch {
1590
1851
  continue; // routes dir gone/unreadable - not a drift we can assess
@@ -1594,6 +1855,6 @@ export async function scanServerManifestDrift(cwd) {
1594
1855
  findings.push({ file: rel, missing: drift.missing, extra: drift.extra });
1595
1856
  }
1596
1857
  }
1597
- return findings.sort((a, b) => a.file.localeCompare(b.file));
1858
+ return findings.sort((a, b) => codeUnitOrder(a.file, b.file));
1598
1859
  }
1599
1860
  //# sourceMappingURL=check-scan.js.map