@nifrajs/cli 4.0.2 → 5.1.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 (364) hide show
  1. package/README.md +10 -2
  2. package/dist/agent-files.d.ts +107 -0
  3. package/dist/agent-files.d.ts.map +1 -0
  4. package/dist/agent-files.js +162 -0
  5. package/dist/agent-files.js.map +1 -0
  6. package/dist/app-files.d.ts +6 -0
  7. package/dist/app-files.d.ts.map +1 -1
  8. package/dist/app-files.js +27 -2
  9. package/dist/app-files.js.map +1 -1
  10. package/dist/assure-hydration.d.ts.map +1 -1
  11. package/dist/assure-hydration.js +9 -2
  12. package/dist/assure-hydration.js.map +1 -1
  13. package/dist/assure.d.ts +12 -2
  14. package/dist/assure.d.ts.map +1 -1
  15. package/dist/assure.js +59 -8
  16. package/dist/assure.js.map +1 -1
  17. package/dist/capabilities-tool.d.ts.map +1 -1
  18. package/dist/capabilities-tool.js +36 -9
  19. package/dist/capabilities-tool.js.map +1 -1
  20. package/dist/check-diagnostics.d.ts +22 -2
  21. package/dist/check-diagnostics.d.ts.map +1 -1
  22. package/dist/check-diagnostics.js +54 -7
  23. package/dist/check-diagnostics.js.map +1 -1
  24. package/dist/check-scan.d.ts +83 -24
  25. package/dist/check-scan.d.ts.map +1 -1
  26. package/dist/check-scan.js +1516 -318
  27. package/dist/check-scan.js.map +1 -1
  28. package/dist/check.d.ts +2 -2
  29. package/dist/check.d.ts.map +1 -1
  30. package/dist/check.js +203 -70
  31. package/dist/check.js.map +1 -1
  32. package/dist/cli.d.ts +9 -0
  33. package/dist/cli.d.ts.map +1 -1
  34. package/dist/cli.js +215 -50
  35. package/dist/cli.js.map +1 -1
  36. package/dist/client-module.d.ts +20 -0
  37. package/dist/client-module.d.ts.map +1 -0
  38. package/dist/client-module.js +73 -0
  39. package/dist/client-module.js.map +1 -0
  40. package/dist/command-catalog.d.ts +24 -5
  41. package/dist/command-catalog.d.ts.map +1 -1
  42. package/dist/command-catalog.js +194 -45
  43. package/dist/command-catalog.js.map +1 -1
  44. package/dist/component-check.d.ts +13 -0
  45. package/dist/component-check.d.ts.map +1 -0
  46. package/dist/component-check.js +59 -0
  47. package/dist/component-check.js.map +1 -0
  48. package/dist/contract-proof.d.ts +1 -1
  49. package/dist/contract-proof.d.ts.map +1 -1
  50. package/dist/contract-proof.js +2 -24
  51. package/dist/contract-proof.js.map +1 -1
  52. package/dist/contracts.d.ts +19 -0
  53. package/dist/contracts.d.ts.map +1 -1
  54. package/dist/contracts.js +50 -3
  55. package/dist/contracts.js.map +1 -1
  56. package/dist/db-tool.d.ts.map +1 -1
  57. package/dist/db-tool.js +4 -3
  58. package/dist/db-tool.js.map +1 -1
  59. package/dist/dev-bun-config.d.ts +5 -1
  60. package/dist/dev-bun-config.d.ts.map +1 -1
  61. package/dist/dev-bun-config.js +44 -11
  62. package/dist/dev-bun-config.js.map +1 -1
  63. package/dist/dev-feed-tool.d.ts +18 -1
  64. package/dist/dev-feed-tool.d.ts.map +1 -1
  65. package/dist/dev-feed-tool.js +93 -0
  66. package/dist/dev-feed-tool.js.map +1 -1
  67. package/dist/diagnostics.d.ts +3 -0
  68. package/dist/diagnostics.d.ts.map +1 -1
  69. package/dist/diagnostics.js.map +1 -1
  70. package/dist/diff-tool.d.ts +9 -8
  71. package/dist/diff-tool.d.ts.map +1 -1
  72. package/dist/diff-tool.js +28 -24
  73. package/dist/diff-tool.js.map +1 -1
  74. package/dist/doctor.d.ts +13 -0
  75. package/dist/doctor.d.ts.map +1 -1
  76. package/dist/doctor.js +23 -15
  77. package/dist/doctor.js.map +1 -1
  78. package/dist/env-file.d.ts +6 -0
  79. package/dist/env-file.d.ts.map +1 -1
  80. package/dist/env-file.js +6 -0
  81. package/dist/env-file.js.map +1 -1
  82. package/dist/fix-recipes.d.ts +2 -0
  83. package/dist/fix-recipes.d.ts.map +1 -1
  84. package/dist/fix-recipes.js +102 -2
  85. package/dist/fix-recipes.js.map +1 -1
  86. package/dist/hand-built-manifest.d.ts +27 -0
  87. package/dist/hand-built-manifest.d.ts.map +1 -0
  88. package/dist/hand-built-manifest.js +124 -0
  89. package/dist/hand-built-manifest.js.map +1 -0
  90. package/dist/hooks-report.d.ts +23 -0
  91. package/dist/hooks-report.d.ts.map +1 -0
  92. package/dist/hooks-report.js +104 -0
  93. package/dist/hooks-report.js.map +1 -0
  94. package/dist/init-agents.d.ts +18 -2
  95. package/dist/init-agents.d.ts.map +1 -1
  96. package/dist/init-agents.js +20 -13
  97. package/dist/init-agents.js.map +1 -1
  98. package/dist/internal/config-paths.d.ts +16 -0
  99. package/dist/internal/config-paths.d.ts.map +1 -0
  100. package/dist/internal/config-paths.js +124 -0
  101. package/dist/internal/config-paths.js.map +1 -0
  102. package/dist/internal/installed-package.d.ts +4 -0
  103. package/dist/internal/installed-package.d.ts.map +1 -0
  104. package/dist/internal/installed-package.js +17 -0
  105. package/dist/internal/installed-package.js.map +1 -0
  106. package/dist/internal/nifra-project.d.ts +41 -0
  107. package/dist/internal/nifra-project.d.ts.map +1 -0
  108. package/dist/internal/nifra-project.js +183 -0
  109. package/dist/internal/nifra-project.js.map +1 -0
  110. package/dist/internal/project-specifiers.d.ts +29 -0
  111. package/dist/internal/project-specifiers.d.ts.map +1 -0
  112. package/dist/internal/project-specifiers.js +435 -0
  113. package/dist/internal/project-specifiers.js.map +1 -0
  114. package/dist/internal/script-kind.d.ts.map +1 -1
  115. package/dist/internal/script-kind.js +2 -1
  116. package/dist/internal/script-kind.js.map +1 -1
  117. package/dist/internal/source-facts.d.ts +8 -1
  118. package/dist/internal/source-facts.d.ts.map +1 -1
  119. package/dist/internal/source-facts.js +22 -5
  120. package/dist/internal/source-facts.js.map +1 -1
  121. package/dist/internal/typescript-import.d.ts +1 -1
  122. package/dist/internal/typescript-import.d.ts.map +1 -1
  123. package/dist/internal/typescript-import.js +10 -6
  124. package/dist/internal/typescript-import.js.map +1 -1
  125. package/dist/introspect.d.ts +15 -6
  126. package/dist/introspect.d.ts.map +1 -1
  127. package/dist/introspect.js +87 -25
  128. package/dist/introspect.js.map +1 -1
  129. package/dist/levels-tool.d.ts.map +1 -1
  130. package/dist/levels-tool.js +98 -23
  131. package/dist/levels-tool.js.map +1 -1
  132. package/dist/load.d.ts +33 -10
  133. package/dist/load.d.ts.map +1 -1
  134. package/dist/load.js +60 -10
  135. package/dist/load.js.map +1 -1
  136. package/dist/mcp-context.d.ts +5 -5
  137. package/dist/mcp-context.d.ts.map +1 -1
  138. package/dist/mcp-context.js +8 -23
  139. package/dist/mcp-context.js.map +1 -1
  140. package/dist/mcp-delegate.d.ts +2 -2
  141. package/dist/mcp-delegate.d.ts.map +1 -1
  142. package/dist/mcp-delegate.js +19 -7
  143. package/dist/mcp-delegate.js.map +1 -1
  144. package/dist/mcp-exec.d.ts +13 -4
  145. package/dist/mcp-exec.d.ts.map +1 -1
  146. package/dist/mcp-exec.js +56 -14
  147. package/dist/mcp-exec.js.map +1 -1
  148. package/dist/mcp-http.js +1 -1
  149. package/dist/mcp-io.d.ts +30 -0
  150. package/dist/mcp-io.d.ts.map +1 -1
  151. package/dist/mcp-io.js +63 -0
  152. package/dist/mcp-io.js.map +1 -1
  153. package/dist/mcp-isolate.d.ts.map +1 -1
  154. package/dist/mcp-isolate.js +3 -2
  155. package/dist/mcp-isolate.js.map +1 -1
  156. package/dist/mcp-path.d.ts +2 -1
  157. package/dist/mcp-path.d.ts.map +1 -1
  158. package/dist/mcp-path.js +9 -24
  159. package/dist/mcp-path.js.map +1 -1
  160. package/dist/mcp-project-child.js +1 -1
  161. package/dist/mcp-project-child.js.map +1 -1
  162. package/dist/mcp-root.d.ts.map +1 -1
  163. package/dist/mcp-root.js +50 -10
  164. package/dist/mcp-root.js.map +1 -1
  165. package/dist/mcp-ws.d.ts.map +1 -1
  166. package/dist/mcp-ws.js +3 -1
  167. package/dist/mcp-ws.js.map +1 -1
  168. package/dist/mcp.d.ts.map +1 -1
  169. package/dist/mcp.js +48 -19
  170. package/dist/mcp.js.map +1 -1
  171. package/dist/migrate-layout.d.ts +47 -1
  172. package/dist/migrate-layout.d.ts.map +1 -1
  173. package/dist/migrate-layout.js +1972 -170
  174. package/dist/migrate-layout.js.map +1 -1
  175. package/dist/output-schemas.d.ts +128 -0
  176. package/dist/output-schemas.d.ts.map +1 -0
  177. package/dist/output-schemas.js +975 -0
  178. package/dist/output-schemas.js.map +1 -0
  179. package/dist/port.d.ts.map +1 -1
  180. package/dist/port.js +3 -2
  181. package/dist/port.js.map +1 -1
  182. package/dist/project-facts.d.ts +15 -4
  183. package/dist/project-facts.d.ts.map +1 -1
  184. package/dist/project-facts.js +1 -0
  185. package/dist/project-facts.js.map +1 -1
  186. package/dist/project-path.d.ts +6 -0
  187. package/dist/project-path.d.ts.map +1 -1
  188. package/dist/project-path.js +48 -14
  189. package/dist/project-path.js.map +1 -1
  190. package/dist/recipes/5.0.0.d.ts +11 -0
  191. package/dist/recipes/5.0.0.d.ts.map +1 -0
  192. package/dist/recipes/5.0.0.js +34 -0
  193. package/dist/recipes/5.0.0.js.map +1 -0
  194. package/dist/recipes/index.d.ts +7 -0
  195. package/dist/recipes/index.d.ts.map +1 -1
  196. package/dist/recipes/index.js +2 -0
  197. package/dist/recipes/index.js.map +1 -1
  198. package/dist/release-verification.d.ts +5 -0
  199. package/dist/release-verification.d.ts.map +1 -1
  200. package/dist/release-verification.js +14 -0
  201. package/dist/release-verification.js.map +1 -1
  202. package/dist/replay.d.ts.map +1 -1
  203. package/dist/replay.js +12 -3
  204. package/dist/replay.js.map +1 -1
  205. package/dist/review.d.ts +3 -1
  206. package/dist/review.d.ts.map +1 -1
  207. package/dist/review.js +43 -7
  208. package/dist/review.js.map +1 -1
  209. package/dist/rules/codes.d.ts +12 -0
  210. package/dist/rules/codes.d.ts.map +1 -1
  211. package/dist/rules/codes.js +12 -0
  212. package/dist/rules/codes.js.map +1 -1
  213. package/dist/rules/comment-markers.d.ts +16 -7
  214. package/dist/rules/comment-markers.d.ts.map +1 -1
  215. package/dist/rules/comment-markers.js +58 -19
  216. package/dist/rules/comment-markers.js.map +1 -1
  217. package/dist/rules/contract-visibility.d.ts +3 -0
  218. package/dist/rules/contract-visibility.d.ts.map +1 -0
  219. package/dist/rules/contract-visibility.js +337 -0
  220. package/dist/rules/contract-visibility.js.map +1 -0
  221. package/dist/rules/data-guard.d.ts +13 -0
  222. package/dist/rules/data-guard.d.ts.map +1 -1
  223. package/dist/rules/data-guard.js +356 -37
  224. package/dist/rules/data-guard.js.map +1 -1
  225. package/dist/rules/layout.d.ts +4 -0
  226. package/dist/rules/layout.d.ts.map +1 -0
  227. package/dist/rules/layout.js +19 -0
  228. package/dist/rules/layout.js.map +1 -0
  229. package/dist/rules/legacy.d.ts.map +1 -1
  230. package/dist/rules/legacy.js +178 -44
  231. package/dist/rules/legacy.js.map +1 -1
  232. package/dist/rules/pages.d.ts +50 -0
  233. package/dist/rules/pages.d.ts.map +1 -1
  234. package/dist/rules/pages.js +436 -4
  235. package/dist/rules/pages.js.map +1 -1
  236. package/dist/rules/routes.d.ts +6 -0
  237. package/dist/rules/routes.d.ts.map +1 -1
  238. package/dist/rules/routes.js +111 -16
  239. package/dist/rules/routes.js.map +1 -1
  240. package/dist/rules/secrets.js +2 -2
  241. package/dist/rules/secrets.js.map +1 -1
  242. package/dist/rules/security.d.ts +13 -1
  243. package/dist/rules/security.d.ts.map +1 -1
  244. package/dist/rules/security.js +313 -69
  245. package/dist/rules/security.js.map +1 -1
  246. package/dist/rules/zones.d.ts +13 -0
  247. package/dist/rules/zones.d.ts.map +1 -1
  248. package/dist/rules/zones.js +218 -44
  249. package/dist/rules/zones.js.map +1 -1
  250. package/dist/scaffold.d.ts.map +1 -1
  251. package/dist/scaffold.js +6 -4
  252. package/dist/scaffold.js.map +1 -1
  253. package/dist/sdk.d.ts.map +1 -1
  254. package/dist/sdk.js +14 -5
  255. package/dist/sdk.js.map +1 -1
  256. package/dist/stylex-migrate.d.ts.map +1 -1
  257. package/dist/stylex-migrate.js +290 -24
  258. package/dist/stylex-migrate.js.map +1 -1
  259. package/dist/test-tool.d.ts.map +1 -1
  260. package/dist/test-tool.js +6 -3
  261. package/dist/test-tool.js.map +1 -1
  262. package/dist/typecheck-coverage.d.ts +20 -0
  263. package/dist/typecheck-coverage.d.ts.map +1 -0
  264. package/dist/typecheck-coverage.js +129 -0
  265. package/dist/typecheck-coverage.js.map +1 -0
  266. package/dist/upgrade.d.ts +84 -0
  267. package/dist/upgrade.d.ts.map +1 -1
  268. package/dist/upgrade.js +498 -78
  269. package/dist/upgrade.js.map +1 -1
  270. package/dist/verification-plan.d.ts.map +1 -1
  271. package/dist/verification-plan.js +16 -2
  272. package/dist/verification-plan.js.map +1 -1
  273. package/dist/version-drift.d.ts +22 -0
  274. package/dist/version-drift.d.ts.map +1 -0
  275. package/dist/version-drift.js +94 -0
  276. package/dist/version-drift.js.map +1 -0
  277. package/dist/web-app-options.d.ts +1 -1
  278. package/dist/web-app-options.d.ts.map +1 -1
  279. package/dist/web-app-options.js +1 -1
  280. package/dist/web-app-options.js.map +1 -1
  281. package/dist/work-graph.d.ts.map +1 -1
  282. package/dist/work-graph.js +20 -8
  283. package/dist/work-graph.js.map +1 -1
  284. package/docs/examples.json +55 -7
  285. package/docs/llms-full.txt +2043 -222
  286. package/docs/types.json +1573 -246
  287. package/package.json +11 -12
  288. package/src/agent-files.ts +203 -0
  289. package/src/app-files.ts +34 -4
  290. package/src/assure-hydration.ts +18 -2
  291. package/src/assure.ts +88 -10
  292. package/src/capabilities-tool.ts +47 -9
  293. package/src/check-diagnostics.ts +75 -9
  294. package/src/check-scan.ts +1816 -377
  295. package/src/check.ts +258 -81
  296. package/src/cli.ts +242 -37
  297. package/src/client-module.ts +97 -0
  298. package/src/command-catalog.ts +237 -52
  299. package/src/component-check.ts +79 -0
  300. package/src/contract-proof.ts +3 -25
  301. package/src/contracts.ts +60 -3
  302. package/src/db-tool.ts +5 -2
  303. package/src/dev-bun-config.ts +51 -10
  304. package/src/dev-feed-tool.ts +112 -1
  305. package/src/diagnostics.ts +3 -0
  306. package/src/diff-tool.ts +33 -26
  307. package/src/doctor.ts +43 -21
  308. package/src/env-file.ts +7 -0
  309. package/src/fix-recipes.ts +116 -2
  310. package/src/hand-built-manifest.ts +144 -0
  311. package/src/hooks-report.ts +135 -0
  312. package/src/init-agents.ts +21 -14
  313. package/src/internal/config-paths.ts +154 -0
  314. package/src/internal/installed-package.ts +15 -0
  315. package/src/internal/nifra-project.ts +191 -0
  316. package/src/internal/project-specifiers.ts +477 -0
  317. package/src/internal/script-kind.ts +2 -1
  318. package/src/internal/source-facts.ts +48 -5
  319. package/src/internal/typescript-import.ts +15 -6
  320. package/src/introspect.ts +104 -28
  321. package/src/levels-tool.ts +124 -29
  322. package/src/load.ts +107 -17
  323. package/src/mcp-context.ts +12 -25
  324. package/src/mcp-delegate.ts +22 -10
  325. package/src/mcp-exec.ts +68 -18
  326. package/src/mcp-http.ts +1 -1
  327. package/src/mcp-io.ts +74 -0
  328. package/src/mcp-isolate.ts +9 -2
  329. package/src/mcp-path.ts +8 -22
  330. package/src/mcp-project-child.ts +1 -1
  331. package/src/mcp-root.ts +58 -8
  332. package/src/mcp-ws.ts +4 -2
  333. package/src/mcp.ts +56 -22
  334. package/src/migrate-layout.ts +2257 -176
  335. package/src/output-schemas.ts +1240 -0
  336. package/src/port.ts +3 -2
  337. package/src/project-facts.ts +17 -3
  338. package/src/project-path.ts +44 -13
  339. package/src/recipes/5.0.0.ts +35 -0
  340. package/src/recipes/index.ts +10 -0
  341. package/src/release-verification.ts +20 -0
  342. package/src/replay.ts +14 -3
  343. package/src/review.ts +44 -10
  344. package/src/rules/codes.ts +12 -0
  345. package/src/rules/comment-markers.ts +57 -21
  346. package/src/rules/contract-visibility.ts +383 -0
  347. package/src/rules/data-guard.ts +391 -42
  348. package/src/rules/layout.ts +22 -0
  349. package/src/rules/legacy.ts +206 -52
  350. package/src/rules/pages.ts +481 -4
  351. package/src/rules/routes.ts +124 -16
  352. package/src/rules/secrets.ts +2 -2
  353. package/src/rules/security.ts +391 -72
  354. package/src/rules/zones.ts +232 -42
  355. package/src/scaffold.ts +6 -4
  356. package/src/sdk.ts +14 -5
  357. package/src/stylex-migrate.ts +251 -24
  358. package/src/test-tool.ts +12 -3
  359. package/src/typecheck-coverage.ts +134 -0
  360. package/src/upgrade.ts +576 -72
  361. package/src/verification-plan.ts +33 -2
  362. package/src/version-drift.ts +118 -0
  363. package/src/web-app-options.ts +2 -2
  364. package/src/work-graph.ts +17 -12
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nifrajs/cli",
3
- "version": "4.0.2",
3
+ "version": "5.1.0",
4
4
  "description": "The nifra CLI - zero-config dev/build/start for a nifra app (`nifra dev`, `nifra build`, `nifra start`).",
5
5
  "keywords": [
6
6
  "bun",
@@ -69,17 +69,16 @@
69
69
  "access": "public"
70
70
  },
71
71
  "dependencies": {
72
- "@nifrajs/client": "4.0.2",
73
- "@nifrajs/core": "4.0.2",
74
- "@nifrajs/agent-review": "4.0.2",
75
- "@nifrajs/i18n": "4.0.2",
76
- "@nifrajs/mcp": "4.0.2",
77
- "@nifrajs/mcp-db": "4.0.2",
78
- "@nifrajs/runner": "4.0.2",
79
- "@nifrajs/schema": "4.0.2",
80
- "@nifrajs/testing": "4.0.2",
81
- "@nifrajs/web": "4.0.2",
82
- "create-nifra": "4.0.2"
72
+ "@nifrajs/client": "5.1.0",
73
+ "@nifrajs/core": "5.1.0",
74
+ "@nifrajs/agent-review": "5.1.0",
75
+ "@nifrajs/i18n": "5.1.0",
76
+ "@nifrajs/mcp": "5.1.0",
77
+ "@nifrajs/mcp-db": "5.1.0",
78
+ "@nifrajs/runner": "5.1.0",
79
+ "@nifrajs/schema": "5.1.0",
80
+ "@nifrajs/testing": "5.1.0",
81
+ "@nifrajs/web": "5.1.0"
83
82
  },
84
83
  "repository": {
85
84
  "type": "git",
@@ -0,0 +1,203 @@
1
+ /**
2
+ * The canonical generators for the agent files an app ships. `AGENTS.md` is the single source of agent
3
+ * guidance; every other agent's file is a thin pointer to it, so no two copies can drift:
4
+ *
5
+ * AGENTS.md - the guidance (read natively by Codex, Cursor, Copilot and others)
6
+ * CLAUDE.md - Claude Code: `@AGENTS.md` import
7
+ * GEMINI.md - Gemini CLI: `@./AGENTS.md` import
8
+ * .cursor/rules/nifra.mdc - Cursor: an always-applied rule pointing at AGENTS.md
9
+ * .github/copilot-instructions.md - GitHub Copilot: points at AGENTS.md
10
+ * .mcp.json, .cursor/mcp.json - the nifra MCP server, registered for Claude Code and Cursor
11
+ *
12
+ * `create-nifra` (scaffold time) and `@nifrajs/cli`'s `nifra init-agents` (retrofit an existing app) each
13
+ * ship this file byte-identical - `packages/cli/test/init-agents.test.ts` holds the two copies equal - so
14
+ * the generated files can never drift apart and neither package needs the other at runtime. It stays
15
+ * dependency-free (pure string + object generation) on purpose: `create-nifra` ships with no runtime deps.
16
+ *
17
+ * Why `bunx @nifrajs/cli mcp` (not `bunx nifra mcp`): the `nifra` binary is provided by the
18
+ * `@nifrajs/cli` package, and the bare npm package literally named `nifra` (this monorepo's
19
+ * `@nifrajs/web` shim) exposes NO `nifra` bin, so `bunx nifra mcp` would fetch the wrong package in an
20
+ * app that does not carry `@nifrajs/cli`. Naming the bin-owning package resolves the locally-installed
21
+ * bin when present and otherwise fetches the package that actually provides the `nifra` command.
22
+ */
23
+
24
+ import { readFileSync } from "node:fs"
25
+
26
+ /** The MCP launch command, shared by `.mcp.json` and `.cursor/mcp.json`. See the module header for why
27
+ * the package is named explicitly rather than relying on the bare `nifra` bin. */
28
+ export const MCP_SERVER_COMMAND = "bunx" as const
29
+
30
+ /**
31
+ * The `@nifrajs/cli` version the launch command pins to - DERIVED at load time from this package's own
32
+ * `version`, never hardcoded. `fixed` changeset versioning ([["@nifrajs/*", "create-nifra", "nifra"]] in
33
+ * `.changeset/config.json`) bumps `create-nifra` and `@nifrajs/cli` in lockstep, so `create-nifra@x`
34
+ * always means `@nifrajs/cli@x`. Deriving it here means a release bump has nothing to forget to update -
35
+ * there is no stale literal to drift (the 1.0.0 cut shipped a beta pin exactly this way). `check:publish`
36
+ * still asserts it equals `@nifrajs/cli`'s version as a belt-and-suspenders guard on the `fixed` link.
37
+ *
38
+ * Resolves correctly under BOTH export conditions: the `bun` export runs `src/agent-files.ts`
39
+ * (`../package.json` → the package root) and the tsc `default` export runs `dist/agent-files.js`
40
+ * (`../package.json` → the same root, since `dist/` sits beside the manifest). npm always ships a
41
+ * package's own `package.json`, so the read can't miss at install time.
42
+ *
43
+ * Why pin at all instead of `bunx @nifrajs/cli mcp`: `bunx` keys its cache on the exact version spec.
44
+ * An UNPINNED spec resolves to the `latest` tag once, then `bunx` reuses that cached copy on every
45
+ * later spawn WITHOUT re-checking the registry - so an editor that once launched an older `@nifrajs/cli`
46
+ * keeps respawning the stale binary even after a newer one is published (the MCP server silently runs
47
+ * old code). Pinning the exact version makes the version part of the cache key, so each release fetches
48
+ * fresh and a stale cache can never shadow it. The cost: an already-scaffolded app's `.mcp.json` freezes
49
+ * at its scaffold-time version until `nifra init-agents --sync-mcp` re-pins it to the nifra the project
50
+ * installs - an acceptable, deterministic trade.
51
+ */
52
+ export const MCP_CLI_VERSION: string = ownVersion()
53
+
54
+ function ownVersion(): string {
55
+ const manifest: unknown = JSON.parse(
56
+ readFileSync(new URL("../package.json", import.meta.url), "utf8"),
57
+ )
58
+ if (
59
+ typeof manifest === "object" &&
60
+ manifest !== null &&
61
+ "version" in manifest &&
62
+ typeof manifest.version === "string"
63
+ )
64
+ return manifest.version
65
+ throw new Error("[nifra] agent-files: this package's package.json has no version")
66
+ }
67
+ export const MCP_SERVER_ARGS = [`@nifrajs/cli@${MCP_CLI_VERSION}`, "mcp"] as const
68
+
69
+ /** The server entry registered under the `nifra` key in both Claude Code's and Cursor's MCP config. */
70
+ export interface McpServerConfig {
71
+ readonly command: string
72
+ readonly args: readonly string[]
73
+ }
74
+
75
+ /** Claude Code / Cursor MCP config shape: a map of server name → launch config. */
76
+ export interface McpConfig {
77
+ readonly mcpServers: Readonly<Record<string, McpServerConfig>>
78
+ }
79
+
80
+ /** The one canonical MCP config object both registries serialize - the anti-drift seam. */
81
+ export const MCP_CONFIG: McpConfig = {
82
+ mcpServers: {
83
+ nifra: { command: MCP_SERVER_COMMAND, args: [...MCP_SERVER_ARGS] },
84
+ },
85
+ }
86
+
87
+ /** Serialize the canonical MCP config as the JSON written to `.mcp.json` and `.cursor/mcp.json`.
88
+ * Trailing newline so the file is POSIX-clean and diffs don't flag a missing EOL. */
89
+ export function mcpJson(): string {
90
+ return `${JSON.stringify(MCP_CONFIG, null, 2)}\n`
91
+ }
92
+
93
+ const POINTER_INTRO =
94
+ "This project's agent guidance - commands, the project structure, the rules, and the nifra MCP server - lives in AGENTS.md, the single source every agent reads."
95
+
96
+ /** `CLAUDE.md`: Claude Code expands the `@AGENTS.md` import in place. */
97
+ export function claudeMd(): string {
98
+ return `# CLAUDE.md\n\n${POINTER_INTRO}\n\n@AGENTS.md\n`
99
+ }
100
+
101
+ /** `GEMINI.md`: Gemini CLI expands `@./AGENTS.md` imports in its context files. */
102
+ export function geminiMd(): string {
103
+ return `# GEMINI.md\n\n${POINTER_INTRO}\n\n@./AGENTS.md\n`
104
+ }
105
+
106
+ /** `.cursor/rules/nifra.mdc`: an always-applied Cursor rule; `@AGENTS.md` attaches the file. */
107
+ export function cursorRule(): string {
108
+ return `---\ndescription: nifra project guidance\nalwaysApply: true\n---\n\n${POINTER_INTRO} Read it before changing code:\n\n@AGENTS.md\n`
109
+ }
110
+
111
+ /** `.github/copilot-instructions.md`: Copilot has no import syntax, so this names the file. */
112
+ export function copilotInstructions(): string {
113
+ return `${POINTER_INTRO} Read AGENTS.md at the repository root before changing code, and follow it.\n`
114
+ }
115
+
116
+ /**
117
+ * The "## MCP server" section appended to a scaffolded (or retrofitted) `AGENTS.md`, so non-Claude
118
+ * agents (Cursor, and anything that reads `AGENTS.md`) also learn the MCP exists and what to prefer.
119
+ * Mirrors the CLAUDE.md preamble's guidance without the Claude-specific `@import`.
120
+ */
121
+ export function agentsMcpSection(): string {
122
+ return `## MCP server
123
+
124
+ This project ships a **nifra MCP server** - launch it with \`${MCP_SERVER_COMMAND} ${MCP_SERVER_ARGS.join(" ")}\`. It's registered for
125
+ Claude Code in \`.mcp.json\` and for Cursor in \`.cursor/mcp.json\`; other agents can point their MCP client
126
+ at that command. **Prefer its tools** over writing nifra from memory - they're typechecked against this
127
+ project + the installed version, so they beat training-data recall:
128
+
129
+ - \`nifra_docs\` / \`nifra_example\` - exact signatures + verified, compiling snippets.
130
+ - \`nifra_context\` / \`nifra_routes\` - this project's route index + per-route schemas.
131
+ - \`nifra_types\` - the EXACT TypeScript of any \`@nifrajs/*\` symbol (interface/type/function). Call it for a
132
+ precise type; **never read \`@nifrajs\` \`.d.ts\` files** - this is the authoritative, complete source.
133
+ - \`nifra_check\` - the done-gate (typecheck + drift lints). Run it before calling work complete; a
134
+ failing check means it isn't done. (\`nifra check --json\` in a terminal does the same.)`
135
+ }
136
+
137
+ /**
138
+ * The "## Project structure" section of a web app's `AGENTS.md`: the zones the build enforces. Shared so
139
+ * a scaffolded app and one `nifra init-agents` retrofits teach the same rules.
140
+ */
141
+ export function agentsStructureSection(): string {
142
+ return `${STRUCTURE_HEADING}
143
+
144
+ Every file a build loads belongs to one side, and the build refuses an import that crosses the wrong
145
+ way - it fails naming the import chain rather than shipping server code to the browser.
146
+
147
+ | Where | Holds | Reaches the browser |
148
+ |---|---|---|
149
+ | \`routes/x.tsx\` (\`.svelte\`, \`.vue\`, \`.mdx\`) | a page: the component, \`meta\` | yes |
150
+ | \`routes/x.backend.ts\` | that page's \`loader\`, \`action\`, \`loaderOutput\`, \`actionOutput\`, \`middleware\` | never |
151
+ | \`frontend/\` | components, hooks, browser-only code | yes |
152
+ | \`backend/\` | \`app.ts\` (the API), \`framework.ts\`, the database, auth, secrets | never |
153
+ | \`shared/\` | schemas, types and pure helpers both sides import | yes |
154
+ | \`public/\` | static files served as they are | yes |
155
+
156
+ A file outside those folders joins a side with a \`.frontend.ts\`, \`.backend.ts\` or \`.shared.ts\` suffix.
157
+
158
+ - **A route is two files.** \`routes/blog/[slug].tsx\` renders; \`routes/blog/[slug].backend.ts\` loads.
159
+ A server-only export (\`loader\`, \`action\`, ...) in the \`.tsx\` file is a build error.
160
+ - **Frontend code never imports backend code** - not \`backend/\`, not a \`.backend.ts\` file, not a
161
+ database driver or \`node:\` built-in. Data reaches a page only as its loader's return value.
162
+ - **Every loader and action declares what it sends**: \`export const loaderOutput = t.object({ ... })\`
163
+ (and \`actionOutput\`). Only what the schema declares reaches the browser, and a loader without one
164
+ is refused.
165
+ - **Type a route with its generated \`./+types/<name>\`**: \`import type { Route } from "./+types/[slug]"\`
166
+ gives \`Route.Loader\` and \`Route.Action\` in the backend half (\`export const loader: Route.Loader =
167
+ async ({ api, params }) => ...\` checks what it returns against \`loaderOutput\`) and
168
+ \`Route.ComponentProps\` in the page. \`nifra dev\`, \`nifra build\`, \`nifra check\` and
169
+ \`nifra types\` write them.
170
+ - **Secrets stay in \`backend/\`.** A value only the server may see is read there (\`process.env\`), never
171
+ in a frontend file; a build that finds what looks like a credential in browser output fails.`
172
+ }
173
+
174
+ /** The heading `nifra init-agents` looks for before appending {@link agentsStructureSection}. */
175
+ export const STRUCTURE_HEADING = "## Project structure"
176
+
177
+ /** Identifies a generated agent-discovery file: where it goes (relative to the project root) and how to
178
+ * produce its content. `merge` is for files that augment an existing one (AGENTS.md) rather than own it. */
179
+ export interface AgentFileSpec {
180
+ /** Path relative to the project root (POSIX-style; the caller joins onto the cwd). */
181
+ readonly path: string
182
+ /** A human label for the "wrote/skipped" report. */
183
+ readonly label: string
184
+ }
185
+
186
+ /** The standalone files this module fully owns (whole-file generators). AGENTS.md is handled separately
187
+ * because create-nifra builds it from `agents.ts` and the retrofit command appends sections to it. */
188
+ export const MCP_JSON_PATH = ".mcp.json"
189
+ export const CURSOR_MCP_JSON_PATH = ".cursor/mcp.json"
190
+ export const CLAUDE_MD_PATH = "CLAUDE.md"
191
+ export const GEMINI_MD_PATH = "GEMINI.md"
192
+ export const CURSOR_RULE_PATH = ".cursor/rules/nifra.mdc"
193
+ export const COPILOT_INSTRUCTIONS_PATH = ".github/copilot-instructions.md"
194
+ export const AGENTS_MD_PATH = "AGENTS.md"
195
+
196
+ /** Every agent's pointer to `AGENTS.md`, in the order they are written and reported. */
197
+ export const AGENT_POINTERS: readonly { readonly path: string; readonly content: () => string }[] =
198
+ [
199
+ { path: CLAUDE_MD_PATH, content: claudeMd },
200
+ { path: GEMINI_MD_PATH, content: geminiMd },
201
+ { path: CURSOR_RULE_PATH, content: cursorRule },
202
+ { path: COPILOT_INSTRUCTIONS_PATH, content: copilotInstructions },
203
+ ]
package/src/app-files.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * kept free of build tooling because it is bundled into every server build.
7
7
  * - `backend/app.ts` - exports `backend`, the app's API (a `@nifrajs/core` server). Optional.
8
8
  */
9
- import { existsSync } from "node:fs"
9
+ import { existsSync, readFileSync } from "node:fs"
10
10
  import { resolve } from "node:path"
11
11
 
12
12
  export const CONFIG_FILE = "nifra.config.ts"
@@ -19,11 +19,41 @@ export const RETIRED_ROOT_FILES: Readonly<Record<string, string>> = {
19
19
  "framework.ts": FRAMEWORK_FILE,
20
20
  }
21
21
 
22
+ /** Outside a routed app, what made each retired root file the app's: else it is just a module. */
23
+ const RETIRED_EXPORTS: Readonly<Record<string, RegExp>> = {
24
+ "backend.ts":
25
+ /\bexport\s+(?:const|let|var|(?:async\s+)?function|class)\s+backend\b|\bexport\s*\{[^}]*\bbackend\b/,
26
+ "framework.ts":
27
+ /\bexport\s+(?:const|let|var|(?:async\s+)?function|class)\s+adapter\b|\bexport\s*\{[^}]*\badapter\b/,
28
+ }
29
+
30
+ function exportsRetiredName(path: string, pattern: RegExp | undefined): boolean {
31
+ if (!existsSync(path)) return false
32
+ try {
33
+ return pattern === undefined || pattern.test(readFileSync(path, "utf8"))
34
+ } catch {
35
+ // Unreadable: the commands that would load it fail too, so report it rather than skip it.
36
+ return true
37
+ }
38
+ }
39
+
40
+ /**
41
+ * The retired root files present in `cwd`, each with where it lives now. The old loader imported them
42
+ * whatever they exported, so in an app with `routes/` or a config each one counts; elsewhere only one
43
+ * that exports what the loader read does, and a library's `backend.ts` module does not.
44
+ */
45
+ export function retiredRootFiles(cwd: string): ReadonlyArray<readonly [string, string]> {
46
+ const routed = ["routes", CONFIG_FILE, FRAMEWORK_FILE].some((entry) =>
47
+ existsSync(resolve(cwd, entry)),
48
+ )
49
+ return Object.entries(RETIRED_ROOT_FILES).filter(([file]) =>
50
+ exportsRetiredName(resolve(cwd, file), routed ? undefined : RETIRED_EXPORTS[file]),
51
+ )
52
+ }
53
+
22
54
  /** Refuse an app still on the retired root files: run against it, a command would silently miss them. */
23
55
  export function assertCurrentLayout(cwd: string): void {
24
- const retired = Object.entries(RETIRED_ROOT_FILES).filter(([file]) =>
25
- existsSync(resolve(cwd, file)),
26
- )
56
+ const retired = retiredRootFiles(cwd)
27
57
  if (retired.length === 0) return
28
58
  const moves = retired.map(([from, to]) => `${from} -> ${to}`).join(", ")
29
59
  throw new Error(
@@ -11,7 +11,16 @@ import { discoverRoutes } from "@nifrajs/web/fs"
11
11
  import type { BunPlugin } from "bun"
12
12
  import { collectDoctorResult } from "./doctor.ts"
13
13
  import { loadApp, resolvePlugins } from "./load.ts"
14
- import { CHILD_OUTPUT_MAX_BYTES, joinHeadTail, readBoundedStream, readHeadTail } from "./mcp-io.ts"
14
+ import {
15
+ CHILD_OUTPUT_MAX_BYTES,
16
+ escalatingStop,
17
+ forwardTerminalSignals,
18
+ joinHeadTail,
19
+ OWN_PROCESS_GROUP,
20
+ readBoundedStream,
21
+ readHeadTail,
22
+ signalProcessGroup,
23
+ } from "./mcp-io.ts"
15
24
  import { frameworkWebAppOptions } from "./web-app-options.ts"
16
25
 
17
26
  const HYDRATION_ASSURANCE = Symbol.for("nifra.hydration.assurance")
@@ -634,15 +643,20 @@ export async function runHydrationAssurance(
634
643
  stdin: "pipe",
635
644
  stdout: "pipe",
636
645
  stderr: "pipe",
646
+ // Its own group, so a stop reaches whatever the project's code started too; a process left
647
+ // holding the pipes would otherwise keep this run waiting past its timeout.
648
+ detached: OWN_PROCESS_GROUP,
637
649
  },
638
650
  )
651
+ const stopper = escalatingStop((name) => signalProcessGroup(proc, name))
639
652
  let stopped: string | undefined
640
653
  const stop = (reason: string): void => {
641
654
  stopped ??= reason
642
- proc.kill()
655
+ stopper.stop()
643
656
  }
644
657
  const onAbort = (): void => stop("cancelled")
645
658
  control.signal?.addEventListener("abort", onAbort, { once: true })
659
+ const releaseSignals = forwardTerminalSignals(() => stop("interrupted"))
646
660
  const timeoutMs = control.timeoutMs ?? HYDRATION_RUN_TIMEOUT_MS
647
661
  const timer = setTimeout(() => stop(`timed out after ${timeoutMs} ms`), timeoutMs)
648
662
  try {
@@ -672,7 +686,9 @@ export async function runHydrationAssurance(
672
686
  return failed(joinHeadTail(stderr).trim() || "no result")
673
687
  } finally {
674
688
  clearTimeout(timer)
689
+ stopper.dispose()
675
690
  control.signal?.removeEventListener("abort", onAbort)
691
+ releaseSignals()
676
692
  }
677
693
  }
678
694
 
package/src/assure.ts CHANGED
@@ -3,9 +3,10 @@
3
3
  import { existsSync } from "node:fs"
4
4
  import { join, resolve } from "node:path"
5
5
  import { pathToFileURL } from "node:url"
6
- import type { AssuranceConfig, AssuranceReport } from "@nifrajs/core/assurance"
6
+ import type { AssuranceConfig, AssuranceReport, AssuredRoute } from "@nifrajs/core/assurance"
7
7
  import { CONFIG_FILE, FRAMEWORK_FILE } from "./app-files.ts"
8
8
  import type { Diagnostic } from "./diagnostics.ts"
9
+ import type { ProjectVerification } from "./verification.ts"
9
10
 
10
11
  export interface AssureSink {
11
12
  record(bundle: AssureBundle): Promise<void>
@@ -14,6 +15,7 @@ export interface AssureSink {
14
15
  export interface GateResult {
15
16
  readonly gate:
16
17
  | "check"
18
+ | "assurance"
17
19
  | "doctor"
18
20
  | "render"
19
21
  | "hydration"
@@ -91,6 +93,45 @@ export async function loadAssuranceConfig(
91
93
  return loaded.default
92
94
  }
93
95
 
96
+ /** Why declared response/error schemas go unenforced, phrased for the server that has to change. */
97
+ export interface ResponseContractGaps {
98
+ readonly typedOnly: readonly string[]
99
+ readonly warnOnly: readonly string[]
100
+ readonly typedOnlyReason: string | undefined
101
+ readonly warnOnlyReason: string | undefined
102
+ }
103
+
104
+ const sample = (routes: readonly string[]): string =>
105
+ routes.length <= 3
106
+ ? routes.join(", ")
107
+ : `${routes.slice(0, 3).join(", ")} and ${routes.length - 3} more`
108
+
109
+ export function responseContractGaps(routes: readonly AssuredRoute[]): ResponseContractGaps {
110
+ const key = (route: AssuredRoute): string => `${route.method} ${route.path}`
111
+ const typedOnly = routes.filter((route) => route.responseContract === "unchecked").map(key)
112
+ const warnOnly = routes.filter((route) => route.responseContract === "warn").map(key)
113
+ const covered = routes.filter(
114
+ (route) => route.responseContract === "enforce" || route.responseContract === "warn",
115
+ ).length
116
+ // A plugin that covers some routes is installed - just not where these were registered, which is
117
+ // the case "install responseContract" advice used to send people in circles over.
118
+ const typedOnlyReason =
119
+ typedOnly.length === 0
120
+ ? undefined
121
+ : covered > 0
122
+ ? `${typedOnly.length} declared route(s) are typed-only (${sample(typedOnly)}) although responseContract() covers ${covered} other(s): it covers routes registered after it on the same server, and routes merge()d into that server after it. Install responseContract("enforce") on the server that registers these, before them.`
123
+ : `${typedOnly.length} declared route(s) are typed-only (${sample(typedOnly)}); install responseContract("enforce") on the server that registers them, before them - a parent's also covers servers merge()d into it afterwards.`
124
+ const warnOnlyReason =
125
+ warnOnly.length === 0
126
+ ? undefined
127
+ : `${warnOnly.length} declared route(s) are warn-only and still send the original payload (${sample(warnOnly)}); switch responseContract("warn") to "enforce".`
128
+ return { typedOnly, warnOnly, typedOnlyReason, warnOnlyReason }
129
+ }
130
+
131
+ /** The policy line that turns an unenforced contract into a route-assurance failure. */
132
+ export const RESPONSE_CONTRACT_BASELINE_HINT =
133
+ 'to fail route assurance on these, add baseline: [{ name: "response-contract", match: { hasResponse: true }, require: ["nifra.response-contract"] }] to the policy'
134
+
94
135
  export function formatAssuranceReport(report: AssuranceReport): string {
95
136
  if (report.ok) {
96
137
  const contracted = report.routes.filter((route) => route.responseContract !== undefined)
@@ -247,20 +288,57 @@ export async function collectAssureBundle(
247
288
  }
248
289
  }
249
290
 
291
+ // One verification serves both gates that read the app, so the config is evaluated once.
292
+ let verification: Promise<ProjectVerification> | undefined
293
+ const verify = (): Promise<ProjectVerification> =>
294
+ (verification ??= import("./verification.ts").then(({ collectProjectVerification }) =>
295
+ collectProjectVerification(cwd, {
296
+ ...(options.config !== undefined ? { config: options.config } : {}),
297
+ preloaded:
298
+ assuranceConfig !== undefined
299
+ ? { config: assuranceConfig }
300
+ : { error: assuranceConfigError },
301
+ }),
302
+ ))
303
+
250
304
  await addGate("check", async () => {
251
- const { collectProjectVerification } = await import("./verification.ts")
252
- const verification = await collectProjectVerification(cwd, {
253
- ...(options.config !== undefined ? { config: options.config } : {}),
254
- preloaded:
255
- assuranceConfig !== undefined
256
- ? { config: assuranceConfig }
257
- : { error: assuranceConfigError },
258
- })
259
- const result = await verification.check()
305
+ const result = await (await verify()).check()
260
306
  const diagnostics = (result.structuredDiagnostics ?? []).map((item) => item)
261
307
  return { diagnostics }
262
308
  })
263
309
 
310
+ // Route assurance is the gate the bundle exists to carry: a failing policy fails it, and a declared
311
+ // response the runtime does not enforce is a warning, so `--strict` refuses to call it green.
312
+ await addGate("assurance", async () => {
313
+ const report = assuranceConfig === undefined ? undefined : (await verify()).routeAssurance
314
+ if (report === undefined)
315
+ return {
316
+ diagnostics: [],
317
+ skipReason: existsSync(resolve(cwd, options.config ?? DEFAULT_ASSURANCE_CONFIG))
318
+ ? "route assurance could not be evaluated (reported by the check gate)"
319
+ : "no route assurance config",
320
+ }
321
+ const diagnostics = report.findings.map((finding) =>
322
+ diagnosticFromMessage("NF-A001", finding.message, "error"),
323
+ )
324
+ const gaps = responseContractGaps(report.routes)
325
+ for (const [reason, routes] of [
326
+ [gaps.typedOnlyReason, gaps.typedOnly],
327
+ [gaps.warnOnlyReason, gaps.warnOnly],
328
+ ] as const) {
329
+ if (reason === undefined) continue
330
+ diagnostics.push(
331
+ Object.freeze({
332
+ code: "NF-A001",
333
+ severity: "warn" as const,
334
+ message: `${reason} (${RESPONSE_CONTRACT_BASELINE_HINT})`,
335
+ evidence: Object.freeze(routes.slice(0, 20)),
336
+ }),
337
+ )
338
+ }
339
+ return { diagnostics }
340
+ })
341
+
264
342
  await addGate("doctor", async () => {
265
343
  const { collectDoctorResult } = await import("./doctor.ts")
266
344
  const result = await collectDoctorResult(cwd)
@@ -18,7 +18,13 @@ import {
18
18
  } from "@nifrajs/core/capabilities"
19
19
  import { expandOptionalParams } from "@nifrajs/core/pattern"
20
20
  import { type ReflectedMount, type ReflectedRoute, reflectRoutes } from "@nifrajs/core/reflection"
21
- import { scanStaticRouteText, stripComments, walkSource } from "./check.ts"
21
+ import {
22
+ scanSkippedRouteText,
23
+ scanStaticRouteText,
24
+ scanStaticSocketText,
25
+ stripComments,
26
+ walkSource,
27
+ } from "./check.ts"
22
28
 
23
29
  // Import and re-export clauses are spelled out (a default binding, then `* as ns` or a `{ … }` list)
24
30
  // rather than scanned lazily, which is quadratic on a long semicolon-free module.
@@ -200,7 +206,10 @@ function resolveLocalModule(
200
206
  const fromAbsolute = join(cwd, from)
201
207
  const resolved = Bun.resolveSync(specifier, dirname(fromAbsolute))
202
208
  const rel = relative(cwd, resolved).replaceAll("\\", "/")
203
- if (rel.startsWith("../") || isAbsolute(rel)) return undefined
209
+ // Outside the app is still the app's code: a workspace package resolves through its node_modules
210
+ // link to its source beside the app (`../../packages/db`), and an installed package may be hoisted
211
+ // above it. Both are walked, as a package in the app's own node_modules is.
212
+ if (isAbsolute(rel)) return undefined
204
213
  if (!sources.has(rel) && existsSync(resolved)) {
205
214
  try {
206
215
  sources.set(rel, readFileSync(resolved, "utf8"))
@@ -337,8 +346,12 @@ export async function collectCapabilityProjectReport(
337
346
  const policy = defineCapabilityPolicy(policyInput)
338
347
  const sources = await readSources(cwd)
339
348
  const automatic = new Map<string, Set<string>>()
349
+ const skipped = new Map<string, string>()
340
350
  for (const [file, content] of sources) {
341
- for (const route of scanStaticRouteText(file, content)) {
351
+ for (const route of [
352
+ ...scanStaticRouteText(file, content, undefined, sources),
353
+ ...scanStaticSocketText(file, content, undefined, sources),
354
+ ]) {
342
355
  // A path ending in optional params is reflected as one route per form it serves.
343
356
  for (const path of staticRouteForms(route.path)) {
344
357
  const modules = automatic.get(routeKey(route.method, path)) ?? new Set<string>()
@@ -346,6 +359,12 @@ export async function collectCapabilityProjectReport(
346
359
  automatic.set(routeKey(route.method, path), modules)
347
360
  }
348
361
  }
362
+ for (const route of scanSkippedRouteText(file, content, sources)) {
363
+ for (const path of staticRouteForms(route.path)) {
364
+ if (!skipped.has(routeKey(route.method, path)))
365
+ skipped.set(routeKey(route.method, path), `${file}:${route.line}`)
366
+ }
367
+ }
349
368
  }
350
369
 
351
370
  const evidenceRoutes = []
@@ -374,12 +393,25 @@ export async function collectCapabilityProjectReport(
374
393
  policy,
375
394
  sources,
376
395
  )
377
- evidenceRoutes.push({
378
- method: route.method,
379
- path: route.path,
380
- covered: modules.size > 0 && walked.covered,
381
- evidence: walked.evidence,
382
- })
396
+ const site = modules.size === 0 ? skipped.get(routeKey(route.method, route.path)) : undefined
397
+ const uncoveredReason =
398
+ modules.size > 0
399
+ ? undefined
400
+ : site === undefined
401
+ ? "no source file registers it with a path the static scan can read; map it with capabilities.provenance.routeModules"
402
+ : `${site} appears to register it on a server the static scan cannot identify (built by a helper that is neither \`server()\` nor imported from a module that calls it); call server() there, or map the route with capabilities.provenance.routeModules`
403
+ const covered = modules.size > 0 && walked.covered
404
+ evidenceRoutes.push(
405
+ uncoveredReason === undefined
406
+ ? { method: route.method, path: route.path, covered, evidence: walked.evidence }
407
+ : {
408
+ method: route.method,
409
+ path: route.path,
410
+ covered,
411
+ evidence: walked.evidence,
412
+ uncoveredReason,
413
+ },
414
+ )
383
415
  violations.push(...walked.violations)
384
416
  truncations.push(...walked.truncations)
385
417
  }
@@ -703,6 +735,12 @@ export async function runCapabilityCheck(
703
735
  // Stated on every run, passing or not: a gap is the report's own boundary, not a failure.
704
736
  for (const gap of result.report.gaps ?? [])
705
737
  console.log(`• known gap, not analyzed: ${gap.path} - ${gap.reason}`)
738
+ for (const route of result.report.routes) {
739
+ if (route.analysis === "declared")
740
+ console.log(
741
+ `• declared by an opaque mount, not analyzed: ${route.method} ${route.path} [${route.declared.join(", ")}]`,
742
+ )
743
+ }
706
744
  for (const finding of result.report.findings) console.log(`✖ ${finding.message}`)
707
745
  for (const violation of result.violations)
708
746
  console.log(