@nifrajs/cli 4.0.2 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (323) hide show
  1. package/README.md +3 -0
  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 +7 -2
  12. package/dist/assure-hydration.js.map +1 -1
  13. package/dist/capabilities-tool.d.ts.map +1 -1
  14. package/dist/capabilities-tool.js +13 -3
  15. package/dist/capabilities-tool.js.map +1 -1
  16. package/dist/check-diagnostics.d.ts +20 -2
  17. package/dist/check-diagnostics.d.ts.map +1 -1
  18. package/dist/check-diagnostics.js +54 -7
  19. package/dist/check-diagnostics.js.map +1 -1
  20. package/dist/check-scan.d.ts +75 -22
  21. package/dist/check-scan.d.ts.map +1 -1
  22. package/dist/check-scan.js +1475 -310
  23. package/dist/check-scan.js.map +1 -1
  24. package/dist/check.d.ts +2 -2
  25. package/dist/check.d.ts.map +1 -1
  26. package/dist/check.js +192 -65
  27. package/dist/check.js.map +1 -1
  28. package/dist/cli.d.ts +7 -0
  29. package/dist/cli.d.ts.map +1 -1
  30. package/dist/cli.js +112 -5
  31. package/dist/cli.js.map +1 -1
  32. package/dist/client-module.d.ts +18 -0
  33. package/dist/client-module.d.ts.map +1 -0
  34. package/dist/client-module.js +49 -0
  35. package/dist/client-module.js.map +1 -0
  36. package/dist/command-catalog.d.ts +16 -5
  37. package/dist/command-catalog.d.ts.map +1 -1
  38. package/dist/command-catalog.js +133 -29
  39. package/dist/command-catalog.js.map +1 -1
  40. package/dist/component-check.d.ts +13 -0
  41. package/dist/component-check.d.ts.map +1 -0
  42. package/dist/component-check.js +59 -0
  43. package/dist/component-check.js.map +1 -0
  44. package/dist/contract-proof.d.ts +1 -1
  45. package/dist/contract-proof.d.ts.map +1 -1
  46. package/dist/contract-proof.js +2 -24
  47. package/dist/contract-proof.js.map +1 -1
  48. package/dist/contracts.d.ts +19 -0
  49. package/dist/contracts.d.ts.map +1 -1
  50. package/dist/contracts.js +50 -3
  51. package/dist/contracts.js.map +1 -1
  52. package/dist/db-tool.d.ts.map +1 -1
  53. package/dist/db-tool.js +4 -3
  54. package/dist/db-tool.js.map +1 -1
  55. package/dist/dev-bun-config.d.ts +5 -1
  56. package/dist/dev-bun-config.d.ts.map +1 -1
  57. package/dist/dev-bun-config.js +44 -11
  58. package/dist/dev-bun-config.js.map +1 -1
  59. package/dist/diagnostics.d.ts +3 -0
  60. package/dist/diagnostics.d.ts.map +1 -1
  61. package/dist/diagnostics.js.map +1 -1
  62. package/dist/diff-tool.d.ts +9 -8
  63. package/dist/diff-tool.d.ts.map +1 -1
  64. package/dist/diff-tool.js +28 -24
  65. package/dist/diff-tool.js.map +1 -1
  66. package/dist/doctor.d.ts +13 -0
  67. package/dist/doctor.d.ts.map +1 -1
  68. package/dist/doctor.js +23 -15
  69. package/dist/doctor.js.map +1 -1
  70. package/dist/env-file.d.ts +6 -0
  71. package/dist/env-file.d.ts.map +1 -1
  72. package/dist/env-file.js +6 -0
  73. package/dist/env-file.js.map +1 -1
  74. package/dist/fix-recipes.d.ts.map +1 -1
  75. package/dist/fix-recipes.js +21 -2
  76. package/dist/fix-recipes.js.map +1 -1
  77. package/dist/hand-built-manifest.d.ts +27 -0
  78. package/dist/hand-built-manifest.d.ts.map +1 -0
  79. package/dist/hand-built-manifest.js +124 -0
  80. package/dist/hand-built-manifest.js.map +1 -0
  81. package/dist/hooks-report.d.ts +23 -0
  82. package/dist/hooks-report.d.ts.map +1 -0
  83. package/dist/hooks-report.js +104 -0
  84. package/dist/hooks-report.js.map +1 -0
  85. package/dist/init-agents.d.ts +2 -2
  86. package/dist/init-agents.d.ts.map +1 -1
  87. package/dist/init-agents.js +17 -11
  88. package/dist/init-agents.js.map +1 -1
  89. package/dist/internal/nifra-project.d.ts +41 -0
  90. package/dist/internal/nifra-project.d.ts.map +1 -0
  91. package/dist/internal/nifra-project.js +183 -0
  92. package/dist/internal/nifra-project.js.map +1 -0
  93. package/dist/internal/project-specifiers.d.ts +16 -0
  94. package/dist/internal/project-specifiers.d.ts.map +1 -0
  95. package/dist/internal/project-specifiers.js +396 -0
  96. package/dist/internal/project-specifiers.js.map +1 -0
  97. package/dist/internal/script-kind.d.ts.map +1 -1
  98. package/dist/internal/script-kind.js +2 -1
  99. package/dist/internal/script-kind.js.map +1 -1
  100. package/dist/internal/source-facts.d.ts +2 -0
  101. package/dist/internal/source-facts.d.ts.map +1 -1
  102. package/dist/internal/source-facts.js +11 -0
  103. package/dist/internal/source-facts.js.map +1 -1
  104. package/dist/internal/typescript-import.d.ts +1 -1
  105. package/dist/internal/typescript-import.d.ts.map +1 -1
  106. package/dist/internal/typescript-import.js +5 -3
  107. package/dist/internal/typescript-import.js.map +1 -1
  108. package/dist/introspect.d.ts +6 -6
  109. package/dist/introspect.d.ts.map +1 -1
  110. package/dist/introspect.js +55 -24
  111. package/dist/introspect.js.map +1 -1
  112. package/dist/levels-tool.d.ts.map +1 -1
  113. package/dist/levels-tool.js +93 -15
  114. package/dist/levels-tool.js.map +1 -1
  115. package/dist/load.d.ts +31 -8
  116. package/dist/load.d.ts.map +1 -1
  117. package/dist/load.js +37 -7
  118. package/dist/load.js.map +1 -1
  119. package/dist/mcp-context.d.ts +5 -5
  120. package/dist/mcp-context.d.ts.map +1 -1
  121. package/dist/mcp-context.js +2 -2
  122. package/dist/mcp-context.js.map +1 -1
  123. package/dist/mcp-exec.d.ts +12 -4
  124. package/dist/mcp-exec.d.ts.map +1 -1
  125. package/dist/mcp-exec.js +50 -13
  126. package/dist/mcp-exec.js.map +1 -1
  127. package/dist/mcp-http.js +1 -1
  128. package/dist/mcp-io.d.ts +17 -0
  129. package/dist/mcp-io.d.ts.map +1 -1
  130. package/dist/mcp-io.js +40 -0
  131. package/dist/mcp-io.js.map +1 -1
  132. package/dist/mcp-isolate.d.ts.map +1 -1
  133. package/dist/mcp-isolate.js +3 -2
  134. package/dist/mcp-isolate.js.map +1 -1
  135. package/dist/mcp-project-child.js +1 -1
  136. package/dist/mcp-project-child.js.map +1 -1
  137. package/dist/mcp-root.d.ts.map +1 -1
  138. package/dist/mcp-root.js +50 -10
  139. package/dist/mcp-root.js.map +1 -1
  140. package/dist/mcp-ws.d.ts.map +1 -1
  141. package/dist/mcp-ws.js +3 -1
  142. package/dist/mcp-ws.js.map +1 -1
  143. package/dist/mcp.d.ts.map +1 -1
  144. package/dist/mcp.js +8 -2
  145. package/dist/mcp.js.map +1 -1
  146. package/dist/migrate-layout.d.ts +7 -0
  147. package/dist/migrate-layout.d.ts.map +1 -1
  148. package/dist/migrate-layout.js +778 -64
  149. package/dist/migrate-layout.js.map +1 -1
  150. package/dist/output-schemas.d.ts +94 -0
  151. package/dist/output-schemas.d.ts.map +1 -0
  152. package/dist/output-schemas.js +864 -0
  153. package/dist/output-schemas.js.map +1 -0
  154. package/dist/port.d.ts.map +1 -1
  155. package/dist/port.js +3 -2
  156. package/dist/port.js.map +1 -1
  157. package/dist/project-facts.d.ts +15 -4
  158. package/dist/project-facts.d.ts.map +1 -1
  159. package/dist/project-facts.js +1 -0
  160. package/dist/project-facts.js.map +1 -1
  161. package/dist/recipes/5.0.0.d.ts +11 -0
  162. package/dist/recipes/5.0.0.d.ts.map +1 -0
  163. package/dist/recipes/5.0.0.js +34 -0
  164. package/dist/recipes/5.0.0.js.map +1 -0
  165. package/dist/recipes/index.d.ts +7 -0
  166. package/dist/recipes/index.d.ts.map +1 -1
  167. package/dist/recipes/index.js +2 -0
  168. package/dist/recipes/index.js.map +1 -1
  169. package/dist/replay.d.ts.map +1 -1
  170. package/dist/replay.js +12 -3
  171. package/dist/replay.js.map +1 -1
  172. package/dist/review.d.ts +3 -1
  173. package/dist/review.d.ts.map +1 -1
  174. package/dist/review.js +38 -7
  175. package/dist/review.js.map +1 -1
  176. package/dist/rules/codes.d.ts +10 -0
  177. package/dist/rules/codes.d.ts.map +1 -1
  178. package/dist/rules/codes.js +10 -0
  179. package/dist/rules/codes.js.map +1 -1
  180. package/dist/rules/comment-markers.d.ts +16 -7
  181. package/dist/rules/comment-markers.d.ts.map +1 -1
  182. package/dist/rules/comment-markers.js +58 -19
  183. package/dist/rules/comment-markers.js.map +1 -1
  184. package/dist/rules/contract-visibility.d.ts +3 -0
  185. package/dist/rules/contract-visibility.d.ts.map +1 -0
  186. package/dist/rules/contract-visibility.js +337 -0
  187. package/dist/rules/contract-visibility.js.map +1 -0
  188. package/dist/rules/data-guard.d.ts +7 -0
  189. package/dist/rules/data-guard.d.ts.map +1 -1
  190. package/dist/rules/data-guard.js +319 -37
  191. package/dist/rules/data-guard.js.map +1 -1
  192. package/dist/rules/layout.d.ts +4 -0
  193. package/dist/rules/layout.d.ts.map +1 -0
  194. package/dist/rules/layout.js +19 -0
  195. package/dist/rules/layout.js.map +1 -0
  196. package/dist/rules/legacy.d.ts.map +1 -1
  197. package/dist/rules/legacy.js +158 -43
  198. package/dist/rules/legacy.js.map +1 -1
  199. package/dist/rules/pages.d.ts +24 -0
  200. package/dist/rules/pages.d.ts.map +1 -1
  201. package/dist/rules/pages.js +221 -3
  202. package/dist/rules/pages.js.map +1 -1
  203. package/dist/rules/routes.d.ts +6 -0
  204. package/dist/rules/routes.d.ts.map +1 -1
  205. package/dist/rules/routes.js +111 -16
  206. package/dist/rules/routes.js.map +1 -1
  207. package/dist/rules/secrets.js +2 -2
  208. package/dist/rules/secrets.js.map +1 -1
  209. package/dist/rules/security.d.ts +12 -1
  210. package/dist/rules/security.d.ts.map +1 -1
  211. package/dist/rules/security.js +281 -68
  212. package/dist/rules/security.js.map +1 -1
  213. package/dist/rules/zones.d.ts +13 -0
  214. package/dist/rules/zones.d.ts.map +1 -1
  215. package/dist/rules/zones.js +192 -40
  216. package/dist/rules/zones.js.map +1 -1
  217. package/dist/scaffold.d.ts.map +1 -1
  218. package/dist/scaffold.js +6 -4
  219. package/dist/scaffold.js.map +1 -1
  220. package/dist/sdk.d.ts.map +1 -1
  221. package/dist/sdk.js +14 -5
  222. package/dist/sdk.js.map +1 -1
  223. package/dist/stylex-migrate.d.ts.map +1 -1
  224. package/dist/stylex-migrate.js +163 -7
  225. package/dist/stylex-migrate.js.map +1 -1
  226. package/dist/test-tool.d.ts.map +1 -1
  227. package/dist/test-tool.js +12 -3
  228. package/dist/test-tool.js.map +1 -1
  229. package/dist/typecheck-coverage.d.ts +20 -0
  230. package/dist/typecheck-coverage.d.ts.map +1 -0
  231. package/dist/typecheck-coverage.js +129 -0
  232. package/dist/typecheck-coverage.js.map +1 -0
  233. package/dist/upgrade.d.ts +69 -0
  234. package/dist/upgrade.d.ts.map +1 -1
  235. package/dist/upgrade.js +420 -77
  236. package/dist/upgrade.js.map +1 -1
  237. package/dist/verification-plan.d.ts.map +1 -1
  238. package/dist/verification-plan.js +11 -0
  239. package/dist/verification-plan.js.map +1 -1
  240. package/dist/version-drift.d.ts +22 -0
  241. package/dist/version-drift.d.ts.map +1 -0
  242. package/dist/version-drift.js +42 -0
  243. package/dist/version-drift.js.map +1 -0
  244. package/dist/web-app-options.d.ts +1 -1
  245. package/dist/web-app-options.d.ts.map +1 -1
  246. package/dist/web-app-options.js +1 -1
  247. package/dist/web-app-options.js.map +1 -1
  248. package/dist/work-graph.d.ts.map +1 -1
  249. package/dist/work-graph.js +20 -8
  250. package/dist/work-graph.js.map +1 -1
  251. package/docs/examples.json +54 -6
  252. package/docs/llms-full.txt +1539 -138
  253. package/docs/types.json +1456 -206
  254. package/package.json +11 -12
  255. package/src/agent-files.ts +203 -0
  256. package/src/app-files.ts +34 -4
  257. package/src/assure-hydration.ts +15 -2
  258. package/src/capabilities-tool.ts +15 -3
  259. package/src/check-diagnostics.ts +73 -9
  260. package/src/check-scan.ts +1773 -381
  261. package/src/check.ts +237 -76
  262. package/src/cli.ts +141 -5
  263. package/src/client-module.ts +61 -0
  264. package/src/command-catalog.ts +166 -37
  265. package/src/component-check.ts +79 -0
  266. package/src/contract-proof.ts +3 -25
  267. package/src/contracts.ts +60 -3
  268. package/src/db-tool.ts +5 -2
  269. package/src/dev-bun-config.ts +51 -10
  270. package/src/diagnostics.ts +3 -0
  271. package/src/diff-tool.ts +33 -26
  272. package/src/doctor.ts +43 -21
  273. package/src/env-file.ts +7 -0
  274. package/src/fix-recipes.ts +25 -2
  275. package/src/hand-built-manifest.ts +144 -0
  276. package/src/hooks-report.ts +135 -0
  277. package/src/init-agents.ts +17 -11
  278. package/src/internal/nifra-project.ts +191 -0
  279. package/src/internal/project-specifiers.ts +424 -0
  280. package/src/internal/script-kind.ts +2 -1
  281. package/src/internal/source-facts.ts +25 -0
  282. package/src/internal/typescript-import.ts +10 -3
  283. package/src/introspect.ts +65 -27
  284. package/src/levels-tool.ts +117 -20
  285. package/src/load.ts +78 -14
  286. package/src/mcp-context.ts +7 -7
  287. package/src/mcp-exec.ts +60 -17
  288. package/src/mcp-http.ts +1 -1
  289. package/src/mcp-io.ts +41 -0
  290. package/src/mcp-isolate.ts +9 -2
  291. package/src/mcp-project-child.ts +1 -1
  292. package/src/mcp-root.ts +58 -8
  293. package/src/mcp-ws.ts +4 -2
  294. package/src/mcp.ts +8 -3
  295. package/src/migrate-layout.ts +885 -63
  296. package/src/output-schemas.ts +1084 -0
  297. package/src/port.ts +3 -2
  298. package/src/project-facts.ts +17 -3
  299. package/src/recipes/5.0.0.ts +35 -0
  300. package/src/recipes/index.ts +10 -0
  301. package/src/replay.ts +14 -3
  302. package/src/review.ts +39 -10
  303. package/src/rules/codes.ts +10 -0
  304. package/src/rules/comment-markers.ts +57 -21
  305. package/src/rules/contract-visibility.ts +383 -0
  306. package/src/rules/data-guard.ts +351 -42
  307. package/src/rules/layout.ts +22 -0
  308. package/src/rules/legacy.ts +184 -51
  309. package/src/rules/pages.ts +238 -3
  310. package/src/rules/routes.ts +124 -16
  311. package/src/rules/secrets.ts +2 -2
  312. package/src/rules/security.ts +352 -71
  313. package/src/rules/zones.ts +205 -38
  314. package/src/scaffold.ts +6 -4
  315. package/src/sdk.ts +14 -5
  316. package/src/stylex-migrate.ts +134 -7
  317. package/src/test-tool.ts +18 -3
  318. package/src/typecheck-coverage.ts +134 -0
  319. package/src/upgrade.ts +487 -71
  320. package/src/verification-plan.ts +24 -0
  321. package/src/version-drift.ts +67 -0
  322. package/src/web-app-options.ts +2 -2
  323. package/src/work-graph.ts +17 -12
package/dist/upgrade.js CHANGED
@@ -5,6 +5,8 @@
5
5
  *
6
6
  * 1. **pin sweep** - set every matching dependency to the target version across the workspace's
7
7
  * package.json files, preserving the caret/tilde/exact style and skipping `workspace:`/`link:` specs.
8
+ * `overrides`, `resolutions` and `pnpm.overrides` are swept too, since they decide what installs;
9
+ * a version pinned anywhere else (an MCP config, a Dockerfile, another lockfile) is listed only.
8
10
  * 2. **dependency moves** - replace removed packages with their supported successor without
9
11
  * reserializing package.json or leaving duplicate dependency keys.
10
12
  * 3. **import moves** - rewrite exact import specifiers to their updated module paths.
@@ -21,10 +23,13 @@
21
23
  * Deliberately NOT a codemod engine: transforms are string/specifier-level only. Structural (AST)
22
24
  * transforms are a future addition - a recipe that needs one is the signal to add the engine, not before.
23
25
  */
24
- import { readFileSync, writeFileSync } from "node:fs";
25
- import { join, relative } from "node:path";
26
+ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync, } from "node:fs";
27
+ import { dirname, join, relative, sep } from "node:path";
28
+ import { fileURLToPath } from "node:url";
26
29
  import { Glob } from "bun";
30
+ import { collectHookReport, diffHookReports, HOOKS_BEFORE_UPGRADE_FILE, parseHookReport, renderHookDiff, } from "./hooks-report.js";
27
31
  import { getRecipe, listRecipeVersions } from "./recipes/index.js";
32
+ import { markerTarget } from "./rules/comment-markers.js";
28
33
  const DEP_FIELDS = [
29
34
  "dependencies",
30
35
  "devDependencies",
@@ -131,71 +136,155 @@ export function pinSweepText(text, rules, allowDowngrade = false, exact = false)
131
136
  let out = text;
132
137
  const changes = [];
133
138
  const downgrades = [];
139
+ for (const { path, key, target: name, spec: rawSpec } of pinEntries(parsed)) {
140
+ const field = path.join(".");
141
+ const rule = rules.find((r) => name === r.match || (r.match.endsWith("/") && name.startsWith(r.match)));
142
+ if (!rule)
143
+ continue;
144
+ const next = rewriteVersionSpec(rawSpec, rule.to, exact);
145
+ if (next === null)
146
+ continue;
147
+ // Refuse a rollback: `upgrade <version>` pins an exact target, so an OLD target on a newer install
148
+ // walks dependencies backward (e.g. ^2.3.0 → ^2.0.0) and can break a shared-package peer range.
149
+ const current = specVersionTuple(rawSpec);
150
+ const target = specVersionTuple(rule.to);
151
+ if (!allowDowngrade &&
152
+ current !== null &&
153
+ target !== null &&
154
+ compareSemverSpec(rawSpec, rule.to) > 0) {
155
+ downgrades.push({ field, name: key, from: rawSpec, to: next });
156
+ continue;
157
+ }
158
+ // Replace exactly `"key"<ws>:<ws>"spec"` inside this field's object, preserving the whitespace. A
159
+ // nested `overrides` entry repeated with the same spec maps the same way, so every one is replaced.
160
+ const range = objectRangeAt(out, path);
161
+ if (range === undefined)
162
+ continue;
163
+ const pattern = new RegExp(`("${escapeRegExp(key)}"\\s*:\\s*")${escapeRegExp(rawSpec)}(")`, "g");
164
+ const body = out.slice(range.start, range.end);
165
+ const replaced = body.replace(pattern, `$1${next}$2`);
166
+ if (replaced !== body) {
167
+ out = out.slice(0, range.start) + replaced + out.slice(range.end);
168
+ changes.push({ field, name: key, from: rawSpec, to: next });
169
+ }
170
+ }
171
+ return { text: out, changes, downgrades };
172
+ }
173
+ /** Where a package manager forces one version over every range: npm and Bun `overrides` (nested by
174
+ * parent), Yarn and Bun `resolutions`, pnpm `pnpm.overrides`. */
175
+ const OVERRIDE_FIELDS = [
176
+ ["overrides"],
177
+ ["resolutions"],
178
+ ["pnpm", "overrides"],
179
+ ];
180
+ /**
181
+ * The package an override key pins: `@nifrajs/core`, `parent/@nifrajs/core` and the same behind a `**`
182
+ * glob (resolutions), `parent>@nifrajs/core` and `@nifrajs/core@<4` (pnpm).
183
+ */
184
+ function overrideTarget(key) {
185
+ const last = key.slice(key.lastIndexOf(">") + 1);
186
+ return /(?:^|\/)((?:@[^/@]+\/)?[^/@]+)(?:@[^/]*)?$/.exec(last)?.[1] ?? last;
187
+ }
188
+ /** Every string version spec a package.json declares for a package, with the field path it sits in. */
189
+ function pinEntries(parsed) {
190
+ const entries = [];
134
191
  for (const field of DEP_FIELDS) {
135
192
  const deps = parsed[field];
136
193
  if (typeof deps !== "object" || deps === null)
137
194
  continue;
138
- for (const [name, rawSpec] of Object.entries(deps)) {
139
- if (typeof rawSpec !== "string")
140
- continue;
141
- const rule = rules.find((r) => name === r.match || (r.match.endsWith("/") && name.startsWith(r.match)));
142
- if (!rule)
143
- continue;
144
- const next = rewriteVersionSpec(rawSpec, rule.to, exact);
145
- if (next === null)
146
- continue;
147
- // Refuse a rollback: `upgrade <version>` pins an exact target, so an OLD target on a newer install
148
- // walks dependencies backward (e.g. ^2.3.0 → ^2.0.0) and can break a shared-package peer range.
149
- const current = specVersionTuple(rawSpec);
150
- const target = specVersionTuple(rule.to);
151
- if (!allowDowngrade &&
152
- current !== null &&
153
- target !== null &&
154
- compareSemverSpec(rawSpec, rule.to) > 0) {
155
- downgrades.push({ field, name, from: rawSpec, to: next });
156
- continue;
195
+ for (const [name, spec] of Object.entries(deps))
196
+ if (typeof spec === "string")
197
+ entries.push({ path: [field], key: name, target: name, spec });
198
+ }
199
+ for (const path of OVERRIDE_FIELDS) {
200
+ let value = parsed;
201
+ for (const key of path)
202
+ value = typeof value === "object" && value !== null ? Reflect.get(value, key) : undefined;
203
+ // npm nests an override under the package whose dependency it pins; `.` pins that package itself.
204
+ const walk = (object, parent) => {
205
+ for (const [key, spec] of Object.entries(object)) {
206
+ const target = key === "." ? parent : overrideTarget(key);
207
+ if (typeof spec === "string" && target !== undefined)
208
+ entries.push({ path, key, target, spec });
209
+ else if (typeof spec === "object" && spec !== null && !Array.isArray(spec))
210
+ walk(spec, target);
157
211
  }
158
- // Replace exactly `"name"<ws>:<ws>"spec"`, preserving the surrounding whitespace.
159
- const pattern = new RegExp(`("${escapeRegExp(name)}"\\s*:\\s*")${escapeRegExp(rawSpec)}(")`);
160
- const replaced = out.replace(pattern, `$1${next}$2`);
161
- if (replaced !== out) {
162
- out = replaced;
163
- changes.push({ field, name, from: rawSpec, to: next });
164
- }
165
- }
212
+ };
213
+ if (typeof value === "object" && value !== null && !Array.isArray(value))
214
+ walk(value, undefined);
166
215
  }
167
- return { text: out, changes, downgrades };
216
+ return entries;
168
217
  }
169
- function dependencyObjectRange(text, field) {
170
- const match = new RegExp(`"${field}"\\s*:\\s*\\{`).exec(text);
171
- if (match === null)
218
+ /** The body (between the braces) of the object at `path` in a JSON text: top-level key first, then
219
+ * each nested one, never a same-named key deeper in the document. */
220
+ function objectRangeAt(text, path) {
221
+ const rootOpen = text.indexOf("{");
222
+ if (rootOpen === -1)
172
223
  return undefined;
173
- const open = text.indexOf("{", match.index);
224
+ let range = {
225
+ start: rootOpen + 1,
226
+ end: closingBrace(text, rootOpen),
227
+ };
228
+ for (const key of path) {
229
+ if (range === undefined)
230
+ return undefined;
231
+ range = memberObjectRange(text, range, key);
232
+ }
233
+ return range;
234
+ }
235
+ /** The index of the `"` that closes the string opening at `open`. */
236
+ function stringEnd(text, open) {
237
+ for (let index = open + 1; index < text.length; index++) {
238
+ if (text[index] === "\\")
239
+ index++;
240
+ else if (text[index] === '"')
241
+ return index;
242
+ }
243
+ return text.length;
244
+ }
245
+ /** The index of the brace that closes the one at `open`. */
246
+ function closingBrace(text, open) {
174
247
  let depth = 0;
175
- let quoted = false;
176
- let escaped = false;
177
248
  for (let index = open; index < text.length; index++) {
178
249
  const char = text[index];
179
- if (quoted) {
180
- if (escaped)
181
- escaped = false;
182
- else if (char === "\\")
183
- escaped = true;
184
- else if (char === '"')
185
- quoted = false;
186
- continue;
187
- }
188
250
  if (char === '"')
189
- quoted = true;
190
- else if (char === "{")
251
+ index = stringEnd(text, index);
252
+ else if (char === "{" || char === "[")
253
+ depth++;
254
+ else if ((char === "}" || char === "]") && --depth === 0)
255
+ return index;
256
+ }
257
+ return text.length;
258
+ }
259
+ /** The body of `key`'s object value among the direct members of the object body `within`. */
260
+ function memberObjectRange(text, within, key) {
261
+ let depth = 0;
262
+ for (let index = within.start; index < within.end; index++) {
263
+ const char = text[index];
264
+ if (char === "{" || char === "[")
191
265
  depth++;
192
- else if (char === "}" && --depth === 0)
193
- return { start: open + 1, end: index };
266
+ else if (char === "}" || char === "]")
267
+ depth--;
268
+ else if (char === '"') {
269
+ const close = stringEnd(text, index);
270
+ let after = close + 1;
271
+ while (/\s/.test(text[after] ?? ""))
272
+ after++;
273
+ if (depth === 0 && text[after] === ":" && text.slice(index + 1, close) === key) {
274
+ after++;
275
+ while (/\s/.test(text[after] ?? ""))
276
+ after++;
277
+ return text[after] === "{"
278
+ ? { start: after + 1, end: closingBrace(text, after) }
279
+ : undefined;
280
+ }
281
+ index = close;
282
+ }
194
283
  }
195
284
  return undefined;
196
285
  }
197
286
  function rewriteDependencyEntry(text, field, from, fromVersion, to, toVersion) {
198
- const range = dependencyObjectRange(text, field);
287
+ const range = objectRangeAt(text, [field]);
199
288
  if (range === undefined)
200
289
  return text;
201
290
  const body = text.slice(range.start, range.end);
@@ -308,12 +397,14 @@ const isIgnored = (path) => path
308
397
  .split(/[\\/]/)
309
398
  .slice(0, -1)
310
399
  .some((segment) => IGNORED_DIRECTORIES.has(segment));
311
- function scan(cwd, pattern) {
400
+ function scan(cwd, pattern, dot = false) {
312
401
  const glob = new Glob(pattern);
313
402
  const out = [];
314
- for (const path of glob.scanSync({ cwd, dot: false })) {
403
+ // The write boundary: a symlinked file or directory (a linked repository) is never yielded, so the
404
+ // upgrade can only edit files that live under `cwd`.
405
+ for (const path of glob.scanSync({ cwd, dot, followSymlinks: false })) {
315
406
  if (!isIgnored(path))
316
- out.push(path);
407
+ out.push(path.replaceAll("\\", "/"));
317
408
  }
318
409
  return out.sort(); // deterministic order
319
410
  }
@@ -325,11 +416,12 @@ const groupPins = (version) => [
325
416
  { match: "nifra", to: version },
326
417
  ];
327
418
  /**
328
- * The lowest fixed-group version any package.json under `cwd` declares with a plain semver spec, or
329
- * null when none does. Recipes newer than it still have edits to make somewhere in the workspace.
419
+ * Every fixed-group version the package.json files under `cwd` declare with a plain semver spec, each
420
+ * with the files that declare it. An override counts: it decides what installs. `workspace:`, `link:`
421
+ * and other non-semver specs are not versions.
330
422
  */
331
- export function installedGroupVersion(cwd) {
332
- let lowest = null;
423
+ export function groupPinVersions(cwd) {
424
+ const versions = new Map();
333
425
  for (const rel of scan(cwd, "**/package.json")) {
334
426
  let parsed;
335
427
  try {
@@ -338,19 +430,131 @@ export function installedGroupVersion(cwd) {
338
430
  catch {
339
431
  continue;
340
432
  }
341
- for (const field of DEP_FIELDS) {
342
- const deps = parsed[field];
343
- if (typeof deps !== "object" || deps === null)
433
+ for (const { target, spec } of pinEntries(parsed)) {
434
+ if (!isFixedGroup(target))
344
435
  continue;
345
- for (const [name, spec] of Object.entries(deps)) {
346
- if (!isFixedGroup(name) || typeof spec !== "string")
347
- continue;
348
- const version = specVersion(spec);
349
- if (version !== null && (lowest === null || compareSemverSpec(version, lowest) < 0)) {
350
- lowest = version;
351
- }
352
- }
436
+ const version = specVersion(spec);
437
+ if (version === null)
438
+ continue;
439
+ const files = versions.get(version) ?? [];
440
+ if (!files.includes(rel))
441
+ files.push(rel);
442
+ versions.set(version, files);
443
+ }
444
+ }
445
+ return versions;
446
+ }
447
+ const STRAY_PIN_FILES = [
448
+ "**/{.mcp.json,mcp.json}",
449
+ "**/{Dockerfile,Dockerfile.*,*.Dockerfile,*.dockerfile,Containerfile}",
450
+ "**/{docker-compose,compose}*.{yml,yaml}",
451
+ ".github/workflows/*.{yml,yaml}",
452
+ ".gitlab-ci.yml",
453
+ "**/*.lock",
454
+ "**/{package-lock.json,pnpm-lock.yaml}",
455
+ ];
456
+ const LOCKFILE = /(?:\.lock|package-lock\.json|pnpm-lock\.yaml)$/;
457
+ const GROUP_PIN = /(?<![\w.@/-])(@nifrajs\/[a-z0-9._-]+|create-nifra|nifra)@(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/g;
458
+ /**
459
+ * Fixed-group versions other than `version` pinned where `nifra upgrade` does not edit: MCP configs,
460
+ * container files, CI workflows, and lockfiles besides the workspace's own (which install rewrites).
461
+ */
462
+ export function strayPins(cwd, version) {
463
+ const found = [];
464
+ const files = new Set(STRAY_PIN_FILES.flatMap((pattern) => scan(cwd, pattern, true)));
465
+ for (const rel of [...files].sort()) {
466
+ const lockfile = LOCKFILE.test(rel);
467
+ if (lockfile && !/[\\/]/.test(rel))
468
+ continue;
469
+ let text;
470
+ try {
471
+ if (statSync(join(cwd, rel)).size > 8_000_000)
472
+ continue;
473
+ text = readFileSync(join(cwd, rel), "utf8");
474
+ }
475
+ catch {
476
+ continue;
477
+ }
478
+ const stale = [...text.matchAll(GROUP_PIN)].filter((match) => compareSemverSpec(match[2] ?? "", version) !== 0);
479
+ const site = (match, more) => ({
480
+ file: rel,
481
+ line: text.slice(0, match.index ?? 0).split("\n").length,
482
+ pin: match[0],
483
+ lockfile,
484
+ more,
485
+ });
486
+ const first = stale[0];
487
+ if (first === undefined)
488
+ continue;
489
+ if (lockfile)
490
+ found.push(site(first, stale.length - 1));
491
+ else
492
+ for (const match of stale)
493
+ found.push(site(match, 0));
494
+ }
495
+ return found;
496
+ }
497
+ const packageVersionAt = (dir) => {
498
+ try {
499
+ const meta = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
500
+ const version = Reflect.get(Object(meta), "version");
501
+ return typeof version === "string" && version.length > 0 ? version : undefined;
502
+ }
503
+ catch {
504
+ return undefined;
505
+ }
506
+ };
507
+ /**
508
+ * Every fixed-group package installed in `cwd/node_modules`, realpath-resolved so a linked repository
509
+ * reports its own version. `inside` is false for one that resolves outside the workspace root.
510
+ */
511
+ export function installedGroupPackages(cwd) {
512
+ const modules = join(cwd, "node_modules");
513
+ const names = ["nifra", "create-nifra"].filter((name) => existsSync(join(modules, name)));
514
+ try {
515
+ for (const name of readdirSync(join(modules, "@nifrajs")))
516
+ names.push(`@nifrajs/${name}`);
517
+ }
518
+ catch {
519
+ // No scoped installs.
520
+ }
521
+ let root;
522
+ try {
523
+ root = realpathSync(cwd);
524
+ }
525
+ catch {
526
+ root = cwd;
527
+ }
528
+ const found = [];
529
+ for (const name of names) {
530
+ let path;
531
+ try {
532
+ path = realpathSync(join(modules, name));
533
+ }
534
+ catch {
535
+ continue;
353
536
  }
537
+ const version = packageVersionAt(path);
538
+ if (version === undefined)
539
+ continue;
540
+ found.push({
541
+ package: name,
542
+ path,
543
+ version,
544
+ inside: path === root || path.startsWith(root + sep),
545
+ });
546
+ }
547
+ return found;
548
+ }
549
+ /**
550
+ * The lowest fixed-group version any package.json under `cwd` declares with a plain semver spec, or
551
+ * null when none does. Recipes newer than it still have edits to make somewhere in the workspace.
552
+ */
553
+ export function installedGroupVersion(cwd) {
554
+ let lowest = null;
555
+ for (const version of groupPinVersions(cwd).keys()) {
556
+ if (lowest === null || compareSemverSpec(version, lowest) < 0)
557
+ lowest = version;
354
558
  }
355
559
  return lowest;
356
560
  }
@@ -379,6 +583,7 @@ export function chainRecipes(target, installed) {
379
583
  pins: [...pins].map(([match, to]) => ({ match, to })),
380
584
  dependencyMoves: recipes.flatMap((recipe) => recipe.dependencyMoves ?? []),
381
585
  importMoves: recipes.flatMap((recipe) => recipe.importMoves),
586
+ retiredMarkers: recipes.flatMap((recipe) => recipe.retiredMarkers ?? []),
382
587
  notes: recipes.flatMap((recipe) => (recipe.notes ?? []).map((note) => `${recipe.version}: ${note}`)),
383
588
  };
384
589
  }
@@ -388,6 +593,10 @@ export function computeUpgrade(cwd, recipe, write, allowDowngrade = false, exact
388
593
  const dependencyMoves = [];
389
594
  const importMoves = [];
390
595
  const downgrades = [];
596
+ const retiredMarkers = [];
597
+ const linkedOutside = installedGroupPackages(cwd)
598
+ .filter((entry) => !entry.inside && entry.version !== recipe.version)
599
+ .map(({ package: name, path, version }) => ({ package: name, path, version }));
391
600
  if ((recipe.dependencyMoves?.length ?? 0) > 0 || recipe.pins.length > 0) {
392
601
  for (const rel of scan(cwd, "**/package.json")) {
393
602
  const abs = join(cwd, rel);
@@ -417,12 +626,36 @@ export function computeUpgrade(cwd, recipe, write, allowDowngrade = false, exact
417
626
  writeFileSync(abs, result.text);
418
627
  }
419
628
  }
629
+ // Read after the import moves, so a listed line number is the one the file has after `--write`.
630
+ const retired = recipe.retiredMarkers ?? [];
631
+ if (retired.length > 0) {
632
+ for (const rel of scan(cwd, SOURCE_GLOB)) {
633
+ const text = readFileSync(join(cwd, rel), "utf8");
634
+ if (!retired.some(({ marker }) => text.includes(marker)))
635
+ continue;
636
+ const lines = text.split("\n");
637
+ for (let line = 1; line <= lines.length; line++) {
638
+ for (const { marker, instead } of retired) {
639
+ if (!(lines[line - 1] ?? "").includes(marker))
640
+ continue;
641
+ const target = markerTarget(lines, line);
642
+ const covers = target === undefined
643
+ ? undefined
644
+ : { line: target, text: (lines[target - 1] ?? "").trim() };
645
+ retiredMarkers.push({ file: rel, line, marker, covers, instead });
646
+ }
647
+ }
648
+ }
649
+ }
420
650
  return {
421
651
  version: recipe.version,
422
652
  pins,
423
653
  dependencyMoves,
424
654
  importMoves,
425
655
  downgrades,
656
+ retiredMarkers,
657
+ linkedOutside,
658
+ strayPins: strayPins(cwd, recipe.version),
426
659
  notes: recipe.notes ?? [],
427
660
  };
428
661
  }
@@ -438,7 +671,10 @@ function renderPlan(plan, write, from, steps) {
438
671
  lines.push("");
439
672
  if (plan.pins.length === 0 &&
440
673
  plan.dependencyMoves.length === 0 &&
441
- plan.importMoves.length === 0) {
674
+ plan.importMoves.length === 0 &&
675
+ plan.retiredMarkers.length === 0 &&
676
+ plan.linkedOutside.length === 0 &&
677
+ plan.strayPins.length === 0) {
442
678
  lines.push("Already up to date - no changes.");
443
679
  return lines.join("\n");
444
680
  }
@@ -465,6 +701,39 @@ function renderPlan(plan, write, from, steps) {
465
701
  lines.push(` ${m.file} ${m.from} → ${m.to} (${m.count})`);
466
702
  lines.push("");
467
703
  }
704
+ if (plan.retiredMarkers.length > 0) {
705
+ lines.push(`${plan.retiredMarkers.length} retired comment(s) that silence nothing now; nifra check reports what each covered:`);
706
+ for (const site of plan.retiredMarkers) {
707
+ const covers = site.covers === undefined
708
+ ? "covers no line"
709
+ : `covers line ${site.covers.line}: ${site.covers.text}`;
710
+ lines.push(` ${site.file}:${site.line} ${site.marker} ${covers}`);
711
+ lines.push(` write instead: ${site.instead}`);
712
+ }
713
+ lines.push("");
714
+ }
715
+ if (plan.linkedOutside.length > 0) {
716
+ lines.push("Left alone, linked from outside the workspace root (upgrade that repository on its own):");
717
+ for (const entry of plan.linkedOutside)
718
+ lines.push(` ${entry.package} ${entry.version} at ${entry.path}`);
719
+ lines.push("");
720
+ }
721
+ const pinned = plan.strayPins.filter((site) => !site.lockfile);
722
+ if (pinned.length > 0) {
723
+ lines.push(`Left alone, another version pinned outside package.json (update by hand):`);
724
+ for (const site of pinned)
725
+ lines.push(` ${site.file}:${site.line} ${site.pin}`);
726
+ lines.push("");
727
+ }
728
+ const locks = plan.strayPins.filter((site) => site.lockfile);
729
+ if (locks.length > 0) {
730
+ lines.push(`Left alone, a lockfile besides the workspace's own (regenerate it after installing):`);
731
+ for (const site of locks) {
732
+ const more = site.more === 0 ? "" : ` and ${site.more} more`;
733
+ lines.push(` ${site.file}:${site.line} ${site.pin}${more}`);
734
+ }
735
+ lines.push("");
736
+ }
468
737
  for (const note of plan.notes)
469
738
  lines.push(`note: ${note}`);
470
739
  if (!write)
@@ -483,6 +752,63 @@ function renderDowngradeRefusal(version, downgrades) {
483
752
  "installed one, or pass --allow-downgrade if the rollback is intended.",
484
753
  ].join("\n");
485
754
  }
755
+ async function hookReportBefore(cwd) {
756
+ try {
757
+ const { loadBackendApp } = await import("./load.js");
758
+ const { backend } = await loadBackendApp(cwd);
759
+ return await collectHookReport(backend);
760
+ }
761
+ catch (error) {
762
+ return { error: error instanceof Error ? error.message : String(error) };
763
+ }
764
+ }
765
+ /** The report after the edits, from a fresh process: the edited sources must not come from this one's
766
+ * module cache. Saves the pre-upgrade report for `nifra routes --hooks --since` after an install. */
767
+ async function hookScopeAcross(cwd, before) {
768
+ if ("error" in before)
769
+ return { error: before.error };
770
+ const saved = join(cwd, HOOKS_BEFORE_UPGRADE_FILE);
771
+ mkdirSync(dirname(saved), { recursive: true });
772
+ writeFileSync(saved, `${JSON.stringify(before, null, 2)}\n`);
773
+ const entry = fileURLToPath(new URL(import.meta.url.endsWith(".ts") ? "./cli.ts" : "./cli.js", import.meta.url));
774
+ const child = Bun.spawn([process.execPath, entry, "routes", "--hooks", "--json"], {
775
+ cwd,
776
+ env: process.env,
777
+ stdout: "pipe",
778
+ stderr: "pipe",
779
+ });
780
+ const [text, , code] = await Promise.all([
781
+ new Response(child.stdout).text(),
782
+ new Response(child.stderr).text(),
783
+ child.exited,
784
+ ]);
785
+ const after = parseHookReport(text);
786
+ if (after === undefined) {
787
+ return {
788
+ error: `\`nifra routes --hooks\` exited ${code} after the edits`,
789
+ saved: HOOKS_BEFORE_UPGRADE_FILE,
790
+ };
791
+ }
792
+ return { saved: HOOKS_BEFORE_UPGRADE_FILE, changes: diffHookReports(before, after) };
793
+ }
794
+ function renderHookScope(scope) {
795
+ const since = (saved) => [
796
+ "The new release's hooks run once its packages are installed. After installing, compare against the report saved before the edits:",
797
+ ` nifra routes --hooks --since ${saved}`,
798
+ ];
799
+ if ("error" in scope) {
800
+ const head = `Hook scope: not captured (${scope.error}).`;
801
+ return scope.saved === undefined ? head : [head, ...since(scope.saved)].join("\n");
802
+ }
803
+ return [
804
+ "Hook scope across the edits (nifra routes --hooks):",
805
+ renderHookDiff(scope.changes)
806
+ .split("\n")
807
+ .map((line) => ` ${line}`)
808
+ .join("\n"),
809
+ ...since(scope.saved),
810
+ ].join("\n");
811
+ }
486
812
  /** The targets this CLI applies: each release recipe up to its own version, and that version. */
487
813
  export function upgradeTargets(cliVersion) {
488
814
  const versions = listRecipeVersions().filter((version) => compareSemverSpec(version, cliVersion) <= 0);
@@ -497,6 +823,7 @@ function newerCliCommand(version, options) {
497
823
  options.exact === true ? "--exact" : "",
498
824
  options.allowDowngrade === true ? "--allow-downgrade" : "",
499
825
  options.verify === false ? "--no-verify" : "",
826
+ options.hooks === false ? "--no-hooks" : "",
500
827
  options.json === true ? "--json" : "",
501
828
  ].filter((flag) => flag !== "");
502
829
  return ["bunx", `@nifrajs/cli@${version}`, "upgrade", version, ...flags].join(" ");
@@ -565,20 +892,36 @@ export async function runUpgrade(cwd, options) {
565
892
  }
566
893
  return false;
567
894
  }
895
+ const captureHooks = write && options.hooks !== false;
896
+ const before = captureHooks ? await hookReportBefore(cwd) : undefined;
568
897
  const plan = write ? computeUpgrade(cwd, recipe, true, allowDowngrade, exact) : preview;
898
+ const hooks = before === undefined ? undefined : await hookScopeAcross(cwd, before);
899
+ // Verify only makes sense once edits are on disk. Default on after --write; opt out with --no-verify.
900
+ const verify = write && options.verify !== false;
569
901
  if (options.json) {
570
- console.log(JSON.stringify({ ...plan, from, steps: recipe.steps, write }, null, 2));
571
- }
572
- else {
573
- console.log(renderPlan(plan, write, from, recipe.steps));
902
+ // The check result rides inside the one JSON document; its human report would break the stream.
903
+ const report = { ...plan, from, steps: recipe.steps, write };
904
+ if (hooks !== undefined)
905
+ report.hooks = hooks;
906
+ let ok = true;
907
+ if (verify) {
908
+ const { collectProjectVerification } = await import("./verification.js");
909
+ const verification = await collectProjectVerification(cwd, { lintsOnly: false });
910
+ const check = await verification.check();
911
+ report.check = check;
912
+ ok = check.ok;
913
+ }
914
+ console.log(JSON.stringify(report, null, 2));
915
+ return ok;
574
916
  }
575
- // Verify only makes sense once edits are on disk. Default on after --write; opt out with --no-verify.
576
- if (write && options.verify !== false) {
917
+ console.log(renderPlan(plan, write, from, recipe.steps));
918
+ if (hooks !== undefined)
919
+ console.log(`\n${renderHookScope(hooks)}`);
920
+ if (verify) {
577
921
  const { runCheck } = await import("./check.js");
578
922
  const ok = await runCheck(cwd, { json: false });
579
923
  if (!ok) {
580
- if (!options.json)
581
- console.error("\n[nifra] upgrade applied but `nifra check` failed - review above.");
924
+ console.error("\n[nifra] upgrade applied but `nifra check` failed - review above.");
582
925
  return false;
583
926
  }
584
927
  }