@nifrajs/cli 3.5.0 → 4.0.2

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 (404) hide show
  1. package/README.md +42 -3
  2. package/dist/app-files.d.ts +11 -0
  3. package/dist/app-files.d.ts.map +1 -0
  4. package/dist/app-files.js +34 -0
  5. package/dist/app-files.js.map +1 -0
  6. package/dist/assure-hydration.d.ts +7 -1
  7. package/dist/assure-hydration.d.ts.map +1 -1
  8. package/dist/assure-hydration.js +128 -43
  9. package/dist/assure-hydration.js.map +1 -1
  10. package/dist/assure.d.ts.map +1 -1
  11. package/dist/assure.js +16 -2
  12. package/dist/assure.js.map +1 -1
  13. package/dist/capabilities-tool.d.ts +5 -1
  14. package/dist/capabilities-tool.d.ts.map +1 -1
  15. package/dist/capabilities-tool.js +32 -10
  16. package/dist/capabilities-tool.js.map +1 -1
  17. package/dist/cdn-check.d.ts +41 -0
  18. package/dist/cdn-check.d.ts.map +1 -0
  19. package/dist/cdn-check.js +207 -0
  20. package/dist/cdn-check.js.map +1 -0
  21. package/dist/check-diagnostics.d.ts +4 -0
  22. package/dist/check-diagnostics.d.ts.map +1 -1
  23. package/dist/check-diagnostics.js +13 -0
  24. package/dist/check-diagnostics.js.map +1 -1
  25. package/dist/check-scan.d.ts +49 -7
  26. package/dist/check-scan.d.ts.map +1 -1
  27. package/dist/check-scan.js +291 -62
  28. package/dist/check-scan.js.map +1 -1
  29. package/dist/check.d.ts.map +1 -1
  30. package/dist/check.js +99 -63
  31. package/dist/check.js.map +1 -1
  32. package/dist/cli.d.ts +42 -16
  33. package/dist/cli.d.ts.map +1 -1
  34. package/dist/cli.js +262 -82
  35. package/dist/cli.js.map +1 -1
  36. package/dist/command-catalog.d.ts +62 -5
  37. package/dist/command-catalog.d.ts.map +1 -1
  38. package/dist/command-catalog.js +275 -35
  39. package/dist/command-catalog.js.map +1 -1
  40. package/dist/config-target.d.ts +8 -0
  41. package/dist/config-target.d.ts.map +1 -0
  42. package/dist/config-target.js +45 -0
  43. package/dist/config-target.js.map +1 -0
  44. package/dist/contracts.d.ts.map +1 -1
  45. package/dist/contracts.js +52 -13
  46. package/dist/contracts.js.map +1 -1
  47. package/dist/db-audit.d.ts +37 -0
  48. package/dist/db-audit.d.ts.map +1 -0
  49. package/dist/db-audit.js +99 -0
  50. package/dist/db-audit.js.map +1 -0
  51. package/dist/db-child.d.ts +64 -0
  52. package/dist/db-child.d.ts.map +1 -0
  53. package/dist/db-child.js +213 -0
  54. package/dist/db-child.js.map +1 -0
  55. package/dist/db-config.d.ts +60 -0
  56. package/dist/db-config.d.ts.map +1 -0
  57. package/dist/db-config.js +176 -0
  58. package/dist/db-config.js.map +1 -0
  59. package/dist/db-tool.d.ts +84 -0
  60. package/dist/db-tool.d.ts.map +1 -0
  61. package/dist/db-tool.js +537 -0
  62. package/dist/db-tool.js.map +1 -0
  63. package/dist/dev-bun-config.d.ts +20 -13
  64. package/dist/dev-bun-config.d.ts.map +1 -1
  65. package/dist/dev-bun-config.js +98 -41
  66. package/dist/dev-bun-config.js.map +1 -1
  67. package/dist/dev-feed-tool.d.ts +106 -0
  68. package/dist/dev-feed-tool.d.ts.map +1 -0
  69. package/dist/dev-feed-tool.js +713 -0
  70. package/dist/dev-feed-tool.js.map +1 -0
  71. package/dist/dev-server-client.d.ts +67 -0
  72. package/dist/dev-server-client.d.ts.map +1 -0
  73. package/dist/dev-server-client.js +195 -0
  74. package/dist/dev-server-client.js.map +1 -0
  75. package/dist/diff-tool.d.ts +3 -3
  76. package/dist/diff-tool.d.ts.map +1 -1
  77. package/dist/diff-tool.js +6 -5
  78. package/dist/diff-tool.js.map +1 -1
  79. package/dist/docs-search.d.ts.map +1 -1
  80. package/dist/docs-search.js +12 -4
  81. package/dist/docs-search.js.map +1 -1
  82. package/dist/doctor.d.ts +17 -0
  83. package/dist/doctor.d.ts.map +1 -1
  84. package/dist/doctor.js +76 -15
  85. package/dist/doctor.js.map +1 -1
  86. package/dist/env-file.d.ts +15 -0
  87. package/dist/env-file.d.ts.map +1 -1
  88. package/dist/env-file.js +73 -1
  89. package/dist/env-file.js.map +1 -1
  90. package/dist/fix-recipes.d.ts.map +1 -1
  91. package/dist/fix-recipes.js +48 -22
  92. package/dist/fix-recipes.js.map +1 -1
  93. package/dist/frontend-guidance.js +5 -5
  94. package/dist/frontend-guidance.js.map +1 -1
  95. package/dist/i18n-check.d.ts +16 -0
  96. package/dist/i18n-check.d.ts.map +1 -0
  97. package/dist/i18n-check.js +154 -0
  98. package/dist/i18n-check.js.map +1 -0
  99. package/dist/init-agents.d.ts +49 -13
  100. package/dist/init-agents.d.ts.map +1 -1
  101. package/dist/init-agents.js +239 -34
  102. package/dist/init-agents.js.map +1 -1
  103. package/dist/internal/code-unit-order.d.ts +4 -0
  104. package/dist/internal/code-unit-order.d.ts.map +1 -0
  105. package/dist/internal/code-unit-order.js +6 -0
  106. package/dist/internal/code-unit-order.js.map +1 -0
  107. package/dist/internal/script-kind.d.ts +7 -0
  108. package/dist/internal/script-kind.d.ts.map +1 -0
  109. package/dist/internal/script-kind.js +12 -0
  110. package/dist/internal/script-kind.js.map +1 -0
  111. package/dist/internal/source-facts.d.ts +5 -0
  112. package/dist/internal/source-facts.d.ts.map +1 -1
  113. package/dist/internal/source-facts.js +61 -5
  114. package/dist/internal/source-facts.js.map +1 -1
  115. package/dist/internal/typescript-import.d.ts +49 -24
  116. package/dist/internal/typescript-import.d.ts.map +1 -1
  117. package/dist/internal/typescript-import.js +507 -67
  118. package/dist/internal/typescript-import.js.map +1 -1
  119. package/dist/introspect.d.ts +5 -1
  120. package/dist/introspect.d.ts.map +1 -1
  121. package/dist/introspect.js +92 -27
  122. package/dist/introspect.js.map +1 -1
  123. package/dist/learn.js +3 -3
  124. package/dist/learn.js.map +1 -1
  125. package/dist/levels-tool.d.ts +10 -0
  126. package/dist/levels-tool.d.ts.map +1 -1
  127. package/dist/levels-tool.js +33 -3
  128. package/dist/levels-tool.js.map +1 -1
  129. package/dist/load.d.ts +67 -17
  130. package/dist/load.d.ts.map +1 -1
  131. package/dist/load.js +78 -18
  132. package/dist/load.js.map +1 -1
  133. package/dist/manifest-tool.d.ts.map +1 -1
  134. package/dist/manifest-tool.js +3 -2
  135. package/dist/manifest-tool.js.map +1 -1
  136. package/dist/mcp-app-tool.d.ts.map +1 -1
  137. package/dist/mcp-app-tool.js +2 -0
  138. package/dist/mcp-app-tool.js.map +1 -1
  139. package/dist/mcp-context.js +1 -1
  140. package/dist/mcp-context.js.map +1 -1
  141. package/dist/mcp-delegate.d.ts +57 -0
  142. package/dist/mcp-delegate.d.ts.map +1 -0
  143. package/dist/mcp-delegate.js +130 -0
  144. package/dist/mcp-delegate.js.map +1 -0
  145. package/dist/mcp-docs-tools.d.ts.map +1 -1
  146. package/dist/mcp-docs-tools.js +23 -5
  147. package/dist/mcp-docs-tools.js.map +1 -1
  148. package/dist/mcp-exec.d.ts +2 -0
  149. package/dist/mcp-exec.d.ts.map +1 -1
  150. package/dist/mcp-exec.js +132 -108
  151. package/dist/mcp-exec.js.map +1 -1
  152. package/dist/mcp-http.d.ts.map +1 -1
  153. package/dist/mcp-http.js +17 -5
  154. package/dist/mcp-http.js.map +1 -1
  155. package/dist/mcp-io.d.ts +14 -0
  156. package/dist/mcp-io.d.ts.map +1 -1
  157. package/dist/mcp-io.js +45 -0
  158. package/dist/mcp-io.js.map +1 -1
  159. package/dist/mcp-isolate.d.ts +48 -0
  160. package/dist/mcp-isolate.d.ts.map +1 -0
  161. package/dist/mcp-isolate.js +222 -0
  162. package/dist/mcp-isolate.js.map +1 -0
  163. package/dist/mcp-project-child.d.ts +74 -0
  164. package/dist/mcp-project-child.d.ts.map +1 -0
  165. package/dist/mcp-project-child.js +130 -0
  166. package/dist/mcp-project-child.js.map +1 -0
  167. package/dist/mcp-render.js +2 -2
  168. package/dist/mcp-render.js.map +1 -1
  169. package/dist/mcp-root.d.ts +37 -9
  170. package/dist/mcp-root.d.ts.map +1 -1
  171. package/dist/mcp-root.js +110 -23
  172. package/dist/mcp-root.js.map +1 -1
  173. package/dist/mcp-run.d.ts +18 -1
  174. package/dist/mcp-run.d.ts.map +1 -1
  175. package/dist/mcp-run.js +82 -9
  176. package/dist/mcp-run.js.map +1 -1
  177. package/dist/mcp-ws.js +4 -1
  178. package/dist/mcp-ws.js.map +1 -1
  179. package/dist/mcp.d.ts +16 -3
  180. package/dist/mcp.d.ts.map +1 -1
  181. package/dist/mcp.js +81 -53
  182. package/dist/mcp.js.map +1 -1
  183. package/dist/migrate-layout.d.ts +44 -0
  184. package/dist/migrate-layout.d.ts.map +1 -0
  185. package/dist/migrate-layout.js +1155 -0
  186. package/dist/migrate-layout.js.map +1 -0
  187. package/dist/openapi-tool.d.ts +2 -2
  188. package/dist/openapi-tool.d.ts.map +1 -1
  189. package/dist/openapi-tool.js.map +1 -1
  190. package/dist/openapi-types.d.ts +2 -1
  191. package/dist/openapi-types.d.ts.map +1 -1
  192. package/dist/openapi-types.js +240 -12
  193. package/dist/openapi-types.js.map +1 -1
  194. package/dist/pipeline-report.d.ts +3 -2
  195. package/dist/pipeline-report.d.ts.map +1 -1
  196. package/dist/pipeline-report.js +19 -16
  197. package/dist/pipeline-report.js.map +1 -1
  198. package/dist/port.d.ts +5 -4
  199. package/dist/port.d.ts.map +1 -1
  200. package/dist/port.js +46 -29
  201. package/dist/port.js.map +1 -1
  202. package/dist/project-facts.d.ts +5 -0
  203. package/dist/project-facts.d.ts.map +1 -1
  204. package/dist/project-facts.js.map +1 -1
  205. package/dist/recipes/4.0.0.d.ts +9 -0
  206. package/dist/recipes/4.0.0.d.ts.map +1 -0
  207. package/dist/recipes/4.0.0.js +36 -0
  208. package/dist/recipes/4.0.0.js.map +1 -0
  209. package/dist/recipes/index.d.ts.map +1 -1
  210. package/dist/recipes/index.js +2 -0
  211. package/dist/recipes/index.js.map +1 -1
  212. package/dist/release-verification.d.ts +11 -1
  213. package/dist/release-verification.d.ts.map +1 -1
  214. package/dist/release-verification.js +47 -4
  215. package/dist/release-verification.js.map +1 -1
  216. package/dist/review.d.ts.map +1 -1
  217. package/dist/review.js +12 -8
  218. package/dist/review.js.map +1 -1
  219. package/dist/route-types.d.ts +28 -0
  220. package/dist/route-types.d.ts.map +1 -0
  221. package/dist/route-types.js +106 -0
  222. package/dist/route-types.js.map +1 -0
  223. package/dist/rules/codes.d.ts +9 -0
  224. package/dist/rules/codes.d.ts.map +1 -1
  225. package/dist/rules/codes.js +9 -0
  226. package/dist/rules/codes.js.map +1 -1
  227. package/dist/rules/data-guard.d.ts +12 -0
  228. package/dist/rules/data-guard.d.ts.map +1 -0
  229. package/dist/rules/data-guard.js +213 -0
  230. package/dist/rules/data-guard.js.map +1 -0
  231. package/dist/rules/index.d.ts +3 -0
  232. package/dist/rules/index.d.ts.map +1 -1
  233. package/dist/rules/index.js +17 -5
  234. package/dist/rules/index.js.map +1 -1
  235. package/dist/rules/islands.d.ts.map +1 -1
  236. package/dist/rules/islands.js +7 -4
  237. package/dist/rules/islands.js.map +1 -1
  238. package/dist/rules/legacy.d.ts.map +1 -1
  239. package/dist/rules/legacy.js +71 -38
  240. package/dist/rules/legacy.js.map +1 -1
  241. package/dist/rules/nano.d.ts.map +1 -1
  242. package/dist/rules/nano.js +15 -6
  243. package/dist/rules/nano.js.map +1 -1
  244. package/dist/rules/pages.d.ts +15 -0
  245. package/dist/rules/pages.d.ts.map +1 -0
  246. package/dist/rules/pages.js +88 -0
  247. package/dist/rules/pages.js.map +1 -0
  248. package/dist/rules/routes.d.ts +11 -0
  249. package/dist/rules/routes.d.ts.map +1 -1
  250. package/dist/rules/routes.js +180 -2
  251. package/dist/rules/routes.js.map +1 -1
  252. package/dist/rules/secrets.d.ts +10 -0
  253. package/dist/rules/secrets.d.ts.map +1 -0
  254. package/dist/rules/secrets.js +169 -0
  255. package/dist/rules/secrets.js.map +1 -0
  256. package/dist/rules/security.d.ts +7 -0
  257. package/dist/rules/security.d.ts.map +1 -1
  258. package/dist/rules/security.js +171 -65
  259. package/dist/rules/security.js.map +1 -1
  260. package/dist/rules/typescript.d.ts +16 -0
  261. package/dist/rules/typescript.d.ts.map +1 -0
  262. package/dist/rules/typescript.js +59 -0
  263. package/dist/rules/typescript.js.map +1 -0
  264. package/dist/rules/zones.d.ts +17 -0
  265. package/dist/rules/zones.d.ts.map +1 -0
  266. package/dist/rules/zones.js +174 -0
  267. package/dist/rules/zones.js.map +1 -0
  268. package/dist/scaffold.d.ts +25 -12
  269. package/dist/scaffold.d.ts.map +1 -1
  270. package/dist/scaffold.js +132 -87
  271. package/dist/scaffold.js.map +1 -1
  272. package/dist/sdk.d.ts +17 -3
  273. package/dist/sdk.d.ts.map +1 -1
  274. package/dist/sdk.js +941 -153
  275. package/dist/sdk.js.map +1 -1
  276. package/dist/search-terms.d.ts +2 -0
  277. package/dist/search-terms.d.ts.map +1 -1
  278. package/dist/search-terms.js +23 -8
  279. package/dist/search-terms.js.map +1 -1
  280. package/dist/smoke.d.ts +79 -0
  281. package/dist/smoke.d.ts.map +1 -0
  282. package/dist/smoke.js +494 -0
  283. package/dist/smoke.js.map +1 -0
  284. package/dist/sync-manifest.d.ts +10 -2
  285. package/dist/sync-manifest.d.ts.map +1 -1
  286. package/dist/sync-manifest.js +44 -19
  287. package/dist/sync-manifest.js.map +1 -1
  288. package/dist/sync-routes.d.ts +4 -3
  289. package/dist/sync-routes.d.ts.map +1 -1
  290. package/dist/sync-routes.js +4 -3
  291. package/dist/sync-routes.js.map +1 -1
  292. package/dist/test-tool.d.ts.map +1 -1
  293. package/dist/test-tool.js +30 -11
  294. package/dist/test-tool.js.map +1 -1
  295. package/dist/types-search.d.ts.map +1 -1
  296. package/dist/types-search.js +11 -5
  297. package/dist/types-search.js.map +1 -1
  298. package/dist/upgrade.d.ts +36 -6
  299. package/dist/upgrade.d.ts.map +1 -1
  300. package/dist/upgrade.js +167 -29
  301. package/dist/upgrade.js.map +1 -1
  302. package/dist/verification-plan.d.ts.map +1 -1
  303. package/dist/verification-plan.js +4 -0
  304. package/dist/verification-plan.js.map +1 -1
  305. package/dist/verification.d.ts.map +1 -1
  306. package/dist/verification.js +45 -12
  307. package/dist/verification.js.map +1 -1
  308. package/dist/web-app-options.d.ts +19 -0
  309. package/dist/web-app-options.d.ts.map +1 -0
  310. package/dist/web-app-options.js +49 -0
  311. package/dist/web-app-options.js.map +1 -0
  312. package/dist/work-graph.d.ts.map +1 -1
  313. package/dist/work-graph.js +29 -13
  314. package/dist/work-graph.js.map +1 -1
  315. package/docs/examples.json +185 -29
  316. package/docs/llms-full.txt +3807 -580
  317. package/docs/types.json +14229 -10939
  318. package/package.json +19 -12
  319. package/src/app-files.ts +40 -0
  320. package/src/assure-hydration.ts +139 -46
  321. package/src/assure.ts +17 -2
  322. package/src/capabilities-tool.ts +35 -9
  323. package/src/cdn-check.ts +268 -0
  324. package/src/check-diagnostics.ts +17 -0
  325. package/src/check-scan.ts +358 -63
  326. package/src/check.ts +97 -62
  327. package/src/cli.ts +296 -87
  328. package/src/command-catalog.ts +389 -48
  329. package/src/config-target.ts +47 -0
  330. package/src/contracts.ts +54 -12
  331. package/src/db-audit.ts +128 -0
  332. package/src/db-child.ts +285 -0
  333. package/src/db-config.ts +224 -0
  334. package/src/db-tool.ts +690 -0
  335. package/src/dev-bun-config.ts +116 -40
  336. package/src/dev-feed-tool.ts +912 -0
  337. package/src/dev-server-client.ts +267 -0
  338. package/src/diff-tool.ts +6 -5
  339. package/src/docs-search.ts +10 -3
  340. package/src/doctor.ts +98 -15
  341. package/src/env-file.ts +78 -1
  342. package/src/fix-recipes.ts +53 -32
  343. package/src/frontend-guidance.ts +5 -5
  344. package/src/i18n-check.ts +179 -0
  345. package/src/init-agents.ts +301 -38
  346. package/src/internal/code-unit-order.ts +5 -0
  347. package/src/internal/script-kind.ts +11 -0
  348. package/src/internal/source-facts.ts +96 -22
  349. package/src/internal/typescript-import.ts +663 -68
  350. package/src/introspect.ts +90 -27
  351. package/src/learn.ts +3 -3
  352. package/src/levels-tool.ts +47 -2
  353. package/src/load.ts +140 -33
  354. package/src/manifest-tool.ts +3 -2
  355. package/src/mcp-app-tool.ts +2 -0
  356. package/src/mcp-context.ts +1 -1
  357. package/src/mcp-delegate.ts +162 -0
  358. package/src/mcp-docs-tools.ts +23 -5
  359. package/src/mcp-exec.ts +160 -132
  360. package/src/mcp-http.ts +19 -5
  361. package/src/mcp-io.ts +57 -0
  362. package/src/mcp-isolate.ts +287 -0
  363. package/src/mcp-project-child.ts +182 -0
  364. package/src/mcp-render.ts +2 -2
  365. package/src/mcp-root.ts +117 -22
  366. package/src/mcp-run.ts +104 -10
  367. package/src/mcp-ws.ts +4 -1
  368. package/src/mcp.ts +106 -64
  369. package/src/migrate-layout.ts +1338 -0
  370. package/src/openapi-tool.ts +7 -3
  371. package/src/openapi-types.ts +315 -13
  372. package/src/pipeline-report.ts +22 -18
  373. package/src/port.ts +53 -30
  374. package/src/project-facts.ts +5 -0
  375. package/src/recipes/4.0.0.ts +37 -0
  376. package/src/recipes/index.ts +2 -0
  377. package/src/release-verification.ts +74 -5
  378. package/src/review.ts +13 -8
  379. package/src/route-types.ts +133 -0
  380. package/src/rules/codes.ts +9 -0
  381. package/src/rules/data-guard.ts +230 -0
  382. package/src/rules/index.ts +18 -5
  383. package/src/rules/islands.ts +12 -4
  384. package/src/rules/legacy.ts +71 -39
  385. package/src/rules/nano.ts +18 -6
  386. package/src/rules/pages.ts +99 -0
  387. package/src/rules/routes.ts +181 -2
  388. package/src/rules/secrets.ts +174 -0
  389. package/src/rules/security.ts +198 -70
  390. package/src/rules/typescript.ts +77 -0
  391. package/src/rules/zones.ts +195 -0
  392. package/src/scaffold.ts +147 -95
  393. package/src/sdk.ts +1215 -158
  394. package/src/search-terms.ts +24 -7
  395. package/src/smoke.ts +607 -0
  396. package/src/sync-manifest.ts +50 -19
  397. package/src/sync-routes.ts +4 -3
  398. package/src/test-tool.ts +32 -11
  399. package/src/types-search.ts +11 -4
  400. package/src/upgrade.ts +205 -25
  401. package/src/verification-plan.ts +13 -0
  402. package/src/verification.ts +50 -12
  403. package/src/web-app-options.ts +57 -0
  404. package/src/work-graph.ts +27 -13
@@ -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;
@@ -499,8 +544,47 @@ export function scanStaticRouteText(file, content, facts) {
499
544
  return [
500
545
  ...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_DQ, facts),
501
546
  ...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_SQ, facts),
547
+ ...(code.includes(METHODS_SUBPATH) ? scanMethodRoutes(file, content, code, facts) : []),
502
548
  ].sort(bySite);
503
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
+ }
504
588
  /**
505
589
  * SQL built by interpolating a value into the statement text. The value is not a parameter, it is
506
590
  * STATEMENT, so anything the caller controls can end the literal and continue as SQL.
@@ -610,8 +694,16 @@ export const createSqlImports = (load) => ({
610
694
  * a typecheck failure; parser recovery nodes are not evidence of anything, so a file that fails to
611
695
  * parse contributes no findings and proves no constant. */
612
696
  function parseSqlSource(ts, file, content) {
613
- const kind = /\.[cm]?tsx?$/.test(file) ? ts.ScriptKind.TSX : ts.ScriptKind.JS;
614
- 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
+ }
615
707
  const parseDiagnostics = source.parseDiagnostics;
616
708
  return parseDiagnostics !== undefined && parseDiagnostics.length > 0 ? undefined : source;
617
709
  }
@@ -1118,13 +1210,27 @@ export function scanInterpolatedSql(file, content, ts, imports) {
1118
1210
  * answer a security rule must never give when it did not run.
1119
1211
  */
1120
1212
  export async function scanProjectSql(cwd) {
1121
- const ts = await importProjectTypeScript(cwd);
1122
- 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)
1123
1222
  return undefined;
1124
1223
  const out = [];
1125
1224
  const imports = createProjectSqlImports(cwd);
1126
- await walkSource(cwd, (rel, content) => out.push(...scanInterpolatedSql(rel, content, ts, imports)));
1127
- 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
+ }
1128
1234
  }
1129
1235
  /** A declaration file. Excluded from cross-module resolution: `declare const X: string` has no
1130
1236
  * initializer to read, so following one could only ever produce a "proved" the source never wrote. */
@@ -1216,6 +1322,16 @@ export const REMOVED_IMPORTS = [
1216
1322
  since: "2.0",
1217
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',
1218
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
+ },
1219
1335
  {
1220
1336
  specifier: "@nifrajs/core/ws",
1221
1337
  since: "2.0",
@@ -1231,12 +1347,13 @@ function isSideEffectImport(content, index) {
1231
1347
  // no `from` in between.
1232
1348
  return /^import\s*["'`]/.test(content.slice(index, index + 32));
1233
1349
  }
1234
- /** Flag an import of a specifier that no longer exists. Pure; matches the exact specifier or a subpath
1235
- * 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`. */
1236
1353
  export function scanRemovedImports(file, content) {
1237
1354
  const out = [];
1238
1355
  const lines = content.split("\n");
1239
- for (const edge of staticImportEdges(content)) {
1356
+ for (const edge of importEdges(content)) {
1240
1357
  const removed = REMOVED_IMPORTS.find((entry) => edge.specifier === entry.specifier || edge.specifier.startsWith(`${entry.specifier}/`));
1241
1358
  if (removed === undefined)
1242
1359
  continue;
@@ -1245,33 +1362,89 @@ export function scanRemovedImports(file, content) {
1245
1362
  const line = lineAt(content, edge.index);
1246
1363
  out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() });
1247
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
+ }
1248
1370
  return out;
1249
1371
  }
1250
- /** Scan a route module for top-level server-only imports. Returns `[]` for non-route files (only
1251
- * `routes/` modules are browser-bundled, so a server-only import elsewhere is fine). Each finding carries
1252
- * the offending `specifier` so the diagnostic can render the `routeFile → specifier` chain. Pure. */
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
+ }
1432
+ return out;
1433
+ }
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. */
1253
1437
  export function scanServerOnlyImports(file, content, facts) {
1254
- if (!ROUTE_FILE.test(file))
1438
+ if (!isBrowserSource(file))
1255
1439
  return [];
1256
1440
  const out = [];
1257
1441
  const lines = content.split("\n");
1258
- const code = stripComments(content);
1259
- const positions = codePositionMask(content);
1260
- STATIC_IMPORT.lastIndex = 0;
1261
- for (let m = STATIC_IMPORT.exec(code); m !== null; m = STATIC_IMPORT.exec(code)) {
1262
- if (positions[m.index] === " ")
1263
- continue;
1264
- 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)) {
1265
1445
  if (!SERVER_ONLY.test(specifier))
1266
1446
  continue;
1267
- if (facts !== undefined) {
1268
- const source = facts.parse(file, content);
1269
- // Inline `import { type X } from "…"` is erased just like `import type`; don't call it a
1270
- // runtime leak. A parse failure keeps the old lexical finding so the security rule fails closed.
1271
- if (source !== undefined && facts.isValueImportAt(source, m.index, specifier) === false)
1272
- continue;
1273
- }
1274
- const line = lineAt(content, m.index);
1447
+ const line = lineAt(content, index);
1275
1448
  out.push({ file, line, snippet: (lines[line - 1] ?? "").trim(), specifier });
1276
1449
  }
1277
1450
  return out;
@@ -1289,11 +1462,11 @@ export function scanServerOnlyImports(file, content, facts) {
1289
1462
  // npm package. These are never local source we can walk into, so the chain ends here. (Same vocabulary
1290
1463
  // as SERVER_ONLY, minus the relative `../db` arm - a relative `db` module IS local source we resolve.)
1291
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/;
1292
- // The `.server` convention: a module named `*.server.ts(x)` is server-only (the client build empties it).
1293
- const SERVER_MODULE_FILE = /\.server(\.[cm]?[jt]sx?)?$/;
1294
- // The explicit poison-import marker (`@nifrajs/web/server-only`) - a module opting into the client-leak
1295
- // guard. A resolved file whose source carries this side-effect import is a server-only sink.
1296
- 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["']/;
1297
1470
  // Depth/visited caps keep the walk linear + cycle-safe. A route's server-only dependency sits within a
1298
1471
  // few hops in practice; the bound stops a pathological graph from blowing up the per-file scan.
1299
1472
  const TRANSITIVE_MAX_DEPTH = 8;
@@ -1310,11 +1483,10 @@ const staticImportRegex = () => new RegExp(STATIC_IMPORT.source, STATIC_IMPORT.f
1310
1483
  * each with the match index (for line attribution). Mirrors {@link STATIC_IMPORT}, so `import type` +
1311
1484
  * dynamic `import()` are already excluded. Uses a fresh regex instance, so it's safe under the reentrant
1312
1485
  * transitive walk. Pure. */
1313
- function staticImportEdges(content, facts, file) {
1486
+ function staticImportEdges(content, facts, file, positions = codePositionMask(content)) {
1314
1487
  const edges = [];
1315
1488
  const re = staticImportRegex();
1316
1489
  const code = stripComments(content);
1317
- const positions = codePositionMask(content);
1318
1490
  for (let m = re.exec(code); m !== null; m = re.exec(code)) {
1319
1491
  if (positions[m.index] === " ")
1320
1492
  continue;
@@ -1334,6 +1506,54 @@ function staticImportEdges(content, facts, file) {
1334
1506
  export function parseStaticImports(content, facts, file) {
1335
1507
  return staticImportEdges(content, facts, file).map((e) => e.specifier);
1336
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
+ }
1337
1557
  /** The server-only SINK an import specifier names directly (a `node:`/`bun:` builtin or a known
1338
1558
  * server-only package), or `undefined` if it isn't a by-name sink. Pure - the label is the specifier
1339
1559
  * itself (it's already the actionable name). */
@@ -1343,10 +1563,11 @@ export function directSinkSpecifier(spec) {
1343
1563
  /**
1344
1564
  * BFS the LOCAL module graph from a route file for the SHORTEST import chain that reaches a server-only
1345
1565
  * sink, returning `[routeFile, …as-written specifiers…, sink]` or `undefined` if none is reachable. A
1346
- * 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
1347
1567
  * that `resolve` maps to a readable local file (so the walk never descends into node_modules or chases an
1348
1568
  * unresolvable alias). At each node, a by-name sink import (`node:fs`, `postgres`) OR a resolved
1349
- * `*.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
1350
1571
  * it's linear and cycle-free. `routeFile`/`routeContent` seed the walk; `resolve`/`read` supply the graph
1351
1572
  * - pure given those, so it's unit-testable with a fake graph.
1352
1573
  */
@@ -1359,7 +1580,7 @@ export function walkServerOnlyChain(routeFile, routeContent, resolve, read, fact
1359
1580
  for (const node of frontier) {
1360
1581
  if (node.depth >= TRANSITIVE_MAX_DEPTH)
1361
1582
  continue;
1362
- for (const spec of parseStaticImports(node.content, facts, node.abs)) {
1583
+ for (const spec of parseImports(node.content, facts, node.abs)) {
1363
1584
  // (a) A by-name sink (builtin / known server-only pkg) → the chain ends here (shortest first,
1364
1585
  // since BFS reaches the nearest sink before any deeper one).
1365
1586
  const sink = directSinkSpecifier(spec);
@@ -1372,15 +1593,17 @@ export function walkServerOnlyChain(routeFile, routeContent, resolve, read, fact
1372
1593
  const abs = resolve(node.abs, spec);
1373
1594
  if (abs === undefined || seen.has(abs))
1374
1595
  continue;
1375
- // (c) A resolved `*.server` module is a server-only sink by the `.server` convention - the chain
1376
- // ends at it (named by the as-written specifier).
1377
- 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))
1378
1599
  return [...node.chain, spec];
1600
+ if (SERVER_FN_FILE.test(abs))
1601
+ continue;
1379
1602
  const content = read(abs);
1380
1603
  if (content === undefined)
1381
1604
  continue; // unreadable → can't walk; treat as a leaf
1382
- // (d) A resolved module that opts into the `server-only` marker is a sink too.
1383
- 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))
1384
1607
  return [...node.chain, spec];
1385
1608
  if (visited >= TRANSITIVE_MAX_VISITED)
1386
1609
  continue;
@@ -1402,7 +1625,7 @@ export function walkServerOnlyChain(routeFile, routeContent, resolve, read, fact
1402
1625
  * de-duplicated to the shortest chain per first-hop specifier. Non-route files yield `[]`.
1403
1626
  */
1404
1627
  export function resolveServerOnlyChains(file, content, resolve, read, facts) {
1405
- if (!ROUTE_FILE.test(file))
1628
+ if (!isBrowserSource(file))
1406
1629
  return [];
1407
1630
  const lines = content.split("\n");
1408
1631
  const out = [];
@@ -1410,7 +1633,7 @@ export function resolveServerOnlyChains(file, content, resolve, read, facts) {
1410
1633
  // Collect the route's import edges up front (fresh-regex scan) so the per-edge logic below can call
1411
1634
  // the REENTRANT transitive walk without corrupting a shared regex's `lastIndex` (the walk also scans
1412
1635
  // imports). Driving `STATIC_IMPORT.exec` here directly would restart this loop forever.
1413
- for (const { specifier, index } of staticImportEdges(content, facts, file)) {
1636
+ for (const { specifier, index } of importEdges(content, facts, file)) {
1414
1637
  if (flaggedSpecifiers.has(specifier))
1415
1638
  continue;
1416
1639
  const line = lineAt(content, index);
@@ -1436,16 +1659,18 @@ export function resolveServerOnlyChains(file, content, resolve, read, facts) {
1436
1659
  }
1437
1660
  continue;
1438
1661
  }
1439
- // The `.server` / marker sink can be the first hop itself.
1440
- 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)) {
1441
1664
  flaggedSpecifiers.add(specifier);
1442
1665
  out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: false });
1443
1666
  continue;
1444
1667
  }
1668
+ if (SERVER_FN_FILE.test(abs))
1669
+ continue;
1445
1670
  const depContent = read(abs);
1446
1671
  if (depContent === undefined)
1447
1672
  continue;
1448
- if (SERVER_ONLY_MARKER_IMPORT.test(depContent)) {
1673
+ if (BACKEND_ONLY_MARKER_IMPORT.test(depContent)) {
1449
1674
  flaggedSpecifiers.add(specifier);
1450
1675
  out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: false });
1451
1676
  continue;
@@ -1557,17 +1782,18 @@ export async function walkSource(cwd, visit, opts = {}) {
1557
1782
  const rels = [];
1558
1783
  for await (const rawRel of new Glob("**/*.{ts,tsx,mts,cts}").scan({ cwd, dot: false })) {
1559
1784
  const rel = normalizeProjectPath(rawRel);
1560
- if (!skip.test(rel))
1785
+ if (!skip.test(rel) && (opts.ignore === undefined || !opts.ignore(rel)))
1561
1786
  rels.push(rel);
1562
1787
  }
1563
1788
  const ignored = await gitIgnored(cwd, rels);
1564
- 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)) {
1565
1791
  if (ignored.has(rel))
1566
1792
  continue;
1567
1793
  visit(rel, await Bun.file(join(cwd, rel)).text());
1568
1794
  }
1569
1795
  }
1570
- 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;
1571
1797
  /** Collect own-API `fetch()` findings across the project. */
1572
1798
  export async function scanProject(cwd) {
1573
1799
  const out = [];
@@ -1584,8 +1810,11 @@ const GENERATED_MARKER = "GENERATED by @nifrajs/web generateServerManifest";
1584
1810
  // The first route-import specifier's prefix up to `routes/` (e.g. `./`, `../`, `./src/`) - used to
1585
1811
  // locate the routes dir relative to the manifest, and to strip to route-relative keys.
1586
1812
  const ROUTES_PREFIX = /["'](\.{1,2}(?:\/[^"'/]+)*?\/routes\/)[^"']+["']/;
1587
- // 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.
1588
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)$/;
1589
1818
  /**
1590
1819
  * Scan the project for committed, generated `server-manifest.ts` files and report any that have drifted
1591
1820
  * from the live `routes/` tree. For each, the routes dir is derived from the manifest's own import
@@ -1614,9 +1843,9 @@ export async function scanServerManifestDrift(cwd) {
1614
1843
  const routesDir = join(cwd, manifestDir, routesPrefix);
1615
1844
  let discovered;
1616
1845
  try {
1617
- 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 })))
1618
1847
  .map((f) => f.replaceAll("\\", "/"))
1619
- .filter((f) => ROUTE_FILE_EXT.test(f));
1848
+ .filter((f) => ROUTE_FILE_EXT.test(f) || ROUTE_BACKEND_HALF.test(f) || MIDDLEWARE_FILE.test(f));
1620
1849
  }
1621
1850
  catch {
1622
1851
  continue; // routes dir gone/unreadable - not a drift we can assess
@@ -1626,6 +1855,6 @@ export async function scanServerManifestDrift(cwd) {
1626
1855
  findings.push({ file: rel, missing: drift.missing, extra: drift.extra });
1627
1856
  }
1628
1857
  }
1629
- return findings.sort((a, b) => a.file.localeCompare(b.file));
1858
+ return findings.sort((a, b) => codeUnitOrder(a.file, b.file));
1630
1859
  }
1631
1860
  //# sourceMappingURL=check-scan.js.map