@hominis/fireforge 0.44.4 → 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 (378) hide show
  1. package/CHANGELOG.md +120 -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-source-pin.d.ts +29 -0
  18. package/dist/src/commands/doctor-source-pin.js +81 -0
  19. package/dist/src/commands/doctor.d.ts +2 -0
  20. package/dist/src/commands/doctor.js +9 -8
  21. package/dist/src/commands/download.js +35 -24
  22. package/dist/src/commands/export-all.js +21 -4
  23. package/dist/src/commands/export-flow.js +15 -60
  24. package/dist/src/commands/export-placement-conflicts.js +3 -3
  25. package/dist/src/commands/export-placement-gate.js +2 -8
  26. package/dist/src/commands/export-placement-policy.js +3 -5
  27. package/dist/src/commands/export-shared.d.ts +42 -23
  28. package/dist/src/commands/export-shared.js +4 -19
  29. package/dist/src/commands/export.js +10 -1
  30. package/dist/src/commands/furnace/authoring-config.d.ts +13 -0
  31. package/dist/src/commands/furnace/authoring-config.js +19 -0
  32. package/dist/src/commands/furnace/chrome-doc-remove.js +1 -1
  33. package/dist/src/commands/furnace/create-browser-test.js +3 -8
  34. package/dist/src/commands/furnace/create-dry-run.js +2 -5
  35. package/dist/src/commands/furnace/create-templates.d.ts +17 -1
  36. package/dist/src/commands/furnace/create-templates.js +2 -1
  37. package/dist/src/commands/furnace/create.js +25 -19
  38. package/dist/src/commands/furnace/deploy.js +11 -2
  39. package/dist/src/commands/furnace/init.js +2 -1
  40. package/dist/src/commands/furnace/override.js +28 -19
  41. package/dist/src/commands/furnace/refresh.js +9 -1
  42. package/dist/src/commands/furnace/remove-mutations.d.ts +6 -7
  43. package/dist/src/commands/furnace/remove-mutations.js +0 -1
  44. package/dist/src/commands/furnace/remove.js +2 -6
  45. package/dist/src/commands/furnace/rename-browser-test.js +1 -7
  46. package/dist/src/commands/furnace/rename.js +29 -29
  47. package/dist/src/commands/furnace/status.js +12 -9
  48. package/dist/src/commands/furnace/test-file-name.d.ts +28 -0
  49. package/dist/src/commands/furnace/test-file-name.js +39 -0
  50. package/dist/src/commands/furnace/validation-output.d.ts +21 -9
  51. package/dist/src/commands/furnace/validation-output.js +3 -8
  52. package/dist/src/commands/import.js +2 -9
  53. package/dist/src/commands/lint-per-patch.d.ts +2 -2
  54. package/dist/src/commands/lint-per-patch.js +19 -4
  55. package/dist/src/commands/lint-per-run-checkjs.d.ts +2 -2
  56. package/dist/src/commands/lint.d.ts +2 -1
  57. package/dist/src/commands/lint.js +1 -1
  58. package/dist/src/commands/manifest.js +1 -1
  59. package/dist/src/commands/package.js +2 -4
  60. package/dist/src/commands/patch/compact.js +14 -15
  61. package/dist/src/commands/patch/delete.js +15 -25
  62. package/dist/src/commands/patch/entry-projection.d.ts +13 -0
  63. package/dist/src/commands/patch/entry-projection.js +24 -0
  64. package/dist/src/commands/patch/lint-ignore.d.ts +0 -8
  65. package/dist/src/commands/patch/lint-ignore.js +19 -29
  66. package/dist/src/commands/patch/move-files-create.js +24 -10
  67. package/dist/src/commands/patch/move-files-into.js +38 -37
  68. package/dist/src/commands/patch/move-files.js +4 -13
  69. package/dist/src/commands/patch/rename.js +27 -41
  70. package/dist/src/commands/patch/reorder.js +25 -38
  71. package/dist/src/commands/patch/split-plan.d.ts +4 -5
  72. package/dist/src/commands/patch/split-plan.js +4 -11
  73. package/dist/src/commands/patch/split.js +45 -43
  74. package/dist/src/commands/patch/staged-dependency.js +16 -22
  75. package/dist/src/commands/patch/tier.js +15 -25
  76. package/dist/src/commands/patch-rename-map.d.ts +19 -0
  77. package/dist/src/commands/patch-rename-map.js +36 -0
  78. package/dist/src/commands/pending-resolution.d.ts +15 -0
  79. package/dist/src/commands/pending-resolution.js +25 -0
  80. package/dist/src/commands/re-export-adjacent.d.ts +32 -2
  81. package/dist/src/commands/re-export-adjacent.js +51 -10
  82. package/dist/src/commands/re-export-bulk-scan.js +6 -10
  83. package/dist/src/commands/re-export-drift.d.ts +8 -0
  84. package/dist/src/commands/re-export-drift.js +65 -4
  85. package/dist/src/commands/re-export-files.d.ts +2 -3
  86. package/dist/src/commands/re-export-files.js +38 -32
  87. package/dist/src/commands/re-export-lint.d.ts +3 -4
  88. package/dist/src/commands/re-export-lint.js +9 -2
  89. package/dist/src/commands/re-export-options.js +19 -5
  90. package/dist/src/commands/re-export-register.js +5 -1
  91. package/dist/src/commands/re-export-scan.js +8 -8
  92. package/dist/src/commands/re-export-single.d.ts +22 -3
  93. package/dist/src/commands/re-export-single.js +19 -11
  94. package/dist/src/commands/re-export.js +44 -27
  95. package/dist/src/commands/rebase/abort.js +3 -8
  96. package/dist/src/commands/rebase/continue.js +12 -12
  97. package/dist/src/commands/rebase/index.js +2 -2
  98. package/dist/src/commands/rebase/patch-loop.d.ts +2 -2
  99. package/dist/src/commands/rebase/patch-loop.js +2 -7
  100. package/dist/src/commands/resolve.js +18 -10
  101. package/dist/src/commands/run.d.ts +0 -13
  102. package/dist/src/commands/run.js +12 -25
  103. package/dist/src/commands/setup-support.d.ts +16 -4
  104. package/dist/src/commands/setup-support.js +16 -16
  105. package/dist/src/commands/setup.js +3 -9
  106. package/dist/src/commands/source.js +8 -1
  107. package/dist/src/commands/status-check.js +2 -2
  108. package/dist/src/commands/status-output.js +2 -2
  109. package/dist/src/commands/status.js +3 -4
  110. package/dist/src/commands/test-build-phase.d.ts +2 -2
  111. package/dist/src/commands/test-build-phase.js +33 -8
  112. package/dist/src/commands/test-diagnose.js +14 -5
  113. package/dist/src/commands/test-modes.d.ts +2 -2
  114. package/dist/src/commands/test-modes.js +0 -1
  115. package/dist/src/commands/test-register.js +6 -3
  116. package/dist/src/commands/test-run.js +29 -21
  117. package/dist/src/commands/test-stale-gate.js +8 -5
  118. package/dist/src/commands/test-verdict.d.ts +16 -2
  119. package/dist/src/commands/test-verdict.js +18 -3
  120. package/dist/src/commands/test.js +51 -7
  121. package/dist/src/commands/token-list.d.ts +26 -0
  122. package/dist/src/commands/token-list.js +153 -0
  123. package/dist/src/commands/token.js +17 -1
  124. package/dist/src/commands/tree.js +7 -2
  125. package/dist/src/commands/typecheck.js +9 -9
  126. package/dist/src/commands/wire.js +4 -3
  127. package/dist/src/core/branding.d.ts +0 -7
  128. package/dist/src/core/branding.js +3 -2
  129. package/dist/src/core/browser-wire.js +6 -3
  130. package/dist/src/core/build-audit-platform.d.ts +29 -0
  131. package/dist/src/core/build-audit-platform.js +140 -0
  132. package/dist/src/core/build-audit-registration.js +3 -2
  133. package/dist/src/core/build-audit-skip.d.ts +57 -0
  134. package/dist/src/core/build-audit-skip.js +112 -0
  135. package/dist/src/core/build-audit.d.ts +15 -2
  136. package/dist/src/core/build-audit.js +154 -12
  137. package/dist/src/core/build-baseline.d.ts +45 -28
  138. package/dist/src/core/build-baseline.js +6 -29
  139. package/dist/src/core/build-jar-escalation.d.ts +64 -0
  140. package/dist/src/core/build-jar-escalation.js +128 -0
  141. package/dist/src/core/build-overwrite-guard.js +1 -1
  142. package/dist/src/core/build-prepare.d.ts +5 -2
  143. package/dist/src/core/build-prepare.js +34 -6
  144. package/dist/src/core/config-paths.d.ts +2 -6
  145. package/dist/src/core/config-paths.js +4 -2
  146. package/dist/src/core/config-state.d.ts +0 -6
  147. package/dist/src/core/config-state.js +1 -19
  148. package/dist/src/core/config-validate-build-audit.d.ts +8 -0
  149. package/dist/src/core/config-validate-build-audit.js +80 -0
  150. package/dist/src/core/config-validate-file-size.d.ts +12 -0
  151. package/dist/src/core/config-validate-file-size.js +65 -0
  152. package/dist/src/core/config-validate-patch-policy.d.ts +2 -2
  153. package/dist/src/core/config-validate-patch-policy.js +3 -3
  154. package/dist/src/core/config-validate-test-toolchains.d.ts +2 -2
  155. package/dist/src/core/config-validate-test-toolchains.js +4 -5
  156. package/dist/src/core/config-validate.js +20 -3
  157. package/dist/src/core/config.d.ts +2 -2
  158. package/dist/src/core/config.js +11 -15
  159. package/dist/src/core/coverage-extend.js +4 -4
  160. package/dist/src/core/discard-baseline.js +7 -7
  161. package/dist/src/core/engine-precondition.d.ts +1 -9
  162. package/dist/src/core/engine-precondition.js +3 -8
  163. package/dist/src/core/engine-session-lock.js +13 -17
  164. package/dist/src/core/export-moved-code-shape.js +2 -2
  165. package/dist/src/core/file-lock-owner.d.ts +103 -0
  166. package/dist/src/core/file-lock-owner.js +259 -0
  167. package/dist/src/core/file-lock.d.ts +2 -7
  168. package/dist/src/core/file-lock.js +40 -91
  169. package/dist/src/core/firefox-cache.d.ts +13 -1
  170. package/dist/src/core/firefox-cache.js +59 -34
  171. package/dist/src/core/firefox-download.d.ts +15 -0
  172. package/dist/src/core/firefox-download.js +37 -21
  173. package/dist/src/core/firefox-extract.d.ts +6 -5
  174. package/dist/src/core/firefox-extract.js +36 -18
  175. package/dist/src/core/firefox.d.ts +30 -32
  176. package/dist/src/core/firefox.js +5 -36
  177. package/dist/src/core/furnace-apply-dry-run.d.ts +1 -6
  178. package/dist/src/core/furnace-apply-dry-run.js +1 -7
  179. package/dist/src/core/furnace-apply-helpers.d.ts +0 -11
  180. package/dist/src/core/furnace-apply-helpers.js +8 -19
  181. package/dist/src/core/furnace-apply-overwrite-warn.js +4 -2
  182. package/dist/src/core/furnace-apply.d.ts +1 -1
  183. package/dist/src/core/furnace-apply.js +30 -10
  184. package/dist/src/core/furnace-config.d.ts +1 -1
  185. package/dist/src/core/furnace-config.js +23 -36
  186. package/dist/src/core/furnace-constants.js +3 -2
  187. package/dist/src/core/furnace-dir-entry.d.ts +20 -0
  188. package/dist/src/core/furnace-dir-entry.js +21 -0
  189. package/dist/src/core/furnace-operation.d.ts +2 -0
  190. package/dist/src/core/furnace-operation.js +3 -1
  191. package/dist/src/core/furnace-refresh.d.ts +18 -6
  192. package/dist/src/core/furnace-refresh.js +3 -6
  193. package/dist/src/core/furnace-registration-ast.d.ts +0 -1
  194. package/dist/src/core/furnace-registration-ast.js +0 -3
  195. package/dist/src/core/furnace-registration.d.ts +2 -1
  196. package/dist/src/core/furnace-registration.js +22 -29
  197. package/dist/src/core/furnace-scanner.js +3 -6
  198. package/dist/src/core/furnace-state-persist.d.ts +2 -2
  199. package/dist/src/core/furnace-validate-registration.js +5 -7
  200. package/dist/src/core/git-base.d.ts +3 -1
  201. package/dist/src/core/git-base.js +25 -3
  202. package/dist/src/core/git-diff-new-file.d.ts +40 -0
  203. package/dist/src/core/git-diff-new-file.js +119 -0
  204. package/dist/src/core/git-diff.d.ts +9 -9
  205. package/dist/src/core/git-diff.js +88 -157
  206. package/dist/src/core/git-file-ops.d.ts +0 -7
  207. package/dist/src/core/git-file-ops.js +4 -28
  208. package/dist/src/core/git-readonly-index.d.ts +0 -2
  209. package/dist/src/core/git-readonly-index.js +0 -4
  210. package/dist/src/core/git-status.d.ts +7 -0
  211. package/dist/src/core/git-status.js +21 -9
  212. package/dist/src/core/git.d.ts +0 -6
  213. package/dist/src/core/git.js +14 -43
  214. package/dist/src/core/harness-orphans.d.ts +73 -0
  215. package/dist/src/core/harness-orphans.js +187 -0
  216. package/dist/src/core/history-log.d.ts +19 -0
  217. package/dist/src/core/history-log.js +29 -0
  218. package/dist/src/core/mach-error-hints.d.ts +0 -5
  219. package/dist/src/core/mach-error-hints.js +1 -1
  220. package/dist/src/core/mach-known-noise-filter.d.ts +25 -3
  221. package/dist/src/core/mach-known-noise-filter.js +31 -10
  222. package/dist/src/core/mach-mozconfig.js +2 -1
  223. package/dist/src/core/mach-python.d.ts +2 -0
  224. package/dist/src/core/mach-python.js +12 -7
  225. package/dist/src/core/mach-resource-shim.d.ts +13 -1
  226. package/dist/src/core/mach-resource-shim.js +112 -9
  227. package/dist/src/core/mach.d.ts +46 -53
  228. package/dist/src/core/mach.js +31 -83
  229. package/dist/src/core/marionette-port.d.ts +0 -17
  230. package/dist/src/core/marionette-port.js +12 -58
  231. package/dist/src/core/marionette-preflight.js +9 -27
  232. package/dist/src/core/mochitest-server-port.js +3 -3
  233. package/dist/src/core/moz-manifest-rules.js +37 -33
  234. package/dist/src/core/patch-apply-fuzz.js +4 -4
  235. package/dist/src/core/patch-apply.d.ts +4 -5
  236. package/dist/src/core/patch-apply.js +55 -32
  237. package/dist/src/core/patch-export-coverage.d.ts +0 -9
  238. package/dist/src/core/patch-export-coverage.js +1 -30
  239. package/dist/src/core/patch-export-update.d.ts +18 -1
  240. package/dist/src/core/patch-export-update.js +4 -3
  241. package/dist/src/core/patch-export.d.ts +11 -21
  242. package/dist/src/core/patch-export.js +44 -66
  243. package/dist/src/core/patch-files.d.ts +0 -4
  244. package/dist/src/core/patch-files.js +0 -11
  245. package/dist/src/core/patch-lint-binary.d.ts +6 -12
  246. package/dist/src/core/patch-lint-cache.d.ts +17 -1
  247. package/dist/src/core/patch-lint-cache.js +3 -6
  248. package/dist/src/core/patch-lint-checkjs.d.ts +33 -16
  249. package/dist/src/core/patch-lint-checkjs.js +43 -25
  250. package/dist/src/core/patch-lint-creators.d.ts +2 -6
  251. package/dist/src/core/patch-lint-cross.d.ts +16 -8
  252. package/dist/src/core/patch-lint-cross.js +49 -92
  253. package/dist/src/core/patch-lint-css.js +10 -6
  254. package/dist/src/core/patch-lint-file-size.d.ts +52 -0
  255. package/dist/src/core/patch-lint-file-size.js +53 -0
  256. package/dist/src/core/patch-lint-forward-registration.d.ts +58 -0
  257. package/dist/src/core/patch-lint-forward-registration.js +210 -0
  258. package/dist/src/core/patch-lint-jsdoc.js +36 -36
  259. package/dist/src/core/patch-lint-module-registration.d.ts +2 -8
  260. package/dist/src/core/patch-lint-prettier.d.ts +36 -0
  261. package/dist/src/core/patch-lint-prettier.js +126 -0
  262. package/dist/src/core/patch-lint-queue-types.d.ts +60 -0
  263. package/dist/src/core/patch-lint-queue-types.js +12 -0
  264. package/dist/src/core/patch-lint-staged-registration.d.ts +61 -0
  265. package/dist/src/core/patch-lint-staged-registration.js +90 -0
  266. package/dist/src/core/patch-lint-unmanaged-companion.d.ts +106 -0
  267. package/dist/src/core/patch-lint-unmanaged-companion.js +214 -0
  268. package/dist/src/core/patch-lint.d.ts +14 -9
  269. package/dist/src/core/patch-lint.js +27 -27
  270. package/dist/src/core/patch-lock.js +3 -3
  271. package/dist/src/core/patch-manifest-io.js +8 -7
  272. package/dist/src/core/patch-parse.d.ts +19 -8
  273. package/dist/src/core/patch-parse.js +25 -0
  274. package/dist/src/core/patch-policy.js +15 -27
  275. package/dist/src/core/patch-source-metadata.d.ts +1 -3
  276. package/dist/src/core/patch-source-metadata.js +0 -4
  277. package/dist/src/core/patch-transform.d.ts +21 -8
  278. package/dist/src/core/patch-transform.js +38 -10
  279. package/dist/src/core/process-cpu-time.js +1 -1
  280. package/dist/src/core/rebase-session.d.ts +2 -8
  281. package/dist/src/core/rebase-session.js +5 -17
  282. package/dist/src/core/register-browser-content.js +3 -2
  283. package/dist/src/core/register-browser-test.js +2 -1
  284. package/dist/src/core/register-module.js +2 -1
  285. package/dist/src/core/register-scaffold.js +5 -4
  286. package/dist/src/core/register-shared-css.js +2 -1
  287. package/dist/src/core/run-log-redact.d.ts +38 -0
  288. package/dist/src/core/run-log-redact.js +69 -0
  289. package/dist/src/core/run-log.d.ts +13 -2
  290. package/dist/src/core/run-log.js +67 -18
  291. package/dist/src/core/signal-critical.js +2 -4
  292. package/dist/src/core/smoke-patterns.d.ts +0 -1
  293. package/dist/src/core/smoke-patterns.js +18 -11
  294. package/dist/src/core/state-file.d.ts +12 -1
  295. package/dist/src/core/state-file.js +14 -1
  296. package/dist/src/core/status-classify.js +2 -1
  297. package/dist/src/core/test-changed-prefs.js +26 -11
  298. package/dist/src/core/test-harness-crash.js +61 -48
  299. package/dist/src/core/test-harness-verdict-notes.d.ts +55 -0
  300. package/dist/src/core/test-harness-verdict-notes.js +48 -0
  301. package/dist/src/core/test-stale-check.d.ts +23 -8
  302. package/dist/src/core/test-stale-check.js +77 -4
  303. package/dist/src/core/test-stall-triage.js +37 -19
  304. package/dist/src/core/test-xpcshell-retry.d.ts +26 -7
  305. package/dist/src/core/test-xpcshell-retry.js +14 -11
  306. package/dist/src/core/token-category.d.ts +33 -0
  307. package/dist/src/core/token-category.js +56 -48
  308. package/dist/src/core/token-dark-mode.js +2 -14
  309. package/dist/src/core/token-inventory.d.ts +56 -0
  310. package/dist/src/core/token-inventory.js +109 -0
  311. package/dist/src/core/token-manager.d.ts +1 -1
  312. package/dist/src/core/token-manager.js +1 -1
  313. package/dist/src/core/token-variant.js +21 -22
  314. package/dist/src/core/toolchain-preflight.js +1 -1
  315. package/dist/src/core/tree-cow.d.ts +0 -2
  316. package/dist/src/core/tree-cow.js +2 -2
  317. package/dist/src/core/tree-store.js +4 -5
  318. package/dist/src/core/typecheck.js +2 -2
  319. package/dist/src/core/wire-dom-fragment.js +6 -17
  320. package/dist/src/core/wire-utils.js +5 -17
  321. package/dist/src/core/xpcshell-appdir.js +2 -4
  322. package/dist/src/errors/base.d.ts +53 -1
  323. package/dist/src/errors/base.js +79 -11
  324. package/dist/src/errors/build.d.ts +28 -0
  325. package/dist/src/errors/build.js +51 -10
  326. package/dist/src/errors/config.d.ts +0 -14
  327. package/dist/src/errors/config.js +13 -34
  328. package/dist/src/errors/download.d.ts +13 -0
  329. package/dist/src/errors/download.js +53 -24
  330. package/dist/src/errors/furnace.js +8 -9
  331. package/dist/src/errors/git.d.ts +8 -8
  332. package/dist/src/errors/git.js +20 -30
  333. package/dist/src/errors/patch.js +8 -10
  334. package/dist/src/errors/rebase.js +7 -7
  335. package/dist/src/types/commands/options.d.ts +96 -123
  336. package/dist/src/types/commands/patches.d.ts +17 -6
  337. package/dist/src/types/config.d.ts +75 -2
  338. package/dist/src/types/index.d.ts +0 -1
  339. package/dist/src/utils/assert.d.ts +3 -15
  340. package/dist/src/utils/assert.js +1 -18
  341. package/dist/src/utils/build-info.d.ts +1 -1
  342. package/dist/src/utils/child-env.d.ts +2 -1
  343. package/dist/src/utils/elapsed.js +10 -1
  344. package/dist/src/utils/fs.d.ts +4 -2
  345. package/dist/src/utils/fs.js +11 -14
  346. package/dist/src/utils/hash.d.ts +12 -0
  347. package/dist/src/utils/hash.js +16 -0
  348. package/dist/src/utils/line-dispatch.d.ts +15 -0
  349. package/dist/src/utils/line-dispatch.js +48 -0
  350. package/dist/src/utils/options.d.ts +1 -7
  351. package/dist/src/utils/options.js +6 -7
  352. package/dist/src/utils/package-root.js +3 -12
  353. package/dist/src/utils/platform.d.ts +0 -4
  354. package/dist/src/utils/process-group.d.ts +2 -1
  355. package/dist/src/utils/process-group.js +20 -19
  356. package/dist/src/utils/process.d.ts +40 -26
  357. package/dist/src/utils/process.js +115 -166
  358. package/dist/src/utils/ps-duration.js +3 -3
  359. package/dist/src/utils/sleep.d.ts +26 -0
  360. package/dist/src/utils/sleep.js +31 -0
  361. package/dist/src/utils/validation.d.ts +2 -38
  362. package/dist/src/utils/validation.js +7 -16
  363. package/docs/configuration.md +136 -1
  364. package/docs/exit-codes.md +34 -20
  365. package/docs/lifecycle-invariants.md +18 -12
  366. package/docs/machine-output.md +31 -8
  367. package/docs/patch-workflow.md +2 -1
  368. package/docs/run-logs.md +44 -4
  369. package/docs/testing.md +70 -11
  370. package/package.json +8 -7
  371. package/dist/src/commands/rebase.d.ts +0 -4
  372. package/dist/src/commands/rebase.js +0 -5
  373. package/dist/src/core/patch-artifact-normalize.d.ts +0 -21
  374. package/dist/src/core/patch-artifact-normalize.js +0 -24
  375. package/dist/src/core/patch-lint-reexports.d.ts +0 -7
  376. package/dist/src/core/patch-lint-reexports.js +0 -8
  377. package/dist/src/core/wire-targets.d.ts +0 -7
  378. package/dist/src/core/wire-targets.js +0 -8
package/CHANGELOG.md CHANGED
@@ -1,5 +1,125 @@
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
+
83
+ ## 0.45.0
84
+
85
+ ### Patch bodies
86
+
87
+ - **A NEW binary file could not be vendored into a patch at all.** Adopting eight WOFF2 faces through `re-export --scan --scan-file` failed, after a clean lint pass, with `Cannot extract text content from binary patch section for …` — and the faces stayed unmanaged, so a `fireforge reset` left the patch's `@font-face` and `jar.inc.mn` lines pointing at missing files. The export half was never at fault: `generateBinaryFilePatch` wrote a correct, replayable `GIT binary patch`. The refusal came from the FORWARD-IMPORT PROJECTION downstream of it. `detectNewFilesInDiff` keys purely on `new file mode`, so binaries entered the loop, and every one of the five projection sites then handed each detected new file to `extractNewFileContentFromDiff`, which refuses binary sections — correctly, since the base85 alphabet includes `+` and "extract the + lines" would write garbage over a binary target.
88
+ The projection never needed the bytes. It feeds one rule, which scans content for import statements, and a binary blob authors none — so skipping a binary contributes exactly what including it would. The asymmetry was the tell: `buildPatchQueueContext` already tolerated binary new files for REAL queue entries (a per-file try/catch), and only the projections did not. The five duplicated loops are now one `buildNewFileTextProjection`, which skips binary sections and parses the diff once instead of once per new file. `extractNewFileContentFromDiff` still refuses a binary section named explicitly: a caller that asked for THIS file's text must be told no, not silently handed nothing. Nothing is given up by skipping — binary bodies are covered independently by the `binary-body-not-reconstructable` queue lint.
89
+ Worth recording that the reported symptom was narrower than stated in one direction and wider in another. Plain `fireforge export` of a new binary always worked; the export half fails only behind a placement flag (`--order`/`--before`/`--after`), which is what routes it through `projectPlacementForLint`. But the same projection sits under `patch move-files-into` and `patch split`, so a binary could not be moved between patches or split out either.
90
+ - **The new-file conflict recovery could not handle a binary target, and restored text where it should have restored bytes.** When `git apply` fails because a patch creates a file that is already on disk, FireForge resolves the conflict by putting the patch's intended content in place and retrying. For a binary there is nothing to extract — but nothing needs to be: `git apply` decodes the payload itself, so the resolution is to REMOVE the blocking file and let the retry create it. A `Binary files … differ` body, which records that the bytes changed and carries none of them, is refused instead of deleted, because nothing could put the file back. The saved originals are now raw `Buffer`s rather than utf-8 text, which also closes a latent corruption on the pre-existing path: a binary file overwritten by this recovery was previously restored through a `readText`/`writeText` round-trip that mangles it.
91
+
92
+ ### Refusals
93
+
94
+ - **`--expect <path>` warned "showed no drift" over a path that did drift and was captured.** The notice was the only feedback that the belt looked at the right files, and none of the causes it named held; the reporter's workaround was `grep -c` on the written body. The measurement was never stated, and stating it is the diagnosis: the comparison is CONTENT, per patch, on the body about to be written and never against post-write state — and it is ONE-DIRECTIONAL, reporting only the payload lines the refreshed body carries that the old body did not. Removing a line from an engine file makes the refreshed body DROP that file's `+` line, so nothing is absorbed and by design there is no drift to see. The notice now says what it compares and when, leads with that one-directional cause, and adds the `--scan-file` adoption case as a named cause (adopting a file also captures that patch's other drifted files, so a scripted scan-less re-export afterwards legitimately sees nothing left — informational at exit 0, and a chain treating the warning as an error will false-red on it). Widening the comparison was considered and rejected: it would turn every ordinary line removal into a refusal, and the belt exists to catch a concurrent session's edits being swallowed INTO a body.
95
+ - **The coverage refusal left its own triage step to the reader.** Adding a test to `xpcshell.toml` while the packaged runtime came from a browser-chrome `test --build` was refused correctly, naming the covered paths, the requested paths, the rebuild command and `--extend-coverage`. What it did not say is WHY this run needed new coverage: a MANIFEST gained an entry, and a browser-chrome build claim never covers xpcshell. FireForge can see both facts. The refusal now names the test manifests changed since the recorded build that sit in an uncovered request path's directory. Behaviour is unchanged; the probe is best-effort and a failure renders exactly the previous text.
96
+ - **`--refuse-adjacent-unmanaged` was all-or-nothing against a recorded exception.** A project with a reviewed, deliberately unmanaged path beside a patch's ownership had no way to say so, so every re-export of every adjacent patch had to drop the flag entirely and substitute a hand-read of the notice — a weaker belt than the one being disarmed, recurring on every touch. `--expect-unmanaged <path>` (repeatable, requires `--refuse-adjacent-unmanaged`) names an approved exception: the belt stays armed for everything else. Approved paths are still LISTED, as an explicit note that they were admitted rather than dropped, because a carve-out nobody can see is how one quietly widens. A path that is never met is reported at the end of the run, for the same reason `--expect` reports its unseen entries: a typo'd carve-out silently leaves the refusal armed for the path it was meant to admit, while reading as an approval that is in force. Deliberately a flag and not a config list — a standing exception nobody re-reads is exactly what this refusal exists to prevent.
97
+
98
+ ### Diagnostics
99
+
100
+ - **`run --smoke-exit` gave a clean window over the one line that predicts a crash under automation.** The 0.44.1 discriminator (`run --smoke-exit 60 --headless --capture-console <file>`) was run against a build whose startpage pointed at a nonexistent `chrome://` image. It exited 0 with `Unallowed errors: 0` — the "startup is healthy" answer — for a build that hung EVERY browser-chrome run at "Waiting for browser…", while the capture it wrote held the discriminating line: `Missing chrome or resource URL: chrome://…`. That is Gecko's non-automation spelling (`printf_stderr` in `CheckForBrokenChromeURL`, `netwerk/base/nsNetUtil.cpp`); under `xpc::IsInAutomation()` the identical condition is `MOZ_CRASH_UNSAFE_PRINTF("Missing chrome or resource URLs: %s")`. So the probe did discriminate — but only for a reader who grepped the capture, and the exit code said the opposite. `^Missing chrome or resource URLs?:` is now a smoke error pattern, allowlistable through `--console-allow` like any other.
101
+ - **The no-output-stall triage told operators to read a log that structurally cannot carry the evidence.** Cause (3) was recorded as an un-root-caused correlation with a broken `chrome://` image URI. It is now root-caused, and the mechanism explains why the log is empty: under automation the `MOZ_CRASH` lands in whichever process opened the channel, and in the downstream reproduction that was a CONTENT process (`plugin-container`) with the crash reporter compiled out — so the harness log carried neither the message nor a crash line, only the 370 s timeout, `Ran 0 checks`, and mochitest's "Can't trigger Breakpad, just killing process". The only artefact was `~/Library/Logs/DiagnosticReports/plugin-container-*.ips`, faulting frame `CheckForBrokenChromeURL`, which is the symbolicated diagnosis the census bar asks for. The census now names that artefact and the smoke probe together, and states that the log naming nothing is the expected shape rather than an absence of evidence. Detection by log line is still deliberately not specified, and the module doc now records why.
102
+ - **The green-suite teardown belt could reject a run without saying which condition rejected it.** On a composed gate's tiles step: exit 1, 201 `TEST_END`, zero `Test FAIL`, zero `TEST-UNEXPECTED`, only the recognized `mozsystemmonitor` teardown traceback in the tail, FireForge's own "Known upstream …" note printed — and `FIREFORGE-VERDICT: FAIL reason=test-failures … unexpected=0`. The re-run was green and the log had rotated. Every condition in the belt is all-or-nothing, so from outside the failing verdict looks identical whichever one rejected it, and diagnosis reduced to guessing. A rejected run that DID carry the recognized teardown noise now names the first failing condition on the verdict line — `green-teardown override rejected: 2 matched failure line(s), first: …`. A run with no teardown noise was never a candidate for the belt and says nothing, because naming a rejecting condition there would be pure noise. Advisory only: the status and `reason` keys the verdict contract's consumers parse are unchanged.
103
+ - **The undefined-identifier hint named the shim when the cause was an unmanaged companion — and taking the hint would have masked a real bug.** Hoisting duplicated test helpers into a new `head_settings.js` that a managed `head.js` loads via `loadSubScript` leaves the companion owned by no patch until export, so the per-patch program reports every helper use as `Cannot find name` (157 warnings, in the reported case) with a hint offering to add the globals to the extra shim. That remedy makes all 157 disappear AND makes a genuinely missing helper undetectable forever after; the actual fix, `re-export <patch> --scan --scan-file <file>`, the hint never mentioned. When EVERY undefined name in a file is declared at the top level of an unmanaged file that a managed head loads, the hint now names that file, its loader and the adoption command, and rules the shim out explicitly. One unresolved name and the generic hint stays — the file may genuinely be missing a helper, which is the bug this detection exists not to hide.
104
+ - **A test-suite red rendered a build-shaped remedy list.** A failing assertion exited through `BuildError`, so the operator was told to "check the build output above", run `fireforge bootstrap`, and delete `obj-*` directories. None of the three applies to a failing test, and the first points at the wrong half of the log — the half a `tail` keeps. `TestFailureError` now carries remedies that name the failing tests (the `TEST-UNEXPECTED-*` blocks the verdict already collected and, until now, only logged), the `FIREFORGE-VERDICT:` line as ground truth, and the run log path, which is the one artifact that survives the pipe that caused the confusion. It subclasses `BuildError` and **keeps exit 5** deliberately: `docs/exit-codes.md` documents 5 as "a suite that FAILED" and consumers key CI on it, so only the message changes. The `--json` error code does move, from `build` to `test-failure`, because `machineErrorCode` derives it from the class name. `'Test process failed to start'` stays a `BuildError` — a harness that never spawned genuinely can be a build or environment problem, and the build remedies are apt there.
105
+ - **The known-teardown-noise filter covered the test path and not the build path — and could not simply be extended to it.** The `SystemResourceMonitor` `AttributeError` at teardown is recognized, documented upstream noise, and `mach build` surfaced it unannotated, where it reached one consumer as an unexplained build-log traceback during a 153.2.0esr drill. Threading the existing `annotateKnownTeardownNoise` option through the protected build dispatch was the obvious fix and is the wrong one, twice over. It would be a silent NO-OP: recognition requires a previously-seen `SUITE_END`, and a build never prints one. And relaxing that gate would be wrong on its own terms — the marker is what separates teardown from work still in progress, a build has no equivalent boundary, so FireForge cannot tell the known-cosmetic case from a real build-time traceback and must not withhold the block. Separately, the inherit path has no per-chunk hook, only a mirror that feeds the terminal and the run log the same string, so filtering there would strip the traceback from the raw log — the opposite of the "raw into the log, filtered into the echo" invariant the filter was built around.
106
+ So the build path RECOGNIZES rather than collapses: the traceback prints verbatim and one labeled line names it, using the same closed allowlist (two attributes, plus a `resourcemonitor.py` frame). A novel attribute stays unannotated, because it is a new upstream defect. The filter's module doc now states the test-only scope and both reasons for it, `MachOptions.annotateKnownTeardownNoise` documents that the inherit path ignores it (it type-checked and did nothing, which is how this reads as an oversight from outside), and the signature is documented in `docs/testing.md` — resolving the annotation's dangling "See FireForge docs.", which pointed at documentation that did not exist.
107
+ - **`fireforge source set` wrote a pin that a routine revert silently lost.** The pin lands in `fireforge.json` beside hand-maintained policy sections, so `git checkout -- fireforge.json` — undoing an accidental whole-file reformat, say — also reverts an uncommitted pin, and nothing afterwards reported that the pin and the engine checkout had diverged. The field tell was a gate flipping green with no change that should have made it green. `fireforge doctor` now reports the configured pin beside what the checkout actually is — the engine's own `browser/config/version.txt` and the version the last download recorded into state — and warns when they disagree. A report, not a lock and not a new refusal: a tree may legitimately be mid-migration, and the only thing missing was visibility. Scope is limited by what is actually recorded: `product`, `sha256` and `candidate` have no counterpart on disk (state holds only `downloadedVersion` and `baseCommit`), so the check says nothing about them rather than implying it verified them. A blank or whitespace-only `version.txt` — a truncated write, a partial extraction — reads as absent rather than as a mismatch against the empty string. The check reuses `getFirefoxVersion`, which had been sitting in `firefox-extract.ts` with no production caller.
108
+
109
+ ### Builds
110
+
111
+ - **The jar-only full-build escalation was over-broad.** `requiresFullBuildForIncrementalTest` was exactly `isJarManifestPath`, so ANY changed jar manifest escalated the pre-test build to a full `mach build` — ~10 minutes against ~20 s for a chrome-only slice, paid twice per green/no-change pair on a Furnace-touching slice. A downstream experiment (two clean runs against 0.44.4) settled the common case: a jar-only registration of a NEW content file added to an EXISTING `dist/bin` manifest was installed by a plain `fireforge build --ui` both times — `faster/install_dist_bin_browser` named the destination, the file landed in `dist/bin/…/content/browser/` and in the `.app` bundle, and a plain `fireforge test` fetched it over `chrome://` at runtime. Neither run escalated. The escalation is now narrowed to the two halves nobody has run: a NEW `jar.mn` (untracked in the engine repo — no install manifest exists for it yet) and a manifest whose jar declaration carries a bracketed base-directory prefix, which redirects the install destination away from the default chrome root. Every probe failure still escalates, so the error direction stays slow-but-correct, and the notice now names the manifest AND the reason instead of saying "a jar.mn registration changed".
112
+ - **Post-build packaging notices could not be triaged on a shared checkout.** The missing-artifact notice keys on "was touched", which on a checkout several sessions share means any of their dirty files — so an operator got a registration-shaped warning about a file they never edited and had to census ownership by hand to dismiss it. Each notice now carries the owning patch, or an explicit `unmanaged` mark. Resolution is best-effort and shares `status --ownership`'s source (the manifest's `filesAffected`); with no queue or an unreadable manifest the notice renders exactly as before, because "unmanaged" has to mean "checked and unowned" rather than "could not check".
113
+
114
+ ### Test harness
115
+
116
+ - **Nothing could see or reap orphaned harness processes.** Stopping a 45-minute tiles run mid-flight left `xpcshell` (the harness httpd, pegged at 100% CPU), `pywebsocket`, `ssltunnel` and `moz-http2` alive for an hour. Later runs crawled — a three-second suite took six minutes of wall clock, audio-start waits timed out — and nothing in the output connected the slowness to the survivors; the hour went into mis-attributing it to the change under test. Every `fireforge test` dispatch now runs a census of harness helper processes anchored to this project's objdir, naming each with its elapsed time and the `kill` command. It runs at PREFLIGHT, before this run spawns anything, so every hit is by construction a survivor and no age heuristic is needed. A match must be objdir-anchored: `xpcshell` and `server.js` are far too generic to report on their own, the same rule the mochitest server-port preflight already applies. Report-only by default; `--reap-orphans` opts into terminating them. It never refuses a run — a survivor degrades performance rather than making the run dispatch against the wrong server, so the right response is to say so loudly, not to stop.
117
+
118
+ ### Patch lint
119
+
120
+ - **Per-patch lint ran no Prettier on `.sys.mjs`, so module formatting drift was invisible to every gate** — which is how it survived to a human reviewer. `patchLint.prettier` (`'off'` by default) runs the project's configured Prettier over patch-owned `.sys.mjs` modules. It runs with `cwd` set to `engine/`, which is the half that matters: a fork's ROOT `.prettierignore` typically excludes `engine/`, so the same `prettier --check --config engine/.prettierrc.js <engine files>` run from the repo root reports "all files use Prettier code style" for files that fail the identical check from inside `engine/` — a root-level spot-check falsely refuted a correct reviewer finding on exactly this. Prettier is resolved from `engine/node_modules` first, then the project's, then `npx --no-install`. Exit 2 is reported as prettier failing rather than as formatting drift, since `--write` cannot fix it. Left off, formatting is explicitly out of scope for the per-patch tier — that is now the stated contract rather than an accident.
121
+ - **The `file-too-large` thresholds were module constants, and the soft tier was effectively hard.** Nothing in `fireforge.json` could move `500/750/900` (general) or `1200/1400/1600` (test), and because the recommended gate posture is `--max-warnings 0`, the 750 "soft limit" is a hard failure — a controller sitting at 751 lines failed a gate with a message calling 750 soft, at exactly the moment someone is deciding whether to restructure a file. `patchLint.fileSizeThresholds.{general,test}.{notice,warning,error}` now tunes them (each field optional, merged over the defaults, validated as positive integers ordered `notice <= warning <= error` against the MERGED triple). The message drops the soft/hard vocabulary for what the band actually is — a warning — and names the consequence: a gate running `--max-warnings 0` treats it as a failure. The patch-SIZE thresholds keep their existing wording; only the file-size rule was reported.
122
+
3
123
  ## 0.44.0
4
124
 
5
125
  ### Gates
@@ -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.44.4",
4
- "commit": "b8742ddc056124f81624d9c13d182d0b0c57c86c",
5
- "shortCommit": "b8742ddc0561",
3
+ "version": "0.46.0",
4
+ "commit": "cffe7e47008c7a2af690abbbe352eb685d35d3f8",
5
+ "shortCommit": "cffe7e47008c",
6
6
  "dirty": false,
7
7
  "dirtyHash": null,
8
- "buildTime": "2026-08-28T23:48:06.918Z"
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
+ }