@nifrajs/cli 3.3.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (425) hide show
  1. package/README.md +42 -3
  2. package/dist/app-files.d.ts +11 -0
  3. package/dist/app-files.d.ts.map +1 -0
  4. package/dist/app-files.js +34 -0
  5. package/dist/app-files.js.map +1 -0
  6. package/dist/assure-hydration.d.ts +7 -1
  7. package/dist/assure-hydration.d.ts.map +1 -1
  8. package/dist/assure-hydration.js +128 -43
  9. package/dist/assure-hydration.js.map +1 -1
  10. package/dist/assure.d.ts.map +1 -1
  11. package/dist/assure.js +16 -2
  12. package/dist/assure.js.map +1 -1
  13. package/dist/capabilities-tool.d.ts +5 -1
  14. package/dist/capabilities-tool.d.ts.map +1 -1
  15. package/dist/capabilities-tool.js +32 -10
  16. package/dist/capabilities-tool.js.map +1 -1
  17. package/dist/cdn-check.d.ts +41 -0
  18. package/dist/cdn-check.d.ts.map +1 -0
  19. package/dist/cdn-check.js +207 -0
  20. package/dist/cdn-check.js.map +1 -0
  21. package/dist/check-diagnostics.d.ts +7 -15
  22. package/dist/check-diagnostics.d.ts.map +1 -1
  23. package/dist/check-diagnostics.js +162 -713
  24. package/dist/check-diagnostics.js.map +1 -1
  25. package/dist/check-scan.d.ts +58 -7
  26. package/dist/check-scan.d.ts.map +1 -1
  27. package/dist/check-scan.js +323 -62
  28. package/dist/check-scan.js.map +1 -1
  29. package/dist/check.d.ts +1 -0
  30. package/dist/check.d.ts.map +1 -1
  31. package/dist/check.js +145 -54
  32. package/dist/check.js.map +1 -1
  33. package/dist/cli.d.ts +42 -16
  34. package/dist/cli.d.ts.map +1 -1
  35. package/dist/cli.js +306 -87
  36. package/dist/cli.js.map +1 -1
  37. package/dist/command-catalog.d.ts +60 -5
  38. package/dist/command-catalog.d.ts.map +1 -1
  39. package/dist/command-catalog.js +298 -33
  40. package/dist/command-catalog.js.map +1 -1
  41. package/dist/config-target.d.ts +8 -0
  42. package/dist/config-target.d.ts.map +1 -0
  43. package/dist/config-target.js +45 -0
  44. package/dist/config-target.js.map +1 -0
  45. package/dist/contracts.d.ts +3 -2
  46. package/dist/contracts.d.ts.map +1 -1
  47. package/dist/contracts.js +56 -16
  48. package/dist/contracts.js.map +1 -1
  49. package/dist/db-audit.d.ts +37 -0
  50. package/dist/db-audit.d.ts.map +1 -0
  51. package/dist/db-audit.js +99 -0
  52. package/dist/db-audit.js.map +1 -0
  53. package/dist/db-child.d.ts +64 -0
  54. package/dist/db-child.d.ts.map +1 -0
  55. package/dist/db-child.js +213 -0
  56. package/dist/db-child.js.map +1 -0
  57. package/dist/db-config.d.ts +60 -0
  58. package/dist/db-config.d.ts.map +1 -0
  59. package/dist/db-config.js +176 -0
  60. package/dist/db-config.js.map +1 -0
  61. package/dist/db-tool.d.ts +84 -0
  62. package/dist/db-tool.d.ts.map +1 -0
  63. package/dist/db-tool.js +537 -0
  64. package/dist/db-tool.js.map +1 -0
  65. package/dist/dev-bun-config.d.ts +20 -13
  66. package/dist/dev-bun-config.d.ts.map +1 -1
  67. package/dist/dev-bun-config.js +98 -41
  68. package/dist/dev-bun-config.js.map +1 -1
  69. package/dist/dev-feed-tool.d.ts +106 -0
  70. package/dist/dev-feed-tool.d.ts.map +1 -0
  71. package/dist/dev-feed-tool.js +713 -0
  72. package/dist/dev-feed-tool.js.map +1 -0
  73. package/dist/dev-server-client.d.ts +67 -0
  74. package/dist/dev-server-client.d.ts.map +1 -0
  75. package/dist/dev-server-client.js +195 -0
  76. package/dist/dev-server-client.js.map +1 -0
  77. package/dist/diagnostics.d.ts +86 -0
  78. package/dist/diagnostics.d.ts.map +1 -1
  79. package/dist/diagnostics.js +85 -0
  80. package/dist/diagnostics.js.map +1 -1
  81. package/dist/diff-tool.d.ts +3 -3
  82. package/dist/diff-tool.d.ts.map +1 -1
  83. package/dist/diff-tool.js +6 -5
  84. package/dist/diff-tool.js.map +1 -1
  85. package/dist/docs-search.d.ts.map +1 -1
  86. package/dist/docs-search.js +12 -4
  87. package/dist/docs-search.js.map +1 -1
  88. package/dist/doctor.d.ts +17 -0
  89. package/dist/doctor.d.ts.map +1 -1
  90. package/dist/doctor.js +76 -15
  91. package/dist/doctor.js.map +1 -1
  92. package/dist/env-file.d.ts +15 -0
  93. package/dist/env-file.d.ts.map +1 -1
  94. package/dist/env-file.js +73 -1
  95. package/dist/env-file.js.map +1 -1
  96. package/dist/fix-recipes.d.ts.map +1 -1
  97. package/dist/fix-recipes.js +41 -21
  98. package/dist/fix-recipes.js.map +1 -1
  99. package/dist/frontend-guidance.js +5 -5
  100. package/dist/frontend-guidance.js.map +1 -1
  101. package/dist/i18n-check.d.ts +16 -0
  102. package/dist/i18n-check.d.ts.map +1 -0
  103. package/dist/i18n-check.js +154 -0
  104. package/dist/i18n-check.js.map +1 -0
  105. package/dist/init-agents.d.ts +49 -13
  106. package/dist/init-agents.d.ts.map +1 -1
  107. package/dist/init-agents.js +239 -34
  108. package/dist/init-agents.js.map +1 -1
  109. package/dist/internal/code-unit-order.d.ts +4 -0
  110. package/dist/internal/code-unit-order.d.ts.map +1 -0
  111. package/dist/internal/code-unit-order.js +6 -0
  112. package/dist/internal/code-unit-order.js.map +1 -0
  113. package/dist/internal/script-kind.d.ts +7 -0
  114. package/dist/internal/script-kind.d.ts.map +1 -0
  115. package/dist/internal/script-kind.js +12 -0
  116. package/dist/internal/script-kind.js.map +1 -0
  117. package/dist/internal/source-facts.d.ts +5 -0
  118. package/dist/internal/source-facts.d.ts.map +1 -1
  119. package/dist/internal/source-facts.js +61 -5
  120. package/dist/internal/source-facts.js.map +1 -1
  121. package/dist/internal/typescript-import.d.ts +49 -24
  122. package/dist/internal/typescript-import.d.ts.map +1 -1
  123. package/dist/internal/typescript-import.js +507 -67
  124. package/dist/internal/typescript-import.js.map +1 -1
  125. package/dist/introspect.d.ts +6 -4
  126. package/dist/introspect.d.ts.map +1 -1
  127. package/dist/introspect.js +93 -74
  128. package/dist/introspect.js.map +1 -1
  129. package/dist/learn.js +3 -3
  130. package/dist/learn.js.map +1 -1
  131. package/dist/levels-tool.d.ts +10 -0
  132. package/dist/levels-tool.d.ts.map +1 -1
  133. package/dist/levels-tool.js +33 -3
  134. package/dist/levels-tool.js.map +1 -1
  135. package/dist/load.d.ts +71 -16
  136. package/dist/load.d.ts.map +1 -1
  137. package/dist/load.js +78 -18
  138. package/dist/load.js.map +1 -1
  139. package/dist/manifest-tool.d.ts.map +1 -1
  140. package/dist/manifest-tool.js +3 -2
  141. package/dist/manifest-tool.js.map +1 -1
  142. package/dist/mcp-app-tool.d.ts.map +1 -1
  143. package/dist/mcp-app-tool.js +2 -0
  144. package/dist/mcp-app-tool.js.map +1 -1
  145. package/dist/mcp-context.d.ts.map +1 -1
  146. package/dist/mcp-context.js +5 -6
  147. package/dist/mcp-context.js.map +1 -1
  148. package/dist/mcp-delegate.d.ts +57 -0
  149. package/dist/mcp-delegate.d.ts.map +1 -0
  150. package/dist/mcp-delegate.js +130 -0
  151. package/dist/mcp-delegate.js.map +1 -0
  152. package/dist/mcp-docs-tools.d.ts.map +1 -1
  153. package/dist/mcp-docs-tools.js +23 -5
  154. package/dist/mcp-docs-tools.js.map +1 -1
  155. package/dist/mcp-exec.d.ts +3 -0
  156. package/dist/mcp-exec.d.ts.map +1 -1
  157. package/dist/mcp-exec.js +263 -131
  158. package/dist/mcp-exec.js.map +1 -1
  159. package/dist/mcp-http.d.ts.map +1 -1
  160. package/dist/mcp-http.js +20 -5
  161. package/dist/mcp-http.js.map +1 -1
  162. package/dist/mcp-io.d.ts +26 -0
  163. package/dist/mcp-io.d.ts.map +1 -1
  164. package/dist/mcp-io.js +113 -0
  165. package/dist/mcp-io.js.map +1 -1
  166. package/dist/mcp-isolate.d.ts +48 -0
  167. package/dist/mcp-isolate.d.ts.map +1 -0
  168. package/dist/mcp-isolate.js +222 -0
  169. package/dist/mcp-isolate.js.map +1 -0
  170. package/dist/mcp-path.d.ts +10 -0
  171. package/dist/mcp-path.d.ts.map +1 -0
  172. package/dist/mcp-path.js +51 -0
  173. package/dist/mcp-path.js.map +1 -0
  174. package/dist/mcp-project-child.d.ts +74 -0
  175. package/dist/mcp-project-child.d.ts.map +1 -0
  176. package/dist/mcp-project-child.js +130 -0
  177. package/dist/mcp-project-child.js.map +1 -0
  178. package/dist/mcp-reflect.d.ts.map +1 -1
  179. package/dist/mcp-reflect.js +2 -2
  180. package/dist/mcp-reflect.js.map +1 -1
  181. package/dist/mcp-render.d.ts.map +1 -1
  182. package/dist/mcp-render.js +64 -45
  183. package/dist/mcp-render.js.map +1 -1
  184. package/dist/mcp-root.d.ts +37 -9
  185. package/dist/mcp-root.d.ts.map +1 -1
  186. package/dist/mcp-root.js +124 -25
  187. package/dist/mcp-root.js.map +1 -1
  188. package/dist/mcp-run.d.ts +18 -1
  189. package/dist/mcp-run.d.ts.map +1 -1
  190. package/dist/mcp-run.js +159 -55
  191. package/dist/mcp-run.js.map +1 -1
  192. package/dist/mcp-ws.js +4 -1
  193. package/dist/mcp-ws.js.map +1 -1
  194. package/dist/mcp.d.ts +18 -5
  195. package/dist/mcp.d.ts.map +1 -1
  196. package/dist/mcp.js +165 -82
  197. package/dist/mcp.js.map +1 -1
  198. package/dist/migrate-layout.d.ts +44 -0
  199. package/dist/migrate-layout.d.ts.map +1 -0
  200. package/dist/migrate-layout.js +982 -0
  201. package/dist/migrate-layout.js.map +1 -0
  202. package/dist/openapi-tool.d.ts +2 -2
  203. package/dist/openapi-tool.d.ts.map +1 -1
  204. package/dist/openapi-tool.js.map +1 -1
  205. package/dist/openapi-types.d.ts +2 -1
  206. package/dist/openapi-types.d.ts.map +1 -1
  207. package/dist/openapi-types.js +240 -12
  208. package/dist/openapi-types.js.map +1 -1
  209. package/dist/pipeline-report.d.ts +3 -2
  210. package/dist/pipeline-report.d.ts.map +1 -1
  211. package/dist/pipeline-report.js +19 -16
  212. package/dist/pipeline-report.js.map +1 -1
  213. package/dist/port.d.ts +5 -4
  214. package/dist/port.d.ts.map +1 -1
  215. package/dist/port.js +46 -29
  216. package/dist/port.js.map +1 -1
  217. package/dist/project-facts.d.ts +28 -4
  218. package/dist/project-facts.d.ts.map +1 -1
  219. package/dist/project-facts.js +12 -2
  220. package/dist/project-facts.js.map +1 -1
  221. package/dist/recipes/4.0.0.d.ts +9 -0
  222. package/dist/recipes/4.0.0.d.ts.map +1 -0
  223. package/dist/recipes/4.0.0.js +34 -0
  224. package/dist/recipes/4.0.0.js.map +1 -0
  225. package/dist/recipes/index.d.ts.map +1 -1
  226. package/dist/recipes/index.js +2 -0
  227. package/dist/recipes/index.js.map +1 -1
  228. package/dist/release-verification.d.ts +11 -1
  229. package/dist/release-verification.d.ts.map +1 -1
  230. package/dist/release-verification.js +47 -4
  231. package/dist/release-verification.js.map +1 -1
  232. package/dist/review.d.ts +21 -0
  233. package/dist/review.d.ts.map +1 -0
  234. package/dist/review.js +823 -0
  235. package/dist/review.js.map +1 -0
  236. package/dist/route-types.d.ts +28 -0
  237. package/dist/route-types.d.ts.map +1 -0
  238. package/dist/route-types.js +106 -0
  239. package/dist/route-types.js.map +1 -0
  240. package/dist/rules/codes.d.ts +9 -0
  241. package/dist/rules/codes.d.ts.map +1 -1
  242. package/dist/rules/codes.js +9 -0
  243. package/dist/rules/codes.js.map +1 -1
  244. package/dist/rules/data-guard.d.ts +12 -0
  245. package/dist/rules/data-guard.d.ts.map +1 -0
  246. package/dist/rules/data-guard.js +213 -0
  247. package/dist/rules/data-guard.js.map +1 -0
  248. package/dist/rules/index.d.ts +3 -0
  249. package/dist/rules/index.d.ts.map +1 -1
  250. package/dist/rules/index.js +17 -5
  251. package/dist/rules/index.js.map +1 -1
  252. package/dist/rules/islands.d.ts.map +1 -1
  253. package/dist/rules/islands.js +7 -4
  254. package/dist/rules/islands.js.map +1 -1
  255. package/dist/rules/legacy.d.ts +3 -1
  256. package/dist/rules/legacy.d.ts.map +1 -1
  257. package/dist/rules/legacy.js +829 -34
  258. package/dist/rules/legacy.js.map +1 -1
  259. package/dist/rules/nano.d.ts.map +1 -1
  260. package/dist/rules/nano.js +15 -6
  261. package/dist/rules/nano.js.map +1 -1
  262. package/dist/rules/pages.d.ts +15 -0
  263. package/dist/rules/pages.d.ts.map +1 -0
  264. package/dist/rules/pages.js +88 -0
  265. package/dist/rules/pages.js.map +1 -0
  266. package/dist/rules/routes.d.ts +11 -0
  267. package/dist/rules/routes.d.ts.map +1 -1
  268. package/dist/rules/routes.js +180 -2
  269. package/dist/rules/routes.js.map +1 -1
  270. package/dist/rules/secrets.d.ts +10 -0
  271. package/dist/rules/secrets.d.ts.map +1 -0
  272. package/dist/rules/secrets.js +169 -0
  273. package/dist/rules/secrets.js.map +1 -0
  274. package/dist/rules/security.d.ts +7 -0
  275. package/dist/rules/security.d.ts.map +1 -1
  276. package/dist/rules/security.js +171 -65
  277. package/dist/rules/security.js.map +1 -1
  278. package/dist/rules/typescript.d.ts +16 -0
  279. package/dist/rules/typescript.d.ts.map +1 -0
  280. package/dist/rules/typescript.js +59 -0
  281. package/dist/rules/typescript.js.map +1 -0
  282. package/dist/rules/zones.d.ts +17 -0
  283. package/dist/rules/zones.d.ts.map +1 -0
  284. package/dist/rules/zones.js +174 -0
  285. package/dist/rules/zones.js.map +1 -0
  286. package/dist/scaffold.d.ts +25 -12
  287. package/dist/scaffold.d.ts.map +1 -1
  288. package/dist/scaffold.js +154 -90
  289. package/dist/scaffold.js.map +1 -1
  290. package/dist/sdk.d.ts +17 -3
  291. package/dist/sdk.d.ts.map +1 -1
  292. package/dist/sdk.js +941 -153
  293. package/dist/sdk.js.map +1 -1
  294. package/dist/search-terms.d.ts +2 -0
  295. package/dist/search-terms.d.ts.map +1 -1
  296. package/dist/search-terms.js +23 -8
  297. package/dist/search-terms.js.map +1 -1
  298. package/dist/smoke.d.ts +79 -0
  299. package/dist/smoke.d.ts.map +1 -0
  300. package/dist/smoke.js +494 -0
  301. package/dist/smoke.js.map +1 -0
  302. package/dist/sync-manifest.d.ts.map +1 -1
  303. package/dist/sync-manifest.js +2 -1
  304. package/dist/sync-manifest.js.map +1 -1
  305. package/dist/sync-routes.d.ts +4 -3
  306. package/dist/sync-routes.d.ts.map +1 -1
  307. package/dist/sync-routes.js +4 -3
  308. package/dist/sync-routes.js.map +1 -1
  309. package/dist/test-tool.d.ts.map +1 -1
  310. package/dist/test-tool.js +30 -11
  311. package/dist/test-tool.js.map +1 -1
  312. package/dist/types-search.d.ts.map +1 -1
  313. package/dist/types-search.js +11 -5
  314. package/dist/types-search.js.map +1 -1
  315. package/dist/upgrade.d.ts +36 -6
  316. package/dist/upgrade.d.ts.map +1 -1
  317. package/dist/upgrade.js +167 -29
  318. package/dist/upgrade.js.map +1 -1
  319. package/dist/verification-plan.d.ts.map +1 -1
  320. package/dist/verification-plan.js +8 -0
  321. package/dist/verification-plan.js.map +1 -1
  322. package/dist/verification.d.ts.map +1 -1
  323. package/dist/verification.js +45 -12
  324. package/dist/verification.js.map +1 -1
  325. package/dist/web-app-options.d.ts +19 -0
  326. package/dist/web-app-options.d.ts.map +1 -0
  327. package/dist/web-app-options.js +49 -0
  328. package/dist/web-app-options.js.map +1 -0
  329. package/dist/work-graph.d.ts +3 -0
  330. package/dist/work-graph.d.ts.map +1 -1
  331. package/dist/work-graph.js +33 -16
  332. package/dist/work-graph.js.map +1 -1
  333. package/docs/examples.json +185 -29
  334. package/docs/llms-full.txt +4123 -548
  335. package/docs/types.json +14391 -10060
  336. package/package.json +19 -11
  337. package/src/app-files.ts +40 -0
  338. package/src/assure-hydration.ts +139 -46
  339. package/src/assure.ts +17 -2
  340. package/src/capabilities-tool.ts +35 -9
  341. package/src/cdn-check.ts +268 -0
  342. package/src/check-diagnostics.ts +199 -797
  343. package/src/check-scan.ts +394 -63
  344. package/src/check.ts +146 -58
  345. package/src/cli.ts +347 -92
  346. package/src/command-catalog.ts +407 -45
  347. package/src/config-target.ts +47 -0
  348. package/src/contracts.ts +66 -15
  349. package/src/db-audit.ts +128 -0
  350. package/src/db-child.ts +285 -0
  351. package/src/db-config.ts +224 -0
  352. package/src/db-tool.ts +690 -0
  353. package/src/dev-bun-config.ts +116 -40
  354. package/src/dev-feed-tool.ts +912 -0
  355. package/src/dev-server-client.ts +267 -0
  356. package/src/diagnostics.ts +179 -0
  357. package/src/diff-tool.ts +6 -5
  358. package/src/docs-search.ts +10 -3
  359. package/src/doctor.ts +98 -15
  360. package/src/env-file.ts +78 -1
  361. package/src/fix-recipes.ts +44 -31
  362. package/src/frontend-guidance.ts +5 -5
  363. package/src/i18n-check.ts +179 -0
  364. package/src/init-agents.ts +301 -38
  365. package/src/internal/code-unit-order.ts +5 -0
  366. package/src/internal/script-kind.ts +11 -0
  367. package/src/internal/source-facts.ts +96 -22
  368. package/src/internal/typescript-import.ts +663 -68
  369. package/src/introspect.ts +93 -82
  370. package/src/learn.ts +3 -3
  371. package/src/levels-tool.ts +47 -2
  372. package/src/load.ts +144 -32
  373. package/src/manifest-tool.ts +3 -2
  374. package/src/mcp-app-tool.ts +2 -0
  375. package/src/mcp-context.ts +5 -7
  376. package/src/mcp-delegate.ts +162 -0
  377. package/src/mcp-docs-tools.ts +23 -5
  378. package/src/mcp-exec.ts +301 -161
  379. package/src/mcp-http.ts +22 -5
  380. package/src/mcp-io.ts +130 -0
  381. package/src/mcp-isolate.ts +287 -0
  382. package/src/mcp-path.ts +50 -0
  383. package/src/mcp-project-child.ts +182 -0
  384. package/src/mcp-reflect.ts +3 -2
  385. package/src/mcp-render.ts +72 -42
  386. package/src/mcp-root.ts +130 -24
  387. package/src/mcp-run.ts +190 -56
  388. package/src/mcp-ws.ts +4 -1
  389. package/src/mcp.ts +193 -91
  390. package/src/migrate-layout.ts +1121 -0
  391. package/src/openapi-tool.ts +7 -3
  392. package/src/openapi-types.ts +315 -13
  393. package/src/pipeline-report.ts +22 -18
  394. package/src/port.ts +53 -30
  395. package/src/project-facts.ts +42 -8
  396. package/src/recipes/4.0.0.ts +35 -0
  397. package/src/recipes/index.ts +2 -0
  398. package/src/release-verification.ts +74 -5
  399. package/src/review.ts +988 -0
  400. package/src/route-types.ts +133 -0
  401. package/src/rules/codes.ts +9 -0
  402. package/src/rules/data-guard.ts +230 -0
  403. package/src/rules/index.ts +18 -5
  404. package/src/rules/islands.ts +12 -4
  405. package/src/rules/legacy.ts +947 -35
  406. package/src/rules/nano.ts +18 -6
  407. package/src/rules/pages.ts +99 -0
  408. package/src/rules/routes.ts +181 -2
  409. package/src/rules/secrets.ts +174 -0
  410. package/src/rules/security.ts +198 -70
  411. package/src/rules/typescript.ts +77 -0
  412. package/src/rules/zones.ts +195 -0
  413. package/src/scaffold.ts +175 -98
  414. package/src/sdk.ts +1215 -158
  415. package/src/search-terms.ts +24 -7
  416. package/src/smoke.ts +607 -0
  417. package/src/sync-manifest.ts +2 -1
  418. package/src/sync-routes.ts +4 -3
  419. package/src/test-tool.ts +32 -11
  420. package/src/types-search.ts +11 -4
  421. package/src/upgrade.ts +205 -25
  422. package/src/verification-plan.ts +27 -0
  423. package/src/verification.ts +50 -12
  424. package/src/web-app-options.ts +57 -0
  425. package/src/work-graph.ts +40 -16
@@ -1,5 +1,28 @@
1
- import type { CheckDiagnostic } from "../check.ts"
2
- import type { Diagnostic, Severity } from "../diagnostics.ts"
1
+ import { existsSync } from "node:fs"
2
+ import { resolve } from "node:path"
3
+ import { reservedKeyFor } from "@nifrajs/client"
4
+ import type { AssuranceConfig, AssuranceReport } from "@nifrajs/core/assurance"
5
+ import { type ProjectEvidenceSnapshot, snapshotProjectEvidence } from "@nifrajs/core/evidence"
6
+ import { SINGLE_COPY_REGISTER_SPECIFIER } from "@nifrajs/core/single-copy"
7
+ import type { CapabilityProjectReport } from "../capabilities-tool.ts"
8
+ import type { SourceFinding, StaticRouteFinding } from "../check-scan.ts"
9
+ import {
10
+ IDENT,
11
+ movedExportSites,
12
+ parseSimpleFetchCall,
13
+ REMOVED_IMPORTS,
14
+ SIMPLE_REWRITE_METHODS,
15
+ } from "../check-scan.ts"
16
+ import {
17
+ type Diagnostic,
18
+ type DiagnosticCompatibility,
19
+ type DiagnosticSuggestion,
20
+ diagnostic,
21
+ diagnosticWithCompatibility,
22
+ } from "../diagnostics.ts"
23
+ import type { DuplicateInstallFinding } from "../doctor.ts"
24
+ import { codeUnitOrder } from "../internal/code-unit-order.ts"
25
+ import { unsupportedTypeScriptDiagnostic } from "../internal/typescript-import.ts"
3
26
  import type { CheckRule, RuleContext } from "./index.ts"
4
27
 
5
28
  /** Legacy rule name → stable NF- code. Exported so `nifra.check.json` rule overrides can be keyed
@@ -44,50 +67,939 @@ const TITLES: Readonly<Record<string, string>> = Object.freeze({
44
67
  "contract-drift": "Contract snapshot drift",
45
68
  })
46
69
 
47
- function recipeFor(finding: CheckDiagnostic): Diagnostic["fix"] {
48
- if (finding.rule === "stale-workspace-dist")
70
+ /** The order used by the original human/legacy result. Structured diagnostics keep registry order. */
71
+ export const LEGACY_RULE_ORDER: readonly string[] = Object.freeze([
72
+ "check-config",
73
+ "typecheck",
74
+ "typed-client",
75
+ "removed-import",
76
+ "untyped-client",
77
+ "server-only-import",
78
+ "interpolated-sql",
79
+ "response-route",
80
+ "undeclared-dependency",
81
+ "duplicate-install",
82
+ "stale-workspace-dist",
83
+ "pipeline",
84
+ "server-manifest-drift",
85
+ "capability-assurance",
86
+ "manifest-drift",
87
+ "capability-config",
88
+ "contract-drift",
89
+ ])
90
+
91
+ const FETCH_HINT =
92
+ "hand-rolled fetch() to your own API - call it through client<typeof app> (from @nifrajs/client) so the compiler catches drift"
93
+ const STREAM_HINT =
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"
95
+ const SERVER_IMPORT_HINT =
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"
97
+ const RESPONSE_ROUTE_HINT =
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"
99
+ const PIPELINE_DOC_HINT =
100
+ "nifra runs one bundler per phase - `vitePlugins` feed Vite, `clientPlugins`/`serverPlugins` feed Bun, and the file `nifra build` imports the adapter from is bundled into the server. See the Gotchas section of the Dev & HMR guide."
101
+ const UNDECLARED_DEP_HINT =
102
+ "imported package is not declared in package.json dependencies - run bun add to declare it"
103
+ const SQL_COMPILER_MISSING_HINT =
104
+ "the interpolated-SQL rule did NOT run - it parses source with the TypeScript compiler, which is an optional peer and is not installed here. This report says nothing about SQL injection either way. Install it with `bun add -d typescript`"
105
+ const SQL_INTERPOLATION_EXAMPLE = "$" + "{value}"
106
+ const INTERPOLATED_SQL_HINT = `SQL built by interpolating a value into the statement text - the value becomes statement, not a parameter, so anything the caller controls can end the literal and continue as SQL. Pass it as a bound parameter (\`?\` / \`$1\` and an argument), or use your driver's tagged template (sql\`… ${SQL_INTERPOLATION_EXAMPLE} …\`), which binds the substitutions for you`
107
+ const MANIFEST_DRIFT_HINT =
108
+ "server-manifest.ts is out of sync with routes/ - re-run the build to regenerate it (a disk-less worker bakes this route table, so the drift is a silent edge break), then commit it"
109
+ const TRUST_MANIFEST_DRIFT_HINT =
110
+ "nifra.manifest.json is missing, invalid, or out of sync - run `nifra manifest emit`, review it, and commit the regenerated trust artifact"
111
+ const CAPABILITY_HINT =
112
+ "effect/capability assurance failed - align the route declaration with approved adapter provenance; never bypass an owned effect seam"
113
+
114
+ const TSC_LINE = /^(.+?)\((\d+),\d+\):\s*(?:error|warning)\s+TS\d+:\s*(.+)$/
115
+ const UNTYPED_CLIENT_HINT =
116
+ 'client("…") without a type argument - write client<typeof app>("…") (or client(contract, url)) so the compiler can catch drift'
117
+
118
+ type LegacySeverity = "error" | "warning" | "info"
119
+
120
+ interface LegacyFields {
121
+ readonly severity: LegacySeverity
122
+ readonly file?: string
123
+ readonly line?: number
124
+ readonly message: string
125
+ readonly evidence?: readonly string[]
126
+ readonly chain?: readonly string[]
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"]
130
+ readonly suggestion?: DiagnosticSuggestion
131
+ readonly verify?: string
132
+ readonly includeCode?: boolean
133
+ }
134
+
135
+ function canonicalRecipe(rule: string): Diagnostic["fix"] {
136
+ if (rule === "stale-workspace-dist")
49
137
  return { recipe: "workspace-dist.rebuild", command: "nifra fix --code NF-C010" }
50
- if (finding.rule === "server-manifest-drift")
138
+ if (rule === "server-manifest-drift")
51
139
  return { recipe: "manifest.sync", command: "nifra sync-manifest" }
52
- if (finding.rule === "contract-drift")
140
+ if (rule === "contract-drift")
53
141
  return { recipe: "contracts.snapshot", command: "nifra contracts snapshot" }
54
142
  return undefined
55
143
  }
56
144
 
57
- function toDiagnostic(finding: CheckDiagnostic, code: string): Diagnostic {
58
- const severity: Severity = finding.severity === "warning" ? "warn" : finding.severity
59
- const evidence = finding.evidence ?? finding.chain
60
- const fix = recipeFor(finding)
61
- return Object.freeze({
145
+ /** Build the stable structured diagnostic and attach only the data the legacy renderer needs. */
146
+ function legacyDiagnostic(rule: string, fields: LegacyFields): Diagnostic {
147
+ const code = LEGACY_RULE_CODES[rule]
148
+ if (code === undefined) throw new Error(`unknown legacy rule ${rule}`)
149
+ const evidence = fields.evidence ?? fields.chain
150
+ const fix = fields.recipe ?? canonicalRecipe(rule)
151
+ const canonical: Diagnostic = {
62
152
  code,
63
- severity,
64
- message: finding.message,
65
- ...(finding.file === undefined ? {} : { file: finding.file }),
66
- ...(finding.line === undefined ? {} : { line: finding.line }),
67
- ...(evidence === undefined ? {} : { evidence }),
153
+ severity: fields.severity === "warning" ? "warn" : fields.severity,
154
+ message: fields.message,
155
+ ...(fields.file === undefined ? {} : { file: fields.file }),
156
+ ...(fields.line === undefined ? {} : { line: fields.line }),
157
+ ...(evidence === undefined ? {} : { evidence: Object.freeze([...evidence]) }),
68
158
  ...(fix === undefined ? {} : { fix }),
69
- ...(finding.verify === undefined ? {} : { verify: finding.verify }),
70
- })
159
+ ...(fields.verify === undefined ? {} : { verify: fields.verify }),
160
+ }
161
+ const compatibility: DiagnosticCompatibility = {
162
+ rule,
163
+ ...(fields.fix === undefined ? {} : { fix: fields.fix }),
164
+ ...(fields.suggestion === undefined ? {} : { suggestion: fields.suggestion }),
165
+ ...(fields.chain === undefined ? {} : { chain: fields.chain }),
166
+ ...(fields.includeCode === true ? { includeCode: true } : {}),
167
+ }
168
+ return diagnosticWithCompatibility(canonical, compatibility)
71
169
  }
72
170
 
73
- function findingsFor(ctx: RuleContext, rule: string): Diagnostic[] {
74
- const findings = ctx.project.legacyDiagnostics
75
- if (!Array.isArray(findings)) return []
76
- const code = LEGACY_RULE_CODES[rule]
77
- if (code === undefined) return []
78
- return findings
79
- .filter((finding): finding is CheckDiagnostic => finding?.rule === rule)
80
- .map((finding) => toDiagnostic(finding, code))
171
+ const bySite = (a: SourceFinding, b: SourceFinding): number =>
172
+ codeUnitOrder(a.file, b.file) || a.line - b.line
173
+
174
+ function oneLineDiff(file: string, line: number, before: string, after: string): string {
175
+ return `--- ${file}:${line}\n+++ ${file}:${line}\n@@\n-${before}\n+${after}`
81
176
  }
82
177
 
83
- function makeRule(rule: string): CheckRule {
84
- const code = LEGACY_RULE_CODES[rule]
85
- const title = TITLES[rule]
86
- if (code === undefined || title === undefined) throw new Error(`unknown legacy rule ${rule}`)
87
- return Object.freeze({ code, title, scan: async (ctx: RuleContext) => findingsFor(ctx, rule) })
178
+ function untypedClientSuggestion(f: SourceFinding): DiagnosticSuggestion {
179
+ const replacement = f.snippet.replace(/(?<![.\w])client\s*\(/, "client<typeof app>(")
180
+ return replacement === f.snippet
181
+ ? {
182
+ kind: "manual",
183
+ title: "Add the app type argument to the client factory",
184
+ steps: [
185
+ 'Change `client("...")` to `client<typeof app>("...")`.',
186
+ "Make sure the backend app type is imported or otherwise in scope.",
187
+ ],
188
+ }
189
+ : {
190
+ kind: "edit",
191
+ title: "Insert `<typeof app>` into the client factory call",
192
+ diff: oneLineDiff(f.file, f.line, f.snippet, replacement),
193
+ steps: ["Make sure the backend app type is imported or otherwise in scope."],
194
+ }
195
+ }
196
+
197
+ function routeKey(method: string, path: string): string {
198
+ return `${method.toUpperCase()} ${path}`
199
+ }
200
+
201
+ function typedClientCall(method: string, path: string): string {
202
+ const segs = path.split("/").filter((seg) => seg !== "")
203
+ let chain = "api"
204
+ if (segs.length === 0) chain += ".index"
205
+ else {
206
+ for (const seg of segs) {
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)}]`
211
+ }
212
+ }
213
+ return `${chain}.${method.toLowerCase()}()`
214
+ }
215
+
216
+ function staticRouteMap(routes: readonly StaticRouteFinding[]): Map<string, StaticRouteFinding[]> {
217
+ const out = new Map<string, StaticRouteFinding[]>()
218
+ for (const route of routes) {
219
+ if (route.path.includes(":") || route.path.includes("*")) continue
220
+ const key = routeKey(route.method, route.path)
221
+ const bucket = out.get(key)
222
+ if (bucket === undefined) out.set(key, [route])
223
+ else bucket.push(route)
224
+ }
225
+ return out
226
+ }
227
+
228
+ function ownFetchEditSuggestion(
229
+ f: SourceFinding,
230
+ routes: Map<string, StaticRouteFinding[]>,
231
+ ): DiagnosticSuggestion | undefined {
232
+ const call = parseSimpleFetchCall(f.snippet)
233
+ if (call === undefined || !SIMPLE_REWRITE_METHODS.has(call.method)) return undefined
234
+ const matches = routes.get(routeKey(call.method, call.path))
235
+ if (matches === undefined || matches.length !== 1) return undefined
236
+ const replacementCall = typedClientCall(call.method, call.path)
237
+ const replacement = `${f.snippet.slice(0, call.start)}${replacementCall}${f.snippet.slice(call.end)}`
238
+ if (replacement === f.snippet) return undefined
239
+ const route = matches[0]
240
+ if (route === undefined) return undefined
241
+ return {
242
+ kind: "edit",
243
+ title: "Rewrite simple own-API fetch to the typed nifra client",
244
+ diff: oneLineDiff(f.file, f.line, f.snippet, replacement),
245
+ steps: [
246
+ `Matched ${route.method} ${route.path} at ${route.file}:${route.line}.`,
247
+ "Use an in-scope typed client named `api` (`client<typeof app>(baseUrl)` or the route loader/action `api`).",
248
+ "Update downstream `Response` handling to branch on `{ ok, data, error }` if this variable is used later.",
249
+ ],
250
+ }
251
+ }
252
+
253
+ function ownFetchSuggestion(
254
+ f: SourceFinding,
255
+ routes: Map<string, StaticRouteFinding[]>,
256
+ ): DiagnosticSuggestion {
257
+ const exact = ownFetchEditSuggestion(f, routes)
258
+ if (exact !== undefined) return exact
259
+ return {
260
+ kind: "manual",
261
+ title: "Replace own-API fetch with the typed nifra client",
262
+ steps: [
263
+ "Call `nifra_routes` or read `nifra://routes` for the exact typed-client call form.",
264
+ "Create `const api = client<typeof app>(baseUrl)` from `@nifrajs/client`.",
265
+ "Replace the relative `fetch()` call with the generated `api...get/post/...` call and branch on `{ ok, data, error }`.",
266
+ ],
267
+ }
268
+ }
269
+
270
+ function ownStreamSuggestion(): DiagnosticSuggestion {
271
+ return {
272
+ kind: "manual",
273
+ title: "Replace own-API EventSource/WebSocket with the typed nifra client",
274
+ steps: [
275
+ "Call `nifra_routes` or read `nifra://routes` for the exact typed-client call form.",
276
+ "Create `const api = client<typeof app>(baseUrl)` from `@nifrajs/client`.",
277
+ 'Replace `new EventSource("/…")` with the generated `api.…subscribe(onEvent)` call for the matching `app.sse()` route.',
278
+ 'Replace `new WebSocket("/…")` with the generated `api.….ws()` handle for the matching `app.ws()` route.',
279
+ ],
280
+ }
281
+ }
282
+
283
+ function serverImportSuggestion(
284
+ specifier: string,
285
+ chain: readonly string[],
286
+ fallback: boolean,
287
+ ): DiagnosticSuggestion {
288
+ const sink = chain[chain.length - 1] ?? specifier
289
+ const chainStep =
290
+ chain.length > 2
291
+ ? fallback
292
+ ? `Server-only code reaches this route through \`${chain.join(" → ")}\` (the deeper chain couldn't be resolved precisely - trace it from \`${specifier}\`).`
293
+ : `Server-only code reaches this route transitively: \`${chain.join(" → ")}\`. The sink is \`${sink}\`; break the chain at the first hop (\`${specifier}\`) or move the sink behind the server boundary.`
294
+ : undefined
295
+ return {
296
+ kind: "manual",
297
+ title: "Move server-only code behind the route's backend half",
298
+ steps: [
299
+ ...(chainStep === undefined ? [] : [chainStep]),
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.",
304
+ ],
305
+ }
306
+ }
307
+
308
+ function responseRouteSuggestion(): DiagnosticSuggestion {
309
+ return {
310
+ kind: "manual",
311
+ title: "Preserve typed-client response inference",
312
+ steps: [
313
+ "Prefer returning a plain object from JSON routes; nifra serializes it for you.",
314
+ "For a stream, use a typed SSE route - `app.sse(...)` (or `sse(c, run)` from `@nifrajs/core/server`) - which keeps typed events instead of collapsing the client to `data: never`.",
315
+ "If this route must return a raw Response (redirect, file), declare an explicit response schema, or add a `// nifra-expect raw-response` comment above the return to mark it intentional and silence this advisory.",
316
+ ],
317
+ }
318
+ }
319
+
320
+ const typecheckRule: CheckRule = {
321
+ code: LEGACY_RULE_CODES.typecheck!,
322
+ title: TITLES.typecheck!,
323
+ async scan(ctx) {
324
+ const tc = ctx.project.check.typecheck
325
+ const findings: Diagnostic[] = []
326
+ if (tc.missingTypeScript === true) {
327
+ findings.push(
328
+ legacyDiagnostic("typecheck", {
329
+ severity: "error",
330
+ file: "tsconfig.json",
331
+ message:
332
+ "tsconfig.json is present but no `typescript` install was found from this directory upward - the typecheck gate did NOT run",
333
+ fix: "bun add -d typescript",
334
+ suggestion: {
335
+ kind: "command",
336
+ title: "Install TypeScript so the contract gate can run",
337
+ command: ["bun", "add", "-d", "typescript"],
338
+ },
339
+ }),
340
+ )
341
+ }
342
+ if (tc.ran && !tc.ok) {
343
+ const lines = (tc.output ?? "").split("\n")
344
+ let matched = false
345
+ for (const line of lines) {
346
+ const match = TSC_LINE.exec(line.trim())
347
+ if (match === null) continue
348
+ matched = true
349
+ findings.push(
350
+ legacyDiagnostic("typecheck", {
351
+ severity: "error",
352
+ file: match[1] as string,
353
+ line: Number(match[2]),
354
+ message: match[3] as string,
355
+ suggestion: {
356
+ kind: "manual",
357
+ title: "Fix the TypeScript contract error",
358
+ steps: [
359
+ "Open the reported file and line.",
360
+ "Align the handler, route schema, or typed-client call with the compiler error.",
361
+ "Run `nifra_check` again after the edit.",
362
+ ],
363
+ },
364
+ }),
365
+ )
366
+ }
367
+ if (!matched) {
368
+ findings.push(
369
+ legacyDiagnostic("typecheck", {
370
+ severity: "error",
371
+ message: tc.output || "typecheck failed",
372
+ suggestion: {
373
+ kind: "manual",
374
+ title: "Fix the TypeScript contract error",
375
+ steps: ["Run `tsc --noEmit` locally for the full compiler output."],
376
+ },
377
+ }),
378
+ )
379
+ }
380
+ }
381
+ return findings
382
+ },
383
+ }
384
+
385
+ const typedClientRule: CheckRule = {
386
+ code: LEGACY_RULE_CODES["typed-client"]!,
387
+ title: TITLES["typed-client"]!,
388
+ async scan(ctx) {
389
+ const routes = staticRouteMap(ctx.project.routes)
390
+ const fetchFindings = [...ctx.project.sourceFindings.fetches].sort(bySite).map((finding) =>
391
+ legacyDiagnostic("typed-client", {
392
+ severity: "error",
393
+ file: finding.file,
394
+ line: finding.line,
395
+ message: `${finding.snippet} - ${FETCH_HINT}`,
396
+ fix: FETCH_HINT,
397
+ suggestion: ownFetchSuggestion(finding, routes),
398
+ }),
399
+ )
400
+ const streamFindings = [...ctx.project.sourceFindings.streams].sort(bySite).map((finding) =>
401
+ legacyDiagnostic("typed-client", {
402
+ severity: "error",
403
+ file: finding.file,
404
+ line: finding.line,
405
+ message: `${finding.snippet} - ${STREAM_HINT}`,
406
+ fix: STREAM_HINT,
407
+ suggestion: ownStreamSuggestion(),
408
+ }),
409
+ )
410
+ return [...fetchFindings, ...streamFindings].sort(
411
+ (a, b) => codeUnitOrder(a.file ?? "", b.file ?? "") || (a.line ?? 0) - (b.line ?? 0),
412
+ )
413
+ },
414
+ }
415
+
416
+ const removedImportRule: CheckRule = {
417
+ code: LEGACY_RULE_CODES["removed-import"]!,
418
+ title: TITLES["removed-import"]!,
419
+ async scan(ctx) {
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
+ }
433
+ const entry = REMOVED_IMPORTS.find(
434
+ (candidate) =>
435
+ finding.snippet.includes(`"${candidate.specifier}`) ||
436
+ finding.snippet.includes(`'${candidate.specifier}`),
437
+ )
438
+ const replacement = entry?.replacement ?? "see the changelog"
439
+ return legacyDiagnostic("removed-import", {
440
+ severity: "error",
441
+ file: finding.file,
442
+ line: finding.line,
443
+ message: `${finding.snippet} - removed in nifra ${entry?.since ?? "2.0"}: ${replacement}`,
444
+ fix: replacement,
445
+ })
446
+ })
447
+ },
448
+ }
449
+
450
+ const untypedClientRule: CheckRule = {
451
+ code: LEGACY_RULE_CODES["untyped-client"]!,
452
+ title: TITLES["untyped-client"]!,
453
+ async scan(ctx) {
454
+ return [...ctx.project.sourceFindings.untypedClients].sort(bySite).map((finding) =>
455
+ legacyDiagnostic("untyped-client", {
456
+ severity: "error",
457
+ file: finding.file,
458
+ line: finding.line,
459
+ message: `${finding.snippet} - ${UNTYPED_CLIENT_HINT}`,
460
+ fix: UNTYPED_CLIENT_HINT,
461
+ suggestion: untypedClientSuggestion(finding),
462
+ }),
463
+ )
464
+ },
465
+ }
466
+
467
+ const serverOnlyImportRule: CheckRule = {
468
+ code: LEGACY_RULE_CODES["server-only-import"]!,
469
+ title: TITLES["server-only-import"]!,
470
+ async scan(ctx) {
471
+ return [...ctx.project.importGraph].sort(bySite).map((finding) => {
472
+ const chain = finding.chain
473
+ const sink = chain[chain.length - 1] ?? finding.specifier
474
+ return legacyDiagnostic("server-only-import", {
475
+ severity: "error",
476
+ file: finding.file,
477
+ line: finding.line,
478
+ message: `${finding.snippet} - server-only "${sink}" reaches the browser bundle via ${chain.join(" → ")}${finding.fallback ? " (direct edge - couldn't resolve the transitive chain precisely)" : ""}; ${SERVER_IMPORT_HINT}`,
479
+ fix: SERVER_IMPORT_HINT,
480
+ chain,
481
+ suggestion: serverImportSuggestion(finding.specifier, chain, finding.fallback),
482
+ })
483
+ })
484
+ },
485
+ }
486
+
487
+ const interpolatedSqlRule: CheckRule = {
488
+ code: LEGACY_RULE_CODES["interpolated-sql"]!,
489
+ title: TITLES["interpolated-sql"]!,
490
+ async scan(ctx) {
491
+ const findings: Diagnostic[] = []
492
+ if (ctx.project.check.unsupportedTypeScriptVersion !== undefined) {
493
+ findings.push(unsupportedTypeScriptDiagnostic(ctx.project.check.unsupportedTypeScriptVersion))
494
+ } else if (!ctx.project.check.sqlCompilerAvailable) {
495
+ findings.push(
496
+ legacyDiagnostic("interpolated-sql", {
497
+ severity: "warning",
498
+ message: SQL_COMPILER_MISSING_HINT,
499
+ fix: SQL_COMPILER_MISSING_HINT,
500
+ suggestion: {
501
+ kind: "command",
502
+ title: "Install TypeScript so the SQL rule can run",
503
+ // Keep the one-element command array used by the legacy renderer for exact output.
504
+ command: ["bun add -d typescript"],
505
+ },
506
+ }),
507
+ )
508
+ }
509
+ for (const finding of [...ctx.project.sourceFindings.interpolatedSql].sort(bySite)) {
510
+ findings.push(
511
+ legacyDiagnostic("interpolated-sql", {
512
+ severity: "error",
513
+ file: finding.file,
514
+ line: finding.line,
515
+ message: `${finding.snippet} - ${INTERPOLATED_SQL_HINT}`,
516
+ fix: "bind the value as a parameter instead of interpolating it into the statement",
517
+ suggestion: {
518
+ kind: "manual",
519
+ title: "Bind the value instead of interpolating it",
520
+ steps: [
521
+ "Replace the interpolation with a placeholder your driver understands (`?` for SQLite/MySQL, `$1` for Postgres).",
522
+ "Pass the value as an argument alongside the statement, so the driver binds it.",
523
+ `Or switch to the driver's tagged template (sql\`… ${SQL_INTERPOLATION_EXAMPLE} …\`): the tag receives substitutions separately and binds them.`,
524
+ "An identifier that genuinely cannot be bound (a table or column name) must be checked against an allowlist you control, never taken from the request.",
525
+ "If the statement is dynamic but every value is already bound (generating `($1),($2),…` for a batch insert), mark it with a `// nifra-expect sql-dynamic: <reason>` comment on the line above - the reason is required.",
526
+ ],
527
+ },
528
+ }),
529
+ )
530
+ }
531
+ return findings
532
+ },
533
+ }
534
+
535
+ const responseRouteRule: CheckRule = {
536
+ code: LEGACY_RULE_CODES["response-route"]!,
537
+ title: TITLES["response-route"]!,
538
+ async scan(ctx) {
539
+ return [...ctx.project.sourceFindings.responseRoutes].sort(bySite).map((finding) =>
540
+ legacyDiagnostic("response-route", {
541
+ severity: "warning",
542
+ file: finding.file,
543
+ line: finding.line,
544
+ message: `${finding.snippet} - ${RESPONSE_ROUTE_HINT}`,
545
+ fix: RESPONSE_ROUTE_HINT,
546
+ suggestion: responseRouteSuggestion(),
547
+ }),
548
+ )
549
+ },
550
+ }
551
+
552
+ function copyLines(finding: DuplicateInstallFinding): string[] {
553
+ return finding.copies.map(
554
+ (copy) =>
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(", ")}`}`,
556
+ )
557
+ }
558
+
559
+ function duplicateSuggestion(finding: DuplicateInstallFinding): DiagnosticSuggestion {
560
+ const lines = copyLines(finding)
561
+ return {
562
+ kind: "manual",
563
+ title: `Resolve ${finding.package} to one loaded copy`,
564
+ diff: [
565
+ "package.json:",
566
+ ` "nifra": { "singleCopy": ["${finding.package}"] }`,
567
+ "bunfig.toml:",
568
+ ` preload = ["${SINGLE_COPY_REGISTER_SPECIFIER}"]`,
569
+ " [test]",
570
+ ` preload = ["${SINGLE_COPY_REGISTER_SPECIFIER}"]`,
571
+ ].join("\n"),
572
+ steps: [
573
+ ...lines.map((line) => `Copy: ${line}`),
574
+ ...(finding.provenance === undefined
575
+ ? []
576
+ : [`Planted links: ${finding.provenance} Try this before the fixes below.`]),
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.",
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." : ""}`,
579
+ "Re-run `nifra check`; the gate stays failing until one fix lands.",
580
+ ],
581
+ }
582
+ }
583
+
584
+ const doctorRules: readonly CheckRule[] = Object.freeze([
585
+ {
586
+ code: LEGACY_RULE_CODES["undeclared-dependency"]!,
587
+ title: TITLES["undeclared-dependency"]!,
588
+ async scan(ctx: RuleContext) {
589
+ const doctor = ctx.project.packages.doctor
590
+ if (!doctor.ran) return []
591
+ return doctor.findings.map((finding) =>
592
+ legacyDiagnostic("undeclared-dependency", {
593
+ severity: "error",
594
+ file: finding.file,
595
+ line: finding.line,
596
+ message: `imports ${finding.package} which is not declared in package.json - ${UNDECLARED_DEP_HINT}`,
597
+ fix: `add ${finding.package} to package.json dependencies`,
598
+ suggestion: {
599
+ kind: "command",
600
+ title: `Declare ${finding.package} in package.json`,
601
+ command: ["bun", "add", finding.package],
602
+ },
603
+ }),
604
+ )
605
+ },
606
+ },
607
+ {
608
+ code: LEGACY_RULE_CODES["duplicate-install"]!,
609
+ title: TITLES["duplicate-install"]!,
610
+ async scan(ctx: RuleContext) {
611
+ const doctor = ctx.project.packages.doctor
612
+ if (!doctor.ran) return []
613
+ const findings: Diagnostic[] = []
614
+ for (const finding of doctor.duplicateInstalls) {
615
+ const copies = copyLines(finding)
616
+ findings.push(
617
+ legacyDiagnostic("duplicate-install", {
618
+ severity: "error",
619
+ message: `${finding.package} identity preflight found ${finding.cause} - ${finding.explanation}${finding.topology === undefined ? "" : `. Topology: ${finding.topology}`}${finding.scope === undefined ? "" : `. Scope: ${finding.scope}`}`,
620
+ fix: finding.remediation,
621
+ evidence: copies,
622
+ suggestion: duplicateSuggestion(finding),
623
+ }),
624
+ )
625
+ }
626
+ return findings
627
+ },
628
+ },
629
+ {
630
+ code: LEGACY_RULE_CODES["stale-workspace-dist"]!,
631
+ title: TITLES["stale-workspace-dist"]!,
632
+ async scan(ctx: RuleContext) {
633
+ const doctor = ctx.project.packages.doctor
634
+ if (!doctor.ran) return []
635
+ return doctor.staleDists.map((finding) => {
636
+ const rebuild =
637
+ finding.buildScript === undefined
638
+ ? `${finding.packageDir} declares no build script - build it the way that package expects`
639
+ : `cd ${finding.packageDir} && bun run ${finding.buildScript} (or \`nifra fix --code NF-C010\`)`
640
+ return legacyDiagnostic("stale-workspace-dist", {
641
+ severity: "warning",
642
+ message: finding.missing
643
+ ? `${finding.package} was never built - ${finding.distFile} is missing, but its export map serves it to Vite SSR/node consumers while Bun reads src - ${rebuild}`
644
+ : `${finding.package} has a stale build artifact - ${finding.behindSeconds}s older than ${finding.sourceFile}, and Vite SSR/node consumers read the artifact while Bun reads src - ${rebuild}`,
645
+ fix: rebuild,
646
+ evidence: [finding.package, finding.distFile, finding.sourceFile, finding.packageDir],
647
+ suggestion: {
648
+ kind: "manual",
649
+ title: `Rebuild ${finding.package}`,
650
+ steps: [
651
+ rebuild,
652
+ "Only workspace-linked installs drift; npm tarballs are immutable and never flagged.",
653
+ ],
654
+ },
655
+ })
656
+ })
657
+ },
658
+ },
659
+ ])
660
+
661
+ const pipelineRule: CheckRule = {
662
+ code: LEGACY_RULE_CODES.pipeline!,
663
+ title: TITLES.pipeline!,
664
+ async scan(ctx) {
665
+ return (ctx.project.packages.doctor.pipeline?.findings ?? []).map((finding) =>
666
+ legacyDiagnostic("pipeline", {
667
+ severity: finding.severity,
668
+ file: finding.file,
669
+ ...(finding.line === undefined ? {} : { line: finding.line }),
670
+ message: finding.message,
671
+ fix: finding.fix,
672
+ suggestion: { kind: "manual", title: finding.fix, steps: [PIPELINE_DOC_HINT] },
673
+ }),
674
+ )
675
+ },
676
+ }
677
+
678
+ const serverManifestRule: CheckRule = {
679
+ code: LEGACY_RULE_CODES["server-manifest-drift"]!,
680
+ title: TITLES["server-manifest-drift"]!,
681
+ async scan(ctx) {
682
+ return ctx.project.packages.manifestDrift.map((finding) => {
683
+ const parts: string[] = []
684
+ if (finding.missing.length > 0)
685
+ parts.push(`missing from manifest: ${finding.missing.join(", ")}`)
686
+ if (finding.extra.length > 0) parts.push(`stale in manifest: ${finding.extra.join(", ")}`)
687
+ return legacyDiagnostic("server-manifest-drift", {
688
+ severity: "error",
689
+ file: finding.file,
690
+ message: `${finding.file} drifted from routes/ (${parts.join("; ")}) - ${MANIFEST_DRIFT_HINT}`,
691
+ fix: MANIFEST_DRIFT_HINT,
692
+ evidence: [...finding.missing, ...finding.extra],
693
+ suggestion: {
694
+ kind: "manual",
695
+ title: "Regenerate the committed server manifest",
696
+ steps: [
697
+ "Re-run your build (`nifra build --target <t>` or your build script) - it regenerates server-manifest.ts from the current routes/.",
698
+ "Commit the updated server-manifest.ts.",
699
+ ],
700
+ },
701
+ })
702
+ })
703
+ },
704
+ }
705
+
706
+ function capabilityContext(ctx: RuleContext):
707
+ | {
708
+ readonly config: AssuranceConfig
709
+ readonly project?: CapabilityProjectReport
710
+ readonly routeAssurance?: AssuranceReport
711
+ readonly evidence?: ProjectEvidenceSnapshot
712
+ }
713
+ | undefined {
714
+ const assurance = ctx.project.policies.assurance
715
+ if (assurance === undefined || !assurance.present || assurance.error !== undefined)
716
+ return undefined
717
+ if (assurance.config === undefined) return undefined
718
+ return {
719
+ config: assurance.config,
720
+ ...(assurance.capability === undefined ? {} : { project: assurance.capability }),
721
+ ...(assurance.routeAssurance === undefined ? {} : { routeAssurance: assurance.routeAssurance }),
722
+ ...(assurance.evidence === undefined ? {} : { evidence: assurance.evidence }),
723
+ }
724
+ }
725
+
726
+ const capabilityAssuranceRule: CheckRule = {
727
+ code: LEGACY_RULE_CODES["capability-assurance"]!,
728
+ title: TITLES["capability-assurance"]!,
729
+ async scan(ctx) {
730
+ const context = capabilityContext(ctx)
731
+ const project = context?.project
732
+ const config = context?.config
733
+ if (project === undefined || config?.capabilities === undefined) return []
734
+ const findings: Diagnostic[] = []
735
+ for (const finding of project.report.findings) {
736
+ const violation =
737
+ finding.code === "forbidden-effect-import"
738
+ ? project.violations.find(
739
+ (candidate) => candidate.method === finding.method && candidate.path === finding.path,
740
+ )
741
+ : undefined
742
+ const truncation =
743
+ finding.code === "provenance-truncated"
744
+ ? project.truncations.find(
745
+ (candidate) => candidate.method === finding.method && candidate.path === finding.path,
746
+ )
747
+ : undefined
748
+ const seamFix =
749
+ finding.code === "unmatched-provenance-seam"
750
+ ? "Write the seam exactly as the code imports it, or delete the rule."
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
757
+ findings.push(
758
+ legacyDiagnostic("capability-assurance", {
759
+ severity: "error",
760
+ ...(violation === undefined
761
+ ? truncation === undefined
762
+ ? {}
763
+ : { chain: truncation.chain }
764
+ : { file: violation.module, chain: violation.chain }),
765
+ message: `${finding.message}${ownFix === undefined ? ` - ${CAPABILITY_HINT}` : ""}`,
766
+ fix: ownFix ?? CAPABILITY_HINT,
767
+ suggestion:
768
+ mountFix !== undefined
769
+ ? {
770
+ kind: "manual",
771
+ title: "Analyze the mounted routes, or state why they cannot be",
772
+ steps: [
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.",
776
+ ],
777
+ }
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
+ },
799
+ }),
800
+ )
801
+ }
802
+ return findings
803
+ },
804
+ }
805
+
806
+ const manifestRule: CheckRule = {
807
+ code: LEGACY_RULE_CODES["manifest-drift"]!,
808
+ title: TITLES["manifest-drift"]!,
809
+ async scan(ctx) {
810
+ const context = capabilityContext(ctx)
811
+ if (context === undefined || context.config.manifest === undefined) return []
812
+ const { config, project, routeAssurance: knownAssurance } = context
813
+ const capabilityReport = project?.report
814
+ let assurance = knownAssurance
815
+ let evidence = context.evidence
816
+ try {
817
+ if (assurance === undefined) {
818
+ const { evaluateRouteAssurance } = await import("@nifrajs/core/assurance")
819
+ assurance = evaluateRouteAssurance(config.source, config.policy, {
820
+ ...(config.capabilities === undefined
821
+ ? {}
822
+ : { definitions: config.capabilities.definitions }),
823
+ })
824
+ }
825
+ evidence ??= snapshotProjectEvidence(config.source, {
826
+ assurance,
827
+ ...(capabilityReport === undefined ? {} : { capabilities: capabilityReport }),
828
+ })
829
+ const manifest = config.manifest
830
+ if (manifest === undefined) return []
831
+ const path = resolve(ctx.root, manifest.path ?? "nifra.manifest.json")
832
+ let message: string | undefined
833
+ if (!assurance.ok || (capabilityReport !== undefined && !capabilityReport.ok)) {
834
+ message =
835
+ "the configured assurance policy is failing, so a trusted manifest cannot be built"
836
+ } else if (!existsSync(path)) {
837
+ message = "the configured trust manifest is missing"
838
+ } else {
839
+ try {
840
+ const { buildNifraManifest, parseNifraManifest, serializeNifraManifest } = await import(
841
+ "@nifrajs/core/manifest"
842
+ )
843
+ const current = await buildNifraManifest({
844
+ evidence,
845
+ assurance,
846
+ ...(capabilityReport === undefined ? {} : { capabilities: capabilityReport }),
847
+ })
848
+ const storedText = await Bun.file(path).text()
849
+ const stored = await parseNifraManifest(storedText, path)
850
+ const expectedText = `${serializeNifraManifest(current)}\n`
851
+ if (storedText !== expectedText || stored.contentHash !== current.contentHash) {
852
+ message = "the configured trust manifest does not match live route reflection"
853
+ }
854
+ } catch (error) {
855
+ message = `the configured trust manifest is invalid: ${error instanceof Error ? error.message : String(error)}`
856
+ }
857
+ }
858
+ if (message === undefined) return []
859
+ return [
860
+ legacyDiagnostic("manifest-drift", {
861
+ severity: "error",
862
+ file: path,
863
+ message: `${message} - ${TRUST_MANIFEST_DRIFT_HINT}`,
864
+ fix: TRUST_MANIFEST_DRIFT_HINT,
865
+ suggestion: {
866
+ kind: "command",
867
+ title: "Regenerate the signed-manifest input artifact",
868
+ command: ["nifra", "manifest", "emit"],
869
+ steps: [
870
+ "Review the route, assurance, capability, and classification delta before committing it.",
871
+ ],
872
+ },
873
+ }),
874
+ ]
875
+ } catch {
876
+ // A config-load failure is owned and reported by capability-config. Do not duplicate it as a
877
+ // manifest failure, because there is no trustworthy input from which to compare a manifest.
878
+ return []
879
+ }
880
+ },
881
+ }
882
+
883
+ const capabilityConfigRule: CheckRule = {
884
+ code: LEGACY_RULE_CODES["capability-config"]!,
885
+ title: TITLES["capability-config"]!,
886
+ async scan(ctx) {
887
+ const assurance = ctx.project.policies.assurance
888
+ if (assurance === undefined || !assurance.present) return []
889
+ if (assurance.error === undefined && assurance.config !== undefined) return []
890
+ const error = assurance.error
891
+ return [
892
+ legacyDiagnostic("capability-config", {
893
+ severity: "error",
894
+ file: "nifra.assurance.ts",
895
+ message: `capability assurance config could not be evaluated: ${error instanceof Error ? error.message : String(error ?? "configuration unavailable")}`,
896
+ suggestion: {
897
+ kind: "manual",
898
+ title: "Repair the assurance config",
899
+ steps: [
900
+ "Ensure nifra.assurance.ts default-exports defineAssuranceConfig({ source, policy, capabilities }).",
901
+ "Fix configuration/import errors; the provenance firewall fails closed when its policy cannot load.",
902
+ ],
903
+ },
904
+ }),
905
+ ]
906
+ },
907
+ }
908
+
909
+ const checkConfigRule: CheckRule = {
910
+ code: LEGACY_RULE_CODES["check-config"]!,
911
+ title: TITLES["check-config"]!,
912
+ async scan(ctx) {
913
+ const { checkConfigError, checkConfigWarnings } = ctx.project.check
914
+ const findings: Diagnostic[] = []
915
+ if (checkConfigError !== undefined) {
916
+ findings.push(
917
+ legacyDiagnostic("check-config", {
918
+ severity: "warning",
919
+ file: "nifra.check.json",
920
+ message: `nifra.check.json could not be parsed (${checkConfigError}) - its external-mount allowlist was ignored`,
921
+ fix: "Fix the JSON syntax in nifra.check.json",
922
+ }),
923
+ )
924
+ }
925
+ for (const warning of checkConfigWarnings) {
926
+ findings.push(
927
+ legacyDiagnostic("check-config", {
928
+ severity: "warning",
929
+ file: "nifra.check.json",
930
+ message: `nifra.check.json: ${warning}`,
931
+ fix: "Fix the entry in nifra.check.json",
932
+ }),
933
+ )
934
+ }
935
+ return findings
936
+ },
937
+ }
938
+
939
+ const contractRule: CheckRule = {
940
+ code: LEGACY_RULE_CODES["contract-drift"]!,
941
+ title: TITLES["contract-drift"]!,
942
+ async scan(ctx) {
943
+ const contract = ctx.project.check.contracts
944
+ if (!contract.present) {
945
+ return [
946
+ diagnostic({
947
+ code: LEGACY_RULE_CODES["contract-drift"]!,
948
+ severity: "info",
949
+ message: "no contract lock; run `nifra contracts snapshot` to enable drift detection",
950
+ verify: "nifra contracts snapshot",
951
+ }),
952
+ ]
953
+ }
954
+ if (contract.vacuous) {
955
+ return [
956
+ diagnostic({
957
+ code: LEGACY_RULE_CODES["contract-drift"]!,
958
+ severity: "info",
959
+ message:
960
+ "no route schemas declared - the trust manifest is vacuous: every contract hash is the empty-schema digest, so drift detection guards nothing. Declare route schemas (body/query/params/response) so the lock has a contract to protect.",
961
+ verify: "nifra check --lints-only",
962
+ }),
963
+ ]
964
+ }
965
+ if (contract.error !== undefined) {
966
+ return [
967
+ legacyDiagnostic("contract-drift", {
968
+ severity: "error",
969
+ message: `contract lock could not be checked: ${contract.error}`,
970
+ includeCode: true,
971
+ verify: "nifra contracts snapshot",
972
+ }),
973
+ ]
974
+ }
975
+ return contract.diagnostics.map((finding) =>
976
+ legacyDiagnostic("contract-drift", {
977
+ severity: "error",
978
+ ...(finding.route === undefined ? {} : { evidence: [finding.route] }),
979
+ message: finding.message,
980
+ fix: "run `nifra contracts snapshot` after reviewing the contract change",
981
+ includeCode: true,
982
+ verify: "nifra check --lints-only",
983
+ }),
984
+ )
985
+ },
88
986
  }
89
987
 
90
- /** Stable registry adapters for the existing source scanners. */
91
- export const legacyRules: readonly CheckRule[] = Object.freeze(
92
- Object.keys(LEGACY_RULE_CODES).map((name) => makeRule(name)),
93
- )
988
+ /** Built-in rules that used to be formatted imperatively in check-diagnostics.ts. */
989
+ export const legacyRules: readonly CheckRule[] = Object.freeze([
990
+ typecheckRule,
991
+ typedClientRule,
992
+ untypedClientRule,
993
+ serverOnlyImportRule,
994
+ removedImportRule,
995
+ interpolatedSqlRule,
996
+ responseRouteRule,
997
+ ...doctorRules,
998
+ pipelineRule,
999
+ serverManifestRule,
1000
+ manifestRule,
1001
+ capabilityAssuranceRule,
1002
+ capabilityConfigRule,
1003
+ checkConfigRule,
1004
+ contractRule,
1005
+ ])