@hominis/fireforge 0.49.2 → 0.50.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 (334) hide show
  1. package/CHANGELOG.md +40 -1
  2. package/README.md +1 -1
  3. package/dist/bin/fireforge.js +6 -0
  4. package/dist/build-info.json +4 -4
  5. package/dist/src/commands/build-prelock.d.ts +20 -0
  6. package/dist/src/commands/build-prelock.js +50 -0
  7. package/dist/src/commands/build.d.ts +3 -1
  8. package/dist/src/commands/build.js +13 -6
  9. package/dist/src/commands/check-report.d.ts +28 -0
  10. package/dist/src/commands/check-report.js +78 -0
  11. package/dist/src/commands/check-run.d.ts +28 -0
  12. package/dist/src/commands/check-run.js +104 -0
  13. package/dist/src/commands/check-steps.d.ts +67 -0
  14. package/dist/src/commands/check-steps.js +175 -0
  15. package/dist/src/commands/check.d.ts +32 -0
  16. package/dist/src/commands/check.js +160 -0
  17. package/dist/src/commands/config.js +5 -6
  18. package/dist/src/commands/doctor-engine-eslint.js +6 -1
  19. package/dist/src/commands/doctor-source-pin.js +4 -1
  20. package/dist/src/commands/export-adopt-check.d.ts +38 -0
  21. package/dist/src/commands/export-adopt-check.js +98 -0
  22. package/dist/src/commands/export-flow.js +29 -14
  23. package/dist/src/commands/export-placement-gate.d.ts +6 -2
  24. package/dist/src/commands/export-shared.d.ts +2 -1
  25. package/dist/src/commands/export-staged-registration.d.ts +46 -0
  26. package/dist/src/commands/export-staged-registration.js +91 -0
  27. package/dist/src/commands/forward-import-remedy.d.ts +32 -0
  28. package/dist/src/commands/forward-import-remedy.js +63 -0
  29. package/dist/src/commands/furnace/apply.js +3 -3
  30. package/dist/src/commands/furnace/chrome-doc-remove.d.ts +2 -1
  31. package/dist/src/commands/furnace/chrome-doc-remove.js +2 -2
  32. package/dist/src/commands/furnace/chrome-doc.d.ts +2 -1
  33. package/dist/src/commands/furnace/chrome-doc.js +2 -2
  34. package/dist/src/commands/furnace/create.js +2 -2
  35. package/dist/src/commands/furnace/deploy-named.d.ts +48 -0
  36. package/dist/src/commands/furnace/deploy-named.js +107 -0
  37. package/dist/src/commands/furnace/deploy.d.ts +3 -2
  38. package/dist/src/commands/furnace/deploy.js +50 -86
  39. package/dist/src/commands/furnace/diff.js +26 -3
  40. package/dist/src/commands/furnace/index.js +66 -41
  41. package/dist/src/commands/furnace/init.js +13 -6
  42. package/dist/src/commands/furnace/override.js +4 -2
  43. package/dist/src/commands/furnace/preview.js +4 -3
  44. package/dist/src/commands/furnace/refresh.js +2 -2
  45. package/dist/src/commands/furnace/remove.js +4 -3
  46. package/dist/src/commands/furnace/rename-dry-run.d.ts +29 -0
  47. package/dist/src/commands/furnace/rename-dry-run.js +127 -0
  48. package/dist/src/commands/furnace/rename-helpers.d.ts +7 -0
  49. package/dist/src/commands/furnace/rename-helpers.js +15 -1
  50. package/dist/src/commands/furnace/rename-registrations.d.ts +41 -0
  51. package/dist/src/commands/furnace/rename-registrations.js +93 -0
  52. package/dist/src/commands/furnace/rename.d.ts +2 -1
  53. package/dist/src/commands/furnace/rename.js +28 -66
  54. package/dist/src/commands/furnace/scan.d.ts +9 -4
  55. package/dist/src/commands/furnace/scan.js +11 -9
  56. package/dist/src/commands/furnace/sync.js +3 -2
  57. package/dist/src/commands/furnace/validate-report.d.ts +45 -0
  58. package/dist/src/commands/furnace/validate-report.js +91 -0
  59. package/dist/src/commands/furnace/validate.d.ts +1 -1
  60. package/dist/src/commands/furnace/validate.js +127 -114
  61. package/dist/src/commands/furnace/validation-output.js +14 -3
  62. package/dist/src/commands/lint-per-patch.js +31 -60
  63. package/dist/src/commands/lint-report.d.ts +8 -4
  64. package/dist/src/commands/lint-report.js +15 -5
  65. package/dist/src/commands/lint.js +15 -34
  66. package/dist/src/commands/manifest.js +2 -0
  67. package/dist/src/commands/patch/delete-gap-notice.js +3 -1
  68. package/dist/src/commands/patch/delete.d.ts +1 -1
  69. package/dist/src/commands/patch/delete.js +13 -98
  70. package/dist/src/commands/patch/index.d.ts +1 -1
  71. package/dist/src/commands/patch/index.js +4 -2
  72. package/dist/src/commands/patch/lint-ignore.d.ts +5 -1
  73. package/dist/src/commands/patch/lint-ignore.js +65 -28
  74. package/dist/src/commands/patch/move-files-create.js +26 -17
  75. package/dist/src/commands/patch/move-files-into.js +5 -3
  76. package/dist/src/commands/patch/move-files.js +3 -1
  77. package/dist/src/commands/patch/removal-dependents.d.ts +63 -0
  78. package/dist/src/commands/patch/removal-dependents.js +133 -0
  79. package/dist/src/commands/patch/rename.js +7 -0
  80. package/dist/src/commands/patch/retire.d.ts +34 -0
  81. package/dist/src/commands/patch/retire.js +128 -0
  82. package/dist/src/commands/patch/split-plan.d.ts +7 -7
  83. package/dist/src/commands/patch/split-plan.js +45 -28
  84. package/dist/src/commands/patch/split-staged.d.ts +108 -0
  85. package/dist/src/commands/patch/split-staged.js +202 -0
  86. package/dist/src/commands/patch/split.d.ts +5 -0
  87. package/dist/src/commands/patch/split.js +51 -15
  88. package/dist/src/commands/patch/staged-dependency-batch.d.ts +48 -0
  89. package/dist/src/commands/patch/staged-dependency-batch.js +176 -0
  90. package/dist/src/commands/patch/staged-dependency-entries.d.ts +36 -0
  91. package/dist/src/commands/patch/staged-dependency-entries.js +52 -0
  92. package/dist/src/commands/patch/staged-dependency-from.d.ts +14 -0
  93. package/dist/src/commands/patch/staged-dependency-from.js +108 -0
  94. package/dist/src/commands/patch/staged-dependency.js +14 -41
  95. package/dist/src/commands/re-export-adjacent-registrations.d.ts +20 -0
  96. package/dist/src/commands/re-export-adjacent-registrations.js +75 -0
  97. package/dist/src/commands/re-export-adjacent.d.ts +45 -2
  98. package/dist/src/commands/re-export-adjacent.js +156 -37
  99. package/dist/src/commands/re-export-drift.d.ts +36 -8
  100. package/dist/src/commands/re-export-drift.js +73 -49
  101. package/dist/src/commands/re-export-files.js +11 -8
  102. package/dist/src/commands/re-export-lint.d.ts +2 -1
  103. package/dist/src/commands/re-export-lint.js +23 -10
  104. package/dist/src/commands/re-export-options.js +45 -4
  105. package/dist/src/commands/re-export-preflight.d.ts +23 -0
  106. package/dist/src/commands/re-export-preflight.js +160 -0
  107. package/dist/src/commands/re-export-register.js +42 -17
  108. package/dist/src/commands/re-export-removed.d.ts +12 -1
  109. package/dist/src/commands/re-export-removed.js +41 -11
  110. package/dist/src/commands/re-export-scan-owner.d.ts +44 -0
  111. package/dist/src/commands/re-export-scan-owner.js +116 -0
  112. package/dist/src/commands/re-export-scan.d.ts +17 -9
  113. package/dist/src/commands/re-export-scan.js +56 -23
  114. package/dist/src/commands/re-export-single.js +22 -6
  115. package/dist/src/commands/re-export-summary.d.ts +28 -0
  116. package/dist/src/commands/re-export-summary.js +36 -0
  117. package/dist/src/commands/re-export.js +37 -24
  118. package/dist/src/commands/rebase/index.js +17 -17
  119. package/dist/src/commands/run.js +16 -2
  120. package/dist/src/commands/status-check.d.ts +55 -16
  121. package/dist/src/commands/status-check.js +92 -37
  122. package/dist/src/commands/status-json.d.ts +22 -9
  123. package/dist/src/commands/status-json.js +47 -32
  124. package/dist/src/commands/status-output.js +3 -1
  125. package/dist/src/commands/status-scope.d.ts +37 -0
  126. package/dist/src/commands/status-scope.js +54 -0
  127. package/dist/src/commands/status.js +66 -23
  128. package/dist/src/commands/test-browser-preflight.js +19 -3
  129. package/dist/src/commands/test-build-furnace.d.ts +21 -0
  130. package/dist/src/commands/test-build-furnace.js +48 -0
  131. package/dist/src/commands/test-build-phase.js +47 -41
  132. package/dist/src/commands/test-checkjs.js +7 -0
  133. package/dist/src/commands/test-diagnose.js +7 -0
  134. package/dist/src/commands/test-modes.d.ts +16 -1
  135. package/dist/src/commands/test-modes.js +24 -1
  136. package/dist/src/commands/test-multi-verdict.d.ts +61 -0
  137. package/dist/src/commands/test-multi-verdict.js +111 -0
  138. package/dist/src/commands/test-orchestrate.d.ts +43 -0
  139. package/dist/src/commands/test-orchestrate.js +222 -0
  140. package/dist/src/commands/test-perf-scope.d.ts +29 -0
  141. package/dist/src/commands/test-perf-scope.js +83 -0
  142. package/dist/src/commands/test-prelock.d.ts +51 -0
  143. package/dist/src/commands/test-prelock.js +177 -0
  144. package/dist/src/commands/test-register.js +54 -10
  145. package/dist/src/commands/test-run.js +4 -1
  146. package/dist/src/commands/test-stale-gate.js +57 -27
  147. package/dist/src/commands/test-verdict.d.ts +10 -1
  148. package/dist/src/commands/test-verdict.js +28 -1
  149. package/dist/src/commands/test.d.ts +6 -1
  150. package/dist/src/commands/test.js +66 -60
  151. package/dist/src/commands/token-lint.d.ts +14 -0
  152. package/dist/src/commands/token-lint.js +111 -0
  153. package/dist/src/commands/token.js +10 -0
  154. package/dist/src/commands/verify-scope.d.ts +28 -0
  155. package/dist/src/commands/verify-scope.js +67 -0
  156. package/dist/src/commands/verify.d.ts +10 -2
  157. package/dist/src/commands/verify.js +59 -35
  158. package/dist/src/core/build-baseline-types.d.ts +46 -0
  159. package/dist/src/core/build-baseline-validate.js +19 -0
  160. package/dist/src/core/build-baseline.d.ts +5 -1
  161. package/dist/src/core/build-baseline.js +3 -1
  162. package/dist/src/core/build-overwrite-guard.d.ts +50 -0
  163. package/dist/src/core/build-overwrite-guard.js +84 -4
  164. package/dist/src/core/build-prepare.d.ts +7 -0
  165. package/dist/src/core/build-prepare.js +14 -8
  166. package/dist/src/core/build-run-record.d.ts +74 -0
  167. package/dist/src/core/build-run-record.js +136 -0
  168. package/dist/src/core/checkjs-support-heads.d.ts +41 -0
  169. package/dist/src/core/checkjs-support-heads.js +104 -0
  170. package/dist/src/core/config-paths.d.ts +2 -2
  171. package/dist/src/core/config-paths.js +8 -0
  172. package/dist/src/core/config-validate-check.d.ts +8 -0
  173. package/dist/src/core/config-validate-check.js +143 -0
  174. package/dist/src/core/config-validate-file-size.d.ts +9 -6
  175. package/dist/src/core/config-validate-file-size.js +91 -2
  176. package/dist/src/core/config-validate-history.d.ts +11 -0
  177. package/dist/src/core/config-validate-history.js +38 -0
  178. package/dist/src/core/config-validate-patch-policy.js +100 -0
  179. package/dist/src/core/config-validate-test-toolchains.js +7 -0
  180. package/dist/src/core/config-validate-token-scales.d.ts +6 -0
  181. package/dist/src/core/config-validate-token-scales.js +50 -0
  182. package/dist/src/core/config-validate.js +14 -5
  183. package/dist/src/core/config.d.ts +5 -0
  184. package/dist/src/core/config.js +16 -0
  185. package/dist/src/core/coverage-dispatch.d.ts +24 -0
  186. package/dist/src/core/coverage-dispatch.js +44 -0
  187. package/dist/src/core/coverage-extend.d.ts +70 -45
  188. package/dist/src/core/coverage-extend.js +199 -144
  189. package/dist/src/core/coverage-records.d.ts +97 -0
  190. package/dist/src/core/coverage-records.js +350 -0
  191. package/dist/src/core/destructive.d.ts +7 -4
  192. package/dist/src/core/destructive.js +10 -10
  193. package/dist/src/core/engine-session-lock.d.ts +10 -1
  194. package/dist/src/core/engine-session-lock.js +28 -8
  195. package/dist/src/core/export-moved-code-shape.d.ts +49 -11
  196. package/dist/src/core/export-moved-code-shape.js +135 -44
  197. package/dist/src/core/file-lock-queue.d.ts +67 -0
  198. package/dist/src/core/file-lock-queue.js +221 -0
  199. package/dist/src/core/file-lock.d.ts +4 -25
  200. package/dist/src/core/file-lock.js +105 -138
  201. package/dist/src/core/furnace-apply-output.d.ts +10 -0
  202. package/dist/src/core/furnace-apply-output.js +31 -1
  203. package/dist/src/core/furnace-apply.js +12 -2
  204. package/dist/src/core/furnace-composed-source.d.ts +76 -0
  205. package/dist/src/core/furnace-composed-source.js +167 -0
  206. package/dist/src/core/furnace-config-validate-exceptions.d.ts +9 -0
  207. package/dist/src/core/furnace-config-validate-exceptions.js +71 -0
  208. package/dist/src/core/furnace-config-validate.js +8 -0
  209. package/dist/src/core/furnace-css-fragments.d.ts +10 -0
  210. package/dist/src/core/furnace-css-fragments.js +26 -0
  211. package/dist/src/core/furnace-operation.d.ts +3 -3
  212. package/dist/src/core/furnace-registration-remove.d.ts +22 -1
  213. package/dist/src/core/furnace-registration-remove.js +53 -17
  214. package/dist/src/core/furnace-registration-rename.d.ts +26 -0
  215. package/dist/src/core/furnace-registration-rename.js +77 -0
  216. package/dist/src/core/furnace-registration.d.ts +14 -4
  217. package/dist/src/core/furnace-registration.js +44 -10
  218. package/dist/src/core/furnace-staleness.d.ts +7 -0
  219. package/dist/src/core/furnace-staleness.js +34 -24
  220. package/dist/src/core/furnace-validate-accessibility.d.ts +4 -2
  221. package/dist/src/core/furnace-validate-accessibility.js +10 -1
  222. package/dist/src/core/furnace-validate-exceptions.d.ts +40 -0
  223. package/dist/src/core/furnace-validate-exceptions.js +43 -0
  224. package/dist/src/core/furnace-validate-icon-button.d.ts +7 -0
  225. package/dist/src/core/furnace-validate-icon-button.js +49 -0
  226. package/dist/src/core/furnace-validate.js +1 -1
  227. package/dist/src/core/harness-port-fallback.d.ts +60 -0
  228. package/dist/src/core/harness-port-fallback.js +96 -0
  229. package/dist/src/core/history-log-file.d.ts +61 -0
  230. package/dist/src/core/history-log-file.js +157 -0
  231. package/dist/src/core/history-log.d.ts +2 -1
  232. package/dist/src/core/history-log.js +2 -1
  233. package/dist/src/core/json-preserving-edit.d.ts +22 -0
  234. package/dist/src/core/json-preserving-edit.js +199 -0
  235. package/dist/src/core/mach.js +8 -1
  236. package/dist/src/core/negative-control-diff.d.ts +39 -0
  237. package/dist/src/core/negative-control-diff.js +136 -0
  238. package/dist/src/core/negative-control.d.ts +138 -0
  239. package/dist/src/core/negative-control.js +356 -0
  240. package/dist/src/core/objdir-install-manifests.d.ts +73 -0
  241. package/dist/src/core/objdir-install-manifests.js +226 -0
  242. package/dist/src/core/patch-export-update.d.ts +2 -0
  243. package/dist/src/core/patch-export-update.js +1 -0
  244. package/dist/src/core/patch-export.js +3 -4
  245. package/dist/src/core/patch-lint-checkjs-any-import.d.ts +37 -0
  246. package/dist/src/core/patch-lint-checkjs-any-import.js +61 -0
  247. package/dist/src/core/patch-lint-checkjs.js +8 -11
  248. package/dist/src/core/patch-lint-code-lines.d.ts +59 -0
  249. package/dist/src/core/patch-lint-code-lines.js +105 -0
  250. package/dist/src/core/patch-lint-cross.js +12 -4
  251. package/dist/src/core/patch-lint-css.d.ts +8 -2
  252. package/dist/src/core/patch-lint-css.js +10 -4
  253. package/dist/src/core/patch-lint-file-size.d.ts +12 -5
  254. package/dist/src/core/patch-lint-file-size.js +22 -11
  255. package/dist/src/core/patch-lint-forward-registration-extended.d.ts +18 -0
  256. package/dist/src/core/patch-lint-forward-registration-extended.js +35 -10
  257. package/dist/src/core/patch-lint-forward-registration.js +10 -0
  258. package/dist/src/core/patch-lint-ignore.d.ts +152 -0
  259. package/dist/src/core/patch-lint-ignore.js +299 -0
  260. package/dist/src/core/patch-lint-size-thresholds.d.ts +52 -0
  261. package/dist/src/core/patch-lint-size-thresholds.js +110 -0
  262. package/dist/src/core/patch-lint-size.d.ts +117 -0
  263. package/dist/src/core/patch-lint-size.js +323 -0
  264. package/dist/src/core/patch-lint-staged-registration.d.ts +18 -1
  265. package/dist/src/core/patch-lint-staged-registration.js +20 -0
  266. package/dist/src/core/patch-lint-token-scale.d.ts +39 -0
  267. package/dist/src/core/patch-lint-token-scale.js +109 -0
  268. package/dist/src/core/patch-lint.d.ts +11 -83
  269. package/dist/src/core/patch-lint.js +64 -286
  270. package/dist/src/core/patch-lock.js +7 -6
  271. package/dist/src/core/patch-manifest-consistency.js +7 -6
  272. package/dist/src/core/patch-manifest-io.d.ts +6 -3
  273. package/dist/src/core/patch-manifest-io.js +19 -8
  274. package/dist/src/core/patch-manifest-validate.js +34 -2
  275. package/dist/src/core/patch-order-legal.d.ts +25 -0
  276. package/dist/src/core/patch-order-legal.js +83 -0
  277. package/dist/src/core/patch-policy-content.d.ts +40 -0
  278. package/dist/src/core/patch-policy-content.js +216 -0
  279. package/dist/src/core/patch-policy.d.ts +16 -3
  280. package/dist/src/core/patch-policy.js +16 -9
  281. package/dist/src/core/patch-retired.d.ts +35 -0
  282. package/dist/src/core/patch-retired.js +54 -0
  283. package/dist/src/core/patch-subset.d.ts +16 -0
  284. package/dist/src/core/patch-subset.js +66 -0
  285. package/dist/src/core/preflight-wait.d.ts +20 -0
  286. package/dist/src/core/preflight-wait.js +38 -0
  287. package/dist/src/core/rebase-from-version.d.ts +46 -0
  288. package/dist/src/core/rebase-from-version.js +71 -0
  289. package/dist/src/core/run-log.d.ts +20 -0
  290. package/dist/src/core/run-log.js +89 -3
  291. package/dist/src/core/run-profile.d.ts +7 -0
  292. package/dist/src/core/run-profile.js +7 -1
  293. package/dist/src/core/smoke-patterns.d.ts +5 -0
  294. package/dist/src/core/smoke-patterns.js +13 -0
  295. package/dist/src/core/test-harness-crash.d.ts +18 -1
  296. package/dist/src/core/test-harness-crash.js +6 -4
  297. package/dist/src/core/test-harness-signature.d.ts +17 -0
  298. package/dist/src/core/test-harness-signature.js +4 -0
  299. package/dist/src/core/test-run-completeness.d.ts +57 -0
  300. package/dist/src/core/test-run-completeness.js +144 -0
  301. package/dist/src/core/test-stale-check.d.ts +27 -1
  302. package/dist/src/core/test-stale-check.js +39 -49
  303. package/dist/src/core/test-stale-exempt.d.ts +40 -0
  304. package/dist/src/core/test-stale-exempt.js +70 -0
  305. package/dist/src/core/token-lint.d.ts +50 -0
  306. package/dist/src/core/token-lint.js +126 -0
  307. package/dist/src/core/tree-guard.js +3 -0
  308. package/dist/src/core/typecheck-shim.d.ts +1 -1
  309. package/dist/src/core/typecheck-shim.js +90 -2
  310. package/dist/src/index.d.ts +8 -3
  311. package/dist/src/index.js +3 -1
  312. package/dist/src/types/commands/index.d.ts +2 -2
  313. package/dist/src/types/commands/options.d.ts +148 -21
  314. package/dist/src/types/commands/patches.d.ts +75 -1
  315. package/dist/src/types/config.d.ts +192 -0
  316. package/dist/src/types/config.js +9 -0
  317. package/dist/src/types/furnace.d.ts +43 -1
  318. package/dist/src/types/furnace.js +7 -0
  319. package/dist/src/types/index.d.ts +1 -1
  320. package/dist/src/utils/options.d.ts +34 -6
  321. package/dist/src/utils/options.js +86 -5
  322. package/dist/src/utils/path-glob.d.ts +15 -0
  323. package/dist/src/utils/path-glob.js +47 -0
  324. package/dist/src/utils/regex.d.ts +15 -3
  325. package/dist/src/utils/regex.js +37 -16
  326. package/docs/configuration.md +282 -9
  327. package/docs/exit-codes.md +18 -1
  328. package/docs/furnace.md +97 -3
  329. package/docs/lifecycle-invariants.md +52 -19
  330. package/docs/machine-output.md +74 -12
  331. package/docs/patch-workflow.md +297 -22
  332. package/docs/run-logs.md +20 -1
  333. package/docs/testing.md +276 -20
  334. package/package.json +1 -1
@@ -15,15 +15,26 @@ import { mapWithConcurrency } from '../utils/concurrency.js';
15
15
  import { pathExists } from '../utils/fs.js';
16
16
  import { cancel, info, isCancel, warn } from '../utils/logger.js';
17
17
  import { isContainedRelativePath, normalizePathSlashes, stripEnginePrefix, } from '../utils/paths.js';
18
+ import { formatForwardImportRemedy } from './forward-import-remedy.js';
19
+ import { joinScanFileOwner, splitScanFileOwner } from './re-export-scan-owner.js';
18
20
  const SCAN_ADD_COUNT_THRESHOLD = 3;
19
21
  /** Concurrency bound for existence probes (matches the classify/lint pools). */
20
22
  const PATH_PROBE_CONCURRENCY = 8;
21
23
  const SCAN_DIR_COUNT_THRESHOLD = 2;
22
- /** Normalizes repeatable `--scan-file` inputs into safe engine-relative paths. */
24
+ /**
25
+ * Normalizes repeatable `--scan-file` inputs into safe engine-relative
26
+ * paths, keeping an optional `<patch>:` owner prefix intact.
27
+ */
23
28
  export function normalizeScanFiles(scanFiles) {
24
- const normalized = [...new Set(scanFiles ?? [])]
25
- .map((file) => normalizeEngineRelativeInput(file, '--scan-file'))
26
- .sort();
29
+ const normalized = [
30
+ ...new Set((scanFiles ?? []).map((file) => {
31
+ const spec = splitScanFileOwner(file);
32
+ return joinScanFileOwner({
33
+ owner: spec.owner,
34
+ path: normalizeEngineRelativeInput(spec.path, '--scan-file'),
35
+ });
36
+ })),
37
+ ].sort();
27
38
  return normalized.length > 0 ? normalized : undefined;
28
39
  }
29
40
  /** Normalizes one CLI-provided path into a safe engine-relative path. */
@@ -171,7 +182,7 @@ function scanAdditionsNeedConfirmation(added) {
171
182
  * (all adopt through the same scan result), including dry-run.
172
183
  */
173
184
  export async function assertScanAdoptionsHaveNoForwardImports(args) {
174
- const { patchesDir, engineDir, patchFilename, added } = args;
185
+ const { patchesDir, engineDir, patchFilename, added, config } = args;
175
186
  if (added.length === 0)
176
187
  return;
177
188
  const candidateDiff = await getDiffForFilesAgainstHead(engineDir, [...added]);
@@ -180,7 +191,7 @@ export async function assertScanAdoptionsHaveNoForwardImports(args) {
180
191
  const candidateNewFiles = buildNewFileTextProjection(candidateDiff);
181
192
  const candidateCreations = detectNewFilesInDiff(candidateDiff);
182
193
  const candidateAdditions = buildModifiedFileAdditionsFromDiff(candidateDiff);
183
- const baseCtx = await buildPatchQueueContext(patchesDir);
194
+ const baseCtx = await buildPatchQueueContext(patchesDir, config);
184
195
  const projectedEntries = baseCtx.entries.map((entry) => {
185
196
  if (entry.filename !== patchFilename)
186
197
  return entry;
@@ -193,27 +204,49 @@ export async function assertScanAdoptionsHaveNoForwardImports(args) {
193
204
  };
194
205
  });
195
206
  const baselineIssues = lintPatchQueue(baseCtx).filter((i) => i.severity === 'error');
196
- const projectedIssues = lintPatchQueue({ entries: projectedEntries }).filter((i) => i.severity === 'error');
207
+ const projectedIssues = lintPatchQueue({ ...baseCtx, entries: projectedEntries }).filter((i) => i.severity === 'error');
197
208
  const addedSet = new Set(added);
198
- const offending = computeProjectedLintRegressions(baselineIssues, projectedIssues).filter((issue) => issue.check === 'forward-import' && addedSet.has(issue.file));
199
- if (offending.length === 0)
209
+ const regressions = computeProjectedLintRegressions(baselineIssues, projectedIssues);
210
+ const offending = regressions.filter((issue) => issue.check === 'forward-import' && addedSet.has(issue.file));
211
+ // A forward registration (jar.mn packaging, a test manifest, ...) only
212
+ // regresses here because the candidates moved the registered file's
213
+ // creation to this patch, or because a candidate registers it: either way
214
+ // the adoption caused it, and the operator should declare it in the same
215
+ // pass as the import instead of meeting it later in the queue audit.
216
+ const registrations = regressions.filter((issue) => issue.check === 'forward-registration');
217
+ if (offending.length === 0 && registrations.length === 0)
200
218
  return;
201
219
  const details = offending.map((issue) => ` - ${issue.file}: ${issue.message}`).join('\n');
202
- throw new GeneralError(`Refusing to adopt ${offending.length} scanned file${offending.length === 1 ? '' : 's'} into ${patchFilename} ` +
203
- `because they import modules created by later patches:\n${details}\n` +
204
- formatForwardImportRemedy('re-running the scan'));
220
+ const registrationDetails = registrations
221
+ .map((issue) => ` - ${issue.file}: ${issue.message}`)
222
+ .join('\n');
223
+ const parts = [];
224
+ if (offending.length > 0) {
225
+ parts.push(`${offending.length} scanned file${offending.length === 1 ? '' : 's'} import modules created by later patches:\n${details}`);
226
+ }
227
+ if (registrations.length > 0) {
228
+ parts.push(`${registrations.length} forward registration${registrations.length === 1 ? '' : 's'} the adoption would leave dangling:\n${registrationDetails}`);
229
+ }
230
+ throw new GeneralError(`Refusing to adopt scanned files into ${patchFilename}: ${parts.join('\n')}\n` +
231
+ formatForwardImportRemedy({
232
+ reRunHint: 're-running the scan',
233
+ issues: [...offending, ...registrations],
234
+ manifest: manifestFromQueue(baseCtx),
235
+ ...(args.config !== undefined ? { config: args.config } : {}),
236
+ ...categoryOf(baseCtx, patchFilename),
237
+ }));
205
238
  }
206
- /**
207
- * The one remedy sentence for a forward import a patch body cannot carry.
208
- * Shared by the `--scan` adoption gate and the plain re-export projection
209
- * gate so both refusals point at the same commands.
210
- *
211
- * @param reRunHint - What to do after the fix ("re-running the scan")
212
- */
213
- export function formatForwardImportRemedy(reRunHint) {
214
- return ('Export those files as their own later patch ("fireforge export --order <n>" / ' +
215
- '"fireforge patch split --order <n>") or declare the intentional dependency with ' +
216
- `"fireforge patch staged-dependency --add" before ${reRunHint}.`);
239
+ /** The manifest rows a queue context carries, for remedy computations. */
240
+ export function manifestFromQueue(ctx) {
241
+ return {
242
+ version: 1,
243
+ patches: ctx.entries.flatMap((entry) => (entry.metadata ? [entry.metadata] : [])),
244
+ };
245
+ }
246
+ /** `{ category }` of the named queue entry, or `{}` when it has no metadata. */
247
+ export function categoryOf(ctx, filename) {
248
+ const category = ctx.entries.find((entry) => entry.filename === filename)?.metadata?.category;
249
+ return category !== undefined ? { category } : {};
217
250
  }
218
251
  /** Refuses explicit `--scan-file` additions that did not produce patch hunks. */
219
252
  export function assertScanFileAdditionsHaveDiffHunks(args) {
@@ -10,6 +10,7 @@ import { enforceFreshFurnaceSources } from '../core/furnace-stale-export.js';
10
10
  import { getDiffForFilesAgainstHead } from '../core/git-diff.js';
11
11
  import { listTrackedInHead } from '../core/git-file-ops.js';
12
12
  import { updatePatchAndMetadata } from '../core/patch-export.js';
13
+ import { buildLintIgnoreChecks, lintIgnoreRule, mergeLintIgnoreIds, } from '../core/patch-lint-ignore.js';
13
14
  import { buildProjectedManifest, enforcePatchPolicy } from '../core/patch-policy.js';
14
15
  import { isGitIndexLockConflict } from '../errors/git.js';
15
16
  import { info, success, warn } from '../utils/logger.js';
@@ -42,7 +43,9 @@ export async function reExportSinglePatchWithIndexLockRetry(args) {
42
43
  async function reExportSinglePatch(args) {
43
44
  const { patch, paths, manifest, options, isDryRun, config, adjacentCtx, driftCtx, lintCtx } = args;
44
45
  const removed = new Set(options.expectRemoved ?? []);
45
- let currentFilesAffected = patch.filesAffected.filter((file) => !removed.has(file));
46
+ const retainedFiles = patch.filesAffected.filter((file) => !removed.has(file));
47
+ let currentFilesAffected = retainedFiles;
48
+ let adoptedFiles = [];
46
49
  for (const file of patch.filesAffected.filter((path) => removed.has(path)))
47
50
  info(` - ${file} (--expect-removed)`);
48
51
  // --- Scan for new/removed files ---
@@ -65,6 +68,7 @@ async function reExportSinglePatch(args) {
65
68
  engineDir: paths.engine,
66
69
  patchFilename: patch.filename,
67
70
  added: scanResult.added,
71
+ config,
68
72
  });
69
73
  if (options.scanFiles === undefined) {
70
74
  const isInteractive = stdioIsInteractive();
@@ -80,6 +84,7 @@ async function reExportSinglePatch(args) {
80
84
  }
81
85
  }
82
86
  currentFilesAffected = scanResult.updated;
87
+ adoptedFiles = scanResult.added;
83
88
  }
84
89
  else if (options.files === undefined) {
85
90
  // When neither `--scan` nor `--files` is set and some of the manifest's
@@ -153,13 +158,17 @@ async function reExportSinglePatch(args) {
153
158
  // payload lines are about to enter the body that the old body did not
154
159
  // carry, the case where a concurrent session's uncommitted edits in
155
160
  // owned files would be silently absorbed. Always previews (including
156
- // dry-run and --scan). Refuses only under --refuse-foreign-drift.
161
+ // dry-run and --scan). Refuses only under --refuse-foreign-drift. Covers
162
+ // the files the patch already owned; adopted and retired files are the
163
+ // run's stated intent, not drift.
157
164
  if (await reportForeignDrift({
158
165
  patch,
159
166
  patchesDir: paths.patches,
160
167
  engineDir: paths.engine,
161
168
  newDiffContent: diffContent,
162
169
  ctx: driftCtx,
170
+ ownedFiles: retainedFiles,
171
+ adoptedFiles,
163
172
  })) {
164
173
  return false;
165
174
  }
@@ -228,6 +237,13 @@ function enforceProjectedManifestPolicy(args) {
228
237
  manifest: buildProjectedManifest(manifest, manifest.patches.map((entry) => entry.filename === patch.filename ? { ...entry, ...updates } : entry)),
229
238
  command: 're-export',
230
239
  forceUnsafe: options.forceUnsafe === true,
240
+ // A re-export changes only this row's contents, so only its findings
241
+ // can be new; another patch's stale row must not fail this one.
242
+ scope: [patch.filename],
243
+ hints: {
244
+ 'reserved-files': 'A path whose engine copy is back at HEAD can be dropped from the patch with ' +
245
+ '--expect-removed <path>; any other path needs its reserved allowlist entry.',
246
+ },
231
247
  });
232
248
  }
233
249
  /**
@@ -252,6 +268,7 @@ async function commitRefreshedPatch(args) {
252
268
  config,
253
269
  command: 're-export',
254
270
  forceUnsafe: options.forceUnsafe === true,
271
+ scope: [patch.filename],
255
272
  },
256
273
  });
257
274
  refreshQueueCtxEntry(lintCtx, patch.filename, diffContent);
@@ -289,10 +306,9 @@ async function commitRefreshedPatch(args) {
289
306
  * pass so the new intent takes effect on this invocation.
290
307
  */
291
308
  function computeReExportUpdates(patch, options, currentFilesAffected) {
292
- const existingIgnoreSet = new Set(patch.lintIgnore ?? []);
309
+ const existingIgnoreSet = new Set((patch.lintIgnore ?? []).map(lintIgnoreRule));
293
310
  const flagIgnoreSet = new Set(options.lintIgnore ?? []);
294
- const mergedIgnoreSet = new Set([...existingIgnoreSet, ...flagIgnoreSet]);
295
- const effectiveLintIgnore = mergedIgnoreSet.size > 0 ? [...mergedIgnoreSet] : undefined;
311
+ const effectiveLintIgnore = mergeLintIgnoreIds(patch.lintIgnore, [...flagIgnoreSet]);
296
312
  const updates = {
297
313
  filesAffected: currentFilesAffected,
298
314
  };
@@ -304,7 +320,7 @@ function computeReExportUpdates(patch, options, currentFilesAffected) {
304
320
  }
305
321
  return {
306
322
  updates,
307
- ignoreChecks: effectiveLintIgnore ? new Set(effectiveLintIgnore) : undefined,
323
+ ignoreChecks: buildLintIgnoreChecks(effectiveLintIgnore),
308
324
  effectiveTier: options.tier ?? patch.tier,
309
325
  existingIgnoreSet,
310
326
  flagIgnoreSet,
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The one-line end-of-run summary of a multi-patch `re-export`.
3
+ *
4
+ * A long run interleaves per-patch lint output, drift previews and
5
+ * refusals, and the patches it wrote were easy to confuse with the one it
6
+ * did not. The summary names both sets and why each unwritten patch was
7
+ * not written, so the re-run list is one line to read.
8
+ */
9
+ /** What became of each selected patch. */
10
+ export interface ReExportRunTally {
11
+ /** Selected patches, in run order. */
12
+ selected: readonly string[];
13
+ /** Patches written (or, in a dry run, that would be written). */
14
+ written: readonly string[];
15
+ /** Patches whose re-export threw. */
16
+ failed: readonly string[];
17
+ /** Patches refused by `--refuse-adjacent-unmanaged`. */
18
+ refusedAdjacent: readonly string[];
19
+ /** Patches refused by `--refuse-foreign-drift`. */
20
+ refusedDrift: readonly string[];
21
+ /** Whether the run is a dry run. */
22
+ isDryRun: boolean;
23
+ }
24
+ /**
25
+ * Renders the summary, or undefined for a single-patch run, where the
26
+ * outcome line already says everything.
27
+ */
28
+ export declare function formatReExportRunSummary(tally: ReExportRunTally): string | undefined;
@@ -0,0 +1,36 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * The one-line end-of-run summary of a multi-patch `re-export`.
4
+ *
5
+ * A long run interleaves per-patch lint output, drift previews and
6
+ * refusals, and the patches it wrote were easy to confuse with the one it
7
+ * did not. The summary names both sets and why each unwritten patch was
8
+ * not written, so the re-run list is one line to read.
9
+ */
10
+ /**
11
+ * Renders the summary, or undefined for a single-patch run, where the
12
+ * outcome line already says everything.
13
+ */
14
+ export function formatReExportRunSummary(tally) {
15
+ if (tally.selected.length <= 1)
16
+ return undefined;
17
+ const written = new Set(tally.written);
18
+ const reasons = new Map();
19
+ for (const name of tally.failed)
20
+ reasons.set(name, 'failed');
21
+ for (const name of tally.refusedAdjacent)
22
+ reasons.set(name, 'refused: adjacent unmanaged');
23
+ for (const name of tally.refusedDrift)
24
+ reasons.set(name, 'refused: foreign drift');
25
+ const notWritten = tally.selected
26
+ .filter((name) => !written.has(name))
27
+ .map((name) => `${name} (${reasons.get(name) ?? 'skipped'})`);
28
+ const verb = tally.isDryRun ? 'would write' : 'wrote';
29
+ const writtenPart = tally.written.length > 0
30
+ ? `${verb} ${tally.written.length} of ${tally.selected.length}: ${tally.written.join(', ')}`
31
+ : `${verb} 0 of ${tally.selected.length}`;
32
+ const notWrittenPart = notWritten.length > 0
33
+ ? `; ${tally.isDryRun ? 'would NOT write' : 'NOT written'}: ${notWritten.join(', ')}`
34
+ : '';
35
+ return `Re-export summary: ${writtenPart}${notWrittenPart}.`;
36
+ }
@@ -10,6 +10,7 @@ import { GeneralError, InvalidArgumentError } from '../errors/base.js';
10
10
  import { elapsedSince } from '../utils/elapsed.js';
11
11
  import { toError } from '../utils/errors.js';
12
12
  import { cancel, info, intro, isCancel, outro, spinner, success, warn, } from '../utils/logger.js';
13
+ import { ADJACENT_MEANS, buildAdjacentUnmanagedContext, reportAdjacentCarveOutUsage, } from './re-export-adjacent.js';
13
14
  import { loadScanFilesAssignments, withDryRunPurityGuard, withDryRunReExportLock, } from './re-export-bulk-scan.js';
14
15
  import { warnUnseenExpectedDrift } from './re-export-drift.js';
15
16
  import { reExportFilesInPlace } from './re-export-files.js';
@@ -17,7 +18,9 @@ import { buildReExportLintContext, finishReExportLint } from './re-export-lint.j
17
18
  import { applyReExportFilesPositionalFolding, validateReExportOptionCombinations, } from './re-export-options.js';
18
19
  import { validateExpectedRemovals } from './re-export-removed.js';
19
20
  import { normalizeEngineRelativeInput, normalizeScanFiles } from './re-export-scan.js';
21
+ import { assignScanFileOwners, scanOptionsForPatch } from './re-export-scan-owner.js';
20
22
  import { reExportSinglePatchWithIndexLockRetry } from './re-export-single.js';
23
+ import { formatReExportRunSummary } from './re-export-summary.js';
21
24
  async function resolveSelectedPatches(patches, options, manifest) {
22
25
  if (options.all) {
23
26
  return [...manifest.patches];
@@ -73,7 +76,9 @@ export async function reExportCommand(projectRoot, patches, options) {
73
76
  options = cleanedOptions;
74
77
  }
75
78
  const isDryRun = options.dryRun === true;
76
- intro(isDryRun ? 'FireForge Re-export (dry run)' : 'FireForge Re-export');
79
+ // The banner says which mode is in force: a path flag that swallowed
80
+ // `--dry-run` as its value once turned a preview into a write unnoticed.
81
+ intro(isDryRun ? 'FireForge Re-export (DRY RUN)' : 'FireForge Re-export (WRITING)');
77
82
  // Accept export-style space-separated paths after --files by folding
78
83
  // path-shaped extra positionals into the file list.
79
84
  ({ patches, options } = applyReExportFilesPositionalFolding(patches, options));
@@ -94,6 +99,7 @@ export async function reExportCommand(projectRoot, patches, options) {
94
99
  : await resolveSelectedPatches(patches, options, manifest);
95
100
  if (!selectedPatches)
96
101
  return;
102
+ const scanAssignments = scanFilesByPatch ?? assignScanFileOwners(options.scanFiles, selectedPatches, manifest);
97
103
  if (selectedPatches.length === 0) {
98
104
  warn('No patches selected');
99
105
  outro('Nothing to re-export');
@@ -125,14 +131,7 @@ export async function reExportCommand(projectRoot, patches, options) {
125
131
  const config = await loadConfig(projectRoot);
126
132
  // Classification inputs for the scan-less adjacency advisory, computed
127
133
  // once for the whole run.
128
- const adjacentCtx = {
129
- binaryName: config.binaryName,
130
- furnacePrefixes: await collectFurnaceManagedPrefixes(paths.root),
131
- refuseAdjacentUnmanaged: options.refuseAdjacentUnmanaged === true,
132
- approvedUnmanaged: new Set((options.expectUnmanaged ?? []).map((file) => normalizeEngineRelativeInput(file, '--expect-unmanaged'))),
133
- approvedSeen: new Set(),
134
- refusals: [],
135
- };
134
+ const adjacentCtx = buildAdjacentUnmanagedContext(options, config.binaryName, await collectFurnaceManagedPrefixes(paths.root));
136
135
  const driftCtx = buildForeignDriftContext(options);
137
136
  // Hoisted lint context, one per run: queue context + checkJs
138
137
  // program + per-patch result cache, shared across every loop iteration.
@@ -146,8 +145,7 @@ export async function reExportCommand(projectRoot, patches, options) {
146
145
  const progress = spinner('Preparing re-export...');
147
146
  await withDryRunReExportLock(paths.fireforgeDir, isDryRun, async () => withDryRunPurityGuard(paths.engine, paths.patches, isDryRun, async () => {
148
147
  for (const [index, patch] of selectedPatches.entries()) {
149
- const assignedScanFiles = scanFilesByPatch?.get(patch.filename);
150
- const patchOptions = assignedScanFiles !== undefined ? { ...options, scanFiles: assignedScanFiles } : options;
148
+ const patchOptions = scanOptionsForPatch(options, patch.filename, scanAssignments);
151
149
  progress.message(`Re-exporting ${index + 1}/${selectedPatches.length}: ${patch.filename} (${patch.filesAffected.length} file(s), ${elapsedSince(startedAt)} elapsed)...`);
152
150
  try {
153
151
  const exported = await reExportSinglePatchWithIndexLockRetry({
@@ -176,6 +174,16 @@ export async function reExportCommand(projectRoot, patches, options) {
176
174
  await finishReExportLint(lintCtx);
177
175
  warnUnseenExpectedDrift(driftCtx);
178
176
  warnUnseenApprovedUnmanaged(adjacentCtx);
177
+ const summary = formatReExportRunSummary({
178
+ selected: selectedPatches.map((patch) => patch.filename),
179
+ written: reExportedFilenames,
180
+ failed: failedFilenames,
181
+ refusedAdjacent: adjacentCtx.refusals.map((r) => r.patchFilename),
182
+ refusedDrift: driftCtx.refusals.map((r) => r.patchFilename),
183
+ isDryRun,
184
+ });
185
+ if (summary !== undefined)
186
+ info(summary);
179
187
  throwRunLevelRefusals(adjacentCtx, driftCtx, progress);
180
188
  if (reExported === 0 && selectedPatches.length > 0) {
181
189
  progress.error('Re-export failed');
@@ -198,6 +206,7 @@ export async function reExportCommand(projectRoot, patches, options) {
198
206
  selectedCount: selectedPatches.length,
199
207
  reExportedFilenames,
200
208
  failedFilenames,
209
+ selectedFilenames: selectedPatches.map((patch) => patch.filename),
201
210
  shouldStamp,
202
211
  stampRequested: options.stamp === true,
203
212
  config,
@@ -224,9 +233,11 @@ function buildForeignDriftContext(options) {
224
233
  * admit, and reads as an approval that is in force when it is not.
225
234
  * A warning rather than a refusal: an approved path legitimately goes unmet
226
235
  * when the reviewed file has since been adopted into a patch, which is the
227
- * outcome the carve-out exists to be retired by.
236
+ * outcome the carve-out exists to be retired by. The `--expect-new` and
237
+ * `--adjacent-ignore` accounting is reported first, alongside.
228
238
  */
229
239
  function warnUnseenApprovedUnmanaged(adjacentCtx) {
240
+ reportAdjacentCarveOutUsage(adjacentCtx);
230
241
  const unseen = [...adjacentCtx.approvedUnmanaged].filter((file) => !adjacentCtx.approvedSeen.has(file));
231
242
  if (unseen.length === 0)
232
243
  return;
@@ -244,17 +255,18 @@ function warnUnseenApprovedUnmanaged(adjacentCtx) {
244
255
  function throwRunLevelRefusals(adjacentCtx, driftCtx, progress) {
245
256
  if (adjacentCtx.refusals.length > 0) {
246
257
  progress.error('Re-export refused');
247
- // Each offender is named with the owned directory that made it
248
- // adjacent: an unattended run reads only this line, and without the
249
- // anchor it cannot tell which of a multi-directory patch's locations
250
- // the file sits beside.
258
+ // Each offender is named with the owned directory (or registering
259
+ // carrier) that made it adjacent: an unattended run reads only this
260
+ // line, and without the anchor it cannot tell which of a
261
+ // multi-directory patch's locations the file belongs to.
251
262
  const offenders = adjacentCtx.refusals
252
263
  .map((r) => `${r.patchFilename} (${r.anchored.join(', ')})`)
253
264
  .join('; ');
254
265
  throw new GeneralError(`Refused ${adjacentCtx.refusals.length} patch(es) with adjacent unmanaged files ` +
255
- `(--refuse-adjacent-unmanaged): ${offenders}. Adjacent means the file sits in a ` +
256
- `directory the patch already owns a file in. Adopt reviewed files with ` +
257
- `--scan --scan-file, or export them as a new patch.`);
266
+ `(--refuse-adjacent-unmanaged): ${offenders}. ${ADJACENT_MEANS} ` +
267
+ `Adopt reviewed files with --scan --scan-file, or export them as a new patch ` +
268
+ `(name a file your next export adopts with --expect-new, and a reviewed, deliberately ` +
269
+ `unmanaged file with --expect-unmanaged or --adjacent-ignore).`);
258
270
  }
259
271
  if (driftCtx.refusals.length > 0) {
260
272
  progress.error('Re-export refused');
@@ -263,11 +275,11 @@ function throwRunLevelRefusals(adjacentCtx, driftCtx, progress) {
263
275
  const unreadablePart = unreadable.length > 0
264
276
  ? ` ${unreadable.length} of these refused because the old patch body was missing or unreadable, so the drift comparison could not run (fail-closed).`
265
277
  : '';
266
- throw new GeneralError(`Refused ${driftCtx.refusals.length} patch(es) whose refreshed body would absorb ` +
267
- `engine edits not present in the old patch body (--refuse-foreign-drift): ${names}.${unreadablePart}\n\n` +
278
+ throw new GeneralError(`Refused ${driftCtx.refusals.length} patch(es) whose refreshed body would add or drop ` +
279
+ `lines relative to the old patch body (--refuse-foreign-drift): ${names}.${unreadablePart}\n\n` +
268
280
  'The flag cannot tell WHO wrote those lines, and the two populations have opposite ' +
269
281
  'remedies:\n' +
270
- ' - Lines you added yourself since the last export (the per-file report tags these ' +
282
+ ' - Lines you changed yourself since the last export (the per-file report tags these ' +
271
283
  '"edited since your last export"): name them with --expect <path> so this run captures ' +
272
284
  'them, or re-run without the flag to capture every drifting file.\n' +
273
285
  " - Lines another session owns: do NOT proceed — commit or stash that session's work " +
@@ -305,9 +317,10 @@ function reportReExportOutcome(args) {
305
317
  outro('Re-export complete');
306
318
  }
307
319
  if (reExported < selectedCount) {
308
- const skippedCount = selectedCount - reExported - args.failedFilenames.length;
309
320
  const failedPart = args.failedFilenames.length > 0 ? ` Failed: ${args.failedFilenames.join(', ')}.` : '';
310
- const skippedPart = skippedCount > 0 ? ` Skipped: ${skippedCount} patch(es) (see warnings above).` : '';
321
+ const accounted = new Set([...args.reExportedFilenames, ...args.failedFilenames]);
322
+ const skipped = args.selectedFilenames.filter((name) => !accounted.has(name));
323
+ const skippedPart = skipped.length > 0 ? ` Skipped: ${skipped.join(', ')} (see warnings above).` : '';
311
324
  throw new GeneralError(`${isDryRun ? '[dry-run] Would re-export' : 'Re-exported'} only ${reExported} of ` +
312
325
  `${selectedCount} selected patch(es).${failedPart}${skippedPart} ` +
313
326
  'Partial re-export exits non-zero so it cannot pass as success in scripts; ' +
@@ -19,12 +19,13 @@ import { discoverPatches } from '../../core/patch-files.js';
19
19
  import { loadPatchesManifest } from '../../core/patch-manifest.js';
20
20
  import { getPatchSourceVersion } from '../../core/patch-source-metadata.js';
21
21
  import { replayQueueIndexOnly } from '../../core/rebase-dry-run.js';
22
+ import { formatPatchStampSpread, readCommittedFirefoxPin, resolveRebaseFromVersion, tallyPatchStamps, } from '../../core/rebase-from-version.js';
22
23
  import { getRebaseSessionPath, readRebaseSession, saveRebaseSession, } from '../../core/rebase-session.js';
23
24
  import { GeneralError } from '../../errors/base.js';
24
25
  import { CorruptRebaseSessionError, RebaseDryRunRejectError, RebaseSessionExistsError, } from '../../errors/rebase.js';
25
26
  import { info, intro, outro, spinner, warn } from '../../utils/logger.js';
26
27
  import { addWaitLockOption, commanderArgParser, pickDefined, resolveWaitLockSeconds, } from '../../utils/options.js';
27
- import { compareFirefoxVersions, parsePositiveIntegerFlag } from '../../utils/validation.js';
28
+ import { parsePositiveIntegerFlag } from '../../utils/validation.js';
28
29
  import { handleAbort } from './abort.js';
29
30
  import { confirmDirtyEngineReset } from './confirm.js';
30
31
  import { handleContinue } from './continue.js';
@@ -58,27 +59,26 @@ async function handleFreshStart(projectRoot, options) {
58
59
  if (!manifest || manifest.patches.length === 0) {
59
60
  throw new GeneralError('No patches found in manifest. Nothing to rebase.');
60
61
  }
61
- // Determine the "from" version from the patch stamps. A queue can carry
62
- // several: patches re-exported since the last hop are stamped newer than
63
- // untouched ones. The oldest stamp is "from", compared semantically (a
64
- // lexical sort put 153.10.0esr before 153.2.0esr).
65
- const versionCounts = new Map();
66
- for (const patch of manifest.patches) {
67
- const version = getPatchSourceVersion(patch);
68
- versionCounts.set(version, (versionCounts.get(version) ?? 0) + 1);
69
- }
70
- const sortedVersions = [...versionCounts.keys()].sort(compareFirefoxVersions);
71
- const fromVersion = sortedVersions[0] ?? currentVersion;
72
- const fromProduct = manifest.patches.find((p) => getPatchSourceVersion(p) === fromVersion)?.sourceProduct ??
73
- config.firefox.product;
74
- if (versionCounts.size === 1 && fromVersion === currentVersion) {
62
+ // The queue can carry several stamps: patches re-exported since the last
63
+ // hop are stamped newer than untouched ones. "Nothing to rebase" is a fact
64
+ // about the stamps; the "from" label is the previous pin (the committed
65
+ // `firefox.version`), with the oldest stamp only as the fallback.
66
+ const spread = tallyPatchStamps(manifest.patches.map((p) => getPatchSourceVersion(p)));
67
+ const oldestStamp = spread.versions[0] ?? currentVersion;
68
+ const from = resolveRebaseFromVersion(currentVersion, await readCommittedFirefoxPin(projectRoot), spread, manifest.patches.find((p) => getPatchSourceVersion(p) === oldestStamp)?.sourceProduct);
69
+ const fromVersion = from.version;
70
+ const fromProduct = from.product ?? config.firefox.product;
71
+ if (spread.versions.length === 1 && oldestStamp === currentVersion) {
75
72
  info('All patches already match the current Firefox version. Nothing to rebase.');
76
73
  outro('Rebase not needed');
77
74
  return;
78
75
  }
79
76
  info(`Rebasing patches: ${fromVersion} → ${currentVersion}`);
80
- if (versionCounts.size > 1) {
81
- info(`Patch stamps: ${sortedVersions.map((v) => `${versionCounts.get(v)} at ${v}`).join(', ')}`);
77
+ if (from.origin === 'patch-stamps') {
78
+ info('From-version is the oldest patch stamp: no committed fireforge.json pin precedes the target.');
79
+ }
80
+ if (from.origin === 'committed-pin' || spread.versions.length > 1) {
81
+ info(`Patch stamps: ${formatPatchStampSpread(spread)}`);
82
82
  }
83
83
  // The rebase replays onto whatever engine/ holds. The pin is the target,
84
84
  // so an engine still on another version means the download step has not
@@ -9,7 +9,7 @@ import { warnIfFurnaceStale } from '../core/furnace-staleness.js';
9
9
  import { hasBuildArtifacts, hasRunnableBundle, run, runMachSmoke } from '../core/mach.js';
10
10
  import { assertBuildArtifacts } from '../core/mach-build-artifacts.js';
11
11
  import { resolveRunProfile, sweepAbandonedRunProfiles, } from '../core/run-profile.js';
12
- import { compileAllowlistFromFile, compileAllowlistFromStrings, findOverCapAllowlistEntries, matchAllowlist, matchesSmokeError, } from '../core/smoke-patterns.js';
12
+ import { compileAllowlistFromFile, compileAllowlistFromStrings, findOverCapAllowlistEntries, isBrowserConsoleLine, matchAllowlist, matchesSmokeError, } from '../core/smoke-patterns.js';
13
13
  import { GeneralError, InvalidArgumentError } from '../errors/base.js';
14
14
  import { BuildError } from '../errors/build.js';
15
15
  import { ExitCode } from '../errors/codes.js';
@@ -105,6 +105,7 @@ export async function runCommand(projectRoot, options = {}) {
105
105
  const profile = await resolveRunProfile({
106
106
  profile: options.profile,
107
107
  tempProfile: options.tempProfile,
108
+ purgeCaches: options.purgeCaches,
108
109
  smoke: options.smokeExit !== undefined,
109
110
  });
110
111
  try {
@@ -188,6 +189,7 @@ async function runSmokeExit(engineDir, options, profile) {
188
189
  warn(`--capture-console stream error: ${err.message}`);
189
190
  });
190
191
  const findings = [];
192
+ let browserConsoleLines = 0;
191
193
  let allowlistedErrorHits = 0;
192
194
  let allowlistedTotalHits = 0;
193
195
  const allowlistHits = new Array(allowlist.length).fill(0);
@@ -197,6 +199,8 @@ async function runSmokeExit(engineDir, options, profile) {
197
199
  // mirror preserves stdout/stderr separation for downstream piping.
198
200
  const sink = stream === 'stdout' ? process.stdout : process.stderr;
199
201
  sink.write(`${line}\n`);
202
+ if (isBrowserConsoleLine(line))
203
+ browserConsoleLines += 1;
200
204
  // Count allowlist hits up-front, regardless of error-pattern match.
201
205
  // Incrementing only when the line also matches an error pattern makes an
202
206
  // allowlist regex that visibly matches `console.warn: RSLoader:` report 0
@@ -269,6 +273,15 @@ async function runSmokeExit(engineDir, options, profile) {
269
273
  return;
270
274
  }
271
275
  if (result.exitCode === 0 || result.exitCode === 130 || result.exitCode === 143) {
276
+ // A clean status before the window with no browser output at all is a
277
+ // launch that never reached a window (a profile lock, a startup-crash
278
+ // streak's silent exit, a self-restart), not a clean run.
279
+ if (browserConsoleLines === 0) {
280
+ throw new SmokeRunError(`Browser exited with code ${result.exitCode} after ${(elapsedMs / 1000).toFixed(1)}s, ` +
281
+ 'before the smoke-exit window elapsed, without any console output. A silent early ' +
282
+ 'exit means the launch never reached a window: check the profile for a stale ' +
283
+ '.parentlock or a startup-crash streak (toolkit.startup.recent_crashes).', ExitCode.SMOKE_LAUNCH_FAILURE);
284
+ }
272
285
  return;
273
286
  }
274
287
  throw new SmokeRunError(`Browser exited with code ${result.exitCode} before smoke-exit window elapsed.`, ExitCode.SMOKE_LAUNCH_FAILURE);
@@ -392,7 +405,8 @@ export function registerRun(program, { getProjectRoot, withErrorHandling }) {
392
405
  .option('--console-allow-file <path>', 'Newline-delimited allowlist regex file. Blank lines and # comments are ignored. A "# max-hits: N" comment directly above an entry caps how many lines it may match in one run; more than that fails the run with exit 12.')
393
406
  .option('--capture-console <file>', 'Mirror captured console output to <file> for post-exit inspection.')
394
407
  .option('--headless', 'Launch the browser with --headless. Recommended for --smoke-exit on a shared desktop: input into a headed smoke window contaminates the console capture.')
395
- .option('--profile <path>', 'Launch with this profile directory (passed to Firefox as -profile). Left in place after the run. With --smoke-exit it replaces the default fresh profile.')
408
+ .option('--profile <path>', 'Launch with this profile directory (passed to Firefox as -profile). Left in place after the run. With --smoke-exit it replaces the default fresh profile. Firefox gets -purgecaches too, so the profile does not run chrome scripts an earlier build cached.')
409
+ .option('--no-purge-caches', 'With --profile, keep the profile startup cache (do not pass -purgecaches).')
396
410
  .option('--temp-profile', 'Launch with a fresh temporary profile that is removed when the run ends. The default for --smoke-exit.')
397
411
  .action(withErrorHandling(
398
412
  // `args` is the variadic positional, not a Commander flag, so it