@hominis/fireforge 0.39.0 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (279) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +35 -5
  3. package/dist/bin/fireforge.js +25 -4
  4. package/dist/build-info.json +9 -0
  5. package/dist/src/cli.js +27 -3
  6. package/dist/src/commands/bootstrap.js +5 -1
  7. package/dist/src/commands/discard.js +2 -22
  8. package/dist/src/commands/doctor/post-rebase-audit.js +27 -4
  9. package/dist/src/commands/doctor-check-core.d.ts +20 -0
  10. package/dist/src/commands/doctor-check-core.js +28 -0
  11. package/dist/src/commands/doctor-external-toolchains.js +2 -0
  12. package/dist/src/commands/doctor-furnace-manifest-sync.js +144 -46
  13. package/dist/src/commands/doctor-furnace.js +13 -11
  14. package/dist/src/commands/doctor-orphaned-harness.js +2 -1
  15. package/dist/src/commands/doctor.js +2 -2
  16. package/dist/src/commands/export-all.js +9 -1
  17. package/dist/src/commands/export-flow.js +27 -9
  18. package/dist/src/commands/export-placement-conflicts.d.ts +17 -0
  19. package/dist/src/commands/export-placement-conflicts.js +74 -0
  20. package/dist/src/commands/export-placement-gate.js +1 -1
  21. package/dist/src/commands/export-shared.d.ts +8 -1
  22. package/dist/src/commands/export-shared.js +30 -10
  23. package/dist/src/commands/export.js +2 -2
  24. package/dist/src/commands/furnace/apply.js +4 -3
  25. package/dist/src/commands/furnace/chrome-doc-remove.js +13 -2
  26. package/dist/src/commands/furnace/chrome-doc.js +13 -2
  27. package/dist/src/commands/furnace/create-browser-test.d.ts +5 -0
  28. package/dist/src/commands/furnace/create-browser-test.js +5 -0
  29. package/dist/src/commands/furnace/create.js +2 -2
  30. package/dist/src/commands/furnace/diff.js +2 -1
  31. package/dist/src/commands/furnace/init.js +4 -6
  32. package/dist/src/commands/furnace/override.js +2 -2
  33. package/dist/src/commands/furnace/remove-mutations.d.ts +40 -0
  34. package/dist/src/commands/furnace/remove-mutations.js +171 -0
  35. package/dist/src/commands/furnace/remove.js +21 -128
  36. package/dist/src/commands/furnace/rename-browser-test.js +54 -5
  37. package/dist/src/commands/furnace/scan.js +27 -36
  38. package/dist/src/commands/furnace/status.js +30 -28
  39. package/dist/src/commands/furnace/validate.js +25 -18
  40. package/dist/src/commands/import.js +6 -20
  41. package/dist/src/commands/lint-per-patch.js +73 -51
  42. package/dist/src/commands/lint-per-run-checkjs.d.ts +64 -0
  43. package/dist/src/commands/lint-per-run-checkjs.js +102 -0
  44. package/dist/src/commands/lint-report.d.ts +21 -0
  45. package/dist/src/commands/lint-report.js +62 -0
  46. package/dist/src/commands/lint.js +26 -22
  47. package/dist/src/commands/manifest.js +3 -1
  48. package/dist/src/commands/package.js +9 -16
  49. package/dist/src/commands/patch/compact.js +5 -5
  50. package/dist/src/commands/patch/delete.js +5 -5
  51. package/dist/src/commands/patch/lint-ignore.js +3 -2
  52. package/dist/src/commands/patch/move-files-create.js +22 -4
  53. package/dist/src/commands/patch/move-files-into.js +14 -10
  54. package/dist/src/commands/patch/move-files.js +6 -5
  55. package/dist/src/commands/patch/rename.d.ts +2 -2
  56. package/dist/src/commands/patch/rename.js +152 -51
  57. package/dist/src/commands/patch/reorder.d.ts +9 -0
  58. package/dist/src/commands/patch/reorder.js +14 -10
  59. package/dist/src/commands/patch/split-plan.d.ts +13 -6
  60. package/dist/src/commands/patch/split-plan.js +22 -12
  61. package/dist/src/commands/patch/split.d.ts +1 -1
  62. package/dist/src/commands/patch/split.js +23 -9
  63. package/dist/src/commands/patch/staged-dependency-validate.d.ts +15 -0
  64. package/dist/src/commands/patch/staged-dependency-validate.js +49 -0
  65. package/dist/src/commands/patch/staged-dependency.js +112 -14
  66. package/dist/src/commands/re-export-adjacent.d.ts +34 -0
  67. package/dist/src/commands/re-export-adjacent.js +105 -0
  68. package/dist/src/commands/re-export-bulk-scan.d.ts +13 -0
  69. package/dist/src/commands/re-export-bulk-scan.js +142 -2
  70. package/dist/src/commands/re-export-drift.d.ts +74 -0
  71. package/dist/src/commands/re-export-drift.js +234 -0
  72. package/dist/src/commands/re-export-files.js +5 -3
  73. package/dist/src/commands/re-export-lint.d.ts +97 -0
  74. package/dist/src/commands/re-export-lint.js +210 -0
  75. package/dist/src/commands/re-export-options.js +11 -0
  76. package/dist/src/commands/re-export-register.d.ts +18 -0
  77. package/dist/src/commands/re-export-register.js +61 -0
  78. package/dist/src/commands/re-export-scan.js +13 -7
  79. package/dist/src/commands/re-export-single.d.ts +20 -0
  80. package/dist/src/commands/re-export-single.js +261 -0
  81. package/dist/src/commands/re-export.d.ts +0 -4
  82. package/dist/src/commands/re-export.js +123 -289
  83. package/dist/src/commands/rebase/abort.js +13 -4
  84. package/dist/src/commands/rebase/continue.js +11 -4
  85. package/dist/src/commands/rebase/index.js +19 -29
  86. package/dist/src/commands/rebase/patch-loop.js +12 -0
  87. package/dist/src/commands/reset.js +4 -23
  88. package/dist/src/commands/resolve.js +2 -9
  89. package/dist/src/commands/run.js +29 -23
  90. package/dist/src/commands/source.js +3 -9
  91. package/dist/src/commands/status-check.d.ts +48 -0
  92. package/dist/src/commands/status-check.js +106 -0
  93. package/dist/src/commands/status-json.d.ts +31 -0
  94. package/dist/src/commands/status-json.js +76 -0
  95. package/dist/src/commands/status-output.d.ts +1 -0
  96. package/dist/src/commands/status-output.js +10 -1
  97. package/dist/src/commands/status-ownership.d.ts +52 -0
  98. package/dist/src/commands/status-ownership.js +57 -0
  99. package/dist/src/commands/status.js +190 -170
  100. package/dist/src/commands/test-build-phase.d.ts +19 -0
  101. package/dist/src/commands/test-build-phase.js +168 -0
  102. package/dist/src/commands/test-diagnose.js +30 -2
  103. package/dist/src/commands/test-modes.d.ts +9 -1
  104. package/dist/src/commands/test-modes.js +46 -13
  105. package/dist/src/commands/test-register.js +59 -27
  106. package/dist/src/commands/test-run.d.ts +29 -3
  107. package/dist/src/commands/test-run.js +59 -7
  108. package/dist/src/commands/test-verdict.d.ts +32 -0
  109. package/dist/src/commands/test-verdict.js +48 -0
  110. package/dist/src/commands/test.d.ts +9 -0
  111. package/dist/src/commands/test.js +100 -123
  112. package/dist/src/commands/token-coverage.js +4 -9
  113. package/dist/src/commands/token.js +4 -1
  114. package/dist/src/commands/tree.d.ts +19 -0
  115. package/dist/src/commands/tree.js +320 -0
  116. package/dist/src/commands/verify.js +2 -2
  117. package/dist/src/commands/watch.js +22 -21
  118. package/dist/src/commands/wire.js +1 -1
  119. package/dist/src/core/ast-utils.d.ts +11 -0
  120. package/dist/src/core/ast-utils.js +11 -0
  121. package/dist/src/core/build-audit-platform.js +6 -1
  122. package/dist/src/core/build-audit-registration.js +2 -0
  123. package/dist/src/core/build-audit-resolve.d.ts +13 -0
  124. package/dist/src/core/build-audit-resolve.js +34 -13
  125. package/dist/src/core/build-audit.js +4 -20
  126. package/dist/src/core/build-baseline-types.d.ts +29 -8
  127. package/dist/src/core/build-baseline-types.js +2 -1
  128. package/dist/src/core/build-baseline.d.ts +10 -2
  129. package/dist/src/core/build-baseline.js +39 -8
  130. package/dist/src/core/build-prepare.d.ts +4 -0
  131. package/dist/src/core/build-prepare.js +7 -1
  132. package/dist/src/core/config-paths.d.ts +1 -1
  133. package/dist/src/core/config-paths.js +2 -0
  134. package/dist/src/core/config-validate-test-toolchains.js +3 -2
  135. package/dist/src/core/config-validate.js +45 -30
  136. package/dist/src/core/config.js +16 -3
  137. package/dist/src/core/coverage-extend.d.ts +89 -0
  138. package/dist/src/core/coverage-extend.js +188 -0
  139. package/dist/src/core/destructive.js +3 -1
  140. package/dist/src/core/discard-baseline.js +18 -4
  141. package/dist/src/core/engine-precondition.d.ts +35 -0
  142. package/dist/src/core/engine-precondition.js +50 -0
  143. package/dist/src/core/engine-session-lock.d.ts +38 -1
  144. package/dist/src/core/engine-session-lock.js +78 -4
  145. package/dist/src/core/file-lock.d.ts +7 -0
  146. package/dist/src/core/file-lock.js +20 -30
  147. package/dist/src/core/firefox.js +5 -2
  148. package/dist/src/core/furnace-apply-helpers.d.ts +12 -1
  149. package/dist/src/core/furnace-apply-helpers.js +12 -3
  150. package/dist/src/core/furnace-apply-output.js +9 -0
  151. package/dist/src/core/furnace-apply-overwrite-warn.d.ts +63 -0
  152. package/dist/src/core/furnace-apply-overwrite-warn.js +123 -0
  153. package/dist/src/core/furnace-apply-undeploy-actions.d.ts +11 -0
  154. package/dist/src/core/furnace-apply-undeploy-actions.js +49 -0
  155. package/dist/src/core/furnace-apply.d.ts +16 -4
  156. package/dist/src/core/furnace-apply.js +39 -43
  157. package/dist/src/core/furnace-config.js +2 -0
  158. package/dist/src/core/furnace-css-fragments.js +2 -0
  159. package/dist/src/core/furnace-jsconfig.js +14 -7
  160. package/dist/src/core/furnace-registration-ast.js +8 -8
  161. package/dist/src/core/furnace-registration-remove.js +6 -3
  162. package/dist/src/core/furnace-registration.js +9 -12
  163. package/dist/src/core/furnace-rollback.js +1 -1
  164. package/dist/src/core/furnace-scanner.js +2 -1
  165. package/dist/src/core/furnace-stale-export.js +2 -1
  166. package/dist/src/core/furnace-state-persist.d.ts +4 -6
  167. package/dist/src/core/furnace-validate-compatibility.js +4 -7
  168. package/dist/src/core/furnace-validate-helpers.js +33 -1
  169. package/dist/src/core/furnace-validate-registration.js +16 -0
  170. package/dist/src/core/furnace-validate.js +4 -2
  171. package/dist/src/core/git-diff.js +37 -2
  172. package/dist/src/core/lint-cache.d.ts +15 -5
  173. package/dist/src/core/lint-cache.js +49 -14
  174. package/dist/src/core/mach-build-artifacts.d.ts +63 -1
  175. package/dist/src/core/mach-build-artifacts.js +108 -2
  176. package/dist/src/core/mach-resource-shim.js +1 -0
  177. package/dist/src/core/marionette-port.d.ts +16 -0
  178. package/dist/src/core/marionette-port.js +93 -8
  179. package/dist/src/core/marionette-preflight.js +6 -5
  180. package/dist/src/core/patch-apply.d.ts +20 -13
  181. package/dist/src/core/patch-apply.js +32 -33
  182. package/dist/src/core/patch-export.d.ts +2 -2
  183. package/dist/src/core/patch-export.js +4 -6
  184. package/dist/src/core/patch-lint-checkjs.d.ts +42 -2
  185. package/dist/src/core/patch-lint-checkjs.js +132 -8
  186. package/dist/src/core/patch-lint-chrome-jsdoc.js +9 -0
  187. package/dist/src/core/patch-lint-creators.d.ts +30 -0
  188. package/dist/src/core/patch-lint-creators.js +45 -0
  189. package/dist/src/core/patch-lint-cross.d.ts +1 -17
  190. package/dist/src/core/patch-lint-cross.js +16 -32
  191. package/dist/src/core/patch-lint-css.js +64 -16
  192. package/dist/src/core/patch-lint-diff.d.ts +8 -0
  193. package/dist/src/core/patch-lint-diff.js +31 -0
  194. package/dist/src/core/patch-lint-jsdoc.d.ts +3 -1
  195. package/dist/src/core/patch-lint-jsdoc.js +15 -5
  196. package/dist/src/core/patch-lint-module-registration.d.ts +15 -0
  197. package/dist/src/core/patch-lint-module-registration.js +88 -0
  198. package/dist/src/core/patch-lint-mozbuild.js +99 -11
  199. package/dist/src/core/patch-lint-ownership.d.ts +21 -0
  200. package/dist/src/core/patch-lint-ownership.js +30 -0
  201. package/dist/src/core/patch-lint-reexports.d.ts +2 -1
  202. package/dist/src/core/patch-lint-reexports.js +1 -0
  203. package/dist/src/core/patch-lint.d.ts +31 -0
  204. package/dist/src/core/patch-lint.js +114 -8
  205. package/dist/src/core/patch-lock.d.ts +12 -1
  206. package/dist/src/core/patch-lock.js +40 -4
  207. package/dist/src/core/patch-manifest-consistency.js +15 -3
  208. package/dist/src/core/patch-manifest.d.ts +1 -1
  209. package/dist/src/core/patch-parse.d.ts +8 -0
  210. package/dist/src/core/patch-parse.js +10 -0
  211. package/dist/src/core/patch-policy.d.ts +7 -0
  212. package/dist/src/core/patch-policy.js +4 -1
  213. package/dist/src/core/patch-transform.d.ts +10 -0
  214. package/dist/src/core/patch-transform.js +13 -2
  215. package/dist/src/core/rebase-session.d.ts +44 -2
  216. package/dist/src/core/rebase-session.js +114 -17
  217. package/dist/src/core/register-browser-content.js +2 -1
  218. package/dist/src/core/register-shared-css.js +2 -1
  219. package/dist/src/core/smoke-patterns.d.ts +24 -16
  220. package/dist/src/core/smoke-patterns.js +26 -14
  221. package/dist/src/core/status-binary.d.ts +55 -0
  222. package/dist/src/core/status-binary.js +124 -0
  223. package/dist/src/core/status-classify.d.ts +22 -1
  224. package/dist/src/core/status-classify.js +112 -70
  225. package/dist/src/core/test-harness-crash.d.ts +32 -1
  226. package/dist/src/core/test-harness-crash.js +59 -4
  227. package/dist/src/core/test-path-scope.js +2 -0
  228. package/dist/src/core/test-stale-check.js +1 -20
  229. package/dist/src/core/token-category.d.ts +40 -0
  230. package/dist/src/core/token-category.js +264 -0
  231. package/dist/src/core/token-manager.d.ts +7 -0
  232. package/dist/src/core/token-manager.js +48 -196
  233. package/dist/src/core/toolchain-preflight.js +24 -26
  234. package/dist/src/core/tree-cow.d.ts +18 -0
  235. package/dist/src/core/tree-cow.js +71 -0
  236. package/dist/src/core/tree-guard.d.ts +35 -0
  237. package/dist/src/core/tree-guard.js +179 -0
  238. package/dist/src/core/tree-store.d.ts +129 -0
  239. package/dist/src/core/tree-store.js +452 -0
  240. package/dist/src/core/typecheck-shim.d.ts +14 -0
  241. package/dist/src/core/typecheck-shim.js +41 -2
  242. package/dist/src/core/typecheck.js +2 -1
  243. package/dist/src/core/wire-destroy.js +14 -3
  244. package/dist/src/core/wire-init.js +37 -40
  245. package/dist/src/core/wire-subscript.js +11 -3
  246. package/dist/src/core/wire-utils.js +4 -4
  247. package/dist/src/core/xpcshell-appdir.js +3 -0
  248. package/dist/src/core/xpcshell-profile-dir.d.ts +12 -0
  249. package/dist/src/core/xpcshell-profile-dir.js +45 -0
  250. package/dist/src/errors/base.d.ts +13 -0
  251. package/dist/src/errors/base.js +13 -0
  252. package/dist/src/errors/git.d.ts +8 -0
  253. package/dist/src/errors/git.js +17 -0
  254. package/dist/src/errors/rebase.d.ts +13 -0
  255. package/dist/src/errors/rebase.js +17 -0
  256. package/dist/src/index.d.ts +2 -0
  257. package/dist/src/index.js +1 -0
  258. package/dist/src/types/commands/options.d.ts +119 -3
  259. package/dist/src/types/commands/patches.d.ts +8 -0
  260. package/dist/src/types/config.d.ts +19 -0
  261. package/dist/src/types/furnace.d.ts +7 -0
  262. package/dist/src/utils/build-info.d.ts +31 -0
  263. package/dist/src/utils/build-info.js +98 -0
  264. package/dist/src/utils/concurrency.d.ts +8 -0
  265. package/dist/src/utils/concurrency.js +21 -0
  266. package/dist/src/utils/errors.d.ts +39 -0
  267. package/dist/src/utils/errors.js +55 -0
  268. package/dist/src/utils/fs.js +5 -12
  269. package/dist/src/utils/logger.d.ts +14 -2
  270. package/dist/src/utils/logger.js +43 -13
  271. package/dist/src/utils/options.d.ts +21 -4
  272. package/dist/src/utils/options.js +26 -6
  273. package/dist/src/utils/package-root.js +7 -4
  274. package/dist/src/utils/process-group.js +13 -2
  275. package/dist/src/utils/stdio-drain.d.ts +28 -0
  276. package/dist/src/utils/stdio-drain.js +54 -0
  277. package/dist/src/utils/validation.d.ts +33 -2
  278. package/dist/src/utils/validation.js +57 -2
  279. package/package.json +13 -13
@@ -3,20 +3,18 @@ import { readdir, unlink } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { confirm } from '@clack/prompts';
5
5
  import { getProjectPaths, loadConfig } from '../../core/config.js';
6
- import { removeCustomFtlJarMnEntry } from '../../core/furnace-apply-ftl.js';
7
- import { extractComponentChecksums, getOverrideEngineTargetPath, isOverrideCopyCandidate, restoreOverrideFileToBaseline, } from '../../core/furnace-apply-helpers.js';
8
6
  import { getFurnacePaths, loadFurnaceConfig, loadFurnaceState, updateFurnaceState, writeFurnaceConfig, } from '../../core/furnace-config.js';
9
7
  import { resolveFtlDir, xpcshellTestParentDir } from '../../core/furnace-constants.js';
10
8
  import { recordFurnaceRollbackFailure, runFurnaceMutation } from '../../core/furnace-operation.js';
11
- import { removeCustomElementRegistration, removeJarMnEntries, } from '../../core/furnace-registration.js';
12
9
  import { createRollbackJournal, restoreRollbackJournalOrThrow, snapshotDir, snapshotFile, } from '../../core/furnace-rollback.js';
13
10
  import { isGitRepository } from '../../core/git.js';
14
11
  import { deregisterTestManifest } from '../../core/manifest-register.js';
15
12
  import { FurnaceError } from '../../errors/furnace.js';
16
13
  import { toError } from '../../utils/errors.js';
17
- import { pathExists, readText, removeDir, removeFile, writeText } from '../../utils/fs.js';
14
+ import { pathExists, readText, removeDir, writeText } from '../../utils/fs.js';
18
15
  import { cancel, info, intro, isCancel, outro, warn } from '../../utils/logger.js';
19
- import { dropChecksumsByPrefix, removeDeployedCustomFiles } from './remove-state.js';
16
+ import { performCustomRemovalMutations, performOverrideRemovalMutations, } from './remove-mutations.js';
17
+ import { dropChecksumsByPrefix } from './remove-state.js';
20
18
  /**
21
19
  * Removes an entire TOML section (header + body lines) for a given test file.
22
20
  * Matches from `["filename"]` up to the next section header `[` or end-of-file,
@@ -69,61 +67,6 @@ function findComponentType(config, name) {
69
67
  return 'custom';
70
68
  return undefined;
71
69
  }
72
- /**
73
- * Restores every override-deployed file in `engine/` to its pristine HEAD
74
- * state, inverting what `applyOverrideComponent` would have written. Files that
75
- * existed in HEAD are restored via `git restore`; files the override
76
- * introduced (not in HEAD) are deleted outright.
77
- *
78
- * The restore set is the **union** of (a) files currently in the override
79
- * workspace directory and (b) filenames recorded in `previousChecksumKeys`
80
- * — i.e. files we know we deployed last time, even if the developer has
81
- * since deleted them from the workspace. Without (b), a workspace deletion
82
- * leaves an orphaned engine copy that `furnace remove` would never see.
83
- *
84
- * Every touched engine file is snapshotted into the rollback journal before
85
- * mutation so a mid-remove failure still rolls the engine back to its
86
- * pre-command state.
87
- */
88
- async function restoreOverrideEngineFiles(engineDir, overrideDir, overrideConfig, previousChecksumKeys, ftlDir, journal) {
89
- // Engine-as-git is a hard precondition for restoration: git HEAD is the only
90
- // honest oracle for "what was there before the override". If the engine is
91
- // not a git repo we refuse rather than silently leaving files behind — the
92
- // previous warn-and-continue behaviour is exactly what this fix removes.
93
- if (!(await isGitRepository(engineDir))) {
94
- throw new FurnaceError('Cannot restore override files: engine is not a git repository. Run "fireforge download" to initialise it.');
95
- }
96
- // Build the union of "files we still see on disk" and "files state.json
97
- // claims we deployed". The state set is the only authority for files that
98
- // were deployed and later deleted from the workspace; the workspace set is
99
- // the only authority for files added since last apply that have not yet
100
- // been recorded in state. We need both.
101
- const fileSet = new Set();
102
- if (await pathExists(overrideDir)) {
103
- const entries = await readdir(overrideDir, { withFileTypes: true });
104
- for (const entry of entries) {
105
- if (!entry.isFile())
106
- continue;
107
- if (!isOverrideCopyCandidate(entry.name, overrideConfig.type))
108
- continue;
109
- fileSet.add(entry.name);
110
- }
111
- }
112
- for (const key of previousChecksumKeys) {
113
- fileSet.add(key);
114
- }
115
- let restored = 0;
116
- let removed = 0;
117
- for (const fileName of fileSet) {
118
- const enginePath = getOverrideEngineTargetPath(engineDir, overrideConfig, fileName, ftlDir);
119
- const action = await restoreOverrideFileToBaseline(engineDir, enginePath, journal);
120
- if (action === 'restored')
121
- restored += 1;
122
- else if (action === 'removed')
123
- removed += 1;
124
- }
125
- return { restored, removed };
126
- }
127
70
  /**
128
71
  * Removes generated browser mochitest files associated with a custom component.
129
72
  * @param name - Custom component tag name
@@ -410,76 +353,26 @@ export async function furnaceRemoveCommand(projectRoot, name, options = {}) {
410
353
  try {
411
354
  const { config: freshConfig, ftlDir, state: freshState, type: freshType, } = await loadFreshRemoveTarget(projectRoot, name, paths.engine);
412
355
  if (freshType === 'override') {
413
- const overrideConfig = freshConfig.overrides[name];
414
- const dir = join(furnacePaths.overridesDir, name);
415
- // Restore deployed engine files BEFORE removing the workspace
416
- // directory. The restore set is the union of (a) files currently in
417
- // the workspace and (b) files state.json says we deployed last time
418
- // — without (b), source-side deletions would orphan engine copies
419
- // that this command can never see again.
420
- if (overrideConfig?.basePath) {
421
- const previousKeys = Object.keys(extractComponentChecksums(freshState.appliedChecksums, 'override', name));
422
- const { restored, removed } = await restoreOverrideEngineFiles(paths.engine, dir, overrideConfig, previousKeys, ftlDir, journal);
423
- if (restored > 0) {
424
- info(`Restored ${restored} file${restored === 1 ? '' : 's'} in engine/${overrideConfig.basePath} to Firefox baseline`);
425
- }
426
- if (removed > 0) {
427
- info(`Removed ${removed} override-introduced file${removed === 1 ? '' : 's'} from engine/${overrideConfig.basePath}`);
428
- }
429
- }
430
- if (await pathExists(dir)) {
431
- await snapshotDir(journal, dir);
432
- await removeDir(dir);
433
- info(`Deleted components/overrides/${name}/`);
434
- }
356
+ await performOverrideRemovalMutations({
357
+ name,
358
+ paths,
359
+ furnacePaths,
360
+ freshConfig,
361
+ freshState,
362
+ ftlDir,
363
+ journal,
364
+ });
435
365
  }
436
366
  else if (freshType === 'custom') {
437
- const customConfig = freshConfig.custom[name];
438
- // Custom-component removal mutates engine files (jar.mn,
439
- // customElements.js, deployed widgets, optional .ftl) and the
440
- // rollback journal is the only safety net for those edits while
441
- // the command runs. The git-as-engine precondition is enforced
442
- // before the lock is acquired (see furnaceRemoveCommand above)
443
- // so if we reach this point, the engine is a git repository.
444
- if (customConfig?.register) {
445
- // customElements.js is the only file removeCustomElementRegistration touches.
446
- await snapshotFile(journal, join(paths.engine, 'toolkit/content/customElements.js'));
447
- await removeCustomElementRegistration(paths.engine, name);
448
- info(`Deregistered ${name} from customElements.js`);
449
- }
450
- // jar.mn is the only file removeJarMnEntries touches.
451
- await snapshotFile(journal, join(paths.engine, 'toolkit/content/jar.mn'));
452
- await removeJarMnEntries(paths.engine, name);
453
- info(`Removed ${name} entries from toolkit/content/jar.mn`);
454
- const dir = join(furnacePaths.customDir, name);
455
- if (await pathExists(dir)) {
456
- await snapshotDir(journal, dir);
457
- await removeDir(dir);
458
- info(`Deleted components/custom/${name}/`);
459
- }
460
- // Clean up deployed files in engine (per-file — see helper doc).
461
- if (customConfig?.targetPath) {
462
- await removeDeployedCustomFiles(projectRoot, paths.engine, name, customConfig.targetPath, journal);
463
- }
464
- // Localized components deploy a .ftl outside targetPath into the
465
- // shared Fluent tree; apply writes it, so remove must delete it too
466
- // or the locale payload is orphaned.
467
- if (customConfig?.localized) {
468
- const ftlRel = join(ftlDir, `${name}.ftl`);
469
- const ftlPath = join(paths.engine, ftlRel);
470
- if (await pathExists(ftlPath)) {
471
- await snapshotFile(journal, ftlPath);
472
- await removeFile(ftlPath);
473
- info(`Deleted localized file engine/${ftlRel}`);
474
- }
475
- // Drop the locale jar.mn chrome registration that `applyCustomFtlFile`
476
- // wrote during deploy — otherwise the engine is left with a
477
- // `locale/.../${name}.ftl` entry pointing at a file we just
478
- // deleted. 2026-04-21 eval (Finding #1): `furnace remove` left
479
- // `browser/locales/jar.mn` referencing the missing FTL, which
480
- // would break the next package-manifest validation.
481
- await removeCustomFtlJarMnEntry(paths.engine, `${name}.ftl`, ftlDir, customConfig, journal);
482
- }
367
+ await performCustomRemovalMutations({
368
+ projectRoot,
369
+ name,
370
+ paths,
371
+ furnacePaths,
372
+ freshConfig,
373
+ ftlDir,
374
+ journal,
375
+ });
483
376
  }
484
377
  const testCleanupFailures = freshType === 'custom' ? await cleanupAllCustomTestFiles(name, projectRoot, journal) : [];
485
378
  // Remove entry from furnace.json
@@ -16,9 +16,58 @@ export function updateBrowserChromeTestContent(content, oldName, newName, binary
16
16
  const newUnderscored = newName.replace(/-/g, '_');
17
17
  const oldTestStem = deriveTestStem(oldName, binaryName);
18
18
  const newTestStem = deriveTestStem(newName, binaryName);
19
- return content
20
- .replace(new RegExp(escapeRegex(oldName), 'g'), newName)
21
- .replace(new RegExp(escapeRegex(oldClassName), 'g'), newClassName)
22
- .replace(new RegExp(escapeRegex(oldUnderscored), 'g'), newUnderscored)
23
- .replace(new RegExp(escapeRegex(oldTestStem), 'g'), newTestStem);
19
+ // ONE pass over the content, not four chained `.replace()` calls.
20
+ //
21
+ // Chaining let each rule see the previous rule's output. Renaming
22
+ // `acme-widget` to `acme-widget-v2` rewrote `test_acme_widget_defined` to
23
+ // `test_acme_widget_v2_defined` via the underscored form, and then the stem
24
+ // rule matched the `acme_widget` still inside that result and produced
25
+ // `test_acme_widget_v2_v2_defined`. A single alternation with a callback
26
+ // cannot re-enter its own output.
27
+ //
28
+ // Ordering is longest-first, because JS alternation takes the first
29
+ // alternative that matches at a given position.
30
+ const alternatives = [
31
+ // The stem is a short bare fragment (`moz-` and binary prefixes stripped,
32
+ // hyphens underscored) — `panel` for `moz-panel`. Matching it loose, even
33
+ // with hyphen guards, rewrote unrelated underscore-delimited identifiers
34
+ // such as `test_panel_group_integration`, because `_` must stay a legal
35
+ // neighbour for the generated name to match at all. The scaffold emits the
36
+ // stem in exactly one shape, so anchor to that shape instead of guessing
37
+ // from boundaries — with its own guards, so the anchored shape cannot
38
+ // match inside a longer identifier (`mytest_panel_defined_extra`).
39
+ {
40
+ pattern: `(?<![\\w-])test_${escapeRegex(oldTestStem)}_defined(?![\\w-])`,
41
+ to: `test_${newTestStem}_defined`,
42
+ },
43
+ // Word-boundary-aware, matching `furnace/rename.ts` and
44
+ // `rename-xpcshell.ts`: without the guards a rename of `moz-panel` rewrote
45
+ // `moz-panel-group`. `_` stays a legal neighbour for the underscored form
46
+ // because it is deliberately embedded in generated identifiers; `-` does
47
+ // not.
48
+ //
49
+ // Skipped when the underscored form IS the tag name (a hyphen-less
50
+ // component): both rules then match the same text and this one would win
51
+ // on order, substituting `newUnderscored` where the tag `newName` was
52
+ // meant. The stem rule still covers the one generated identifier shape.
53
+ ...(oldUnderscored === oldName
54
+ ? []
55
+ : [
56
+ {
57
+ pattern: `(?<![A-Za-z0-9-])${escapeRegex(oldUnderscored)}(?![A-Za-z0-9-])`,
58
+ to: newUnderscored,
59
+ },
60
+ ]),
61
+ { pattern: `(?<![\\w-])${escapeRegex(oldName)}(?![\\w-])`, to: newName },
62
+ { pattern: `\\b${escapeRegex(oldClassName)}\\b`, to: newClassName },
63
+ ];
64
+ // Each alternative is its own capture group and the replacement is picked by
65
+ // the group that matched — not by matched TEXT, which is ambiguous whenever
66
+ // two rules can produce the same match with different substitutions.
67
+ const combined = new RegExp(alternatives.map(({ pattern }) => `(${pattern})`).join('|'), 'g');
68
+ return content.replace(combined, (matched, ...args) => {
69
+ const groups = args.slice(0, alternatives.length);
70
+ const index = groups.findIndex((group) => group !== undefined);
71
+ return alternatives[index]?.to ?? matched;
72
+ });
24
73
  }
@@ -28,17 +28,10 @@ async function promptAddComponents(components, tracked, projectRoot) {
28
28
  }
29
29
  const selected = await multiselect({
30
30
  message: 'Select components to add as stock',
31
- options: untrackedComponents.map((c) => {
32
- const features = [];
33
- if (c.hasCSS)
34
- features.push('CSS');
35
- if (c.hasFTL)
36
- features.push('FTL');
37
- if (c.isRegistered)
38
- features.push('registered');
39
- const label = features.length > 0 ? `${c.tagName} — ${features.join(', ')}` : c.tagName;
40
- return { value: c.tagName, label };
41
- }),
31
+ options: untrackedComponents.map((c) => ({
32
+ value: c.tagName,
33
+ label: `${c.tagName}${formatComponentFeatures(c)}`,
34
+ })),
42
35
  });
43
36
  if (isCancel(selected)) {
44
37
  cancel('Cancelled');
@@ -110,6 +103,22 @@ async function persistStockComponents(projectRoot, names) {
110
103
  }
111
104
  });
112
105
  }
106
+ /**
107
+ * Renders a component's discovered capabilities as a display suffix.
108
+ * Written twice before 0.41.0 — once for the interactive multiselect labels
109
+ * and once for the report rows — which is part of what pinned
110
+ * `furnaceScanCommand` at complexity 30/30.
111
+ */
112
+ function formatComponentFeatures(component) {
113
+ const features = [];
114
+ if (component.hasCSS)
115
+ features.push('CSS');
116
+ if (component.hasFTL)
117
+ features.push('FTL');
118
+ if (component.isRegistered)
119
+ features.push('registered');
120
+ return features.length > 0 ? ` — ${features.join(', ')}` : '';
121
+ }
113
122
  /**
114
123
  * Runs the furnace scan command to discover MozLitElement components.
115
124
  * @param projectRoot - Root directory of the project
@@ -153,33 +162,15 @@ export async function furnaceScanCommand(projectRoot, options = {}) {
153
162
  tracked.set(name, 'custom');
154
163
  }
155
164
  }
156
- // Display each component
165
+ // Partition once and reuse: the tracked/untracked split was computed twice
166
+ // (imperatively here, declaratively again for --track) before 0.41.0.
167
+ const untrackedComponentList = components.filter((c) => !tracked.has(c.tagName));
168
+ const untrackedCount = untrackedComponentList.length;
169
+ const trackedCount = components.length - untrackedCount;
157
170
  for (const component of components) {
158
- const features = [];
159
- if (component.hasCSS)
160
- features.push('CSS');
161
- if (component.hasFTL)
162
- features.push('FTL');
163
- if (component.isRegistered)
164
- features.push('registered');
165
- let line = component.tagName;
166
- if (features.length > 0) {
167
- line += ` — ${features.join(', ')}`;
168
- }
169
171
  const type = tracked.get(component.tagName);
170
- if (type) {
171
- line += ` [${type}]`;
172
- }
173
- info(line);
174
- }
175
- // Summary
176
- let trackedCount = 0;
177
- for (const component of components) {
178
- if (tracked.has(component.tagName)) {
179
- trackedCount++;
180
- }
172
+ info(`${component.tagName}${formatComponentFeatures(component)}${type ? ` [${type}]` : ''}`);
181
173
  }
182
- const untrackedCount = components.length - trackedCount;
183
174
  note(`Total: ${components.length} Tracked: ${trackedCount} Untracked: ${untrackedCount}`, 'Summary');
184
175
  // --track: persist the discovered untracked inventory into the `stock`
185
176
  // section without prompting (works non-interactively). Without it, scan
@@ -191,7 +182,7 @@ export async function furnaceScanCommand(projectRoot, options = {}) {
191
182
  outro('Scan complete');
192
183
  return;
193
184
  }
194
- const untrackedNames = components.filter((c) => !tracked.has(c.tagName)).map((c) => c.tagName);
185
+ const untrackedNames = untrackedComponentList.map((c) => c.tagName);
195
186
  await persistStockComponents(projectRoot, untrackedNames);
196
187
  success(`Tracked ${untrackedNames.length} component${untrackedNames.length === 1 ? '' : 's'} in the stock section of furnace.json`);
197
188
  outro('Scan complete');
@@ -157,42 +157,44 @@ export async function furnaceStatusCommand(projectRoot, name) {
157
157
  // hints, so report them separately rather than collapsing into a single
158
158
  // "something is off" message.
159
159
  if (await pathExists(paths.engine)) {
160
- let workspaceChanged = false;
161
- let engineDrifted = false;
162
- for (const [oName, overrideConfig] of Object.entries(config.overrides)) {
163
- const componentDir = join(furnacePaths.overridesDir, oName);
164
- if (!(await pathExists(componentDir)))
165
- continue;
166
- const previous = extractComponentChecksums(state.appliedChecksums, 'override', oName);
167
- if (await hasComponentChanged(componentDir, previous)) {
168
- workspaceChanged = true;
169
- }
170
- else if (await hasOverrideEngineDrift(paths.engine, componentDir, overrideConfig, ftlDir)) {
171
- engineDrifted = true;
172
- }
173
- if (workspaceChanged && engineDrifted)
174
- break;
175
- }
176
- if (!(workspaceChanged && engineDrifted)) {
177
- for (const [cName, customConfig] of Object.entries(config.custom)) {
178
- const componentDir = join(furnacePaths.customDir, cName);
160
+ // Held in an object rather than two `let`s: the scanner below mutates
161
+ // them from inside a closure, and TS's control-flow analysis does not
162
+ // propagate that through the call — it would narrow both to the literal
163
+ // `false` and flag every later read as always-falsy.
164
+ const drift = { workspaceChanged: false, engineDrifted: false };
165
+ /**
166
+ * Scans one component family for workspace or engine drift.
167
+ *
168
+ * The engine probe sits behind `else if` deliberately: a component whose
169
+ * workspace already changed does not need an engine comparison to decide
170
+ * what `status` reports, and the probe is the expensive half (it re-hashes
171
+ * deployed files). The consequence is that engine drift can go UNREPORTED
172
+ * for a component that also has workspace changes — the summary says
173
+ * "workspace changed", not both. Preserved verbatim from the two
174
+ * hand-written copies this replaced.
175
+ */
176
+ const scanFamily = async (entries, baseDir, kind, probeEngineDrift) => {
177
+ for (const [componentName, componentConfig] of entries) {
178
+ if (drift.workspaceChanged && drift.engineDrifted)
179
+ return;
180
+ const componentDir = join(baseDir, componentName);
179
181
  if (!(await pathExists(componentDir)))
180
182
  continue;
181
- const previous = extractComponentChecksums(state.appliedChecksums, 'custom', cName);
183
+ const previous = extractComponentChecksums(state.appliedChecksums, kind, componentName);
182
184
  if (await hasComponentChanged(componentDir, previous)) {
183
- workspaceChanged = true;
185
+ drift.workspaceChanged = true;
184
186
  }
185
- else if (await hasCustomEngineDrift(projectRoot, cName, componentDir, customConfig, ftlDir)) {
186
- engineDrifted = true;
187
+ else if (await probeEngineDrift(componentName, componentDir, componentConfig)) {
188
+ drift.engineDrifted = true;
187
189
  }
188
- if (workspaceChanged && engineDrifted)
189
- break;
190
190
  }
191
- }
192
- if (workspaceChanged) {
191
+ };
192
+ await scanFamily(Object.entries(config.overrides), furnacePaths.overridesDir, 'override', (_name, componentDir, cfg) => hasOverrideEngineDrift(paths.engine, componentDir, cfg, ftlDir));
193
+ await scanFamily(Object.entries(config.custom), furnacePaths.customDir, 'custom', (name, componentDir, cfg) => hasCustomEngineDrift(projectRoot, name, componentDir, cfg, ftlDir));
194
+ if (drift.workspaceChanged) {
193
195
  warn('Components have been modified since last apply. Run `fireforge build` or `fireforge furnace apply`.');
194
196
  }
195
- if (engineDrifted) {
197
+ if (drift.engineDrifted) {
196
198
  warn('Engine drift detected since last apply (reset/download/manual edit). Run `fireforge furnace apply` to re-deploy.');
197
199
  }
198
200
  }
@@ -6,6 +6,7 @@ import { furnaceConfigExists, getFurnacePaths, loadFurnaceConfig, } from '../../
6
6
  import { addCustomElementRegistration, addJarMnEntries, pruneStaleJarMnEntries, } from '../../core/furnace-registration.js';
7
7
  import { validateAllComponents, validateComponent } from '../../core/furnace-validate.js';
8
8
  import { FurnaceError } from '../../errors/furnace.js';
9
+ import { toError } from '../../utils/errors.js';
9
10
  import { pathExists } from '../../utils/fs.js';
10
11
  import { info, intro, note, outro, success, warn } from '../../utils/logger.js';
11
12
  import { displayValidationIssues } from './validation-output.js';
@@ -15,6 +16,7 @@ const FIXABLE_CHECKS = new Set([
15
16
  'missing-jar-mn-css',
16
17
  'wrong-registration-pattern',
17
18
  'stale-jar-registration',
19
+ 'missing-custom-element-registration',
18
20
  ]);
19
21
  /**
20
22
  * Auto-fixes registration issues that have deterministic solutions.
@@ -53,7 +55,7 @@ async function autoFixIssues(projectRoot, issues) {
53
55
  }
54
56
  }
55
57
  catch (err) {
56
- warn(`Could not prune stale jar.mn lines: ${err instanceof Error ? err.message : String(err)}`);
58
+ warn(`Could not prune stale jar.mn lines: ${toError(err).message}`);
57
59
  }
58
60
  }
59
61
  // Fix jar.mn entries
@@ -72,21 +74,26 @@ async function autoFixIssues(projectRoot, issues) {
72
74
  }
73
75
  }
74
76
  catch (err) {
75
- warn(`Could not fix jar.mn for ${componentName}: ${err instanceof Error ? err.message : String(err)}`);
77
+ warn(`Could not fix jar.mn for ${componentName}: ${toError(err).message}`);
76
78
  }
77
79
  }
78
- // Fix missing customElements.js registrations
79
- for (const issue of issues) {
80
- if (issue.check !== 'wrong-registration-pattern')
81
- continue;
82
- // wrong-registration-pattern means it IS registered, but in the wrong block.
83
- // We don't auto-fix this as it requires moving code between blocks, which
84
- // is too risky. Only fix truly missing registrations.
85
- }
86
- // Check for components that are missing from customElements.js entirely
87
- // (detected by post-apply consistency, not by validate — but we can check here)
88
- for (const [componentName, customConfig] of Object.entries(config.custom)) {
89
- if (!customConfig.register)
80
+ // `wrong-registration-pattern` is deliberately NOT auto-fixed: it means the
81
+ // component IS registered but in the wrong block, and moving code between
82
+ // blocks is too risky to do unattended. Only truly missing registrations are
83
+ // repaired, below.
84
+ // Repair components missing from customElements.js entirely (reported by
85
+ // validate as `missing-custom-element-registration`).
86
+ //
87
+ // Scoped to the components named in `issues`. Until 0.41.0 this iterated
88
+ // every entry in `config.custom`, so `furnace validate <one-component> --fix`
89
+ // wrote customElements.js registrations for EVERY custom component — outside
90
+ // the issue list it was handed, and invisibly, since `fixed` was never
91
+ // incremented here. The block's own comment already claimed this scoping;
92
+ // the code did not implement it.
93
+ const componentsWithIssues = new Set(issues.map((issue) => issue.component));
94
+ for (const componentName of componentsWithIssues) {
95
+ const customConfig = config.custom[componentName];
96
+ if (!customConfig?.register)
90
97
  continue;
91
98
  const componentDir = join(furnacePaths.customDir, componentName);
92
99
  if (!(await pathExists(componentDir)))
@@ -97,13 +104,13 @@ async function autoFixIssues(projectRoot, issues) {
97
104
  continue;
98
105
  const modulePath = `chrome://global/content/elements/${componentName}.mjs`;
99
106
  try {
107
+ // Idempotent: returns without error when already registered, so this
108
+ // stays a no-op for components whose registration is already correct.
100
109
  await addCustomElementRegistration(engineDir, componentName, modulePath);
101
- // addCustomElementRegistration is idempotent — it returns without error
102
- // if already registered. We only count it as fixed if a matching issue
103
- // existed in the input.
104
110
  }
105
111
  catch {
106
- // Ignore — idempotent call, may already be registered
112
+ // Best-effort repair — the re-validation pass at the call site reports
113
+ // whatever is still outstanding.
107
114
  }
108
115
  }
109
116
  return fixed;
@@ -5,12 +5,13 @@ import { Command } from 'commander';
5
5
  import { getProjectPaths, loadConfig, loadState, updateState } from '../core/config.js';
6
6
  import { getHead } from '../core/git.js';
7
7
  import { getDirtyFiles } from '../core/git-status.js';
8
- import { applyPatchesWithContinue, countPatches, createPatchedContentComputer, discoverPatches, extractAffectedFiles, matchesUntilFilename, PatchError, } from '../core/patch-apply.js';
8
+ import { applyPatchesWithContinue, countPatches, createPatchedContentContext, discoverPatches, extractAffectedFiles, matchesUntilFilename, PatchError, } from '../core/patch-apply.js';
9
9
  import { checkVersionCompatibility, loadPatchesManifest, validatePatchesManifestConsistency, validatePatchIntegrity, } from '../core/patch-manifest.js';
10
10
  import { getPatchSourceVersion } from '../core/patch-source-metadata.js';
11
11
  import { warnIfStaticComponentsStale } from '../core/test-stale-check.js';
12
12
  import { GeneralError } from '../errors/base.js';
13
- import { toError } from '../utils/errors.js';
13
+ import { mapWithConcurrency } from '../utils/concurrency.js';
14
+ import { getNodeErrorCode, toError } from '../utils/errors.js';
14
15
  import { pathExists, readText } from '../utils/fs.js';
15
16
  import { error, info, intro, isCancel, outro, spinner, success, verbose, warn, } from '../utils/logger.js';
16
17
  import { pickDefined } from '../utils/options.js';
@@ -24,8 +25,8 @@ import { pickDefined } from '../utils/options.js';
24
25
  */
25
26
  const SAFE_IO_FALLBACK_CODES = new Set(['ENOENT', 'EACCES', 'EPERM', 'EISDIR', 'EBUSY']);
26
27
  function isSafeIoFallback(error) {
27
- const code = error?.code;
28
- return typeof code === 'string' && SAFE_IO_FALLBACK_CODES.has(code);
28
+ const code = getNodeErrorCode(error);
29
+ return code !== undefined && SAFE_IO_FALLBACK_CODES.has(code);
29
30
  }
30
31
  /** Concurrency bound for per-file classification (each call spawns git). */
31
32
  const UNMANAGED_CLASSIFY_CONCURRENCY = 8;
@@ -33,7 +34,7 @@ async function getUnmanagedDirtyFiles(engineDir, patchesDir, dirtyFiles) {
33
34
  // One manifest+patch-discovery load for the whole batch (the per-call
34
35
  // computePatchedContent re-read everything for every file), and bounded
35
36
  // concurrency instead of an unbounded Promise.all over git spawns.
36
- const computeExpected = await createPatchedContentComputer(patchesDir, engineDir);
37
+ const { computePatched: computeExpected } = await createPatchedContentContext(patchesDir, engineDir);
37
38
  const classifications = await mapWithConcurrency(dirtyFiles, UNMANAGED_CLASSIFY_CONCURRENCY, async (file) => {
38
39
  try {
39
40
  const [expected, exists] = await Promise.all([
@@ -62,21 +63,6 @@ async function getUnmanagedDirtyFiles(engineDir, patchesDir, dirtyFiles) {
62
63
  });
63
64
  return classifications.filter((file) => file !== null).sort();
64
65
  }
65
- /** Maps items with at most `limit` in-flight promises. Preserves order. */
66
- async function mapWithConcurrency(items, limit, fn) {
67
- const results = new Array(items.length);
68
- let next = 0;
69
- const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
70
- for (;;) {
71
- const index = next++;
72
- if (index >= items.length)
73
- return;
74
- results[index] = await fn(items[index]);
75
- }
76
- });
77
- await Promise.all(workers);
78
- return results;
79
- }
80
66
  function reportForcedOverwriteRisk(unmanagedDirtyFiles) {
81
67
  warn(`--force will overwrite ${unmanagedDirtyFiles.length} unmanaged change${unmanagedDirtyFiles.length === 1 ? '' : 's'} in patch-touched file${unmanagedDirtyFiles.length === 1 ? '' : 's'}:`);
82
68
  for (const file of unmanagedDirtyFiles) {