@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
@@ -1,5 +1,6 @@
1
1
  import { existsSync } from "node:fs"
2
2
  import { resolve } from "node:path"
3
+ import { reservedKeyFor } from "@nifrajs/client"
3
4
  import type { AssuranceConfig, AssuranceReport } from "@nifrajs/core/assurance"
4
5
  import { type ProjectEvidenceSnapshot, snapshotProjectEvidence } from "@nifrajs/core/evidence"
5
6
  import { SINGLE_COPY_REGISTER_SPECIFIER } from "@nifrajs/core/single-copy"
@@ -7,6 +8,7 @@ import type { CapabilityProjectReport } from "../capabilities-tool.ts"
7
8
  import type { SourceFinding, StaticRouteFinding } from "../check-scan.ts"
8
9
  import {
9
10
  IDENT,
11
+ movedExportSites,
10
12
  parseSimpleFetchCall,
11
13
  REMOVED_IMPORTS,
12
14
  SIMPLE_REWRITE_METHODS,
@@ -19,6 +21,8 @@ import {
19
21
  diagnosticWithCompatibility,
20
22
  } from "../diagnostics.ts"
21
23
  import type { DuplicateInstallFinding } from "../doctor.ts"
24
+ import { codeUnitOrder } from "../internal/code-unit-order.ts"
25
+ import { unsupportedTypeScriptDiagnostic } from "../internal/typescript-import.ts"
22
26
  import type { CheckRule, RuleContext } from "./index.ts"
23
27
 
24
28
  /** Legacy rule name → stable NF- code. Exported so `nifra.check.json` rule overrides can be keyed
@@ -89,7 +93,7 @@ const FETCH_HINT =
89
93
  const STREAM_HINT =
90
94
  "hand-rolled EventSource/WebSocket to your own API - subscribe through client<typeof app> (`.subscribe()` for `app.sse()` routes, `.ws()` for `app.ws()` routes) so the compiler catches drift"
91
95
  const SERVER_IMPORT_HINT =
92
- "server-only import in a route module (bundled for the browser) - reach it via c.db / ctx.api inside a loader, never a top-level import"
96
+ "server-only code in browser code (a route's frontend half, frontend/ or shared/) - reach it from the route's backend half (x.backend.ts) or a *.fn.ts server function; a dynamic import() is bundled too"
93
97
  const RESPONSE_ROUTE_HINT =
94
98
  "route handler returns a raw Response - the typed client infers `data: never`, so drift detection is lost for this route. Return a plain object (it's serialized for you); for a stream use a typed SSE route (`app.sse(...)`), which keeps typed events; or, if a raw Response is intended (file/redirect), add `{ response: t.… }` or a `// nifra-expect raw-response` comment to mark it and silence this"
95
99
  const PIPELINE_DOC_HINT =
@@ -121,6 +125,8 @@ interface LegacyFields {
121
125
  readonly evidence?: readonly string[]
122
126
  readonly chain?: readonly string[]
123
127
  readonly fix?: string
128
+ /** A recipe for this one finding, when the rule's findings are not all mechanically fixable. */
129
+ readonly recipe?: Diagnostic["fix"]
124
130
  readonly suggestion?: DiagnosticSuggestion
125
131
  readonly verify?: string
126
132
  readonly includeCode?: boolean
@@ -141,7 +147,7 @@ function legacyDiagnostic(rule: string, fields: LegacyFields): Diagnostic {
141
147
  const code = LEGACY_RULE_CODES[rule]
142
148
  if (code === undefined) throw new Error(`unknown legacy rule ${rule}`)
143
149
  const evidence = fields.evidence ?? fields.chain
144
- const fix = canonicalRecipe(rule)
150
+ const fix = fields.recipe ?? canonicalRecipe(rule)
145
151
  const canonical: Diagnostic = {
146
152
  code,
147
153
  severity: fields.severity === "warning" ? "warn" : fields.severity,
@@ -163,7 +169,7 @@ function legacyDiagnostic(rule: string, fields: LegacyFields): Diagnostic {
163
169
  }
164
170
 
165
171
  const bySite = (a: SourceFinding, b: SourceFinding): number =>
166
- a.file.localeCompare(b.file) || a.line - b.line
172
+ codeUnitOrder(a.file, b.file) || a.line - b.line
167
173
 
168
174
  function oneLineDiff(file: string, line: number, before: string, after: string): string {
169
175
  return `--- ${file}:${line}\n+++ ${file}:${line}\n@@\n-${before}\n+${after}`
@@ -198,7 +204,10 @@ function typedClientCall(method: string, path: string): string {
198
204
  if (segs.length === 0) chain += ".index"
199
205
  else {
200
206
  for (const seg of segs) {
201
- chain += IDENT.test(seg) ? `.${seg}` : `[${JSON.stringify(seg)}]`
207
+ // The proxy answers a reserved name (`post`, `index`, `then`...) itself, so that segment is
208
+ // appended by a call instead of a property read.
209
+ if (reservedKeyFor(seg) !== undefined) chain += `(${JSON.stringify(seg)})`
210
+ else chain += IDENT.test(seg) ? `.${seg}` : `[${JSON.stringify(seg)}]`
202
211
  }
203
212
  }
204
213
  return `${chain}.${method.toLowerCase()}()`
@@ -285,12 +294,13 @@ function serverImportSuggestion(
285
294
  : undefined
286
295
  return {
287
296
  kind: "manual",
288
- title: "Move server-only code behind the route server boundary",
297
+ title: "Move server-only code behind the route's backend half",
289
298
  steps: [
290
299
  ...(chainStep === undefined ? [] : [chainStep]),
291
- `Remove the top-level \`import … from "${specifier}"\` from this route module (it's bundled for the browser).`,
292
- "Access backend/data work through the route `loader`/`action` context (`api`, `env`, or project server context).",
293
- `If a direct module import is unavoidable, lazy-load it (\`await import("${specifier}")\`) inside the server-only loader/action path.`,
300
+ `Remove the import of "${specifier}" from this module: it is browser code, bundled for the browser.`,
301
+ "Do the backend work in the route's backend half (`x.backend.ts` - its `loader` or `action`) and pass the result as loader data, or call a server function (`*.fn.ts`).",
302
+ "Code both sides need belongs in `shared/`, which may import only shared code and third-party packages.",
303
+ "A dynamic `import()` does not keep code off the client: the build bundles its target as a lazy chunk.",
294
304
  ],
295
305
  }
296
306
  }
@@ -398,7 +408,7 @@ const typedClientRule: CheckRule = {
398
408
  }),
399
409
  )
400
410
  return [...fetchFindings, ...streamFindings].sort(
401
- (a, b) => (a.file ?? "").localeCompare(b.file ?? "") || (a.line ?? 0) - (b.line ?? 0),
411
+ (a, b) => codeUnitOrder(a.file ?? "", b.file ?? "") || (a.line ?? 0) - (b.line ?? 0),
402
412
  )
403
413
  },
404
414
  }
@@ -408,6 +418,18 @@ const removedImportRule: CheckRule = {
408
418
  title: TITLES["removed-import"]!,
409
419
  async scan(ctx) {
410
420
  return [...ctx.project.sourceFindings.removedImports].sort(bySite).map((finding) => {
421
+ const moved = movedExportSites(finding.snippet)[0]
422
+ if (moved !== undefined) {
423
+ const names = moved.moved.map((binding) => `\`${binding.split(/\s+/)[0]}\``).join(", ")
424
+ return legacyDiagnostic("removed-import", {
425
+ severity: "error",
426
+ file: finding.file,
427
+ line: finding.line,
428
+ message: `${finding.snippet} - ${names} moved from "${moved.from}" to "${moved.to}"`,
429
+ fix: `import ${names} from "${moved.to}" - \`nifra fix --code NF-C005\` rewrites it`,
430
+ recipe: { recipe: "imports.moved-export", command: "nifra fix --code NF-C005" },
431
+ })
432
+ }
411
433
  const entry = REMOVED_IMPORTS.find(
412
434
  (candidate) =>
413
435
  finding.snippet.includes(`"${candidate.specifier}`) ||
@@ -467,7 +489,9 @@ const interpolatedSqlRule: CheckRule = {
467
489
  title: TITLES["interpolated-sql"]!,
468
490
  async scan(ctx) {
469
491
  const findings: Diagnostic[] = []
470
- if (!ctx.project.check.sqlCompilerAvailable) {
492
+ if (ctx.project.check.unsupportedTypeScriptVersion !== undefined) {
493
+ findings.push(unsupportedTypeScriptDiagnostic(ctx.project.check.unsupportedTypeScriptVersion))
494
+ } else if (!ctx.project.check.sqlCompilerAvailable) {
471
495
  findings.push(
472
496
  legacyDiagnostic("interpolated-sql", {
473
497
  severity: "warning",
@@ -528,7 +552,7 @@ const responseRouteRule: CheckRule = {
528
552
  function copyLines(finding: DuplicateInstallFinding): string[] {
529
553
  return finding.copies.map(
530
554
  (copy) =>
531
- `${finding.package}@${copy.version} at ${copy.absolutePath ?? copy.path} - pulled in by ${copy.importers.join(", ")}`,
555
+ `${finding.package}@${copy.version} at ${copy.absolutePath ?? copy.path} - pulled in by ${copy.importers.join(", ")}${copy.links === undefined ? "" : ` through the symlink ${copy.links.join(", ")}`}`,
532
556
  )
533
557
  }
534
558
 
@@ -547,6 +571,9 @@ function duplicateSuggestion(finding: DuplicateInstallFinding): DiagnosticSugges
547
571
  ].join("\n"),
548
572
  steps: [
549
573
  ...lines.map((line) => `Copy: ${line}`),
574
+ ...(finding.provenance === undefined
575
+ ? []
576
+ : [`Planted links: ${finding.provenance} Try this before the fixes below.`]),
550
577
  "Fix 1 - deduplicate: align workspace dependency and peer ranges on one compatible version, remove stale nested installs, and reinstall from the workspace root so every importer resolves one physical copy.",
551
578
  `Fix 2 - declare single-copy: apply the package.json and bunfig.toml config printed above; nifra then rewrites every duplicate to this app's copy.${finding.cause === "version-skew" ? " A declaration only covers same-version duplicates, so fix 1's range alignment must land first." : ""}`,
552
579
  "Re-run `nifra check`; the gate stays failing until one fix lands.",
@@ -596,16 +623,6 @@ const doctorRules: readonly CheckRule[] = Object.freeze([
596
623
  }),
597
624
  )
598
625
  }
599
- for (const finding of doctor.deduplicatedInstalls ?? []) {
600
- const paths = finding.copies.map((copy) => copy.path).join("; ")
601
- findings.push(
602
- legacyDiagnostic("duplicate-install", {
603
- severity: "warning",
604
- message: `${finding.package} is installed at ${finding.copies.length} paths (${paths}) and is declared single-copy - nifra resolves every duplicate to this app's copy`,
605
- fix: finding.remediation,
606
- }),
607
- )
608
- }
609
626
  return findings
610
627
  },
611
628
  },
@@ -732,6 +749,11 @@ const capabilityAssuranceRule: CheckRule = {
732
749
  finding.code === "unmatched-provenance-seam"
733
750
  ? "Write the seam exactly as the code imports it, or delete the rule."
734
751
  : undefined
752
+ const mountFix =
753
+ finding.code === "opaque-mount-undeclared"
754
+ ? 'merge() a nifra server() instead of mounting it, or add { opaque: "<why it cannot be analyzed>" } to the mount.'
755
+ : undefined
756
+ const ownFix = seamFix ?? mountFix
735
757
  findings.push(
736
758
  legacyDiagnostic("capability-assurance", {
737
759
  severity: "error",
@@ -740,30 +762,40 @@ const capabilityAssuranceRule: CheckRule = {
740
762
  ? {}
741
763
  : { chain: truncation.chain }
742
764
  : { file: violation.module, chain: violation.chain }),
743
- message: `${finding.message}${seamFix === undefined ? ` - ${CAPABILITY_HINT}` : ""}`,
744
- fix: seamFix ?? CAPABILITY_HINT,
765
+ message: `${finding.message}${ownFix === undefined ? ` - ${CAPABILITY_HINT}` : ""}`,
766
+ fix: ownFix ?? CAPABILITY_HINT,
745
767
  suggestion:
746
- seamFix === undefined
768
+ mountFix !== undefined
747
769
  ? {
748
770
  kind: "manual",
749
- title: "Restore declared effect provenance",
771
+ title: "Analyze the mounted routes, or state why they cannot be",
750
772
  steps: [
751
- "Route effectful work through an import listed in capabilities.provenance.imports.",
752
- "Declare the exact capability token on the route; do not widen unrelated routes in the same file.",
753
- "For domain writes, add the adapter the capability definition requires: `schema.idempotency` for the `request` tier, `.use(durableCommand({ journal }))` from @nifrajs/middleware for the `durable` tier.",
754
- "Run `nifra capabilities snapshot` only after assurance passes, then review the lockfile diff.",
773
+ "If the child is a nifra server() you own, merge() it so its routes join the analyzed app.",
774
+ 'If its effects live in code nifra cannot follow (a handler closure from another package), add { opaque: "<reason>" } to the mount.',
775
+ "A declared opaque mount is listed as a known gap on every run; it no longer fails assurance.",
755
776
  ],
756
777
  }
757
- : {
758
- kind: "manual",
759
- title: "Point the provenance rule at a module that exists",
760
- steps: [
761
- "Copy the specifier from the import statement itself - it is matched as written, with no extension or index resolution.",
762
- "Use a trailing `/*` when the seam is a directory of modules (`@myorg/db/*`).",
763
- "For a routeModules entry, give the project-relative path of the file that implements the route.",
764
- "Delete the rule if the seam it governed is gone; leaving it in place proves nothing.",
765
- ],
766
- },
778
+ : seamFix === undefined
779
+ ? {
780
+ kind: "manual",
781
+ title: "Restore declared effect provenance",
782
+ steps: [
783
+ "Route effectful work through an import listed in capabilities.provenance.imports.",
784
+ "Declare the exact capability token on the route; do not widen unrelated routes in the same file.",
785
+ "For domain writes, add the adapter the capability definition requires: `schema.idempotency` for the `request` tier, `.use(durableCommand({ journal }))` from @nifrajs/middleware for the `durable` tier.",
786
+ "Run `nifra capabilities snapshot` only after assurance passes, then review the lockfile diff.",
787
+ ],
788
+ }
789
+ : {
790
+ kind: "manual",
791
+ title: "Point the provenance rule at a module that exists",
792
+ steps: [
793
+ "Copy the specifier from the import statement itself - it is matched as written, with no extension or index resolution.",
794
+ "Use a trailing `/*` when the seam is a directory of modules (`@myorg/db/*`).",
795
+ "For a routeModules entry, give the project-relative path of the file that implements the route.",
796
+ "Delete the rule if the seam it governed is gone; leaving it in place proves nothing.",
797
+ ],
798
+ },
767
799
  }),
768
800
  )
769
801
  }
package/src/rules/nano.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import type * as TSApi from "typescript"
2
2
  import { type Diagnostic, diagnostic } from "../diagnostics.ts"
3
- import { importProjectTypeScript } from "../internal/typescript-import.ts"
3
+ import { scriptKindOf } from "../internal/script-kind.ts"
4
4
  import type { CheckRule, SourceIndex } from "./index.ts"
5
+ import { loadRuleTypeScript } from "./typescript.ts"
5
6
 
6
7
  /**
7
8
  * The nano lane lints (`@nifrajs/web/nano`). nano is deliberately explicit - every reactive edge is a
@@ -38,8 +39,13 @@ function parsedTree(
38
39
  files.set(file, null)
39
40
  return undefined
40
41
  }
41
- const kind = /\.tsx?$/.test(file) ? ts.ScriptKind.TSX : ts.ScriptKind.JS
42
- const tree = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, kind)
42
+ const tree = ts.createSourceFile(
43
+ file,
44
+ source,
45
+ ts.ScriptTarget.Latest,
46
+ true,
47
+ scriptKindOf(ts, file),
48
+ )
43
49
  files.set(file, { tree })
44
50
  return tree
45
51
  }
@@ -165,7 +171,9 @@ export const nanoBindCleanupRule: CheckRule = {
165
171
  code: "NF-C021",
166
172
  title: "nano binding cleanup check",
167
173
  async scan(ctx) {
168
- const ts = await importProjectTypeScript(ctx.root)
174
+ const loaded = await loadRuleTypeScript(ctx)
175
+ if (loaded.diagnostics !== undefined) return loaded.diagnostics
176
+ const ts = loaded.compiler
169
177
  if (ts === undefined) return [toolchainMissing("NF-C021")]
170
178
  const findings: Diagnostic[] = []
171
179
  for (const file of ctx.project.source.files) {
@@ -205,7 +213,9 @@ export const nanoListKeyRule: CheckRule = {
205
213
  code: "NF-C022",
206
214
  title: "nano bindList key check",
207
215
  async scan(ctx) {
208
- const ts = await importProjectTypeScript(ctx.root)
216
+ const loaded = await loadRuleTypeScript(ctx)
217
+ if (loaded.diagnostics !== undefined) return loaded.diagnostics
218
+ const ts = loaded.compiler
209
219
  if (ts === undefined) return [toolchainMissing("NF-C022")]
210
220
  const findings: Diagnostic[] = []
211
221
  for (const file of ctx.project.source.files) {
@@ -246,7 +256,9 @@ export const nanoComputedDepsRule: CheckRule = {
246
256
  code: "NF-C023",
247
257
  title: "nano computed deps check",
248
258
  async scan(ctx) {
249
- const ts = await importProjectTypeScript(ctx.root)
259
+ const loaded = await loadRuleTypeScript(ctx)
260
+ if (loaded.diagnostics !== undefined) return loaded.diagnostics
261
+ const ts = loaded.compiler
250
262
  if (ts === undefined) return [toolchainMissing("NF-C023")]
251
263
  const findings: Diagnostic[] = []
252
264
  for (const file of ctx.project.source.files) {
@@ -0,0 +1,99 @@
1
+ /**
2
+ * NF-C027: a page file whose URL sits under the backend's mount path.
3
+ *
4
+ * `backend/app.ts`'s backend is mounted at `apiPrefix` (default `/api`) ahead of page routing, and
5
+ * its 404 is final, so `routes/api/report.tsx` never renders - every request for it gets the
6
+ * backend's 404. `createWebApp` refuses such an app at startup and `nifra build` refuses to build
7
+ * it; this reports it earlier, without running app code (check's pre-`loadApp` invariant): the
8
+ * routes come from a scan of `routes/`, and `apiPrefix` is read only when backend/framework.ts
9
+ * declares it as a string literal.
10
+ *
11
+ * Mounts other than the backend's hold `app` objects no static read can see, so they are left to the
12
+ * startup and build checks.
13
+ */
14
+ import { existsSync, readFileSync } from "node:fs"
15
+ import { join } from "node:path"
16
+ import { discoverRoutes } from "@nifrajs/web/fs"
17
+ import { shadowedPages } from "@nifrajs/web/route-manifest"
18
+ import { BACKEND_APP_FILE, CONFIG_FILE, FRAMEWORK_FILE } from "../app-files.ts"
19
+ import { stripComments } from "../check-scan.ts"
20
+ import { type Diagnostic, diagnostic } from "../diagnostics.ts"
21
+ import type { CheckRule } from "./index.ts"
22
+
23
+ /** What a static read of a framework config says about `apiPrefix`. */
24
+ export type StaticApiPrefix =
25
+ | { readonly kind: "default" }
26
+ | { readonly kind: "literal"; readonly value: string }
27
+ | { readonly kind: "unreadable" }
28
+
29
+ const LITERAL_EXPORT =
30
+ /\bexport\s+const\s+apiPrefix\s*(?::\s*string\s*)?=\s*(?:"([^"\\\r\n]*)"|'([^'\\\r\n]*)')\s*(?:as\s+const\s*)?(?:;|\r?\n|$)/
31
+ const MENTION = /\bapiPrefix\b/
32
+ const BACKEND_EXPORT =
33
+ /\bexport\s+(?:const|let|var|function|class)\s+backend\b|\bexport\s*\{[^}]*\bbackend\b[^}]*\}/
34
+
35
+ /** Read `apiPrefix` from a framework config's source without running it. Pure. */
36
+ export function readStaticApiPrefix(source: string): StaticApiPrefix {
37
+ const code = stripComments(source)
38
+ const literal = LITERAL_EXPORT.exec(code)
39
+ if (literal !== null) return { kind: "literal", value: literal[1] ?? literal[2] ?? "" }
40
+ return MENTION.test(code) ? { kind: "unreadable" } : { kind: "default" }
41
+ }
42
+
43
+ const CONFIG_FILES = [FRAMEWORK_FILE, CONFIG_FILE] as const
44
+
45
+ export const shadowedPageRule: CheckRule = {
46
+ code: "NF-C027",
47
+ title: "Page route under the backend mount",
48
+ async scan(ctx) {
49
+ const backendFile = join(ctx.root, BACKEND_APP_FILE)
50
+ const routesDir = join(ctx.root, "routes")
51
+ if (!existsSync(backendFile) || !existsSync(routesDir)) return []
52
+ if (!BACKEND_EXPORT.test(stripComments(readFileSync(backendFile, "utf8")))) return []
53
+
54
+ let apiPrefix = "/api"
55
+ // backend/framework.ts first: the generated server entry imports from it, and the build refuses
56
+ // a nifra.config.ts whose forwarded fields differ from it.
57
+ const configFile = CONFIG_FILES.find((file) => existsSync(join(ctx.root, file)))
58
+ if (configFile !== undefined) {
59
+ const read = readStaticApiPrefix(readFileSync(join(ctx.root, configFile), "utf8"))
60
+ if (read.kind === "unreadable") {
61
+ return [
62
+ diagnostic({
63
+ code: "NF-C027",
64
+ severity: "info",
65
+ file: configFile,
66
+ message: `apiPrefix in ${configFile} is not a string literal, so check cannot tell which page routes sit under the backend mount; createWebApp still refuses a page under it at startup, and nifra build at build time`,
67
+ verify: "nifra build",
68
+ }),
69
+ ]
70
+ }
71
+ if (read.kind === "literal") apiPrefix = read.value
72
+ }
73
+ if (apiPrefix === "") return []
74
+
75
+ let manifest: ReturnType<typeof discoverRoutes>
76
+ try {
77
+ manifest = discoverRoutes(routesDir)
78
+ } catch {
79
+ // A routes/ tree the manifest refuses fails dev and build with its own error.
80
+ return []
81
+ }
82
+ const findings: Diagnostic[] = []
83
+ for (const page of shadowedPages(manifest, [apiPrefix])) {
84
+ findings.push(
85
+ diagnostic({
86
+ code: "NF-C027",
87
+ severity: "error",
88
+ file: `routes/${page.file}`,
89
+ message: `routes/${page.file} serves ${page.pattern}, under the backend mounted at ${page.mount} - the backend answers every request there before page routing and its 404 is final, so this page can never render. Move the file out of ${page.mount}, serve the URL from backend/app.ts, or change the mount path with \`export const apiPrefix = "/backend"\` in backend/framework.ts`,
90
+ evidence: [`page: ${page.pattern}`, `mount: ${page.mount}`],
91
+ verify: "nifra check",
92
+ }),
93
+ )
94
+ }
95
+ return findings
96
+ },
97
+ }
98
+
99
+ export const pageRules = Object.freeze([shadowedPageRule])
@@ -1,4 +1,6 @@
1
1
  import { RESERVED_KEY_READOUT, reservedKeyFor } from "@nifrajs/client"
2
+ import { RoutePatternOverlapLimitError, routePatternOverlap } from "@nifrajs/core"
3
+ import { expandOptionalParams, paramConstraint } from "@nifrajs/core/pattern"
2
4
  import { type Diagnostic, diagnostic } from "../diagnostics.ts"
3
5
  import { commentBlockHasMarker } from "./comment-markers.ts"
4
6
  import type { CheckRule, RuleContext } from "./index.ts"
@@ -23,6 +25,8 @@ import type { CheckRule, RuleContext } from "./index.ts"
23
25
 
24
26
  /** Opt-out pragma for a route deliberately served only to NON-typed-client consumers. */
25
27
  const RESERVED_SEGMENT_PRAGMA = "nifra-expect reserved-segment"
28
+ const ROUTE_OVERLAP_PRAGMA = "nifra-expect route-overlap"
29
+ const PARAM_MODIFIER_PRAGMA = "nifra-expect param-modifier"
26
30
 
27
31
  interface StaticRouteFact {
28
32
  readonly file: string
@@ -50,6 +54,17 @@ function routeFacts(ctx: RuleContext): StaticRouteFact[] {
50
54
  return out
51
55
  }
52
56
 
57
+ /** One fact per registration site, so `all()` or `method([...])` reports its path once, not per method. */
58
+ function pathSites(routes: readonly StaticRouteFact[]): StaticRouteFact[] {
59
+ const seen = new Set<string>()
60
+ return routes.filter((route) => {
61
+ const site = `${route.file}\n${route.line}\n${route.path}`
62
+ if (seen.has(site)) return false
63
+ seen.add(site)
64
+ return true
65
+ })
66
+ }
67
+
53
68
  /**
54
69
  * The typed escape spelling for a colliding segment, e.g. `/api/delete` + `delete` →
55
70
  * `api("delete").post()` shown as the chain up to the collision. Best-effort readability: earlier
@@ -67,7 +82,7 @@ export const reservedSegmentRule: CheckRule = {
67
82
  async scan(ctx) {
68
83
  const findings: Diagnostic[] = []
69
84
  const linesByFile = new Map<string, readonly string[]>()
70
- for (const route of routeFacts(ctx)) {
85
+ for (const route of pathSites(routeFacts(ctx))) {
71
86
  for (const segment of route.path.split("/")) {
72
87
  if (segment === "") continue
73
88
  const collision = reservedKeyFor(segment)
@@ -112,6 +127,9 @@ export const duplicateRouteRule: CheckRule = {
112
127
  async scan(ctx) {
113
128
  const findings: Diagnostic[] = []
114
129
  const byFile = new Map<string, Map<string, StaticRouteFact>>()
130
+ // Two calls that each register several methods collide once per shared method; that is one
131
+ // pair of lines to fix, so it is reported once.
132
+ const reported = new Set<string>()
115
133
  for (const route of routeFacts(ctx)) {
116
134
  let seen = byFile.get(route.file)
117
135
  if (seen === undefined) {
@@ -124,6 +142,9 @@ export const duplicateRouteRule: CheckRule = {
124
142
  seen.set(key, route)
125
143
  continue
126
144
  }
145
+ const pair = `${route.file}\n${route.line}\n${first.line}\n${route.path}`
146
+ if (reported.has(pair)) continue
147
+ reported.add(pair)
127
148
  findings.push(
128
149
  diagnostic({
129
150
  code: "NF-C019",
@@ -140,4 +161,162 @@ export const duplicateRouteRule: CheckRule = {
140
161
  },
141
162
  }
142
163
 
143
- export const routeRules = Object.freeze([reservedSegmentRule, duplicateRouteRule])
164
+ /**
165
+ * NF-C024: two different same-method route patterns in one source file accept at least one of the
166
+ * same paths. This is deliberately file-scoped, like NF-C019: the static scanner cannot prove
167
+ * which application instance owns routes collected from different files in a monorepo.
168
+ */
169
+ export const overlappingRouteRule: CheckRule = {
170
+ code: "NF-C024",
171
+ title: "Overlapping route registration",
172
+ async scan(ctx) {
173
+ const findings: Diagnostic[] = []
174
+ const linesByFile = new Map<string, readonly string[]>()
175
+ const byFile = new Map<string, StaticRouteFact[]>()
176
+ for (const route of routeFacts(ctx)) {
177
+ const routes = byFile.get(route.file)
178
+ if (routes === undefined) byFile.set(route.file, [route])
179
+ else routes.push(route)
180
+ }
181
+
182
+ // As in NF-C019: two multi-method calls overlap once per shared method, reported once.
183
+ const reported = new Set<string>()
184
+ for (const [file, routes] of byFile) {
185
+ const lines = (): readonly string[] => {
186
+ let value = linesByFile.get(file)
187
+ if (value === undefined) {
188
+ value = (ctx.project.source.read(file) ?? "").split("\n")
189
+ linesByFile.set(file, value)
190
+ }
191
+ return value
192
+ }
193
+
194
+ for (let laterIndex = 0; laterIndex < routes.length; laterIndex += 1) {
195
+ const later = routes[laterIndex]!
196
+ const laterKey = `${later.method} ${later.path}`
197
+ for (let earlierIndex = 0; earlierIndex < laterIndex; earlierIndex += 1) {
198
+ const earlier = routes[earlierIndex]!
199
+ if (earlier.method !== later.method || `${earlier.method} ${earlier.path}` === laterKey)
200
+ continue
201
+ if (commentBlockHasMarker(lines(), later.line, ROUTE_OVERLAP_PRAGMA)) continue
202
+ if (commentBlockHasMarker(lines(), earlier.line, ROUTE_OVERLAP_PRAGMA)) continue
203
+
204
+ let witness: string | undefined
205
+ try {
206
+ witness = routePatternOverlap(earlier.path, later.path)
207
+ } catch (error) {
208
+ if (error instanceof RoutePatternOverlapLimitError) {
209
+ findings.push(
210
+ diagnostic({
211
+ code: "NF-C025",
212
+ severity: "error",
213
+ file: later.file,
214
+ line: later.line,
215
+ message: `${laterKey} could not be proven disjoint from ${earlier.method} ${earlier.path}; bounded route-overlap analysis exceeded its safety budget - simplify the patterns or add the ${ROUTE_OVERLAP_PRAGMA} comment only after manual review`,
216
+ evidence: [
217
+ `${earlier.method} ${earlier.path}`,
218
+ `${later.method} ${later.path}`,
219
+ `first registration: line ${earlier.line}`,
220
+ ],
221
+ verify: "nifra check --lints-only",
222
+ }),
223
+ )
224
+ }
225
+ // The primary route parser diagnostic owns malformed route patterns. A secondary lint
226
+ // must remain total and never turn an invalid route into a checker crash.
227
+ continue
228
+ }
229
+ if (witness === undefined) continue
230
+ const pair = `${file}\n${later.line}\n${later.path}\n${earlier.line}\n${earlier.path}`
231
+ if (reported.has(pair)) break
232
+ reported.add(pair)
233
+
234
+ findings.push(
235
+ diagnostic({
236
+ code: "NF-C024",
237
+ severity: "error",
238
+ file: later.file,
239
+ line: later.line,
240
+ message: `${laterKey} overlaps ${earlier.method} ${earlier.path} (first at line ${earlier.line}); witness path: ${witness} - make the patterns disjoint, order them intentionally with a pragma, or add the ${ROUTE_OVERLAP_PRAGMA} comment above either registration`,
241
+ evidence: [
242
+ `${earlier.method} ${earlier.path}`,
243
+ `${later.method} ${later.path}`,
244
+ `witness: ${witness}`,
245
+ `first registration: line ${earlier.line}`,
246
+ ],
247
+ verify: "nifra check --lints-only",
248
+ }),
249
+ )
250
+ break
251
+ }
252
+ }
253
+ }
254
+ return findings
255
+ },
256
+ }
257
+
258
+ /** A param name directly followed by a character other routers read as a modifier. */
259
+ const PARAM_MODIFIER = /:[A-Za-z_][A-Za-z0-9_]*[?*+{(<]/g
260
+
261
+ /**
262
+ * The first param in `path` whose modifier the router reads as literal text. A `{...}` group the
263
+ * router reads as a constraint (`:id{[0-9]+}`, `:ext{png|jpg}`) is syntax, so it is passed over.
264
+ */
265
+ function literalModifier(path: string): string | undefined {
266
+ PARAM_MODIFIER.lastIndex = 0
267
+ for (let found = PARAM_MODIFIER.exec(path); found !== null; found = PARAM_MODIFIER.exec(path)) {
268
+ const text = found[0]
269
+ if (!text.endsWith("{")) return text
270
+ const constraint = paramConstraint(path.slice(PARAM_MODIFIER.lastIndex - 1))
271
+ if (constraint === undefined) return text
272
+ const end = PARAM_MODIFIER.lastIndex + constraint.source.length + 1
273
+ // A modifier after the constraint is literal text like any other (`:id{[0-9]+}?/posts`).
274
+ if ("?*+{(<".includes(path[end] ?? "/")) return path.slice(found.index, end + 1)
275
+ PARAM_MODIFIER.lastIndex = end
276
+ }
277
+ return undefined
278
+ }
279
+
280
+ /**
281
+ * NF-C026: a param followed by `?`, `*`, `+`, `{`, `(` or `<` that the router reads as literal text
282
+ * (`/users/:id?/posts`, `/users/:id{int}`). Only a trailing `?` run and a valid `{...}` are syntax.
283
+ */
284
+ export const paramModifierRule: CheckRule = {
285
+ code: "NF-C026",
286
+ title: "Route param followed by an unsupported modifier",
287
+ async scan(ctx) {
288
+ const findings: Diagnostic[] = []
289
+ const linesByFile = new Map<string, readonly string[]>()
290
+ for (const route of pathSites(routeFacts(ctx))) {
291
+ const forms = expandOptionalParams(route.path)
292
+ const text = literalModifier(forms[forms.length - 1] ?? route.path)
293
+ if (text === undefined) continue
294
+ let lines = linesByFile.get(route.file)
295
+ if (lines === undefined) {
296
+ lines = (ctx.project.source.read(route.file) ?? "").split("\n")
297
+ linesByFile.set(route.file, lines)
298
+ }
299
+ if (commentBlockHasMarker(lines, route.line, PARAM_MODIFIER_PRAGMA)) continue
300
+ const character = text[text.length - 1]!
301
+ findings.push(
302
+ diagnostic({
303
+ code: "NF-C026",
304
+ severity: "warn",
305
+ file: route.file,
306
+ line: route.line,
307
+ message: `${route.method} ${route.path} - '${character}' after '${text.slice(0, -1)}' is matched as literal text, not as a param modifier${character === "?" ? "; a request path never contains '?', so this route cannot be reached" : ""}. Optional params are supported as the trailing whole segments of a path (\`/users/:id?\`, \`/d/:year?/:month?\`), and a constraint as one character class with an optional count (\`:id{[0-9]+}\`, \`:code{[A-Z]{2}}\`) or a list of two or more values (\`:ext{png|jpg}\`); for anything else register each path, or mark a deliberate literal with \`// ${PARAM_MODIFIER_PRAGMA}\` above the registration`,
308
+ evidence: [`${route.method} ${route.path}`, `literal: ${text}`],
309
+ verify: "nifra check --lints-only",
310
+ }),
311
+ )
312
+ }
313
+ return findings
314
+ },
315
+ }
316
+
317
+ export const routeRules = Object.freeze([
318
+ reservedSegmentRule,
319
+ duplicateRouteRule,
320
+ overlappingRouteRule,
321
+ paramModifierRule,
322
+ ])