@hominis/fireforge 0.45.0 → 0.46.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 (359) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/dist/bin/fireforge.js +6 -0
  3. package/dist/build-info.json +4 -4
  4. package/dist/src/cli-usage-error.d.ts +46 -0
  5. package/dist/src/cli-usage-error.js +81 -0
  6. package/dist/src/cli.d.ts +5 -1
  7. package/dist/src/cli.js +20 -21
  8. package/dist/src/commands/bootstrap.js +1 -2
  9. package/dist/src/commands/build.js +15 -6
  10. package/dist/src/commands/destructive-decision.d.ts +19 -0
  11. package/dist/src/commands/destructive-decision.js +31 -0
  12. package/dist/src/commands/doctor-check-core.d.ts +0 -6
  13. package/dist/src/commands/doctor-check-core.js +0 -8
  14. package/dist/src/commands/doctor-orphaned-harness.d.ts +0 -6
  15. package/dist/src/commands/doctor-orphaned-harness.js +4 -12
  16. package/dist/src/commands/doctor-post-rebase-audit.js +1 -1
  17. package/dist/src/commands/doctor.d.ts +2 -0
  18. package/dist/src/commands/doctor.js +7 -8
  19. package/dist/src/commands/download.js +35 -24
  20. package/dist/src/commands/export-all.js +21 -4
  21. package/dist/src/commands/export-flow.js +15 -45
  22. package/dist/src/commands/export-placement-conflicts.js +3 -3
  23. package/dist/src/commands/export-placement-gate.js +2 -8
  24. package/dist/src/commands/export-placement-policy.js +3 -5
  25. package/dist/src/commands/export-shared.d.ts +42 -23
  26. package/dist/src/commands/export-shared.js +4 -19
  27. package/dist/src/commands/export.js +10 -1
  28. package/dist/src/commands/furnace/authoring-config.d.ts +13 -0
  29. package/dist/src/commands/furnace/authoring-config.js +19 -0
  30. package/dist/src/commands/furnace/chrome-doc-remove.js +1 -1
  31. package/dist/src/commands/furnace/create-browser-test.js +3 -8
  32. package/dist/src/commands/furnace/create-dry-run.js +2 -5
  33. package/dist/src/commands/furnace/create-templates.d.ts +17 -1
  34. package/dist/src/commands/furnace/create-templates.js +2 -1
  35. package/dist/src/commands/furnace/create.js +25 -19
  36. package/dist/src/commands/furnace/deploy.js +11 -2
  37. package/dist/src/commands/furnace/init.js +2 -1
  38. package/dist/src/commands/furnace/override.js +28 -19
  39. package/dist/src/commands/furnace/refresh.js +9 -1
  40. package/dist/src/commands/furnace/remove-mutations.d.ts +6 -7
  41. package/dist/src/commands/furnace/remove-mutations.js +0 -1
  42. package/dist/src/commands/furnace/remove.js +2 -6
  43. package/dist/src/commands/furnace/rename-browser-test.js +1 -7
  44. package/dist/src/commands/furnace/rename.js +29 -29
  45. package/dist/src/commands/furnace/status.js +12 -9
  46. package/dist/src/commands/furnace/test-file-name.d.ts +28 -0
  47. package/dist/src/commands/furnace/test-file-name.js +39 -0
  48. package/dist/src/commands/furnace/validation-output.d.ts +21 -9
  49. package/dist/src/commands/furnace/validation-output.js +3 -8
  50. package/dist/src/commands/import.js +2 -9
  51. package/dist/src/commands/lint-per-patch.d.ts +2 -2
  52. package/dist/src/commands/lint-per-patch.js +19 -4
  53. package/dist/src/commands/lint-per-run-checkjs.d.ts +2 -2
  54. package/dist/src/commands/lint.d.ts +2 -1
  55. package/dist/src/commands/lint.js +1 -1
  56. package/dist/src/commands/manifest.js +1 -1
  57. package/dist/src/commands/package.js +2 -4
  58. package/dist/src/commands/patch/compact.js +14 -15
  59. package/dist/src/commands/patch/delete.js +15 -25
  60. package/dist/src/commands/patch/entry-projection.d.ts +13 -0
  61. package/dist/src/commands/patch/entry-projection.js +24 -0
  62. package/dist/src/commands/patch/lint-ignore.d.ts +0 -8
  63. package/dist/src/commands/patch/lint-ignore.js +19 -29
  64. package/dist/src/commands/patch/move-files-create.js +24 -10
  65. package/dist/src/commands/patch/move-files-into.js +38 -34
  66. package/dist/src/commands/patch/move-files.js +4 -13
  67. package/dist/src/commands/patch/rename.js +27 -41
  68. package/dist/src/commands/patch/reorder.js +25 -38
  69. package/dist/src/commands/patch/split-plan.d.ts +4 -5
  70. package/dist/src/commands/patch/split-plan.js +4 -8
  71. package/dist/src/commands/patch/split.js +45 -43
  72. package/dist/src/commands/patch/staged-dependency.js +16 -22
  73. package/dist/src/commands/patch/tier.js +15 -25
  74. package/dist/src/commands/patch-rename-map.d.ts +19 -0
  75. package/dist/src/commands/patch-rename-map.js +36 -0
  76. package/dist/src/commands/pending-resolution.d.ts +15 -0
  77. package/dist/src/commands/pending-resolution.js +25 -0
  78. package/dist/src/commands/re-export-adjacent.d.ts +24 -2
  79. package/dist/src/commands/re-export-adjacent.js +32 -16
  80. package/dist/src/commands/re-export-bulk-scan.js +6 -10
  81. package/dist/src/commands/re-export-drift.js +4 -4
  82. package/dist/src/commands/re-export-files.d.ts +2 -3
  83. package/dist/src/commands/re-export-files.js +38 -29
  84. package/dist/src/commands/re-export-lint.d.ts +3 -4
  85. package/dist/src/commands/re-export-lint.js +9 -2
  86. package/dist/src/commands/re-export-scan.js +5 -2
  87. package/dist/src/commands/re-export-single.d.ts +22 -3
  88. package/dist/src/commands/re-export-single.js +19 -11
  89. package/dist/src/commands/re-export.js +28 -11
  90. package/dist/src/commands/rebase/abort.js +3 -8
  91. package/dist/src/commands/rebase/continue.js +12 -12
  92. package/dist/src/commands/rebase/index.js +2 -2
  93. package/dist/src/commands/rebase/patch-loop.d.ts +2 -2
  94. package/dist/src/commands/rebase/patch-loop.js +2 -7
  95. package/dist/src/commands/resolve.js +18 -10
  96. package/dist/src/commands/run.d.ts +0 -13
  97. package/dist/src/commands/run.js +12 -25
  98. package/dist/src/commands/setup-support.d.ts +16 -4
  99. package/dist/src/commands/setup-support.js +16 -16
  100. package/dist/src/commands/setup.js +3 -9
  101. package/dist/src/commands/source.js +8 -1
  102. package/dist/src/commands/status-check.js +2 -2
  103. package/dist/src/commands/status-output.js +2 -2
  104. package/dist/src/commands/status.js +3 -4
  105. package/dist/src/commands/test-build-phase.d.ts +2 -2
  106. package/dist/src/commands/test-build-phase.js +31 -7
  107. package/dist/src/commands/test-modes.d.ts +2 -2
  108. package/dist/src/commands/test-modes.js +0 -1
  109. package/dist/src/commands/test-register.js +5 -3
  110. package/dist/src/commands/test-run.js +25 -18
  111. package/dist/src/commands/test-stale-gate.js +4 -4
  112. package/dist/src/commands/test-verdict.d.ts +16 -2
  113. package/dist/src/commands/test-verdict.js +18 -3
  114. package/dist/src/commands/test.js +42 -5
  115. package/dist/src/commands/token-list.d.ts +26 -0
  116. package/dist/src/commands/token-list.js +153 -0
  117. package/dist/src/commands/token.js +17 -1
  118. package/dist/src/commands/tree.js +7 -2
  119. package/dist/src/commands/typecheck.js +9 -9
  120. package/dist/src/commands/wire.js +4 -3
  121. package/dist/src/core/branding.d.ts +0 -7
  122. package/dist/src/core/branding.js +3 -2
  123. package/dist/src/core/browser-wire.js +6 -3
  124. package/dist/src/core/build-audit-platform.d.ts +29 -0
  125. package/dist/src/core/build-audit-platform.js +140 -0
  126. package/dist/src/core/build-audit-registration.js +3 -2
  127. package/dist/src/core/build-audit-skip.d.ts +57 -0
  128. package/dist/src/core/build-audit-skip.js +112 -0
  129. package/dist/src/core/build-audit.d.ts +15 -2
  130. package/dist/src/core/build-audit.js +110 -10
  131. package/dist/src/core/build-baseline.d.ts +45 -28
  132. package/dist/src/core/build-baseline.js +6 -29
  133. package/dist/src/core/build-jar-escalation.js +3 -2
  134. package/dist/src/core/build-overwrite-guard.js +1 -1
  135. package/dist/src/core/build-prepare.d.ts +0 -8
  136. package/dist/src/core/build-prepare.js +7 -12
  137. package/dist/src/core/config-paths.d.ts +2 -6
  138. package/dist/src/core/config-paths.js +4 -2
  139. package/dist/src/core/config-state.d.ts +0 -6
  140. package/dist/src/core/config-state.js +1 -19
  141. package/dist/src/core/config-validate-build-audit.d.ts +8 -0
  142. package/dist/src/core/config-validate-build-audit.js +80 -0
  143. package/dist/src/core/config-validate-file-size.js +1 -1
  144. package/dist/src/core/config-validate-patch-policy.d.ts +2 -2
  145. package/dist/src/core/config-validate-patch-policy.js +3 -3
  146. package/dist/src/core/config-validate-test-toolchains.d.ts +2 -2
  147. package/dist/src/core/config-validate-test-toolchains.js +4 -5
  148. package/dist/src/core/config-validate.js +14 -3
  149. package/dist/src/core/config.d.ts +2 -2
  150. package/dist/src/core/config.js +11 -15
  151. package/dist/src/core/coverage-extend.js +4 -4
  152. package/dist/src/core/discard-baseline.js +7 -7
  153. package/dist/src/core/engine-precondition.d.ts +1 -9
  154. package/dist/src/core/engine-precondition.js +3 -8
  155. package/dist/src/core/engine-session-lock.js +13 -17
  156. package/dist/src/core/export-moved-code-shape.js +2 -2
  157. package/dist/src/core/file-lock-owner.d.ts +103 -0
  158. package/dist/src/core/file-lock-owner.js +259 -0
  159. package/dist/src/core/file-lock.d.ts +2 -7
  160. package/dist/src/core/file-lock.js +40 -91
  161. package/dist/src/core/firefox-cache.d.ts +13 -1
  162. package/dist/src/core/firefox-cache.js +59 -34
  163. package/dist/src/core/firefox-download.d.ts +15 -0
  164. package/dist/src/core/firefox-download.js +37 -21
  165. package/dist/src/core/firefox-extract.d.ts +6 -5
  166. package/dist/src/core/firefox-extract.js +36 -18
  167. package/dist/src/core/firefox.d.ts +30 -32
  168. package/dist/src/core/firefox.js +5 -36
  169. package/dist/src/core/furnace-apply-dry-run.d.ts +1 -6
  170. package/dist/src/core/furnace-apply-dry-run.js +1 -7
  171. package/dist/src/core/furnace-apply-helpers.d.ts +0 -11
  172. package/dist/src/core/furnace-apply-helpers.js +8 -19
  173. package/dist/src/core/furnace-apply-overwrite-warn.js +4 -2
  174. package/dist/src/core/furnace-apply.d.ts +1 -1
  175. package/dist/src/core/furnace-apply.js +30 -10
  176. package/dist/src/core/furnace-config.d.ts +1 -1
  177. package/dist/src/core/furnace-config.js +23 -36
  178. package/dist/src/core/furnace-constants.js +3 -2
  179. package/dist/src/core/furnace-dir-entry.d.ts +20 -0
  180. package/dist/src/core/furnace-dir-entry.js +21 -0
  181. package/dist/src/core/furnace-operation.d.ts +2 -0
  182. package/dist/src/core/furnace-operation.js +3 -1
  183. package/dist/src/core/furnace-refresh.d.ts +18 -6
  184. package/dist/src/core/furnace-refresh.js +3 -6
  185. package/dist/src/core/furnace-registration-ast.d.ts +0 -1
  186. package/dist/src/core/furnace-registration-ast.js +0 -3
  187. package/dist/src/core/furnace-registration.d.ts +2 -1
  188. package/dist/src/core/furnace-registration.js +22 -29
  189. package/dist/src/core/furnace-scanner.js +3 -6
  190. package/dist/src/core/furnace-state-persist.d.ts +2 -2
  191. package/dist/src/core/furnace-validate-registration.js +5 -7
  192. package/dist/src/core/git-base.d.ts +3 -1
  193. package/dist/src/core/git-base.js +25 -3
  194. package/dist/src/core/git-diff-new-file.d.ts +40 -0
  195. package/dist/src/core/git-diff-new-file.js +119 -0
  196. package/dist/src/core/git-diff.d.ts +9 -9
  197. package/dist/src/core/git-diff.js +88 -157
  198. package/dist/src/core/git-file-ops.d.ts +0 -7
  199. package/dist/src/core/git-file-ops.js +4 -28
  200. package/dist/src/core/git-readonly-index.d.ts +0 -2
  201. package/dist/src/core/git-readonly-index.js +0 -4
  202. package/dist/src/core/git-status.d.ts +7 -0
  203. package/dist/src/core/git-status.js +21 -9
  204. package/dist/src/core/git.d.ts +0 -6
  205. package/dist/src/core/git.js +14 -43
  206. package/dist/src/core/harness-orphans.js +8 -7
  207. package/dist/src/core/history-log.d.ts +19 -0
  208. package/dist/src/core/history-log.js +29 -0
  209. package/dist/src/core/mach-error-hints.d.ts +0 -5
  210. package/dist/src/core/mach-error-hints.js +1 -1
  211. package/dist/src/core/mach-known-noise-filter.d.ts +4 -3
  212. package/dist/src/core/mach-known-noise-filter.js +7 -9
  213. package/dist/src/core/mach-mozconfig.js +2 -1
  214. package/dist/src/core/mach-python.d.ts +2 -0
  215. package/dist/src/core/mach-python.js +12 -7
  216. package/dist/src/core/mach-resource-shim.d.ts +13 -1
  217. package/dist/src/core/mach-resource-shim.js +112 -9
  218. package/dist/src/core/mach.d.ts +39 -53
  219. package/dist/src/core/mach.js +13 -81
  220. package/dist/src/core/marionette-port.d.ts +0 -17
  221. package/dist/src/core/marionette-port.js +12 -58
  222. package/dist/src/core/marionette-preflight.js +9 -27
  223. package/dist/src/core/mochitest-server-port.js +3 -3
  224. package/dist/src/core/moz-manifest-rules.js +37 -33
  225. package/dist/src/core/patch-apply-fuzz.js +4 -4
  226. package/dist/src/core/patch-apply.d.ts +4 -5
  227. package/dist/src/core/patch-apply.js +6 -12
  228. package/dist/src/core/patch-export-coverage.d.ts +0 -9
  229. package/dist/src/core/patch-export-coverage.js +1 -30
  230. package/dist/src/core/patch-export-update.d.ts +18 -1
  231. package/dist/src/core/patch-export-update.js +4 -3
  232. package/dist/src/core/patch-export.d.ts +11 -21
  233. package/dist/src/core/patch-export.js +44 -66
  234. package/dist/src/core/patch-files.d.ts +0 -4
  235. package/dist/src/core/patch-files.js +0 -11
  236. package/dist/src/core/patch-lint-binary.d.ts +6 -12
  237. package/dist/src/core/patch-lint-cache.d.ts +17 -1
  238. package/dist/src/core/patch-lint-cache.js +3 -6
  239. package/dist/src/core/patch-lint-checkjs.d.ts +33 -16
  240. package/dist/src/core/patch-lint-checkjs.js +37 -24
  241. package/dist/src/core/patch-lint-creators.d.ts +2 -6
  242. package/dist/src/core/patch-lint-cross.d.ts +16 -8
  243. package/dist/src/core/patch-lint-cross.js +49 -92
  244. package/dist/src/core/patch-lint-css.js +10 -6
  245. package/dist/src/core/patch-lint-file-size.js +3 -3
  246. package/dist/src/core/patch-lint-forward-registration.d.ts +58 -0
  247. package/dist/src/core/patch-lint-forward-registration.js +210 -0
  248. package/dist/src/core/patch-lint-jsdoc.js +36 -36
  249. package/dist/src/core/patch-lint-module-registration.d.ts +2 -8
  250. package/dist/src/core/patch-lint-prettier.js +1 -1
  251. package/dist/src/core/patch-lint-queue-types.d.ts +60 -0
  252. package/dist/src/core/patch-lint-queue-types.js +12 -0
  253. package/dist/src/core/patch-lint-staged-registration.d.ts +61 -0
  254. package/dist/src/core/patch-lint-staged-registration.js +90 -0
  255. package/dist/src/core/patch-lint.d.ts +14 -9
  256. package/dist/src/core/patch-lint.js +4 -3
  257. package/dist/src/core/patch-lock.js +3 -3
  258. package/dist/src/core/patch-manifest-io.js +8 -7
  259. package/dist/src/core/patch-parse.d.ts +19 -8
  260. package/dist/src/core/patch-parse.js +25 -0
  261. package/dist/src/core/patch-policy.js +15 -27
  262. package/dist/src/core/patch-source-metadata.d.ts +1 -3
  263. package/dist/src/core/patch-source-metadata.js +0 -4
  264. package/dist/src/core/patch-transform.d.ts +0 -8
  265. package/dist/src/core/patch-transform.js +0 -10
  266. package/dist/src/core/process-cpu-time.js +1 -1
  267. package/dist/src/core/rebase-session.d.ts +2 -8
  268. package/dist/src/core/rebase-session.js +5 -17
  269. package/dist/src/core/register-browser-content.js +3 -2
  270. package/dist/src/core/register-browser-test.js +2 -1
  271. package/dist/src/core/register-module.js +2 -1
  272. package/dist/src/core/register-scaffold.js +5 -4
  273. package/dist/src/core/register-shared-css.js +2 -1
  274. package/dist/src/core/run-log-redact.d.ts +38 -0
  275. package/dist/src/core/run-log-redact.js +69 -0
  276. package/dist/src/core/run-log.d.ts +13 -2
  277. package/dist/src/core/run-log.js +67 -18
  278. package/dist/src/core/signal-critical.js +2 -4
  279. package/dist/src/core/smoke-patterns.d.ts +0 -1
  280. package/dist/src/core/smoke-patterns.js +6 -11
  281. package/dist/src/core/state-file.d.ts +12 -1
  282. package/dist/src/core/state-file.js +14 -1
  283. package/dist/src/core/status-classify.js +2 -1
  284. package/dist/src/core/test-changed-prefs.js +26 -11
  285. package/dist/src/core/test-harness-crash.js +2 -2
  286. package/dist/src/core/test-stale-check.d.ts +0 -2
  287. package/dist/src/core/test-stale-check.js +2 -2
  288. package/dist/src/core/test-xpcshell-retry.d.ts +26 -7
  289. package/dist/src/core/test-xpcshell-retry.js +14 -11
  290. package/dist/src/core/token-category.d.ts +33 -0
  291. package/dist/src/core/token-category.js +56 -48
  292. package/dist/src/core/token-dark-mode.js +2 -14
  293. package/dist/src/core/token-inventory.d.ts +56 -0
  294. package/dist/src/core/token-inventory.js +109 -0
  295. package/dist/src/core/token-manager.d.ts +1 -1
  296. package/dist/src/core/token-manager.js +1 -1
  297. package/dist/src/core/token-variant.js +21 -22
  298. package/dist/src/core/toolchain-preflight.js +1 -1
  299. package/dist/src/core/tree-cow.d.ts +0 -2
  300. package/dist/src/core/tree-cow.js +2 -2
  301. package/dist/src/core/tree-store.js +4 -5
  302. package/dist/src/core/typecheck.js +2 -2
  303. package/dist/src/core/wire-dom-fragment.js +6 -17
  304. package/dist/src/core/wire-utils.js +5 -17
  305. package/dist/src/core/xpcshell-appdir.js +2 -4
  306. package/dist/src/errors/base.d.ts +53 -1
  307. package/dist/src/errors/base.js +79 -11
  308. package/dist/src/errors/build.js +20 -28
  309. package/dist/src/errors/config.d.ts +0 -14
  310. package/dist/src/errors/config.js +13 -34
  311. package/dist/src/errors/download.d.ts +13 -0
  312. package/dist/src/errors/download.js +53 -24
  313. package/dist/src/errors/furnace.js +8 -9
  314. package/dist/src/errors/git.d.ts +8 -8
  315. package/dist/src/errors/git.js +20 -30
  316. package/dist/src/errors/patch.js +8 -10
  317. package/dist/src/errors/rebase.js +7 -7
  318. package/dist/src/types/commands/options.d.ts +77 -123
  319. package/dist/src/types/config.d.ts +34 -2
  320. package/dist/src/types/index.d.ts +0 -1
  321. package/dist/src/utils/assert.d.ts +3 -15
  322. package/dist/src/utils/assert.js +1 -18
  323. package/dist/src/utils/build-info.d.ts +1 -1
  324. package/dist/src/utils/child-env.d.ts +2 -1
  325. package/dist/src/utils/elapsed.js +10 -1
  326. package/dist/src/utils/fs.js +8 -13
  327. package/dist/src/utils/hash.d.ts +12 -0
  328. package/dist/src/utils/hash.js +16 -0
  329. package/dist/src/utils/line-dispatch.d.ts +15 -0
  330. package/dist/src/utils/line-dispatch.js +48 -0
  331. package/dist/src/utils/options.d.ts +1 -7
  332. package/dist/src/utils/options.js +6 -7
  333. package/dist/src/utils/package-root.js +3 -12
  334. package/dist/src/utils/platform.d.ts +0 -4
  335. package/dist/src/utils/process-group.d.ts +2 -1
  336. package/dist/src/utils/process-group.js +20 -19
  337. package/dist/src/utils/process.d.ts +40 -26
  338. package/dist/src/utils/process.js +115 -166
  339. package/dist/src/utils/ps-duration.js +3 -3
  340. package/dist/src/utils/sleep.d.ts +26 -0
  341. package/dist/src/utils/sleep.js +31 -0
  342. package/dist/src/utils/validation.d.ts +2 -38
  343. package/dist/src/utils/validation.js +7 -16
  344. package/docs/configuration.md +89 -1
  345. package/docs/exit-codes.md +31 -19
  346. package/docs/lifecycle-invariants.md +18 -12
  347. package/docs/machine-output.md +31 -8
  348. package/docs/patch-workflow.md +2 -1
  349. package/docs/run-logs.md +44 -4
  350. package/docs/testing.md +8 -1
  351. package/package.json +8 -7
  352. package/dist/src/commands/rebase.d.ts +0 -4
  353. package/dist/src/commands/rebase.js +0 -5
  354. package/dist/src/core/patch-artifact-normalize.d.ts +0 -21
  355. package/dist/src/core/patch-artifact-normalize.js +0 -24
  356. package/dist/src/core/patch-lint-reexports.d.ts +0 -7
  357. package/dist/src/core/patch-lint-reexports.js +0 -8
  358. package/dist/src/core/wire-targets.d.ts +0 -7
  359. package/dist/src/core/wire-targets.js +0 -8
package/CHANGELOG.md CHANGED
@@ -1,5 +1,85 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.46.0
4
+
5
+ ### Downstream friction
6
+
7
+ - **A successful build printed seven `Audit:` warnings, none of them real, and summarised `4 missing` on a run with zero real misses.** The post-build packaging audit is warn-only, which makes a false positive cheap alone and ruinous in aggregate: an operator who learns these lines are usually wrong stops reading the one that is right. Four distinct causes, each now its own classification with its own counted skip reason. (1) `--with-branding` selects ONE branding tree; files under the others are not inputs to the objdir at all, so "no packaged artifact" is their expected state — and worse, the basename walker matched them against the SELECTED branding's same-named artifact and reported them "newer than its packaged artifact", a stale claim about a comparison with no meaning. The selected branding is read from the `engine/mozconfig` FireForge generates, through the `extractWithBrandingPath` that already existed for the branding-agreement assertion; when the mozconfig cannot be read or names no branding, the old behaviour is kept deliberately — a skip that cannot name its evidence is a masked warning. (2) `detectPlatformGate` reads the file's OWN nearest `moz.build`, but Firefox gates whole subtrees one or more levels up: nothing in `toolkit/mozapps/defaultagent/moz.build` says "Windows only", yet `toolkit/moz.build` reaches it through a `DIRS +=` nested inside `OS_ARCH == "WINNT"`, a `CC_TYPE` test and `MOZ_DEFAULT_BROWSER_AGENT`. A new ancestor walk collects the FULL enclosing `if` stack for the `DIRS` entry that reaches the file's directory — not the innermost, which `findEnclosingGate` returns, because it is the OUTERMOST one that excludes the directory off-Windows — and only quoted values inside a `DIRS` assignment are considered, so an unrelated string literal cannot gate a subtree. The walk is bounded (12 ancestors, 256 KiB per `moz.build`) and an unreadable or oversized ancestor falls back to warning. (3) `*.stories.mjs` / `*.stories.js` are rendered by the in-tree Storybook's own Vite config and carry no packaging entry; the suffix list is closed and documented rather than generalised to "anything with a dotted infix", which would swallow the `*.worker.js` and `*.sys.mjs` that ARE packaged. (4) A deliberately unpackaged source — a type-only mirror whose header says it is never loaded — is the one case FireForge cannot derive from the tree, so it takes a declaration: `buildAudit.unpackaged: [{ path, reason }]` in `fireforge.json`, `reason` required and non-empty because the declaration IS the evidence. It follows the 0.45.0 `--expect-unmanaged` model: admitted paths are LISTED, never silenced, and an admitted path that DOES resolve to a packaged artifact is reported as a STALE carve-out rather than suppressed — the declaration asserts a fact about the tree that has stopped being true. One deliberate divergence from that model: a declaration matching nothing that changed is NOT reported. `--expect-unmanaged` reports its unmet entries because it is a per-invocation flag list where an unmet entry is probably a typo in the command just typed; this is a standing list checked only against the files that happened to change, so "not met" is the normal case and reporting it would put a warning on every build. `**` in a carve-out path is refused outright — a subtree exception is how a reviewed one quietly becomes a blanket one. The `Packaged:` line now names every non-zero skip class with its count, so the four false `missing` cannot recur without a counterpart in the numbers an operator can check. A downstream-shaped fixture (selected branding, unselected branding, ancestor-gated directory, story file, declared mirror) asserts zero warnings and the per-class counts, with a negative control per class — a classifier that skipped unconditionally would also pass a zero-warnings assertion.
8
+ - **Considered and rejected: a header marker instead of the config list.** A first-comment `fireforge: unpackaged` line would keep the declaration next to the file it describes, which is the strongest argument for it, and the 0.45.0 reasoning against standing config lists ("a standing exception nobody re-reads is exactly what this refusal exists to prevent") appears to apply. It does not, and the difference is what decided it: `--expect-unmanaged` guards a REFUSAL, where a forgotten carve-out silently disarms a gate on the next run; this guards a WARNING, and the failure mode is the opposite — nobody can review a suppression they cannot enumerate. A marker is invisible to anyone auditing what a project has admitted, cannot be diffed as a set, and puts the rationale in the one place a reader of `fireforge.json` will not look. The config list is reviewable in one place, is what `git blame` answers questions about, and — because admitted paths are listed on every run and a stale one is reported — cannot rot unnoticed the way the 0.45.0 entry feared.
9
+ - **`fireforge test` preflight refusals were invisible in redirected output.** With stdout redirected, both `fireforge test …` and `fireforge -v test …` printed only `FIREFORGE-VERDICT: FAIL reason=preflight`, and the run log the line named held only the pre-test build. The gates were right; the text was lost twice over, for two independent reasons. `emitFailVerdict` calls `setStdoutSealed(true)` — the seal that keeps the verdict the run's LAST stdout write — and `routeToStderr()` then sends everything the CLI error boundary renders to stderr, so a `> file` capture kept the verdict and dropped the reason. And that rendering happens after `testCommand`'s `finally` has already closed and flushed the run log, so the artifact the verdict's own `log=` key points at never contained the refusal either. The obvious fix — unsealing, or rendering to stdout from the boundary — trades away the contract that made the verdict readable in the first place. Instead the refusal's own text is written to stdout AND into the run log inside `testCommand`'s catch, BEFORE the verdict is emitted: both channels get the reason and the verdict is still last. Preflight gates that classify themselves now throw `PreflightRefusalError`, which carries a stable kebab-case class and reports it as an additive `note=` on the verdict line (`stale-browser`, `coverage-replaced`, `marionette-port-busy`, …). `reason=` is unchanged and still comes from the closed set, `note=` joins the existing additive `log=`/`signal=`/`shards=` keys, and a gate that names no class emits no `note=` — so a consumer tokenising `key=value` pairs is unaffected. `PreflightRefusalError` extends `GeneralError` and keeps exit 1: no exit code moves.
10
+ - **The staged-dependency registration rule was blind to binary-created files, and its other half did not exist.** Patch 102's `xpcshell.toml` registers `support-files = ["fixtures/*.sqlite"]` for a fixture patch 103 creates as `new file mode` + `GIT binary patch`. With the declaration present, lint reported `staged-dependency-unused`; with it removed, lint was equally silent. Two independent defects, and the symmetry is what made them invisible rather than merely absent. First, `detectNewFilesInDiff` does see the binary creation, but `buildPatchQueueContext` then drops it: `extractNewFileContent` correctly refuses to decode a binary body, and the `catch` left the path out of `newFiles` — the map the registration `creates` resolution indexed. So a declared dependency on a binary could never resolve. A new `createdFiles: Set<string>` beside `newFiles` carries every creation regardless of body kind, and the registration index is built from it; `newFiles` stays text-only, because 0.45.0's `buildNewFileTextProjection` reasoning still holds — a binary authors no imports, so the import rules gain nothing from it. A payload-less `Binary files … differ` section counts as a creation too whenever it carries `new file mode`: the file exists after apply either way, and only the body differs. Making the field required forced every projection site (`export --order`, `re-export --files`, `re-export --scan`, `patch split`, `patch move-files`) to supply it, which also collapsed three open-coded copies of the projection onto the existing `projectEntryBody`. Second, there was no "declaration needed" arm for registrations at all: `forward-import` is import-only, and registration metadata was checked for STALENESS once declared and never for absence. A new `forward-registration` rule flags a test manifest that registers a file only a later-ordered patch creates, resolving each `support-files` entry against the manifest's own directory and expanding a `*` within one path segment. Scope is deliberately narrow and the alternative is rejected outright: jar.mn packaging lines and actor/customElements registrations name their targets through indirection the linter cannot resolve without the build system, and a rule that guessed there would refuse correct queues on a Firefox-sized tree. The manifest basename list is closed (`xpcshell`, `mochitest`, `browser`, `chrome`, `a11y`, `.toml` and `.ini`) rather than "anything `.toml`", which would sweep in Cargo and taskcluster manifests whose `support-files`-shaped keys mean something else. Both rules share one apply-order predicate, so the arm that FLAGS an undeclared registration and the arm that VALIDATES a declared one cannot disagree about the same pair of patches. The new rule is an **error**, matching `forward-import` for the identical defect class, and consumers should expect it to find pre-existing instances: measured against the reporting fork's own 338-patch queue it raises 48 findings where there were previously zero cross-patch findings, from exactly three shared test manifests (102's `xpcshell.toml`, 111's and 201's `browser.toml`) whose directories later patches add fixtures to. Those are genuine — applying the queue up to 201 leaves `browser.toml` naming support files that do not exist yet, which is a bisect-time failure — so they are reported rather than tuned away; a rule narrowed until one fork's queue passed would be shaped to that fork's data, and an exact filename is a MORE certain forward reference than the glob the report was about, not less. Each message carries the paste-and-run `patch staged-dependency --add --kind registration` that discharges it, and reordering is the other option.
11
+ - **The `forward-registration` remedy did not discharge the finding it printed.** All 44 unique paste-and-run commands the new rule emitted on the reporting fork's 338-patch queue were run verbatim; the next `lint --per-patch --max-warnings 0` came back with zero `forward-registration` errors and **36 `staged-dependency-unused` warnings** — "the patch does not add that line" — on the declarations the remedy had just told the operator to write. The 0.46.0 claim that both arms "share one apply-order predicate, so the arm that FLAGS and the arm that VALIDATES cannot disagree about the same pair of patches" was true and insufficient: they agreed about ORDER and disagreed about the LINE, and the line is what the operator pastes. The rule resolved a `support-files` ENTRY and synthesised `--line` as `support-files = ["<entry>"]`, while `isRegistrationLinePresent` compares `--line`, whitespace-trimmed, against the lines the patch ADDS — a match only for a manifest whose array is single-line and single-entry. A Firefox tree writes the other two shapes constantly: 201's `browser.toml` lists its support files one per line inside a multi-line array, and 111's and 201's per-test sections use two-entry one-liners, so 36 of 44 synthesised lines matched nothing. `extractSupportFileEntries` now matches quoted tokens per PHYSICAL line instead of over the accumulated array body, so every entry carries the line it is written on, and the printed `--line` is that line — which is what `--line`'s help text has always promised ("as the patch adds it") and what `--remove` matches on. Entries are deduplicated across the manifest, and an entry spelled out on several added lines prints the first and says so: one declaration covers the file either way, because the declared check keys on `--file` and `--creates`. Registration lines carry their own quotes, so both messages that print a command now escape them for the shell. **Considered and rejected: relaxing the used-arm** to treat a declaration as used when the patch adds a line CONTAINING the declared entry as a quoted token. It would also have cleared the 36, and it is the smaller diff, but it makes `--line` mean two different things in the two directions — the add path would accept a line the patch never adds, and `--remove --line` would then have to carry the same relaxed match or a declaration would be unremovable by the command that created it. Printing the truth keeps one meaning. The fork's 44 hand-corrected declarations are exactly the shapes the fixed rule now emits, and a fixture pins each shape so a later change cannot strand them.
12
+ - **The `staged-dependency-unused` remedy named a command that refuses without its arguments, and `--patches` refused the ordinal it advertised.** The warning said to remove the stale declaration with `fireforge patch staged-dependency --remove --kind registration`, but that form refuses without `--file` AND `--line` (`--creates` only disambiguates) — and the `--line` it needs is the stale one the declaration holds, which the warning already had in hand. It now prints the whole command, shell-quoted, so the remedy is paste-and-run in both directions. Separately, `lint --per-patch --patches 102` was refused with "No patch in the queue matches" by a message that offers stems in the same breath; the bare order number is now an alias, both as the manifest records it and as the filename pads it (`2` and `002`).
13
+ - **`fireforge token list` and `token show` are new.** `token add` requires `--category` and refuses one the tokens CSS does not declare, but nothing reported the categories a project has — so naming one meant hand-parsing a neighbouring `= Category =` banner out of the file. `token list [--category <name>] [--json]` reports each banner with its tokens in file order; `token show <name> [--json]` reports the owning category and the value the token takes in every declaring block (base `:root`, the dark `@media` mirror, each `:root[variant]`) with the line of each. Both read through the parser `token add` and `token coverage` already use — a second tokens-CSS reader would let the report and the writer disagree about which section a token lives in, which is exactly the mistake `token list` exists to prevent. Two shapes are reported rather than dropped, because dropping them would make a real file look like a different one: a declaration above the first banner (listed under `(no category)`) and a banner with no tokens under it (still listed — `token add --category` accepts it). An unknown `--category` is refused by naming the ones that exist, since a filter that printed nothing would read as "this category is empty", which is a different fact.
14
+ - **`--refuse-adjacent-unmanaged` named no anchor — and the report's other half is refuted.** The notice named the offending file and the patch but not WHICH of the patch's owned locations the file sits beside, so an unattended run could not triage a refusal on a patch owning files in several directories. Both the per-patch notice and the run-level refusal now name the anchoring directory and state the rule in one clause. The rest of the report does not survive checking: adjacency is NOT scoped to a shared ancestor. `findAdjacentUnmanagedFiles` has always scanned exactly the immediate directories of the patch's own `filesAffected` — there is nothing to narrow. Verified against the fork that reported it: `905-branding-trademark-artwork.patch` owns files only under `…/test/about/` and `…/test/contextMenu/`, so a file under `…/test/hominis-tiles/` cannot be adjacent to it under the current rule; the notice must have named one of the ~150 patches that DO own files in `hominis-tiles`, and the anchor is precisely what would have made that legible. The belt still catches a peer's file in the SAME directory as an owned file, which is the case it exists for, and the 0.45.0 residual blind spot (a cousin directory no owned file lives in) is unchanged.
15
+ - **The mach resource-monitor shim degraded on every 153.x build, and the reported diagnosis was wrong.** Every build printed `FireForge: host resource monitor degraded (SystemResourceMonitor.record_marker() takes 4 positional arguments but 5 were given)`, which reads as a stale arity in the shim's wrapper. It is not: the wrapper has been `(self, *args, **kwargs)` passthrough all along, so making it "signature-agnostic" would have changed nothing. The real cause is a DESCRIPTOR mismatch. In 153, `SystemResourceMonitor.record_marker` and `record_event` are `@staticmethod`s taking no receiver. `getattr(cls, name)` hands back the underlying plain function; setting that back on the class re-binds it as an ordinary instance method, so an instance-qualified call threads the receiver through as a fifth positional and the original raises — which tripped the shim's own degrade path and disabled resource monitoring for the whole build. The install loop now reads the raw class attribute with `inspect.getattr_static`, wraps a `staticmethod`/`classmethod` without injecting a receiver, and re-applies the descriptor it actually had; those wrappers consult the module-level host-degraded flag, since a static method has no instance to carry one. The degrade path is kept for a genuinely incompatible host. Pinned by a python3 harness that declares a 4-argument `@staticmethod record_marker` exactly as 153.0esr does and asserts both call shapes return the real value with no warning; the pre-fix source reproduces the reported message byte for byte. Deliberately NOT folded together with the recognised `stop_time`/`AttributeError` teardown noise `docs/testing.md` annotates — that is cosmetic upstream shutdown noise after `SUITE_END`, this silently disabled a subsystem for the whole run. Also confirmed rather than assumed: the shim directory that ships is the per-user, uid-suffixed `<tmpdir>/fireforge-mach-resource-shim-<uid>`, created `0700` and `lstat`-verified for ownership and mode before it joins `PYTHONPATH` — not the per-dispatch `mkdtemp` the 0.45.0 entry described and did not ship.
16
+
17
+ ### Integrity
18
+
19
+ - **Checksum verification failed open.** When Mozilla's `SHA256SUMS` could not be fetched — a 404, a captive portal's HTML, a network error — the tarball was accepted on TLS alone with a warning. That is precisely the condition a network-positioned attacker can arrange, and it defeated the whole "trusted baseline + patches" model: an unverified archive became the git baseline. Unpinned downloads now fail closed with a `ChecksumUnavailableError` (exit 3) naming the three ways out: fix connectivity, pin `firefox.sha256`, or set the new `firefox.allowUnverifiedDownload: true` for a trusted mirror (`fireforge config set` accepts the key). A digest that is fetched but does not match is never accepted, opt-out or not. The checksum request also had no timeout and no retry, unlike the tarball fetch; it now goes through the same `fetchWithRetry` — the same 60 s abort ceiling per attempt and the same three-attempt back-off on 429/5xx and network errors — so an unresponsive checksum host cannot hang the command after the tarball has landed, and one dropped connection on the small checksum request does not fail the verification closed and discard a 600 MB archive that downloaded fine.
20
+ - **Archive preflight let `.git` members through.** `isUnsafeArchivePath` rejected only traversal. An archive carrying `firefox-x/.git/config` or `.git/hooks/*` extracted cleanly, and the subsequent `git init`/commit honoured whatever it configured (`core.hooksPath`, `core.fsmonitor`, hooks). Any member with a `.git` path segment at any depth is now rejected before extraction; `.gitmodules` and `.gitignore` remain legitimate upstream files and pass.
21
+ - **The mach PYTHONPATH shim lived at a fixed shared temp path.** The 0.45.0 entry described the shim as "minted per dispatch with `mkdtemp`"; that design did not ship. What shipped wrote `$TMPDIR/fireforge-mach-resource-shim/sitecustomize.py` with the default mode and no ownership check, so on a shared `/tmp` another local account could pre-create the directory and have its Python run inside every build and test dispatch. The directory is now per-user (uid-suffixed via `process.getuid`, not `os.userInfo()`, which throws for a uid with no passwd entry — containers run as an arbitrary uid would otherwise lose every build and test dispatch; username on Windows), created `0700`, and `lstat`-verified as a real directory owned by the current uid and not group/world-writable before it is placed on `PYTHONPATH`; the ownership and mode checks are skipped on Windows, where the per-user name still applies.
22
+ - **Run logs were written unredacted.** The `.fireforge/logs` tee copied every byte, including the environment dumps mach prints. A conservative per-line pass now masks `KEY=value` where `KEY` matches `TOKEN|SECRET|PASSWORD|PASSWD|API_KEY|AUTH` (case-insensitive), including the `--password=…` and `-DAUTH_TOKEN=…` spellings mach and configure echo from their argv, and the credential in `Authorization:` headers — file sink only, terminal untouched, lines split across chunks handled. Bare tokens in URLs, space-separated CLI flag values, or JSON fields still pass through; `docs/run-logs.md` now states exactly what is and is not masked instead of "raw bytes". The tee treats a lone `\r` as a line end (progress-bar repaints never send a newline), caps the held partial line at 1 MiB so a newline-free stream cannot grow memory without bound, and is flushed from the SIGINT/SIGTERM path so a killed run's last line survives.
23
+
24
+ ### Locks
25
+
26
+ - **A failed owner-record write could let two builds into the critical section.** `withFileLock` created the lock directory and then wrote the PID/token file best-effort. A failed write left an owner-less lock, which stale recovery reaps by age after five minutes regardless of whether the holder is alive. The build lock legitimately holds for hours, so a holder whose record failed to write was reaped mid-build and a second `fireforge build` entered. The write is now fatal: it is re-read and verified, and on any failure the just-created directory is removed and a `GeneralError` ("Could not record ownership of lock …") is raised before the operation runs. A read-back that ERRORS (an on-access scanner briefly holding the file on Windows, a FUSE mount's `EIO`) is retried a few times and then reported with its errno, rather than being mistaken for a record that never landed.
27
+ - **A recycled PID kept a dead lock honoured until the waiter's timeout.** Liveness was `process.kill(pid, 0)`, so a crashed holder's PID reused by a long-lived daemon looked alive. On Linux the owner record now carries the holder's own boot-relative start tick (`start-tick=`, field 22 of `/proc/self/stat`), placed among the metadata lines so pre-existing records and readers stay compatible; a stale probe reads the candidate's tick from `/proc/<pid>/stat` and a different tick is a different process, so the lock is stale. Ticks rather than wall-clock times, deliberately: `btime` moves with every clock step on current kernels, so comparing a recorded acquisition time against a `btime`-derived start would have reaped a LIVE build lock after NTP stepped the clock forward. The record also carries `acquired-at-ms=` for diagnostics only. macOS and Windows keep PID-only liveness deliberately: their only start-time source is a `ps`/`wmic` spawn per poll, not worth the cost. `docs/lifecycle-invariants.md` records both contracts and the suites that pin them.
28
+
29
+ ### Exit codes
30
+
31
+ - **Commander usage errors exited 1, not the documented 8.** An unknown command, unknown option, or missing argument was terminated by commander itself before any command action — and hence before `withErrorHandling` — ran, so `docs/exit-codes.md` was wrong for the most common refusal, and under `--json` there was no envelope at all. The program now installs `exitOverride()`, inherited by every subcommand, and the entry path maps the resulting `CommanderError` to `INVALID_ARGUMENT` with the standard `{"schemaVersion":1,"error":…,"code":"invalid-argument"}` envelope when `--json` is present. `--help` and `--version` still exit 0 with unchanged output, and so does a command group invoked without a subcommand (`fireforge`, `fireforge tree`, `fireforge source`): that is an informational invocation, not a wrong flag, and it now matches the exit 0 `patch`, `token` and `furnace` already gave through an explicit help action (`tree` and `source` gained the same action, so their help lands on stdout).
32
+ - **`GitNotFoundError` exited 4 while the docs said 7.** It extends `GitError` and inherited `GIT_ERROR`; `MachNotFoundError` and `PythonNotFoundError` override to `MISSING_DEPENDENCY`, and CI routing on 7 for "install the toolchain" missed git. It now carries 7 like its siblings. A new contract test instantiates every concrete `FireForgeError` subclass, asserts its code, and parses the table in `docs/exit-codes.md` in both directions — every class has a row, no row names a class that does not exist. The table was also corrected for several classes it never listed.
33
+ - **Manifest invariant failures rendered as "Unexpected error" plus a stack.** `mutatePatchRowsInManifest` re-checked the raw manifest shape after `validatePatchesManifest` had already accepted it and threw bare `Error`. Those conditions cannot be reached by user input, so they are now `assert()` calls raising `InternalInvariantError`, which the CLI formats as a bug report.
34
+
35
+ ### Patch queue
36
+
37
+ - **`patch compact` could commit renames the operator never confirmed.** After the prompt, it recomputed the rename plan from the manifest under the patch-directory lock and applied whatever came out; a patch exported concurrently between prompt and lock was renumbered without ever appearing in the confirmed summary, and the history entry recorded the run as consented. Compact now applies the same drift gate as `patch reorder` — the recomputed plan must equal the confirmed one, otherwise it refuses with "Patch queue changed while waiting for confirmation" and nothing is renamed.
38
+
39
+ ### Patch bodies
40
+
41
+ - **A NEW non-UTF-8 text file was corrupted on export, and every hash-keyed check stayed green.** The synthesized new-file body was built from a UTF-8 decode — every Latin-1 high byte became U+FFFD — while the `index` line's blob hash came from `git hash-object` over the real bytes. The patch applied, wrote different bytes than the tree held, and drift detection flagged the file forever because the recorded hash never matched the body. Text-vs-binary for a new file is now decided in one place: a NUL byte or invalid UTF-8 (strict `TextDecoder`, BOM preserved) sends the file down the binary-patch arm. Because git's own NUL-only heuristic would still render a Latin-1 file as a text diff, the binary arm forces `-diff` through a throwaway `core.attributesFile` so `git diff --binary` emits a byte-faithful `GIT binary patch`. A real-git test exports a Latin-1 file through all three arms, applies it in a fresh clone, and asserts byte- and blob-hash-equality. A TRACKED, MODIFIED non-UTF-8 file still goes through plain `git diff --binary HEAD` and remains subject to the same decode loss; that is a separate change.
42
+ - **Worktree-side renames produced a phantom status entry.** `parsePorcelainStatus` consumed the second NUL record (the original path) only when the INDEX column was `R`/`C`, but git ≥ 2.18 with `status.renames` emits an unstaged rename as ` R new\0old\0`. The `old` record was then parsed as its own entry whose status was its first two path characters. Either column being `R`/`C` now consumes the original path.
43
+
44
+ ### Diagnostics
45
+
46
+ - **A timed-out command threw away everything it had captured.** `exec()` collected stdout and stderr right up to the deadline and then built `ExecTimeoutError` from only the command and budget, so a `git add -A` killed after ten minutes gave no clue what git had been doing. The error now carries `stdout`, `stderr`, and an `outputTruncated` flag, and its user message shows the last 20 lines.
47
+ - **`exec()` accepted `processGroup` and `mirror` and ignored both.** They lived on the shared `ExecOptions` type although only the streaming and inherit variants read them — the "accepted but silently ignored" trap 0.45.0 already closed for `SmokeRunOptions`. The type is split so `exec()` cannot admit them; typecheck confirmed no caller was passing them.
48
+ - **`fireforge test --help` listed six verdict reasons; the type and `docs/machine-output.md` have seven.** `killed` is now in the epilogue with its meaning, and the help test pins the list against `FireforgeVerdictReason`.
49
+
50
+ ### Tooling
51
+
52
+ - **`@types/node` was two majors ahead of the supported floor, and nothing tested the floor.** `engines` says `>=22.22.2`, `@types/node` was `^26`, and CI ran only the `.nvmrc` version, so code could typecheck against APIs absent on Node 22. The types are now `^22`, which surfaced only test-side differences (path methods no longer `this: void`, `Dirent.path`), and a separate `node-floor` CI job runs the suite on Node 22, 24, and `.nvmrc` on Ubuntu — a new job rather than a wider matrix, so the ruleset-required check names are unchanged.
53
+ - **Three copy-pasted mach test wrappers collapsed.** `testWithOutput`/`xpcshellTestWithOutput`/`mochitestWithOutput` differed only in the mach subcommand and carried a five-positional-argument signature that forced callers to juggle arity. They are one `runMachTestSuite(kind, options)`; argv and env behaviour are unchanged. `initRepository`/`resumeRepository` likewise shared a duplicated stage-and-commit tail, now one helper; `export-flow` and `patch reorder` carried byte-identical rename-map helpers, now `patch-rename-map.ts`; the dry-run preview and `commitExportedPatch` each open-coded the same eleven-field planner input, now `buildPlanExportInput`, so the two paths cannot drift field by field.
54
+ - **`ReturnType<typeof X>` on local imports is now a lint error.** Fourteen files spelled `ReturnType<typeof getProjectPaths>` while `ProjectPaths` was exported beside the function; the structural spelling hides the contract and drifts silently. Every site now uses a named exported type, and `fireforge/no-return-type-of-import` keeps it that way (package imports such as `ReturnType<typeof spinner>` are exempt). `performRenameMutations` also lost four parameters it re-derived under the lock and never read.
55
+ - **Two lint rules were added for cleanups worth doing only once.** `@typescript-eslint/no-unnecessary-type-conversion` keeps `${String(n)}` out of template literals, which `restrict-template-expressions` has allowed unwrapped since it was configured with `allowNumber` — 204 sites spelled it the long way. `prefer-object-has-own` catches the last `Object.prototype.hasOwnProperty.call(...)`. Both were already satisfied when they landed, so neither carries a backlog.
56
+ - **The shared spawned-CLI test helper settled on `'exit'`.** stdio pipes can still hold the final chunk when a child's `'exit'` fires, so the verdict-line assertions that read the last line of stdout could flake on a loaded runner, and a spawn failure left the promise pending until vitest's timeout. It now settles on `'close'`, rejects on `'error'`, and reports signal deaths as 128 + signal like `exec()` does.
57
+ - **Wall-clock test budgets replaced.** The token-scan linearity test asserted an absolute 200 ms budget and the signal-critical test used `Date.now()` bounds, both flaky on loaded runners for reasons unrelated to the code. The linearity test now asserts time(4N)/time(N) stays well under quadratic after a warm-up; the signal test uses fake timers. fast-check property tests now cover diff-section round-trips (spaces, unicode, git octal quoting, CRLF, renames).
58
+
59
+ ### Dead code
60
+
61
+ - **Twenty-two exports had no caller outside the test suite, and the dead-code gate could not see them.** `knip` treats every `*.test.ts` as an entry point, so an export that only a test reaches counts as used. Under that rule `generateModificationDiff` (a 64-line tempfile diff helper), four superseded `mach` wrappers, `parseFilename`, `findExistingPatchForFile`, `findSupersededPatches`, `isMarionetteFlavor` (orphaned when its caller was replaced in 0.44.x), `getDownloadUrl`, `getTarballFilename`, `applyPatchToContent`, `tryReadRebaseSession`, `saveState`, `hasReadOnlyGitIndexScope`, `isDefined`, `assertDefined`, `isValidPatchCategory`, the `Arch` type, and the never-thrown `MissingFieldError` / `InvalidFieldError` / `DirtyRepositoryError` all stayed alive on test references alone. They are gone, along with the tests that existed only to exercise them and the barrel re-export lines that had no consumer left. `deadcode:check:production` now resolves only from `src/index.ts` and `bin/fireforge.ts` and is part of `release:check`, so this cannot re-accrete; the handful of deliberate test-only seams (`resetResolvedPython`, `resetBrokenPipeHandlerForTests`, `__resetFurnaceOperationStateForTests`, `DOCTOR_CHECK_ORDER`) are tagged `@internal` and exempt by name rather than by accident.
62
+ - **Dead code cascaded once its first layer was removed.** `isNewFilePatch` was reachable only from `findSupersededPatches`, which was itself reachable only from a test; deleting the outer function exposed the inner one. The production gate catches that second layer, which a one-pass sweep does not.
63
+ - **Three "impossible state" fallbacks were removed rather than documented.** `stageAllFilesChunked`'s caller wrapped it in a `try`/`catch` whose two arms both rethrew the error unchanged; `ParamsReturnsContext.skipParams` gated a branch nothing ever set, so the guard was always true; `FireForgeState.lastBuild` was declared, carried through the state-recovery allowlist, and never written or read. `migrateFurnaceConfig` migrated a version 2 that has never existed — its two real range checks moved into `validateFurnaceConfig` and the migration scaffolding went.
64
+ - **`fireforge import --stop-at` is removed.** It was registered as an alias for `--until` in the same commit that introduced `--until`, so it was never a migration path from an older spelling, and no documentation mentioned it.
65
+
66
+ ### Duplication
67
+
68
+ - **The same delay was written nine times, and the copies disagreed on the thing that matters.** Five modules had a private `sleep`/`delay`/`sweepDelay` and four more inlined `new Promise((resolve) => setTimeout(resolve, ms))`. Whether the pending timer keeps the event loop alive is load-bearing — an unref'd wait between a group SIGTERM and its SIGKILL escalation lets Node exit mid-grace and skip the escalation — yet that distinction was invisible at each call site. One `sleep(ms, { unref })` in `src/utils/sleep.ts` carries both behaviours and the reason for the default. It is deliberately built on the global `setTimeout` rather than `node:timers/promises`, because vitest's fake timers patch the global but not the promisified module, and every retry and grace-period path is driven by fake timers.
69
+ - **Eight `--order`-style flag parsers disagreed on what a valid integer is.** `parsePositiveIntegerFlag` already existed and rejects `"12abc"`, leading zeros and decimals with a strict pattern; five call sites re-implemented the check with `Number.parseInt`, which accepts `"12abc"` as `12`, and threw three different error classes between them. `patch split`, `patch move-files` and `patch rename` now route `--order` through the shared parser, so a typo is refused instead of silently becoming a different order.
70
+ - **A documented identity function sat on every patch write path.** `normalizePatchArtifact` returned its input unchanged at seven call sites, guarded by a comment warning against reintroducing whitespace trimming — a real past corruption, since Firefox sources contain whitespace-only context lines. Its property tests asserted idempotence and byte-preservation, both vacuously true of `x => x`. The warning is what had value, so it moved to the export write site, and the contract it protects is now pinned where it can actually fail: `git-diff-latin1-roundtrip.test.ts` applies an exported patch in a fresh clone and asserts blob-hash equality.
71
+ - **`await ensureGit()` was written before 32 of the 38 `git()` calls, and was not memoised.** Each call spawned `which git`. It is memoised and called inside `git()`; the six remaining explicit calls are the paths that shell out via `exec('git', …)` without going through the wrapper. `execGitWithAllowedExitCodes` was deleted outright: its `allowedExitCodes` parameter was dead at all eight call sites, and the rest of it duplicated `git()`.
72
+ - **One consolidation per repeated shape.** `sha256Hex` (every inlined one-shot `createHash` chain; the two streaming digests, which must not buffer a 500 MB tarball, stay as they are), `normalizePathSlashes` (twenty-seven open-coded `.replace(/\\/g, '/')`), `formatPatchOrder` (eleven `String(order).padStart(3, '0')`), `appendHistoryBestEffort` (eleven copies of the same try/warn block, whose swallow is deliberate and now explained once), `proceedAfterDecision` (eleven copies of the `confirmDestructive` tail, which also settles the dry-run wording as one string), `clearPendingResolution` (five copies of a transactional spread-and-delete), `deriveTestStem` (five copies of the `moz-` strip that `furnace rename` and `furnace remove` must compute identically to `furnace create` or they leave the old test behind), `manifestIncludes` (five near-identical registration probes), `projectEntryBody`, `loadAuthoringFurnaceConfig`, `remedies`, and a shared `spawnTracked` core under `execStream` / `execInherit` / `execInheritCapture`.
73
+ - **`--dry-run`, `--yes` and `--force-unsafe` were documented four different ways each.** `src/types/commands/options.ts` declared `dryRun` on twenty-four interfaces with five different doc comments. Five mixins (`DryRunnable`, `Confirmable`, `UnsafeOverridable`, `EngineLockWaitable`, `PatchLockWaitable`) now state each contract once. The two wait-lock mixins stay separate on purpose: the flag shares a spelling but bounds the engine session lock in one case and the patch directory lock in the other, and a reader needs to know which.
74
+
75
+ ### Test suite
76
+
77
+ - **The suite tested barrels, layers and coverage thresholds rather than behaviour.** Eleven test files and roughly 5,600 lines are gone with no behavioural assertion lost. `coverage-gaps.test.ts` and `furnace-registration-ast-coverage.test.ts` announced their purpose in their names; five of the former's six blocks re-tested what `git.test.ts`, `patch-parse-helpers.test.ts` and `patch-lock.test.ts` already covered, and its wire-name block declared its own copy of the regex and tested the copy. `furnace-apply.test.ts` re-ran `furnace-registration-ast.test.ts` through a barrel with mocked filesystem calls; `moz-manifest-register.test.ts` did the same for the `register-*` leaves. `build-artifact-preflight.test.ts` asserted two refusals once per command that every per-command file already asserted. `user-messages.test.ts` snapshotted 36 error strings that `src/errors/__tests__` already pins fragment by fragment. Unique cases were relocated, not dropped.
78
+ - **Help output is snapshotted as an inventory, not as prose.** Twenty-one full-text snapshots totalling 486 lines regenerated on every wording or terminal-width change, so nobody read the diff. One 70-line snapshot now records each command with its sorted flag names: removing `--stop-at` showed up as a single changed line, while rewording a description does not touch it at all.
79
+ - **`process-boundary.test.ts` is replaced by the lint rule that already existed.** Its first test walked `src/` grepping for `process.exit(`, which `no-restricted-properties` enforces with a precise location; its second asserted that `bin/fireforge.ts` contains exactly two `process.exit(` and exactly five `exitAfterStdioFlush(` occurrences, a count that any harmless extraction breaks.
80
+ - **Four tests asserted less than they claimed.** A fast-check property titled "exactly the three valid products" filtered against three literals while `FIREFOX_PRODUCTS` has four, and passed only because `fc.string()` never generated the fourth. A logger test asserted the same call twice under a comment claiming it verified a re-print. `run.test.ts`'s `--console-allow` accumulation test asserted only a call count, and its own comment said so; it now feeds two error lines and fails if either repeat is dropped. Three `furnace-index` tests titled "routes … through the engine session lock" never mentioned the lock.
81
+ - **The type-level guards are type-level now.** A 40-line hand-written exhaustive `switch` in `validation.test.ts` conceded in its own comment that its runtime assertions named nothing; it is a `satisfies` operand beside `FIREFOX_PRODUCTS` that fails compilation in both directions, verified by adding a member and watching `tsc` reject it.
82
+
3
83
  ## 0.45.0
4
84
 
5
85
  ### Patch bodies
@@ -11,6 +11,7 @@ import { installBrokenPipeHandler, main } from '../src/cli.js';
11
11
  import { emitKilledVerdict } from '../src/commands/test-verdict.js';
12
12
  import { forceReleaseHeldLocksForSignal } from '../src/core/file-lock.js';
13
13
  import { forceReleaseFurnaceLocksForActiveOperations, isSignalRollbackInFlight, rollbackActiveOperationsForSignal, } from '../src/core/furnace-operation.js';
14
+ import { closeActiveRunLog } from '../src/core/run-log.js';
14
15
  import { waitForActiveCriticalSections } from '../src/core/signal-critical.js';
15
16
  import { CommandError } from '../src/errors/base.js';
16
17
  import { waitForActiveChildShutdown } from '../src/utils/process.js';
@@ -102,6 +103,11 @@ function installFurnaceSignalHandler(signal, exitCode) {
102
103
  // ownership checks see a settled tree.
103
104
  .then(() => forceReleaseHeldLocksForSignal())
104
105
  .then(() => forceReleaseFurnaceLocksForActiveOperations())
106
+ // The run log's held partial line and queued writes do not survive
107
+ // `process.exit`; the command's own `finally` that would close it is
108
+ // racing this handler and usually loses. Best-effort like the log
109
+ // itself — a diagnostic must never keep the process from exiting.
110
+ .then(() => closeActiveRunLog().catch(() => undefined))
105
111
  .finally(() => {
106
112
  exitAfterStdioFlush(exitCode);
107
113
  });
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "version": "0.45.0",
4
- "commit": "19d461eb43b89962970510f9a2e655704e9fb952",
5
- "shortCommit": "19d461eb43b8",
3
+ "version": "0.46.0",
4
+ "commit": "cffe7e47008c7a2af690abbbe352eb685d35d3f8",
5
+ "shortCommit": "cffe7e47008c",
6
6
  "dirty": false,
7
7
  "dirtyHash": null,
8
- "buildTime": "2026-09-01T10:47:47.465Z"
8
+ "buildTime": "2026-09-05T19:28:43.539Z"
9
9
  }
@@ -0,0 +1,46 @@
1
+ import { FireForgeError } from './errors/base.js';
2
+ /**
3
+ * Stable machine-readable tag for an error class, for the `--json` envelope.
4
+ *
5
+ * Derived from the class name rather than hand-mapped: a new error class gets
6
+ * a sensible tag automatically, and the mapping cannot drift out of sync with
7
+ * the taxonomy. `ConfigNotFoundError` becomes `config-not-found`.
8
+ *
9
+ * @param error - The error being rendered
10
+ * @returns A kebab-case tag with the `Error` suffix stripped
11
+ */
12
+ export declare function machineErrorCode(error: FireForgeError): string;
13
+ /**
14
+ * Whether the invocation asked for machine-readable output.
15
+ *
16
+ * Reading the flag from argv is blunt but exact: if the invocation asked for
17
+ * machine output, stdout belongs to the payload from the first byte — before
18
+ * commander has parsed anything.
19
+ *
20
+ * @param argv - Process argv (defaults to `process.argv`)
21
+ * @returns True when `--json` or `--raw` is present
22
+ */
23
+ export declare function wantsMachineOutput(argv?: readonly string[]): boolean;
24
+ /**
25
+ * Converts a `CommanderError` thrown out of `parseAsync` into the exit
26
+ * contract, and rethrows anything else untouched.
27
+ *
28
+ * `--help` and `--version` also travel through `exitOverride()` (codes
29
+ * `commander.helpDisplayed` / `commander.version`, exit 0): commander has
30
+ * already written their output, so those simply return. So does
31
+ * `commander.help`: a command group invoked with no subcommand (`fireforge`,
32
+ * `fireforge tree`), for which commander prints the group's help and would
33
+ * exit 1. That is an informational invocation, not a wrong flag — `patch`,
34
+ * `token` and `furnace` already answer it with help and exit 0 through an
35
+ * explicit action, and the groups without one must not exit 8 (or leak
36
+ * commander's `(outputHelp)` placeholder into a `--json` envelope) merely
37
+ * for lacking it. A genuine usage error has already had its message written
38
+ * to stderr by commander; it becomes exit 8, and under `--json` additionally
39
+ * gets the standard refusal envelope on stdout so a scripted consumer sees a
40
+ * parseable failure.
41
+ *
42
+ * @param error - Whatever `parseAsync` rejected with
43
+ * @param machineOutput - Whether the invocation asked for `--json`/`--raw`
44
+ * @throws CommandError carrying `INVALID_ARGUMENT` for a usage error
45
+ */
46
+ export declare function handleParseError(error: unknown, machineOutput: boolean): void;
@@ -0,0 +1,81 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Maps commander's own parse failures onto the FireForge exit-code contract.
4
+ *
5
+ * Commander terminates the process itself on a usage error (unknown option,
6
+ * missing argument, unknown command) with exit 1, which contradicts the
7
+ * documented `INVALID_ARGUMENT` (8) in `docs/exit-codes.md` and bypasses the
8
+ * `--json` refusal envelope because no action — and therefore no
9
+ * `withErrorHandling` wrapper — ever runs. `createProgram` installs
10
+ * `exitOverride()` so those failures surface as a thrown `CommanderError`,
11
+ * and `main` routes them through here.
12
+ */
13
+ import { CommanderError } from 'commander';
14
+ import { CommandError, FireForgeError, InvalidArgumentError } from './errors/base.js';
15
+ import { emitMachineError } from './utils/machine-output.js';
16
+ /**
17
+ * Stable machine-readable tag for an error class, for the `--json` envelope.
18
+ *
19
+ * Derived from the class name rather than hand-mapped: a new error class gets
20
+ * a sensible tag automatically, and the mapping cannot drift out of sync with
21
+ * the taxonomy. `ConfigNotFoundError` becomes `config-not-found`.
22
+ *
23
+ * @param error - The error being rendered
24
+ * @returns A kebab-case tag with the `Error` suffix stripped
25
+ */
26
+ export function machineErrorCode(error) {
27
+ return error.name
28
+ .replace(/Error$/, '')
29
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
30
+ .toLowerCase();
31
+ }
32
+ /**
33
+ * Whether the invocation asked for machine-readable output.
34
+ *
35
+ * Reading the flag from argv is blunt but exact: if the invocation asked for
36
+ * machine output, stdout belongs to the payload from the first byte — before
37
+ * commander has parsed anything.
38
+ *
39
+ * @param argv - Process argv (defaults to `process.argv`)
40
+ * @returns True when `--json` or `--raw` is present
41
+ */
42
+ export function wantsMachineOutput(argv = process.argv) {
43
+ return argv.includes('--json') || argv.includes('--raw');
44
+ }
45
+ /**
46
+ * Converts a `CommanderError` thrown out of `parseAsync` into the exit
47
+ * contract, and rethrows anything else untouched.
48
+ *
49
+ * `--help` and `--version` also travel through `exitOverride()` (codes
50
+ * `commander.helpDisplayed` / `commander.version`, exit 0): commander has
51
+ * already written their output, so those simply return. So does
52
+ * `commander.help`: a command group invoked with no subcommand (`fireforge`,
53
+ * `fireforge tree`), for which commander prints the group's help and would
54
+ * exit 1. That is an informational invocation, not a wrong flag — `patch`,
55
+ * `token` and `furnace` already answer it with help and exit 0 through an
56
+ * explicit action, and the groups without one must not exit 8 (or leak
57
+ * commander's `(outputHelp)` placeholder into a `--json` envelope) merely
58
+ * for lacking it. A genuine usage error has already had its message written
59
+ * to stderr by commander; it becomes exit 8, and under `--json` additionally
60
+ * gets the standard refusal envelope on stdout so a scripted consumer sees a
61
+ * parseable failure.
62
+ *
63
+ * @param error - Whatever `parseAsync` rejected with
64
+ * @param machineOutput - Whether the invocation asked for `--json`/`--raw`
65
+ * @throws CommandError carrying `INVALID_ARGUMENT` for a usage error
66
+ */
67
+ export function handleParseError(error, machineOutput) {
68
+ if (!(error instanceof CommanderError)) {
69
+ throw error;
70
+ }
71
+ if (error.exitCode === 0 || error.code === 'commander.help') {
72
+ return;
73
+ }
74
+ // Commander's message carries its own `error: ` prefix; strip it so the
75
+ // envelope text matches how every other refusal reads.
76
+ const usage = new InvalidArgumentError(error.message.replace(/^error:\s*/, ''));
77
+ if (machineOutput) {
78
+ emitMachineError(machineErrorCode(usage), usage.message, usage.code);
79
+ }
80
+ throw new CommandError(usage.code);
81
+ }
package/dist/src/cli.d.ts CHANGED
@@ -6,7 +6,11 @@ import { Command } from 'commander';
6
6
  * We treat this as a clean exit.
7
7
  */
8
8
  export declare function installBrokenPipeHandler(): void;
9
- /** Removes the broken-pipe handler installed for CLI tests. */
9
+ /**
10
+ * Removes the broken-pipe handler installed for CLI tests.
11
+ *
12
+ * @internal Exported only so tests can reach it; not part of the public surface.
13
+ */
10
14
  export declare function resetBrokenPipeHandlerForTests(): void;
11
15
  /**
12
16
  * Gets the project root directory.
package/dist/src/cli.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import { existsSync } from 'node:fs';
3
3
  import { dirname, join, resolve } from 'node:path';
4
4
  import { Command, Help } from 'commander';
5
+ import { handleParseError, machineErrorCode, wantsMachineOutput } from './cli-usage-error.js';
5
6
  import { COMMAND_MANIFEST } from './commands/manifest.js';
6
7
  import { runTreeGuardHook } from './core/tree-guard.js';
7
8
  import { CancellationError, CommandError, FireForgeError, InternalInvariantError, } from './errors/base.js';
@@ -32,22 +33,6 @@ function getBrokenPipeHandler(state) {
32
33
  state[brokenPipeListenerKey] = handler;
33
34
  return handler;
34
35
  }
35
- /**
36
- * Stable machine-readable tag for an error class, for the `--json` envelope.
37
- *
38
- * Derived from the class name rather than hand-mapped: a new error class gets
39
- * a sensible tag automatically, and the mapping cannot drift out of sync with
40
- * the taxonomy. `ConfigNotFoundError` becomes `config-not-found`.
41
- *
42
- * @param error - The error being rendered
43
- * @returns A kebab-case tag with the `Error` suffix stripped
44
- */
45
- function machineErrorCode(error) {
46
- return error.name
47
- .replace(/Error$/, '')
48
- .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
49
- .toLowerCase();
50
- }
51
36
  /**
52
37
  * Prints an error's `cause` chain under `--verbose`.
53
38
  *
@@ -93,7 +78,11 @@ export function installBrokenPipeHandler() {
93
78
  process.stderr.on('error', handleStreamError);
94
79
  state[brokenPipeInstalledKey] = true;
95
80
  }
96
- /** Removes the broken-pipe handler installed for CLI tests. */
81
+ /**
82
+ * Removes the broken-pipe handler installed for CLI tests.
83
+ *
84
+ * @internal Exported only so tests can reach it; not part of the public surface.
85
+ */
97
86
  export function resetBrokenPipeHandlerForTests() {
98
87
  const state = getProcessState();
99
88
  const handleStreamError = state[brokenPipeListenerKey];
@@ -150,8 +139,8 @@ export function withErrorHandling(handler) {
150
139
  // un-parseable output. Reading the flag from argv is blunt but exact: if
151
140
  // the invocation asked for machine output, stdout belongs to the payload
152
141
  // from the first byte.
153
- const wantsMachineOutput = process.argv.includes('--json') || process.argv.includes('--raw');
154
- if (wantsMachineOutput) {
142
+ const machineOutput = wantsMachineOutput();
143
+ if (machineOutput) {
155
144
  setMachineOutputMode(true);
156
145
  }
157
146
  try {
@@ -191,7 +180,7 @@ export function withErrorHandling(handler) {
191
180
  // Not when a payload already owns stdout: `status --json --fail-on`
192
181
  // writes its full document and THEN refuses, and appending an
193
182
  // envelope would make that two JSON documents.
194
- if (wantsMachineOutput && !isStdoutSealed()) {
183
+ if (machineOutput && !isStdoutSealed()) {
195
184
  emitMachineError(machineErrorCode(error), error.message, error.code);
196
185
  }
197
186
  throw new CommandError(error.code);
@@ -339,6 +328,11 @@ export function createProgram() {
339
328
  const program = new Command();
340
329
  program
341
330
  .name('fireforge')
331
+ // Usage errors must NOT let commander call process.exit(1) itself: the
332
+ // documented code is INVALID_ARGUMENT (8) and `--json` owes a refusal
333
+ // envelope. Installed before any subcommand registers, because
334
+ // `.command()` copies the override into the child at creation time.
335
+ .exitOverride()
342
336
  .description('A build tool for customizing Firefox')
343
337
  .option('-v, --verbose', 'Enable debug output')
344
338
  .option('--ignore-corrupt-tree-marker', 'Run even when .fireforge/tree.json exists but cannot be parsed. Without this the tree guard refuses, because an unreadable marker leaves it unknown whether this is a snapshot or the primary tree.')
@@ -388,5 +382,10 @@ export async function main() {
388
382
  return;
389
383
  }
390
384
  const program = createProgram();
391
- await program.parseAsync(process.argv);
385
+ try {
386
+ await program.parseAsync(process.argv);
387
+ }
388
+ catch (error) {
389
+ handleParseError(error, wantsMachineOutput());
390
+ }
392
391
  }
@@ -10,7 +10,6 @@ import { ExitCode } from '../errors/codes.js';
10
10
  import { error, info, intro, outro, warn } from '../utils/logger.js';
11
11
  import { detectBootstrapIssues, runPostBootstrapChecks, } from './bootstrap-checks.js';
12
12
  import { reportDoctorResults } from './doctor.js';
13
- import { resolveDoctorSeverity } from './doctor-check-core.js';
14
13
  /** One sentence per detected issue, keyed by the scanner's own tags. */
15
14
  const BOOTSTRAP_FAILURE_SENTENCES = {
16
15
  'python-traceback': 'Bootstrap emitted a Python traceback.',
@@ -68,7 +67,7 @@ export async function bootstrapCommand(projectRoot) {
68
67
  // Shares one resolver with reportDoctorResults so the two consumers of
69
68
  // this same array cannot drift apart on how a check's severity is
70
69
  // derived.
71
- const hasErrors = checks.some((c) => resolveDoctorSeverity(c) === 'error');
70
+ const hasErrors = checks.some((c) => c.severity === 'error');
72
71
  info('');
73
72
  if (hasErrors) {
74
73
  warn('Bootstrap completed with issues:');
@@ -13,6 +13,7 @@ import { buildHarnessCrashMessage } from '../core/test-harness-crash.js';
13
13
  import { formatToolchainMismatchMessage, runToolchainPreflight, } from '../core/toolchain-preflight.js';
14
14
  import { GeneralError, InvalidArgumentError } from '../errors/base.js';
15
15
  import { AmbiguousBuildArtifactsError, BuildError } from '../errors/build.js';
16
+ import { elapsedSince } from '../utils/elapsed.js';
16
17
  import { toError } from '../utils/errors.js';
17
18
  import { checkDiskSpace } from '../utils/fs.js';
18
19
  import { error, info, intro, outro, spinner, verbose, warn } from '../utils/logger.js';
@@ -288,10 +289,7 @@ async function runBuildCommandBody(projectRoot, options) {
288
289
  catch (error) {
289
290
  throw new BuildError('Build process failed to start', options.ui ? 'mach build faster' : 'mach build', error instanceof Error ? error : undefined);
290
291
  }
291
- const duration = Date.now() - startTime;
292
- const minutes = Math.floor(duration / 60000);
293
- const seconds = Math.floor((duration % 60000) / 1000);
294
- const timeStr = minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
292
+ const timeStr = elapsedSince(startTime);
295
293
  if (result.exitCode !== 0) {
296
294
  error(`Build failed after ${timeStr}`);
297
295
  const machCommand = options.ui ? 'mach build faster' : 'mach build';
@@ -334,7 +332,9 @@ async function runBuildCommandBody(projectRoot, options) {
334
332
  // previous-build baseline. Never fails the build; the worst case is a
335
333
  // warning an operator chooses to investigate.
336
334
  try {
337
- await auditBuildArtifacts(projectRoot, paths.engine, previousBaseline);
335
+ await auditBuildArtifacts(projectRoot, paths.engine, previousBaseline, {
336
+ ...(config.buildAudit?.unpackaged ? { unpackaged: config.buildAudit.unpackaged } : {}),
337
+ });
338
338
  }
339
339
  catch (auditError) {
340
340
  verbose(`Audit skipped: ${toError(auditError).message}`);
@@ -353,7 +353,16 @@ async function runBuildCommandBody(projectRoot, options) {
353
353
  */
354
354
  async function recordFullBuildBaseline(projectRoot, engineDir, binaryName, ui, previousBaseline) {
355
355
  try {
356
- await writeBuildBaseline(projectRoot, engineDir, binaryName, 'full', previousBaseline, ui ? 'fireforge build --ui' : 'fireforge build', 'auto', ui ? 'faster' : 'full');
356
+ await writeBuildBaseline({
357
+ projectRoot,
358
+ engineDir,
359
+ binaryName,
360
+ testPackagingCoverage: 'full',
361
+ previousBaseline,
362
+ recordedBy: ui ? 'fireforge build --ui' : 'fireforge build',
363
+ staticComponentsHandling: 'auto',
364
+ buildKind: ui ? 'faster' : 'full',
365
+ });
357
366
  }
358
367
  catch (baselineError) {
359
368
  verbose(`Could not persist build baseline: ${toError(baselineError).message}`);
@@ -0,0 +1,19 @@
1
+ import type { DestructiveOpResult } from '../core/destructive.js';
2
+ /**
3
+ * Closes out a {@link DestructiveOpResult} and says whether to run the
4
+ * mutation.
5
+ *
6
+ * Eleven commands had copied the same tail: `if (decision === 'dry-run') {
7
+ * outro(…); return; } if (decision === 'declined') { outro(…); return; }`.
8
+ * The dry-run wording is a user-facing contract that must not drift between
9
+ * commands, so it lives here; the declined wording is per-command and stays a
10
+ * parameter.
11
+ *
12
+ * `confirmDestructive` never mutates on its own, so a caller that forgets this
13
+ * check silently performs a "dry run" for real — hence a boolean return the
14
+ * caller must act on rather than a helper that returns void.
15
+ * @param decision - The result from `confirmDestructive`.
16
+ * @param declinedOutro - Outro to print when the operator answered "no".
17
+ * @returns True when the caller should proceed with the mutation.
18
+ */
19
+ export declare function proceedAfterDecision(decision: DestructiveOpResult, declinedOutro: string): boolean;
@@ -0,0 +1,31 @@
1
+ import { outro } from '../utils/logger.js';
2
+ /** The dry-run outro every command prints, spelled once. */
3
+ const DRY_RUN_OUTRO = 'Dry run complete — no changes made';
4
+ /**
5
+ * Closes out a {@link DestructiveOpResult} and says whether to run the
6
+ * mutation.
7
+ *
8
+ * Eleven commands had copied the same tail: `if (decision === 'dry-run') {
9
+ * outro(…); return; } if (decision === 'declined') { outro(…); return; }`.
10
+ * The dry-run wording is a user-facing contract that must not drift between
11
+ * commands, so it lives here; the declined wording is per-command and stays a
12
+ * parameter.
13
+ *
14
+ * `confirmDestructive` never mutates on its own, so a caller that forgets this
15
+ * check silently performs a "dry run" for real — hence a boolean return the
16
+ * caller must act on rather than a helper that returns void.
17
+ * @param decision - The result from `confirmDestructive`.
18
+ * @param declinedOutro - Outro to print when the operator answered "no".
19
+ * @returns True when the caller should proceed with the mutation.
20
+ */
21
+ export function proceedAfterDecision(decision, declinedOutro) {
22
+ if (decision === 'dry-run') {
23
+ outro(DRY_RUN_OUTRO);
24
+ return false;
25
+ }
26
+ if (decision === 'declined') {
27
+ outro(declinedOutro);
28
+ return false;
29
+ }
30
+ return true;
31
+ }
@@ -96,12 +96,6 @@ export interface DoctorCheckDefinition {
96
96
  */
97
97
  fix?: string;
98
98
  }
99
- /**
100
- * Resolves a {@link DoctorCheck} to its effective severity. `severity` is the
101
- * single source of truth; this exists so every consumer of a
102
- * `DoctorCheck[]` reads it the same way.
103
- */
104
- export declare function resolveDoctorSeverity(check: DoctorCheck): 'ok' | 'warning' | 'error';
105
99
  /**
106
100
  * Builds a DoctorCheck object representing a successful "OK" check.
107
101
  */
@@ -4,14 +4,6 @@
4
4
  * `doctor.ts` and sibling check modules. Kept separate so registries can import
5
5
  * these symbols without creating cycles through `doctor.ts`.
6
6
  */
7
- /**
8
- * Resolves a {@link DoctorCheck} to its effective severity. `severity` is the
9
- * single source of truth; this exists so every consumer of a
10
- * `DoctorCheck[]` reads it the same way.
11
- */
12
- export function resolveDoctorSeverity(check) {
13
- return check.severity;
14
- }
15
7
  /**
16
8
  * Builds a DoctorCheck object representing a successful "OK" check.
17
9
  */
@@ -27,12 +27,6 @@ export interface OrphanedHarnessWorker {
27
27
  cpuSeconds: number;
28
28
  command: string;
29
29
  }
30
- /**
31
- * Parses a `ps` TIME (accumulated CPU) value. Implemented in terms of
32
- * {@link parsePsDuration} — `time=` and `etime=` share one grammar, and a
33
- * second copy is how the two would drift.
34
- */
35
- export declare function parseCpuTime(time: string): number;
36
30
  /**
37
31
  * Scans `ps -axo pid=,ppid=,time=,command=` output for orphaned harness
38
32
  * workers: PPID 1 (reparented to init/launchd), a multiprocessing
@@ -29,14 +29,6 @@ import { ok, warning } from './doctor-check-core.js';
29
29
  const HARNESS_WORKER_COMMAND_PATTERN = /multiprocessing\.(?:spawn|forkserver)\b|multiprocessing\.resource_tracker|from multiprocessing\.\w+ import/;
30
30
  /** Minimum accumulated CPU time before a match is reported (10 minutes). */
31
31
  const DEFAULT_MIN_CPU_SECONDS = 600;
32
- /**
33
- * Parses a `ps` TIME (accumulated CPU) value. Implemented in terms of
34
- * {@link parsePsDuration} — `time=` and `etime=` share one grammar, and a
35
- * second copy is how the two would drift.
36
- */
37
- export function parseCpuTime(time) {
38
- return parsePsDuration(time);
39
- }
40
32
  /**
41
33
  * Scans `ps -axo pid=,ppid=,time=,command=` output for orphaned harness
42
34
  * workers: PPID 1 (reparented to init/launchd), a multiprocessing
@@ -57,7 +49,7 @@ export function findOrphanedHarnessWorkers(psOutput, minCpuSeconds = DEFAULT_MIN
57
49
  continue;
58
50
  if (!HARNESS_WORKER_COMMAND_PATTERN.test(command))
59
51
  continue;
60
- const cpuSeconds = parseCpuTime(cpuTime);
52
+ const cpuSeconds = parsePsDuration(cpuTime);
61
53
  if (Number.isNaN(cpuSeconds) || cpuSeconds < minCpuSeconds)
62
54
  continue;
63
55
  workers.push({ pid, ppid, cpuTime, cpuSeconds, command });
@@ -68,7 +60,7 @@ export function findOrphanedHarnessWorkers(psOutput, minCpuSeconds = DEFAULT_MIN
68
60
  async function listSystemProcesses() {
69
61
  const result = await exec('ps', ['-axo', 'pid=,ppid=,time=,command='], { timeout: 10000 });
70
62
  if (result.exitCode !== 0) {
71
- throw new Error(`ps exited ${String(result.exitCode)}`);
63
+ throw new Error(`ps exited ${result.exitCode}`);
72
64
  }
73
65
  return result.stdout;
74
66
  }
@@ -86,10 +78,10 @@ async function runOrphanedHarnessCheck() {
86
78
  return ok(CHECK_NAME);
87
79
  }
88
80
  const rows = orphans
89
- .map((w) => `PID ${String(w.pid)} (CPU time ${w.cpuTime}): ${w.command.slice(0, 160)}`)
81
+ .map((w) => `PID ${w.pid} (CPU time ${w.cpuTime}): ${w.command.slice(0, 160)}`)
90
82
  .join('; ');
91
83
  const pids = orphans.map((w) => String(w.pid)).join(' ');
92
- return warning(CHECK_NAME, `Found ${String(orphans.length)} orphaned Python multiprocessing worker(s) — PPID 1 with ` +
84
+ return warning(CHECK_NAME, `Found ${orphans.length} orphaned Python multiprocessing worker(s) — PPID 1 with ` +
93
85
  `high accumulated CPU time, the shape a test harness that died at startup leaves behind ` +
94
86
  `(field incident: one such worker busy-spun for ~26 days). ${rows}`, `These look like workers orphaned by a crashed test harness. Verify each command line, ` +
95
87
  `then terminate with: kill ${pids} (or kill -9 if a process survives). ` +