@nifrajs/cli 3.5.0 → 4.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (404) hide show
  1. package/README.md +42 -3
  2. package/dist/app-files.d.ts +11 -0
  3. package/dist/app-files.d.ts.map +1 -0
  4. package/dist/app-files.js +34 -0
  5. package/dist/app-files.js.map +1 -0
  6. package/dist/assure-hydration.d.ts +7 -1
  7. package/dist/assure-hydration.d.ts.map +1 -1
  8. package/dist/assure-hydration.js +128 -43
  9. package/dist/assure-hydration.js.map +1 -1
  10. package/dist/assure.d.ts.map +1 -1
  11. package/dist/assure.js +16 -2
  12. package/dist/assure.js.map +1 -1
  13. package/dist/capabilities-tool.d.ts +5 -1
  14. package/dist/capabilities-tool.d.ts.map +1 -1
  15. package/dist/capabilities-tool.js +32 -10
  16. package/dist/capabilities-tool.js.map +1 -1
  17. package/dist/cdn-check.d.ts +41 -0
  18. package/dist/cdn-check.d.ts.map +1 -0
  19. package/dist/cdn-check.js +207 -0
  20. package/dist/cdn-check.js.map +1 -0
  21. package/dist/check-diagnostics.d.ts +4 -0
  22. package/dist/check-diagnostics.d.ts.map +1 -1
  23. package/dist/check-diagnostics.js +13 -0
  24. package/dist/check-diagnostics.js.map +1 -1
  25. package/dist/check-scan.d.ts +49 -7
  26. package/dist/check-scan.d.ts.map +1 -1
  27. package/dist/check-scan.js +291 -62
  28. package/dist/check-scan.js.map +1 -1
  29. package/dist/check.d.ts.map +1 -1
  30. package/dist/check.js +99 -63
  31. package/dist/check.js.map +1 -1
  32. package/dist/cli.d.ts +42 -16
  33. package/dist/cli.d.ts.map +1 -1
  34. package/dist/cli.js +262 -82
  35. package/dist/cli.js.map +1 -1
  36. package/dist/command-catalog.d.ts +62 -5
  37. package/dist/command-catalog.d.ts.map +1 -1
  38. package/dist/command-catalog.js +275 -35
  39. package/dist/command-catalog.js.map +1 -1
  40. package/dist/config-target.d.ts +8 -0
  41. package/dist/config-target.d.ts.map +1 -0
  42. package/dist/config-target.js +45 -0
  43. package/dist/config-target.js.map +1 -0
  44. package/dist/contracts.d.ts.map +1 -1
  45. package/dist/contracts.js +52 -13
  46. package/dist/contracts.js.map +1 -1
  47. package/dist/db-audit.d.ts +37 -0
  48. package/dist/db-audit.d.ts.map +1 -0
  49. package/dist/db-audit.js +99 -0
  50. package/dist/db-audit.js.map +1 -0
  51. package/dist/db-child.d.ts +64 -0
  52. package/dist/db-child.d.ts.map +1 -0
  53. package/dist/db-child.js +213 -0
  54. package/dist/db-child.js.map +1 -0
  55. package/dist/db-config.d.ts +60 -0
  56. package/dist/db-config.d.ts.map +1 -0
  57. package/dist/db-config.js +176 -0
  58. package/dist/db-config.js.map +1 -0
  59. package/dist/db-tool.d.ts +84 -0
  60. package/dist/db-tool.d.ts.map +1 -0
  61. package/dist/db-tool.js +537 -0
  62. package/dist/db-tool.js.map +1 -0
  63. package/dist/dev-bun-config.d.ts +20 -13
  64. package/dist/dev-bun-config.d.ts.map +1 -1
  65. package/dist/dev-bun-config.js +98 -41
  66. package/dist/dev-bun-config.js.map +1 -1
  67. package/dist/dev-feed-tool.d.ts +106 -0
  68. package/dist/dev-feed-tool.d.ts.map +1 -0
  69. package/dist/dev-feed-tool.js +713 -0
  70. package/dist/dev-feed-tool.js.map +1 -0
  71. package/dist/dev-server-client.d.ts +67 -0
  72. package/dist/dev-server-client.d.ts.map +1 -0
  73. package/dist/dev-server-client.js +195 -0
  74. package/dist/dev-server-client.js.map +1 -0
  75. package/dist/diff-tool.d.ts +3 -3
  76. package/dist/diff-tool.d.ts.map +1 -1
  77. package/dist/diff-tool.js +6 -5
  78. package/dist/diff-tool.js.map +1 -1
  79. package/dist/docs-search.d.ts.map +1 -1
  80. package/dist/docs-search.js +12 -4
  81. package/dist/docs-search.js.map +1 -1
  82. package/dist/doctor.d.ts +17 -0
  83. package/dist/doctor.d.ts.map +1 -1
  84. package/dist/doctor.js +76 -15
  85. package/dist/doctor.js.map +1 -1
  86. package/dist/env-file.d.ts +15 -0
  87. package/dist/env-file.d.ts.map +1 -1
  88. package/dist/env-file.js +73 -1
  89. package/dist/env-file.js.map +1 -1
  90. package/dist/fix-recipes.d.ts.map +1 -1
  91. package/dist/fix-recipes.js +48 -22
  92. package/dist/fix-recipes.js.map +1 -1
  93. package/dist/frontend-guidance.js +5 -5
  94. package/dist/frontend-guidance.js.map +1 -1
  95. package/dist/i18n-check.d.ts +16 -0
  96. package/dist/i18n-check.d.ts.map +1 -0
  97. package/dist/i18n-check.js +154 -0
  98. package/dist/i18n-check.js.map +1 -0
  99. package/dist/init-agents.d.ts +49 -13
  100. package/dist/init-agents.d.ts.map +1 -1
  101. package/dist/init-agents.js +239 -34
  102. package/dist/init-agents.js.map +1 -1
  103. package/dist/internal/code-unit-order.d.ts +4 -0
  104. package/dist/internal/code-unit-order.d.ts.map +1 -0
  105. package/dist/internal/code-unit-order.js +6 -0
  106. package/dist/internal/code-unit-order.js.map +1 -0
  107. package/dist/internal/script-kind.d.ts +7 -0
  108. package/dist/internal/script-kind.d.ts.map +1 -0
  109. package/dist/internal/script-kind.js +12 -0
  110. package/dist/internal/script-kind.js.map +1 -0
  111. package/dist/internal/source-facts.d.ts +5 -0
  112. package/dist/internal/source-facts.d.ts.map +1 -1
  113. package/dist/internal/source-facts.js +61 -5
  114. package/dist/internal/source-facts.js.map +1 -1
  115. package/dist/internal/typescript-import.d.ts +49 -24
  116. package/dist/internal/typescript-import.d.ts.map +1 -1
  117. package/dist/internal/typescript-import.js +507 -67
  118. package/dist/internal/typescript-import.js.map +1 -1
  119. package/dist/introspect.d.ts +5 -1
  120. package/dist/introspect.d.ts.map +1 -1
  121. package/dist/introspect.js +92 -27
  122. package/dist/introspect.js.map +1 -1
  123. package/dist/learn.js +3 -3
  124. package/dist/learn.js.map +1 -1
  125. package/dist/levels-tool.d.ts +10 -0
  126. package/dist/levels-tool.d.ts.map +1 -1
  127. package/dist/levels-tool.js +33 -3
  128. package/dist/levels-tool.js.map +1 -1
  129. package/dist/load.d.ts +67 -17
  130. package/dist/load.d.ts.map +1 -1
  131. package/dist/load.js +78 -18
  132. package/dist/load.js.map +1 -1
  133. package/dist/manifest-tool.d.ts.map +1 -1
  134. package/dist/manifest-tool.js +3 -2
  135. package/dist/manifest-tool.js.map +1 -1
  136. package/dist/mcp-app-tool.d.ts.map +1 -1
  137. package/dist/mcp-app-tool.js +2 -0
  138. package/dist/mcp-app-tool.js.map +1 -1
  139. package/dist/mcp-context.js +1 -1
  140. package/dist/mcp-context.js.map +1 -1
  141. package/dist/mcp-delegate.d.ts +57 -0
  142. package/dist/mcp-delegate.d.ts.map +1 -0
  143. package/dist/mcp-delegate.js +130 -0
  144. package/dist/mcp-delegate.js.map +1 -0
  145. package/dist/mcp-docs-tools.d.ts.map +1 -1
  146. package/dist/mcp-docs-tools.js +23 -5
  147. package/dist/mcp-docs-tools.js.map +1 -1
  148. package/dist/mcp-exec.d.ts +2 -0
  149. package/dist/mcp-exec.d.ts.map +1 -1
  150. package/dist/mcp-exec.js +132 -108
  151. package/dist/mcp-exec.js.map +1 -1
  152. package/dist/mcp-http.d.ts.map +1 -1
  153. package/dist/mcp-http.js +17 -5
  154. package/dist/mcp-http.js.map +1 -1
  155. package/dist/mcp-io.d.ts +14 -0
  156. package/dist/mcp-io.d.ts.map +1 -1
  157. package/dist/mcp-io.js +45 -0
  158. package/dist/mcp-io.js.map +1 -1
  159. package/dist/mcp-isolate.d.ts +48 -0
  160. package/dist/mcp-isolate.d.ts.map +1 -0
  161. package/dist/mcp-isolate.js +222 -0
  162. package/dist/mcp-isolate.js.map +1 -0
  163. package/dist/mcp-project-child.d.ts +74 -0
  164. package/dist/mcp-project-child.d.ts.map +1 -0
  165. package/dist/mcp-project-child.js +130 -0
  166. package/dist/mcp-project-child.js.map +1 -0
  167. package/dist/mcp-render.js +2 -2
  168. package/dist/mcp-render.js.map +1 -1
  169. package/dist/mcp-root.d.ts +37 -9
  170. package/dist/mcp-root.d.ts.map +1 -1
  171. package/dist/mcp-root.js +110 -23
  172. package/dist/mcp-root.js.map +1 -1
  173. package/dist/mcp-run.d.ts +18 -1
  174. package/dist/mcp-run.d.ts.map +1 -1
  175. package/dist/mcp-run.js +82 -9
  176. package/dist/mcp-run.js.map +1 -1
  177. package/dist/mcp-ws.js +4 -1
  178. package/dist/mcp-ws.js.map +1 -1
  179. package/dist/mcp.d.ts +16 -3
  180. package/dist/mcp.d.ts.map +1 -1
  181. package/dist/mcp.js +81 -53
  182. package/dist/mcp.js.map +1 -1
  183. package/dist/migrate-layout.d.ts +44 -0
  184. package/dist/migrate-layout.d.ts.map +1 -0
  185. package/dist/migrate-layout.js +1155 -0
  186. package/dist/migrate-layout.js.map +1 -0
  187. package/dist/openapi-tool.d.ts +2 -2
  188. package/dist/openapi-tool.d.ts.map +1 -1
  189. package/dist/openapi-tool.js.map +1 -1
  190. package/dist/openapi-types.d.ts +2 -1
  191. package/dist/openapi-types.d.ts.map +1 -1
  192. package/dist/openapi-types.js +240 -12
  193. package/dist/openapi-types.js.map +1 -1
  194. package/dist/pipeline-report.d.ts +3 -2
  195. package/dist/pipeline-report.d.ts.map +1 -1
  196. package/dist/pipeline-report.js +19 -16
  197. package/dist/pipeline-report.js.map +1 -1
  198. package/dist/port.d.ts +5 -4
  199. package/dist/port.d.ts.map +1 -1
  200. package/dist/port.js +46 -29
  201. package/dist/port.js.map +1 -1
  202. package/dist/project-facts.d.ts +5 -0
  203. package/dist/project-facts.d.ts.map +1 -1
  204. package/dist/project-facts.js.map +1 -1
  205. package/dist/recipes/4.0.0.d.ts +9 -0
  206. package/dist/recipes/4.0.0.d.ts.map +1 -0
  207. package/dist/recipes/4.0.0.js +36 -0
  208. package/dist/recipes/4.0.0.js.map +1 -0
  209. package/dist/recipes/index.d.ts.map +1 -1
  210. package/dist/recipes/index.js +2 -0
  211. package/dist/recipes/index.js.map +1 -1
  212. package/dist/release-verification.d.ts +11 -1
  213. package/dist/release-verification.d.ts.map +1 -1
  214. package/dist/release-verification.js +47 -4
  215. package/dist/release-verification.js.map +1 -1
  216. package/dist/review.d.ts.map +1 -1
  217. package/dist/review.js +12 -8
  218. package/dist/review.js.map +1 -1
  219. package/dist/route-types.d.ts +28 -0
  220. package/dist/route-types.d.ts.map +1 -0
  221. package/dist/route-types.js +106 -0
  222. package/dist/route-types.js.map +1 -0
  223. package/dist/rules/codes.d.ts +9 -0
  224. package/dist/rules/codes.d.ts.map +1 -1
  225. package/dist/rules/codes.js +9 -0
  226. package/dist/rules/codes.js.map +1 -1
  227. package/dist/rules/data-guard.d.ts +12 -0
  228. package/dist/rules/data-guard.d.ts.map +1 -0
  229. package/dist/rules/data-guard.js +213 -0
  230. package/dist/rules/data-guard.js.map +1 -0
  231. package/dist/rules/index.d.ts +3 -0
  232. package/dist/rules/index.d.ts.map +1 -1
  233. package/dist/rules/index.js +17 -5
  234. package/dist/rules/index.js.map +1 -1
  235. package/dist/rules/islands.d.ts.map +1 -1
  236. package/dist/rules/islands.js +7 -4
  237. package/dist/rules/islands.js.map +1 -1
  238. package/dist/rules/legacy.d.ts.map +1 -1
  239. package/dist/rules/legacy.js +71 -38
  240. package/dist/rules/legacy.js.map +1 -1
  241. package/dist/rules/nano.d.ts.map +1 -1
  242. package/dist/rules/nano.js +15 -6
  243. package/dist/rules/nano.js.map +1 -1
  244. package/dist/rules/pages.d.ts +15 -0
  245. package/dist/rules/pages.d.ts.map +1 -0
  246. package/dist/rules/pages.js +88 -0
  247. package/dist/rules/pages.js.map +1 -0
  248. package/dist/rules/routes.d.ts +11 -0
  249. package/dist/rules/routes.d.ts.map +1 -1
  250. package/dist/rules/routes.js +180 -2
  251. package/dist/rules/routes.js.map +1 -1
  252. package/dist/rules/secrets.d.ts +10 -0
  253. package/dist/rules/secrets.d.ts.map +1 -0
  254. package/dist/rules/secrets.js +169 -0
  255. package/dist/rules/secrets.js.map +1 -0
  256. package/dist/rules/security.d.ts +7 -0
  257. package/dist/rules/security.d.ts.map +1 -1
  258. package/dist/rules/security.js +171 -65
  259. package/dist/rules/security.js.map +1 -1
  260. package/dist/rules/typescript.d.ts +16 -0
  261. package/dist/rules/typescript.d.ts.map +1 -0
  262. package/dist/rules/typescript.js +59 -0
  263. package/dist/rules/typescript.js.map +1 -0
  264. package/dist/rules/zones.d.ts +17 -0
  265. package/dist/rules/zones.d.ts.map +1 -0
  266. package/dist/rules/zones.js +174 -0
  267. package/dist/rules/zones.js.map +1 -0
  268. package/dist/scaffold.d.ts +25 -12
  269. package/dist/scaffold.d.ts.map +1 -1
  270. package/dist/scaffold.js +132 -87
  271. package/dist/scaffold.js.map +1 -1
  272. package/dist/sdk.d.ts +17 -3
  273. package/dist/sdk.d.ts.map +1 -1
  274. package/dist/sdk.js +941 -153
  275. package/dist/sdk.js.map +1 -1
  276. package/dist/search-terms.d.ts +2 -0
  277. package/dist/search-terms.d.ts.map +1 -1
  278. package/dist/search-terms.js +23 -8
  279. package/dist/search-terms.js.map +1 -1
  280. package/dist/smoke.d.ts +79 -0
  281. package/dist/smoke.d.ts.map +1 -0
  282. package/dist/smoke.js +494 -0
  283. package/dist/smoke.js.map +1 -0
  284. package/dist/sync-manifest.d.ts +10 -2
  285. package/dist/sync-manifest.d.ts.map +1 -1
  286. package/dist/sync-manifest.js +44 -19
  287. package/dist/sync-manifest.js.map +1 -1
  288. package/dist/sync-routes.d.ts +4 -3
  289. package/dist/sync-routes.d.ts.map +1 -1
  290. package/dist/sync-routes.js +4 -3
  291. package/dist/sync-routes.js.map +1 -1
  292. package/dist/test-tool.d.ts.map +1 -1
  293. package/dist/test-tool.js +30 -11
  294. package/dist/test-tool.js.map +1 -1
  295. package/dist/types-search.d.ts.map +1 -1
  296. package/dist/types-search.js +11 -5
  297. package/dist/types-search.js.map +1 -1
  298. package/dist/upgrade.d.ts +36 -6
  299. package/dist/upgrade.d.ts.map +1 -1
  300. package/dist/upgrade.js +167 -29
  301. package/dist/upgrade.js.map +1 -1
  302. package/dist/verification-plan.d.ts.map +1 -1
  303. package/dist/verification-plan.js +4 -0
  304. package/dist/verification-plan.js.map +1 -1
  305. package/dist/verification.d.ts.map +1 -1
  306. package/dist/verification.js +45 -12
  307. package/dist/verification.js.map +1 -1
  308. package/dist/web-app-options.d.ts +19 -0
  309. package/dist/web-app-options.d.ts.map +1 -0
  310. package/dist/web-app-options.js +49 -0
  311. package/dist/web-app-options.js.map +1 -0
  312. package/dist/work-graph.d.ts.map +1 -1
  313. package/dist/work-graph.js +29 -13
  314. package/dist/work-graph.js.map +1 -1
  315. package/docs/examples.json +185 -29
  316. package/docs/llms-full.txt +3807 -580
  317. package/docs/types.json +14229 -10939
  318. package/package.json +19 -12
  319. package/src/app-files.ts +40 -0
  320. package/src/assure-hydration.ts +139 -46
  321. package/src/assure.ts +17 -2
  322. package/src/capabilities-tool.ts +35 -9
  323. package/src/cdn-check.ts +268 -0
  324. package/src/check-diagnostics.ts +17 -0
  325. package/src/check-scan.ts +358 -63
  326. package/src/check.ts +97 -62
  327. package/src/cli.ts +296 -87
  328. package/src/command-catalog.ts +389 -48
  329. package/src/config-target.ts +47 -0
  330. package/src/contracts.ts +54 -12
  331. package/src/db-audit.ts +128 -0
  332. package/src/db-child.ts +285 -0
  333. package/src/db-config.ts +224 -0
  334. package/src/db-tool.ts +690 -0
  335. package/src/dev-bun-config.ts +116 -40
  336. package/src/dev-feed-tool.ts +912 -0
  337. package/src/dev-server-client.ts +267 -0
  338. package/src/diff-tool.ts +6 -5
  339. package/src/docs-search.ts +10 -3
  340. package/src/doctor.ts +98 -15
  341. package/src/env-file.ts +78 -1
  342. package/src/fix-recipes.ts +53 -32
  343. package/src/frontend-guidance.ts +5 -5
  344. package/src/i18n-check.ts +179 -0
  345. package/src/init-agents.ts +301 -38
  346. package/src/internal/code-unit-order.ts +5 -0
  347. package/src/internal/script-kind.ts +11 -0
  348. package/src/internal/source-facts.ts +96 -22
  349. package/src/internal/typescript-import.ts +663 -68
  350. package/src/introspect.ts +90 -27
  351. package/src/learn.ts +3 -3
  352. package/src/levels-tool.ts +47 -2
  353. package/src/load.ts +140 -33
  354. package/src/manifest-tool.ts +3 -2
  355. package/src/mcp-app-tool.ts +2 -0
  356. package/src/mcp-context.ts +1 -1
  357. package/src/mcp-delegate.ts +162 -0
  358. package/src/mcp-docs-tools.ts +23 -5
  359. package/src/mcp-exec.ts +160 -132
  360. package/src/mcp-http.ts +19 -5
  361. package/src/mcp-io.ts +57 -0
  362. package/src/mcp-isolate.ts +287 -0
  363. package/src/mcp-project-child.ts +182 -0
  364. package/src/mcp-render.ts +2 -2
  365. package/src/mcp-root.ts +117 -22
  366. package/src/mcp-run.ts +104 -10
  367. package/src/mcp-ws.ts +4 -1
  368. package/src/mcp.ts +106 -64
  369. package/src/migrate-layout.ts +1338 -0
  370. package/src/openapi-tool.ts +7 -3
  371. package/src/openapi-types.ts +315 -13
  372. package/src/pipeline-report.ts +22 -18
  373. package/src/port.ts +53 -30
  374. package/src/project-facts.ts +5 -0
  375. package/src/recipes/4.0.0.ts +37 -0
  376. package/src/recipes/index.ts +2 -0
  377. package/src/release-verification.ts +74 -5
  378. package/src/review.ts +13 -8
  379. package/src/route-types.ts +133 -0
  380. package/src/rules/codes.ts +9 -0
  381. package/src/rules/data-guard.ts +230 -0
  382. package/src/rules/index.ts +18 -5
  383. package/src/rules/islands.ts +12 -4
  384. package/src/rules/legacy.ts +71 -39
  385. package/src/rules/nano.ts +18 -6
  386. package/src/rules/pages.ts +99 -0
  387. package/src/rules/routes.ts +181 -2
  388. package/src/rules/secrets.ts +174 -0
  389. package/src/rules/security.ts +198 -70
  390. package/src/rules/typescript.ts +77 -0
  391. package/src/rules/zones.ts +195 -0
  392. package/src/scaffold.ts +147 -95
  393. package/src/sdk.ts +1215 -158
  394. package/src/search-terms.ts +24 -7
  395. package/src/smoke.ts +607 -0
  396. package/src/sync-manifest.ts +50 -19
  397. package/src/sync-routes.ts +4 -3
  398. package/src/test-tool.ts +32 -11
  399. package/src/types-search.ts +11 -4
  400. package/src/upgrade.ts +205 -25
  401. package/src/verification-plan.ts +13 -0
  402. package/src/verification.ts +50 -12
  403. package/src/web-app-options.ts +57 -0
  404. package/src/work-graph.ts +27 -13
@@ -1,43 +1,61 @@
1
1
  /**
2
- * `nifra init-agents` - retrofit an EXISTING app with the agent-discovery files a freshly scaffolded
3
- * app ships, so an already-built project adopts the nifra MCP in one command:
2
+ * `nifra init-agents` - retrofit an EXISTING app with the agent files a freshly scaffolded app ships, so
3
+ * an already-built project adopts them in one command:
4
4
  *
5
- * .mcp.json - Claude Code's project MCP registry (launches `bunx @nifrajs/cli mcp`)
6
- * .cursor/mcp.json - Cursor's MCP registry (same server config)
7
- * CLAUDE.md - Claude's MCP-first preamble + `@AGENTS.md` import
8
- * AGENTS.md - a `## MCP server` section appended (or a minimal file if none exists)
5
+ * .mcp.json, .cursor/mcp.json - the nifra MCP server for Claude Code and Cursor
6
+ * CLAUDE.md, GEMINI.md, .cursor/rules/nifra.mdc, .github/copilot-instructions.md
7
+ * - each agent's pointer to AGENTS.md, the single source of guidance
8
+ * AGENTS.md - the `## MCP server` section, and for an app with `routes/` the
9
+ * `## Project structure` section, appended when missing (or a minimal
10
+ * file if none exists)
9
11
  *
10
12
  * The generators are imported from `create-nifra/agent-files` - the SAME source of truth `create-nifra`
11
13
  * uses at scaffold time - so a retrofitted app and a freshly scaffolded one get byte-identical configs.
12
14
  *
13
15
  * Safety: this writes into the user's existing tree, so it NEVER silently clobbers a file they may have
14
16
  * customized. By default an existing `.mcp.json` / `CLAUDE.md` / `.cursor/mcp.json` is SKIPPED with a
15
- * notice; `--force` overwrites. `AGENTS.md` is special-cased - if it already has the MCP section it's
16
- * left alone, otherwise the section is APPENDED (never overwriting the user's conventions), and `--force`
17
- * is not needed for that append since it's additive. Every write path is resolved + confined under the
17
+ * notice; `--force` overwrites. `AGENTS.md` is special-cased - a section it already has is left alone,
18
+ * a missing one is APPENDED (never overwriting the user's conventions), and `--force` is not needed for
19
+ * that append since it's additive. Every write path is resolved + confined under the
18
20
  * cwd (no `..` traversal escaping the project root).
21
+ *
22
+ * `--sync-mcp` is the upgrade path: the launch command pins an exact `@nifrajs/cli` version, so after
23
+ * the app upgrades nifra every existing file still launches the old CLI. It rewrites that version and
24
+ * nothing else - the pin in both MCP registries and the launch command in CLAUDE.md and AGENTS.md's
25
+ * MCP section - to the nifra the project installs, leaving every other byte of every file as it was.
26
+ *
27
+ * At a workspace root that is not itself a nifra project but has exactly one nifra member, both runs
28
+ * also name that member in the registries' launch args (`mcp app`), so the server keeps its root when
29
+ * a second nifra member arrives.
19
30
  */
20
31
 
21
32
  import type { Stats } from "node:fs"
22
- import { lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"
23
- import { isAbsolute, relative, resolve } from "node:path"
33
+ import { chmod, lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"
34
+ import { isAbsolute, relative, resolve, sep } from "node:path"
24
35
  import {
36
+ AGENT_POINTERS,
25
37
  AGENTS_MD_PATH,
26
38
  agentsMcpSection,
39
+ agentsStructureSection,
27
40
  CLAUDE_MD_PATH,
28
41
  CURSOR_MCP_JSON_PATH,
29
- claudeMd,
42
+ MCP_CLI_VERSION,
30
43
  MCP_JSON_PATH,
44
+ MCP_SERVER_COMMAND,
31
45
  mcpJson,
46
+ STRUCTURE_HEADING,
32
47
  } from "create-nifra/agent-files"
48
+ import { installedNifraVersion, resolveRootState } from "./mcp-root.ts"
33
49
 
34
50
  /** What happened to one file during the retrofit, for the printed report + the `--json` shape. */
35
51
  export interface InitAgentsFileResult {
36
52
  /** Project-root-relative POSIX path. */
37
53
  readonly path: string
38
- /** `wrote` - created or (with --force) overwrote; `appended` - added the MCP section to an existing
39
- * AGENTS.md; `skipped` - already present and not forced; `present` - MCP section already there. */
40
- readonly action: "wrote" | "appended" | "skipped" | "present"
54
+ /** `wrote` - created or (with --force) overwrote; `appended` - added missing sections to an existing
55
+ * AGENTS.md; `skipped` - already present and not forced (or, under --sync-mcp, absent or unpinned);
56
+ * `present` - every section already there (or, under --sync-mcp, already pinned to the target);
57
+ * `synced` - --sync-mcp rewrote a stale pinned version or named the workspace member. */
58
+ readonly action: "wrote" | "appended" | "skipped" | "present" | "synced"
41
59
  /** Why it was skipped/left, for the notice (e.g. "exists - pass --force to overwrite"). */
42
60
  readonly note?: string
43
61
  }
@@ -45,11 +63,16 @@ export interface InitAgentsFileResult {
45
63
  export interface InitAgentsResult {
46
64
  readonly cwd: string
47
65
  readonly files: readonly InitAgentsFileResult[]
66
+ /** Under --sync-mcp: the `@nifrajs/cli` version every pin was brought to. */
67
+ readonly syncedTo?: string
48
68
  }
49
69
 
50
70
  export interface InitAgentsOptions {
51
71
  /** Overwrite an existing `.mcp.json` / `CLAUDE.md` / `.cursor/mcp.json` instead of skipping it. */
52
72
  readonly force?: boolean
73
+ /** Rewrite only the pinned `@nifrajs/cli` version (and, at a workspace root, the member the launch
74
+ * names) in the existing files; create and append nothing. */
75
+ readonly syncMcp?: boolean
53
76
  }
54
77
 
55
78
  /** Confine a project-relative path under `cwd` and return the absolute path. Rejects a spec that escapes
@@ -123,7 +146,7 @@ async function writeOwned(
123
146
  if (!force && (await fileExists(abs))) {
124
147
  return { path: rel, action: "skipped", note: "exists - pass --force to overwrite" }
125
148
  }
126
- // `.cursor/mcp.json` needs its parent dir; `recursive` is a no-op for the root-level files.
149
+ // A nested file (`.cursor/`, `.github/`) needs its parent dir; `recursive` is a no-op at the root.
127
150
  await mkdir(resolve(abs, ".."), { recursive: true })
128
151
  await atomicWrite(abs, content)
129
152
  return { path: rel, action: "wrote" }
@@ -131,11 +154,16 @@ async function writeOwned(
131
154
 
132
155
  /** Write via a sibling temp file so a crash mid-write can't leave a half-written config in place.
133
156
  * `wx` fails rather than following an existing name, and the temp is cleaned up on any failure so a
134
- * botched run doesn't litter the user's project. */
135
- async function atomicWrite(abs: string, content: string): Promise<void> {
157
+ * botched run doesn't litter the user's project. `mode` carries a replaced file's permissions over. */
158
+ async function atomicWrite(
159
+ abs: string,
160
+ content: string | Uint8Array,
161
+ mode?: number,
162
+ ): Promise<void> {
136
163
  const tmp = `${abs}.nifra-${process.pid}-${Date.now()}`
137
164
  await writeFile(tmp, content, { flag: "wx" })
138
165
  try {
166
+ if (mode !== undefined) await chmod(tmp, mode)
139
167
  await rename(tmp, abs)
140
168
  } catch (error) {
141
169
  await rm(tmp, { force: true })
@@ -144,16 +172,19 @@ async function atomicWrite(abs: string, content: string): Promise<void> {
144
172
  }
145
173
 
146
174
  /**
147
- * AGENTS.md is additive, not owned: if it exists and already has the MCP section, leave it; if it exists
148
- * without the section, append the section (preserving the user's conventions); if it's absent, write a
149
- * minimal AGENTS.md that is just the MCP section under a heading. `--force` is irrelevant here - we never
150
- * overwrite the user's existing guidance.
175
+ * AGENTS.md is additive, not owned: each section it lacks is appended (preserving the user's
176
+ * conventions), each it has is left alone, and an absent file is written with just those sections.
177
+ * `--force` is irrelevant here - we never overwrite the user's existing guidance. The structure section
178
+ * is for an app with `routes/`; an API-only app has no frontend to keep apart.
151
179
  */
152
180
  async function ensureAgentsMd(cwd: string): Promise<InitAgentsFileResult> {
153
181
  const abs = safeJoin(cwd, AGENTS_MD_PATH)
154
182
  await assertSafeParents(cwd, abs)
155
183
  await assertRegularTarget(abs, AGENTS_MD_PATH)
156
- const section = agentsMcpSection()
184
+ const sections = [{ heading: "## MCP server", text: agentsMcpSection() }]
185
+ if (await isDirectory(safeJoin(cwd, "routes"))) {
186
+ sections.push({ heading: STRUCTURE_HEADING, text: agentsStructureSection() })
187
+ }
157
188
  let existing: string | undefined
158
189
  try {
159
190
  existing = await readFile(abs, "utf8")
@@ -161,23 +192,221 @@ async function ensureAgentsMd(cwd: string): Promise<InitAgentsFileResult> {
161
192
  existing = undefined // no AGENTS.md yet
162
193
  }
163
194
  if (existing === undefined) {
195
+ const body = sections.map((section) => section.text).join("\n\n")
164
196
  await atomicWrite(
165
197
  abs,
166
- `# AGENTS.md\n\nGuidance for AI coding agents working in this repo.\n\n${section}\n`,
198
+ `# AGENTS.md\n\nGuidance for AI coding agents working in this repo.\n\n${body}\n`,
167
199
  )
168
200
  return { path: AGENTS_MD_PATH, action: "wrote" }
169
201
  }
170
202
  // Match on the section heading, not the full body, so a reformatted-but-present section still counts.
171
- if (existing.includes("## MCP server")) {
172
- return { path: AGENTS_MD_PATH, action: "present", note: "already has an MCP section" }
203
+ const current = existing
204
+ const missing = sections.filter((section) => !current.includes(section.heading))
205
+ if (missing.length === 0) {
206
+ return { path: AGENTS_MD_PATH, action: "present", note: "already has every section" }
173
207
  }
174
- const sep = existing.endsWith("\n") ? "\n" : "\n\n"
175
- await atomicWrite(abs, `${existing}${sep}${section}\n`)
176
- return { path: AGENTS_MD_PATH, action: "appended" }
208
+ const sep = current.endsWith("\n") ? "\n" : "\n\n"
209
+ await atomicWrite(abs, `${current}${sep}${missing.map((section) => section.text).join("\n\n")}\n`)
210
+ const names = missing.map((section) => section.heading.slice(3)).join(" + ")
211
+ return { path: AGENTS_MD_PATH, action: "appended", note: names }
212
+ }
213
+
214
+ const isDirectory = (path: string): Promise<boolean> =>
215
+ lstat(path)
216
+ .then((stat) => stat.isDirectory())
217
+ .catch(() => false)
218
+
219
+ /** An exact version: the only pin --sync-mcp rewrites. A dist-tag or a range is a deliberate choice,
220
+ * not a scaffold-time freeze. Prerelease and build suffixes are part of the version. */
221
+ const PIN_VERSION = String.raw`\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?`
222
+ /** The pin as both MCP registries hold it: one JSON string in the server's `args`. */
223
+ const JSON_PIN = new RegExp(`"@nifrajs/cli@(${PIN_VERSION})"`, "g")
224
+ /** The pin as the markdown quotes it: the whole launch command, so prose that merely mentions a
225
+ * version (an upgrade note, a changelog line) is never touched. */
226
+ const LAUNCH_PIN = new RegExp(`${MCP_SERVER_COMMAND} @nifrajs/cli@(${PIN_VERSION}) mcp`, "g")
227
+
228
+ /** The one nifra workspace member `cwd` resolves to, as a POSIX path relative to it, when `cwd` is a
229
+ * workspace root and not itself a nifra project. */
230
+ async function workspaceMember(cwd: string): Promise<string | undefined> {
231
+ const state = await resolveRootState(cwd, false)
232
+ return state.source === "workspace" ? relative(cwd, state.root).split(sep).join("/") : undefined
233
+ }
234
+
235
+ /** The registries' launch args when they name no directory: `["@nifrajs/cli@x.y.z", "mcp"]`. Group 2
236
+ * is the whitespace between the two, reused before the member so the file keeps its own layout. */
237
+ const JSON_BARE_LAUNCH = new RegExp(`("@nifrajs/cli@${PIN_VERSION}"\\s*,(\\s*)"mcp")(\\s*\\])`, "g")
238
+
239
+ /** Add `member` after a bare `"mcp"` launch arg; a launch that already names a directory is left alone. */
240
+ function nameMember(
241
+ text: string,
242
+ member: string,
243
+ ): { readonly text: string; readonly named: boolean } {
244
+ let named = false
245
+ const rewritten = text.replace(
246
+ JSON_BARE_LAUNCH,
247
+ (_match: string, launch: string, gap: string, close: string) => {
248
+ named = true
249
+ return `${launch},${gap}${JSON.stringify(member)}${close}`
250
+ },
251
+ )
252
+ return { text: rewritten, named }
253
+ }
254
+
255
+ interface PinScan {
256
+ /** The input with every stale pin rewritten - every other character untouched. */
257
+ readonly text: string
258
+ /** Pins found, current or stale. */
259
+ readonly pins: number
260
+ /** The distinct stale versions, in order of appearance. */
261
+ readonly stale: readonly string[]
262
+ /** The workspace member this scan added to a launch that named no directory. */
263
+ readonly named?: string
264
+ }
265
+
266
+ function repin(text: string, pattern: RegExp, target: string): PinScan {
267
+ let pins = 0
268
+ const stale: string[] = []
269
+ const rewritten = text.replace(pattern, (match: string, version: string) => {
270
+ pins++
271
+ if (version === target) return match
272
+ if (!stale.includes(version)) stale.push(version)
273
+ return match.replace(`@${version}`, () => `@${target}`)
274
+ })
275
+ return { text: rewritten, pins, stale }
276
+ }
277
+
278
+ /** AGENTS.md is the user's file: only its `## MCP server` section - the heading up to the next `#` or
279
+ * `##` heading, or the end of the file - is ours to re-pin. */
280
+ function repinMcpSection(text: string, target: string): PinScan {
281
+ const heading = /^## MCP server\b.*$/m.exec(text)
282
+ if (heading === null) return { text, pins: 0, stale: [] }
283
+ const start = heading.index + heading[0].length
284
+ const next = /^#{1,2}[ \t]/m.exec(text.slice(start))
285
+ const end = next === null ? text.length : start + next.index
286
+ const section = repin(text.slice(start, end), LAUNCH_PIN, target)
287
+ return { ...section, text: text.slice(0, start) + section.text + text.slice(end) }
288
+ }
289
+
290
+ /** An MCP registry: its pin, and at a workspace root the member its launch names. Only the registries
291
+ * launch the server, so the markdown launch commands keep their wording. */
292
+ function repinRegistry(text: string, target: string, member?: string): PinScan {
293
+ const scan = repin(text, JSON_PIN, target)
294
+ if (member === undefined || scan.pins === 0) return scan
295
+ const { text: rewritten, named } = nameMember(scan.text, member)
296
+ return named ? { ...scan, text: rewritten, named: member } : scan
297
+ }
298
+
299
+ /** Every file that carries the pin, and the part of it --sync-mcp may rewrite. */
300
+ const PIN_SITES: readonly {
301
+ readonly path: string
302
+ readonly repin: (text: string, target: string, member?: string) => PinScan
303
+ }[] = [
304
+ { path: MCP_JSON_PATH, repin: repinRegistry },
305
+ { path: CURSOR_MCP_JSON_PATH, repin: repinRegistry },
306
+ { path: CLAUDE_MD_PATH, repin: (text, target) => repin(text, LAUNCH_PIN, target) },
307
+ { path: AGENTS_MD_PATH, repin: repinMcpSection },
308
+ ]
309
+
310
+ type PinState =
311
+ | { readonly kind: "skip"; readonly note: string }
312
+ | { readonly kind: "current" }
313
+ | {
314
+ readonly kind: "stale"
315
+ readonly abs: string
316
+ readonly mode: number
317
+ readonly scan: PinScan
318
+ }
319
+
320
+ /** Where one file's pin stands against `target`. Never throws on a file it would not write: a
321
+ * symlinked CLAUDE.md (a common alias for AGENTS.md) is reported and left alone, not a hard stop. */
322
+ async function readPin(
323
+ cwd: string,
324
+ site: (typeof PIN_SITES)[number],
325
+ target: string,
326
+ member?: string,
327
+ ): Promise<PinState> {
328
+ const abs = safeJoin(cwd, site.path)
329
+ try {
330
+ await assertSafeParents(cwd, abs)
331
+ } catch (error) {
332
+ return {
333
+ kind: "skip",
334
+ note: `left alone - ${(error as Error).message.replace(/^\[nifra\] /, "")}`,
335
+ }
336
+ }
337
+ const stat = await lstatOrMissing(abs)
338
+ if (stat === undefined) return { kind: "skip", note: "absent - `nifra init-agents` creates it" }
339
+ if (stat.isSymbolicLink() || !stat.isFile())
340
+ return { kind: "skip", note: "not a regular file - left alone" }
341
+ // latin1 is one char per byte, so writing it back reproduces every byte outside a pin exactly,
342
+ // whatever the file's encoding; the pin patterns are ASCII, so they match as they would in UTF-8.
343
+ const scan = site.repin(await readFile(abs, "latin1"), target, member)
344
+ if (scan.pins === 0) return { kind: "skip", note: "no pinned @nifrajs/cli launch command" }
345
+ if (scan.stale.length === 0 && scan.named === undefined) return { kind: "current" }
346
+ return { kind: "stale", abs, mode: stat.mode & 0o777, scan }
347
+ }
348
+
349
+ /**
350
+ * The version --sync-mcp pins to: the nifra the project installs, so the server an agent launches
351
+ * describes the surface the code builds with. The running CLI's own version when nothing is
352
+ * installed. Not simply the running CLI's version: the command that fixes a drifted server is run
353
+ * by that drifted CLI, and it must not re-pin to itself.
354
+ */
355
+ export async function mcpPinTarget(cwd: string): Promise<string> {
356
+ const installed = await installedNifraVersion(cwd)
357
+ if (installed !== undefined) return installed.version
358
+ // A workspace with isolated installs keeps nifra in the member's own node_modules.
359
+ const member = await workspaceMember(cwd)
360
+ const memberInstalled =
361
+ member === undefined ? undefined : await installedNifraVersion(resolve(cwd, member))
362
+ return memberInstalled?.version ?? MCP_CLI_VERSION
363
+ }
364
+
365
+ /** One file whose launch command pins a version other than {@link mcpPinTarget}. */
366
+ export interface StaleMcpPin {
367
+ readonly path: string
368
+ readonly pinned: readonly string[]
369
+ }
370
+
371
+ /** Read-only: every agent file whose pinned `@nifrajs/cli` is not the one the project installs. */
372
+ export async function collectStaleMcpPins(
373
+ cwd: string,
374
+ ): Promise<{ readonly target: string; readonly files: readonly StaleMcpPin[] }> {
375
+ const target = await mcpPinTarget(cwd)
376
+ const files: StaleMcpPin[] = []
377
+ for (const site of PIN_SITES) {
378
+ const state = await readPin(cwd, site, target)
379
+ if (state.kind === "stale") files.push({ path: site.path, pinned: state.scan.stale })
380
+ }
381
+ return { target, files }
382
+ }
383
+
384
+ /** --sync-mcp: rewrite each stale pin to the project's version and touch nothing else. Creates no
385
+ * file and appends no section - a file without a pin is reported, not repaired. */
386
+ async function syncMcpPins(cwd: string): Promise<InitAgentsResult> {
387
+ const target = await mcpPinTarget(cwd)
388
+ const member = await workspaceMember(cwd)
389
+ const files: InitAgentsFileResult[] = []
390
+ for (const site of PIN_SITES) {
391
+ const state = await readPin(cwd, site, target, member)
392
+ if (state.kind === "skip") {
393
+ files.push({ path: site.path, action: "skipped", note: state.note })
394
+ } else if (state.kind === "current") {
395
+ files.push({ path: site.path, action: "present", note: `already pinned to ${target}` })
396
+ } else {
397
+ await atomicWrite(state.abs, Buffer.from(state.scan.text, "latin1"), state.mode)
398
+ const notes = [
399
+ ...(state.scan.stale.length > 0 ? [`${state.scan.stale.join(", ")} -> ${target}`] : []),
400
+ ...(state.scan.named !== undefined ? [`launches \`mcp ${state.scan.named}\``] : []),
401
+ ]
402
+ files.push({ path: site.path, action: "synced", note: notes.join("; ") })
403
+ }
404
+ }
405
+ return { cwd, files, syncedTo: target }
177
406
  }
178
407
 
179
408
  /**
180
- * Retrofit `cwd` with the four agent-discovery files. Pure enough to unit-test (no argv, no process.exit,
409
+ * Retrofit `cwd` with the agent files. Pure enough to unit-test (no argv, no process.exit,
181
410
  * no console) - the CLI wrapper handles printing + the exit code.
182
411
  */
183
412
  export async function initAgents(
@@ -185,20 +414,45 @@ export async function initAgents(
185
414
  opts: InitAgentsOptions = {},
186
415
  ): Promise<InitAgentsResult> {
187
416
  const force = opts.force ?? false
188
- // Order: the two MCP registries + CLAUDE.md (owned, no-clobber), then AGENTS.md (additive).
417
+ if (opts.syncMcp === true) {
418
+ // --force rewrites whole files; --sync-mcp exists to promise it will not. Combined, one of the
419
+ // two promises breaks, so refuse rather than pick.
420
+ if (force) throw new Error("[nifra] --sync-mcp cannot be combined with --force")
421
+ return syncMcpPins(cwd)
422
+ }
423
+ // Order: the two MCP registries and each agent's pointer (owned, no-clobber), then AGENTS.md (additive).
424
+ const member = await workspaceMember(cwd)
425
+ const registry = member === undefined ? mcpJson() : nameMember(mcpJson(), member).text
189
426
  const files: InitAgentsFileResult[] = []
190
- files.push(await writeOwned(cwd, MCP_JSON_PATH, mcpJson(), force))
191
- files.push(await writeOwned(cwd, CURSOR_MCP_JSON_PATH, mcpJson(), force))
192
- files.push(await writeOwned(cwd, CLAUDE_MD_PATH, claudeMd(), force))
427
+ files.push(await writeOwned(cwd, MCP_JSON_PATH, registry, force))
428
+ files.push(await writeOwned(cwd, CURSOR_MCP_JSON_PATH, registry, force))
429
+ for (const pointer of AGENT_POINTERS) {
430
+ files.push(await writeOwned(cwd, pointer.path, pointer.content(), force))
431
+ }
193
432
  files.push(await ensureAgentsMd(cwd))
194
- return { cwd, files }
433
+ // A file the run left alone may still launch an old CLI. Say so where the file is reported, with
434
+ // the command that fixes only that. Advisory: a file it cannot read costs the hint, not the run.
435
+ const { target, files: stale } = await collectStaleMcpPins(cwd).catch(() => ({
436
+ target: "",
437
+ files: [] as readonly StaleMcpPin[],
438
+ }))
439
+ return {
440
+ cwd,
441
+ files: files.map((file) => {
442
+ const pin = stale.find((entry) => entry.path === file.path)
443
+ if (pin === undefined || (file.action !== "skipped" && file.action !== "present")) return file
444
+ const hint = `pinned to ${pin.pinned.join(", ")}; \`nifra init-agents --sync-mcp\` re-pins it to ${target}`
445
+ return { ...file, note: file.note === undefined ? hint : `${file.note}; ${hint}` }
446
+ }),
447
+ }
195
448
  }
196
449
 
197
450
  const ACTION_GLYPH: Readonly<Record<InitAgentsFileResult["action"], string>> = {
198
451
  wrote: "✓ wrote",
199
- appended: "✓ appended MCP section to",
452
+ appended: "✓ appended to",
200
453
  skipped: "• skipped",
201
454
  present: "• kept",
455
+ synced: "✓ re-pinned",
202
456
  }
203
457
 
204
458
  /** Format the retrofit result for the terminal. */
@@ -207,6 +461,12 @@ export function renderInitAgents(result: InitAgentsResult): string {
207
461
  const tail = f.note ? ` (${f.note})` : ""
208
462
  return ` ${ACTION_GLYPH[f.action]} ${f.path}${tail}`
209
463
  })
464
+ if (result.syncedTo !== undefined) {
465
+ const footer = result.files.some((f) => f.action === "synced")
466
+ ? `\nThe nifra MCP launch now pins @nifrajs/cli@${result.syncedTo}. Restart your agent so it respawns the server.`
467
+ : `\nNothing to re-pin - no file pins a version other than @nifrajs/cli@${result.syncedTo}.`
468
+ return `nifra init-agents --sync-mcp\n\n${lines.join("\n")}\n${footer}`
469
+ }
210
470
  const wroteAny = result.files.some((f) => f.action === "wrote" || f.action === "appended")
211
471
  const footer = wroteAny
212
472
  ? "\nThe nifra MCP is now registered. Restart your agent so it picks up .mcp.json, then prefer nifra_docs / nifra_example and gate on nifra check."
@@ -221,9 +481,12 @@ export function renderInitAgents(result: InitAgentsResult): string {
221
481
  */
222
482
  export async function runInitAgents(
223
483
  cwd: string,
224
- opts: { readonly json?: boolean; readonly force?: boolean } = {},
484
+ opts: { readonly json?: boolean; readonly force?: boolean; readonly syncMcp?: boolean } = {},
225
485
  ): Promise<boolean> {
226
- const result = await initAgents(cwd, { force: opts.force ?? false })
486
+ const result = await initAgents(cwd, {
487
+ force: opts.force ?? false,
488
+ syncMcp: opts.syncMcp ?? false,
489
+ })
227
490
  if (opts.json) {
228
491
  console.log(JSON.stringify(result, null, 2))
229
492
  } else {
@@ -0,0 +1,5 @@
1
+ /** Code-unit order, the same on every runtime and in every locale. A file a project commits (a lock, a
2
+ * generated SDK) sorts with this, so its bytes do not depend on the machine that wrote it. */
3
+ export function codeUnitOrder(a: string, b: string): number {
4
+ return a < b ? -1 : a > b ? 1 : 0
5
+ }
@@ -0,0 +1,11 @@
1
+ import type * as TSApi from "typescript"
2
+
3
+ /**
4
+ * The parser mode for a source file, by its extension. A `.ts` file is not parsed as TSX: there
5
+ * `<T>(x: T) => x` and `<Type>value` read as JSX, and parser recovery loses what follows them.
6
+ */
7
+ export function scriptKindOf(ts: typeof TSApi, file: string): TSApi.ScriptKind {
8
+ if (file.endsWith(".tsx")) return ts.ScriptKind.TSX
9
+ if (/\.[cm]?ts$/.test(file)) return ts.ScriptKind.TS
10
+ return file.endsWith(".jsx") ? ts.ScriptKind.JSX : ts.ScriptKind.JS
11
+ }
@@ -1,4 +1,5 @@
1
1
  import type * as TSApi from "typescript"
2
+ import { scriptKindOf } from "./script-kind.ts"
2
3
  import type { TypeScriptApi } from "./typescript-import.ts"
3
4
 
4
5
  /**
@@ -19,7 +20,22 @@ export interface SourceFacts {
19
20
  method: string,
20
21
  path: string,
21
22
  ): boolean | undefined
23
+ /** A call to the free function `callee` whose argument at `pathIndex` is the literal `path`. */
24
+ isFunctionRouteCallAt(
25
+ source: TSApi.SourceFile,
26
+ position: number,
27
+ callee: string,
28
+ pathIndex: number,
29
+ path: string,
30
+ ): boolean | undefined
22
31
  isResponseSyntaxAt(source: TSApi.SourceFile, position: number): boolean | undefined
32
+ /** Whether the `import` keyword at `position` starts a runtime `import(specifier)` call (true) or a
33
+ * type-position `import("…")` such as `typeof import("x")` (false); undefined when neither. */
34
+ isDynamicImportAt(
35
+ source: TSApi.SourceFile,
36
+ position: number,
37
+ specifier: string,
38
+ ): boolean | undefined
23
39
  }
24
40
 
25
41
  function nodeAt(ts: TypeScriptApi, source: TSApi.SourceFile, position: number): TSApi.Node {
@@ -33,12 +49,12 @@ function nodeAt(ts: TypeScriptApi, source: TSApi.SourceFile, position: number):
33
49
  return best
34
50
  }
35
51
 
36
- function ancestorAt(
52
+ function ancestorAt<T extends TSApi.Node>(
37
53
  ts: TypeScriptApi,
38
54
  source: TSApi.SourceFile,
39
55
  position: number,
40
- predicate: (node: TSApi.Node) => boolean,
41
- ): TSApi.Node | undefined {
56
+ predicate: (node: TSApi.Node) => node is T,
57
+ ): T | undefined {
42
58
  let node: TSApi.Node = nodeAt(ts, source, Math.max(0, Math.min(position, source.end - 1)))
43
59
  for (;;) {
44
60
  if (predicate(node)) return node
@@ -65,12 +81,19 @@ export function createSourceFacts(ts: TypeScriptApi): SourceFacts {
65
81
  const parse = (file: string, content: string): TSApi.SourceFile | undefined => {
66
82
  const cached = cache.get(file)
67
83
  if (cached?.content === content) return cached.source
68
- const kind = /\.tsx?$/.test(file) ? ts.ScriptKind.TSX : ts.ScriptKind.JS
69
- const source = ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true, kind)
70
- const diagnostics = (
71
- source as TSApi.SourceFile & { parseDiagnostics?: readonly TSApi.Diagnostic[] }
72
- ).parseDiagnostics
73
- const parsed = diagnostics !== undefined && diagnostics.length > 0 ? undefined : source
84
+ const kind = scriptKindOf(ts, file)
85
+ let parsed: TSApi.SourceFile | undefined
86
+ try {
87
+ const source = ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true, kind)
88
+ const diagnostics = (
89
+ source as TSApi.SourceFile & { parseDiagnostics?: readonly TSApi.Diagnostic[] }
90
+ ).parseDiagnostics
91
+ parsed = diagnostics !== undefined && diagnostics.length > 0 ? undefined : source
92
+ } catch {
93
+ // TypeScript 7 parses only the files its session preloaded; a module reached by following an
94
+ // import may not be one. Callers fall back to the lexical rule for a file with no tree.
95
+ parsed = undefined
96
+ }
74
97
  cache.set(file, { content, source: parsed })
75
98
  return parsed
76
99
  }
@@ -80,9 +103,18 @@ export function createSourceFacts(ts: TypeScriptApi): SourceFacts {
80
103
  position: number,
81
104
  specifier: string,
82
105
  ): boolean | undefined => {
83
- const declaration = ancestorAt(ts, source, position, ts.isImportDeclaration) as
84
- | TSApi.ImportDeclaration
85
- | undefined
106
+ const reexport = ancestorAt(ts, source, position, ts.isExportDeclaration)
107
+ if (reexport !== undefined) {
108
+ const from = reexport.moduleSpecifier
109
+ if (from === undefined || !ts.isStringLiteral(from) || from.text !== specifier)
110
+ return undefined
111
+ if (reexport.isTypeOnly) return false
112
+ const named = reexport.exportClause
113
+ if (named !== undefined && ts.isNamedExports(named))
114
+ return named.elements.some((element) => !element.isTypeOnly)
115
+ return true // `export *` and `export * as ns`
116
+ }
117
+ const declaration = ancestorAt(ts, source, position, ts.isImportDeclaration)
86
118
  if (declaration === undefined || !ts.isStringLiteral(declaration.moduleSpecifier))
87
119
  return undefined
88
120
  if (declaration.moduleSpecifier.text !== specifier) return undefined
@@ -101,9 +133,7 @@ export function createSourceFacts(ts: TypeScriptApi): SourceFacts {
101
133
  method: string,
102
134
  path: string,
103
135
  ): boolean | undefined => {
104
- const call = ancestorAt(ts, source, position, ts.isCallExpression) as
105
- | TSApi.CallExpression
106
- | undefined
136
+ const call = ancestorAt(ts, source, position, ts.isCallExpression)
107
137
  if (call === undefined || !ts.isPropertyAccessExpression(call.expression)) return false
108
138
  const name = call.expression.name.text.toUpperCase()
109
139
  const first = call.arguments[0]
@@ -112,22 +142,66 @@ export function createSourceFacts(ts: TypeScriptApi): SourceFacts {
112
142
  )
113
143
  }
114
144
 
145
+ const isFunctionRouteCallAt = (
146
+ source: TSApi.SourceFile,
147
+ position: number,
148
+ callee: string,
149
+ pathIndex: number,
150
+ path: string,
151
+ ): boolean | undefined => {
152
+ const call = ancestorAt(ts, source, position, ts.isCallExpression)
153
+ if (call === undefined || !ts.isIdentifier(call.expression)) return false
154
+ const argument = call.arguments[pathIndex]
155
+ return (
156
+ call.expression.text === callee &&
157
+ argument !== undefined &&
158
+ ts.isStringLiteralLike(argument) &&
159
+ argument.text === path
160
+ )
161
+ }
162
+
115
163
  const isResponseSyntaxAt = (source: TSApi.SourceFile, position: number): boolean | undefined => {
116
- const returnStatement = ancestorAt(ts, source, position, ts.isReturnStatement) as
117
- | TSApi.ReturnStatement
118
- | undefined
164
+ const returnStatement = ancestorAt(ts, source, position, ts.isReturnStatement)
119
165
  if (returnStatement !== undefined) {
120
166
  return (
121
167
  returnStatement.expression !== undefined &&
122
168
  rawResponseExpression(ts, returnStatement.expression)
123
169
  )
124
170
  }
125
- const arrow = ancestorAt(ts, source, position, ts.isArrowFunction) as
126
- | TSApi.ArrowFunction
127
- | undefined
171
+ const arrow = ancestorAt(ts, source, position, ts.isArrowFunction)
128
172
  if (arrow !== undefined && !ts.isBlock(arrow.body)) return rawResponseExpression(ts, arrow.body)
129
173
  return false
130
174
  }
131
175
 
132
- return { parse, isValueImportAt, isRouteRegistrationAt, isResponseSyntaxAt }
176
+ const isDynamicImportAt = (
177
+ source: TSApi.SourceFile,
178
+ position: number,
179
+ specifier: string,
180
+ ): boolean | undefined => {
181
+ let node: TSApi.Node = nodeAt(ts, source, Math.max(0, Math.min(position, source.end - 1)))
182
+ for (;;) {
183
+ if (ts.isImportTypeNode(node)) return false
184
+ if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) {
185
+ const argument = node.arguments[0]
186
+ if (
187
+ argument === undefined ||
188
+ !(ts.isStringLiteral(argument) || ts.isNoSubstitutionTemplateLiteral(argument))
189
+ ) {
190
+ return undefined
191
+ }
192
+ return argument.text === specifier ? true : undefined
193
+ }
194
+ if (node === source) return undefined
195
+ node = node.parent
196
+ }
197
+ }
198
+
199
+ return {
200
+ parse,
201
+ isValueImportAt,
202
+ isRouteRegistrationAt,
203
+ isFunctionRouteCallAt,
204
+ isResponseSyntaxAt,
205
+ isDynamicImportAt,
206
+ }
133
207
  }