@hominis/fireforge 0.46.0 → 0.46.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (606) hide show
  1. package/CHANGELOG.md +381 -498
  2. package/dist/bin/fireforge.d.ts +1 -1
  3. package/dist/bin/fireforge.js +17 -17
  4. package/dist/build-info.json +4 -4
  5. package/dist/src/cli-usage-error.d.ts +9 -9
  6. package/dist/src/cli-usage-error.js +12 -12
  7. package/dist/src/cli.d.ts +3 -2
  8. package/dist/src/cli.js +29 -28
  9. package/dist/src/commands/bootstrap-checks.js +2 -1
  10. package/dist/src/commands/bootstrap.js +2 -2
  11. package/dist/src/commands/build.js +12 -11
  12. package/dist/src/commands/config.js +9 -9
  13. package/dist/src/commands/destructive-decision.d.ts +4 -4
  14. package/dist/src/commands/destructive-decision.js +4 -4
  15. package/dist/src/commands/discard.js +5 -5
  16. package/dist/src/commands/doctor-check-core.d.ts +4 -4
  17. package/dist/src/commands/doctor-external-toolchains.js +2 -2
  18. package/dist/src/commands/doctor-furnace-config-sync.d.ts +2 -2
  19. package/dist/src/commands/doctor-furnace-config-sync.js +20 -20
  20. package/dist/src/commands/doctor-furnace-jar.d.ts +1 -1
  21. package/dist/src/commands/doctor-furnace-jar.js +1 -1
  22. package/dist/src/commands/doctor-furnace.js +22 -22
  23. package/dist/src/commands/doctor-orphaned-harness.d.ts +10 -11
  24. package/dist/src/commands/doctor-orphaned-harness.js +10 -11
  25. package/dist/src/commands/doctor-patch-manifest.d.ts +2 -1
  26. package/dist/src/commands/doctor-patch-manifest.js +7 -6
  27. package/dist/src/commands/doctor-post-rebase-audit.js +2 -2
  28. package/dist/src/commands/doctor-source-pin.d.ts +12 -12
  29. package/dist/src/commands/doctor-source-pin.js +14 -14
  30. package/dist/src/commands/doctor-working-tree.d.ts +3 -3
  31. package/dist/src/commands/doctor-working-tree.js +3 -3
  32. package/dist/src/commands/doctor.d.ts +2 -2
  33. package/dist/src/commands/doctor.js +9 -9
  34. package/dist/src/commands/download.js +15 -13
  35. package/dist/src/commands/export-all.js +8 -8
  36. package/dist/src/commands/export-flow.d.ts +5 -5
  37. package/dist/src/commands/export-flow.js +14 -14
  38. package/dist/src/commands/export-placement-conflicts.d.ts +3 -3
  39. package/dist/src/commands/export-placement-conflicts.js +9 -9
  40. package/dist/src/commands/export-placement-gate.d.ts +2 -2
  41. package/dist/src/commands/export-placement-gate.js +5 -5
  42. package/dist/src/commands/export-placement-policy.d.ts +1 -1
  43. package/dist/src/commands/export-placement-policy.js +1 -1
  44. package/dist/src/commands/export-shared.d.ts +10 -10
  45. package/dist/src/commands/export-shared.js +13 -13
  46. package/dist/src/commands/export.js +4 -4
  47. package/dist/src/commands/furnace/apply.js +16 -16
  48. package/dist/src/commands/furnace/chrome-doc-remove.d.ts +1 -1
  49. package/dist/src/commands/furnace/chrome-doc-remove.js +1 -1
  50. package/dist/src/commands/furnace/chrome-doc-templates.d.ts +14 -14
  51. package/dist/src/commands/furnace/chrome-doc-templates.js +17 -17
  52. package/dist/src/commands/furnace/chrome-doc-tests.d.ts +2 -2
  53. package/dist/src/commands/furnace/chrome-doc-tests.js +4 -4
  54. package/dist/src/commands/furnace/chrome-doc.d.ts +8 -8
  55. package/dist/src/commands/furnace/chrome-doc.js +18 -18
  56. package/dist/src/commands/furnace/create-browser-test.d.ts +1 -1
  57. package/dist/src/commands/furnace/create-browser-test.js +4 -4
  58. package/dist/src/commands/furnace/create-dry-run.d.ts +4 -4
  59. package/dist/src/commands/furnace/create-dry-run.js +4 -4
  60. package/dist/src/commands/furnace/create-features.d.ts +2 -2
  61. package/dist/src/commands/furnace/create-features.js +2 -2
  62. package/dist/src/commands/furnace/create-mochikit.d.ts +4 -4
  63. package/dist/src/commands/furnace/create-mochikit.js +7 -7
  64. package/dist/src/commands/furnace/create-templates.d.ts +14 -14
  65. package/dist/src/commands/furnace/create-templates.js +18 -18
  66. package/dist/src/commands/furnace/create-xpcshell.d.ts +5 -5
  67. package/dist/src/commands/furnace/create-xpcshell.js +8 -8
  68. package/dist/src/commands/furnace/create.d.ts +1 -1
  69. package/dist/src/commands/furnace/create.js +10 -10
  70. package/dist/src/commands/furnace/deploy.js +4 -4
  71. package/dist/src/commands/furnace/diff.js +7 -7
  72. package/dist/src/commands/furnace/index.js +1 -1
  73. package/dist/src/commands/furnace/init.js +15 -15
  74. package/dist/src/commands/furnace/list.js +2 -2
  75. package/dist/src/commands/furnace/override.d.ts +1 -1
  76. package/dist/src/commands/furnace/override.js +15 -15
  77. package/dist/src/commands/furnace/preview.js +17 -17
  78. package/dist/src/commands/furnace/refresh.js +6 -6
  79. package/dist/src/commands/furnace/remove-mutations.d.ts +3 -3
  80. package/dist/src/commands/furnace/remove-mutations.js +19 -18
  81. package/dist/src/commands/furnace/remove-state.d.ts +1 -1
  82. package/dist/src/commands/furnace/remove-state.js +1 -1
  83. package/dist/src/commands/furnace/remove.js +2 -2
  84. package/dist/src/commands/furnace/rename-browser-test.js +9 -8
  85. package/dist/src/commands/furnace/rename-xpcshell.d.ts +2 -2
  86. package/dist/src/commands/furnace/rename-xpcshell.js +3 -3
  87. package/dist/src/commands/furnace/rename.js +9 -9
  88. package/dist/src/commands/furnace/scan.js +3 -3
  89. package/dist/src/commands/furnace/status.js +7 -7
  90. package/dist/src/commands/furnace/sync.js +6 -6
  91. package/dist/src/commands/furnace/test-file-name.d.ts +2 -2
  92. package/dist/src/commands/furnace/test-file-name.js +2 -2
  93. package/dist/src/commands/furnace/validate.js +11 -12
  94. package/dist/src/commands/import.js +20 -20
  95. package/dist/src/commands/lint-per-patch.js +8 -8
  96. package/dist/src/commands/lint-per-run-checkjs.d.ts +12 -13
  97. package/dist/src/commands/lint-per-run-checkjs.js +14 -15
  98. package/dist/src/commands/lint-report.d.ts +3 -3
  99. package/dist/src/commands/lint-report.js +4 -4
  100. package/dist/src/commands/lint.d.ts +3 -3
  101. package/dist/src/commands/lint.js +35 -35
  102. package/dist/src/commands/manifest.d.ts +4 -4
  103. package/dist/src/commands/package.js +2 -2
  104. package/dist/src/commands/patch/compact.d.ts +2 -2
  105. package/dist/src/commands/patch/compact.js +8 -8
  106. package/dist/src/commands/patch/delete.d.ts +1 -1
  107. package/dist/src/commands/patch/delete.js +8 -8
  108. package/dist/src/commands/patch/lint-ignore.d.ts +3 -4
  109. package/dist/src/commands/patch/lint-ignore.js +14 -15
  110. package/dist/src/commands/patch/move-files-create.d.ts +2 -2
  111. package/dist/src/commands/patch/move-files-create.js +5 -5
  112. package/dist/src/commands/patch/move-files-into.d.ts +2 -2
  113. package/dist/src/commands/patch/move-files-into.js +3 -3
  114. package/dist/src/commands/patch/move-files.js +1 -1
  115. package/dist/src/commands/patch/patch-context.d.ts +1 -1
  116. package/dist/src/commands/patch/rename.d.ts +4 -4
  117. package/dist/src/commands/patch/rename.js +8 -8
  118. package/dist/src/commands/patch/reorder.js +10 -10
  119. package/dist/src/commands/patch/split-plan.d.ts +3 -3
  120. package/dist/src/commands/patch/split-plan.js +6 -5
  121. package/dist/src/commands/patch/split.d.ts +2 -2
  122. package/dist/src/commands/patch/split.js +5 -5
  123. package/dist/src/commands/patch/staged-dependency-validate.d.ts +1 -1
  124. package/dist/src/commands/patch/staged-dependency-validate.js +3 -3
  125. package/dist/src/commands/patch/staged-dependency.d.ts +1 -1
  126. package/dist/src/commands/patch/staged-dependency.js +6 -6
  127. package/dist/src/commands/patch/tier.d.ts +2 -2
  128. package/dist/src/commands/patch/tier.js +5 -5
  129. package/dist/src/commands/patch-rename-map.d.ts +1 -1
  130. package/dist/src/commands/patch-rename-map.js +1 -1
  131. package/dist/src/commands/pending-resolution.d.ts +5 -5
  132. package/dist/src/commands/pending-resolution.js +5 -5
  133. package/dist/src/commands/re-export-adjacent.d.ts +3 -3
  134. package/dist/src/commands/re-export-adjacent.js +8 -8
  135. package/dist/src/commands/re-export-bulk-scan.d.ts +3 -3
  136. package/dist/src/commands/re-export-bulk-scan.js +17 -17
  137. package/dist/src/commands/re-export-drift.d.ts +10 -10
  138. package/dist/src/commands/re-export-drift.js +29 -29
  139. package/dist/src/commands/re-export-files.js +14 -15
  140. package/dist/src/commands/re-export-lint.d.ts +26 -26
  141. package/dist/src/commands/re-export-lint.js +27 -27
  142. package/dist/src/commands/re-export-options.d.ts +1 -1
  143. package/dist/src/commands/re-export-options.js +1 -1
  144. package/dist/src/commands/re-export-register.d.ts +1 -1
  145. package/dist/src/commands/re-export-register.js +1 -1
  146. package/dist/src/commands/re-export-scan.d.ts +1 -1
  147. package/dist/src/commands/re-export-scan.js +7 -7
  148. package/dist/src/commands/re-export-single.d.ts +1 -1
  149. package/dist/src/commands/re-export-single.js +20 -20
  150. package/dist/src/commands/re-export.js +7 -6
  151. package/dist/src/commands/rebase/abort.js +6 -6
  152. package/dist/src/commands/rebase/continue.js +8 -7
  153. package/dist/src/commands/rebase/index.d.ts +1 -1
  154. package/dist/src/commands/rebase/index.js +3 -3
  155. package/dist/src/commands/rebase/patch-loop.js +18 -17
  156. package/dist/src/commands/register.js +5 -5
  157. package/dist/src/commands/reset.js +1 -1
  158. package/dist/src/commands/resolve.d.ts +2 -2
  159. package/dist/src/commands/resolve.js +3 -3
  160. package/dist/src/commands/run.js +26 -26
  161. package/dist/src/commands/setup-support.js +4 -4
  162. package/dist/src/commands/setup.js +1 -1
  163. package/dist/src/commands/source.js +1 -1
  164. package/dist/src/commands/status-check.d.ts +3 -3
  165. package/dist/src/commands/status-check.js +4 -4
  166. package/dist/src/commands/status-json.d.ts +14 -13
  167. package/dist/src/commands/status-json.js +9 -8
  168. package/dist/src/commands/status-output.d.ts +3 -3
  169. package/dist/src/commands/status-output.js +3 -3
  170. package/dist/src/commands/status-ownership.d.ts +3 -3
  171. package/dist/src/commands/status-ownership.js +3 -3
  172. package/dist/src/commands/status.js +22 -22
  173. package/dist/src/commands/test-build-phase.d.ts +1 -1
  174. package/dist/src/commands/test-build-phase.js +17 -17
  175. package/dist/src/commands/test-diagnose.d.ts +1 -1
  176. package/dist/src/commands/test-diagnose.js +20 -20
  177. package/dist/src/commands/test-modes.d.ts +2 -2
  178. package/dist/src/commands/test-modes.js +4 -4
  179. package/dist/src/commands/test-run.d.ts +11 -11
  180. package/dist/src/commands/test-run.js +15 -15
  181. package/dist/src/commands/test-stale-gate.d.ts +12 -12
  182. package/dist/src/commands/test-stale-gate.js +13 -13
  183. package/dist/src/commands/test-verdict.d.ts +6 -6
  184. package/dist/src/commands/test-verdict.js +11 -11
  185. package/dist/src/commands/test.d.ts +3 -3
  186. package/dist/src/commands/test.js +36 -36
  187. package/dist/src/commands/token-coverage.js +3 -3
  188. package/dist/src/commands/token-list.js +5 -5
  189. package/dist/src/commands/token.js +9 -9
  190. package/dist/src/commands/tree.js +19 -19
  191. package/dist/src/commands/typecheck.d.ts +6 -6
  192. package/dist/src/commands/typecheck.js +7 -7
  193. package/dist/src/commands/verify.d.ts +3 -3
  194. package/dist/src/commands/verify.js +10 -10
  195. package/dist/src/commands/watch.js +11 -11
  196. package/dist/src/commands/wire.js +13 -13
  197. package/dist/src/core/ast-utils.d.ts +7 -7
  198. package/dist/src/core/ast-utils.js +8 -8
  199. package/dist/src/core/branding.d.ts +5 -5
  200. package/dist/src/core/branding.js +3 -3
  201. package/dist/src/core/browser-wire.d.ts +1 -1
  202. package/dist/src/core/browser-wire.js +4 -4
  203. package/dist/src/core/build-audit-platform.d.ts +6 -6
  204. package/dist/src/core/build-audit-platform.js +26 -26
  205. package/dist/src/core/build-audit-registration.d.ts +5 -5
  206. package/dist/src/core/build-audit-registration.js +7 -7
  207. package/dist/src/core/build-audit-resolve.d.ts +5 -5
  208. package/dist/src/core/build-audit-resolve.js +6 -6
  209. package/dist/src/core/build-audit-skip.d.ts +8 -8
  210. package/dist/src/core/build-audit-skip.js +12 -12
  211. package/dist/src/core/build-audit-transforms.d.ts +2 -2
  212. package/dist/src/core/build-audit-transforms.js +3 -3
  213. package/dist/src/core/build-audit.d.ts +8 -8
  214. package/dist/src/core/build-audit.js +39 -37
  215. package/dist/src/core/build-baseline-types.d.ts +19 -18
  216. package/dist/src/core/build-baseline-types.js +1 -1
  217. package/dist/src/core/build-baseline.d.ts +8 -8
  218. package/dist/src/core/build-baseline.js +14 -14
  219. package/dist/src/core/build-jar-escalation.d.ts +8 -8
  220. package/dist/src/core/build-jar-escalation.js +15 -15
  221. package/dist/src/core/build-overwrite-guard.d.ts +4 -4
  222. package/dist/src/core/build-overwrite-guard.js +13 -13
  223. package/dist/src/core/build-prepare.d.ts +4 -4
  224. package/dist/src/core/build-prepare.js +16 -15
  225. package/dist/src/core/config-mutate.js +4 -4
  226. package/dist/src/core/config-state.js +2 -2
  227. package/dist/src/core/config-validate-build-audit.js +3 -3
  228. package/dist/src/core/config-validate-file-size.d.ts +1 -1
  229. package/dist/src/core/config-validate-file-size.js +2 -2
  230. package/dist/src/core/config-validate.js +8 -9
  231. package/dist/src/core/config.d.ts +10 -10
  232. package/dist/src/core/config.js +10 -10
  233. package/dist/src/core/coverage-extend.d.ts +16 -16
  234. package/dist/src/core/coverage-extend.js +16 -16
  235. package/dist/src/core/destructive.d.ts +14 -14
  236. package/dist/src/core/destructive.js +9 -9
  237. package/dist/src/core/diff-hunks.d.ts +2 -2
  238. package/dist/src/core/diff-hunks.js +7 -7
  239. package/dist/src/core/discard-baseline.d.ts +10 -10
  240. package/dist/src/core/discard-baseline.js +15 -16
  241. package/dist/src/core/display-state.d.ts +11 -11
  242. package/dist/src/core/display-state.js +12 -12
  243. package/dist/src/core/engine-changes.d.ts +2 -2
  244. package/dist/src/core/engine-changes.js +2 -2
  245. package/dist/src/core/engine-precondition.d.ts +4 -4
  246. package/dist/src/core/engine-precondition.js +2 -2
  247. package/dist/src/core/engine-session-lock.d.ts +18 -18
  248. package/dist/src/core/engine-session-lock.js +37 -37
  249. package/dist/src/core/export-moved-code-shape.d.ts +7 -7
  250. package/dist/src/core/export-moved-code-shape.js +8 -8
  251. package/dist/src/core/file-lock-owner.d.ts +11 -11
  252. package/dist/src/core/file-lock-owner.js +18 -18
  253. package/dist/src/core/file-lock.d.ts +12 -12
  254. package/dist/src/core/file-lock.js +52 -39
  255. package/dist/src/core/firefox-cache.d.ts +1 -1
  256. package/dist/src/core/firefox-cache.js +10 -10
  257. package/dist/src/core/firefox-download.d.ts +3 -3
  258. package/dist/src/core/firefox-download.js +6 -6
  259. package/dist/src/core/firefox-extract.d.ts +2 -2
  260. package/dist/src/core/firefox-extract.js +13 -13
  261. package/dist/src/core/firefox.d.ts +5 -5
  262. package/dist/src/core/firefox.js +8 -8
  263. package/dist/src/core/furnace-apply-dry-run.js +1 -1
  264. package/dist/src/core/furnace-apply-ftl.d.ts +5 -5
  265. package/dist/src/core/furnace-apply-ftl.js +8 -8
  266. package/dist/src/core/furnace-apply-helpers.d.ts +9 -9
  267. package/dist/src/core/furnace-apply-helpers.js +21 -21
  268. package/dist/src/core/furnace-apply-output.js +1 -1
  269. package/dist/src/core/furnace-apply-overwrite-warn.d.ts +9 -9
  270. package/dist/src/core/furnace-apply-overwrite-warn.js +9 -9
  271. package/dist/src/core/furnace-apply-undeploy-actions.d.ts +1 -1
  272. package/dist/src/core/furnace-apply-undeploy-actions.js +1 -1
  273. package/dist/src/core/furnace-apply.d.ts +3 -3
  274. package/dist/src/core/furnace-apply.js +22 -21
  275. package/dist/src/core/furnace-checksum-utils.d.ts +5 -5
  276. package/dist/src/core/furnace-checksum-utils.js +5 -5
  277. package/dist/src/core/furnace-config-custom.d.ts +1 -1
  278. package/dist/src/core/furnace-config-custom.js +2 -2
  279. package/dist/src/core/furnace-config-order.js +1 -1
  280. package/dist/src/core/furnace-config-tokens.d.ts +3 -3
  281. package/dist/src/core/furnace-config-tokens.js +3 -3
  282. package/dist/src/core/furnace-config-validate.js +2 -2
  283. package/dist/src/core/furnace-config.d.ts +4 -4
  284. package/dist/src/core/furnace-config.js +18 -18
  285. package/dist/src/core/furnace-constants.d.ts +6 -6
  286. package/dist/src/core/furnace-constants.js +6 -6
  287. package/dist/src/core/furnace-css-fragments.d.ts +6 -6
  288. package/dist/src/core/furnace-css-fragments.js +11 -11
  289. package/dist/src/core/furnace-dir-entry.d.ts +1 -1
  290. package/dist/src/core/furnace-dir-entry.js +1 -1
  291. package/dist/src/core/furnace-jsconfig.d.ts +7 -7
  292. package/dist/src/core/furnace-jsconfig.js +22 -20
  293. package/dist/src/core/furnace-operation.d.ts +21 -20
  294. package/dist/src/core/furnace-operation.js +36 -34
  295. package/dist/src/core/furnace-precondition.d.ts +10 -10
  296. package/dist/src/core/furnace-precondition.js +8 -8
  297. package/dist/src/core/furnace-refresh.js +2 -2
  298. package/dist/src/core/furnace-registration-ast.d.ts +2 -2
  299. package/dist/src/core/furnace-registration-ast.js +9 -9
  300. package/dist/src/core/furnace-registration-remove.d.ts +4 -4
  301. package/dist/src/core/furnace-registration-remove.js +6 -6
  302. package/dist/src/core/furnace-registration-validate.d.ts +2 -2
  303. package/dist/src/core/furnace-registration-validate.js +2 -2
  304. package/dist/src/core/furnace-registration.d.ts +5 -5
  305. package/dist/src/core/furnace-registration.js +7 -7
  306. package/dist/src/core/furnace-rollback.d.ts +1 -1
  307. package/dist/src/core/furnace-rollback.js +5 -5
  308. package/dist/src/core/furnace-stale-export.d.ts +17 -16
  309. package/dist/src/core/furnace-stale-export.js +16 -15
  310. package/dist/src/core/furnace-staleness.d.ts +2 -2
  311. package/dist/src/core/furnace-staleness.js +2 -2
  312. package/dist/src/core/furnace-state-persist.d.ts +10 -10
  313. package/dist/src/core/furnace-state-persist.js +10 -10
  314. package/dist/src/core/furnace-step-errors.d.ts +6 -6
  315. package/dist/src/core/furnace-step-errors.js +6 -6
  316. package/dist/src/core/furnace-validate-accessibility.js +14 -14
  317. package/dist/src/core/furnace-validate-compatibility.js +9 -9
  318. package/dist/src/core/furnace-validate-registration.d.ts +2 -2
  319. package/dist/src/core/furnace-validate-registration.js +13 -13
  320. package/dist/src/core/furnace-validate-structure.d.ts +1 -1
  321. package/dist/src/core/furnace-validate-structure.js +4 -4
  322. package/dist/src/core/furnace-validate.js +9 -9
  323. package/dist/src/core/furnace-version-drift.d.ts +5 -5
  324. package/dist/src/core/furnace-version-drift.js +5 -5
  325. package/dist/src/core/git-base.d.ts +4 -4
  326. package/dist/src/core/git-base.js +9 -9
  327. package/dist/src/core/git-diff-new-file.d.ts +4 -4
  328. package/dist/src/core/git-diff-new-file.js +8 -8
  329. package/dist/src/core/git-diff.d.ts +4 -4
  330. package/dist/src/core/git-diff.js +35 -34
  331. package/dist/src/core/git-file-ops.d.ts +6 -6
  332. package/dist/src/core/git-file-ops.js +7 -7
  333. package/dist/src/core/git-readonly-index.d.ts +12 -12
  334. package/dist/src/core/git-readonly-index.js +12 -12
  335. package/dist/src/core/git-status.d.ts +4 -4
  336. package/dist/src/core/git-status.js +4 -4
  337. package/dist/src/core/git.d.ts +3 -3
  338. package/dist/src/core/git.js +14 -14
  339. package/dist/src/core/harness-orphans.d.ts +17 -17
  340. package/dist/src/core/harness-orphans.js +20 -20
  341. package/dist/src/core/history-log.d.ts +3 -3
  342. package/dist/src/core/history-log.js +3 -3
  343. package/dist/src/core/license-headers.d.ts +19 -18
  344. package/dist/src/core/license-headers.js +29 -28
  345. package/dist/src/core/mach-build-artifacts.d.ts +21 -21
  346. package/dist/src/core/mach-build-artifacts.js +23 -23
  347. package/dist/src/core/mach-error-hints.d.ts +5 -5
  348. package/dist/src/core/mach-error-hints.js +13 -13
  349. package/dist/src/core/mach-known-noise-filter.d.ts +41 -38
  350. package/dist/src/core/mach-known-noise-filter.js +53 -50
  351. package/dist/src/core/mach-mozconfig.d.ts +6 -6
  352. package/dist/src/core/mach-mozconfig.js +15 -15
  353. package/dist/src/core/mach-python.d.ts +2 -1
  354. package/dist/src/core/mach-python.js +4 -3
  355. package/dist/src/core/mach-resource-shim.d.ts +12 -12
  356. package/dist/src/core/mach-resource-shim.js +20 -20
  357. package/dist/src/core/mach.d.ts +23 -23
  358. package/dist/src/core/mach.js +36 -36
  359. package/dist/src/core/marionette-port.d.ts +15 -13
  360. package/dist/src/core/marionette-port.js +34 -31
  361. package/dist/src/core/marionette-preflight.d.ts +6 -6
  362. package/dist/src/core/marionette-preflight.js +23 -23
  363. package/dist/src/core/markdown-table.d.ts +9 -9
  364. package/dist/src/core/markdown-table.js +14 -14
  365. package/dist/src/core/mochitest-server-port.d.ts +8 -8
  366. package/dist/src/core/mochitest-server-port.js +14 -14
  367. package/dist/src/core/module-resolution-preflight.d.ts +8 -8
  368. package/dist/src/core/module-resolution-preflight.js +11 -11
  369. package/dist/src/core/moz-manifest-helpers.d.ts +7 -7
  370. package/dist/src/core/moz-manifest-helpers.js +7 -7
  371. package/dist/src/core/moz-manifest-register.d.ts +1 -1
  372. package/dist/src/core/moz-manifest-register.js +2 -2
  373. package/dist/src/core/moz-manifest-rules.js +11 -11
  374. package/dist/src/core/moz-manifest-tokenizers.js +1 -1
  375. package/dist/src/core/ownership-table.d.ts +5 -5
  376. package/dist/src/core/ownership-table.js +7 -7
  377. package/dist/src/core/parser-fallback.d.ts +3 -3
  378. package/dist/src/core/parser-fallback.js +3 -3
  379. package/dist/src/core/patch-apply-fuzz.d.ts +5 -5
  380. package/dist/src/core/patch-apply-fuzz.js +9 -9
  381. package/dist/src/core/patch-apply.d.ts +9 -9
  382. package/dist/src/core/patch-apply.js +30 -30
  383. package/dist/src/core/patch-export-update.d.ts +4 -4
  384. package/dist/src/core/patch-export-update.js +3 -3
  385. package/dist/src/core/patch-export.d.ts +10 -10
  386. package/dist/src/core/patch-export.js +13 -13
  387. package/dist/src/core/patch-identifier-suggest.d.ts +1 -1
  388. package/dist/src/core/patch-identifier-suggest.js +4 -4
  389. package/dist/src/core/patch-lint-binary.d.ts +7 -7
  390. package/dist/src/core/patch-lint-cache.d.ts +2 -2
  391. package/dist/src/core/patch-lint-cache.js +13 -13
  392. package/dist/src/core/patch-lint-checkjs.d.ts +26 -26
  393. package/dist/src/core/patch-lint-checkjs.js +42 -41
  394. package/dist/src/core/patch-lint-chrome-jsdoc.d.ts +8 -9
  395. package/dist/src/core/patch-lint-chrome-jsdoc.js +10 -11
  396. package/dist/src/core/patch-lint-creators.d.ts +3 -3
  397. package/dist/src/core/patch-lint-creators.js +3 -3
  398. package/dist/src/core/patch-lint-cross.d.ts +13 -13
  399. package/dist/src/core/patch-lint-cross.js +21 -21
  400. package/dist/src/core/patch-lint-css.d.ts +1 -1
  401. package/dist/src/core/patch-lint-css.js +5 -5
  402. package/dist/src/core/patch-lint-diff-tag.d.ts +5 -5
  403. package/dist/src/core/patch-lint-diff-tag.js +5 -5
  404. package/dist/src/core/patch-lint-diff.d.ts +4 -4
  405. package/dist/src/core/patch-lint-diff.js +4 -4
  406. package/dist/src/core/patch-lint-file-size.d.ts +3 -3
  407. package/dist/src/core/patch-lint-file-size.js +3 -3
  408. package/dist/src/core/patch-lint-forward-registration.d.ts +4 -4
  409. package/dist/src/core/patch-lint-forward-registration.js +12 -12
  410. package/dist/src/core/patch-lint-jsdoc.d.ts +2 -2
  411. package/dist/src/core/patch-lint-jsdoc.js +11 -11
  412. package/dist/src/core/patch-lint-module-registration.d.ts +1 -1
  413. package/dist/src/core/patch-lint-module-registration.js +1 -1
  414. package/dist/src/core/patch-lint-mozbuild.d.ts +1 -1
  415. package/dist/src/core/patch-lint-mozbuild.js +10 -10
  416. package/dist/src/core/patch-lint-observer.d.ts +3 -3
  417. package/dist/src/core/patch-lint-observer.js +7 -7
  418. package/dist/src/core/patch-lint-ownership.d.ts +6 -6
  419. package/dist/src/core/patch-lint-ownership.js +6 -6
  420. package/dist/src/core/patch-lint-prettier.d.ts +6 -6
  421. package/dist/src/core/patch-lint-prettier.js +6 -6
  422. package/dist/src/core/patch-lint-queue-types.d.ts +1 -1
  423. package/dist/src/core/patch-lint-queue-types.js +1 -1
  424. package/dist/src/core/patch-lint-staged-registration.d.ts +2 -2
  425. package/dist/src/core/patch-lint-staged-registration.js +6 -6
  426. package/dist/src/core/patch-lint-unmanaged-companion.d.ts +23 -23
  427. package/dist/src/core/patch-lint-unmanaged-companion.js +26 -26
  428. package/dist/src/core/patch-lint.d.ts +27 -26
  429. package/dist/src/core/patch-lint.js +48 -46
  430. package/dist/src/core/patch-lock.d.ts +1 -1
  431. package/dist/src/core/patch-lock.js +8 -8
  432. package/dist/src/core/patch-manifest-consistency.d.ts +7 -7
  433. package/dist/src/core/patch-manifest-consistency.js +16 -17
  434. package/dist/src/core/patch-manifest-files-affected.d.ts +1 -1
  435. package/dist/src/core/patch-manifest-files-affected.js +3 -3
  436. package/dist/src/core/patch-manifest-io.d.ts +14 -14
  437. package/dist/src/core/patch-manifest-io.js +27 -27
  438. package/dist/src/core/patch-manifest-query.d.ts +1 -1
  439. package/dist/src/core/patch-manifest-query.js +4 -4
  440. package/dist/src/core/patch-manifest-resolve.d.ts +5 -5
  441. package/dist/src/core/patch-manifest-resolve.js +7 -7
  442. package/dist/src/core/patch-manifest-validate.js +3 -3
  443. package/dist/src/core/patch-manifest.d.ts +2 -2
  444. package/dist/src/core/patch-manifest.js +2 -2
  445. package/dist/src/core/patch-parse.d.ts +14 -14
  446. package/dist/src/core/patch-parse.js +16 -16
  447. package/dist/src/core/patch-registration-refs.d.ts +4 -4
  448. package/dist/src/core/patch-registration-refs.js +7 -7
  449. package/dist/src/core/patch-transform.d.ts +8 -8
  450. package/dist/src/core/patch-transform.js +13 -13
  451. package/dist/src/core/process-cpu-time.d.ts +1 -1
  452. package/dist/src/core/process-cpu-time.js +5 -5
  453. package/dist/src/core/rebase-session.d.ts +7 -7
  454. package/dist/src/core/rebase-session.js +8 -8
  455. package/dist/src/core/register-result.d.ts +1 -1
  456. package/dist/src/core/register-result.js +1 -1
  457. package/dist/src/core/register-scaffold.d.ts +2 -2
  458. package/dist/src/core/register-scaffold.js +3 -3
  459. package/dist/src/core/register-shared-css.d.ts +3 -3
  460. package/dist/src/core/register-shared-css.js +4 -4
  461. package/dist/src/core/register-xpcshell-test.d.ts +2 -2
  462. package/dist/src/core/register-xpcshell-test.js +2 -2
  463. package/dist/src/core/run-log-redact.d.ts +12 -12
  464. package/dist/src/core/run-log-redact.js +16 -16
  465. package/dist/src/core/run-log.d.ts +9 -9
  466. package/dist/src/core/run-log.js +14 -14
  467. package/dist/src/core/shared-ftl.d.ts +2 -2
  468. package/dist/src/core/shared-ftl.js +6 -6
  469. package/dist/src/core/signal-critical.d.ts +2 -2
  470. package/dist/src/core/signal-critical.js +2 -2
  471. package/dist/src/core/smoke-patterns.d.ts +5 -5
  472. package/dist/src/core/smoke-patterns.js +9 -9
  473. package/dist/src/core/state-file.d.ts +3 -3
  474. package/dist/src/core/status-binary.d.ts +8 -8
  475. package/dist/src/core/status-binary.js +12 -12
  476. package/dist/src/core/status-classify.d.ts +17 -17
  477. package/dist/src/core/status-classify.js +11 -11
  478. package/dist/src/core/test-changed-prefs.d.ts +12 -12
  479. package/dist/src/core/test-changed-prefs.js +15 -15
  480. package/dist/src/core/test-harness-crash.d.ts +39 -39
  481. package/dist/src/core/test-harness-crash.js +67 -67
  482. package/dist/src/core/test-harness-output.d.ts +7 -7
  483. package/dist/src/core/test-harness-output.js +7 -7
  484. package/dist/src/core/test-harness-verdict-notes.d.ts +13 -14
  485. package/dist/src/core/test-harness-verdict-notes.js +4 -4
  486. package/dist/src/core/test-path-scope.d.ts +8 -8
  487. package/dist/src/core/test-path-scope.js +11 -11
  488. package/dist/src/core/test-stale-check.d.ts +22 -21
  489. package/dist/src/core/test-stale-check.js +29 -28
  490. package/dist/src/core/test-stale-symlink.d.ts +1 -1
  491. package/dist/src/core/test-stale-symlink.js +1 -1
  492. package/dist/src/core/test-stall-triage.d.ts +9 -9
  493. package/dist/src/core/test-stall-triage.js +22 -22
  494. package/dist/src/core/token-category.d.ts +4 -4
  495. package/dist/src/core/token-category.js +13 -12
  496. package/dist/src/core/token-coverage.js +4 -5
  497. package/dist/src/core/token-dark-mode.d.ts +17 -9
  498. package/dist/src/core/token-dark-mode.js +41 -28
  499. package/dist/src/core/token-docs.d.ts +1 -1
  500. package/dist/src/core/token-inventory.d.ts +6 -6
  501. package/dist/src/core/token-inventory.js +7 -7
  502. package/dist/src/core/token-manager.d.ts +3 -3
  503. package/dist/src/core/token-manager.js +21 -21
  504. package/dist/src/core/token-scaffold.d.ts +4 -4
  505. package/dist/src/core/token-scaffold.js +4 -4
  506. package/dist/src/core/token-variant.d.ts +14 -14
  507. package/dist/src/core/token-variant.js +32 -32
  508. package/dist/src/core/toolchain-preflight.d.ts +15 -15
  509. package/dist/src/core/toolchain-preflight.js +22 -22
  510. package/dist/src/core/tree-cow.d.ts +3 -3
  511. package/dist/src/core/tree-cow.js +6 -6
  512. package/dist/src/core/tree-guard.d.ts +3 -3
  513. package/dist/src/core/tree-guard.js +23 -23
  514. package/dist/src/core/tree-store.d.ts +15 -15
  515. package/dist/src/core/tree-store.js +35 -33
  516. package/dist/src/core/typecheck-shim.d.ts +16 -15
  517. package/dist/src/core/typecheck-shim.js +26 -25
  518. package/dist/src/core/typecheck.d.ts +12 -12
  519. package/dist/src/core/typecheck.js +31 -31
  520. package/dist/src/core/wire-destroy.d.ts +2 -2
  521. package/dist/src/core/wire-destroy.js +5 -5
  522. package/dist/src/core/wire-dom-fragment.d.ts +3 -3
  523. package/dist/src/core/wire-dom-fragment.js +6 -6
  524. package/dist/src/core/wire-init.d.ts +2 -2
  525. package/dist/src/core/wire-init.js +7 -7
  526. package/dist/src/core/wire-subscript.d.ts +1 -1
  527. package/dist/src/core/wire-subscript.js +3 -3
  528. package/dist/src/core/wire-utils.d.ts +9 -9
  529. package/dist/src/core/wire-utils.js +11 -11
  530. package/dist/src/core/xpcshell-appdir.d.ts +20 -20
  531. package/dist/src/core/xpcshell-appdir.js +26 -26
  532. package/dist/src/core/xpcshell-profile-dir.d.ts +3 -3
  533. package/dist/src/core/xpcshell-profile-dir.js +7 -7
  534. package/dist/src/errors/base.d.ts +22 -21
  535. package/dist/src/errors/base.js +22 -21
  536. package/dist/src/errors/build.d.ts +6 -6
  537. package/dist/src/errors/build.js +6 -6
  538. package/dist/src/errors/codes.d.ts +10 -10
  539. package/dist/src/errors/codes.js +10 -10
  540. package/dist/src/errors/furnace.d.ts +2 -2
  541. package/dist/src/errors/git.d.ts +1 -1
  542. package/dist/src/errors/git.js +1 -1
  543. package/dist/src/errors/patch.d.ts +3 -3
  544. package/dist/src/errors/patch.js +3 -3
  545. package/dist/src/errors/rebase.d.ts +1 -1
  546. package/dist/src/errors/rebase.js +1 -1
  547. package/dist/src/errors/run.d.ts +1 -1
  548. package/dist/src/errors/run.js +1 -1
  549. package/dist/src/index.d.ts +4 -4
  550. package/dist/src/index.js +4 -4
  551. package/dist/src/types/commands/options.d.ts +86 -85
  552. package/dist/src/types/commands/patches.d.ts +19 -19
  553. package/dist/src/types/commands/project.d.ts +3 -3
  554. package/dist/src/types/config.d.ts +24 -23
  555. package/dist/src/types/furnace.d.ts +17 -17
  556. package/dist/src/types/json.d.ts +3 -3
  557. package/dist/src/types/json.js +2 -2
  558. package/dist/src/types/typecheck.d.ts +3 -3
  559. package/dist/src/utils/assert.d.ts +1 -1
  560. package/dist/src/utils/assert.js +6 -6
  561. package/dist/src/utils/build-info.d.ts +5 -5
  562. package/dist/src/utils/build-info.js +6 -6
  563. package/dist/src/utils/child-env.d.ts +5 -5
  564. package/dist/src/utils/child-env.js +5 -5
  565. package/dist/src/utils/concurrency.d.ts +2 -2
  566. package/dist/src/utils/concurrency.js +2 -2
  567. package/dist/src/utils/elapsed.js +1 -1
  568. package/dist/src/utils/errors.d.ts +8 -8
  569. package/dist/src/utils/errors.js +8 -8
  570. package/dist/src/utils/fs.d.ts +1 -1
  571. package/dist/src/utils/fs.js +8 -8
  572. package/dist/src/utils/line-dispatch.d.ts +3 -3
  573. package/dist/src/utils/line-dispatch.js +5 -5
  574. package/dist/src/utils/logger.d.ts +4 -4
  575. package/dist/src/utils/logger.js +11 -11
  576. package/dist/src/utils/machine-output.js +2 -2
  577. package/dist/src/utils/options.d.ts +13 -13
  578. package/dist/src/utils/options.js +20 -20
  579. package/dist/src/utils/package-root.js +2 -2
  580. package/dist/src/utils/paths.d.ts +4 -4
  581. package/dist/src/utils/paths.js +4 -4
  582. package/dist/src/utils/process-group.d.ts +8 -8
  583. package/dist/src/utils/process-group.js +19 -19
  584. package/dist/src/utils/process.d.ts +34 -33
  585. package/dist/src/utils/process.js +20 -19
  586. package/dist/src/utils/ps-duration.d.ts +4 -4
  587. package/dist/src/utils/ps-duration.js +6 -6
  588. package/dist/src/utils/regex.d.ts +4 -4
  589. package/dist/src/utils/regex.js +7 -7
  590. package/dist/src/utils/sleep.d.ts +7 -7
  591. package/dist/src/utils/sleep.js +7 -7
  592. package/dist/src/utils/stdio-drain.d.ts +7 -7
  593. package/dist/src/utils/stdio-drain.js +8 -8
  594. package/dist/src/utils/validation.d.ts +11 -12
  595. package/dist/src/utils/validation.js +20 -21
  596. package/docs/README.md +2 -2
  597. package/docs/configuration.md +120 -114
  598. package/docs/exit-codes.md +63 -58
  599. package/docs/furnace.md +20 -19
  600. package/docs/lifecycle-invariants.md +412 -251
  601. package/docs/machine-output.md +33 -32
  602. package/docs/patch-workflow.md +48 -45
  603. package/docs/run-logs.md +62 -58
  604. package/docs/testing.md +101 -101
  605. package/docs/verification-trees.md +23 -22
  606. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -2,702 +2,588 @@
2
2
 
3
3
  ## 0.46.0
4
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.
5
+ ### Packaging audit
16
6
 
17
- ### Integrity
7
+ - The post-build audit no longer warns about files it was never going to package. It now skips unselected branding trees, directories gated off-platform by an ancestor `moz.build`, Storybook `*.stories.mjs`/`*.stories.js` files, and paths declared in `buildAudit.unpackaged` in `fireforge.json`. The `Packaged:` line counts every skip reason so the numbers can be checked.
8
+ - A declared carve-out that does resolve to a packaged artifact is reported as stale rather than hidden. `**` in a carve-out path is refused.
18
9
 
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.
10
+ ### Patch queue and lint
23
11
 
24
- ### Locks
12
+ - New `forward-registration` lint error: a test manifest that registers a file only a later patch creates. On a 338-patch queue this finds 48 real cases in three shared manifests. Each message prints the `patch staged-dependency --add --kind registration` command that fixes it.
13
+ - The command that rule prints now quotes the manifest line the way the patch actually writes it, including multi-line and multi-entry arrays. Before this it produced declarations that immediately warned as unused.
14
+ - The `staged-dependency-unused` warning prints the full removal command instead of one that refuses without its arguments.
15
+ - `lint --per-patch --patches 102` accepts a bare order number, padded or not.
16
+ - Staged-dependency registration checks now see binary files. A patch creating a binary through `GIT binary patch` used to be invisible to the resolver.
17
+ - `patch compact` refuses if the queue changed between the confirmation prompt and the lock, the same gate `patch reorder` already had.
25
18
 
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.
19
+ ### Patch bodies
28
20
 
29
- ### Exit codes
21
+ - Fixed corruption of new non-UTF-8 text files on export. The body was built from a UTF-8 decode while the blob hash came from the real bytes, so the patch wrote different bytes than the tree held and drift detection flagged the file forever. Text vs binary is now decided by a NUL byte or invalid UTF-8, and the binary arm forces a byte-faithful `GIT binary patch`.
22
+ - Worktree-side renames no longer produce a phantom status entry. Git emits an unstaged rename as ` R new\0old\0`, and the old path was parsed as its own entry.
30
23
 
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.
24
+ ### Integrity
25
+
26
+ - Checksum verification fails closed. An unreachable `SHA256SUMS` used to accept the tarball on TLS alone. Ways out: fix connectivity, pin `firefox.sha256`, or set `firefox.allowUnverifiedDownload: true`. The checksum request now uses the same timeout and retry as the tarball fetch.
27
+ - Archive extraction rejects any member with a `.git` path segment. `.gitmodules` and `.gitignore` still pass.
28
+ - The mach `PYTHONPATH` shim directory is now per-user, created `0700`, and checked for ownership and mode before it joins `PYTHONPATH`. It used to be a fixed shared temp path any local account could pre-create.
29
+ - Run logs are redacted. `KEY=value` pairs matching `TOKEN|SECRET|PASSWORD|PASSWD|API_KEY|AUTH`, `--password=` style flags, and `Authorization:` headers are masked in the log file only. `docs/run-logs.md` states what is and is not masked.
34
30
 
35
- ### Patch queue
31
+ ### Locks
36
32
 
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.
33
+ - A failed owner-record write no longer lets two builds into the critical section. The write is verified, and on failure the lock directory is removed and the run refuses.
34
+ - A recycled PID no longer keeps a dead lock honoured. On Linux the owner record carries the holder's start tick from `/proc`, so a different process with the same PID reads as stale. macOS and Windows keep PID-only liveness.
38
35
 
39
- ### Patch bodies
36
+ ### Exit codes
40
37
 
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.
38
+ - Commander usage errors exit 8 as documented, with a JSON envelope under `--json`. `--help`, `--version`, and a command group with no subcommand still exit 0.
39
+ - `GitNotFoundError` exits 7 like its siblings. A contract test now checks every error class against the table in `docs/exit-codes.md` in both directions.
40
+ - Manifest invariant failures raise `InternalInvariantError` and render as a bug report instead of a bare stack.
43
41
 
44
42
  ### Diagnostics
45
43
 
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`.
44
+ - A timed-out command keeps what it captured. `ExecTimeoutError` carries stdout, stderr, and a truncation flag, and shows the last 20 lines.
45
+ - `exec()` used to accept `processGroup` and `mirror` and ignore both. The options type is split so it cannot admit them.
46
+ - `test --help` lists all seven verdict reasons.
49
47
 
50
48
  ### Tooling
51
49
 
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).
50
+ - `@types/node` moves to `^22` to match the supported Node floor, and a `node-floor` CI job runs the suite on Node 22, 24, and `.nvmrc`.
51
+ - Three copy-pasted mach test wrappers became one `runMachTestSuite(kind, options)`. Several other duplicated helpers collapsed the same way.
52
+ - `ReturnType<typeof X>` on local imports is now a lint error; every site uses the named exported type.
53
+ - Two new lint rules: `no-unnecessary-type-conversion` and `prefer-object-has-own`. Both were already satisfied when they landed.
54
+ - The spawned-CLI test helper settles on `'close'` and rejects on `'error'`.
55
+ - Wall-clock test budgets replaced with ratio checks and fake timers.
58
56
 
59
57
  ### Dead code
60
58
 
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.
59
+ - Twenty-two exports that only tests referenced are gone, along with those tests and the barrel lines. `deadcode:check:production` resolves from the two real entry points and runs in `release:check`, so it cannot come back.
60
+ - Three impossible-state fallbacks removed rather than documented.
61
+ - `fireforge import --stop-at` is removed. It was an alias added in the same commit as `--until`.
65
62
 
66
63
  ### Duplication
67
64
 
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.
65
+ - One `sleep(ms, { unref })` replaces nine copies that disagreed on whether the timer keeps the event loop alive.
66
+ - Eight `--order` parsers now share `parsePositiveIntegerFlag`, so `--order 12abc` is refused instead of becoming 12.
67
+ - `normalizePatchArtifact` was an identity function on every patch write path; it is gone and its warning moved to the export write site.
68
+ - `ensureGit()` is memoised and called inside `git()` instead of before 32 of the 38 call sites.
69
+ - Single helpers now cover repeated shapes: `sha256Hex`, `normalizePathSlashes`, `formatPatchOrder`, `appendHistoryBestEffort`, `proceedAfterDecision`, `deriveTestStem`, and others.
70
+ - `--dry-run`, `--yes`, and `--force-unsafe` are documented once each through five option mixins.
74
71
 
75
72
  ### Test suite
76
73
 
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.
74
+ - Eleven test files and about 5,600 lines are gone with no assertion lost. They tested barrels, layers, and coverage thresholds rather than behaviour.
75
+ - Twenty-one full-text help snapshots became one inventory snapshot of commands and sorted flag names.
76
+ - `process-boundary.test.ts` is replaced by the lint rule that already enforced it.
77
+ - Four tests that asserted less than their titles claimed now assert what they say.
82
78
 
83
79
  ## 0.45.0
84
80
 
85
81
  ### Patch bodies
86
82
 
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.
83
+ - A new binary file can be vendored into a patch. The export was always correct; the forward-import projection refused to decode the binary body. The five duplicated projection loops became one helper that skips binary sections.
84
+ - New-file conflict recovery handles binary targets by removing the blocking file and letting `git apply` recreate it. Saved originals are buffers now, which also fixes a latent corruption on the existing path.
91
85
 
92
86
  ### Refusals
93
87
 
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.
88
+ - The `--expect` "showed no drift" warning now says what it compares and when, and names the two cases that legitimately produce no drift.
89
+ - The coverage refusal names the test manifests that changed since the recorded build.
90
+ - New `--expect-unmanaged <path>` records an approved exception to `--refuse-adjacent-unmanaged`. Approved paths are always listed, and one that is never met is reported.
97
91
 
98
92
  ### Diagnostics
99
93
 
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.
94
+ - `run --smoke-exit` treats `Missing chrome or resource URL:` as a smoke error. It used to exit 0 on a build that hung every browser-chrome run.
95
+ - The no-output-stall triage records the real mechanism: the crash lands in a content process with no crash reporter, so the harness log carries only the timeout. The census names the `.ips` diagnostic report to look for.
96
+ - The green-suite teardown belt names the first condition that rejected a run.
97
+ - The undefined-identifier hint recognises an unmanaged companion file loaded by a managed head and points at `re-export --scan --scan-file` instead of the globals shim.
98
+ - A failing test now raises `TestFailureError` with test-shaped remedies. Exit code stays 5; the `--json` code moves to `test-failure`.
99
+ - The build path prints a recognised teardown traceback verbatim with one labelled line naming it, rather than filtering it like the test path does.
100
+ - `fireforge doctor` reports the configured source pin beside the engine's actual `version.txt` and warns when they disagree.
108
101
 
109
102
  ### Builds
110
103
 
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".
104
+ - The jar-only full-build escalation is narrowed to a new `jar.mn` or a jar declaration with a bracketed base directory. Everything else stays incremental. Probe failures still escalate.
105
+ - Post-build packaging notices name the owning patch, or mark the file `unmanaged`.
113
106
 
114
107
  ### Test harness
115
108
 
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.
109
+ - Every `fireforge test` runs a preflight census of leftover harness processes anchored to this objdir, naming each with its elapsed time and the kill command. `--reap-orphans` terminates them. It never refuses a run.
117
110
 
118
111
  ### Patch lint
119
112
 
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.
113
+ - `patchLint.prettier` (off by default) runs Prettier over patch-owned `.sys.mjs` modules with `cwd` set to `engine/`, which is the only place the check is meaningful.
114
+ - `patchLint.fileSizeThresholds` makes the file-size bands configurable. The message drops the soft/hard wording and says a gate at `--max-warnings 0` treats a warning as a failure.
122
115
 
123
116
  ## 0.44.0
124
117
 
125
118
  ### Gates
126
119
 
127
- - **`release:check` ran twice per release**, because `npm publish` re-triggers it through `prepublishOnly`. The explicit step is gone; `prepublishOnly` is the single gate. The redundant third `npm pack` (`pack:dry-run`, already run by `pack:verify`) was dropped from the gate.
128
- - Branch-protection contexts are stable: the smoke job is `smoke (${{ matrix.os }})` rather than embedding the Node version — the same trap already fixed for `quality (ubuntu)` — and `setup-rulesets.sh` reconciles (GET by name, then PUT or POST) instead of POSTing unconditionally, which is why the live ruleset had drifted to requiring nothing.
129
- - **CI ran the lint toolchain on a Node version that toolchain excludes.** `eslint-plugin-jsdoc` and its two dependencies (`@es-joy/jsdoccomment`, `jsdoc-type-pratt-parser`) declare `node: ^22.22.2 || >=24.15.0`, and every workflow pinned **22.22.1** — one patch below the floor. `npm ci` only WARNS on an engine mismatch (`npm warn EBADENGINE`), so the gate stayed green while `lint:ci` ran on a combination the plugin's author had ruled out; the next symptom of that is an unexplained eslint crash rather than a version complaint. The pin moves to **22.23.2** (newest on the 22 line). The eight hardcoded `22.22.1` literals across four workflows are gone: all six setup-node steps now read `node-version-file: '.nvmrc'`, so the version is derived exactly the way the npm pin already is, and the drift that let the lockfile's floor climb past the pin cannot recur. `ci.yml`'s smoke matrix loses its now-uniform `node-version` column; the check contexts are unaffected, since `name:` pins them explicitly for the reason recorded above. `engines.node` deliberately moves only to `>=22.22.2`, NOT to the pin: the `^22.22.2` requirement comes from devDependencies, so a consumer of the published package must not be pushed to 22.23.2 for a lint plugin they never install. `.nvmrc` sitting above `engines` is the intended shape, not a number someone should reconcile.
130
- - **A test repo committed without a committer name, so the gate passed on every developer machine and failed on the runner.** `apply-patch-idempotent.test.ts` was the only repo-creating test that configured `user.email` without `user.name` — the other twenty set both. Git falls back to the OS gecos field for a missing name, which is populated on a workstation and EMPTY for GitHub's `runner` user, so there `git commit -m baseline` died with `fatal: empty ident name` and left HEAD unborn. The helper used `exec`, which returns a non-zero `exitCode` rather than throwing, so the dead commit was discarded silently and the run continued against a repo with no commits; the failure surfaced six steps later inside `restoreFilesToHead` as `fatal: Not a valid object name HEAD`, wearing a `PatchApplyError` whose remedies are all about Firefox version drift. Six of the file's seven tests failed that way and pointed at the wrong subsystem. The helper now sets `user.name` and routes every git call through `runGit` (`src/test-utils/index.ts`), which is `execFile`-backed and REJECTS on non-zero — so a future setup command that fails is reported where it failed rather than as a corrupted repo later. An environment with no ambient git identity (`user.useConfigOnly`) reproduces the old failure exactly and passes on the new code.
120
+ - `release:check` runs once per release instead of twice. `prepublishOnly` is the single gate.
121
+ - Branch-protection contexts are stable, and `setup-rulesets.sh` reconciles instead of blindly POSTing.
122
+ - All workflows read the Node version from `.nvmrc`. The old pin was one patch below the floor `eslint-plugin-jsdoc` requires, and `npm ci` only warns about that.
123
+ - A test repo now sets `user.name` as well as `user.email`, so it stops failing only on CI runners. Its git calls reject on non-zero instead of continuing against an empty repo.
131
124
 
132
125
  ### Multi-session contention
133
126
 
134
- - **A `--wait-lock` budget expired one position from the head of the queue** — the worst possible outcome, because it pays the entire wait and gets nothing. `--wait-lock 300` died still-queued where `3600` was needed. The timeout is now a budget against a STALLED queue rather than against wall-clock: each time the observed queue position improves, the wait is granted a fresh budget, up to four times the requested one (ceiling one hour, matching the flag's own bound). A queue that stops moving still starves on exactly the budget you asked for, which is what the timeout is for. The refusal also names the position reached — "you were still 2 from the head of a queue of 3" — so raising the budget is an informed decision instead of a guess. Only `--wait-lock` runs extend; the no-flag ~1 s fail-fast path is untouched.
135
- - **A wait budget could not be expressed once for a session.** `FIREFORGE_WAIT_LOCK=<seconds>` now supplies the budget for every lock-taking command that was not given an explicit `--wait-lock` (which always wins). An unattended loop threading the flag through every invocation was the shape most likely to be forgotten, and the omission only surfaced as a tail of refusals after the loop had already run. An unusable value is refused rather than silently falling back to fail-fast. Note that `test`, `export` and `re-export` never differed here: all three fail fast at ~1 s without the flag.
136
- - **A killed run wrote no terminal line**, so a log tail could not distinguish "killed" from "still running" from "never started" — recovery meant checking `ps` and the lock file by hand, at exactly the moment (a mass kill under a usage limit, mid-drain) when the tree's state most needs to be readable. SIGTERM/SIGINT now emit `FIREFORGE-VERDICT: FAIL reason=killed signal=…` before the shutdown drain, so the line survives a drain that times out. It is a no-op unless a test run was actually in flight: a Ctrl+C during `status` must not claim a test run happened.
137
- - **The signal sweep released only furnace locks**, so a SIGTERM'd `fireforge test` left `.fireforge/engine-session.lock` behind — reclaimed immediately by the next contender's PID-first staleness check, but reported as held-by-a-dead-process in between. `file-lock.ts` now tracks the locks this process holds and the signal path sweeps all of them through the existing ownership-checked `releaseLockIfOwned`; one mechanism rather than a fourth sweeper. The furnace sweep stays and runs after it, because it carries rollback bookkeeping the generic one does not.
138
-
139
- - **FireForge's own binary-patch bookkeeping broke the one-writer invariant it enforces.** `generateBinaryFilePatch` has to write an index entry to diff an untracked binary, and it wrote it to the SHARED index — visible, for the width of a stage/diff/restore, to anything running `git status`. That is precisely what `fireforge test` does to fingerprint `engine/`, so a parallel gate lane exporting the branding PNG/icns/bmp set killed healthy suites with `FAIL reason=inconclusive` on branding paths flapping between `A` and `??`. **This is the cause the 2026-08-21 report could not find**: it looked at `engine/.git/index`, which the detector cannot see, when the flap was in the porcelain TEXT.
140
- `binaryStagingLock` never covered it — it is a module-level promise chain, so it serializes within one process while `--parallel-verify` lanes are separate processes. The staging now writes a per-call PRIVATE index (`mintDisposableGitIndex`) and throws it away, so the primary `.git/index` never moves, nothing is restored because nothing shared changed, and two concurrent callers get two indexes rather than contending. The 0.43.0 private-index work closed this for read-only commands; this path is the deliberate writer it did not cover, and it could not have been fixed by leaning on that scope — `export` and `re-export` install none, and `withPrivateGitIndex` is fail-open by design, which on a write path means silently mutating the primary checkout.
141
- The seed is load-bearing rather than tidy: a tracked, unmodified binary reaches the same branch (its `diff HEAD` is empty), and against an empty index it would report as a fresh add. The shared-index path survives as a fallback for when no private index can be minted — refusing there would fail an export over an optimisation. A real-git test polls `git status` throughout the call and asserts the transient entry is never observable; it fails on the old implementation, which is what makes it worth having.
142
-
143
- - **`--wait-lock` governed only ONE of the two locks a furnace mutation takes.** `furnace apply`/`deploy`/`sync` routed the resolved budget into the engine session lock and stopped there. `runFurnaceMutation` then acquired `.fireforge/furnace.lock` through `withFileLock`, passing `timeoutMs` only when `options.lockTimeoutMs` was set — and **no production caller ever set it**: the field's only other occurrences were its own declaration and one test, and its doc comment cited a watch-mode caller that does not exist. So the file lock always fell to `DEFAULT_LOCK_TIMEOUT_MS`, a fixed 30 s. A contended `deploy --wait-lock 1800` therefore died at 30 s while its operator believed a half-hour budget was in force, which is the worst shape a budget can have: it pays the entire advertised wait and still refuses. `FIREFORGE_WAIT_LOCK` did not help — it feeds the same resolver into the same single sink.
144
- The resolved budget now reaches both. It is threaded explicitly into the three commands that declare the flag (`waitLockMutationOptions` does the seconds-to-milliseconds conversion once, rather than at three call sites where dropping the factor is a silent 1000× error), and `runFurnaceMutation` falls back to `sessionWaitLockMs()` — the environment budget — so the twelve furnace mutators with no flag to thread (`create`, `remove`, `rename`, `refresh`, `scan`, `override`, `chrome-doc`, …) are covered too. An explicit `lockTimeoutMs` still wins, and absent both the ~1 s fail-fast and the 30 s default are untouched.
145
- The invisibility was the other half of the defect: the flag's help text said "the contended lock", singular, and the furnace refusal named neither the lock nor the flag — it advised removing a stale lock directory. The help now says the budget covers every lock the command takes, and the refusal names the FURNACE lock, says it is the second of two, and points at `--wait-lock`/`FIREFORGE_WAIT_LOCK`. A test asserts the budget reaches the command options, and a sibling asserts nothing is threaded without the flag.
127
+ - `--wait-lock` is a budget against a stalled queue. Each improvement in queue position grants a fresh budget, up to four times the request and one hour. The refusal names the position reached.
128
+ - `FIREFORGE_WAIT_LOCK=<seconds>` supplies the budget for any lock-taking command without an explicit `--wait-lock`.
129
+ - SIGTERM and SIGINT emit `FIREFORGE-VERDICT: FAIL reason=killed signal=…` before the shutdown drain, but only if a test run was in flight.
130
+ - The signal sweep releases every lock this process holds, not just furnace ones.
131
+ - Binary-patch staging writes to a private git index. It used to touch the shared index, which made a concurrent `fireforge test` see files flapping between `A` and `??` and fail as inconclusive.
132
+ - `--wait-lock` now reaches both locks a furnace mutation takes. The furnace file lock always used a fixed 30 s, so `deploy --wait-lock 1800` died at 30 s. The refusal names which lock it is and how to raise the budget.
146
133
 
147
134
  ### Exit codes
148
135
 
149
- - **Red, never-ran and thrown-away were three facts sharing two exit codes.** `reason=inconclusive` — a suite whose verdict was DISCARDED because `engine/` moved under it — exited **1** beside real test failures' **5**, and a summary line reading `FAIL — exit 1` next to `FAIL — exit 5` invites treating opposites as the same kind of fact: one suite failed, the other's healthy result was thrown away. It now exits **14** (`InconclusiveVerdictError`). `LockContentionError` moves from 1 to **15**: a lock timeout means the run never started, so a script should re-queue it rather than report a failure of the work it asked for. That re-codes patch-directory, build and furnace lock timeouts too — deliberately, since the fact is the same whichever lock was contended. See `docs/exit-codes.md`.
136
+ - `reason=inconclusive` exits 14 instead of sharing 1 with real failures. `LockContentionError` moves to 15, since the run never started.
150
137
 
151
138
  ### Diagnostics
152
139
 
153
- - **A stale mochitest httpd on the harness server port stalled every run, and nothing named the port.** The mochitest harness serves its manifest from an xpcshell `server.js` on `127.0.0.1:8888`. A survivor of an interrupted run keeps that port, and a fresh browser then connects to the ZOMBIE server, which cannot serve the new manifest — so the run stalls between browser startup and `TEST_START` and dies on the 370 s no-output timeout with `Ran 0 checks`. Nothing in that signature mentions a port, so the failure survived fresh builds and a sandbox change and cost one downstream fork a multi-hour bisect of files the bisect then exonerated. `--kill-stale-marionette` never covered it: it clears the browser and the Marionette control port, and the httpd is neither. A preflight now probes the port for browser-chrome dispatches — reusing `probeMarionettePort`, which was already port-generic, rather than growing a second probe — and refuses with the holder named. A recognized harness `server.js` can be terminated with `--kill-stale-marionette`; an unrecognized listener is refused but never killed, and recognition requires objdir/xpcshell provenance as well as the filename, because `server.js` is one of the most common filenames there is and a developer's own service must not be offered up. Best-effort throughout: an unprobeable port reports free, so a host without `lsof` runs exactly as before. The cause joins the no-output-stall census with `lsof -nP -iTCP:8888` as its probe — the one entry there whose mechanism is root-caused rather than correlated.
154
- - **The stale-browser refusal named a PID the operator must not kill, and offered the flag that would kill it.** The objdir-bundle preflight matches any process running this project's binary, which on a shared checkout includes a peer session's LIVE browser — and it reported only the PID, while holding the command line and never surfacing it. One downstream session met exactly this: five identical preflight failures on a browser belonging to another session, with the message suggesting `--kill-stale-marionette`, which would have killed a multi-hour evaluation run. The refusal now reports the evidence — elapsed time (from `ps etime=`) and the full command line — and offers the kill flag ONLY for a marionette-driven browser. A bare launch is reported as not-a-test-browser with the flag deliberately withheld and the reason stated, because FireForge cannot tell a dead run's orphan from someone's live window. Making the preflight self-clearing was considered and rejected for the same reason. The elapsed field is parsed by the same `parsePsDuration` the orphaned-harness doctor check uses for `time=`; the two columns share one grammar and a second copy is how they would drift. A listing without the column still parses, with the age reported as unknown and the field folded back into the command line — a probe must degrade, not drop the process.
155
- - **`--wait-lock`'s progress line kept quoting a budget that was no longer in force**, so a wait that had been legitimately extended was indistinguishable from a timeout that does not fire — which is how a downstream report read it. The line now reports the budget currently in force, and an extension announces itself, naming both figures: the one you asked for and the one now in force, plus the position that bought it. A wait that silently outlives its own stated timeout looks broken whether or not it is.
156
- - **`status --lock` could not tell a working holder from a wedged one.** It reported holder liveness via `kill(pid, 0)`, which answers only "does the process exist" — so a queue stalled behind a command that had consumed 0.3 s of CPU in eleven minutes looked exactly like one behind a healthy long build, and the downstream working practice was to run `ps aux | grep fireforge` and read near-zero CPU by hand. That fact is now read and reported, with a long hold at near-zero CPU flagged as the shape of a wedged command. Stated as an observation with its own caveat, never as a verdict: a holder blocked on I/O looks identical, and nothing keys a refusal, a kill or a lock steal off the number. An unmeasurable value is omitted rather than rendered as zero, which would invert the diagnosis it exists for.
157
- - **The 2026-08-27 report's stated cause was not reproducible, and is recorded rather than "fixed".** It attributed a hung `furnace deploy` holding the engine lock to an interactive prompt with no TTY to answer it. `furnace deploy` contains no prompt: its "overwriting deployed … its engine content differs" text is an advisory warning pushed onto `result.warnings`, in a module whose contract is that the probe must never block an apply. Nor is there an unguarded prompt anywhere else — `confirmDestructive` refuses a non-TTY run rather than reading stdin, and every raw prompt site gates on `stdioIsInteractive()` first. The two real defects behind the observed symptom are the waiter fixes above. Naming what the code actually does is how the next occurrence gets attributed to something else quickly, instead of inheriting a guess as a fact.
158
- - **`--expect`'s help text read as per-patch on a command that takes many patches.** It is a whitelist of files across the whole invocation — a patch can only absorb drift in files it owns anyway — but the wording sent a consumer looping 18 single-patch re-exports at ~33 s each, past a tool timeout, to do what one call does. The text now says the whitelist spans every patch in the invocation.
159
-
160
- - **The inconclusive refusal said only THAT `engine/` changed**, leaving the operator to reconstruct the writer by hand — `find -newermt` against the run window, minus the objdir, minus git's own bookkeeping. Both generation tokens are in hand at the refusal site, so it now names what moved: a HEAD move by both SHAs, and working-tree entries that appeared or went away, capped at five with a `(+N more)` tail. The `-z` rename shape is handled explicitly (an `R`/`C` record carries a second path field; consuming one desynchronises the parse and turns a single rename into a report that the whole tree moved). Degrades to the old message on an unparseable token — a diagnostic must never turn a refusal into a crash. Worth recording that the reported cause was NOT reproducible in this code: the detector is `git rev-parse HEAD` plus `git status --porcelain=v1 -z`, which never sees `.git/**` and honours `.gitignore`, so `engine/.git/index` moving cannot produce that refusal. Naming the delta is how the next occurrence answers the question itself.
161
- - **A `moz.build` sort violation arrived as a raw Python traceback.** `mozbuild.util.UnsortedError` is now translated: the rule stated is the one operators get wrong, and it is the OPPOSITE of the intuitive reading — mozbuild lowercases before comparing (`StrictOrderingOnAppendList.ensure_sorted`), so uppercase does NOT sort ahead of lowercase, and `HominisEditorFilePaths.sys.mjs` belongs before `HominisEditorFiles.sys.mjs`. The hint also points at `fireforge lint --per-patch` (`mozbuild-unsorted-list`), which already catches this before a build and is the cheaper place to catch it.
162
- - **`unexpected=2` on the `lastColdStartupCheck` / `globalprivacycontrol` pair was indistinguishable from two real assertion failures.** Those preferences are time-driven — they move once a run is long enough to cross the cold-startup check interval — so they correlate with run LENGTH, not with the build or with the change under test; the same work at `--chunk 8` passes where a 40-row chunk is red. FireForge cannot forgive the check (it is the harness's) but a run whose EVERY unexpected result is that pair now says so, in the failure message and as a verdict-line note, and names halving the chunk as the first probe. A single real failure beside the noise disqualifies the whole set — the point is to describe a run, never to explain away a failure. Keyed on the pref names rather than on the harness's phrasing, which has no contract with us.
163
- - **The packaging-coverage refusal never mentioned `--extend-coverage`.** The cumulative claim shipped in 0.43.0, but under concurrent sessions the operator meets it at the moment a peer's scoped build has replaced coverage they still hold — and the refusal pointed only at a full rebuild. It now offers the union, and states the anchor conditions (unchanged engine HEAD and `engine/mozconfig`) so the operator knows when it will refuse. Coverage still REPLACES by default, for the reason documented on `BuildBaseline.testPackagingCoverage`: a blind union whitewashes an earlier scope's edited fixtures while `obj-*/_tests/` still holds that scope's stale staging.
164
- - **The no-output-stall census asserted a mechanism it had no evidence for, and its third cause carried no discriminating step.** The `chrome://`-image entry shipped in 0.43.0 stating that a broken image URI on the startup page "stalls first paint"; that mechanism was never established here — it was written on top of a single downstream report which explicitly did not root-cause it, and it is doubtful on its own terms, since a failed image load in Gecko fires `error` without gating the document load event. The entry now states the correlation it actually rests on (one consumer, reproduced and un-reproduced on one variable) and says the mechanism is open, so nobody inherits a guess as a fact. Separately, its remedy — run a known-good control — is the correct opening move for **all three** causes, so attaching it to one entry made reaching that entry informationally empty; the control step is hoisted above the list, and cause 3 gains a probe that actually separates it from the other two (`fireforge run --smoke-exit … --capture-console`, which starts the same browser outside the test harness). No detector is claimed: no log line is known to key on, and `mach-error-hints.ts` sets the bar at an established diagnosis rather than a plausible one. The census moved to `src/core/test-stall-triage.ts` — it is operator advice, not classification, and the rules for editing it are not the rules for editing a classifier.
165
-
166
- - **A run's only copy of its own diagnosis died with the pipe.** `fireforge test … 2>&1 | tail -40` keeps the summary and discards the `TEST-UNEXPECTED-FAIL` lines that say WHAT broke; it also launders the exit code. Nothing else held the output — `runMachCapture` keeps a 2 MB in-memory tail that dies with the process, and the shard retry loop discards earlier attempts outright — so one downstream failure is now permanently undiagnosable and cost two further full runs to pin as "unreproduced". The operator rule ("never pipe a run") had been written down three times and broken after each writing, which is the tell that it is not an operator problem: piping is the ergonomic default when output is long, and the tool is the only party that can make the log survive the mistake.
167
- `test` and `build` now write their own complete output to `.fireforge/logs/<command>-<timestamp>.log` as they stream, retained 20 deep per command kind (per-kind, so a busy `test` loop cannot evict the `build` log an operator needs to read beside it). The tee sits at the two mach capture funnels rather than at the commands, so it covers every dispatch — the three test dispatchers, `package`, and the stdio-inheriting path `mach build` uses, which rides the collectors' existing `mirror` hook rather than growing a second one. The log gets the RAW bytes, never the echo filter's collapsed form: the artifact exists to be re-read by whoever is diagnosing the run, so shortening it would reintroduce the loss it prevents.
168
- The path rides the `FIREFORGE-VERDICT:` line as an additive `log=<path>` key. That is deliberate and not a free choice — the verdict must stay the run's LAST stdout write, so a separate announcement after it would break the contract and one before it is the first thing a `tail` cuts. `build`, which prints no verdict line, announces the path directly. Everything here is best-effort: a log that cannot be opened, written or pruned degrades to no log, because a run must never fail over a diagnostic. Opened before any preflight, so a refusal is logged too — those are exactly the runs whose only output a `tail` throws away.
169
- - **Post-rebuild failure diagnosis promoted recognized teardown noise over the real assertion.** An export shard failed on its file-count assertion; the diagnosis named the known `AttributeError: 'SystemResourceMonitor' object has no attribute 'stop_time'` instead. The raw Error Summary had the assertion right, so the information was present and the SELECTION was wrong — the expensive kind, because the named cause is a real, documented, unrelated upstream defect and therefore reads as an answer.
170
- 0.44.0 already ships `mach-known-noise-filter.ts`, whose module doc says exactly the right thing about closing the presentation gap — but `findFirstUsefulFailureLine` never consulted it. It matched a bare `/AttributeError:\s+/` among its patterns and selected by first-matching-LINE, not first-matching-pattern, so that arm had precisely the same standing as `TEST-UNEXPECTED-FAIL` and whichever appeared earlier won. The selector now runs two passes: candidates excluding the recognized noise first, the full set only if that finds nothing. The narrowness is preserved exactly — the exclusion is gated on `hasKnownTeardownNoise` for the whole capture, so it still requires both the closed `stop_time`/`poll_interval` allowlist AND a `resourcemonitor.py` frame, and a novel attribute or a traceback from anywhere else stays a real failure. Three tests pin those escape hatches; the one that pins the fix fails on the old code.
171
- Worth recording that the misdiagnosis was CONDITIONAL, not universal: the classifier reads stdout then stderr, and the traceback usually lands on stderr while the suite output lands on stdout, so a real `TEST-UNEXPECTED-*` normally won on ordering alone. It bites when the traceback shares or precedes the stream, or when the real failure matches none of the other patterns. The reporter could not have known that, and it does not change the fix — but a defect described as always-firing gets re-opened as "not reproducible" by the next reader.
172
- - **mozbuild's AI-agent output limiter quieted test runs, not just builds.** `is_running_under_coding_agent()` keys on `CLAUDECODE`, which FireForge inherited into every mach dispatch verbatim — it had no handling of the variable anywhere. The build half of that quieting is genuinely useful; the test half removes `TEST_START` and console INFO, exactly the lines a hang diagnosis needs, and the ones FireForge's own classifier reads (`Ran N checks`, `Unexpected results:`, `TEST-UNEXPECTED-*`), so suppression pushes a run toward `reason=no-tests`. Unsetting the variable for the whole process gives up the build half too.
173
- `fireforge test --full-output` now unsets the marker for TEST dispatches only; the build path is untouched. It is opt-in rather than automatic because it changes how much a third party prints, and an operator who wants the quieting should keep it. The exec layer gained an explicit `envUnset` for this: `env` can only add or overwrite, and a variable read for its PRESENCE is not unset by an empty value — and the removal is a real delete rather than a `Record<string, string | undefined>` widening, because relying on Node dropping `undefined` env entries would be a semantics gamble at the one layer that must not have any.
140
+ - A stale mochitest httpd on port 8888 is detected at preflight and named. It used to stall runs into a 370 s timeout with nothing pointing at a port.
141
+ - The stale-browser refusal reports elapsed time and the full command line, and offers `--kill-stale-marionette` only for a marionette-driven browser. A bare launch may be someone else's live window.
142
+ - The `--wait-lock` progress line reports the budget currently in force and announces extensions.
143
+ - `status --lock` reports the holder's CPU time, so a wedged holder looks different from a healthy long build. It is an observation, never a verdict.
144
+ - The inconclusive refusal names what moved in `engine/`: the HEAD SHAs and up to five worktree entries.
145
+ - A `moz.build` sort violation is translated instead of arriving as a Python traceback. Mozbuild lowercases before comparing, which is the opposite of the intuitive rule.
146
+ - A run whose only unexpected results are the time-driven `lastColdStartupCheck`/`globalprivacycontrol` pair says so and suggests halving the chunk.
147
+ - The packaging-coverage refusal offers `--extend-coverage` and states when it applies.
148
+ - The no-output-stall census hoists the known-good control step above the list and gives cause 3 a probe that separates it from the others.
149
+ - `test` and `build` write their full output to `.fireforge/logs/<command>-<timestamp>.log` as they stream, 20 deep per command kind. The path rides the verdict line as `log=<path>`. Opened before any preflight, so refusals are logged too.
150
+ - Failure diagnosis no longer promotes recognised teardown noise over the real assertion. The selector runs a pass that excludes known noise first.
151
+ - `fireforge test --full-output` unsets `CLAUDECODE` for test dispatches only, so mozbuild's agent output limiter stops hiding the lines the classifier reads.
174
152
 
175
153
  ### Build output resolution
176
154
 
177
- - **A second `obj-*` directory made FireForge refuse every command, even when the mozconfig said which one to use.** The refusal is correct where the choice is genuinely ambiguous — building a dev test against a release objdir is exactly the silent corruption it prevents — but `MOZ_OBJDIR` was never read, so an operator whose active configuration had already answered the question was sent to rename a directory to satisfy a scan. An ambiguous glob now consults `engine/mozconfig` (`mk_add_options MOZ_OBJDIR=…` or a bare `export`, `@TOPSRCDIR@` and relative forms resolved to a path segment) and proceeds when the declaration resolves to exactly one candidate — into the existing `mozinfo.json` verification, which is the real safety net. Everything else still refuses, and a declaration that named something the scan cannot see is reported, because that is itself the diagnosis.
155
+ - A second `obj-*` directory no longer refuses every command when `engine/mozconfig` declares `MOZ_OBJDIR`. Genuinely ambiguous cases still refuse.
178
156
 
179
157
  ### Correctness
180
158
 
181
- - **A test's own passing diagnostic could turn a green run red, and the verdict then blamed the diagnostic.** The failure-line classifier matched `/\bFAIL\b/` and a case-INSENSITIVE `/\b(?:Assertion failure|MOZ_ASSERT|ASSERTION)\b/` against raw output, so any line containing the ordinary English word "assertion" counted as a real failure. `realFailures.length === 0` is a required rung in both `hasGreenShapedSummary` and the known-teardown-noise pass, so one such line made that pass unreachable: a substantively green suite (`Ran 11572 checks … Unexpected results: 0 … OK`) whose `mach` exit was pushed to 1 by the recognized upstream mozsystemmonitor teardown reported `FAIL reason=test-failures checks=11572 unexpected=0` — and named the diagnostic as `First real test failure:`, pointing the reader at a line that was a symptom of the misclassification rather than its cause. A downstream fork lost hours to it, and the trap is silent and inverted: a note whose whole purpose is to explain a red run can, by its wording alone, manufacture one.
182
- The bare-word arm hid a second defect nobody had noticed: `\bFAIL\b` matches `TEST-KNOWN-FAIL`, an EXPECTED failure, so a suite carrying a known-fail annotation was counted as carrying a real one. Both arms are now harness-shaped — `TEST-(?!KNOWN-FAIL)[A-Z-]*FAIL` and the three concrete Gecko shapes (`Assertion failure:`, `MOZ_ASSERT(`, `###!!! ASSERTION`), case-SENSITIVE. A belt covers what tightening cannot reach: when the summary says `Unexpected results: 0` and no matched evidence carries a `TEST-UNEXPECTED-*` marker, the verdict line says so, because `unexpected=0` printed beside `reason=test-failures` is precisely the tell that the classification rests on a word match. Deliberately a note and not a reclassification — a non-zero exit with no green-shaped summary is still a failing run, and inventing a `reason` would break the contract its consumers parse.
183
- - **`fireforge test --build` could not arm `--refuse-unexported-drift`.** The flag existed only on `build`, so the one shape the multi-session hazard actually shows up in — a scripted `test --build` on a checkout with concurrent sessions — had no way to turn the warning into a refusal, which is what a downstream fork reached for immediately after a near-miss. `test` now accepts it and forwards it to the pre-test build. It is refused without `--build`/`--build-only` rather than accepted over a build that will not run; the release has enough silent no-op flags in it already.
184
- - **A `--wait-lock` waiter outlived the budget it was given, even against a queue that never moved.** The 0.44.0 extension renews the budget on a strict improvement in queue position — but `bestAhead` started `undefined`, so the FIRST observation always satisfied that test and handed out a free full grant. `--wait-lock 300` therefore waited past 300 s on a completely stalled queue, which is what a downstream report described as "it does not reliably give up after N seconds". The first probe now SEEDS the best-seen position without extending, so only a genuine advance buys time and a stalled queue starves on exactly the budget asked for. A test asserts no extension is granted on a stalled queue; it fails on the old implementation.
185
-
186
- - **`re-export` rewrote a binary patch into an un-appliable stub, and every gate stayed green.** `export` has always been binary-aware — it routes a binary file to `generateBinaryFilePatch` and emits a real `GIT binary patch` delta. The batched diff path `re-export` uses did the same for UNTRACKED binaries only; a binary already tracked in HEAD fell through to a plain `git diff HEAD`, which degrades to the informational `Binary files a/x and b/x differ`. That form records THAT the bytes changed and carries none of them, so a bulk `re-export --all` replaced a downstream fork's two MAR signing-certificate deltas with bodies that cannot recreate the files — on patches the run had no reason to touch.
187
- What makes it dangerous rather than lossy is that the stub still carries a CORRECT `index <old>..<new>` line. Every identity-shaped check keys on that hash: worktree classification hashed the live file against it, matched, and reported `patch-backed`; foreign-drift detection compared old and new hashes, saw no change, and reported no drift; `verify` never read the body at all. So the queue held a patch that provably could not rebuild its own file while `verify`, `status --check` and `--fail-on binary-unsupported` all passed, and the loss would surface only at the next clean-room apply. `--binary` now covers `getFileDiff`, `getAllDiff`, `buildTrackedSections` and `getStagedDiffForFiles` — it expands only the binary section's index line to full hashes, so text patches are byte-for-byte unaffected, which is what made the fix safe to apply across every caller at once (`re-export`, `export --all`, `lint`, `resolve`, `rebase --continue`).
188
- Two independent guards close it from the other side, because the export fix cannot help a body that is already degraded or was hand-written. `parseDiffSections` now distinguishes a payload-carrying `GIT binary patch` from the stub (`hasBinaryDelta`), a new queue lint (`binary-body-not-reconstructable`) fails closed on any non-deletion binary section without a payload — reaching `verify`, `lint` and re-export's projected-queue gate, so a run that would introduce one is refused before anything is written — and binary classification no longer trusts a stub's index hash, so such a file reports `binary-unsupported` instead of `patch-backed`. A real-git test asserts the delta round-trips and that `git apply` replays it; it fails on the old implementation.
189
- - **`re-export` reported "Re-exported" for patches whose body never moved.** A bulk run refreshes every row in the manifest and wrote each one unconditionally, so the output gave no signal about which patches actually changed and `git status` was the only place an unexpected rewrite showed up — the review step that caught the binary loss above. A body byte-identical to what is on disk is no longer written (its mtime is left alone) and is reported as `Unchanged`. Metadata still moves either way, and the patch still counts as refreshed, so `--stamp` completeness is unaffected.
190
- - **`fireforge test --doctor` orphaned a whole browser tree on Ctrl+C.** `marionette-preflight.ts` holds the repo's one legitimate raw `spawn` — it needs the live `ChildProcess` mid-run, returns while the browser is still running, and aborts on a TCP byte rather than a deadline, none of which the exec-layer wrappers can express — but it had skipped the shutdown contract every wrapper applies to a detached child. It now registers closure tracking, forwards the parent's SIGINT/SIGTERM to the group, and sweeps for survivors. `execStream` was the one wrapper missing `trackChildClosure` and gained it, so `waitForActiveChildShutdown` finally waits for `runMachStream`'s children too.
191
- - **Every unexpected failure during a patch-queue mutation was reported as a patch-compatibility problem.** One blanket catch in `withPatchDirectoryLock` funnelled 15 call sites: an `EACCES` writing `patches.json`, a rename postcondition failure, or a plain `TypeError` from a FireForge bug all reached the operator as "Patch Error" followed by three fixed remedies about Firefox versions and `fireforge reset`. Worse, `cli.ts` prints a stack only for non-`FireForgeError` throwables, so the rewrap moved real bugs into the branch that discards their stack. The catch now narrows by ORIGIN — the body's own failure passes through with its class, stack and exit code intact — and a genuine lock failure carries its `cause`. `CommandError`, the one class in `src/errors/` that is not a `FireForgeError`, is no longer swallowed.
192
- - **A declined confirmation and an interrupted one were indistinguishable.** Both returned `'cancelled'` and exited 0, and `CancellationError.code` was `GENERAL_ERROR` — a contradiction with `ExitCode.USER_CANCELLED`'s own documentation that survived because the CLI intercepts the class by name before reading `.code`. Esc/Ctrl+C now raises `CancellationError` and exits **130**; answering "no" returns normally and exits **0**, because the run did what it was asked. See `docs/exit-codes.md`.
193
- - **The mach PYTHONPATH guard wrote to a fixed, shared temp path.** `$TMPDIR/fireforge-mach-resource-shim` — fixed name, default mode, no ownership check, never removed — was prepended to `PYTHONPATH` for every protected build and test dispatch. Inert on macOS, where `tmpdir()` is the per-user `0700` `$TMPDIR`; on Linux, where `/tmp` is shared, another local account can own `sitecustomize.py` and have it execute inside the victim's build. It is now minted per dispatch with `mkdtemp` and removed in a `finally` that covers retries.
194
- - **`furnace validate` never checked that the engine existed.** It was the one command in the family that checked `furnace.json` alone, so validating against a missing engine produced downstream noise instead of the family's refusal.
195
- - **`furnace create` could scaffold a syntactically broken `.mjs`.** A `"` or newline in the FTL path, or a `*/` in the description, went unescaped into the generated module. Both are now escaped at the sink — which also covers the `ftlBasePath` and derived-name paths that never passed through the flag validator.
196
- - The signal-time furnace lock sweeper removed lock directories without checking ownership, so it could delete a lock another process had acquired in the window between our operation dying and the sweep running. It now uses `releaseLockIfOwned`.
197
- - `killProcessTree`'s POSIX branch swallowed `ESRCH`/`EPERM` from the group kill without falling through to a direct `child.kill`, so a failed group kill left the child running.
198
-
199
- - **A DELETION of an upstream file could not be made durable through `re-export`, and the failure was silent.** With eighteen retired files, `rm` left `fireforge status --json` reporting `"status": "D"` with `"classification": "unmanaged"` — correct as a description, fatal as a state, because `check-engine-clean` refuses an unmanaged file precisely because it is not durable through a reset, and a reset would restore every deleted file. No re-export path captured it: `--scan` pruned the path from `filesAffected` and emitted no deletion hunk, `--scan-file` refused it outright, and **`--files` accepted the path and emitted no hunk** — the dangerous shape, because the run reported success while writing a patch that silently CLAIMED a file it did not restore. On a fresh clone that patch applies cleanly and resurrects exactly what the change meant to remove.
200
- The cause was one conflation. All three paths filtered "absent from disk" out of the diff scope, on a comment asserting the diff "would fail" on a missing path — true only for a path that was NEVER TRACKED. For a tracked file deleted in the worktree, `git diff HEAD -- <path>` emits a proper `deleted file mode` section, and every downstream consumer already branches on `isDeletedFile`. The two cases are now split by `listTrackedInHead`: a tracked absent path stays in the diff scope and is captured, an untracked one is refused rather than warned about, since the patch cannot express it either way. A captured deletion KEEPS its `filesAffected` entry — the patch is what performs the deletion, so pruning it would leave the body deleting a file the manifest says the patch has nothing to do with — and it still requires the confirmation, because the patch will now remove those files wherever it is applied.
201
- The report's priority-1 item — make the silent success LOUD — turned out to need two words, not a feature: the `--files` path already refused when a requested path produced no hunk, but its domain was the post-filter subset, so anything dropped upstream was invisible to the check by construction. It now runs against the full requested list. A real-git test asserts the emitted patch contains `deleted file mode`; it fails on the old implementation.
202
- Recorded because the report says otherwise and the next reader should not chase it: **`export` was never affected.** It accepts a `D` status from `getStatusWithCodes` and routes a HEAD-tracked path to `git diff --binary HEAD`, which has always carried the deletion. Only the batched `re-export` paths dropped it.
203
- - **`token add --mode override` silently skipped a QUALIFIED `[data-theme="light"]` block.** The override path mirrors into both `:root[data-theme="dark"]` and `:root[data-theme="light"]`, guarded by `variantBlockExists` — and that guard compared canonically against a fragment sliced to the LAST `]` before the opening brace. For `:root[data-theme="light"]:not([data-private]) { }` the last `]` is the one inside `:not(…)`, so the computed fragment was `:root[data-theme="light"]:not([data-private]` and the comparison could never match. The unqualified dark block beside it matched fine. That asymmetry was the whole bug: the override wrote dark, skipped light, and said nothing — so a Light choice under a dark OS inherited the new dark token, and the operator met the gap as a theme bug rather than as tool output. `THEME_ATTRIBUTE_VARIANTS`' own doc comment names the failure this feature exists to prevent — "a guaranteed half-finished edit" — and the half-finished edit is what shipped.
204
- The last-`]` slice was deliberate, not sloppy: it avoids a `js/polynomial-redos` regex, and taking the last `]` rather than the first keeps a multi-attribute selector comparing as a whole instead of colliding with a single-attribute variant that is its prefix. Both properties survive. The selector is now parsed structurally — consume consecutive `[…]` groups after `:root`, stop at the first character that is not `[` — which is still linear and still matches multi-attribute blocks whole, but excludes a pseudo-class tail instead of swallowing it. A regression test uses a two-attribute block to pin that.
205
- The qualifier is reported rather than applied silently. `:not([data-private])` is semantically load-bearing — writing through it narrows where the declaration applies, and that may not be what the operator meant — but matching it is still the right call, because silently skipping it is the worse failure and the one that shipped. So the write happens and a warning names the selector it went through. The pinning test fails on the old matcher.
206
- - **`token add --variant` required a flag that described nothing, rejected its companion, and reported a no-op as success.** Three separate frictions, all in the variant path. `--category` was an unconditional `requiredOption` that had to name a REAL category even under `--variant`, where the declaration is routed into a `:root<selector>` block and never into a category section — the requirement existed so the argument would not be "silently discarded", a fair goal whose effect was a mandatory argument saying nothing about where the token lands. It is now optional there (still validated when supplied, so a typo is not accepted just because the flag is inert), and its absence on a BASE declaration is refused with the REASON stated rather than commander's bare "required option not specified".
207
- `--create-category` is still rejected with `--variant` — the two really are incompatible — but the working create-then-variant order (author the category with the base token, then add each variant) was discoverable only by failing twice. It is now in both the error and `--variant`'s help text.
208
- And a token already present in the target variant block was skipped with a bare `{ added: false }`, so a re-run intending to CHANGE a value exited 0 having changed nothing, while the base path had been reporting the category and line for the same situation all along. The variant path now returns the location too, and the skip reads `already exists in :root[…] (line N), unchanged` — the block being the location, since a variant declaration has no category to name.
209
- - **The CC0 public-domain dedication Mozilla puts on its test files was not a recognized license header.** `containsUpstreamLicenseText` knew MPL, Apache, MIT, GPL and a bare SPDX tag; nothing knew `Any copyright is dedicated to the Public Domain. http://creativecommons.org/publicdomain/zero/1.0/`. A modified upstream test whose notice was UNCHANGED therefore warned `modified-file-missing-header`, and the downstream workaround — two identifier-only edits, an `SPDX-License-Identifier: CC0-1.0` line above each untouched notice on patch 905 — was editing Mozilla's notice to satisfy FireForge's recognizer. Worse than filed: `hasThirdPartyPermissiveBanner` did not know it either, so a NEW file copied from the upstream test tree was neither "acceptable" nor "vendored", and `export -y` would have prepended the PROJECT's license header onto code Mozilla dedicated to the public domain — precisely the mislicensing the vendored carve-out (0.38.0) exists to prevent.
210
- Both predicates now recognize the dedication in every spelling the tree carries — the prose, the `creativecommons.org/publicdomain/zero/1.0/` URL with either scheme, and the `CC0-1.0` SPDX identifier — through one shared matcher, so the two cannot drift. Deliberately narrow, with the negative controls pinned: a different Creative Commons license (`licenses/by/4.0/`, `CC-BY-4.0`), a made-up `publicdomain/zero/2.0/`, a bare "public domain" mention, and a dedication below the scanned head are all still refused.
159
+ - The failure-line classifier no longer matches the English word "assertion" or `TEST-KNOWN-FAIL`. Both arms are harness-shaped and case-sensitive now. A green run could previously be turned red by its own diagnostic.
160
+ - `fireforge test --build` accepts `--refuse-unexported-drift` and forwards it to the pre-test build. It is refused without `--build`.
161
+ - A `--wait-lock` waiter no longer gets a free extension on its first probe, so a stalled queue starves on exactly the budget asked for.
162
+ - `re-export` no longer rewrites a tracked binary patch into an un-appliable stub. `--binary` now covers every diff path, a new `binary-body-not-reconstructable` lint fails closed on a payload-less binary section, and classification no longer trusts a stub's index hash.
163
+ - `re-export` reports `Unchanged` for a patch whose body did not move, and does not rewrite the file.
164
+ - `fireforge test --doctor` no longer orphans a browser tree on Ctrl+C.
165
+ - Failures during a patch-queue mutation keep their own class, stack, and exit code instead of all rendering as a patch-compatibility problem.
166
+ - Esc or Ctrl+C at a confirmation exits 130. Answering "no" exits 0.
167
+ - `furnace validate` checks that the engine exists.
168
+ - `furnace create` escapes quotes, newlines, and `*/` in generated modules.
169
+ - The signal-time furnace lock sweeper checks ownership before removing a lock.
170
+ - `killProcessTree` falls through to a direct kill when the group kill fails.
171
+ - A deletion of an upstream file can now be captured through `re-export`. All three paths filtered absent files out of the diff scope; a tracked absent path is kept and produces a real `deleted file mode` section. `--files` refuses against the full requested list, so a path that produces no hunk is reported.
172
+ - `token add --mode override` writes qualified blocks such as `:root[data-theme="light"]:not([data-private])`. It used to write dark and silently skip light. The selector is parsed structurally, and the qualifier is reported in a warning.
173
+ - `token add --variant` no longer requires `--category`, explains the create-then-variant order, and reports an already-present token with its location instead of a silent no-op.
174
+ - Mozilla's CC0 dedication is recognised as a license header in both the modified-file and vendored-file checks. Without it, `export -y` would have prepended the project header onto public-domain code.
211
175
 
212
176
  ### Security
213
177
 
214
- - **Six scanners over `tokens.css` were super-linear in the length of a single line** (`js/polynomial-redos`). Worth stating plainly what the input is: `tokens.css` and `docs/design/SRC_TOKENS.md` are files FireForge reads out of a CONSUMER's engine tree, so "attacker-controlled" is the wrong frame — a minified, generated or merge-mangled line that happens to repeat `:root`, `:root[`, `var(` or `/*=` is enough to wedge `token add` with nobody doing anything hostile. Measured on the witnesses now pinned as tests: 4 000 `=` after a `/*` cost the category scanner **8.2 s**, and 20 000-40 000 repetitions cost the other five between **0.8 s and 3.6 s** each. Two distinct causes, and they want different remedies:
215
- - _Ambiguous adjacent quantifiers_, where two atoms match the same character so the engine re-partitions the run once per length: `\s*=+\s*(.+?)\s*=+\s*` in the banner reader (`.` matches both `\s` and `=`), `\/\*\s*=.*=\s*\*\/` in the section-end scan, `(^|[\s,{])\s*:root\b` in the dark-mode scan (`[\s,{]` and `\s*` both match a tab), and `:root\[[^{]*\]` in the variant scan (`[^{]` also matches `]`). The dark-mode one is a one-token fix — `(?:^|[\s,{]):root\b` is exactly equivalent, since the last whitespace character of any run satisfies the class on its own. The other three became index arithmetic (`blockCommentBody`, `singleLineBannerName`, `rootAttributeSelector`), which is also where the duplicated "is this a banner comment?" spelling collapsed into one predicate.
216
- - _An unanchored prefix that can start at many positions_, which is quadratic even when the pattern itself is unambiguous: `:root[^{}]*\{` restarts at every `:root` in the line, and `value.replace(/var\(([^)]+)\)/, …)` at every `var(`. The docs one is a one-character fix — the branch it sits on has already established `value.startsWith('var(')`, so anchoring is both linear and exact. The other became `rootOpensBraceOnLine`.
217
- - The variant scanner's replacement deliberately spans to the LAST `]` before the opening brace rather than the first. Taking the first would have been the shorter fix and a silent behaviour change: `:root[data-skin=precision][data-private]` would then satisfy a request for the `[data-skin=precision]` variant it merely starts with, and the declaration would land in the wrong block. A test pins that, and it passes on both implementations — which is the point of it.
218
- - The regression tests assert wall-clock, because the defect IS a running time; every one of these calls returns the same answer before and after. Each witness fails the old implementation by an order of magnitude or more, and the whole file now runs in ~26 ms.
219
- - **The prototype-chain guard on `config <key> <value> --force` was correct and invisible** (`js/prototype-pollution-utility`). `mutateConfig` rejected `__proto__`, `constructor` and `prototype` — but once, up front, in a helper, against a `Set`; the descent and the leaf assignment two dozen lines below therefore read as unguarded to anything that is not interprocedural, CodeQL included. The check now happens at the point each segment is USED as a property name, spelled as explicit comparisons, with only the message factored out. That is not just to satisfy the query: a guard that lives at a distance from its write is one refactor of its caller away from being skipped, and the existing tests keep passing untouched because nothing about the behaviour moved.
220
- - **Two `replace` calls read as incomplete escaping** (`js/incomplete-sanitization`), and both were in fact correct — which is exactly why they are rewritten rather than dismissed, since "correct, trust me" is not a property a scanner can re-derive next quarter. `patch-lint-checkjs` substituted a TypeScript `paths` wildcard with `target.replace('*', captured)`; a target carries at most one `*` and first-occurrence replacement IS the contract, now spelled as index arithmetic mirroring the prefix/suffix split already used on the pattern side, with a test pinning both the wildcard and wildcard-free target shapes. The pack smoke test hand-escaped a version string with `.replace(/\./g, '\\.')`; it now uses `escapeRegex` from `src/utils/regex.ts`, the helper ~40 other call sites already use.
178
+ - Six `tokens.css` scanners were super-linear in the length of a single line. One took 8.2 s on 4,000 repeated characters. Fixed by removing ambiguous adjacent quantifiers and anchoring unanchored prefixes; four became index arithmetic. Regression tests assert wall-clock, and the file now runs in about 26 ms.
179
+ - The prototype-chain guard on `config --force` moved to the point each segment is used as a property name.
180
+ - Two `replace` calls that read as incomplete escaping were rewritten as index arithmetic and the shared `escapeRegex`.
221
181
 
222
182
  ### Manifest repair
223
183
 
224
- - **The rebuild dropped `stagedDependencies` from every entry that had one.** `doctor --repair-patches-manifest` builds each row as an explicit object literal, field by field, from the existing entry — so it preserves exactly the fields someone remembered to list, and silently drops the rest. `stagedDependencies` was never listed, which on a queue that uses it means the `registrations` block that lets a branding `distribution/` directory land with a later patch is gone, and the next `lint` fires on a dependency the operator had declared. A patch body cannot express it, so nothing recomputes it either. The row is now built by spreading the existing entry and overriding only the two fields the rebuild OWNS — `filesAffected` and `order` — so a field a `.patch` cannot carry survives by construction, including whatever is added to `PatchMetadata` next. This is the same failure the `lintIgnore`/`tier` lines were added to fix, one field later; the enumeration was the defect, not the omission.
225
- - **An unparseable `patches.json` was rebuilt into a wholly invented one, and the report did not see this because it is not what happened to it.** An existing-but-invalid manifest loads as `null`, which the merge cannot tell apart from "no manifest at all" — so every entry was treated as new, and every entry's `description`, `createdAt`, `category` and source version was reinvented from filename, mtime and fallback. `manifest-invalid` is one of the consistency issues that triggers the repair, so `doctor --repair-patches-manifest` reached it directly. It now refuses, naming the parse error and what would be lost, and the escape hatch is explicit (`--allow-metadata-loss`) rather than the default.
226
- - **A repair that wrote was reported as a run where nothing happened.** The repair runs inside the check loop; the exit code is computed after every check. The repair itself returns a warning, so it never fails the run — but any later failing check makes doctor exit 1 with the manifest already rewritten, and a non-zero exit reads as "the command refused". Doctor now prints what it wrote, immediately before the summary, in every branch including the failing one. The write is not rolled back: the repairs are independent of the checks that follow them, and undoing a successful repair because something unrelated is also wrong would be the larger surprise.
227
- - **`--repair-patches-manifest` was named as the fix for a one-field drift, by both `import` and doctor's own hint.** `files-affected-mismatch` is a derived list disagreeing with the diff that defines it, and a tool that names a whole-manifest rewrite as the remedy for that will have it run. There is now a narrow repair — `doctor --repair-files-affected` — which recomputes only the drifted lists through `mutatePatchRowsInManifest`, so every untouched row keeps its exact JSON, and which needs no project config, so unlike the rebuild it works on a project whose `fireforge.json` is also broken. The refusals now offer whichever repair fits the issue codes actually present. Both repairs gained `--dry-run`, which `patch lint-ignore` has had all along; neither gained a confirmation prompt, because `confirmDestructive` refuses a non-TTY run and these are run from scripts — the repair flag is itself the explicit opt-in. Manifest rows the rebuild drops because their patch file is gone are now named too.
228
- - **Two of the report's claims do not hold against this code, and are recorded rather than "fixed".** `lintIgnore`, `tier`, `description`, `category`, `createdAt` and `sourceEsrVersion` were all already preserved on an existing entry — the rebuild has been a merge, not a from-scratch rebuild, since the branding tier landed. Most of the 1823 deletions were `filesAffected` lists being re-sorted (the rebuild normalises them; the narrow repair does too, but only on rows that were already wrong) plus the `stagedDependencies` blocks above. And the three binary-asset patches the report flagged as possible mismatches were an artifact of its own scan: `getAllTargetFilesFromPatch` reads `diff --git` headers, so `GIT binary patch` sections with no `+++ b/` line have been counted correctly since the fix that put them there. Naming what the code actually does is how the next occurrence gets attributed quickly instead of inheriting a guess.
184
+ - `doctor --repair-patches-manifest` no longer drops `stagedDependencies`. The row is built by spreading the existing entry and overriding only `filesAffected` and `order`, so any future field survives too.
185
+ - An unparseable `patches.json` is no longer rebuilt into an invented one. Doctor refuses, names the parse error, and requires `--allow-metadata-loss`.
186
+ - Doctor prints what it wrote before the summary, including on a failing run.
187
+ - New narrow `doctor --repair-files-affected` recomputes only the drifted lists. Both repairs gained `--dry-run`.
229
188
 
230
189
  ### Performance
231
190
 
232
- - **`getAllDiff` spawned one `git hash-object` per untracked file, uncapped.** Its sibling `getDiffForFilesAgainstHead` was reshaped into classify → one batched hash → render for exactly this reason; this path kept the per-file form, and it is the only path behind `tree create`, `tree list`, `tree remove --all`, `lint` and `export-all` — against a Firefox tree where "untracked" can mean a very large number of files. Now batched. Everything observable is deliberately unchanged (renames, `ls-files` ordering, tracked-block-first emission, the `'\n'` empty sentinel) because this output is SHA-256'd into every tree fingerprint and written verbatim into `.patch` files; a new real-git test pins those bytes, which nothing did before.
233
- - **A dirty `jar.mn` escalated EVERY `test --build` to a full `mach build`, and a dirty `moz.build` re-ran `mach configure` every time — including immediately after the full build that had consumed exactly that content.** A downstream session measured it: 15:00 for the first full build, 14:03 for the next `test --build` because "`jar.mn` changed relative to its own successful-build record", and 17 s for the CSS-only fix that followed. Two of those three builds were the same work twice.
234
- The cause is what "changed" meant. `collectChangedEnginePaths` unions the baseline-to-HEAD diff with every WORKTREE modification, and a fork's worktree is permanently dirty — imported patches plus Furnace-applied components are a standing diff against HEAD — so a patch-touched `jar.mn` was "changed" on every run for as long as the patch existed. The baseline already solved this for packageable files (`packageableFingerprints`, 0.40.0) and for `components.conf` (the static-components anchor), and both consumers compare content, not dirtiness; the build inputs were the one family with no fingerprint, because `isPackageablePath` excludes them by design and nothing else recorded them.
235
- `last-build.json` now carries `buildInputFingerprints` — a content hash per dirty `jar.mn` / `moz.build` / `moz.configure` / `Makefile.in` / `mozbuild.in` — and `build-prepare` drops any input whose live hash still matches before deciding either escalation. The two halves refresh on different rules, and the difference is load-bearing: backend inputs are refreshed on every successful write, because every baseline-writing path ran the auto-configure preflight against the previous record first; `jar.mn` entries are refreshed only by a FULL build and carried forward verbatim on a `faster` one, because `fireforge build --ui` is a `mach build faster` that never escalates, and recording a `jar.mn` no full build has installed would silence the next escalation. `build --ui` therefore now says which kind of build it was (`writeBuildBaseline`'s new `buildKind`), and passes the previous baseline it was already holding. An unreadable file, a missing entry, or a marker written before the field existed all count as changed — "cannot prove unchanged" never becomes "unchanged". The stale-build preflight's hash loop became the shared `dropPathsMatchingFingerprints` so both consumers compare the same way.
236
- **On the escalation itself** — the downstream ask was a scoped install-manifest refresh for jar-only registrations, or a reason full compilation is necessary. Read against mozbuild: every `mach build`, `faster` included, first runs `backend_out_of_date` and regenerates the backend through `config.status` when any `backend.*Backend.in` dependency is newer, and `jar.mn` files are such dependencies; the FasterMake backend consumes `JARManifest` objects for `dist/bin` targets into its own install manifests. So for an entry added to an EXISTING `jar.mn` under `dist/bin` — the Furnace widget case — a `faster` build likely installs the destination, and the 0.41.0 rule ("full build for new `jar.mn` registrations") looks over-broad. It is NOT relaxed here: the rule came from an observed failure whose specifics were not recorded, and the claim needs a real objdir to prove, which a source reading is not. The seam is already available to prove it with: `fireforge build --ui` never escalates, so a jar-only registration followed by `build --ui`, a check that `obj-*/faster/install_dist_bin` names the new destination and the file landed under `dist/bin`, then a plain `fireforge test`, is the experiment; two clean runs of it make narrowing the escalation (to new `jar.mn` files and non-`dist/bin` targets) a safe change. Recorded in `docs/testing.md` so the next reader inherits the analysis rather than the guess. Also recorded, not fixed: `build --ui` ignores `fullBuildRequired` and `package` runs `prepareBuildEnvironment` with no baseline at all, so neither auto-configures nor escalates.
237
-
238
- ### Drift collapsed
239
-
240
- - The engine-precondition refusal was written out 15 times across 13 files, and `engine-precondition.ts` exists _specifically_ because five commands had drifted copies of it. Four two-rung sites now use `assertEngineGitReady` (gaining the unborn-HEAD rung they were missing); the rest use a new one-rung `assertEngineExists`.
241
- - The Furnace family repeated its own two-rung ladder at nine sites in three different subsets. `assertFurnaceReady` / `assertFurnaceEngineReady` replace them, with an injectable error factory because `FurnaceError`'s exit code 9 is load-bearing. Three en-GB "initialise" messages and one em-dash variant were aligned.
242
- - The same 15-line rollback catch appeared verbatim in eight furnace command bodies, two of which cross-referenced the duplication in a comment ("Matches the seven sibling mutation sites"). `completeJournalRollback` owns the sequence now — shaped as a catch-body handler rather than a wrapper, because the eight bodies differ in early returns and converting them to callbacks would restructure control flow through the engine's rollback path for a cosmetic gain.
243
- - Five tokenizer-primary register/wire modules kept a hand-written legacy scanner whose only trigger was the primary's own "could not find X". Deleted (~230 lines). Three tests that fed bare list bodies with no header had been passing only because the lax legacy scanner tolerated them; they now use realistic manifests. The three acorn-primary fallbacks in `wire-init`/`wire-destroy`/`wire-subscript` are load-bearing and stay.
244
- - `bootstrap.ts` carried a second copy of `detectBootstrapIssues`'s six regexes, and the copies disagreed on the traceback+403 case. It calls the canonical scanner now.
245
- - Two content-equality policies were split across five comparison sites, so on a CRLF checkout `furnace status` and `furnace validate` could report drift for files `apply` had just decided were identical. `normalizeForChecksum` moved to `furnace-checksum-utils.ts` and all five use it.
246
- - The four manifest registrars each carried their own copy of the insert sequence. The two jar.mn ones (browser content, shared CSS) were byte-identical apart from a sort regex, a sort key and a refusal message; the two moz.build ones likewise. They route through `insertJarMnEntry` and `insertMozBuildListEntry` now — deliberately two family-specific helpers rather than one generic pair, because `after` and the section concept are meaningless on the moz.build side, and because `tokenizeMozBuildList` **mutates the line array in place** when it normalizes `FOO += []` into the multi-line form. That rewrite is what makes the caller's splice land inside the list body, so a wrapper tokenizing a copy would have silently reintroduced a fixed bug.
247
- - `registerToolkitWidget` was the one jar.mn target still hand-scanning lines with its own section-bounds and sort-key regexes, so it never inherited fixes made to the shared tokenizer path. It uses the same helper as its three siblings now. The dead `findAlphabeticalPosition` (its last caller) went with it, and `findAlphabeticalTokenPosition` became module-private.
248
- - `WIDGETS_DIR` moved out of `furnace-scanner.ts`; the seven-line `withParserFallback` rationale moved onto the shared predicate it explains (one of its three copies had already drifted); 18 open-coded `process.stdin.isTTY && process.stdout.isTTY` checks became `stdioIsInteractive()`.
249
-
250
- ### Structure
251
-
252
- - `src/commands/doctor/` held a single file while six `doctor-*` siblings sat flat; it moved up to `doctor-post-rebase-audit.ts`, turning its lone `../doctor-check-core.js` reach-back into `./`.
253
- - `furnace-validate-helpers.ts` was a 572-line spill file with three disjoint subjects and 18 of its 19 exports having exactly one consumer each. Each block moved to the validator that uses it; the module is now 20 lines holding the one export that genuinely had two consumers. **knip then revealed 16 of those exports as module-private** — they had only been exported to cross a boundary that no longer exists.
254
- - Four ambiguous `manifest-*.ts` modules became `moz-manifest-*.ts` — "manifest" named both `patches.json` and Firefox build files — and `register-test-manifest.ts` became `register-browser-test.ts` so it reads as a `register-*` leaf. `lint-cache.ts` and `lint-projection.ts` joined the `patch-lint-*` family they belong to. The `register-*` family was deliberately left alone: `register-` names the `fireforge register` command those modules implement.
255
- - The `Furnace manifest sync` doctor check does not look at a manifest — it compares component directories on disk against `furnace.json`. In a codebase where "manifest" already names `patches.json` and Firefox build files, that made the one referent visible to operators the most misleading one. Check, module and symbol are now `Furnace config sync`.
256
- - Four modules reached for the same two drift predicates through two different paths — `furnace-apply-helpers.js` in two, the `furnace-apply.js` compat barrel in the others. They all use the defining module now, and the three names nothing else re-exported came off the barrel. The direction matters: `furnace-apply.ts` is 650 lines against a 500-line cap, and routing a consumer through it drags the whole apply graph into every suite that mocks it — which is exactly how the first attempt at this cleanup broke seven doctor tests, whose exhaustive `vi.mock` factory for the barrel listed one export. `mach.ts`'s re-export block now carries a comment saying it is a frozen compatibility list, not a barrel to grow.
257
- - Dead exports deleted: `applyPatches`/`validatePatches` (superseded by `applyPatchesWithContinue`), six unused `platform.ts` helpers, `hasActiveRebaseSession` (every reference was a test using it as an assertion helper), `peekParserFallbackEvents`. knip could not see any of them, because tests count as consumers.
258
- - Fifteen `await import()` calls became static imports. `patch-export.ts`'s `await import('./patch-manifest.js')` was the notable one: tracing the static graph showed no cycle, so the laziness bought nothing and hid a real edge from the `dpdm` gate — which still reports no cycles after the change.
191
+ - `getAllDiff` batches its `git hash-object` calls instead of one spawn per untracked file. Output bytes are unchanged and now pinned by a test.
192
+ - `last-build.json` carries content hashes for dirty `jar.mn`, `moz.build`, `moz.configure`, `Makefile.in`, and `mozbuild.in`, so a permanently dirty worktree stops escalating every build to a full one and re-running configure. `build --ui` records which kind of build it was.
193
+
194
+ ### Cleanup
195
+
196
+ - The engine-precondition refusal was written out 15 times across 13 files; it is now `assertEngineGitReady` or `assertEngineExists`.
197
+ - The furnace two-rung ladder, the 15-line rollback catch, the four manifest registrars, and five legacy scanners all collapsed to shared helpers.
198
+ - `bootstrap.ts` uses the canonical bootstrap-issue scanner instead of a second copy whose regexes had drifted.
199
+ - Content-equality now goes through one `normalizeForChecksum`, so `furnace status` and `furnace validate` cannot disagree with `apply` on a CRLF checkout.
200
+ - 18 open-coded TTY checks became `stdioIsInteractive()`.
201
+ - Modules renamed for clarity: four `manifest-*.ts` became `moz-manifest-*.ts`, the `Furnace manifest sync` check became `Furnace config sync`, and the lint modules joined the `patch-lint-*` family.
202
+ - `furnace-validate-helpers.ts` was a 572-line spill file; each block moved to the validator that uses it and 16 exports turned out to be module-private.
203
+ - Fifteen `await import()` calls became static imports.
259
204
 
260
205
  ### Types
261
206
 
262
- - **`DoctorCheck` encoded one ternary result in three fields** — `passed` + optional `severity` + a legacy `warning` flag — with the precedence rules in a prose block and a reconciler function, which made states like `passed: true, severity: 'error'` representable. `severity` is now required and the only field; `ok()` gained an optional message, absorbing all five hand-written literals.
263
- - **Three string unions accepted a stale allowlist with no compile error**, and `FirefoxProduct` carried three copies of its member list that nothing linked to the union — one of them on the `fireforge.json` read path, where adding a product would have been silently rejected from every project config. A shared `makeEnumGuard` derives each type from its list; ~12 non-narrowing casts went with it.
264
- - **`src/index.ts` published 32 types unreachable from any exported value's signature**, including 15 `*Options` types for commands it does not export and two (`GlobalOptions`, `ProjectStatus`) with no reference anywhere in the repository. Removed. The surface gained what a consumer genuinely could not name: `ApplyAllComponentsResult` is what `applyAllComponents` actually returns.
265
- - **The per-component apply family passed four interchangeable `string`s in three different orders.** `applyCustomComponent` opened `(engineDir, name, componentDir, …)`, `buildCustomDryRunActions` opened `(name, componentDir, engineDir, …)`, and the drift pair disagreed on slot 1 outright: `hasOverrideEngineDrift(engineDir, …)` against `hasCustomEngineDrift(root, …)`. Every permutation typechecked against every other, so a swap surfaced as a wrong-directory read at runtime. The eight helpers now take `Pick<ComponentApplyContext, …>` of exactly the members they read — a caller is never asked to invent a value it does not have. The two undeploy helpers keep their positional form: they take one path and one directory separated by two non-string parameters, which is not the confusable shape.
266
- - `lintExportedPatch` took four optional positional parameters after `config`, and four of its five call sites passed `undefined` placeholders to reach the one they wanted (`lint.ts` passed two in a row). `patchQueueCtx`, `ignoreChecks` and `patchTier` moved into the existing `LintExportedPatchOptions`. This is ergonomics, not a defect fix — TypeScript's tuple-arity check already caught drift, and it caught every stale test assertion during the change.
267
- - Six Commander handlers annotated their options with an inline anonymous type that duplicated a named interface exactly (`FurnaceCreateOptions`, `FurnaceChromeDocCreateOptions`, `FurnaceRefreshOptions`, `PatchReorderOptions`, `PatchStagedDependencyOptions`, `WireOptions`). They use the names now. `run.ts` uses `Omit<RunOptions, 'args'>` — `args` is the variadic positional, not a flag, so it never appears in that object. The handlers whose inline literal is genuinely `string` where the interface is a narrowed union keep theirs: narrowing is what those handlers exist to do.
268
- - Twelve runtime flag checks raised `GeneralError` (exit 1) where 191 raise `InvalidArgumentError` (exit 8) — including three consecutive checks in one `status.ts` function that used both.
269
- - **Nothing at the CLI boundary ever read an error's `cause`.** Nine classes declare one and 22 throw sites pass one, but a `FireForgeError` rendered as its `userMessage` and stopped, so the underlying git stderr or errno never reached the operator; `download.ts` had hand-rolled the rendering four times to make its own "--verbose for the underlying error" promise true. The boundary now walks the chain under `--verbose`.
207
+ - `DoctorCheck` encoded one ternary result in three fields. `severity` is now required and the only field.
208
+ - Three string unions are derived from their member lists through `makeEnumGuard`, so a stale allowlist is a compile error.
209
+ - `src/index.ts` no longer publishes 32 unreachable types, and gained `ApplyAllComponentsResult`.
210
+ - The per-component apply helpers take `Pick<ComponentApplyContext, …>` instead of four interchangeable strings in three different orders.
211
+ - `lintExportedPatch` takes its optional arguments in an options object.
212
+ - Twelve runtime flag checks raise `InvalidArgumentError` (exit 8) like the other 191.
213
+ - The CLI boundary walks the error `cause` chain under `--verbose`.
270
214
 
271
215
  ### CLI
272
216
 
273
- - **`--wait-lock` told commands they took no lock when they did.** `patch tier`, `patch lint-ignore` and `patch staged-dependency` mutate patch metadata under the patch-directory lock, but carried the accept-and-ignore registration whose help text read "accepted and ignored: this command takes no FireForge lock" — so a scripted `--wait-lock 300` was silently given the default budget. They honour it now.
274
- - `import` and `setup` accept `-y, --yes`. On `setup` it is a plain alias of `--force`. On `import` it is **not**: `--force` waives both the drift prompt and the patch-integrity gate, and `--yes` waives only the prompt, so an unattended import keeps the integrity refusal armed.
275
- - **`fireforge test --doctor` printed the same preflight line three times** on a terminal: a 2026-04-24 fix added an emission for non-TTY capture without removing the two it superseded. The raw writes are gated on non-TTY, which is where the capture requirement applies; the clack framing stays for terminals.
276
- - **All seven** `--wait-lock` commands that take the patch-directory lock now honour it: `patch tier`, `patch lint-ignore`, `patch staged-dependency`, `export-all`, `resolve`, `doctor` and `rebase`. Each routes through a different lock entry point, so the budget is threaded through `updatePatchMetadata`, `mutatePatchMetadata`, `commitExportedPatch`, `updatePatchAndMetadata`, `rebuildPatchesManifest` and the rebase patch loop.
277
- - `status --json` outside a project wrote a clack-styled block to **stdout** and exited 2, because the config error is raised before the command body engages machine mode. The boundary now engages it from the flag, so stdout carries exactly one JSON envelope and the human guidance goes to stderr — rule 3 of `docs/machine-output.md`, which was aspirational until now. A payload that already owns stdout (`status --json --fail-on`) suppresses the envelope, so the "exactly one document" half of the contract holds too.
278
- - **`--variant` refused selectors the matcher had just learned to find.** 0.44.2 widened `rootAttributeSelector` to consume consecutive `[…]` groups and to stop at a pseudo-class tail, so `:root[data-skin="humanist"][data-theme="dark"]` and `:root[data-theme="light"]:not([data-private])` became blocks FireForge can locate and write through. The CLI validator did not move with it: its own regex still accepted exactly one `[attr]` fragment, so `token add --variant '[data-skin=humanist]:not([data-private])'` was refused as "must be a single attribute selector" — a block the tool can FIND was one it would not let you NAME, and the private-exclusion selectors the widening exists for are precisely that shape. Downstream the values were maintained by hand in the allowlisted block instead.
279
- The validator now runs the MATCHER'S parse and requires it to consume the whole fragment, rather than carrying a second grammar that can drift from the first again. Character safety stays a separate check, because that parse finds bracket boundaries and validates no characters at all — it is applied one bracket group at a time rather than as a repeated pattern, which is the shape that backtracks (`js/polynomial-redos`), and a linearity test pins that alongside the matcher's own. Normalization to `="value"` now covers every fragment in a run and the `:not(…)` argument, not just a trailing one.
280
- `findVariantBlock` had to move with it: it compared a request against a block's ATTRIBUTE run only, so a variant carrying its own qualifier would have matched nothing and spliced a fresh duplicate block on every add. Request and candidate go through the same parse and are compared tail-to-tail. An UNqualified request still matches a qualified block — that is the 0.44.2 behaviour `variantBlockQualifier` exists to report out loud, since silently skipping the block is the worse failure — but a request that names a qualifier means it.
281
- - `tree list --json` gained the failure envelope `status --json` already emitted, so a scripted consumer no longer gets a parseable refusal from one and a bare non-zero exit from the other. The contract is written down in `docs/machine-output.md`.
217
+ - `patch tier`, `patch lint-ignore`, and `patch staged-dependency` honour `--wait-lock` instead of claiming they take no lock. All seven patch-directory commands honour it now.
218
+ - `import` and `setup` accept `-y, --yes`. On `import` it waives the prompt but keeps the integrity gate armed.
219
+ - `test --doctor` prints its preflight line once.
220
+ - `status --json` outside a project writes one JSON envelope to stdout and the guidance to stderr.
221
+ - `token add --variant` accepts every selector the matcher can find, using the matcher's own parse rather than a second grammar.
222
+ - `tree list --json` emits the same failure envelope as `status --json`.
282
223
 
283
224
  ### More correctness
284
225
 
285
- - **`furnace create`'s interactive tag prompt died on an invalid name.** It passed `validateTagName` — which THROWS — to clack's `validate` callback, which expects a returned message and re-prompts on it. So a typo escaped clack's validation loop as a `FurnaceError` and killed the prompt instead of showing the rule inline. This is the concrete cost of `validate*` spanning six incompatible return contracts across 42 exports: the name did not say which one you were getting. The message-returning minority is now `describe*Problem`, `validateTagName` is implemented in terms of its new sibling so the two cannot disagree, and the prefix contract is written down in `utils/validation.ts`.
286
- - **`register --after` was silently ignored for four of six file types.** The CLI accepted the flag, the adapter took it as `_after`, and the entry went in alphabetically with nothing said. Rules now declare `supportsAfter`, and passing `--after` to one that cannot honour it says so.
287
- - `RebasePatchEntry` became a discriminated union on `status`, so a status flip can no longer strand the previous status's payload — which `rebase --continue` did, persisting `{ status: 'resolved', error, conflictingFiles }` to the session file. The on-disk validator is deliberately NOT tightened to match: the session file carries no schema version, so a stricter validator would refuse a 0.43.x file mid-rebase, where the only remedies discard the operator's conflict resolution. Entries are normalized on read instead, and two tests pin that an older session still loads.
288
- - **A `patches.json` from a newer FireForge was reported as malformed.** `patches.json version must be 1` sent the operator looking for damage in a file that only needed a newer build to read it, and the manifest had no migration hook at all. It now refuses by name — naming the found and supported schema versions and telling the operator not to delete the file — while a version below 1 or non-integer keeps the plain malformed message, because that one really is malformed.
289
- - The three JSDoc walkers in `patch-lint-jsdoc.ts` return their issues instead of pushing into an out-param, matching the ~30 other patch-lint entry points. The new `jsdoc/check-param-names` rule immediately caught the stale `@param issues` the change left behind — which is the point of enabling it.
290
- - **`furnace create --test-dir` was honoured by the scaffolder and ignored by everything that told the operator where the files went.** `create.ts` resolved the override once and passed it to the scaffolders, but `formatDryRunPlan` and `formatSuccessNote` received only `binaryName` and recomputed the DEFAULT from it — so `--dry-run` planned `test/hominis/` plus `hominis/browser.toml` in `moz.build` for a command that would write to `test/hominis-tiles/`, and the success note after the real run named `hominis/` while the files sat in `hominis-tiles/`. The one message an operator gets as confirmation was the one thing that could not be trusted. Both formatters now take the resolved directory, and the default lives in one place: `resolveBrowserChromeTestDir` / `resolveXpcshellTestDir` in `furnace-constants.ts`, called by the scaffolders AND the formatters, so the two cannot disagree again. The moz.build registration line follows the override too (it is the path below `browser/base/content/test/`, which under `--test-dir` is not the binary name), and a test scaffolds for real and asserts the printed directory is the one on disk — default and overridden, browser-chrome and xpcshell.
226
+ - `furnace create`'s tag prompt shows the rule inline instead of dying on a typo. The message-returning validators are now named `describe*Problem`.
227
+ - `register --after` says so when a file type cannot honour it, instead of ignoring the flag.
228
+ - `RebasePatchEntry` is a discriminated union, so a status flip cannot strand the previous status's payload. Older session files still load.
229
+ - A `patches.json` from a newer FireForge is refused by name instead of being reported as malformed.
230
+ - `furnace create --test-dir` is honoured by the dry-run plan and the success note, not just the scaffolder.
291
231
 
292
232
  ### Windows
293
233
 
294
- - **`fireforge lint`'s checkJs pass reported ZERO findings on Windows, silently.** `patch-lint-checkjs.ts` keys `ownedAbsolute` / `relByAbsolute` / `pathsResolved` on `resolve(repoDir, rel)` — backslashes — but TypeScript's `CompilerHost` hands `getSourceFile`/`fileExists` a `/`-normalized `fileName` on every platform. Every lookup missed, so the host served an empty synthetic source file for each owned file and the whole pass came back clean. "No diagnostics" is indistinguishable from "no problems", which is why nothing caught it. The maps are now keyed through one `toTsPath()` helper in the TypeScript spelling. `typecheck.ts` had the same defect on its synthetic shim: never served, and its own diagnostics never filtered out.
295
- - **`furnace diff` reported every overridden file as a new file.** The engine-relative path it hands `git show <ref>:<path>` came from slicing a native path, and git's `ref:path` form only understands forward slashes — so every baseline read missed and the command showed nothing to diff.
296
- - **Engine-relative paths were recorded in two spellings at once.** `furnace-apply-helpers.ts` and `furnace-apply-ftl.ts` pushed `relative(engineDir, …)` results — backslashes on Windows — into lists whose sibling entries are POSIX literals, and those lists are identifiers: they go to git, into furnace state, and are compared against the POSIX paths `furnace.json` and `jar.mn` already carry. Normalized at the five producing sites.
297
- - **`build-audit` collapsed to one path segment**, so the same-basename collision heuristic and the stale/missing classification stopped discriminating; **`removeTree` refused every removal** as an escape from the trees directory, because it compared a `join`ed prefix against a `resolve`d root and only the latter carries a drive letter; and **`initRepository` never pinned `core.autocrlf`**, so on a host with git's Windows default the checkout rewrote LF to CRLF — shifting the blob hashes `hashObjectBatch` delegates to git, and with them the bytes patches are exported from and applied to.
298
- - **`furnace init` accepted `--ftl-base-path ../escape/path` on Windows.** The traversal guard normalized the value and then tested it for a `../` prefix, but the platform `normalize` emits backslashes there, so `..\escape\path` failed the check and the escape was written into `furnace.json`. The guard normalizes with the POSIX rules regardless of host; the sibling absolute-path and null-byte checks were already separator-agnostic.
299
- - **`furnace` jsconfig sync did nothing on Windows.** `isManagedEntry` decided whether a `compilerOptions.paths` entry pointed into the components workspace with `target.startsWith(resolve(customDir) + '/')`. The hard-coded `/` never matches what `resolve` produces there, so every Furnace-managed entry was classified as hand-written and preserved verbatim: entries whose helper was deleted were never pruned and entries whose target moved were never updated. Containment now goes through `isPathInsideRoot`, the same `relative`-based check the rest of the codebase uses. The failure was silent in the worst way — a clean `+0 added, ~0 updated, -0 pruned` summary reads as "already in sync".
300
- - **The `build-audit` segment-splitting defect had a second site.** `pathSegments` was normalized above, but `isConfidentMatch` still split candidates on `/` alone, so the non-generic-segment bonus — the signal that keeps a cross-tree re-root like `branding/<name>/content/aboutDialog.css` → `chrome/<area>/content/branding/aboutDialog.css` classified as `stale` — could never fire on Windows, and real staleness was reported as `missing`.
301
-
302
- On the test side: path expectations and mock keys are rendered through a new `nativePath()` test helper rather than hard-coded POSIX literals; fixture repos pin `core.autocrlf=false` / `core.eol=lf`; the three modules that branch on `process.platform` directly now have that branch forced rather than inherited from the runner; and the doctor summary count is derived rather than pinned at 16, since the orphaned-harness check is `skipIf` win32. The genuinely POSIX-only suites — `tree`, `tree exec`, `run --smoke-exit`, and the `chmod`-as-refusal tests — are skipped explicitly, and each skip is paired with a new platform-stubbed test that pins the win32 refusal itself, which nothing did before.
303
-
304
- `nativePath()` turned out to be the wrong tool for part of that job, and applying it uniformly produced failures of its own. It swaps separators only, which is correct for a value the code built with `join` but wrong for one it passed through `resolve`: on Windows `resolve` also prefixes the current drive, so the expectation has to be `D:\project\x`, not `\project\x`. A sibling `nativeAbsPath()` covers that case, and the assertions that were wrapped despite never touching a separator at all — pure suffix concatenation, a pass-through argument, and a suite whose `getProjectPaths` mock returns POSIX literals on every platform — go back to comparing the literal. Four doctor summary assertions had also kept their POSIX counts inline while the clean-run count next to them was already derived, so the derivation now covers every summary line in the suite. Two more `chmod`-as-refusal tests joined the skip list: they were skipped for `root` but not for Windows, where mode bits are ignored outright, so the "unreadable" setup was a no-op and the code correctly reported success.
234
+ - `fireforge lint`'s checkJs pass reported zero findings, silently: the path maps were keyed with backslashes while TypeScript hands out forward slashes. `typecheck` had the same defect.
235
+ - `furnace diff` reported every overridden file as new, because `git show ref:path` only understands forward slashes.
236
+ - Engine-relative paths were recorded in two spellings at once; normalised at the five producing sites.
237
+ - `build-audit` collapsed to one path segment, `removeTree` refused every removal, and `initRepository` never pinned `core.autocrlf`, which shifted every blob hash.
238
+ - `furnace init` accepted `--ftl-base-path ../escape/path`, because the traversal guard normalised to backslashes before testing for `../`.
239
+ - `furnace` jsconfig sync did nothing and reported a clean summary while doing it.
240
+ - On the test side: path expectations go through `nativePath()` and `nativeAbsPath()`, fixture repos pin `core.autocrlf=false`, platform branches are forced rather than inherited, and POSIX-only suites are skipped explicitly with a paired win32 refusal test.
305
241
 
306
242
  ### Test infrastructure
307
243
 
308
- - **`createLoggerMock()` and `createFsMock()`** in `src/test-utils/module-mocks.ts` replace hand-rolled `vi.mock` factories at 110 sites. `utils/logger.js` was mocked in 118 files by 113 full-replacement factories across 55 distinct key-sets, 34 appearing exactly once; `utils/fs.js` had 89 more. Each enumerated a SUBSET of the real module, so adding one export turned it `undefined` everywhere the factory omitted it — which happened twice while writing this release, 106 files each time.
309
- The factories are annotated with `typeof import(...)`, so `tsc --noEmit` — already in the gate — reports a missing export as an error in **one** file instead of a runtime failure in a hundred suites. Verified by adding a probe export to each real module and confirming the failure lands only in `module-mocks.ts`.
310
- - **`test.test.ts` was 2,943 lines under one `describe`.** What made it unsplittable was its own 190-line `vi.mock` header: any split either duplicated the header into each new file or did not happen. The header moved to `test-command-mocks.ts` as one factory per mocked module, called from inside each file's own `vi.mock` callback — hoisting is unaffected and every suite still gets fresh `vi.fn()`s rather than shared call history. The file is now six, largest 952 lines, split on the seams the tests already formed: discovery and failure rewriting, staleness and packaging coverage, xpcshell/mochitest hints, Marionette forwarding, harness resilience, verdict contract.
311
- The split exposed a real test-isolation bug: `keeps the Marionette preflight for xpcshell-only --doctor runs` had never set a preflight result, and passed only because an `it` roughly 1,000 lines earlier had left one on the shared mock with `mockResolvedValue`. It sets its own now. Coverage of `src/commands/test.ts` is byte-identical before and after the split (95.12 / 94.28 / 100 / 86.23), which is the check that the mock rewiring did not quietly stop exercising anything.
312
- - Five rollback tests moved from asserting a module-level spy to asserting the persisted pending-repair marker. The spy stopped working when the sequence moved into `completeJournalRollback` (its call to the recorder is intra-module), and the outcome is the better assertion anyway.
313
- - **Fourteen verdict assertions passed only because writing to a filesystem root fails.** The command suites pass `/project` as the project root and assert the `FIREFORGE-VERDICT:` line by exact string — which holds only while no run log is open, since the line carries an open log's path as a ` log=<path>` key. On POSIX `mkdir('/project/.fireforge/logs')` fails at the root, the best-effort open degrades to "no log", and the assertions passed for a reason none of them stated. On Windows the same path resolves against the CURRENT DRIVE, the mkdir succeeds, and all fourteen failed — while the run also created real `C:\project\.fireforge\logs` directories on the runner. `createRunLogMock()` joins the two factories in `module-mocks.ts` and the five suites state the precondition instead of inheriting it from a permission error. Because mocking the module away removes the suffix's only coverage, `test-verdict.test.ts` gains the opposite assertion: with a log open, the path rides the line, once, as the run's last stdout write.
314
- - **A lock-wait test decided its result on a `readdir`.** `a wait whose queue position keeps improving > survives a drain longer than its budget` runs on real timers and a real filesystem, and gave each probe about 20 ms of slack inside a 100 ms budget; it also retired its simulated queue with un-awaited `rm` calls, so a retirement could land after the probe meant to observe it and a release could leave `mkdir` seeing `EEXIST` past the intended free. On a Windows runner the second probe missed the deadline, no extension was ever granted, and the wait expired still three from the head — the exact refusal the test exists to prove does NOT happen. The retirement and release are synchronous now (the callback returns `void` and cannot await), and the constants are scaled to ~100 ms of slack per probe while keeping the two invariants that fix them: the budget must span two probes, and the drain must outlast the budget or the sibling test would acquire instead of expiring. No production change — `withFileLock` checking its deadline before probing is correct, since a wait must not extend itself on a probe taken after it expired.
315
- - `git-readonly-index.test.ts` carried a latent racy-clean flake: it edited `const b = 2;` to `const b = 3;` — the same byte length, within the same second as the index entry — without invalidating the stat cache, so git could report the file unmodified. Its sibling test already guarded for this.
244
+ - `createLoggerMock()` and `createFsMock()` replace hand-rolled `vi.mock` factories at 110 sites. Each old factory listed a subset of the real module, so adding an export broke a hundred suites at once. The new ones are typed, so `tsc` reports a missing export in one file.
245
+ - `test.test.ts` was 2,943 lines held together by a 190-line mock header. The header moved to `test-command-mocks.ts` and the file split into six. The split exposed a real isolation bug in a Marionette preflight test.
246
+ - Fourteen verdict assertions passed only because writing to a filesystem root fails on POSIX. They state the precondition now via `createRunLogMock()`.
247
+ - A lock-wait test decided its result on a `readdir`; its retirement and release are synchronous and its timing constants scaled.
316
248
 
317
249
  ### Documentation
318
250
 
319
- - **README.md had become the reference manual.** Its "Regular Workflow" section had grown into fourteen paragraphs of flag semantics — scan manifests, shard behaviour, packaging-coverage anchors, tree CoW, shim configuration — read by nobody starting out and found by nobody looking for a specific rule, because prose in a getting-started page has no index. The reference material moves to `docs/`, split by the thing a reader is actually doing: `patch-workflow.md`, `testing.md`, `furnace.md`, `verification-trees.md`, `configuration.md`, plus `docs/README.md` as the index. README keeps installation, the loop's shape, rebasing, Furnace's premise, the roadmap and the licence — the parts that are project statement rather than manual — and links out for the rest.
320
- - **`docs/run-logs.md`** states the `.fireforge/logs/` contract that previously existed only as a table cell in `machine-output.md`: raw bytes rather than the echo filter's form, opened before any preflight, retained 20 deep per command kind, best-effort throughout, and the tee sitting at the two mach capture funnels rather than at the commands.
321
- - Two documents had gone stale against the code. `machine-output.md`'s surface table omitted `status --raw`, which engages the same stdout discipline through the same argv check in `cli.ts` — a machine surface to which rules 3 and 4 apply and rules 1 and 2 do not — and omitted `build`'s direct log announcement, which exists precisely because `build` prints no verdict line to carry a `log=` key. `lifecycle-invariants.md` still described `--wait-lock` as the engine-session lock's budget alone, which stopped being true when the budget was threaded into the furnace lock as well; both lock rows now state the resolution order (`lockTimeoutMs` → `FIREFORGE_WAIT_LOCK` → the 30 s default) and which mutators each covers.
322
- - **The package did not ship the `docs/` tree its own README links to.** The `files` allowlist was `dist/`, `templates/` and the three root markdown files, so `node_modules/@hominis/fireforge/docs` did not exist and all eleven relative `docs/…` links dead-ended for a consumer — against a doctrine that names the INSTALLED package as the authority, precisely so an agent never has to leave its repo to answer a tool question. The links resolved on GitHub and on the registry page, which rewrites them, which is why nothing caught it. `docs/` is now published (~100 KB of markdown against a package that already ships `dist/`), and `wrapper-smoke.test.ts` asserts the tree is both packed and present after install, the way it already did for `templates/` — so a future `files` edit that drops it fails the gate rather than the reader.
323
- - `FIREFORGE_WAIT_LOCK` and the three other environment variables FireForge reads were documented only in help text and JSDoc. They are tabulated in `configuration.md` with their defaults and their behaviour on a malformed value.
251
+ - README's reference material moved to `docs/`, split by task: `patch-workflow.md`, `testing.md`, `furnace.md`, `verification-trees.md`, `configuration.md`, with `docs/README.md` as the index.
252
+ - New `docs/run-logs.md` states the `.fireforge/logs/` contract.
253
+ - `machine-output.md` and `lifecycle-invariants.md` were stale against the code and now match it.
254
+ - The published package ships `docs/`, so the README's relative links resolve after install. A smoke test asserts the tree is packed.
255
+ - The four environment variables FireForge reads are tabulated in `configuration.md`.
324
256
 
325
257
  ## 0.43.0
326
258
 
327
259
  ### Verdict correctness
328
260
 
329
- - A suite that ends clean (`Ran N checks` / `Unexpected results: 0`, no unexpected lines) and then dies in the recognized upstream mozsystemmonitor teardown traceback is now a `PASS … (harness teardown noise ignored)`. The classifier's green-summary veto required a `SUITE_END` marker, and that teardown crash is precisely what prevents the marker from printing — so a substantively green suite was indistinguishable at the summary level from a red one. Every hard-evidence veto still fails the run: a crash marker, a non-zero unexpected count, a real `TEST-UNEXPECTED-*` line, the shutdown-re-entry shape, or a requested file that never started. An unrecognized teardown traceback is not forgiven.
330
- - `engine/mozconfig` is written only when the rendered content changes. `mozconfig` is a mach _configure input_, so its mtime — not its content — is compared against `config.status`; regenerating a byte-identical file on every build-capable invocation made mach re-run `configure` plus backend regeneration ("Backend config changed; N files touched") on every run, each run re-arming the next. **Consumers who raised hang-canary time bounds to absorb this can restore them.**
331
- - Read-only commands (`verify`, `lint --per-patch`, `typecheck`) no longer write the primary engine checkout. `git status` and `git diff HEAD` refresh and rewrite `.git/index`, which made these commands a second writer and tripped a concurrent `fireforge test`'s engine-fingerprint refusal (`FAIL reason=inconclusive`) on suites that had actually passed. They now run their git plumbing against a private `GIT_INDEX_FILE` seeded from the repository's own index, and `typecheck` never emits an incremental `.tsbuildinfo` sidecar. The engine-change refusal itself is unchanged. A transient `.git/index.lock` during the engine-generation probe is retried rather than latched as "unmeasurable"; a durable failure (a non-git `engine/`) still reports immediately.
261
+ - A suite that ends clean and then dies in the known mozsystemmonitor teardown traceback is a PASS with a note. Every hard-evidence veto still fails the run.
262
+ - `engine/mozconfig` is written only when its content changes, so mach stops re-running configure on every invocation. Consumers who raised hang-canary bounds for this can restore them.
263
+ - `verify`, `lint --per-patch`, and `typecheck` no longer write the primary engine checkout. They run git plumbing against a private `GIT_INDEX_FILE`, so they stop tripping a concurrent test run's inconclusive refusal.
332
264
 
333
265
  ### Diagnostics
334
266
 
335
- - An xpcshell run that dies on SIGSEGV having produced no test evidence now names its known cause — an `.sys.mjs` imported from a packaged module whose `EXTRA_JS_MODULES` registration is missing — instead of surfacing as a bare `return code: -11` with no output.
336
- - `fireforge verify` gained an engine-aware module-resolution preflight: every `resource://` system-module specifier the queue-owned modules import (including lazy `ChromeUtils.defineESModuleGetters` specifiers, whose failure surfaces at runtime only as an `undefined` binding with no logged import error) is resolved against `engine/`, and an unregistered or missing target is named as text. The 0.41.0 `unregistered-system-module` check only sees modules a patch newly creates; the class recurred through imports added to an existing module, which this covers.
337
- - The headed no-output stall now probes the display's power state on macOS and states a measured-asleep display as fact in both the failure message and the verdict line (`(headed run stalled with the display asleep)`). It also prints the three recorded causes of that signature — sleeping display, headless SWGL compositor failure, broken `chrome://` image on the startup page — so one stall points at the discriminating next step. The previous advice to wrap runs in `caffeinate` is corrected: `caffeinate` prevents sleep, it cannot wake an already-sleeping display.
338
- - FireForge's own escalation and explanatory notices (the jar.mn full-build escalation, backend regeneration, the Furnace sync banner, the sharding notice) are emitted at warning severity with a `[FireForge] NOTICE:` prefix, so agent-facing output filters that keep only warnings and errors no longer drop the one line that explains a multi-minute build.
267
+ - An xpcshell SIGSEGV with no test evidence names its known cause: an `.sys.mjs` imported from a packaged module with no `EXTRA_JS_MODULES` registration.
268
+ - `fireforge verify` resolves every `resource://` specifier the queue-owned modules import, including lazy `defineESModuleGetters` ones, and names unregistered targets.
269
+ - A headed stall probes the display power state on macOS and reports it, and lists the three recorded causes of that signature. The old `caffeinate` advice was wrong: it prevents sleep, it cannot wake a display.
270
+ - FireForge's own escalation notices carry a `[FireForge] NOTICE:` prefix at warning severity, so agent output filters keep them.
339
271
 
340
272
  ### Messages and refusals
341
273
 
342
- - The stale-build refusal suggests `--build` first and frames `--allow-stale-build` as for out-of-band rebuilds only, stating plainly that it accepts a packaging predating the edit under test.
343
- - `--refuse-foreign-drift` no longer calls the operator's own additions "foreign". The per-file drift report tags each file `[edited since your last export]` or `[unchanged since your last export — another session may own it]` by comparing the engine file's mtime against the patch body being refreshed, and the refusal separates the two populations and their opposite remedies.
344
- - The `--expect` "showed no drift" warning names the converged case: a concurrent session may already have exported the same change.
345
- - Unknown patch identifiers on `re-export` and `--scan-files` now suggest the nearest matches (prefix and edit distance) instead of printing the full manifest. Prefix matching also resolves typed abbreviations that edit distance misses; a one-character ordinal can no longer prefix-match everything.
346
- - `patch move-files --create`, `patch move-files`, and `patch split` refuse a prompt-less non-TTY run **up front**, naming `--yes`, instead of after minutes of diff and projected-lint work.
274
+ - The stale-build refusal suggests `--build` first and says plainly what `--allow-stale-build` accepts.
275
+ - `--refuse-foreign-drift` stops calling the operator's own additions foreign, and tags each file by whether it changed since their last export.
276
+ - Unknown patch identifiers suggest the nearest matches instead of printing the whole manifest.
277
+ - `patch move-files` and `patch split` refuse a prompt-less non-TTY run up front, naming `--yes`.
347
278
 
348
279
  ### Features
349
280
 
350
- - `--wait-lock` is accepted by every command. Lock-taking commands honour it as before; lock-free ones (`status`, `patch staged-dependency`, and the rest) accept and ignore it, saying so in their help — a scripted sequence that blanket-appends the flag no longer dies with a usage error. A malformed value is still a usage error everywhere.
351
- - `fireforge status --lock` reports the engine session lock without acquiring it: holder pid, the command it is running, how long it has held the lock, whether the holder is still alive, and how many waiters are queued. Waiters advertise themselves, so the `--wait-lock` progress line now also states queue position ("2 ahead of you (queue of 3)").
352
- - An export refused by projected cross-patch lint now detects the new-file-plus-moved-code shape — new files carrying lines an existing patch still contains — and prints the working adopt-then-split sequence (`re-export --scan --scan-file`, then `patch move-files --create --order N`) with real arguments.
353
- - `fireforge typecheck` runs the per-patch checkJs pass — the authority `export` enforces — and reports its findings in the same tally. A green `typecheck` now means export-clean types; previously the two authorities could disagree and only the stricter one spoke, at export time.
354
- - The per-patch lint cache records the lint-ignore waiver set that produced each entry and refuses a replay under a different waiver set, so writing a waiver can never be answered with the pre-waiver verdict. The cache schema bump discards any entry already holding a mismatched result.
355
- - FireForge now checks its own internal invariants at runtime. A new assertion primitive (`src/utils/assert.ts`) guards the state transitions the mutation lifecycle depends on — the rollback journal being registered before the first mutation, the furnace lock being held before the body runs, manifest order matching the filenames it is written alongside — and a violation stops the run at the first inconsistency instead of continuing to write. **Assertion failures exit with the new code 11 (`INTERNAL_ERROR`)** rather than the generic 1, and print a stack trace: they are FireForge bugs, not operator errors, so CI can escalate them instead of retrying. Nothing an operator can misconfigure reaches this path — user, filesystem, and subprocess failures keep their existing typed errors and exit codes.
281
+ - `--wait-lock` is accepted by every command. Lock-free commands accept and ignore it and say so.
282
+ - `status --lock` reports the engine session lock without acquiring it: holder, command, hold time, liveness, and queue depth. The wait progress line now states queue position.
283
+ - An export refused by projected cross-patch lint prints the working adopt-then-split sequence with real arguments.
284
+ - `fireforge typecheck` runs the per-patch checkJs pass, so a green typecheck means export-clean types.
285
+ - The per-patch lint cache records the waiver set that produced each entry and refuses a replay under a different one.
286
+ - Internal invariants are checked at runtime and exit 11 with a stack trace. Nothing an operator can misconfigure reaches this path.
356
287
 
357
288
  ### Hardening
358
289
 
359
- - `build-prepare` warns — naming every file — before overwriting engine content that matches neither a patch body nor the pristine baseline, and `fireforge build --refuse-unexported-drift` makes it a hard stop. On a multi-session checkout the silent overwrite is what let an unexported edit be reverted and a later re-export capture a half-reverted hybrid that every gate then passed.
360
- - **A furnace mutation that fails now rolls back the engine, not just one that is interrupted.** `runFurnaceMutation` previously restored its rollback journal on SIGINT/SIGTERM only; on a thrown error its `finally` merely deregistered the operation — and because it also marked it completed, the operation vanished from the signal handler's view too. Bodies that catch and restore on their own were unaffected, but `applyAllComponents` rolls back on _collected_ component errors, so anything thrown from its pre- or post-loop work (patch-claim loading, the post-apply consistency checks, the state write) escaped with the engine torn and no `pendingRepair` breadcrumb — reachable from `furnace apply`, `sync`, `deploy`, `build-prepare`, and `doctor --repair-furnace`. The wrapper now runs cleanup callbacks, restores the journal, and rethrows the **original** error; a rollback that itself fails becomes a `pendingRepair` marker plus a warning rather than replacing the error you needed to read. A pre-flight refusal that never registered a journal deliberately writes no marker, so a validation failure cannot block later mutations behind a repair with nothing to reconcile.
361
- - **The unexported-drift guard false-positived on collapsed untracked directories, and `fireforge build --refuse-unexported-drift` was therefore unusable on any consumer whose build-prepare-owned tree contains a wholly-untracked directory — every scripted build hard-failed on a false alarm.** Git reports a directory in which _every_ file is untracked as a single `?? dir/` entry instead of listing the files under it. `findUnexportedDriftAtRisk` passed raw `git status` entries to the classifier; a directory has no content to compare, so it matched neither a patch body nor the pristine baseline and classified `unmanaged` — an at-risk bucket — before the guard's branding exclusion could apply. The guard now expands collapsed untracked directories through the same `expandUntrackedDirectoryEntries` helper `status`, `reset`, `discard`, `verify`, `lint`, and `export-all` use, so it and `fireforge status --unmanaged` answer the question about the same tree identically by construction. Two subsystems disagreeing was the defect; the agreement is what the regression test asserts. A genuinely unmanaged file inside such a directory is still reported, and a collapsed directory that merely _contains_ a build-prepare-owned prefix is walked only within that prefix, so the expansion never enumerates an unrelated subtree.
290
+ - `build-prepare` warns before overwriting engine content matching neither a patch body nor the baseline, and `build --refuse-unexported-drift` makes it a hard stop.
291
+ - A furnace mutation that throws now rolls back the engine, not just one that is interrupted. A rollback that itself fails leaves a `pendingRepair` marker and keeps the original error.
292
+ - The unexported-drift guard expands collapsed untracked directories, so it stops false-positiving on any wholly-untracked directory. It and `status --unmanaged` now answer about the same tree.
362
293
 
363
294
  ### Release hygiene
364
295
 
365
- - The composed macOS bundle identity is now pinned end-to-end: `MOZ_MACBUNDLE_ID` (the leaf, from branding `configure.sh`) recomposed with `--with-distribution-id` (the prefix, from the generated mozconfig) must equal the configured `appId` exactly. Each half was already unit-tested after the 0.42.0 fix; nothing pinned the composition, which is the property that shipped broken as `org.hominis.org.hominis.browser`. Verified present in the 0.43.0 lineage.
296
+ - The composed macOS bundle identity is pinned end to end: `MOZ_MACBUNDLE_ID` plus `--with-distribution-id` must equal the configured `appId`.
366
297
 
367
298
  ## 0.42.0
368
299
 
369
- ### macOS bundle identity
370
-
371
- - `MOZ_MACBUNDLE_ID` in the generated branding `configure.sh` now carries only the LEAF segment of `appId`, and the generated mozconfig emits `ac_add_options --with-distribution-id=<appId prefix>` — upstream `toolkit/moz.configure` composes `CFBundleIdentifier` as `<distribution-id>.<MOZ_MACBUNDLE_ID>`, so writing the full reverse-domain `appId` double-prefixed the shipped bundle id (observed: `org.mozilla.org.hominis.browser`). A `configure.sh` carrying the full `appId` now reads as stale and is rewritten.
300
+ - `MOZ_MACBUNDLE_ID` carries only the leaf segment of `appId`, and the mozconfig emits `--with-distribution-id=<prefix>`. Upstream composes the two, so writing the full reverse-domain name double-prefixed the shipped bundle id.
372
301
 
373
302
  ## 0.41.0
374
303
 
375
304
  ### Release and build integrity
376
305
 
377
- - Release publishing commits version bumps before building and enforces a clean tree with `FIREFORGE_PACK_STRICT=1`.
378
- - Builds stamp `dist/build-info.json`; installed `--version` output includes the commit and dirty-content hash.
379
- - Build baselines retain deletion tombstones, so removed packageable files and `components.conf` entries cannot look fresh.
380
- - `test --build` uses a full build for new `jar.mn` registrations; mixed-harness `--build-only` packages all requested paths once.
381
- - `--extend-coverage` unions scoped packaging coverage only while HEAD, packageable inputs, and `mozconfig` remain unchanged.
382
- - Shared build and engine preflights now reject incomplete builds and unborn engine repositories consistently.
306
+ - Release publishing commits version bumps before building and enforces a clean tree.
307
+ - Builds stamp `dist/build-info.json`; `--version` includes the commit and dirty-content hash.
308
+ - Build baselines keep deletion tombstones, so removed files cannot look fresh.
309
+ - `test --build` uses a full build for new `jar.mn` registrations.
310
+ - `--extend-coverage` unions scoped coverage only while HEAD, packageable inputs, and mozconfig are unchanged.
311
+ - Shared build and engine preflights reject incomplete builds and unborn engine repositories consistently.
383
312
 
384
313
  ### Tests and verification trees
385
314
 
386
- - Tests emit exactly one final `FIREFORGE-VERDICT:` line with the correct pass/failure reason, including sharded, lock-timeout, crash, and inconclusive runs.
387
- - `tree exec` preserves the child verdict as the final stdout line; pre-spawn refusals emit no verdict.
388
- - Engine-integrity checks run before successful verdicts.
389
- - Browser preflight detects stale apps from the same objdir even after they release Marionette; `--kill-stale-marionette` can terminate them.
390
- - Mach commands use process groups and reap surviving browser or harness descendants.
391
- - `tree create --with-objdir` supports build-less tests after relocation and reconfiguration checks.
392
- - Tree creation refuses symlinked objdirs, cleans failed clones, validates markers, and handles unreadable locks fail-closed.
393
- - Tree freshness covers dirty engine content and patch bodies.
394
- - Tree creation skips unwanted `obj-*` directories before cloning and uses bounded concurrency.
395
- - `tree create --wait-lock` now matches its lock-refusal guidance.
396
- - Verification trees permit `re-export --dry-run`; mutating operations remain blocked.
397
- - Test assertion lint now recognizes real assertion calls, and critical modules have explicit coverage floors.
315
+ - Tests emit exactly one final `FIREFORGE-VERDICT:` line with the right reason, including sharded, lock-timeout, crash, and inconclusive runs.
316
+ - `tree exec` preserves the child verdict as the last stdout line.
317
+ - Browser preflight detects stale apps from the same objdir even after they release Marionette.
318
+ - Mach commands use process groups and reap surviving descendants.
319
+ - `tree create --with-objdir` supports build-less tests, with relocation and reconfiguration checks.
320
+ - Tree creation refuses symlinked objdirs, cleans failed clones, and handles unreadable locks fail-closed.
321
+ - Verification trees permit `re-export --dry-run`; mutating operations stay blocked.
398
322
 
399
323
  ### Status and diagnostics
400
324
 
401
- - Status classification reuses one patch context and classifies files through a bounded pool.
402
- - `status --json --summary` returns counts and offenders without the full file payload.
403
- - `status --json --include-ownership` adds ownership data without another scan.
404
- - JSON output drains fully through slow pipes, and machine-mode refusals route to stderr.
405
- - Multi-owner files remain conflicts even when Furnace-managed or generated branding paths.
406
- - Lock contention and unreadable/corrupt state now produce concise, accurate diagnostics.
325
+ - Status classification reuses one patch context and runs through a bounded pool.
326
+ - `status --json --summary` returns counts and offenders without the file payload; `--include-ownership` adds ownership without a second scan.
327
+ - JSON output drains fully through slow pipes, and machine-mode refusals go to stderr.
328
+ - Multi-owner files stay conflicts even when Furnace-managed or branding-generated.
407
329
 
408
330
  ### Patch, export, and lint workflow
409
331
 
410
- - `export-all --exclude-furnace` keeps its filtered scope after license-header repair.
411
- - `re-export --expect <path>` permits intended drift and reports paths skipped by earlier refusals as not evaluated.
412
- - Missing drift baselines fail closed, binary patches compare by content, and partial or fully refused re-exports exit non-zero.
413
- - `re-export --dry-run` uses before/after fingerprints and cannot roll back another completed write.
414
- - Re-export builds checkJs once per run and reuses the per-patch cache; `--patches` subsets narrow computation.
415
- - Re-export probes and purity hashing use bounded concurrency; patch writes remain serial.
416
- - Patch lint rejects new imported system modules without a projected `moz.build` registration.
417
- - The checkJs cache hashes both configurable shims.
418
- - Export placement refusals print projected errors and identify renumbering consequences.
332
+ - `export-all --exclude-furnace` keeps its scope after license-header repair.
333
+ - `re-export --expect <path>` permits intended drift and reports paths skipped by earlier refusals.
334
+ - Missing drift baselines fail closed, binary patches compare by content, and partial re-exports exit non-zero.
335
+ - Re-export builds checkJs once per run and reuses the per-patch cache.
336
+ - Patch lint rejects new imported system modules with no projected `moz.build` registration.
337
+ - Export placement refusals print projected errors and name renumbering consequences.
419
338
  - `patch rename` can recategorize and renumber atomically.
420
- - `patch move-files` and `patch split` use whole-queue projection lint, normalize names, and provide split guidance.
421
- - `patch move-files --create` names the required `--description` option.
422
- - Queue-mutating patch commands support `--wait-lock` and avoid unsafe stale-lock advice.
423
- - Staged-dependency declarations reject patch-name-shaped file arguments.
424
- - `CommandError` passes through the top-level handler without duplicate or JSON-corrupting output.
339
+ - `patch move-files` and `patch split` use whole-queue projection lint.
340
+ - Queue-mutating patch commands support `--wait-lock`.
425
341
 
426
342
  ### Furnace and validation
427
343
 
428
344
  - Furnace computes checksums once per apply and warns before overwriting divergent patch-owned files.
429
- - Furnace jsconfig synchronization includes `register: false` components.
430
345
  - Manifest-sync failures are visible, and repairs run under the Furnace lock.
431
346
  - `furnace validate --fix` changes only reported components and detects missing custom-element registrations.
432
347
  - Localization validation recognizes non-Latin text while exempting emoji and punctuation.
433
348
  - `furnace rename` uses single-pass, boundary-aware substitutions.
434
- - Rollback failures retain repair breadcrumbs for `doctor --repair-furnace`.
435
- - The reported no-change/apply-state disagreement was not reproducible and is regression-tested.
349
+ - Rollback failures leave repair breadcrumbs for `doctor --repair-furnace`.
436
350
 
437
351
  ### Safety, performance, and maintenance
438
352
 
439
353
  - Nano ID 3.3.18 resolves a high-severity audit finding.
440
- - Rebase loading distinguishes absent and corrupt sessions; `rebase --abort` can recover invalid state.
441
- - PID liveness treats `EPERM` as alive, preventing deletion through another user's lock.
442
- - The moz.build parser handles comments, quoted brackets/apostrophes, and unterminated lists.
443
- - Parser failures no longer silently certify validation success; internal invariant errors bypass fallback.
444
- - Toolchain probes, baseline fingerprints, cache hashes, and manifest reads use bounded concurrency.
445
- - Existing Git tuning remains enabled; fsmonitor stays disabled because of stale-daemon risk.
446
- - A persistent checkJs daemon remains deferred until safe invalidation is cheaper; see `docs/lint-daemon-design.md`.
447
- - Shared helpers replace duplicated preflights, error conversion, regex escaping, option parsing, object guards, and ESTree casts.
354
+ - Rebase loading distinguishes absent from corrupt sessions; `rebase --abort` can recover invalid state.
355
+ - PID liveness treats `EPERM` as alive.
356
+ - The moz.build parser handles comments, quoted brackets and apostrophes, and unterminated lists.
357
+ - Parser failures no longer silently certify validation success.
358
+ - Toolchain probes, baseline fingerprints, and manifest reads use bounded concurrency.
448
359
  - Four local ESLint rules prevent unsafe error casts, duplicate regex escaping, and empty JSDoc.
449
- - Oversized modules were split or simplified, silent degradations documented, and stale dead-code suppressions removed.
450
360
  - The release gate pins weak critical modules so aggregate coverage cannot hide local regressions.
451
361
 
452
362
  ## 0.40.0
453
363
 
454
- - **Dependencies: adopted all five open dependabot PRs (#48, #47, #45, #44, #42) after Go-based TypeScript 6 compatibility review** — the pinned `typescript ~6.0.0` (6.0.3 installed) constrained which upgrades were feasible; every bumped tool checked out: `typescript-eslint` declares peer `typescript >=4.8.4 <6.1.0`, `knip` carries no TypeScript dependency at all (oxc parser), and `dpdm` bundles its own nested TypeScript 5.9.3, so none touch the root compiler. Production: `acorn` 8.17.0→8.18.0 (lockfile-only) and `magic-string` 0.30.21→**1.1.0** — a major that only drops the CJS/UMD builds for pure ESM (FireForge is pure ESM; the five `MagicString` call sites use the unchanged core API). Dev: `@types/node` 26.1.2, `dpdm` 4.3.0, `eslint` 10.8.0, `eslint-plugin-jsdoc` 63.3.3, `knip` 6.29.0, `lint-staged` 17.3.0, `prettier` 3.9.6, `typescript-eslint` 8.66.0, `vite` 8.2.1, and `eslint-plugin-simple-import-sort` 13→**14** (breaking only for string-literal export names, which the codebase does not use); four of these resolved one patch/minor past PR #48's snapshot within the existing caret ranges (typescript-eslint 8.66.0 re-verified against the same `<6.1.0` peer window). CI: the SHA-pinned actions group (checkout 7.0.1 with `--unset`-escaping and branch-trim fixes, setup-node, codeql init/analyze). knip 6.29's stricter re-export tracking flagged seven type re-exports (`PatchCoverage`, `ClearablePatchMetadataField`, `PatchMetadataMutation{,Result}` in `patch-export.ts`; `ForwardImportEdge` in `patch-lint-reexports.ts`; `PatchManifestRowMutation{,Result}` in `patch-manifest.ts`) that no module, test, or public-API entry consumed via the barrel hop — a real 6.26 blind spot, so the dead re-exports were removed (the types stay exported from their defining modules). Verified by a full green `release:check`.
455
- - **New `fireforge tree` command: copy-on-write verification clones for concurrent read-mostly work** (feature — every consumer parallelisation attempt dead-ended on the ONE applied engine tree: `engine/` is gitignored by design so git worktrees provide no isolation, and the repo-root build lock plus one objdir kept all fix/test/export work serial; measured on APFS, `engine/browser` — 11,513 files, 156 MB — clones via clonefile in ~3 s): `tree create <name>` snapshots the project (fireforge.json, configs, src, patches, components, and `engine/` with `obj-*` excluded) into `.fireforge/trees/<name>` via filesystem CoW (APFS clonefile / btrfs-XFS reflink, probed BY DOING in the destination directory), taken under the primary engine-session lock so a mid-mutation state is never captured; filesystems without CoW REFUSE honestly, with `--force-copy` opting into a full physical copy explicitly (never silently). A tree is a full project root, so `status`, `lint`, `typecheck`, `verify`, `doctor`, and `export --dry-run` run inside it unmodified with their own per-tree locks — while every mutating command (`export`, `re-export`, `patch *`, `token *`, `furnace *`, `build`, headed `run`, …) is refused via the `.fireforge/tree.json` marker and a default-deny verdict table enforced in one commander preAction hook (a drift test forces every new command to be classified); patches and exports stay strictly serial in the primary tree with no merge-back model. `tree list [--json]` reports staleness against the primary engine HEAD and `patches.json` fingerprint; `tree remove` refuses while a live process holds a tree lock and path-contains its delete; `tree exec <name> -- <cmd>` runs a command inside a tree; Windows is refused (POSIX clonefile/reflink only). **Deferred to 0.41.0 with the design recorded: `--with-objdir` and in-tree `test`** — mach objdirs embed absolute `topsrcdir`/`topobjdir` paths (FireForge's own `mach-build-artifacts.ts` parses them), so a cloned objdir would silently operate against the primary tree; a `mozinfo.json` preflight is the planned gate. New invariant 7 in `docs/lifecycle-invariants.md`. Pinned by `tree-cow.test.ts` (per-platform cp argv, probe verdicts), `tree-store.integration.test.ts` (snapshot contents/exclusions, independence, staleness, live-holder refusal), `tree-guard.test.ts` (verdict drift gate), and `tree.integration.test.ts` (real clone + real-program guard refusal).
456
- - **Previously deferred items, re-adjudicated for 0.40.0**: auto-`caffeinate` for headed macOS dispatches stays deferred (the 0.39.0 F17 message + operator rule works; auto-wrap adds TTY/permission complexity for little gain); real co-ownership of one upstream file by two patches stays deferred (design-level — shared-file diffs are pristine-baseline so co-ownership remains unrepresentable; the consumer's single-owner consolidation plus audit-side per-patch entry lists stands); `furnace create` maintaining the consumer jsconfig path mapping for new widget modules stays deferred this release (the JSONC-preserving edit machinery exists, but the mapping shape is consumer-project-specific — revisit with a concrete config contract).
457
- - **Concurrent xpcshell runs no longer collide on Firefox's fixed profile directory** (bug — the harness defaults `XPCSHELL_TEST_PROFILE_DIR` to a fixed `$TMPDIR/firefox/xpcshellprofile`, so two overlapping `fireforge test` invocations corrupted each other's profiles; FireForge itself never set the variable): every harness invocation that can dispatch xpcshell (single runs, each shard, and generic `mach test` runs) now exports a fresh `mkdtemp` profile dir (`fireforge-xpcshell-profile-*`) into the mach environment via the new `withXpcshellProfileDir` helper and removes it after the run (best-effort — a cleanup failure warns and leaves the dir to the OS tmp reaper); the crash/symlink-repair retries within one invocation deliberately reuse the same directory, an operator-provided `XPCSHELL_TEST_PROFILE_DIR` is respected verbatim (and never deleted), and pure mochitest dispatches keep a byte-identical env. This is the independently-shippable half of the G15 verification-tree design — it also unblocks concurrent test runs from separate checkouts today. Pinned by uniqueness/opt-out/override/retry-reuse cases in `test-run-suite-dispatch.test.ts` and the lifecycle cases in `xpcshell-profile-dir.test.ts`.
458
- - **Per-patch lint can now typecheck patch-adopted test `.js` files against a consumer-configurable harness shim: `patchLint.checkJsTestFiles` + `patchLint.checkJsTestShim`** (enforcement gap — honest framing: the consumer's seven TS2339s on `TestUtils.waitForCondition` were invisible to `lint --per-patch --max-warnings 0` not because the shim differed from the jsconfig projects', but because test `.js` files were never in the checkJs program at all — only new `.sys.mjs` modules were; the harness global that does not exist should fail at the patch boundary where the test was authored): opt-in (`checkJsTestFiles` requires `checkJs`; new failure surface, nonzero compile cost) extends the check set to patch-owned `browser_*`/`test_*`/`xpcshell_*` and `/test/`-path `.js` files, each checked as its OWN small script-scope program (mochitest scripts share top-level scope only with their directory's `head*.js` helpers, which are included as roots) against the built-in Firefox shim, a new loose built-in test-harness shim (`TestUtils`, `BrowserTestUtils`, `add_task`, `ok`/`is`, …), and the consumer's `checkJsTestShim` `.d.ts` — whose typed declarations compose BEFORE the loose baseline and therefore win, so a typed `TestUtils` there is exactly what turns the consumer's case into an export-time error. Wired identically into `lint --per-patch` (one merged lazy build per run) and the export/re-export path so the two agree; `typecheck.projectOverrides` remains typecheck-only by design (loading real jsconfig projects into per-patch lint's empty-tree compiler host would pull the Firefox tree in). Pinned by the was-never-checked gap pin, the TS2339-surfaces case, and the loose-baseline pass in `lint-per-patch-checkjs.integration.test.ts`, plus config cross-field validation in `config.test.ts`.
459
- - **Patch size metrics are now on the public API — `countNonBinaryDiffLines`, `resolvePatchSizeTier`, and the new `getPatchSizeThresholds` — plus a machine-readable per-patch report via `lint --per-patch --report <path>`** (feature — the size counter `large-patch-lines` fires on was unreachable programmatically (`"./*": null` blocks deep imports and `src/index.ts` never exported it), so the consumer MIRRORED it to build waiver-freshness checks, a silent-divergence risk by construction): the three functions and the `PatchSizeTierDecision` type join the entry exports (pinned by `public-api.test.ts`), and the `--report` JSON (schemaVersion 1; requires `--per-patch`) carries per patch: filename, name, status (`linted`/`cached`/`skipped`), `lineCount` (non-binary diff lines), `filesAffected` count, the tier decision, the ACTIVE thresholds for that tier, the surfaced issues, and the lintIgnore-suppressed issues, plus run totals. Pinned by the report-shape assertions in `lint.integration.test.ts`.
460
- - **A size finding waived by `lintIgnore` no longer vanishes without a trace in per-patch mode** (observability — the waiver filter dropped the whole issue including the measurement embedded in its message, so a waived patch's CURRENT size could not be read back from the tool that enforces it and waiver-cited counts had to be calibrated by hand; aggregate mode printed a suppressed count while per-patch printed nothing): the finding stays suppressed (no exit-code or `--max-warnings` effect) but its measurement is reported as `NOTICE [large-patch-lines] <patch>: suppressed by lintIgnore — Patch is N lines (hard limit: M) …` (likewise `large-patch-files` / `file-too-large`), the run summary adds `Suppressed k issue(s) via per-patch lintIgnore.` mirroring the aggregate wording, and suppressed issues plus the measured line count are carried through the per-patch lint cache (schema bump to v2, one-time cold cache) so a warm run can never report less than a cold one — the same F5-class hazard the run-level checkJs fix closed. Also available structurally in the `--report` JSON. Pinned by the waived-measurement case in `lint.integration.test.ts` and the cache round-trip in `lint-cache.test.ts`.
461
- - **Smoke-run summaries now attribute allowlist hits per entry, with zero-hit entries flagged as removal candidates** (observability — `matchesAllowlist` returned a bare boolean and `compileAllowlistFromFile` discarded source line numbers, so an allowlist entry that silently stopped matching was undetectable and the consumer rebuilt per-entry replay in ~190 lines against a retained capture): compiled entries now carry their verbatim source and origin (`allow.txt:12`, `--console-allow #2` — file origins keep the ORIGINAL line numbers, comments included) via the new `CompiledAllowlistEntry` shape, `matchAllowlist` returns the first matching index (deterministic first-match attribution, replacing the boolean `matchesAllowlist`), and the post-run summary prints one `N× <origin> <source>` row per entry with `0×` rows suffixed `(never matched — candidate for removal)`. The three scalar counters and the exit contract (12/13) are unchanged; the human summary is the supported surface (no JSON sidecar this release). Pinned by the attribution rows in `run.test.ts` and the origin/first-match cases in `smoke-patterns.test.ts`.
462
- - **`lint --per-patch <patch…>` now works: positional arguments in per-patch mode select patches through the same alias resolution as `--patches`** (ergonomics — the natural invocation `lint --per-patch a.patch b.patch` was rejected with the misleading "cannot be combined with explicit engine file paths" while the suggested form required retyping the list behind a flag): positionals are appended after explicit `--patches` entries and resolved tolerantly (filename, stem, manifest name, category-prefixed slug, or bare slug); a non-matching positional fails loud with `No patch in the queue matches "…". In --per-patch mode, positional arguments and --patches select patches … drop --per-patch to lint engine paths.` plus the available-patches listing, so an engine path can never silently lint the wrong thing. The `--patches` help notes the positional equivalence. Pinned by the positional-subset and unmatched-positional cases in `lint.test.ts`.
463
- - **`patch staged-dependency --remove` now accepts `--file` + `--specifier` alone, inferring `--creates` from a unique match; missing-flag errors name the actually-missing flags** (ergonomics + message — the remove path demanded the full triple even when file+specifier identified exactly one entry, and every `Invalid Argument` printed `Argument: patch staged-dependency`, restating the command instead of the offending flag): a unique file+specifier(+`--owner`) match infers its `creates` with an `Inferred --creates …` notice; an ambiguous match refuses listing every candidate (`pass --creates to pick one`); no match keeps the honest exit-0 "no staged forward-import matched" summary. `--kind registration` gets the identical treatment keyed on `--line`. All `Argument:` lines in this command now name flags (`--specifier, --creates`, `--kind`, `--add/--remove/--clear`, …), and the `-y/--yes` help no longer claims a confirmation prompt exists (message-only — the command never prompted; the flag records scripted consent in the history log). Pinned by the inference, ambiguity, no-match, and attribution cases in `patch-tier-and-lint-ignore.integration.test.ts`.
464
- - **`token add` can no longer silently strand or skip a token when its category is wrong — every bypass around the missing-category refusal is closed** (bug + claim partially REFUTED — the reported plain-path "missing category exits 0" is contradicted by the pre-existing `assertTokenCategoryExists` throw, pinned since before 0.39.0; the 19 observed silent no-ops reproduce only through three real bypasses, all fixed): (1) category banners now match EXACTLY (`/* = Name = */` text equality, multi-line block-line equality) instead of by substring, so a TOC comment mentioning "Colors — Terminal" or a `Colors — Canvas` banner can no longer satisfy a lookup for a different name — assert and writer share the matcher (new `token-category.ts`); (2) the whole-file idempotency check is replaced by a per-category one — a token already in the TARGET category skips with its location named (`already exists in category "X" (line N)`), while a token declared elsewhere in the base `:root` block REFUSES with `Token "…" is already declared outside category "…" (<file>:<line>, section "…")` instead of silently skipping and discarding `--create-category`; dark-`@media`/variant companion blocks are excluded from ownership; the exact name must match (`--foo` no longer collides with `--x-foo`); (3) `--variant` now validates `--category` against the file instead of silently discarding it, and `--variant` + `--create-category` is refused. Dry-run agrees with the real run on all three. Behavior change: loose banner matches that previously "worked" now fail loud with the existing available-categories listing. Pinned by the bypass matrix in `token-manager.test.ts`.
465
- - **Patch-lint `raw-color-value` no longer fires on hex colors inside CSS comments that span the context/added-line boundary** (bug — the per-patch comment stripper only saw the joined added lines, so a `/*` opened on an unchanged context line never suppressed a hex mention on an added line; two prose references to a vendored sheet's black paint inside comments tripped the rule for the consumer on 2026-07-30): the diff branch now masks comments across the full post-patch file (spaces preserve line numbers; an unclosed trailing `/*` masks to EOF) via the new `extractAddedLineNumbersPerFile` diff walker, then scans only the patch's added line numbers. Inline `fireforge-ignore: raw-color-value` waivers keep matching against pre-strip text; the no-diff branch is unchanged; and when the diff's line numbers fall outside the on-disk file (drifted patch) the scan falls back to the previous joined-added-lines behavior instead of misattributing lines. Pinned by the boundary-spanning, still-fails-in-declaration, waiver, and drift-fallback cases in `patch-lint.test.ts`.
466
- - **`export --name ui-foo --category ui` now lands manifest `name: "foo"` in one step, and `--name <stem>.patch` no longer trips the dot validator** (bug + message — the filename slug pipeline has stripped redundant category prefixes since 0.33.0 (and `.patch` suffixes since 0.39.0's F9) but the manifest display name kept the raw flag value, so a bare-filename-slug naming policy required a follow-up `patch rename --to` after every export): display names are normalized after category resolution with the same require-the-category-token rule as filenames via the new `normalizePatchDisplayName` (`NNN-ui-` / `ui-` prefixes stripped case-insensitively and repeatedly; a bare leading number is never stripped, so `2-step-verification` survives; the strip falls back to the stem rather than emptying the name), announced with `Patch name normalized: "ui-foo" → "foo" (the filename carries the order and category prefix).`; a trailing `.patch` is stripped before `validatePatchName` runs. Applies to `export` and `export-all`. `patch rename` additionally gains `-d` as an alias for `--description`; multi-target `re-export --scan --scan-file` remains deferred (file→patch assignment would be ambiguous — `--scan-files <manifest>` already serves bulk assignment). Pinned in `export-shared.test.ts` and `validation.test.ts`.
467
- - **New `status --check` (and `--fail-on <class,...>`) turns classification drift into a CI-enforceable non-zero exit** (enforcement gap — every 0.39.0 status variant exited 0 even while reporting unmanaged/drifted/conflicted files, so "refuse if dirty" required every consumer to parse `--json` and re-implement the classification policy; combined with the G2 gap this let two manifest-registered proving suites ship into no patch while every advertised gate stayed green): `--check` fails with exit 1 whenever any `unmanaged`, `patch-owned-drift`, or `conflict` file exists, naming each offending classification with its first three paths (`status --check failed: 2 unmanaged (a, b), 1 patch-owned-drift (c). …`); `--fail-on` replaces the policy set (implying `--check`, refusing unknown values by naming the valid six); composes with the default view and `--json` (the JSON stays parseable on stdout, the refusal routes to stderr) and is refused alongside `--raw`/`--unmanaged`/`--ownership`/`--test-coverage`. A clean tree passes. Pinned by `status-check.test.ts` and the `--check` cases in `status.test.ts`.
468
- - **`status --json` files[] entries now carry a `patch` field naming the owning patch filename (`null` when unowned)** (feature — the classifier resolved every file's owner at classify time and then discarded it for non-conflict entries, so resolving 16 drifted files to their patches meant scraping the human `--ownership` box table with `awk`): single-owner `patch-backed`, `patch-owned-drift`, and healthy single-owner `furnace` entries name their owner; `branding`, unowned `furnace`, and `conflict` entries report `null` (conflicts keep the existing `claimedBy` array — a single-owner field must not misrepresent a multi-claim). Additive to `schemaVersion: 1`, so consumers pinning the schema keep parsing; the post-slice re-export loop is now scriptable straight off `--json`. Pinned by the owner-matrix case in `status.test.ts`.
469
- - **Scan-less `re-export`'s adjacency notice is now classification-aware, and the new `--refuse-adjacent-unmanaged` flag upgrades it to a non-zero refusal that skips the write** (bug + message — the claim that 0.39.0 was fully silent is partially REFUTED: the v0.27.2 sibling advisory did fire for the reported shape, a new test created beside a patch's owned tests, and is pinned by the pre-fix reproduction in `re-export.integration.test.ts`; but it counted tool-managed paths as "unowned siblings" and offered no enforcement). The notice now names only genuinely unmanaged adjacent files — generated branding paths and furnace-managed prefixes are excluded, while a brand-new unowned file under the branding root stays listed (the Assets.car precedent) — with the wording `found N unmanaged file(s) adjacent to this patch's ownership (<first 3>)`. Under `--refuse-adjacent-unmanaged` (scan-less path only; refused alongside `--scan`/`--files`) each offending patch is skipped unwritten and the run fails with one refusal naming every offender, so CI cannot ship a patch refresh past an unadopted file. Residual blind spot documented in-test: a new file in a cousin directory no owned file lives in is still unreported (recursive scans are too noisy on Firefox-sized trees). Pinned in `re-export.test.ts` (branding/furnace exclusion, skip-and-refuse) and `re-export.integration.test.ts`.
470
- - **`re-export` now retries once, after a 300 ms backoff, when a patch fails on transient git `index.lock` contention** (bug — an external git process touching `engine/.git/index.lock` during the binary-staging `git add -N`/`git diff --binary` window permanently failed that patch while the run reported `Re-exported 1 of 2` and exited 0; the identical immediate rerun succeeded with no hand cleanup, which is exactly the shape an internal retry should absorb): lock-class failures are detected via the new `isGitIndexLockConflict` heuristic in `errors/git.ts` (a `GitIndexLockError`, or a `GitError` whose message pairs `index.lock` with lock phrasing), the retry re-enters the per-patch export from the top so it re-reads clean state, and a second failure keeps the honest `N of M` summary. Non-lock failures never retry. Partial-failure exit code is intentionally unchanged this release; a non-zero exit (or `--strict`) for partial runs is the recommended follow-up. Pinned by the retry, double-failure, partial-success, and no-retry cases in `re-export.test.ts` and the heuristic table in `git-index-lock.test.ts`.
471
- - **`export --before/--after` no longer refuses placements whose renumber never actually reaches a reserved range** (bug — `computePlacementPlan` shifted every patch at or above the insertion point by +1, so any positional insert below a `patchPolicy.reservedRanges` block dragged the reserved patches into the rename map and tripped the up-front refusal even with free ordinals in between; in a ~230-patch tree with a 900-999 reserved block, `--before`/`--after` were effectively unavailable and every insert needed an explicit `--order`): the +1 cascade now stops at the first unoccupied ordinal — a gap absorbs the insert, so the tail beyond it (including any reserved block) keeps its numbering — while a genuinely contiguous run into the block still refuses with the same single up-front error. The same cascade serves `patch split` and `patch move-files --create` placements. Pinned by the stop-at-gap and contiguous-refusal cases in `export-placement-policy.test.ts` and the rewritten sparse-queue case in `export-flow.test.ts`.
364
+ - Adopted all five open dependabot PRs after a TypeScript 6 compatibility review. Production: `acorn` 8.18.0 and `magic-string` 1.1.0 (a major that only drops the CJS/UMD builds). Dev tooling and SHA-pinned actions bumped alongside. knip 6.29's stricter re-export tracking found seven dead type re-exports, now removed.
365
+ - New `fireforge tree`: copy-on-write verification clones for concurrent read-only work. `tree create <name>` snapshots the project into `.fireforge/trees/<name>` via APFS clonefile or btrfs/XFS reflink, taken under the engine session lock. Read-only commands run inside a tree unmodified; every mutating command is refused through a default-deny table. Filesystems without CoW refuse honestly, with `--force-copy` as the explicit opt-in. `tree list`, `tree remove`, and `tree exec` round it out. Windows is not supported.
366
+ - Concurrent xpcshell runs no longer collide on Firefox's fixed profile directory. Every dispatch that can run xpcshell exports a fresh `mkdtemp` profile dir and removes it afterwards. An operator-supplied `XPCSHELL_TEST_PROFILE_DIR` is respected and never deleted.
367
+ - Per-patch lint can typecheck patch-adopted test `.js` files through `patchLint.checkJsTestFiles` and `patchLint.checkJsTestShim`. Test files were never in the checkJs program at all before, so a harness global that does not exist could not fail at the patch boundary.
368
+ - Patch size metrics are on the public API (`countNonBinaryDiffLines`, `resolvePatchSizeTier`, `getPatchSizeThresholds`), and `lint --per-patch --report <path>` writes a machine-readable per-patch report.
369
+ - A size finding waived by `lintIgnore` still reports its measurement as a NOTICE, so a waived patch's current size can be read back from the tool that enforces it.
370
+ - Smoke-run summaries attribute allowlist hits per entry and flag zero-hit entries as removal candidates.
371
+ - `lint --per-patch <patch…>` accepts positional arguments through the same alias resolution as `--patches`.
372
+ - `patch staged-dependency --remove` accepts `--file` plus `--specifier` and infers `--creates` from a unique match. Error messages name the missing flag instead of restating the command.
373
+ - `token add` can no longer strand or skip a token when its category is wrong. Category banners match exactly, idempotency is checked per category, and `--variant` validates `--category` instead of discarding it.
374
+ - Patch-lint `raw-color-value` no longer fires on hex colors inside comments that open on a context line and close on an added one.
375
+ - `export --name ui-foo --category ui` lands manifest `name: "foo"` in one step, and a `.patch` suffix no longer trips the dot validator.
376
+ - New `status --check` and `--fail-on <class,…>` turn classification drift into a non-zero exit, so CI no longer has to parse `--json` and reimplement the rules.
377
+ - `status --json` file entries carry a `patch` field naming the owning patch.
378
+ - The scan-less `re-export` adjacency notice is classification-aware, and `--refuse-adjacent-unmanaged` upgrades it to a refusal that skips the write.
379
+ - `re-export` retries once after 300 ms on transient git `index.lock` contention.
380
+ - `export --before/--after` no longer refuses placements whose renumber never reaches a reserved range.
472
381
 
473
382
  ## 0.39.0
474
383
 
475
- - **`fireforge discard` restores patch-claimed paths to their PATCH-APPLIED baseline instead of pristine upstream, and re-materializes patch-created files instead of deleting them** (P0 data-loss bug — 15 `patch-backed`/`owned` engine files discarded with `--yes` came back as upstream Firefox artwork, `jar.mn` lost all 134 fork entries, and patch-created files like `hominis-start.html` were deleted outright, all under the message "File restored to original state"; recovery cost a full `reset` + 207-patch `import` + rebuild): discard now consults the manifest's ownership claims per status entry and restores claimed files by folding HEAD content through every affecting patch in order (`computePatchedContent`), leaving the post-`import` worktree convention intact (patch edits stay unstaged ` M`, patch creations stay untracked `??`); patch-deleted files are removed to match the baseline. The legacy semantics are an explicit opt-in via the new `--to-upstream` flag. Success messages name the baseline (`File restored to patch baseline (0001-ui-edit.patch)`, `File re-materialized from patch baseline (…)`, `File restored to pristine upstream (HEAD)`); directory mode applies the same rule per file and its outro buckets the outcomes (`3 file(s) restored: 1 to patch baseline, 1 re-materialized, 1 to upstream state`); dry-run lines annotate each path with its restore target. A corrupt manifest or an unreconstructible baseline REFUSES (naming `--to-upstream`) rather than silently reverting to HEAD; multi-owner (conflict) claims restore the cumulative baseline with a warning pointing at `status --ownership`. Pinned by `discard-patch-baseline.integration.test.ts` (the exact handoff shape: edit-patch + create-patch, dirty both, discard each, assert patch content and git state) and `discard-baseline.test.ts`.
476
- - **New per-patch lint check `mozbuild-unsorted-list` catches unsorted `EXTRA_JS_MODULES` (and sibling strict-ordered moz.build lists) before any build is dispatched** (feature — three friction-log occurrences where an unsorted insertion surfaced only as `mozbuild.util.UnsortedError … expected "X" but got "Y"` at configure time, each costing a full multi-minute build cycle): the check reads each patch-touched `moz.build` from the engine tree and reports the exact expected/got pair mozbuild would raise (`moz.build list EXTRA_JS_MODULES is not alphabetically sorted: expected "X" but got "Y" (mozbuild will fail at configure time with UnsortedError)`), using mozbuild's case-insensitive ordering. Covered variables: `EXTRA_COMPONENTS`, `EXTRA_JS_MODULES`, `EXTRA_PP_COMPONENTS`, `EXPORTS`, `PYTHON_UNITTEST_MANIFESTS`, `TESTING_JS_MODULES`, `XPIDL_SOURCES`, including dotted namespaces (`EXTRA_JS_MODULES.<subdir>`); `DIRS` is deliberately excluded (order-meaningful). Severity error; waivable per patch via `lintIgnore: ["mozbuild-unsorted-list"]`; participates in the per-patch lint cache like every other rule.
477
- - **Forward-import detection now catches a bare getter-property line added to an existing `defineESModuleGetters` map, and every refusal names the exact `patch staged-dependency` invocation to paste** (bug + message — a patch inserting `Foo: "resource://gre/modules/Foo.sys.mjs",` into a PRE-EXISTING getter object carried no `defineESModuleGetters(` opener in its added lines, so the export gate missed it and the refusal surfaced one consumer at a time downstream): added-lines-only content is additionally scanned for `Name: "resource://…"` property lines (resource/chrome/moz-src schemes, forward-importable extensions only; deduped against the existing extractors so a patch adding the whole call reports once; the `// fireforge-ignore: forward-import` marker and staged-dependency suppression apply unchanged). The `forward-import` message now embeds the exact per-owner remedy — `fireforge patch staged-dependency <patch> --add --file <site> --specifier "<url>" --creates <path> --owner <patch>` — with the fingerprint unchanged. The "reports only the first offender" half of the field report did NOT reproduce on 0.38.0 code: the rule already emits one issue per site and the export gate returns all details (pinned by a two-site test); the observed one-at-a-time behavior was an artifact of the getter shapes being missed.
478
- - **`patch move-files <from> <to>` into an EXISTING patch is now a real transaction** (feature — the preview-only flow printed two `re-export --files` commands in the WRONG order (grow first) and without the `--allow-shrink`/`--yes` flags the real execution then demanded, costing three refusals per move): the two-positional form now performs the shrink→grow as one operation under the patch-directory lock with rollback, mirroring `patch split`/`--create`: both bodies are regenerated from the worktree, per-patch linted with each patch's own `lintIgnore`/`tier`, cross-patch linted as a projection (only regressions the move itself introduces refuse; `--force-unsafe` overrides), staged-dependency owners re-point to the target, and forward edges the move introduces are auto-declared. `--dry-run` previews without writing; `-y/--yes` covers non-TTY. The equivalent manual `re-export` commands are still printed — now in EXECUTABLE order (shrink first, flags included) with a note that the shrink must land first.
479
- - **`lint --per-patch --max-warnings 0` on the cache-hit path: claim REFUTED; two warm-run gaps hardened** (— the reported 0-exit-despite-warnings was observed once on a 0.37-line host and does not reproduce on 0.38.0+: cached issues flow through the same `--max-warnings` comparison as fresh ones, pinned since 0.38.0 by the "cached warnings still fail per-patch lint" test; the cache key hashes every affected engine file plus the engine HEAD SHA, so an engine-side revert that would empty a patch's diff always misses the cache — now pinned explicitly). One REAL warm-run gap is fixed: an all-cache-hit run previously dropped run-level checkJs errors entirely (e.g. "typescript not installed", unreadable extra shim), letting a warm run report fewer errors than a cold one; global checkJs findings are now emitted whenever checkJs is configured, building the queue-wide program lazily on demand.
480
- - **`patch lint-ignore --add` warns that the waiver is subject to the project's patch-policy review process** (message-only — a size-only waiver turned per-patch lint green and then failed a downstream policy audit as `lint-ignore-unreviewed`, a discovery costing a full multi-minute gate run): every `--add` that lands a new check id (including `--dry-run`) prints: "This lint waiver is subject to the project's patch-policy review process. Downstream policy audits that mirror lintIgnore lists in a reviewed allow-map will refuse this patch until the map is updated to include the new ID(s)." No warning on `--remove`, `--clear`, or a no-op add. (Per the upstream adjudication, the audit demanding the patch's WHOLE lintIgnore list in the review map remains intentional; this is the write-time warning only.)
481
- - **The mixed-harness refusal runs BEFORE the pre-test build** (inefficiency — `fireforge test <xpcshell file> <browser file> --build` spent the full build before reporting "cannot run xpcshell and browser/mochitest paths in the same mach invocation"): harness classification is a pure path→manifest lookup and now runs immediately after path normalization, ahead of the `--build` dispatch and the stale/coverage gates; a mixed request refuses in milliseconds and `runProtectedMachBuild` is never invoked (pinned by test). Stale/coverage refusals still take precedence over missing-path errors.
482
- - **`token add --mode override` also writes existing `:root[data-theme="dark"]` / `:root[data-theme="light"]` blocks** (bug — an override add wrote `:root` and the `@media (prefers-color-scheme: dark)` block but not the explicit theme-attribute blocks, so on scaffolds requiring identical token sets across all themed lists every override add was a guaranteed half-finished edit failing the downstream invariants audit): when the tokens CSS carries top-level `:root[data-theme="dark"]`/`:root[data-theme="light"]` blocks, the dark value is mirrored into the dark block and the base value into the light block (idempotent per block; files without those blocks are unchanged), with an info line naming the blocks written. The report's claim that the FireForge-generated file header documents the CLI "cannot write" those blocks did not match this repo — no such text exists in FireForge's scaffold (it is consumer-authored); nothing to drop here.
483
- - **A `.patch` extension on a patch-name argument no longer double-suffixes, and `patch rename` explains its no-ops** (bug + partial refutation — `patch move-files <from> 348-ui-editor-panels.patch --create` produced `348-ui-editor-panels-patch.patch`, and the repair `rename --to 348-ui-editor-panels.patch` reported "Patch rename (no-op)"): `patchNameSlug` now strips one trailing `.patch` (case-insensitive) before sanitisation, converging `export --name`, `patch split --name`, `move-files --create`, and `rename --to` — so the double-suffix never forms and the repair rename is a REAL rename. A true no-op now says why (`nothing to change — filename already "…" (slug of "X" is "Y"), name already "…"`). The claimed positional `<from> <to>` usage string on `rename` was REFUTED: registration is `rename <name>` with a required `--to` flag on 0.38.0.
484
- - **xpcshell-only runs skip the Marionette port preflight and the mochitest client flags** (bug — `fireforge test` probed port 2828 even for xpcshell path sets, so a developer's interactive browser holding the port killed xpcshell runs, and `--marionette-port <n>` alone then failed the dispatch because the mochitest-only client flags were forwarded to mach; the only working incantation was the `--mach-arg --flavor=xpcshell` crutch): when manifest classification shows every requested path is xpcshell, the stale-port preflight is skipped (with an explanatory line when `--marionette-port`/`--kill-stale-marionette` were passed) and neither `--setpref=marionette.port` nor `--marionette=127.0.0.1:<n>` is forwarded — the crutch is unnecessary. Pathless runs and `--doctor` (whose probe launches a Marionette browser regardless) keep the preflight.
485
- - **New read-only coverage reporter: `fireforge status --test-coverage`** (feature — with concurrent sessions sharing one engine tree, the only way to learn the current recorded test-packaging coverage was to trip the out-of-coverage refusal on a real run): prints the last build baseline's coverage (`full`, or the scoped path list with the refusal implication), `builtAt`, `binaryName`, engine HEAD, and the new `recordedBy` field (`fireforge build`, `fireforge build --ui`, `fireforge test --build <paths>`) that 0.39.0 baselines now record — older baselines render `Recorded by: unknown` and `full (implicit — baseline predates coverage recording)`. Needs only the project root (no engine/git); mutually exclusive with the other status modes. Per the upstream adjudication, coverage REPLACES — no union model; this is strictly a reporter.
486
- - **`typecheck` and the checkJs lint pass no longer silently suppress undefined free identifiers (TS2304/TS2552)** (bug — a deployed module referencing `EditorState.create(...)` with no import typechecked as "0 errors across all three projects" because the codes were unconditionally suppressed as globals-shim noise; the failure surfaced as a runtime `ReferenceError` manifesting as a hung suite): both flows now report undefined identifiers at a configurable severity — default `warning` (visible without breaking gates; blocking under `--max-warnings 0`), `error` to make them hard failures, `off` to restore the historical suppression — via `typecheck.undefinedIdentifiers` and `patchLint.undefinedIdentifiers` in fireforge.json. Shim-covered Firefox globals stay clean; reported diagnostics append a hint naming the extra-shim and config remedies. The other module-resolution suppressions (TS2307/2306/2305/2792/2580/7016) are unchanged.
487
- - **`reset` and `import` warn at completion when `components.conf` diverged from the last full build** (message — after a `reset` + `import` revert train, the next gate run's unit sweep refused on the stale compiled StaticComponents table one full rebuild too late): both commands now run the existing static-components probe after a successful mutation and print: "components.conf changed relative to the last full \"fireforge build\": <paths>. The compiled StaticComponents table in obj-\* no longer matches the tree, so the next \"fireforge test\" will require a full \"fireforge build\" first — a scoped \"fireforge test --build\" cannot regenerate the compiled table." Advisory only (never throws, silent without a baseline); dry-run/cancelled/failed paths skip it.
488
- - **The forward-import "Closest legal ordinal" hint is suppressed when no legal ordinal exists in the importing patch's category range** (message bug — the hint suggested ordinal 388 for a dependency that no reorder could satisfy, sending the operator into a `patch reorder` the category-range policy then refused): when `patchPolicy` is configured and the computed ordinal falls outside the importing patch's category range, the sentence becomes "No legal ordinal in the <category> category range (<from-to>) lands after the creating patch(es); declaring the staged dependency is the recommended remedy." Fingerprints are unchanged across both variants; without a policy the hint prints as before.
489
- - **`export` never offers to prepend the project license header onto a vendored third-party file** (bug — the header auto-fix offered EUPL-1.2 headers for three byte-identity-pinned MIT xterm bundles; accepting (or `-y`) would have mislicensed upstream code): new-file candidates carrying a recognized third-party permissive banner (MIT, ISC, BSD-2/3-Clause, Apache-2.0 — by license text markers or a bare `SPDX-License-Identifier`) are excluded from the offer and the dry-run fix list and reported separately: "N new file(s) carry a third-party permissive license banner and were left untouched (vendored); if the missing-license-header lint check flags them, waive it with --lint-ignore missing-license-header". The lint check itself still fires — the reviewed waiver (expressible at creation since 0.38.0's `export --lint-ignore`) remains the sanctioned path.
490
- - **Signal-shaped configure/build exits are labeled as external interruptions** (— the reported `--headless`→configure exit-144 mechanism was REFUTED: `--headless` only ever lands in mach TEST args and `mach configure` receives no environment from the test path, so no plausible interaction exists): configure and build failures with exit codes in the 128+N signal convention now append e.g. "Exit 144 is signal-shaped (144 - 128 = 16, SIGURG on this host): the process was likely interrupted externally (OOM-killer, terminal disconnect, display/session teardown) rather than failing on its own, and the log may be truncated mid-step", using the host's real signal table — so the next occurrence is self-describing.
491
- - **Headed macOS no-output timeouts hint at display sleep and `caffeinate`** (message-only — a headed single-file run with no `caffeinate` wrapper timed out at 370 s before the first TEST-START; every subsequent `caffeinate -dimsu`-wrapped run passed): when a harness crash with the no-output-timeout shape came from a HEADED run on macOS, the failure message appends: "Hint: this was a HEADED run on macOS that died at the no-output timeout. A common cause is the display going to sleep mid-run, which freezes the headed browser before any test starts. Wrap headed runs in `caffeinate -dimsu <command>` to keep the display awake, or pass --headless." Auto-wrapping dispatches in `caffeinate` was evaluated and DEFERRED — the message half is the clearly-correct, cheap half.
384
+ - `fireforge discard` restores patch-claimed paths to their patch-applied baseline and re-materializes patch-created files. It used to return upstream Firefox artwork and drop every fork line from `jar.mn`.
385
+ - New per-patch check `mozbuild-unsorted-list` catches unsorted `EXTRA_JS_MODULES` before any build is dispatched.
386
+ - Forward-import detection catches a bare getter line added to an existing `defineESModuleGetters` map, and every refusal names the `patch staged-dependency` command to paste.
387
+ - `patch move-files <from> <to>` into an existing patch is a real transaction rather than a printed preview with the two commands in the wrong order.
388
+ - `patch lint-ignore --add` warns that the waiver is subject to the project's patch-policy review.
389
+ - The mixed-harness refusal runs before the pre-test build instead of after it.
390
+ - `token add --mode override` also writes existing `:root[data-theme="dark"]` and `:root[data-theme="light"]` blocks.
391
+ - A `.patch` extension on a patch-name argument no longer double-suffixes, and `patch rename` explains its no-ops.
392
+ - xpcshell-only runs skip the Marionette port preflight and the mochitest client flags.
393
+ - New `fireforge status --test-coverage` reports the last build baseline's packaging coverage.
394
+ - `typecheck` and the checkJs pass no longer suppress undefined free identifiers (TS2304/TS2552).
395
+ - `reset` and `import` warn when `components.conf` diverged from the last full build.
396
+ - The forward-import "closest legal ordinal" hint is suppressed when no legal ordinal exists.
397
+ - `export` never offers to prepend the project license header onto a vendored third-party file.
398
+ - Signal-shaped configure and build exits are labeled as external interruptions.
399
+ - Headed macOS no-output timeouts hint at display sleep.
492
400
 
493
401
  ## 0.38.0
494
402
 
495
- - **`status --ownership` reports a furnace-deployed file whose owning patch body went stale as drift** (friction-log item 1, bug — after `furnace deploy` refreshed 12 deployed widget copies, the pre-export "consult ownership" step read clean while the composed gate caught the staleness later on the re-export path): the classifier no longer short-circuits every furnace-prefixed path into the `furnace` bucket — a furnace path claimed by exactly ONE patch now runs the same expected-content comparison as any patch-owned file (`computePatchedContent` vs the deployed copy, deletion and error branches included), classifying as `patch-owned-drift` on divergence and staying `furnace` on a match. Multi-owner and unowned furnace paths keep the old bucket (cross-patch conflicts are already surfaced independently), so the only behavioral change is that stale widget patches now show as drift in the table and in `--json` (machine consumers see those paths move from `"furnace"` to `"patch-owned-drift"`). An integration test pins the exact observed sequence: deploy, export the patch, edit the component source, deploy again, and `status --ownership` reports the owning patch drifted.
496
- - **Scoped `test --build` and build-less runs refuse a `components.conf` changed since the last FULL build** (friction-log item 2, bug — a new about: module packaged fine under a scoped `test --build` but the child process resolved `NS_ERROR_MALFORMED_URI`, because `components.conf` entries bake into the compiled StaticComponents table that only a full build regenerates; the failure was silent and read as a test bug): the baseline gains `staticComponentsBaseline` (`engineHeadSha` + per-manifest content fingerprints), written fresh only by full-coverage builds and carried forward verbatim through scoped `test --build` writes — so the guard always anchors to the last full build, not the last scoped one. Any scoped `test --build` or non-`--build` run over a changed `components.conf` is refused up front with a message naming the manifest(s), explaining the compiled-table mechanism and the misleading error shape, and advising `fireforge build`; `--allow-stale-build` deliberately does NOT bypass it (it accepts stale packaged content, not a stale compiled registration), while the new `--allow-stale-components` downgrades the refusal to a warning for out-of-band rebuilds. Detection keys on the `components.conf` basename (`isXpcomManifestPath` is the single documented extension point; parsing `moz.build` `XPCOM_MANIFESTS` wiring is out of scope), pre-0.38.0 baselines lack the anchor and degrade to fresh, and probe failures never block a run.
497
- - **Per-patch size lint agrees with `wc -l` and fires only ABOVE the soft limit** (friction-log item 3, lint bug — three independent recurrences: a file counted one line more than `wc -l` from patch-hunk trailing-newline accounting, and `file-too-large` fired at "750 lines (soft limit: 750)" while a wc-1499 patch reported 1500/1500, each costing 1–2 trim/re-export/lint cycles): line counting now drops the phantom element a trailing newline adds (`countContentLines` for on-disk content, the same correction inside `countNonBinaryDiffLines` for patch bodies, with binary-hunk subtraction kept consistent), and every tier of `file-too-large` and `large-patch-lines` compares strictly (`>`), matching the already-strict `large-patch-files`, so a file or patch sitting exactly AT a notice/soft/hard limit is clean. Boundary tests pin limit−1 / limit / limit+1 for both rules across the general, test, and branding tiers, plus the trailing-newline agreement cases.
498
- - **Patch-lint accepts both established upstream MPL header wraps** (friction-log item 4, lint — derived files keeping the genuine upstream header of files like `ext-browser.js`, which wrap after "file," instead of the canonical break after "this", were flagged `missing EUPL-1.2 license header`): the upstream-MPL acceptance in `hasAnyLicenseHeader`/`hasUpstreamMplBlockHeader` keeps its exact-prefix fast path and adds a whitespace-normalized fallback — the leading comment span (line-comment run, first block comment, or hash run; anything else never matches) is collapsed to single spaces and must still begin with the full canonical MPL sentence including the URL — so any line-break position within the verbatim text passes while altered wording still fails. Both wraps are tested on JS and CSS comment styles, with and without leading editor directives.
499
- - **The packaged-test coverage guard accepts same-manifest siblings instead of demanding the exact recorded path set** (friction-log item 5, test inefficiency — a run scoped to `…/hominis-webext/file_A.js` refused `file_B.js` of the SAME manifest, forcing a ~1–2 min repackage per alternation): coverage is now matched at manifest granularity — mozbuild manifests are per-directory and mach stages support-files per manifest, so a request whose manifest granule (a file's containing directory; a directory itself) equals a covered entry's granule is accepted, as are subset paths of a covered directory; genuinely uncovered manifests keep the existing refusal verbatim, and the recorded baseline format is unchanged so 0.37.0 and 0.38.0 baselines interoperate both ways. The entry's second suggestion — unioning covered sets across successive scoped builds to stop concurrent-session ping-pong — was evaluated and rejected as UNSOUND: every baseline write refreshes content fingerprints for ALL dirty packageable paths, so a union would whitewash an earlier scope's edited fixtures while `obj-*/_tests/` still holds that scope's stale staging — precisely the silent-hang class the guard exists to refuse. The verdict is recorded on `testPackagingCoverage`'s doc contract; coverage REPLACES.
500
- - **Positional inserts refuse with ONE actionable error when they would renumber a reserved range, `patch move-files --create --order <n>` bootstraps a split in one step, and `re-export --scan` refuses forward-import adoptions** (friction-log item 6, patch-tooling ergonomics, three parts): (a) `patch split --before` / positional `export` on a dense queue that would shift patches through a `patchPolicy.reservedRanges` block no longer sprays per-patch reserved-range findings — the placement plan is gated up front (`assertPlacementAvoidsReservedRanges`, subsuming the old inside-range check) with a single error naming the range and computing the remedy: `pass --order <first free number below the reserved block>`, or explicit guidance when no free order exists. (b) `patch move-files` gains `--create --order <n>`: creating the target patch and moving the files is one transaction through the split pipeline (same placement gate, projected cross-patch lint, and staged-dependency owner repointing the manual shrink-then-export dance needed by hand), while the flag-less preview behavior is unchanged. (c) `re-export --scan`/`--scan-file`/`--scan-files` run the forward-import check per adoption candidate BEFORE adopting: a candidate importing a module created by a LATER patch is refused with the later patch and closest legal ordinal named, advising `export --order` / `patch split --order` / `patch staged-dependency --add`; declared staged dependencies and lint-ignores are honored because the gate runs the real cross-patch rule on a projected queue.
501
- - **Engine-mutating commands can wait for the engine lock instead of failing fast** (friction-log item 7, missing-feature — two sessions on one engine tree had operators hand-rolling `for i in $(seq 1 60); do CMD; sleep 25; done` around "Another FireForge engine-mutating command is already running"): `test`, `build`, `export`, `re-export`, and the engine-locked furnace subcommands gain `--wait-lock [seconds]` (bare flag = 60, validated 1–3600), which polls the lock with exponential backoff (100 ms doubling to a 2 s cap) up to the bound, printing a progress line about every 5 s identifying the holder from the lock's owner metadata (`Waiting for the FireForge engine lock held by PID … (command=…, started=…) — 10s of up to 60s.`), then proceeds when the lock frees or fails with the unchanged standard message. Without the flag, behavior is byte-identical to the 1 s fail-fast; the lock-ownership invariants and their doc (`docs/lifecycle-invariants.md`) are updated to cover the bounded wait.
502
- - **`furnace validate` credits the implicit wrapping `<label>` association** (friction-log item 8, false positive — `[unlabelled-form-input]` fired on the accessible `<label><input type="radio">…</label>` pattern although the control has an accessible name per the HTML spec): an input/select/textarea nested inside a `<label>` whose content includes real text (tags stripped; a `${…}` binding deliberately counts as text since it is legitimate dynamic label content) is now treated as labelled; a bare input, or one wrapped in an empty or tags-only `<label>`, still warns, and the explicit `for`/`aria-label`/`aria-labelledby` paths are unchanged.
503
- - **`furnace create --shared-ftl <path>` was already shipped; its validation is now pinned by tests** (friction-log item 9, missing-feature — verified already implemented on current main: the flag sets `sharedFtl` in furnace.json, skips the component-local FTL scaffold, emits the `insertFTLIfNeeded` line for the given path verbatim, and rejects the contradictory `--no-localized` combination): 0.38.0 adds the previously missing direct unit tests for `validateSharedFtl` (trimming, empty/non-string rejection, unsafe-character rejection, localized requirement) and a command-level test pinning the exact CLI rejection wording.
504
- - **`source set --candidate <buildN>` fetches release-candidate archives** (friction-log item 10, missing-feature — `resolveArchive` hardcoded `pub/firefox/releases/`, so a `candidates/<version>-candidates/buildN/` source, the normal shape for pre-release verification, forced hand-seeding `.fireforge/cache/` with an independently verified tarball + sidecar): `firefox.candidate` (validated `buildN`, also settable via `fireforge config firefox.candidate` and cleared with `--clear-candidate`) switches resolution to `pub/<product>/candidates/<version>-candidates/<buildN>/source/…` with the matching `SHA256SUMS` alongside the build directory; devedition and ESR fall out of the existing product/version handling, cache filenames gain a `-<buildN>` suffix so candidate artifacts never collide with release artifacts, and the sha256-pin trust model is unchanged (an explicit `firefox.sha256` pin still fails closed and takes precedence over the published checksums). Recording the candidate in exported patches' source provenance was considered and deferred — it cascades into `PatchMetadata` schema/validation churn across the manifest round-trip surface.
403
+ - `status --ownership` reports a furnace-deployed file whose owning patch body went stale as drift.
404
+ - Scoped `test --build` and build-less runs refuse a `components.conf` changed since the last full build, since those entries bake into the compiled static component table.
405
+ - Per-patch size lint agrees with `wc -l` and fires only above the soft limit.
406
+ - Patch lint accepts both established upstream MPL header wraps.
407
+ - The packaged-test coverage guard accepts same-manifest siblings instead of demanding the exact recorded path set.
408
+ - Positional inserts refuse with one actionable error when they would renumber a reserved range, `patch move-files --create --order <n>` bootstraps a split in one step, and `re-export --scan` refuses forward-import adoptions.
409
+ - Engine-mutating commands can wait for the engine lock instead of failing fast.
410
+ - `furnace validate` credits the implicit wrapping `<label>` association.
411
+ - `furnace create --shared-ftl <path>` validation is pinned by tests.
412
+ - `source set --candidate <buildN>` fetches release-candidate archives.
505
413
 
506
414
  ## 0.37.0
507
415
 
508
- - **`fireforge test --build` now records the stale-build baseline** (friction-log item 1, inefficiency — "stale-build guard re-arms across invocation shapes"): a green pre-test build refreshes `.fireforge/last-build.json` through the same `writeBuildBaseline` path as `fireforge build`/`build --ui`, so after one `test --build` over a set of edited files, a subsequent plain `fireforge test` over the same files — per-file, per-directory, or full suite — passes the gate without a second redundant `--build` round trip. The baseline is content-fingerprint-based and project-scoped, which is also per-obj-dir (multi-objdir checkouts are already refused up front), so no per-invocation-shape keying is needed; both directions (file-scoped rebuild → full-suite run, and full/directory rebuild → file run) are covered by tests.
509
- - **The stale-build gate no longer refuses a plain `test` right after a green `test --build`** (friction-log item 2, bug — "test --build must update last-build.json"): this was the same defect — `runPreTestBuild` ran the identical protected `mach build faster` as `build --ui` but never wrote the baseline, so the immediately following plain `fireforge test` was refused listing the very files the embedded build had just rebuilt (workaround was a redundant ~1-min `fireforge build --ui`). The write happens only on build exit 0, mirrors `fireforge build`'s ordering and failure tolerance exactly, and a failed pre-test build keeps the prior baseline.
510
- - **Every non-`--build` test run now tracks packaged-runtime COVERAGE, not just tree state** (friction-log item 3, gotcha — file-scoped `--build` + `--allow-stale-build` over other manifests hangs on unpackaged fixtures): the baseline gains `testPackagingCoverage` (`'full'` from `fireforge build`/`build --ui`/path-less `test --build`; the normalized requested paths from a scoped `test --build`). Any non-`--build` run whose requested paths fall outside the recorded coverage is refused up front — with or without `--allow-stale-build` (the flag only accepts stale CONTENT; missing coverage is a hang risk either way), and regardless of whether the tree is stale, since the hang reproduces with an unchanged tree — with a message naming the uncovered manifests, the recorded coverage, and both remediations (`test --build <paths>` or `fireforge build`); a covered re-run still proceeds, with the usual stale warning under the flag. A path-less `test --doctor` is exempt from the coverage gate — it stops at the Marionette health check and dispatches no test, so it needs no packaging coverage (the stale-content refusal still applies to it, and `--doctor` combined with test paths is checked like any other run). Baselines written before 0.37.0 lack the field and are treated as full coverage, so existing flows are unchanged.
511
- - **`export`/`re-export` refuse to capture a stale furnace deploy** (friction-log item 4, inefficiency — re-export after editing `components/custom/` without `furnace deploy` exports the old deployed copy): when a patch's exported files fall under a furnace component's deployed prefix (custom `targetPath` / override `basePath`, plus a localized component's exact deployed `<ftlDir>/<name>.ftl` in the shared FTL directory; `sharedFtl` bundles are not furnace-deployed and stay unattributed by design) and that component's `components/` source has changed since the last `furnace apply`/`deploy` (checksum comparison against `appliedChecksums` — mtime is deliberately not trusted), the export is refused with a message naming the component and suggesting `fireforge furnace deploy` first; the new `--allow-stale-furnace` flag on both commands downgrades the refusal to a warning. A deploy-then-re-export sequence is untouched, and a broken/absent furnace setup never blocks non-furnace patch work.
512
- - **`stagedDependencies` gains a registration-kind entry shape** (friction-log item 5, missing-feature — jar.mn packaging lines flagged `staged-dependency-unused`): `PatchStagedDependencies.registrations` entries (`{file, line, creates, owner?, reason?}`) declare a packaging/registration LINE — a jar.mn entry, a customElements registration, an actor registration — that references a later-created file. Lint validates them by matching the declared line (whitespace-trimmed) against the patch's added content in the declaring file instead of looking for an import specifier — and, mirroring forward-import validation, by requiring `creates` to resolve to a file a later-ordered patch actually creates and `owner`, when set, to name that patch (each failure kind gets its own message) — so mid-queue buildability facts for registration files (e.g. patch 200's jar.mn line packaging `hominis-history-ui.js` owned by patch 248) are machine-recordable: a matching entry lints clean, an import-kind entry still requires a real import, and a registration entry whose line is absent still flags `staged-dependency-unused`. CLI: `fireforge patch staged-dependency --add/--remove --kind registration --file … --line … --creates …`; owner-rename rewriting covers the new array.
513
- - **`furnace validate` accepts `kind: "library"` components** (friction-log item 6, missing-feature — define-less base-class modules failed `no-custom-element-define`): a custom component manifest may declare `kind: "library"` (base class + helpers, no element of its own — e.g. a shared `moz-hominis-shared` module), which skips the `no-custom-element-define` / `not-moz-lit-element` compatibility errors and the `missing-css` structural advisory while keeping headers, module shape, `relative-import`, FTL rules, and the CSS compatibility rules when a stylesheet exists. `kind: "library"` requires `register: false` (rejected at config parse otherwise); a default-kind component without a define still errors, so the inert-element workaround can be retired.
514
- - **The failure summary echoes the failing TEST-UNEXPECTED lines with their assertion text** (friction-log item 7, papercut — "First real test failure: Unexpected results: 1" was undiagnosable after the fact): `test-failures` verdicts now carry the first 5 `TEST-UNEXPECTED-*` lines verbatim, each with its trailing assertion/diff context (`Got …`/`Expected …`/Assert message/stack head), echoed under the `First real test failure:` headline in single runs and prepended to shard diagnoses; more than 5 append a `…(+N more)` note, and the shutdown-reentry artifact is excluded. A one-off chrome-suite failure that does not reproduce is now diagnosable from the summary block alone.
515
- - **The known mozsystemmonitor teardown traceback is collapsed to one labeled line in the output tail** (friction-log item 8, bug/upstream — every headless run ended with the raw `AttributeError: 'SystemResourceMonitor' object has no attribute 'stop_time'` traceback sitting where the summary is read): the test dispatchers now route their terminal echo through a line-buffered filter that recognizes the exact documented signature — an `AttributeError` naming `stop_time` or `poll_interval` on `SystemResourceMonitor` AND a `mozsystemmonitor/resourcemonitor.py` frame AND a previously-seen `SUITE_END` shutdown marker (the flag is shared across the run's stdout/stderr filter pair, since the marker and the traceback usually land on different streams), including the chained-exception pair — and replaces the block with `[FireForge] Known upstream mozsystemmonitor teardown noise … — not a test failure`. Unrecognized tracebacks always print in full, the captured output stays raw for the 0.36.0 classifier (real-failure precedence and green-summary override are untouched), and oversized or truncated blocks flush verbatim rather than being withheld.
516
- - **Mach dispatches reap their whole process GROUP, and `doctor` detects pre-existing orphaned harness workers** (friction-log item 9, severe — a mach that died at harness startup stranded a Python `multiprocessing` spawn worker that busy-spun at 100% CPU for ~26 days): (a) every mach-wrapping dispatch (test suites, protected builds, bootstrap, watch, package, run) now spawns mach as a POSIX process-group leader, forwards parent SIGINT/SIGTERM to the group (with the existing grace→SIGKILL escalation), group-kills on abort, and sweeps the group after every exit — survivors are named (multiprocessing workers highlighted), group-SIGTERMed, and SIGKILLed after a 2s grace, so a startup death leaves zero surviving multiprocessing children; a healthy run costs one `pgrep`. Windows stays best-effort tree-kill via `taskkill /T /F`. (b) A new `doctor` check ("Orphaned harness workers") scans `ps` for the detection recipe that found the incident — PPID 1 + ≥10 min accumulated CPU time + a `multiprocessing.spawn`/`forkserver`/`resource_tracker` command line, parsing both linux `dd-hh:mm:ss` and darwin `mm:ss.cc` TIME dialects — and reports each match with its PID, CPU time, command line, and a `kill <pid>` suggestion; report-only by design (FireForge never kills pre-existing processes), warning severity so it cannot fail doctor, skipped on Windows.
517
- - **Follow-up findings** (pre-release review of the items above; the behavioral corrections are folded into their bullets): the packaging-coverage refusal now guards EVERY non-`--build` run, not just `--allow-stale-build` ones, with a path-less `test --doctor` exempted since it dispatches no test (item 3 — a plain run over an unchanged tree after a scoped `test --build` sailed through the gate into the very fixture hang the feature targets, while the widened gate would otherwise have refused a doctor-only Marionette probe as an uncovered full-suite request); the stale-furnace export gate attributes a localized component's deployed `<ftlDir>/<name>.ftl` (item 4 — exporting only the deployed FTL file never probed its component source); registration-kind staged dependencies validate `creates`/`owner`/ordering like the import kind (item 5 — metadata naming a nonexistent file or wrong owner linted clean); the teardown-noise echo filter is narrowed to the exact `stop_time`/`poll_interval` signature with a resourcemonitor.py frame after a seen `SUITE_END` (item 8 — it previously collapsed ANY traceback mentioning resourcemonitor.py, hiding novel upstream defects from live output); and the process-group reaper's grace timer is no longer `unref()`'d (item 9a — the timer was awaited from a child `close` handler after the signal forwarder was disposed, so Node could exit mid-grace and skip the SIGKILL escalation; a regression test pins the timer's ref-ness).
416
+ - `fireforge test --build` records the stale-build baseline, so a plain `test` right afterwards is not refused.
417
+ - Every non-`--build` test run tracks packaged-runtime coverage, not just tree state.
418
+ - `export` and `re-export` refuse to capture a stale furnace deploy, with `--allow-stale-furnace` to downgrade it to a warning.
419
+ - `stagedDependencies` gains a registration-kind entry shape for jar.mn lines, customElements registrations, and actor registrations.
420
+ - `furnace validate` accepts `kind: "library"` components.
421
+ - The failure summary echoes the first five `TEST-UNEXPECTED-*` lines with their assertion text.
422
+ - The known mozsystemmonitor teardown traceback is collapsed to one labeled line.
423
+ - Mach dispatches reap their whole process group, and `doctor` detects pre-existing orphaned harness workers.
518
424
 
519
425
  ## 0.36.0
520
426
 
521
- - **Review-findings remediation for the 0.36.0 release gate**: `fireforge test` no longer has a pathless dead-end — no-path runs now require `--auto`, `--doctor`, or `--canary`, `--auto` forwards mach's own selection mode, stale packaged-engine drift fails fast unless `--build` or `--allow-stale-build` is used, `--kill-stale-marionette` can terminate a recognized stale browser listener, and `--canary [path]` / `test.canaryPath` provides a short green/hang/crash harness probe. Harness verdicts now let real failure lines (`FAIL`, `TEST-UNEXPECTED-*`, nonzero unexpected summaries, assertions) beat resource-monitor traceback classification, while still noting the traceback as secondary noise. Patch naming is normalized consistently for `export --name` and `patch rename --to` (bare, category-prefixed, and full stems converge), and `lint --per-patch --patches` accepts repeated flags, comma lists, filenames, stems, manifest names, category-prefixed slugs, and bare slugs. Furnace-managed jsconfig edits are JSONC-preserving via `jsonc-parser`, the Firefox globals shim now includes JSWindowActor bases and `ChromeUtils.registerWindowActor`, `doctor` can validate opt-in `externalToolchains`, and CLI engine-mutating commands take a `.fireforge/engine-session.lock` plus test-run tree-generation checks to catch concurrent engine mutation.
522
- - **Fixed a SIGSEGV-truncated test run being reported as PASSED** (corrects the green-summary override shipped in 0.34.0 and still present in 0.35.0): an 8-file browser-chrome run whose parent process crashed at the second file's `TEST_START` (`Main app process: killed by SIGSEGV`) left an embedded summary that read `Passed: 2 / Failed: 0 / Unexpected results: 0` — green only because the crash prevented the other six files from producing any results — and FireForge overrode the mach exit 1 as "non-fatal harness noise", masking a real regression through an entire source-refresh drill while the operator recorded "full matrix green". The override now runs two vetoes first: a crash-marker scan over the RAW output (`Main app process: killed by SIG*` for any signal, `PROCESS-CRASH`, and the mozcrash minidump-processing headers — these also disqualify the summary from vetoing crash-signature classification), and a completeness cross-check that every requested test file produced a `TEST_START`/`TEST_END` (or `TEST-START`/`TEST-OK`) pair, paired positionally because the xpcshell dialect's `TEST_END` lines do not name the file. Either signal fails the run with a dedicated message naming the crash evidence line and the never-started / started-but-never-finished files, in both single and sharded runs. The lenient path survives only when neither signal fires — the resource-monitor/telemetry noise it was built for (green summary, all files paired, monitor traceback owning the exit code) still passes with the override note, and mach exit 0 classification is untouched. Fixture tests pin all four shapes, with the crash lines verbatim from the field log.
523
- - **Fixed the build toolchain preflight probing a different cbindgen than mach resolves** (corrects 0.35.0): the preflight compared the tree's declared minimum against env-override-or-PATH only, but mach's configure tries the mozbuild state directory FIRST (`~/.mozbuild/cbindgen/cbindgen`, where `fireforge bootstrap` installs; its configure log shows that candidate probed ahead of PATH) — so a host with a current bootstrap-installed 0.29.4 behind a stale `~/.cargo/bin` 0.29.1 was blocked from a build that succeeds (field workaround was `CBINDGEN=… fireforge build`). The probe now enumerates configure's own candidate order — env override (which, when set, is the ONLY candidate: the override wins even over a newer state-dir copy, matching configure), then `$MOZBUILD_STATE_PATH`/`~/.mozbuild` locations, then PATH — and fails only when at least one candidate resolved and NONE meets the minimum, listing every probed candidate in the error; no resolvable candidate still skips silently (fail-soft unchanged). Tests pin all four resolution shapes.
524
- - **Fixed exact-directory test selection being cosmetic — and its exclusion echo being wrong** (corrects 0.35.0): the trailing-`/` normalization does not defeat mochitest's prefix matching on Firefox 153's mach — in the verifying run all 33 `hominis-tiles` sibling files executed anyway, while the new echo gave the operator positive reason to believe they were excluded. A directory argument now dispatches as its enumerated explicit test-file list (a file list cannot prefix-match a sibling), still in ONE mach invocation so the one-browser-instance semantics of a directory run are preserved (cross-file state carries within the directory; sharding of multi-argument runs is per argument, keeping a directory's files together). The echo now describes the real mechanism, and the rare zero-enumerable-files fallback (raw directory form passed through) explicitly warns that prefix matching may still widen the scope instead of claiming exclusion. `test --help` and the README describe the new mechanism; tests pin the explicit-file-list dispatch, the single-invocation directory run, and the per-argument sharding of a directory + file mix.
525
- - **Extended the verbatim upstream MPL-2.0 block header acceptance to non-JS block-comment files** (0.35.0 residual — its fix covered new JS files only, per its own "non-JS styles are unchanged"): a derived CSS file carrying the exact same three-line `/* … */` MPL block header still errored `missing-license-header` on an EUPL project without a patch-level lintIgnore, while the derived JS on the same patch passed natively. The carve-out now keys on the comment syntax, accepting the upstream block form (optionally behind a leading editor-directive comment) on JS and CSS; hash-style files (FTL) stay excluded because `/* … */` is not a comment there, and the `// `-style FireForge-generated MPL header still requires the project's own header on non-MPL projects. The acceptance predicate is now a single shared function (`isAcceptableNewFileHeader`) used by both the lint rule and export's interactive header auto-fix, which previously would have offered to stack a second header onto a file the lint already accepts. Lint tests cover accept/reject for CSS and the FTL exclusion.
526
- - **Fixed the rebase drift-tolerance feature being dead code**: `git apply` has no `--fuzz` flag (that is GNU `patch(1)` syntax), so every escalation level failed with a swallowed usage error and any patch needing context drift was reported as a hard conflict with `.rej` residue — `--max-fuzz` had no effect at all. Reimplemented with git's real knob, `-C<n>` reduced-context matching (exact, then `-C2`/`-C1`/`-C0`, capped at git's default context of 3); `.rej` extraction now parses git's actual `Applying patch <file> with N rejects...` phrasing (the old regex targeted GNU patch output and never matched, so the conflict summary's ".rej files created" hint never fired). The fuzz unit tests were exec-mocked and validated behavior real git cannot produce — they now run against a **real git binary**, and a rebase integration test pins end-to-end drift absorption. User-facing wording now says "context reduction (fuzz-like)"; the `--max-fuzz` flag name is unchanged.
527
- - Fixed `rebase --max-fuzz` **accepting invalid numbers**: the flag was wired to raw `parseInt`, so `--max-fuzz foo` became `NaN`, `--max-fuzz -1` stayed negative, and `--max-fuzz 1.5` silently truncated to 1. With `NaN` or a negative value the escalation loop in `applyPatchWithFuzz` (`for (fuzz = 0; fuzz <= maxFuzz; …)`) never ran a single iteration — the patch skipped even the exact-match attempt and fell straight through to `git apply --reject`, spraying `.rej` files for patches that would have applied cleanly. The flag now uses the same strict `parsePositiveIntegerFlag` parser as `export --order` and `patch reorder --to`, failing fast at parse time, and `applyPatchWithFuzz` itself rejects a non-integer or negative `maxFuzz` as defense in depth (0 = "exact only" stays legal at the core layer). CLI-parse tests pin `foo`/`-1`/`0`/`1.5`/`01` rejection plus a valid value flowing through; core tests pin the guard.
528
- - **Fixed a corrupt `patches.json` being silently destroyed on the next export**: `loadPatchesManifest` returned null for "missing" AND "unparseable", so `addPatchToManifest` rebuilt an empty manifest over a hand-edit typo (losing every patch's tiers/descriptions/lintIgnore/staged dependencies) and a failing export's rollback then deleted `patches.json` because the "before" state looked absent. Every mutating path now loads through `loadPatchesManifestForWrite`, which throws a typed `PatchManifestCorruptError` naming the parse error and the repair steps.
529
- - **Fixed `furnace apply <name>` wiping every other component's checksum state**: named apply ran the batch persist path, whose wholesale `appliedChecksums` replace kept only the named component (deploy documented and avoided this exact trap). Orphan detection and deleted-file undeploy key on that state, so the wiped components' stale engine files became invisible to apply and `furnace validate`. Named apply now mirrors deploy: `persistState: false` plus a per-component merge, both hoisted into `core/furnace-state-persist.ts` as the single owner of state-persistence policy.
530
- - **Fixed ordinary `config <key> <value>` deleting `--force`-written keys**: the non-force branch round-tripped through `validateConfig`, whose typed clone contains only known schema fields. Both branches now mutate the raw document (validation strength unchanged — the result is still strictly validated for known keys), and the catch only labels genuine validation failures as "Invalid value" — lock timeouts and I/O errors keep their own types instead of misdirecting diagnosis.
531
- - **Downloads verify against Mozilla's published SHA256SUMS by default**, failing closed on mismatch (artifact deleted) and warning loudly when the checksum file is unavailable; an explicit `firefox.sha256` still takes precedence and always fails closed. TLS-only trust was thin for the artifact that becomes the git baseline every patch builds on.
532
- - **Closed the silent-degradation class**: the archive-safety preflight streams tar listings line-by-line instead of scanning a silently-truncated 50 MB capture (a padded archive could hide traversal entries past the cap); `export`/`export-all --dry-run` are genuinely read-only (the license-header auto-fix no longer prompts-and-writes into `engine/` mid-"preview"); `furnace sync` refuses to apply while workspace files carry unresolved merge conflict markers or overrides still have baseVersion drift (it used to copy conflict markers into the engine right after warning about them); `bootstrap` propagates error-severity post-check results instead of exiting 0; furnace post-apply consistency failures now trigger rollback (the gate read a pre-check snapshot of step errors); and `.ftl` degradation is a real advisory tier (`StepError.advisory`) instead of a documented-but-false promise — a fork without a locale jar.mn can ship a working component again.
533
- - **Hardened `applyPatchIdempotent` recovery**: git exit codes checked (a failed restore no longer surfaces as a confusing "already exists" later), pathspecs chunked against `E2BIG`, patch-created leftovers deleted explicitly, and a `protectedFiles` set threaded from both apply loops so an `--allow-overlap` queue's later patch no longer resets shared files a previous patch just modified. Relatedly, rebase re-export skips patches whose files another patch also claims (the cumulative per-file diff used to duplicate shared hunks into every owner, corrupting the queue for the next import) and excludes them from version stamping with a manual-re-export warning.
534
- - **File locks are race-hardened**: ownership is PID + per-acquisition token, stale reaping is rename-aside (exactly one reaper can win; the live path is never `rm`'d, and a lock re-acquired mid-probe is restored), the stale probe re-runs every ~5 s while waiting (a build-lock holder dying mid-wait no longer stranded waiters for up to 24 h), and release verifies the lock still belongs to this acquisition. The download lock waits up to 30 min with honest messaging instead of failing after 30 s with "remove the lock" advice while a legitimate download was mid-extraction.
535
- - **Signals**: the bin handler now waits (bounded, 12 s) for spawned children after forwarding SIGINT/SIGTERM, making `execInherit`'s SIGTERM → grace → SIGKILL escalation actually reachable — a hung Firefox is no longer orphaned by Ctrl+C, and a healthy one keeps its AsyncShutdown flush window. `execSmokeRun` forwards parent signals to its detached process group (second signal = group SIGKILL).
536
- - Fixed Windows smoke-run cleanup **killing only the direct child**: `execSmokeRun` signals the whole POSIX process group on timeout, but the Windows fallback was a bare `child.kill()` — for a `mach → python → firefox → content processes` chain that leaves the Firefox tree running after the smoke deadline. The fallback now runs `taskkill /pid <pid> /T /F` to kill the descendant tree (always forced — Windows has no graceful-signal analogue, so the POSIX SIGTERM→SIGKILL grace window does not exist there), keeping `child.kill()` as a last resort if taskkill is unavailable. The docstring's claim that a best-effort warning was logged on Windows (it never was) is gone, and the README's Windows caveat now names the taskkill strategy as best-effort/untested. A regression test pins that the win32 timeout path spawns `taskkill /T /F` instead of signalling `-pid`.
537
- - **Windows atomic-write and test-suite correctness** (from the first Windows-runner failure of the cross-platform CI subset): `writeFileAtomic` now retries its final rename on transient `EPERM`/`EACCES`/`EBUSY` — Windows fails a rename onto a target another writer is concurrently replacing (or that an antivirus/indexer briefly holds open), so concurrent atomic writes to the same path spuriously errored where POSIX succeeded; bounded retries with backoff make them reliable there too. Four test-suite fixes make the Windows subset honest: the POSIX process-group kill test pins a non-Windows `process.platform` (the win32 `taskkill` branch has its own test), the git-diff mocks key their `stat`/`hashObjectBatch` lookups on `path.join`-built paths instead of hard-coded forward-slash strings (they missed the backslash paths the source computes on Windows, silently exercising the wrong branch), and the file-mode preservation test is skipped on win32, where no execute bit exists for chmod to preserve.
538
- - **Machine output**: `status --json`/`--raw` own stdout exclusively — every diagnostic (clack warnings, styled errors, spinner steps, banners) routes to stderr as plain text, so `status --json | jq .` parses even when warnings fire. Truncated untracked directories are reported once, not twice.
539
- - **Encoding and platform correctness**: all captured/streamed child output decodes through `StringDecoder` (multibyte characters split across pipe chunks no longer corrupt smoke-run allowlist matching or mach error hints); patch parsing is CRLF-tolerant via the new single shared diff walker (`parseDiffSections` — quoted/octal-escaped `diff --git` paths, ` b/`-in-path headers, binary-section detection, one implementation instead of six drifting walkers); `exec` timeouts reject with a typed `ExecTimeoutError` naming the command and budget instead of a bare `AbortError`.
540
- - **CLI polish**: `--version`/`-V` works alongside other root flags (handled pre-parse; a commander root `.version()` would hijack `source set --version`); option argParsers surface through commander's invalid-argument channel via the `commanderArgParser` wrapper (`run --smoke-exit abc` prints one clean line instead of a fatal dump); `import --until <ordinal>` resolves ordinals with the same matcher as the apply loop (in-range integrity issues can no longer be silently dropped while the loop applies anyway); prompt cancellation exits 130 (`ExitCode.USER_CANCELLED`); a bare `lint cache` explains the cache-vs-path ambiguity.
541
- - **Furnace authoring safety**: `furnace remove` deletes only the files the component deployed (never `removeDir` of a possibly-shared targetPath; empty dirs pruned; furnace.json warns when two components share a targetPath), and `furnace rename` journals the new-name side (workspace dir + files, test scaffolds, FTL destination) plus furnace-state.json before re-keying, so failed or interrupted renames roll back completely.
542
- - **Patch-core precision**: `normalizePatchArtifact` is the identity chokepoint (whitespace-only payload lines are preserved byte-for-byte — the old trimming made freshly exported patches fail `git apply --check` and silently changed `+` line content; the worktree whitespace check already excludes `patches/*.patch`); zero-old-count insertion hunks splice after line N per unified-diff convention; the LCS-fallback coalesced hunk emits correct `@@` lengths; auto-resolved new-file originals survive the whole run so a later failure's rollback can restore them; empty new files extract as zero bytes; binary new-file sections refuse auto-resolve instead of writing base85 as text.
543
- - **Performance**: `validatePatchIntegrity` batches HEAD-existence checks through one chunked `ls-tree` (one spawn instead of one per patch × file), and import's dirty-file classification loads the manifest/patch set once with bounded concurrency instead of O(files × patches) IO.
544
- - **Structure**: `escapeRegex` (6 copies), `getPrimaryStatusCode` (2), the untracked-directory expander (2 — the shared one now carries the 5000-per-dir cap, and `reset`/`discard` gained status's unborn-HEAD guidance), the pendingRepair-preserving state reset (4 copies → `clearAppliedFurnaceState`), and the cross-patch claim multimap (3 copies → `buildPatchClaims`) are each single implementations; the second patch-delete implementation (`deletePatch`) is gone in favor of `removePatchFileAndManifest`; the furnace batch-apply loops take an `ApplyBatchContext` instead of 12–13 positional parameters; `stripJsComments` tokenizes with acorn so regex literals survive (legacy regex fallback for preprocessor-laden chrome sources).
545
- - **Follow-up findings** (same review, second pass): `doctor --repair-patches-manifest` now rebuilds `patches.json` **under the patch-directory lock** — the repair used to write the manifest lock-free, so it could race a concurrent export/reorder and clobber the other writer (a lock-wait regression test pins the serialization, alongside the existing `stampPatchVersions` pin). `patch tier` and `patch lint-ignore` now follow the full destructive-operation contract via `confirmDestructive` (summary, `--dry-run`, confirmation or `--yes`, non-TTY refusal with `--yes` guidance) — both accepted `--yes` without ever prompting, so the flag only appeared in the history record; suppressing lint findings in particular is a mutation an operator should consciously approve. The root `--version` edge case flagged in the same pass (`fireforge --verbose --version` failing on the argv-length-3 fast path) was already fixed above; two CLI tests now pin both the multi-flag form and the non-hijacking of `source set --version`.
546
- - **Infrastructure**: all GitHub Actions are SHA-pinned (release workflow included); `coverage.include: ['src/**']` closes the Vitest 4 hole where never-imported modules were invisible to thresholds (with new per-module pins for the fuzz/state-persist/step-error modules); the ~2-minute pack smoke test runs once via `pack:verify` (own config) instead of in every `npm test` and twice per release:check; CI has concurrency groups and a stable `quality (ubuntu)` job name that `setup-rulesets.sh` now matches (the Node version embedded in the name silently invalidated the required check on every bump); the full-integration runner's masked-build-failure detection works (its `ls obj-*` glob never expanded under spawn); the coverage-pin matcher requires a path-separator boundary; husky hooks only prepend Homebrew paths that exist; `docs/*.md` and `knip.json` are formatted; `isolatedModules` + `verbatimModuleSyntax` are on (zero fallout); the published tarball no longer ships dangling `sourceMappingURL` comments (build emits no maps); machine-mode logging and the lock races have dedicated unit suites; `docs/lifecycle-invariants.md` records the new lock-ownership, manifest-corruption, and state-persistence invariants with their enforcing tests.
427
+ - `fireforge test` with no path requires `--auto`, `--doctor`, or `--canary`, and stale packaged-engine drift fails fast unless `--build` or `--allow-stale-build` is given.
428
+ - A SIGSEGV-truncated run is no longer reported as PASSED.
429
+ - The build toolchain preflight probes the cbindgen mach actually resolves, which is the mozbuild state directory first.
430
+ - Exact-directory test selection was cosmetic on Firefox 153's mach and its exclusion echo was wrong; both fixed.
431
+ - Verbatim upstream MPL-2.0 block headers are accepted on non-JS files too.
432
+ - `rebase --max-fuzz` was dead code: `git apply` has no `--fuzz` flag, so every escalation failed with a swallowed usage error. Reimplemented, and the flag now rejects invalid numbers.
433
+ - A corrupt `patches.json` is no longer silently destroyed on the next export.
434
+ - `furnace apply <name>` no longer wipes every other component's checksum state.
435
+ - Ordinary `config <key> <value>` no longer deletes `--force`-written keys.
436
+ - Downloads verify against Mozilla's published SHA256SUMS by default, failing closed on mismatch.
437
+ - Archive-safety preflight streams tar listings instead of scanning a truncated capture, and `--dry-run` exports are genuinely read-only.
438
+ - `applyPatchIdempotent` checks git exit codes, chunks pathspecs against `E2BIG`, and threads a protected-files set through both apply loops.
439
+ - File locks are race-hardened: PID plus per-acquisition token, rename-aside stale reaping, and a stale probe that re-runs while waiting.
440
+ - The bin signal handler waits up to 12 s for spawned children, making the SIGTERM to SIGKILL escalation reachable. Windows smoke-run cleanup kills the whole tree.
441
+ - `writeFileAtomic` retries its final rename on transient Windows errors.
442
+ - `status --json` and `--raw` own stdout exclusively; every diagnostic goes to stderr as plain text.
443
+ - All captured child output decodes through `StringDecoder`, and patch parsing is CRLF-tolerant through one shared diff walker.
444
+ - `--version` works alongside other root flags, and option parse errors print one clean line.
445
+ - `furnace remove` deletes only the files the component deployed, and `furnace rename` journals the new-name side.
446
+ - `validatePatchIntegrity` batches HEAD-existence checks through one chunked `ls-tree`.
447
+ - `doctor --repair-patches-manifest` runs under the patch-directory lock.
448
+ - All GitHub Actions are SHA-pinned, and `coverage.include` closes the Vitest 4 hole where never-imported modules were invisible to thresholds.
547
449
 
548
450
  ## 0.35.0
549
451
 
550
- - Fixed the **patch symlink-escape guard being dead code**: the check meant to reject patches targeting a symlink that points outside `engine/` normalized the path text with `resolve()` but never followed the link, so it could not reject anything (the path had already passed the relative-containment check). Target validation now resolves the physical write destination with `realpath` — covering the direct symlink, a _dangling_ symlink (whose destination a write would still create), and a symlinked parent directory — and compares it against the real engine root. Regression tests pin all three escape shapes plus the two accept shapes (inside-tree link, plain new file).
551
- - Fixed `stampPatchVersions` mutating `patches.json` **without the shared patch-directory lock**: version stamping after `re-export --stamp` and at rebase completion now runs the manifest read-modify-write under `withPatchDirectoryLock`, so a concurrent export/reorder/metadata update can no longer interleave with the stamp and lose version stamps or clobber another manifest change. The function documents that the lock is not reentrant (callers must not already hold it), and a regression test pins that a held lock blocks the stamp until release.
552
- - Switched authoritative-state existence probes from the error-swallowing `pathExists` to `pathExistsStrict`, so an EACCES/EPERM now surfaces as an error instead of masquerading as "missing": the patches manifest probes (`loadPatchesManifestState`, `mutatePatchRowsInManifest`, the renumber phase checks, and `removePatchFileAndManifest`), the Firefox archive cache probes (`cacheEntryExists` and the tarball/metadata check in `validateCachedArchive` — a permission error probing the cache no longer reads as "invalid cache" and silently triggers a re-download into a directory that cannot be written anyway), and `isGitRepository` (an unreadable engine checkout no longer reports as "not a git repository", which used to route operators toward re-download advice). Soft probes (lock-file cleanup, optional-file checks) intentionally keep the forgiving behavior.
553
- - Added a **tar extraction preflight** to `extractTarXz`: before anything is written to disk, the archive listing (`tar -tf` for member names, `tar -tvf` for link targets, `LC_ALL=C`) is validated and the archive is rejected if any member name is absolute (POSIX or Windows drive/backslash rooted) or contains a `..` segment, or if any symlink/hardlink target is absolute or `..`-escaping (a relative target without `..` cannot leave the extraction root). Modern GNU tar / bsdtar refuse most of these shapes at extraction time already; the preflight makes the guarantee explicit and independent of the host tar's defaults. Each listing pass costs one full decompression, so the two run **concurrently** and overlap almost perfectly. Measured on a real Firefox ESR 140.9 source tarball (601 MB, bsdtar, macOS): each listing takes ~22 s, both concurrently still ~22 s wall, and extraction itself 58 s — so the preflight adds ~22 s (~38%) to this one-time step of `fireforge download`, versus ~44 s if the listings ran sequentially. Member names are deliberately taken from `-tf` (where the whole line is the name) rather than parsed out of the `-tvf` columns, because the adjacent uname/gname fields are attacker-controlled in a crafted archive and a date-shaped owner name could shift the column boundary and hide an absolute member name from the check. `ExtractionError.userMessage` now includes the underlying reason (this also surfaces the previously hidden tar exit code/stderr detail for ordinary extraction failures).
554
- - Extended the CI matrix with a **cross-platform unit-test subset**: the macOS and Windows smoke jobs now also run the path/fs/process/git/file-lock unit suites (previously Ubuntu-only via `release:check`), and the README's "never tested on Windows" claim was replaced with an accurate statement of what CI does and does not cover there.
555
- - Consolidated the mutation lifecycle safety design into an internal architecture doc, **`docs/lifecycle-invariants.md`**: the six invariants the destructive-operation contract, file locks, furnace rollback, signal-critical sections, and the bin signal pipeline uphold together, who owns which piece, the end-to-end SIGINT/SIGTERM sequence, and a decision table for choosing the right primitive for a new mutating command. Added **scenario tests for a signal during a compound mutation** (`signal-compound-mutation-scenario.test.ts`) that replicate the bin-handler composition exactly: exit is held until an in-flight "engine apply + state persist" pair completes, the hold is bounded so a stuck section cannot postpone exit forever, and furnace rollback + critical-section drain + lock force-release compose correctly.
556
- - Fixed `discard <directory>/` producing **doubled slashes** in the directory-fallback output: a trailing slash on the input was passed through and every message appended `/` again (`stories/furnace//`). The input is now normalized once on entry to the fallback. This was not purely cosmetic — the doubled slash also broke the Furnace-managed-prefix comparison when discarding a _parent_ directory of a managed prefix with a trailing-slash input, silently dropping the "managed by Furnace" warning; regression tests pin both the messages and the warning.
557
- - The installed-package smoke tests now **skip with a clear message when no npm executable is available** (npm_execpath unset and `npm` not on PATH, e.g. a container with only node + node_modules) instead of failing all three with an opaque `spawn npm ENOENT`.
558
- - The release workflow now installs the **exact npm version pinned in package.json's `packageManager` field** (derived at run time so the two cannot drift) instead of `npm@latest`, making releases reproducible and keeping a fresh npm major from changing publish behavior unreviewed.
559
- - Added a **toolchain preflight for Firefox source hops** (152.0b7 → 153.0b8 source-refresh drill: after `download --force` moved the engine to a new major, the first `fireforge build` died ~8s into `mach configure` with `cbindgen version 0.29.1 is too old. At least version 0.29.4 is required.` — and mach's own remediation text names `./mach bootstrap`, the wrong tool for a FireForge-managed repo). Three layers: (1) `fireforge download` now prints a one-line nudge when the downloaded MAJOR version differs from the previously recorded one ("toolchain minimums may have moved — consider running `fireforge bootstrap`"; quiet on first downloads and same-major re-downloads, and also emitted on the resume path); (2) `fireforge build` runs a cheap fail-soft preflight before any pre-build work, comparing the minimums the tree itself declares (`cbindgen_min_version` in `build/moz.configure/bindgen.configure`, mozboot's `MINIMUM_RUST_VERSION`) against the host binaries configure will resolve (honouring the `CBINDGEN`/`RUSTC` env overrides) and failing fast with `fireforge bootstrap` as the named remedy — only a definitively parsed minimum vs a definitively probed host version can fail it, so a moved declaration file or a missing binary skips silently instead of false-positiving; (3) the mach-error-hints translator learned the `… is too old. At least version … is required` and `Rust compiler/Cargo package manager … is too old` shapes, so a failure that still reaches mach (including the auto-configure path in `prepareBuildEnvironment`, which now feeds its captured output through the translator) names `fireforge bootstrap` right below the raw error.
560
- - Made the per-patch lint **Firefox-globals shim extensible and current**: the closed object-literal types for `ChromeUtils` and `Localization` in the checkJs/typecheck shim are now named global interfaces (`ChromeUtilsShim`, `LocalizationShim`/`LocalizationInstanceShim`), so a project's `patchLint.checkJsExtraShim` can ADD members through TypeScript interface merging (`interface ChromeUtilsShim { newApi(): any }`) instead of having no extension point at all (a second `declare var ChromeUtils` is a duplicate-identifier error — that asymmetry forced a drill workaround with a module-local typedef + bounded any-cast while whole-project `fireforge typecheck` accepted the same call via the engine's Gecko types). Added the Firefox 153 member that triggered this, `ChromeUtils.predictRemoteTypeForURI(uri, options)` (loose signature per the shim's pragmatic posture), and documented that the shim tracks upstream WebIDL additions per Firefox release. Regression tests pin both halves: the new member passes out of the box, and an extra-shim interface merge is typed (correct use passes, misuse is still flagged).
561
- - Changed `fireforge test <directory>` to select **exactly the named directory**: mach resolves test paths by string prefix, so a bare directory argument silently swept in prefix-named siblings (drill: `…/test/hominis` also ran `…/test/hominis-tiles` — 1224 tests instead of ~200, with no indication the scope widened). FireForge now normalizes directory arguments with a trailing `/` (which makes the prefix match exact) and, when prefix-matching sibling test directories exist, echoes what was selected and what was excluded with per-directory test-file counts plus how to include them as separate paths. Since FireForge already rejected non-existent paths, raw prefix-widening was never something an operator could invoke deliberately — it only ever happened by accident. Documented in `test --help`.
562
- - Made per-file test sharding **announce itself**: multi-path `fireforge test` runs print one notice that N paths run in isolated browser instances (one mach invocation per path) and that cross-file state is NOT exercised, pointing at `--no-shard` for a combined single-instance run. The silent default cost a real misdiagnosis during the drill: a two-file cross-file pollution repro "passed" because the headed comparison run was silently sharded, briefly blaming a suite-context bug on an upstream headless regression. The `--no-shard` help text now carries the same caveat.
563
- - Added a **headed-smoke contamination notice and `fireforge run --headless`**: when `--smoke-exit` launches headed on a non-CI host (CI env var unset), a one-line warning states that keyboard/mouse input during the window will contaminate the console capture and can fail the run (drill: a human interacted with the smoke window, the password-manager import scan probed an unreadable Chrome profile dir, and the resulting console errors initially read as a product regression). The new `--headless` flag forwards `--headless` to the browser in both smoke and plain launch modes for unattended checks.
564
- - Accepted the **verbatim upstream MPL-2.0 block header on new JS files regardless of the project license**: the `missing-license-header` carve-out whose own comment describes the copied-from-upstream case was gated on `license === 'MPL-2.0'`, making it dead code for EUPL/GPL/0BSD projects — a file legitimately carrying Mozilla's block header (including behind a leading editor-directive comment) had no sanctioned path and consumers carried repo-side audit workarounds. Only the upstream `/* … */` block form is accepted; the `// `-style MPL header FireForge generates for MPL projects still requires the project's own header on non-MPL projects, and non-JS styles are unchanged.
565
- - Added targeted tests and per-module coverage threshold pins for under-covered edge modules that the global threshold was masking: `signal-critical.ts` (first direct tests: body passthrough/cleanup, multi-section waits, bounded timeout, throwing sections), `bootstrap-checks.ts` (bootstrap output pattern detection incl. traceback/403 collapse and all origin-remote phrasings; SDK-probe branching), `discard.ts` (directory-recursion fallback incl. sibling-prefix safety, batch partial failures, dry-run/confirmation paths, Furnace-managed warnings), and the `furnace-validate-compatibility.ts` compose/CSS-hygiene warnings (`compose-not-referenced`/`compose-not-registered`, `excessive-important`, `missing-reduced-motion`).
452
+ - The patch symlink-escape guard was dead code: it normalized the path text but never followed the link. Target validation now resolves the link.
453
+ - `stampPatchVersions` runs under the shared patch-directory lock.
454
+ - Authoritative-state probes use `pathExistsStrict`, so an EACCES surfaces as an error instead of "missing".
455
+ - `extractTarXz` validates the archive listing before writing anything, rejecting absolute members, `..` segments, and escaping link targets.
456
+ - The CI matrix gained a cross-platform unit-test subset on macOS and Windows. The README's "never tested on Windows" claim was replaced with what CI actually covers.
457
+ - New `docs/lifecycle-invariants.md` records the six invariants the destructive-operation contract upholds.
458
+ - `discard <directory>/` no longer produces doubled slashes, which also broke the Furnace fallback.
459
+ - The installed-package smoke tests skip with a clear message when npm is unavailable.
460
+ - The release workflow installs the exact npm version pinned in `packageManager`.
461
+ - New toolchain preflight for Firefox source hops, after a build died 8 s into configure on an out-of-date cbindgen.
462
+ - The per-patch Firefox-globals shim is extensible: `ChromeUtils` and `Localization` are named interfaces a project can add to.
463
+ - `fireforge test <directory>` selects exactly that directory. Mach matches test paths by string prefix, so a bare directory used to sweep in prefix-named siblings.
464
+ - Per-file test sharding announces itself, and `--no-shard` runs a single combined instance.
465
+ - A headed smoke run on a non-CI host warns that input during the window contaminates the console capture. New `fireforge run --headless`.
466
+ - The verbatim upstream MPL-2.0 header is accepted on new JS files regardless of the project license.
566
467
 
567
468
  ## 0.34.0
568
469
 
569
- - Reworked the macOS resource-monitor (psutil) crash mitigation to an **in-process guard FireForge owns on its mach dispatches**: a `fireforge_mach_guard.pth` + module pair is installed directly into every discovered mach virtualenv site-packages (objdir `_virtualenvs` and the `~/.mozbuild` state dir) before each dispatch, because mach's venv re-exec drops `PYTHONPATH` and the 0.33.0 `sitecustomize.py` route never loaded (the env-var shim is retained only for the pre-venv bootstrap phase). The guard now covers the whole crash family: `psutil.virtual_memory`/`swap_memory`/`cpu_percent`/`cpu_times`/`disk_io_counters` are wrapped module-wide (which also covers the direct `psutil.virtual_memory()` call in `mozbuild.base._run_make`), and `SystemResourceMonitor` **construction** is guarded via an import hook that pre-populates `poll_interval` and degrades a partially-constructed monitor to a no-op — so the `AttributeError: ... poll_interval` variant can no longer escape a polling-only patch.
570
- - Routed every mach build dispatch — plain `fireforge build`, `build --ui` (`mach build faster`), and the pre-test build step of `fireforge test --build` — through one protected path (`runProtectedMachBuild`) with a **uniform recognized-crash retry budget** (default 2 retries, matching `--harness-retries`; the pre-test step forwards the operator's `--harness-retries` value). Each retry spawns a fresh mach process AND re-installs the guard (re-discovering venvs), so a venv materialized by a crashed first attempt is guarded on the next one instead of every retry dying on the same wedged state. Non-crash build failures are never retried; exhausted budgets surface the crash shape and evidence line above the regular diagnostics. The suite-specific test dispatches (`mach test` / `xpcshell-test` / `mochitest`) install the same guard.
571
- - Investigated the field incident where a failed pre-test build was followed by a ~64-minute full rebuild: FireForge's protected path performs **no configure/clobber beyond what `mach build faster` itself does** (retries never re-run `prepareBuildEnvironment`/`mach configure`; there is no fallback-to-full-build in FireForge). The pre-test build now also passes the previous build baseline into `prepareBuildEnvironment` exactly like `fireforge build` does, so auto-configure runs under identical conditions on both paths instead of diverging.
572
- - Fixed the degraded-psutil fallback crashing mozsystemmonitor on the fallback itself (downstream report: `TypeError: '_DegradedReading' object is not subscriptable` from `_build_meta`'s `psutil.virtual_memory()[0]`, aborting `mach mochitest` at startup with zero `TEST-START` lines, and `_collect failed: '_DegradedReading' object is not iterable`). The guard's degraded reading is now a **real duck type of psutil's namedtuple results**: each wrapped function degrades to a zeroed instance of psutil's own result namedtuple (`svmem`/`sswap`/`scputimes`/`sdiskio`, resolved via `getattr` without touching the failing syscall), falling back to an extended `_DegradedReading` that supports subscripting, iteration, `len()`, `_fields` (svmem field order), and `_asdict()`. `cpu_percent` degrades to a plain `0.0` float (callers do arithmetic on it), and `disk_io_counters` deliberately degrades to a zeroed struct rather than `None`. Runtime-verified by new python3-executed regression tests covering a mozsystemmonitor-style `_build_meta`/`_collect` sequence against a degraded host.
573
- - Taught the harness-crash classifier the two `_DegradedReading` crash signatures (`object is not subscriptable` / `object is not iterable`), so a startup abort with zero `TEST-START` lines from this family classifies as crash-not-failure and retries; the `_collect failed: '_DegradedReading' ...` evidence line is exempted from the noise-stripping that otherwise drops `_collect failed` degradation chatter, while a completed green summary still vetoes the classification.
574
- - Fixed the sharded/retry crash classifier marking fully green runs as `CRASH (N attempts)`: a completed green embedded summary (a `TEST_START`/`TEST-START` execution signal, `Unexpected results: 0`, and `SUITE_END`) now **vetoes** signature-based crash classification for that attempt, and resource-monitor degradation lines (`UserWarning: psutil failed to run: ...`, `_collect failed: ...`, FireForge's own degradation notice) plus mach's caught telemetry tracebacks are stripped from crash evidence entirely. Exit codes follow the corrected verdict: a run whose every shard is green exits 0, and a non-sharded (`--no-shard`) run whose embedded summary completed green exits 0 even when mach's own exit code went non-zero on harness noise (a note explains the override) — parsing embedded summaries by hand is no longer necessary. Runs with a non-zero unexpected count or real `TEST-UNEXPECTED-*` lines still fail, and the post-green shutdown re-entry shape still classifies as a crash.
575
- - Fixed `fireforge test <directory>` dispatching xpcshell-only directories to the mochitest runner ("could not find any mochitests under the following test path(s)"): the manifest walk now starts at the directory itself, so a directory whose own manifest is an `xpcshell.toml` dispatches to `mach xpcshell-test` just like an explicit `test_*.js` path.
576
- - Added `fireforge register --create-manifest`: registering a module under a directory with no `moz.build` now scaffolds the directory manifest (MPL-header + `EXTRA_JS_MODULES.<namespace>` list) and wires the parent `DIRS` chain up to the nearest existing moz.build (creating intermediates); without the flag the "Manifest not found" error names it. Also added an **xpcshell test-file pattern** (`**/test_*.js` outside `browser/base/content/test/`) that inserts the `["test_*.js"]` section into the directory's `xpcshell.toml` in mozbuild sort order — or creates the manifest and wires `XPCSHELL_TESTS_MANIFESTS` with `--create-manifest` — and extended the browser-chrome manifest rule to fork-owned `browser.toml` manifests at **arbitrary depth** under `browser/base/content/test/` (previously one level only).
577
- - Improved export ergonomics three ways: (1) `re-export <patch> --files` now accepts the `export`-style space-separated path shape — path-shaped extra positionals are folded into the file list with a notice, and the "operates on exactly one target patch" error explains both accepted forms (the dry-run "removed files (N; become unmanaged)" preview is unchanged); (2) directory exports auto-exclude files already owned by other patches, printing a per-file `Excluding <file> ... (owned by <patch>)` notice and a `re-export --files` pointer instead of hitting the duplicate-new-file-creation refusal at placement lint (whose message now recommends an explicit file list rather than `--force-unsafe`; explicitly named files are never auto-excluded); (3) `export --name 203-ui-foo --category ui` no longer produces `203-ui-203-ui-foo.patch` — a leading `NNN-<category>-` prefix matching the selected category is stripped from the name before the filename builders prepend order and category.
578
- - Fixed `furnace deploy` leaving a stale toolkit `jar.mn` line after a component helper file rename (every build then failed at packaging with "File ... not found"): entry removal now keys on the `(widgets/<tag>/...)` source-mapping segment so ALL of a component's lines are removed regardless of basename, and the remove-then-re-add reconciliation therefore prunes renamed/removed helpers. `furnace validate` gained a `stale-jar-registration` error for registration lines pointing at files that no longer exist (`--fix` prunes them), and `doctor` gained a "Furnace jar.mn registrations" check (`--repair-furnace` prunes) — both previously reported success while the stale line survived.
579
- - Added `furnace scan --track`, which persists every discovered untracked component into the `stock` section of furnace.json non-interactively (same locked, rollback-journaled write path as the interactive confirm flow). Scan's help and its non-interactive output now state that scan is report-only by default and where the inventory is consumed, ending the report-persist-nothing-explain-nothing behavior.
580
- - Added `furnace chrome-doc create --browser-window`, a browser.xhtml-like scaffold for the document that ships as the fork's main browser window: `<html id="main-window">` root with the `windowtype="navigator:browser"`/`chromehidden`/geometry-`persist` attributes platform C++ reads before scripts run, while keeping the generic scaffold's bootstrap wiring, sentinel, and (already-correct) jar.mn registrations. When the target document matches a configured `tokenHostDocuments` entry and the flag was not passed, create warns that the browser-window variant is probably intended.
581
- - Added `furnace create --test-dir <dir>` to redirect the `--with-tests` scaffold (browser-chrome and xpcshell styles) to any engine-relative directory under `browser/base/content/test/` — nested manifests register correctly — and made all test scaffolds collision-safe: existing `browser.toml`/`xpcshell.toml`/`chrome.toml` manifests are appended to (with a shared-manifest notice) instead of scaffolded over, and existing `head.js`/test implementation files are never overwritten.
582
- - Fixed the degraded-psutil guard wedging `mach build` at exit on flapping hosts (0.34.1 post-upgrade field report: psutil's vm/swap syscalls flap between working and degraded, the fallback swap reading arrived svmem-shaped (8 fields) where mozsystemmonitor's parent reconstructs a 6-field `sswap` via `self._swap_type(*swap_mem)`, so every collector sample was rejected — `resourcemonitor.py:766: UserWarning: failed to read the received data` — upstream's handler breaks its drain loop, the pipe fills, the collector child blocks in `send()` and never reads the terminate sentinel, and mach hangs forever in its atexit `waitpid`). The guard's fallback readings are now **per-function, arity-correct, module-level namedtuples** (svmem 8 / sswap 6 / scputimes 4 / sdiskio 6; psutil's own result classes are still preferred when resolvable) that pickle by reference across the collector pipe and survive `type(reading)(*values)` reconstruction in **either** flapping direction (real type + degraded reading, or degraded type + real reading); `cpu_percent`/`cpu_times` fallbacks honour `percpu=True` (return `[]`) for the collector child's per-CPU sampling, and the last-resort `_DegradedReading` now tolerates positional-field construction. The report's "factory resolution bypassed in the collector child" observation is consistent with the two-copy load (`sitecustomize` via PYTHONPATH plus the in-venv `.pth`) or degraded-import-time `psutil._psplatform` resolution — the fix no longer depends on psutil internals resolving, so it is robust to both.
583
- - Added **collector suppression on degradation** to the guard: any observed psutil degradation sets a process-wide flag, after which monitors are kept inert — `start` never spawns the mozsystemmonitor collector child — and a degraded transition (any wrapped monitor method raising) or a raising `stop` best-effort terminates a live collector child (`_process.terminate()` + bounded join) and drains the parent end of the pipe, so a malformed sample stream can never fail the command or wedge shutdown even if a future shape mismatch slips through. (The upstream enabler — mozsystemmonitor warning then `break`ing out of its drain loop without draining, leaving the pipe to fill — is worth reporting to Mozilla but is not FireForge-fixable.)
584
- - Fixed the same degradation **failing `mach build` after a fully successful compile** ("Error running mach" with complete, valid `dist/` artifacts): degraded aggregate methods now return zeroed shapes instead of `None` where callers subscript the result (`aggregate_io` → zeroed io namedtuple, so mozbuild's `log_resource_usage` no longer dies on `usage["io"].read_bytes` at `mozbuild/controller/building.py:526`; `aggregate_cpu_percent`/`aggregate_cpu_times` return `[]`/`0.0`/zeroed shapes per their `per_cpu` mode), and the guard additionally wraps `mozbuild.controller.building.BuildMonitor.log_resource_usage` to warn-and-continue on any exception. Runtime-verified by new python3-executed regression tests covering per-function fallback arity, pickle/`type(*values)` round-trips in both flapping directions, collector suppression (stub monitor with upstream-named `_process`/`_pipe`), and the BuildMonitor wrap.
585
- - Taught the harness-crash classifier the post-success `AttributeError: 'NoneType' object has no attribute 'read_bytes'` traceback (protected builds retry it as crash-not-failure — with the fixed guard the incremental retry is cheap and green; real test failures still take precedence), and exempted the upstream drain-loop `failed to read the received data` warning from crash evidence so runs that complete despite the rejected-sample stream never classify as crashes.
470
+ - The macOS resource-monitor crash mitigation is now an in-process guard installed into every discovered mach virtualenv before each dispatch.
471
+ - Every mach build dispatch goes through one protected path with a uniform recognized-crash retry budget.
472
+ - Fixed the degraded-psutil fallback crashing mozsystemmonitor on itself, and taught the crash classifier its two signatures.
473
+ - A fully green run is no longer marked `CRASH (N attempts)`: a completed green summary vetoes signature-based classification.
474
+ - `fireforge test <directory>` dispatches xpcshell-only directories to `mach xpcshell-test`.
475
+ - New `fireforge register --create-manifest` scaffolds a directory `moz.build` and wires the parent `DIRS` chain.
476
+ - `re-export <patch> --files` accepts the space-separated path shape, with clearer dry-run previews.
477
+ - `furnace deploy` no longer leaves a stale toolkit `jar.mn` line after a helper file rename.
478
+ - New `furnace scan --track` persists discovered untracked components non-interactively.
479
+ - New `furnace chrome-doc create --browser-window` scaffolds a browser.xhtml-like main window document.
480
+ - New `furnace create --test-dir <dir>` redirects the test scaffold, and all test scaffolds are collision-safe.
481
+ - Fixed the degraded-psutil guard wedging `mach build` at exit and failing a build after a fully successful compile.
586
482
 
587
483
  ## 0.33.0
588
484
 
589
- - Fixed `furnace deploy`/`apply` to prune a dangling per-widget locale `jar.mn` entry for a `localized: true` widget that uses the `sharedFtl` browser-bundle convention. A stale `locale/@AB_CD@/toolkit/global/<name>.ftl` line (written by an older FireForge) pointed at a `.ftl` that never exists, so `mach build` failed hard (`Cannot find toolkit/global/<name>.ftl`) and blocked every build; apply now drops that per-widget line idempotently while leaving the shared bundle's own line untouched.
590
- - Added `fireforge token add --variant '[data-skin=precision]'` (also `[data-private]`) to author a declaration inside an attribute-keyed `:root[<attr>]` block, creating the block if absent or appending to it if present — so skin/state token overrides no longer have to be hand-edited. The selector is validated and quoted-normalized, variant overrides are CSS-only (the base token owns its docs row), and `token coverage` still accepts the result.
591
- - Fixed `fireforge register` to sort `EXTRA_JS_MODULES` (and the sibling sorted moz.build / jar.mn list helpers) **case-insensitively**, matching mozbuild's `UnsortedError` rule — so e.g. `AppearanceController.sys.mjs` lands before `AppMenuIntegration.sys.mjs` (`appe` < `appm`) instead of in raw byte order, which made `mach configure` abort. The build wrapper now runs `mach configure` with output capture and surfaces the underlying mozbuild error text (e.g. `UnsortedError`) instead of a bare "configure failed with exit code N".
592
- - Changed `fireforge typecheck` to staleness-check and regenerate the Furnace-managed jsconfig (`furnace.json` → `typecheckJsconfig`) before running, using the same reconciler `furnace deploy`/`sync` use — so a stale generated `compilerOptions.paths` shim no longer reports phantom type errors in files the session never touched.
593
- - Fixed `fireforge patch split --dry-run` projected lint to model the forward edge a split introduces into the freshly-created patch: it auto-declares the staged forward-import (the new patch's owner is known) so the dry-run diagnostics match the real `lint --per-patch --max-warnings 0` gate, and persists the declaration on commit so a sound split reads as sound instead of as a cross-patch "has no exported member" error.
594
- - Fixed `fireforge test <one xpcshell .js>` to recognize the suite-specific xpcshell result-summary block (`TEST_END`, `Ran N checks`, `Unexpected results: 0`) as a valid execution signal. The post-run guard keyed only on `TEST-START` lines — which the xpcshell dispatch never prints — so a passing single-file xpcshell run was reported as "finished without starting any of the requested tests" and exited 1; it now exits 0 while a failing summary still flows to the failure diagnosis.
595
- - Changed `fireforge build` / `build --ui` to inject a resource-monitor degrade shim (a `sitecustomize.py` on the mach subprocess `PYTHONPATH`) so a host `psutil.virtual_memory()` failure (`host_statistics64(HOST_VM_INFO64) syscall failed`) degrades to a non-fatal warning instead of aborting `mach build` / `mach build faster` in `start_resource_recording`. The build path no longer depends on which mach entry was used.
596
- - Added per-project `typecheck.projectOverrides` so a project can override or opt out of the shared `extraShim` (`null` opts out, a path overrides). The composed shim is now built per project rather than injected identically everywhere, so a project that narrows `lib`/`types` (e.g. `lib: ["ES2024","DOM"]`) is not forced to absorb another project's Gecko declaration libraries (Element/Node identity splits, nsIPrincipal mismatch).
485
+ - `furnace deploy` and `apply` prune a dangling per-widget locale `jar.mn` entry for a `sharedFtl` widget, which used to fail the build outright.
486
+ - New `fireforge token add --variant '[data-skin=precision]'` authors a declaration inside an attribute-keyed `:root` block, creating it if absent.
487
+ - `fireforge register` sorts `EXTRA_JS_MODULES` case-insensitively, matching mozbuild's rule.
488
+ - `fireforge typecheck` regenerates the Furnace-managed jsconfig before running, so a stale shim stops reporting phantom errors.
489
+ - `patch split --dry-run` models the forward edge the split introduces, so the preview matches the real gate.
490
+ - `fireforge test <one xpcshell .js>` recognizes the xpcshell result-summary block as an execution signal.
491
+ - `fireforge build` injects a resource-monitor degrade shim so a host `psutil` failure warns instead of aborting the build.
492
+ - New per-project `typecheck.projectOverrides` to override or opt out of the shared shim.
597
493
 
598
494
  ## 0.32.0
599
495
 
600
- - Fixed `fireforge lint <files>` to evaluate the `large-patch-files` rule against each file's resolved owning patch instead of the ad-hoc file-list cardinality, so a cross-patch selection no longer synthesizes a phantom oversized patch (e.g. eight files across four patches no longer report `Patch affects 8 files` when no single owner exceeds the threshold). The size rules now run per owning patch — using its real `filesAffected` count, diff, and `tier` — and unowned files are evaluated together as one prospective new patch.
601
- - Taught the ad-hoc `lint <files>` path to honour each file's owning-patch `lintIgnore`, so a check waived via `fireforge patch lint-ignore` is suppressed consistently across `lint <files>`, `lint --per-patch`, and `re-export --dry-run` — the three invocation modes now agree on the same warning set for the same files.
602
- - Added `fireforge lint --per-patch --patches <name…>` to lint a named subset of the queue (matched by filename or manifest `name`) instead of forcing a full queue run to verify a few touched patches; queue-level policy and cross-patch findings are scoped to files the subset touches. The "`--per-patch` cannot be combined with explicit file paths" error now points at the flag.
603
- - Rebuilt the per-patch `checkJs` pass to construct the queue-wide TypeScript program **once per `lint --per-patch` run** (lazily, on first cache miss) and attribute each finding to its owning patch, instead of rebuilding the whole-queue program for every patch — a single queue-wide type regression now surfaces once against its owner rather than duplicated once per patch, and full runs no longer pay to recompile the same program ~N times.
604
- - Resolved cross-patch `resource:///` / `chrome://` imports during `export`/`re-export` lint by threading the whole-queue ownership context into the isolated patch's `checkJs` pass (the export/re-export half of the cross-patch resolution that 0.31.0 landed for the full-queue path), while scoping reported diagnostics to the patch under export. Re-exporting a widget-runtime patch whose module imports another patch's `resource:///` module now type-checks against the real owning sources **without** a hand-generated ambient `declare module` stub shim. `patchLint.checkJsCompilerOptions` additionally accepts a reviewed `paths` mapping (host-resolved against the engine directory, so no `baseUrl` is needed — TS5090-safe), and `extraShim` / `typecheck.extraShim` now inline triple-slash `/// <reference>` directives instead of silently dropping them at the synthetic shim path.
605
- - Fixed `furnace sync` to emit `./`-prefixed relative `compilerOptions.paths` values (e.g. `./components/custom/moz-widget/moz-widget.mjs`) so a synced jsconfig type-checks under TypeScript without `baseUrl` (no TS5090) and without `ignoreDeprecations` on TS6 (no TS5101); the sync reconciler now treats a leading `./` as insignificant, so neither a freshly-synced value nor a hand-written prefix churns as "stale" on the next run. Removes the downstream `baseUrl: "."` + `ignoreDeprecations: "6.0"` workaround.
606
- - Made `fireforge test` auto-dispatch a single-suite run to the suite-specific mach command (`mach xpcshell-test` / `mach mochitest`), which degrade a broken macOS mozlog resource monitor to a warning instead of crashing generic `mach test` at startup — so a sharded single-suite run reaches its tests instead of burning the whole `--harness-retries` budget on a startup traceback. Mixed runs are still rejected and a path-less "run all" stays on `mach test`; `--generic-mach-test` forces the generic command.
607
- - Extended the harness-crash classifier and `--harness-retries` budget to cover the pre-test `--build` step, so `fireforge test --build` retries a `mach build faster` that dies with the same resource-monitor startup crash instead of hard-failing with a bare "Pre-test build failed". Non-crash build failures are not retried.
496
+ - `fireforge lint <files>` evaluates `large-patch-files` against each file's owning patch instead of the file-list size.
497
+ - The ad-hoc `lint <files>` path honours each file's owning-patch `lintIgnore`, so all three lint modes agree.
498
+ - New `lint --per-patch --patches <name…>` lints a named subset of the queue.
499
+ - The per-patch checkJs pass builds the queue-wide program once per run instead of once per patch.
500
+ - Cross-patch `resource:///` and `chrome://` imports resolve during export and re-export lint.
501
+ - `furnace sync` emits `./`-prefixed relative `compilerOptions.paths` values.
502
+ - `fireforge test` auto-dispatches a single-suite run to the suite-specific mach command.
503
+ - The crash classifier and `--harness-retries` budget cover the pre-test `--build` step.
608
504
 
609
505
  ## 0.31.0
610
506
 
611
- - Made `patch compact` range-aware: with `patchPolicy.ranges` configured, each category range compacts independently (anchored at its first occupied ordinal, treating reserved orders as non-gaps), so a mid-range gap under `allowGaps: false` can finally be closed without projecting patches across category boundaries. Reserved-range patches and out-of-range strays are left in place with a warning. Without ranges the historical whole-queue renumber from 1 is unchanged.
612
- - Rewrote `stagedDependencies.forwardImports[].owner` references during every patch renumber (compact, reorder, placement export, rename) and in reorder's dry-run projection, so staged-dependency declarations survive renumbering instead of dangling and surfacing pre-existing forward imports as new errors. `patch delete` now warns when other patches still name the deleted patch as an owner.
613
- - Added `fireforge patch split <source> --files <paths...> --name <name>` to move files out of a patch into a brand-new patch as one transaction: worktree-derived shrink of the source, new-patch creation with `--order`/`--before`/`--after` placement (default: after the source), and staged-dependency owner rewrites in dependent patches — validated against the final projection only, with dry-run support and reverse-order rollback. `patch move-files` now points at it for the move-to-new-patch case.
614
- - Scoped plain `re-export --scan` to the patch's exact directory footprint: git pathspecs recurse, so a claimed file in a shallow directory used to sweep every unmanaged file in the subtree into the scan candidates of whichever patch was exported first. Deeper paths now require an explicit `--scan-file` / `--scan-files` assignment; the broad-scan confirmation guard is unchanged.
615
- - Resolved imports of patch-owned modules to their real sources in the per-patch `checkJs` pass (unique-basename matching for `chrome://`/`resource://` specifiers, with a `.mjs` → `.sys.mjs` fallback), so JSDoc `value is …` type-guard predicates and `@template` generics survive module boundaries instead of degrading to `any`. Unknown or ambiguous specifiers keep the loose ambient-wildcard typing. Note: previously invisible cross-module type errors in patch-owned modules may now surface.
616
- - Added `ChromeUtils.getClassName`, `ChromeUtils.defineLazyGetter`, and the `Localization` constructor to the shipped Firefox-globals typecheck shim, so per-patch lint accepts these stable chrome globals without local casts. Projects can still extend the ambient set via `patchLint.checkJsExtraShim` / `typecheck.extraShim`.
617
- - Fixed the exported-method JSDoc `@param` extractor to scan balanced braces, so inline object types containing nested generics (e.g. `@param {{ id: string, args?: Record<string, string | number | boolean> }} message`) no longer fail `jsdoc-class-method-param-mismatch`; optional `[name]` and defaulted `[name=x]` forms now parse too.
618
- - Rewrote the `observer-topic-naming` check to parse balanced multi-line call sites and inspect the actual topic argument (the second) instead of the first string literal on the line, and added a known-Firefox-topics allowlist (`idle-daily`, the `quit-application` family, lifecycle and `http-on-*` topics) so simulating upstream notifications in tests is never flagged. Constant-named topics remain exempt.
619
- - Taught `fireforge test` to classify harness runs from their output instead of trusting exit codes or summary lines: recognized harness-crash shapes (macOS mozlog resource-monitor/psutil startup tracebacks, pre-test no-output hangs that still print `Passed: 0`, and post-green "Application shut down (without crashing) in the middle of a test!" re-entries after a focus stall) are retried with a bounded budget (`--harness-retries <n>`, default 2), and a zero-exit run with no `TEST-START` now fails instead of passing silently.
620
- - Sharded multi-path `fireforge test` invocations into sequential single-file harness runs with per-shard retries, per-shard diagnosis, and an aggregate PASS/FAIL/CRASH summary, avoiding the cross-file profile/pref bleed that destabilized later files in combined runs. `--no-shard` restores the single combined invocation.
621
- - Added `fireforge test --perf-samples <path>`, which publishes the resolved artifact path to the harness process as `<BINARYNAME>_PERF_SAMPLE_JSON` so downstream perf-budget checkers no longer maintain their own env contract.
622
- - Fixed named `furnace deploy <component>` to run the same pipeline as deploy-all, so renaming or deleting a component file now prunes the stale engine copy, its `jar.mn` line, and (for a removed main module) the `customElements.js` registration, instead of leaving orphans a later re-export would capture. Unchanged components now skip instead of force-reapplying, and `furnace validate` flags engine-side orphans (`orphaned-engine-file`) left by pre-0.31.0 deploys.
623
- - Added shared CSS fragments for Furnace widgets: a `/* @fireforge-include <fragment>.css */` directive in a widget stylesheet expands the named `components/shared/` fragment into the deployed copy (fenced, idempotent), keeping the workspace single-sourced across shadow-DOM-isolated widgets. Fragment edits surface as ordinary component drift and redeploy refreshes every consumer; `furnace validate` reports `missing-fragment` and `stale-fragment-expansion`.
624
- - Added automatic jsconfig `paths` maintenance for multi-file components: with `furnace.json#typecheckJsconfig` set, deploy and sync keep `compilerOptions.paths` entries mapping each registered module's `chrome://global/content/elements/<file>.mjs` URL to its workspace source (no `baseUrl` needed), pruning entries for removed helpers and preserving all hand-written configuration; `furnace validate` reports drift as `jsconfig-paths-drift`.
625
- - Fixed `token add` double-prefixing bare names that already start with the configured `tokenPrefix` text; such names are now treated as fully qualified with an informational note.
626
- - Added `token add --create-category`, which declares the missing category banner inside the `:root` block and inserts the token in the same single write; the "Category not found" error now advertises the flag.
627
- - Added two static release gates: `npm run deadcode:check` (knip, with a `knip.json` tuned to the project's entry points) fails on unused exports/files/dependencies, and `npm run cycles:check` (dpdm) fails on circular imports. Both run in `release:check` and the pre-push hook.
628
- - Internalized 45 exports that had no consumers outside their defining module, and untangled the seven type-only import cycles (`BuildBaseline`, `RegisterResult`, `ResolvedTestStyle`, and `LintCommandOptions` moved to leaf/type modules) so the cycle gate starts from zero.
629
- - Refactored the twelve functions with cyclomatic complexity above 30 (worst: `exportCommand` at 48) into focused helpers — behavior-preserving statement moves, verified by the existing suites — and now enforce `complexity: ["error", 30]` via ESLint. The long-standing `max-lines-per-function` suppression on `exportCommand` is gone with the split.
630
- - Deduplicated four copied sequences: the patch-subcommand preamble (seven commands now share `requirePatchQueue`/`requirePatchTarget`), the changed-since-baseline collector (`build-audit`/`build-prepare`/`test-stale-check` share `collectChangedEnginePaths`), the export supersede+overlap gate (`export`/`export-all` share `runSupersedeAndOverlapGates`), and two same-file clones in `token-dark-mode.ts` and `config-validate-patch-policy.ts`.
631
- - Restricted `process.exit()` to `bin/fireforge.ts` via lint (`no-restricted-properties`), turning the previously comment-enforced invariant into a build failure.
632
- - Bumped the TypeScript `target`/`lib` from ES2022 to ES2023 (Node ≥ 22.22.1 fully implements it) and dropped the redundant `@typescript-eslint/eslint-plugin` / `@typescript-eslint/parser` devDependencies already provided by the `typescript-eslint` meta-package.
507
+ - `patch compact` is range-aware: each configured category range compacts independently.
508
+ - Staged-dependency `owner` references are rewritten during every renumber instead of dangling.
509
+ - New `fireforge patch split <source> --files <paths…> --name <name>` moves files into a new patch as one transaction.
510
+ - Plain `re-export --scan` is scoped to the patch's exact directory footprint. Git pathspecs recurse, so a shallow claimed file used to sweep the whole subtree.
511
+ - The per-patch checkJs pass resolves imports of patch-owned modules to their real sources, so JSDoc type guards and generics survive module boundaries.
512
+ - Added `ChromeUtils.getClassName`, `ChromeUtils.defineLazyGetter`, and the `Localization` constructor to the shipped shim.
513
+ - The JSDoc `@param` extractor scans balanced braces, so nested generics parse.
514
+ - `observer-topic-naming` parses balanced multi-line call sites and inspects the actual topic argument, with a known-Firefox-topics allowlist.
515
+ - `fireforge test` classifies harness runs from their output rather than trusting exit codes.
516
+ - Multi-path test runs are sharded into sequential single-file runs with per-shard retries. `--no-shard` restores the combined invocation.
517
+ - New `fireforge test --perf-samples <path>` publishes the artifact path to the harness.
518
+ - Named `furnace deploy <component>` runs the same pipeline as deploy-all, so renames and deletions prune their engine copies.
519
+ - Shared CSS fragments for Furnace widgets through a `/* @fireforge-include <fragment>.css */` directive.
520
+ - Automatic jsconfig `paths` maintenance for multi-file components.
521
+ - `token add` no longer double-prefixes a name that already starts with the configured prefix.
522
+ - New `token add --create-category` declares the banner and inserts the token in one write.
523
+ - Two new release gates: `deadcode:check` (knip) and `cycles:check` (dpdm).
524
+ - Internalized 45 exports with no outside consumers and untangled seven type-only import cycles.
525
+ - Refactored the twelve functions above cyclomatic complexity 30 and now enforce the limit.
526
+ - `process.exit()` is restricted to `bin/fireforge.ts` by lint.
527
+ - TypeScript `target`/`lib` moved from ES2022 to ES2023.
633
528
 
634
529
  ## 0.30.0
635
530
 
636
- - Added safe repo-local per-patch lint result caching for `lint --per-patch`, plus `--no-cache` and `lint cache clear` escape hatches while preserving release-gate severity accounting and queue-wide checks. Warm cache hits now skip per-patch diff generation as well as lint rule execution, guarded by patch, config, engine content, queue ownership, and engine HEAD inputs.
531
+ - Added repo-local per-patch lint result caching, with `--no-cache` and `lint cache clear`. Warm hits skip diff generation as well as rule execution.
637
532
 
638
533
  ## 0.29.0
639
534
 
640
- - Improved `fireforge test --build` failure reporting so post-rebuild focused test failures name the rebuild command, requested paths, and first failure line separately from stale-artifact rebuild advice.
535
+ - `test --build` failure reporting names the rebuild command, requested paths, and first failure line separately from stale-artifact advice.
641
536
 
642
537
  ## 0.28.0
643
538
 
644
- - Restored mach lint compatibility for FireForge-managed Git-backed Firefox checkouts by materializing a `.hgignore` copy of `.gitignore` when Firefox's ignorefile linter config is present.
645
- - Added the product-resolved Firefox source archive URL to `source set` output so pinned checksums can be verified against the exact archive target before download.
646
- - Added dry-run locking for `re-export` so parallel previews serialize engine git inspection instead of racing on `.git/index.lock`.
647
- - Added `re-export --scan --scan-files <manifest>` for dry-runnable bulk generated-file assignment across owner patches, with ambiguity and ownership refusals.
648
- - Improved `fireforge test` diagnostics for harness startup failures and zero selected tests run, including the actionable harness line before generic failure output.
649
- - Improved build failure summaries so real make/mach failures and target context outrank trailing warning-only output.
650
- - Normalized whitespace-only blank hunk payloads in generated patch artifacts while documenting `npm run whitespace:check` as the release-safe source whitespace gate.
651
- - Fixed re-export serialization so blank context lines keep their unified-diff context marker, preventing FireForge-generated patches from producing false patch-owned drift warnings during `verify`.
652
- - Fixed partial `re-export` manifest writes so legacy source metadata is preserved on unselected patch rows unless `--stamp` or another source metadata update explicitly targets them.
653
- - Added regression coverage for targeted and full stamped re-export round-trips with blank context lines.
539
+ - Restored mach lint compatibility by materializing a `.hgignore` copy of `.gitignore` when Firefox's ignorefile linter config is present.
540
+ - `source set` prints the resolved archive URL so a pinned checksum can be verified before download.
541
+ - `re-export --dry-run` takes a lock so parallel previews serialize instead of racing on `.git/index.lock`.
542
+ - New `re-export --scan --scan-files <manifest>` for bulk generated-file assignment.
543
+ - Better `fireforge test` diagnostics for harness startup failures and zero-tests-run.
544
+ - Build failure summaries put real make and mach failures above trailing warnings.
545
+ - Blank context lines keep their unified-diff marker, so generated patches stop producing false drift warnings.
546
+ - Partial `re-export` writes preserve legacy source metadata on unselected rows.
654
547
 
655
548
  ## 0.27.3
656
549
 
657
- - Fixed `firefox-devedition` source downloads so archive resolution uses `/pub/devedition/releases`.
658
- - Kept existing `engine/` trees intact during `download --force` until the replacement archive downloads, validates, and extracts successfully.
659
- - Improved checksum mismatch diagnostics with resolved URL and product context.
550
+ - `firefox-devedition` downloads resolve against `/pub/devedition/releases`.
551
+ - `download --force` keeps the existing `engine/` tree until the replacement extracts successfully.
552
+ - Checksum mismatch diagnostics include the resolved URL and product.
660
553
 
661
554
  ## 0.27.0
662
555
 
663
- - Added first-class `firefox-devedition` source support and atomic `fireforge source set`.
664
- - Fixed `source set --version` so the subcommand accepts both space and equals forms without colliding with the root CLI version flag.
665
- - Added `sourceProduct` and `sourceVersion` patch metadata while preserving `sourceEsrVersion` as a deprecated compatibility alias.
666
- - Renamed source-rebase reporting away from ESR-only wording and clarified summaries with total patch counts.
667
- - Unified status, ownership, doctor, and verify worktree classification, including an explained patch-owned drift state for manually resolved or re-exported files.
668
- - Hardened build diagnostics so backend regeneration success/failure and failed make/mach commands include exit codes, tails, log hints, and verbose rerun suggestions.
669
- - Improved `download --force` git indexing progress with phase, count, and heartbeat output.
670
- - Added cache metadata progress for archive validation, SHA-256 calculation, and sidecar JSON writes.
671
- - Added elapsed progress for extraction, initial source commits, and rebase/re-export patch refreshes.
672
- - Added `re-export --files --allow-shrink` so patch ownership shrinkage is refused unless explicitly acknowledged, with clearer dry-run previews.
673
- - Surfaced likely new sibling files during plain re-export and aligned verify/status ownership reporting for unowned worktree changes.
556
+ - First-class `firefox-devedition` support and atomic `fireforge source set`.
557
+ - `source set --version` accepts both space and equals forms without colliding with the root version flag.
558
+ - New `sourceProduct` and `sourceVersion` patch metadata; `sourceEsrVersion` stays as a deprecated alias.
559
+ - Unified status, ownership, doctor, and verify worktree classification, including an explained patch-owned drift state.
560
+ - Build diagnostics carry exit codes, tails, log hints, and verbose rerun suggestions.
561
+ - Progress output for git indexing, archive validation, extraction, and rebase refreshes.
562
+ - New `re-export --files --allow-shrink`, so ownership shrinkage must be acknowledged.
674
563
  - Preserved patch-owned branding `configure.sh` settings during build preflight.
675
- - Added custom element registration support for Furnace validate/apply and Firefox 152-style array-backed ESM registrations.
676
- - Normalized generated patch artifacts so blank context lines do not trip raw whitespace checks.
677
- - Improved rebase conflict summaries and added `doctor --post-rebase-audit` for common registration surfaces.
564
+ - Custom element registration support for Furnace validate and apply.
565
+ - Improved rebase conflict summaries and new `doctor --post-rebase-audit`.
678
566
 
679
567
  ## 0.26.0
680
568
 
681
- - Added targeted `re-export --scan --scan-file <path>` for reviewed single-patch new-file assignment without broad sibling collection.
682
- - Added a FireForge-owned worktree whitespace gate that excludes generated `patches/*.patch` diff syntax from repository whitespace checks.
683
- - Kept generated patch context lines unchanged while making release checks use the FireForge whitespace gate.
569
+ - New targeted `re-export --scan --scan-file <path>` for reviewed single-patch new-file assignment.
570
+ - A FireForge-owned whitespace gate excludes generated `patches/*.patch` diff syntax from repository whitespace checks.
684
571
 
685
572
  ## 0.25.0
686
573
 
687
- - Kept `MOZ_APP_VENDOR` in `browser/moz.configure` for Firefox ESR 140 project-flag trees instead of generated branding `configure.sh`.
688
- - Added a regression for stale xpcshell install symlink repair under shared `_tests/testing/mochitest/` harness paths.
574
+ - Kept `MOZ_APP_VENDOR` in `browser/moz.configure` for Firefox ESR 140 trees instead of generated branding `configure.sh`.
575
+ - Added a regression for stale xpcshell install symlink repair.
689
576
 
690
577
  ## 0.24.0
691
578
 
692
- - Moved branding vendor identity into generated branding configure scripts and made `browser/moz.configure` vendor patching optional.
693
- - Added metadata-backed staged forward-import declarations plus `patch staged-dependency` editing.
579
+ - Moved branding vendor identity into generated branding configure scripts.
580
+ - Added metadata-backed staged forward-import declarations and `patch staged-dependency`.
694
581
  - Added stale xpcshell `_tests` symlink repair with a single safe retry.
695
- - Added `patch move-files` for previewable ownership-transfer repair plans.
696
- - Improved queue self-containment guidance for staged dependencies and patch repairs.
582
+ - Added `patch move-files` for previewable ownership-transfer plans.
697
583
 
698
584
  ## 0.23.0
699
585
 
700
- - Improved xpcshell test argument filtering and mixed-harness diagnostics.
586
+ - Improved xpcshell argument filtering and mixed-harness diagnostics.
701
587
  - Locked pre-test build phases and improved stale harness diagnostics.
702
588
  - Fixed binary-safe re-export for new untracked files.
703
589
  - Improved additive `re-export --files` and lint warning guidance.
@@ -708,7 +594,6 @@ On the test side: path expectations and mock keys are rendered through a new `na
708
594
  - Shared patch queue health checks between `verify` and doctor recovery.
709
595
  - Improved Furnace repair for empty custom orphan directories.
710
596
  - Enforced patch policy during `patch compact`.
711
- - Shortened README and changelog into maintainer-facing docs.
712
597
 
713
598
  ## 0.21.0
714
599
 
@@ -716,7 +601,6 @@ On the test side: path expectations and mock keys are rendered through a new `na
716
601
  - Added versioned `status --json` output.
717
602
  - Added configurable patch queue policy.
718
603
  - Hardened export, Furnace deploy, and UI build preflights.
719
- - Improved Furnace rename, override removal, and interrupt diagnostics.
720
604
 
721
605
  ## 0.20.0
722
606
 
@@ -748,12 +632,11 @@ On the test side: path expectations and mock keys are rendered through a new `na
748
632
  - Added `patch tier` and per-patch lint-ignore editing.
749
633
  - Fixed `export-all` ownership and Furnace exclusion cases.
750
634
  - Improved build, test, and status diagnostics.
751
- - Cleaned up fork-specific examples.
752
635
 
753
636
  ## 0.16.0
754
637
 
755
638
  - Hardened release and config security paths.
756
- - Fixed `config --force` read/write behaviour.
639
+ - Fixed `config --force` read and write behaviour.
757
640
  - Improved download, status, and setup feedback.
758
641
  - Added safer Furnace init, create, preview, and chrome-doc behaviour.
759
642
  - Improved lint, build audit, and rebase reliability.
@@ -780,7 +663,7 @@ On the test side: path expectations and mock keys are rendered through a new `na
780
663
  - Added tiered lint severity for large files and patches.
781
664
  - Added raw-colour allowlists and inline suppression.
782
665
  - Added `fireforge patch compact`.
783
- - Improved register support for XHTML, CSS, and clearer advice.
666
+ - Improved register support for XHTML and CSS.
784
667
 
785
668
  ## 0.12.0
786
669