@hominis/fireforge 0.40.0 → 0.42.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 (256) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +16 -10
  3. package/dist/bin/fireforge.js +25 -4
  4. package/dist/build-info.json +9 -0
  5. package/dist/src/cli.js +20 -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 +13 -3
  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 +16 -7
  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 +12 -59
  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.js +4 -4
  45. package/dist/src/commands/manifest.js +1 -1
  46. package/dist/src/commands/package.js +9 -16
  47. package/dist/src/commands/patch/compact.js +5 -5
  48. package/dist/src/commands/patch/delete.js +5 -5
  49. package/dist/src/commands/patch/lint-ignore.js +3 -2
  50. package/dist/src/commands/patch/move-files-create.js +22 -4
  51. package/dist/src/commands/patch/move-files-into.js +14 -10
  52. package/dist/src/commands/patch/move-files.js +6 -5
  53. package/dist/src/commands/patch/rename.d.ts +2 -2
  54. package/dist/src/commands/patch/rename.js +151 -50
  55. package/dist/src/commands/patch/reorder.d.ts +9 -0
  56. package/dist/src/commands/patch/reorder.js +14 -10
  57. package/dist/src/commands/patch/split-plan.d.ts +13 -6
  58. package/dist/src/commands/patch/split-plan.js +22 -12
  59. package/dist/src/commands/patch/split.d.ts +1 -1
  60. package/dist/src/commands/patch/split.js +23 -9
  61. package/dist/src/commands/patch/staged-dependency-validate.d.ts +15 -0
  62. package/dist/src/commands/patch/staged-dependency-validate.js +49 -0
  63. package/dist/src/commands/patch/staged-dependency.js +10 -0
  64. package/dist/src/commands/re-export-adjacent.js +5 -6
  65. package/dist/src/commands/re-export-bulk-scan.d.ts +13 -0
  66. package/dist/src/commands/re-export-bulk-scan.js +142 -2
  67. package/dist/src/commands/re-export-drift.d.ts +74 -0
  68. package/dist/src/commands/re-export-drift.js +234 -0
  69. package/dist/src/commands/re-export-files.js +5 -3
  70. package/dist/src/commands/re-export-lint.d.ts +97 -0
  71. package/dist/src/commands/re-export-lint.js +210 -0
  72. package/dist/src/commands/re-export-options.js +8 -0
  73. package/dist/src/commands/re-export-register.d.ts +18 -0
  74. package/dist/src/commands/re-export-register.js +61 -0
  75. package/dist/src/commands/re-export-scan.js +13 -7
  76. package/dist/src/commands/re-export-single.d.ts +20 -0
  77. package/dist/src/commands/re-export-single.js +261 -0
  78. package/dist/src/commands/re-export.d.ts +0 -4
  79. package/dist/src/commands/re-export.js +113 -268
  80. package/dist/src/commands/rebase/abort.js +13 -4
  81. package/dist/src/commands/rebase/continue.js +11 -4
  82. package/dist/src/commands/rebase/index.js +19 -29
  83. package/dist/src/commands/rebase/patch-loop.js +12 -0
  84. package/dist/src/commands/reset.js +4 -23
  85. package/dist/src/commands/resolve.js +2 -9
  86. package/dist/src/commands/run.js +11 -21
  87. package/dist/src/commands/source.js +3 -9
  88. package/dist/src/commands/status-check.d.ts +13 -0
  89. package/dist/src/commands/status-check.js +31 -13
  90. package/dist/src/commands/status-json.d.ts +31 -0
  91. package/dist/src/commands/status-json.js +76 -0
  92. package/dist/src/commands/status-output.d.ts +1 -0
  93. package/dist/src/commands/status-output.js +10 -1
  94. package/dist/src/commands/status-ownership.d.ts +52 -0
  95. package/dist/src/commands/status-ownership.js +57 -0
  96. package/dist/src/commands/status.js +184 -175
  97. package/dist/src/commands/test-build-phase.d.ts +19 -0
  98. package/dist/src/commands/test-build-phase.js +168 -0
  99. package/dist/src/commands/test-diagnose.js +30 -2
  100. package/dist/src/commands/test-modes.d.ts +9 -1
  101. package/dist/src/commands/test-modes.js +46 -13
  102. package/dist/src/commands/test-register.js +59 -27
  103. package/dist/src/commands/test-run.d.ts +29 -3
  104. package/dist/src/commands/test-run.js +41 -4
  105. package/dist/src/commands/test-verdict.d.ts +32 -0
  106. package/dist/src/commands/test-verdict.js +48 -0
  107. package/dist/src/commands/test.d.ts +9 -0
  108. package/dist/src/commands/test.js +100 -123
  109. package/dist/src/commands/token-coverage.js +4 -9
  110. package/dist/src/commands/tree.d.ts +3 -0
  111. package/dist/src/commands/tree.js +171 -25
  112. package/dist/src/commands/verify.js +2 -2
  113. package/dist/src/commands/watch.js +22 -21
  114. package/dist/src/commands/wire.js +1 -1
  115. package/dist/src/core/ast-utils.d.ts +11 -0
  116. package/dist/src/core/ast-utils.js +11 -0
  117. package/dist/src/core/branding.d.ts +16 -0
  118. package/dist/src/core/branding.js +18 -2
  119. package/dist/src/core/build-audit-platform.js +6 -1
  120. package/dist/src/core/build-audit-registration.js +2 -0
  121. package/dist/src/core/build-audit-resolve.d.ts +13 -0
  122. package/dist/src/core/build-audit-resolve.js +34 -13
  123. package/dist/src/core/build-audit.js +4 -20
  124. package/dist/src/core/build-baseline-types.d.ts +29 -8
  125. package/dist/src/core/build-baseline-types.js +2 -1
  126. package/dist/src/core/build-baseline.d.ts +10 -2
  127. package/dist/src/core/build-baseline.js +39 -8
  128. package/dist/src/core/build-prepare.d.ts +4 -0
  129. package/dist/src/core/build-prepare.js +7 -1
  130. package/dist/src/core/config-paths.d.ts +1 -1
  131. package/dist/src/core/config-paths.js +2 -0
  132. package/dist/src/core/config-validate-test-toolchains.js +3 -2
  133. package/dist/src/core/config-validate.js +34 -36
  134. package/dist/src/core/config.js +16 -3
  135. package/dist/src/core/coverage-extend.d.ts +89 -0
  136. package/dist/src/core/coverage-extend.js +188 -0
  137. package/dist/src/core/destructive.js +3 -1
  138. package/dist/src/core/discard-baseline.js +18 -4
  139. package/dist/src/core/engine-precondition.d.ts +35 -0
  140. package/dist/src/core/engine-precondition.js +50 -0
  141. package/dist/src/core/engine-session-lock.d.ts +38 -1
  142. package/dist/src/core/engine-session-lock.js +78 -4
  143. package/dist/src/core/file-lock.d.ts +7 -0
  144. package/dist/src/core/file-lock.js +20 -30
  145. package/dist/src/core/firefox.js +5 -2
  146. package/dist/src/core/furnace-apply-helpers.d.ts +12 -1
  147. package/dist/src/core/furnace-apply-helpers.js +12 -3
  148. package/dist/src/core/furnace-apply-output.js +9 -0
  149. package/dist/src/core/furnace-apply-overwrite-warn.d.ts +63 -0
  150. package/dist/src/core/furnace-apply-overwrite-warn.js +123 -0
  151. package/dist/src/core/furnace-apply-undeploy-actions.d.ts +11 -0
  152. package/dist/src/core/furnace-apply-undeploy-actions.js +49 -0
  153. package/dist/src/core/furnace-apply.d.ts +16 -4
  154. package/dist/src/core/furnace-apply.js +39 -43
  155. package/dist/src/core/furnace-config.js +2 -0
  156. package/dist/src/core/furnace-css-fragments.js +2 -0
  157. package/dist/src/core/furnace-jsconfig.js +14 -7
  158. package/dist/src/core/furnace-registration-ast.js +8 -8
  159. package/dist/src/core/furnace-registration-remove.js +6 -3
  160. package/dist/src/core/furnace-registration.js +9 -12
  161. package/dist/src/core/furnace-rollback.js +1 -1
  162. package/dist/src/core/furnace-scanner.js +2 -1
  163. package/dist/src/core/furnace-stale-export.js +2 -1
  164. package/dist/src/core/furnace-state-persist.d.ts +4 -6
  165. package/dist/src/core/furnace-validate-compatibility.js +4 -7
  166. package/dist/src/core/furnace-validate-helpers.js +33 -1
  167. package/dist/src/core/furnace-validate-registration.js +16 -0
  168. package/dist/src/core/furnace-validate.js +4 -2
  169. package/dist/src/core/git-diff.js +37 -2
  170. package/dist/src/core/lint-cache.d.ts +1 -1
  171. package/dist/src/core/lint-cache.js +33 -10
  172. package/dist/src/core/mach-build-artifacts.d.ts +63 -1
  173. package/dist/src/core/mach-build-artifacts.js +108 -2
  174. package/dist/src/core/mach-mozconfig.js +9 -1
  175. package/dist/src/core/mach-resource-shim.js +1 -0
  176. package/dist/src/core/marionette-port.d.ts +16 -0
  177. package/dist/src/core/marionette-port.js +93 -8
  178. package/dist/src/core/marionette-preflight.js +6 -5
  179. package/dist/src/core/patch-apply.d.ts +20 -13
  180. package/dist/src/core/patch-apply.js +32 -33
  181. package/dist/src/core/patch-export.js +3 -5
  182. package/dist/src/core/patch-lint-checkjs.d.ts +25 -3
  183. package/dist/src/core/patch-lint-checkjs.js +78 -7
  184. package/dist/src/core/patch-lint-chrome-jsdoc.js +9 -0
  185. package/dist/src/core/patch-lint-creators.d.ts +30 -0
  186. package/dist/src/core/patch-lint-creators.js +45 -0
  187. package/dist/src/core/patch-lint-cross.d.ts +1 -17
  188. package/dist/src/core/patch-lint-cross.js +16 -32
  189. package/dist/src/core/patch-lint-jsdoc.d.ts +3 -1
  190. package/dist/src/core/patch-lint-jsdoc.js +15 -5
  191. package/dist/src/core/patch-lint-module-registration.d.ts +15 -0
  192. package/dist/src/core/patch-lint-module-registration.js +88 -0
  193. package/dist/src/core/patch-lint-mozbuild.js +99 -11
  194. package/dist/src/core/patch-lint-reexports.d.ts +1 -0
  195. package/dist/src/core/patch-lint-reexports.js +1 -0
  196. package/dist/src/core/patch-lint.d.ts +8 -0
  197. package/dist/src/core/patch-lint.js +84 -6
  198. package/dist/src/core/patch-lock.d.ts +12 -1
  199. package/dist/src/core/patch-lock.js +40 -4
  200. package/dist/src/core/patch-manifest-consistency.js +15 -3
  201. package/dist/src/core/patch-parse.d.ts +8 -0
  202. package/dist/src/core/patch-parse.js +10 -0
  203. package/dist/src/core/patch-policy.d.ts +7 -0
  204. package/dist/src/core/patch-policy.js +4 -1
  205. package/dist/src/core/patch-transform.d.ts +10 -0
  206. package/dist/src/core/patch-transform.js +13 -2
  207. package/dist/src/core/rebase-session.d.ts +44 -2
  208. package/dist/src/core/rebase-session.js +114 -17
  209. package/dist/src/core/register-browser-content.js +2 -1
  210. package/dist/src/core/register-shared-css.js +2 -1
  211. package/dist/src/core/smoke-patterns.d.ts +0 -8
  212. package/dist/src/core/smoke-patterns.js +3 -2
  213. package/dist/src/core/status-binary.d.ts +55 -0
  214. package/dist/src/core/status-binary.js +124 -0
  215. package/dist/src/core/status-classify.d.ts +6 -1
  216. package/dist/src/core/status-classify.js +101 -73
  217. package/dist/src/core/test-harness-crash.d.ts +32 -1
  218. package/dist/src/core/test-harness-crash.js +59 -4
  219. package/dist/src/core/test-path-scope.js +2 -0
  220. package/dist/src/core/test-stale-check.js +1 -20
  221. package/dist/src/core/toolchain-preflight.js +24 -26
  222. package/dist/src/core/tree-guard.d.ts +1 -1
  223. package/dist/src/core/tree-guard.js +67 -12
  224. package/dist/src/core/tree-store.d.ts +86 -10
  225. package/dist/src/core/tree-store.js +310 -72
  226. package/dist/src/core/typecheck.js +2 -1
  227. package/dist/src/core/wire-destroy.js +14 -3
  228. package/dist/src/core/wire-init.js +37 -40
  229. package/dist/src/core/wire-subscript.js +11 -3
  230. package/dist/src/core/wire-utils.js +4 -4
  231. package/dist/src/core/xpcshell-appdir.js +3 -0
  232. package/dist/src/errors/base.d.ts +13 -0
  233. package/dist/src/errors/base.js +13 -0
  234. package/dist/src/errors/rebase.d.ts +13 -0
  235. package/dist/src/errors/rebase.js +17 -0
  236. package/dist/src/types/commands/options.d.ts +86 -1
  237. package/dist/src/types/commands/patches.d.ts +8 -0
  238. package/dist/src/types/furnace.d.ts +7 -0
  239. package/dist/src/utils/build-info.d.ts +31 -0
  240. package/dist/src/utils/build-info.js +98 -0
  241. package/dist/src/utils/concurrency.d.ts +8 -0
  242. package/dist/src/utils/concurrency.js +21 -0
  243. package/dist/src/utils/errors.d.ts +39 -0
  244. package/dist/src/utils/errors.js +55 -0
  245. package/dist/src/utils/fs.js +5 -12
  246. package/dist/src/utils/logger.d.ts +14 -2
  247. package/dist/src/utils/logger.js +43 -13
  248. package/dist/src/utils/options.d.ts +21 -4
  249. package/dist/src/utils/options.js +26 -6
  250. package/dist/src/utils/package-root.js +7 -4
  251. package/dist/src/utils/process-group.js +13 -2
  252. package/dist/src/utils/stdio-drain.d.ts +28 -0
  253. package/dist/src/utils/stdio-drain.js +54 -0
  254. package/dist/src/utils/validation.d.ts +23 -2
  255. package/dist/src/utils/validation.js +29 -3
  256. package/package.json +9 -9
@@ -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) {
@@ -3,14 +3,14 @@ import { join } from 'node:path';
3
3
  import { getProjectPaths, loadConfig } from '../core/config.js';
4
4
  import { getDiffForFilesAgainstHead } from '../core/git-diff.js';
5
5
  import { buildPerPatchLintCacheKey, getCachedPerPatchLintIssues, getPerPatchLintCacheHeadSha, loadPerPatchLintCache, savePerPatchLintCache, setCachedPerPatchLintIssues, } from '../core/lint-cache.js';
6
- import { buildPatchQueueContext, countNonBinaryDiffLines, lintExportedPatch, lintPatchQueue, resolvePatchSizeTier, } from '../core/patch-lint.js';
7
- import { invokePatchLintCheckJsGrouped, runCheckJsTestFilesGrouped, } from '../core/patch-lint-checkjs.js';
8
- import { isTestScriptFile, resolvePatchOwnedSysMjs, resolvePatchOwnedTestScripts, } from '../core/patch-lint-ownership.js';
6
+ import { buildPatchQueueContext, countNonBinaryDiffLines, formatPatchLintIssue, lintExportedPatch, lintPatchQueue, resolvePatchSizeTier, } from '../core/patch-lint.js';
9
7
  import { loadPatchesManifest } from '../core/patch-manifest.js';
10
8
  import { evaluatePatchPolicy } from '../core/patch-policy.js';
11
9
  import { GeneralError } from '../errors/base.js';
10
+ import { mapWithConcurrency } from '../utils/concurrency.js';
12
11
  import { pathExists } from '../utils/fs.js';
13
12
  import { info, outro, success, warn } from '../utils/logger.js';
13
+ import { buildPerRunCheckJs } from './lint-per-run-checkjs.js';
14
14
  import { writePerPatchLintReport } from './lint-report.js';
15
15
  function buildPerPatchMaxWarningsMessage(count, maxWarnings, linted) {
16
16
  return (`Patch lint found ${count} warning(s) across ${linted} patch(es), exceeding --max-warnings ${maxWarnings}.` +
@@ -34,11 +34,8 @@ function emitTierNotice(filename, files, tier) {
34
34
  */
35
35
  async function lintQueuedPatch(patch, lintCtx) {
36
36
  const { projectRoot, paths, config, ctx, cache, engineHeadSha } = lintCtx;
37
- const existing = [];
38
- for (const f of patch.filesAffected) {
39
- if (await pathExists(join(paths.engine, f)))
40
- existing.push(f);
41
- }
37
+ const present = await mapWithConcurrency(patch.filesAffected, 8, (f) => pathExists(join(paths.engine, f)));
38
+ const existing = patch.filesAffected.filter((_, index) => present[index] === true);
42
39
  if (existing.length === 0) {
43
40
  return {
44
41
  status: 'skipped',
@@ -237,11 +234,11 @@ function reportPerPatchOutcome(issues, linted, skipped, options) {
237
234
  const warnings = issues.filter((i) => i.severity === 'warning');
238
235
  const notices = issues.filter((i) => i.severity === 'notice');
239
236
  for (const issue of notices)
240
- info(`NOTICE [${issue.check}] ${issue.file}: ${issue.message}`);
237
+ info(`NOTICE ${formatPatchLintIssue(issue)}`);
241
238
  for (const issue of warnings)
242
- warn(`[${issue.check}] ${issue.file}: ${issue.message}`);
239
+ warn(formatPatchLintIssue(issue));
243
240
  for (const issue of errors)
244
- warn(`ERROR [${issue.check}] ${issue.file}: ${issue.message}`);
241
+ warn(`ERROR ${formatPatchLintIssue(issue)}`);
245
242
  info(`\nLint (per-patch over ${linted} patch(es)): ${errors.length} error(s), ${warnings.length} warning(s)`);
246
243
  if (errors.length > 0) {
247
244
  outro('Lint failed');
@@ -305,53 +302,6 @@ function selectPatchSubset(manifest, requested) {
305
302
  }
306
303
  return selected;
307
304
  }
308
- /**
309
- * Builds the per-run checkJs program controller when `patchLint.checkJs` is
310
- * enabled, or returns undefined. The program is built lazily on the first
311
- * cache miss (so an all-warm run never pays for it) and reused for every
312
- * subsequent patch in the run.
313
- */
314
- function buildPerRunCheckJs(projectRoot, paths, config, ctx) {
315
- const patchLint = config.patchLint;
316
- if (!patchLint?.checkJs)
317
- return undefined;
318
- const testFilesEnabled = patchLint.checkJsTestFiles === true;
319
- const ownedByPatch = new Map();
320
- for (const entry of ctx.entries) {
321
- const owned = new Set();
322
- for (const f of entry.newFiles.keys()) {
323
- if (f.endsWith('.sys.mjs'))
324
- owned.add(f);
325
- else if (testFilesEnabled && isTestScriptFile(f))
326
- owned.add(f);
327
- }
328
- if (owned.size > 0)
329
- ownedByPatch.set(entry.filename, owned);
330
- }
331
- // One build for the whole run: the queue-wide `.sys.mjs` program plus —
332
- // when `patchLint.checkJsTestFiles` is on (FORGE G5) — one small
333
- // script-scope program per patch-owned test file, merged by file.
334
- const buildAll = async () => {
335
- const sys = await invokePatchLintCheckJsGrouped(paths.engine, resolvePatchOwnedSysMjs(new Set(), ctx), patchLint, projectRoot);
336
- if (!testFilesEnabled)
337
- return sys;
338
- const tests = await runCheckJsTestFilesGrouped(paths.engine, resolvePatchOwnedTestScripts(new Set(), ctx), patchLint, projectRoot);
339
- const byFile = new Map(sys.byFile);
340
- for (const [rel, list] of tests.byFile) {
341
- byFile.set(rel, [...(byFile.get(rel) ?? []), ...list]);
342
- }
343
- return { byFile, global: [...sys.global, ...tests.global] };
344
- };
345
- // Memoise the *promise*, not the resolved value: under the bounded pool
346
- // several patches can reach `getGrouped` before the first build resolves, and
347
- // `??=` on the promise (a synchronous expression) guarantees a single build.
348
- let groupedPromise;
349
- return {
350
- ownedByPatch,
351
- getGrouped: () => (groupedPromise ??= buildAll()),
352
- getGlobal: async () => (await (groupedPromise ??= buildAll())).global,
353
- };
354
- }
355
305
  /**
356
306
  * Lints each patch in the queue as its own isolated diff, honouring
357
307
  * per-patch `lintIgnore` entries. Cross-patch rules still run once over
@@ -400,7 +350,10 @@ export async function lintPerPatch(projectRoot, paths, options = {}) {
400
350
  severity: issue.severity,
401
351
  });
402
352
  }
403
- const checkJs = buildPerRunCheckJs(projectRoot, paths, config, ctx);
353
+ // With `--patches`, the checkJs program roots at only the subset's owned
354
+ // files — the full queue stays resolvable, so a cold subset run costs the
355
+ // subset's import closure instead of the whole queue (FORGE J1c).
356
+ const checkJs = buildPerRunCheckJs(projectRoot, paths, config, ctx, isSubset ? subsetNames : undefined);
404
357
  // Lint patches concurrently, then apply every side effect in patch order so
405
358
  // the issue rows, the run-level checkJs errors, and the saved cache are
406
359
  // identical to a serial run.
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Per-run checkJs program controller, shared by `lint --per-patch` and
3
+ * `re-export` (FORGE J1).
4
+ *
5
+ * The dominant fixed cost of patch lint is one `ts.createProgram` over the
6
+ * queue's patch-owned `.sys.mjs` files (~37 s on a 290-patch consumer
7
+ * queue). This controller builds that program at most ONCE per command
8
+ * invocation (lazily, promise-memoised) and lets callers slice per-patch
9
+ * findings out of the grouped result. Three levers keep the cost paid only
10
+ * when it buys something:
11
+ *
12
+ * - **Lazy build**: nothing is built until the first cache miss asks.
13
+ * - **Root scoping** (`rootScopePatches`): with `--patches <subset>` the
14
+ * program's roots are only the subset's owned files; the full queue
15
+ * stays resolvable, so cross-patch imports type-check identically while
16
+ * unrelated files are never parsed (FORGE J1c).
17
+ * - **Warm-run probe**: run-level ("global") checkJs findings come only
18
+ * from a missing `typescript` package or an unreadable shim — never
19
+ * from the built program — so an all-cache-hit run satisfies "warm
20
+ * never reports less than cold" (FORGE F5) via
21
+ * `probeCheckJsGlobalIssues` instead of building anything.
22
+ */
23
+ import type { getProjectPaths, loadConfig } from '../core/config.js';
24
+ import { type GroupedCheckJsResult } from '../core/patch-lint-checkjs.js';
25
+ import type { PatchQueueContext } from '../core/patch-lint-cross.js';
26
+ import type { PatchLintIssue } from '../types/commands/index.js';
27
+ /**
28
+ * Queue-wide checkJs program built once per run and sliced per patch, so a
29
+ * single type regression surfaces once against its owning patch instead of
30
+ * being duplicated for every patch in the queue.
31
+ */
32
+ export interface PerRunCheckJs {
33
+ /** Patch filename → the checkJs-relevant files that patch creates. */
34
+ ownedByPatch: Map<string, Set<string>>;
35
+ /** Every checkJs-relevant file in the queue (sys + test scripts) — the
36
+ * resolution universe the program can see. Callers use it to detect
37
+ * files the hoisted program has never heard of (fresh `--scan`
38
+ * adoptions) and fall back to a per-patch build. */
39
+ resolutionSet: ReadonlySet<string>;
40
+ /** Builds (once, lazily on first cache miss) and returns the grouped run.
41
+ * Promise-memoised so the bounded per-patch pool builds the program exactly
42
+ * once even when several patches reach it concurrently. */
43
+ getGrouped: () => Promise<GroupedCheckJsResult>;
44
+ /** Run-level checkJs errors (e.g. TypeScript missing). When the program
45
+ * was never built (all-warm run) this probes the two global failure
46
+ * sources directly instead of building it — FORGE F5 holds because the
47
+ * built program never contributes globals of its own. */
48
+ getGlobal: () => Promise<PatchLintIssue[]>;
49
+ /** The byFile slices for `files` (globals deliberately EXCLUDED — they
50
+ * are run-level, never cached, and emitted once per invocation via
51
+ * `getGlobal`). Feed to `LintExportedPatchOptions.precomputedCheckJs`. */
52
+ sliceFor: (files: ReadonlySet<string>) => Promise<PatchLintIssue[]>;
53
+ /** True when this file kind participates in the run's checkJs surface
54
+ * (`.sys.mjs`, plus test scripts when `checkJsTestFiles` is on). */
55
+ isRelevant: (file: string) => boolean;
56
+ }
57
+ /**
58
+ * Builds the per-run checkJs program controller when `patchLint.checkJs` is
59
+ * enabled, or returns undefined. With `rootScopePatches` set (the
60
+ * `--patches` subset, by patch filename), the program roots at only the
61
+ * subset's owned files and the test-file pass runs only the subset's own
62
+ * scripts (head.js discovery still spans the queue).
63
+ */
64
+ export declare function buildPerRunCheckJs(projectRoot: string, paths: ReturnType<typeof getProjectPaths>, config: Awaited<ReturnType<typeof loadConfig>>, ctx: PatchQueueContext, rootScopePatches?: ReadonlySet<string>): PerRunCheckJs | undefined;