@hominis/fireforge 0.30.0 → 0.31.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 (141) hide show
  1. package/CHANGELOG.md +26 -1
  2. package/README.md +22 -5
  3. package/dist/src/commands/export-all.js +5 -15
  4. package/dist/src/commands/export-flow.d.ts +6 -0
  5. package/dist/src/commands/export-flow.js +6 -1
  6. package/dist/src/commands/export-placement-gate.d.ts +38 -0
  7. package/dist/src/commands/export-placement-gate.js +105 -0
  8. package/dist/src/commands/export-shared.d.ts +28 -0
  9. package/dist/src/commands/export-shared.js +36 -0
  10. package/dist/src/commands/export.js +47 -112
  11. package/dist/src/commands/furnace/chrome-doc-templates.d.ts +0 -13
  12. package/dist/src/commands/furnace/chrome-doc-templates.js +1 -1
  13. package/dist/src/commands/furnace/create-dry-run.d.ts +1 -1
  14. package/dist/src/commands/furnace/create.d.ts +1 -2
  15. package/dist/src/commands/furnace/deploy.js +36 -114
  16. package/dist/src/commands/furnace/refresh.js +52 -32
  17. package/dist/src/commands/furnace/sync.js +2 -0
  18. package/dist/src/commands/import.js +108 -73
  19. package/dist/src/commands/lint-per-patch.d.ts +1 -1
  20. package/dist/src/commands/lint-per-patch.js +119 -78
  21. package/dist/src/commands/lint.d.ts +1 -58
  22. package/dist/src/commands/lint.js +96 -84
  23. package/dist/src/commands/patch/compact.d.ts +5 -2
  24. package/dist/src/commands/patch/compact.js +85 -25
  25. package/dist/src/commands/patch/delete.js +17 -17
  26. package/dist/src/commands/patch/index.js +2 -0
  27. package/dist/src/commands/patch/lint-ignore.js +3 -16
  28. package/dist/src/commands/patch/move-files.js +2 -0
  29. package/dist/src/commands/patch/patch-context.d.ts +41 -0
  30. package/dist/src/commands/patch/patch-context.js +53 -0
  31. package/dist/src/commands/patch/rename.js +10 -15
  32. package/dist/src/commands/patch/reorder.d.ts +0 -2
  33. package/dist/src/commands/patch/reorder.js +18 -19
  34. package/dist/src/commands/patch/split-plan.d.ts +66 -0
  35. package/dist/src/commands/patch/split-plan.js +178 -0
  36. package/dist/src/commands/patch/split.d.ts +30 -0
  37. package/dist/src/commands/patch/split.js +283 -0
  38. package/dist/src/commands/patch/staged-dependency.d.ts +1 -7
  39. package/dist/src/commands/patch/staged-dependency.js +4 -17
  40. package/dist/src/commands/patch/tier.js +4 -17
  41. package/dist/src/commands/re-export-scan.js +8 -1
  42. package/dist/src/commands/rebase/summary.d.ts +1 -5
  43. package/dist/src/commands/rebase/summary.js +1 -1
  44. package/dist/src/commands/status-output.js +77 -68
  45. package/dist/src/commands/test-diagnose.d.ts +23 -0
  46. package/dist/src/commands/test-diagnose.js +210 -0
  47. package/dist/src/commands/test-run.d.ts +58 -0
  48. package/dist/src/commands/test-run.js +88 -0
  49. package/dist/src/commands/test.js +169 -257
  50. package/dist/src/commands/token.js +15 -1
  51. package/dist/src/commands/wire.js +109 -78
  52. package/dist/src/core/build-audit.d.ts +1 -1
  53. package/dist/src/core/build-audit.js +2 -46
  54. package/dist/src/core/build-baseline-types.d.ts +38 -0
  55. package/dist/src/core/build-baseline-types.js +10 -0
  56. package/dist/src/core/build-baseline.d.ts +1 -31
  57. package/dist/src/core/build-prepare.d.ts +1 -1
  58. package/dist/src/core/build-prepare.js +2 -45
  59. package/dist/src/core/config-paths.d.ts +0 -8
  60. package/dist/src/core/config-paths.js +4 -4
  61. package/dist/src/core/config-state.d.ts +0 -6
  62. package/dist/src/core/config-state.js +1 -1
  63. package/dist/src/core/config-validate-patch-policy.js +12 -13
  64. package/dist/src/core/config-validate.js +48 -28
  65. package/dist/src/core/engine-changes.d.ts +24 -0
  66. package/dist/src/core/engine-changes.js +64 -0
  67. package/dist/src/core/firefox-cache.d.ts +0 -5
  68. package/dist/src/core/firefox-cache.js +1 -1
  69. package/dist/src/core/firefox-download.d.ts +0 -6
  70. package/dist/src/core/firefox-download.js +1 -1
  71. package/dist/src/core/furnace-apply-helpers.d.ts +1 -8
  72. package/dist/src/core/furnace-apply-helpers.js +11 -20
  73. package/dist/src/core/furnace-apply.d.ts +1 -1
  74. package/dist/src/core/furnace-apply.js +1 -1
  75. package/dist/src/core/furnace-checksum-utils.d.ts +7 -0
  76. package/dist/src/core/furnace-checksum-utils.js +15 -0
  77. package/dist/src/core/furnace-config-validate.d.ts +31 -0
  78. package/dist/src/core/furnace-config-validate.js +133 -0
  79. package/dist/src/core/furnace-config.d.ts +4 -32
  80. package/dist/src/core/furnace-config.js +15 -111
  81. package/dist/src/core/furnace-constants.d.ts +0 -10
  82. package/dist/src/core/furnace-constants.js +2 -2
  83. package/dist/src/core/furnace-css-fragments.d.ts +79 -0
  84. package/dist/src/core/furnace-css-fragments.js +243 -0
  85. package/dist/src/core/furnace-jsconfig.d.ts +63 -0
  86. package/dist/src/core/furnace-jsconfig.js +171 -0
  87. package/dist/src/core/furnace-validate-helpers.d.ts +16 -14
  88. package/dist/src/core/furnace-validate-helpers.js +40 -1
  89. package/dist/src/core/furnace-validate-registration.js +16 -1
  90. package/dist/src/core/furnace-validate.js +54 -2
  91. package/dist/src/core/git-file-ops.d.ts +0 -12
  92. package/dist/src/core/git-file-ops.js +2 -2
  93. package/dist/src/core/lint-cache.d.ts +3 -13
  94. package/dist/src/core/lint-cache.js +11 -5
  95. package/dist/src/core/mach.d.ts +5 -1
  96. package/dist/src/core/mach.js +6 -2
  97. package/dist/src/core/manifest-register.d.ts +5 -16
  98. package/dist/src/core/manifest-register.js +3 -1
  99. package/dist/src/core/patch-lint-checkjs.js +53 -7
  100. package/dist/src/core/patch-lint-jsdoc.js +63 -4
  101. package/dist/src/core/patch-lint-observer.d.ts +37 -0
  102. package/dist/src/core/patch-lint-observer.js +168 -0
  103. package/dist/src/core/patch-lint.js +132 -125
  104. package/dist/src/core/patch-manifest-io.d.ts +16 -0
  105. package/dist/src/core/patch-manifest-io.js +44 -2
  106. package/dist/src/core/patch-manifest-validate.d.ts +1 -8
  107. package/dist/src/core/patch-manifest-validate.js +1 -1
  108. package/dist/src/core/patch-manifest.d.ts +1 -1
  109. package/dist/src/core/patch-manifest.js +1 -1
  110. package/dist/src/core/patch-policy.d.ts +0 -4
  111. package/dist/src/core/patch-policy.js +10 -4
  112. package/dist/src/core/register-browser-content.d.ts +1 -1
  113. package/dist/src/core/register-module.d.ts +1 -1
  114. package/dist/src/core/register-result.d.ts +21 -0
  115. package/dist/src/core/register-result.js +9 -0
  116. package/dist/src/core/register-shared-css.d.ts +1 -1
  117. package/dist/src/core/register-test-manifest.d.ts +1 -1
  118. package/dist/src/core/test-harness-crash.d.ts +61 -0
  119. package/dist/src/core/test-harness-crash.js +140 -0
  120. package/dist/src/core/test-stale-check.d.ts +1 -1
  121. package/dist/src/core/test-stale-check.js +2 -46
  122. package/dist/src/core/test-xpcshell-retry.d.ts +1 -1
  123. package/dist/src/core/test-xpcshell-retry.js +4 -2
  124. package/dist/src/core/token-dark-mode.js +14 -26
  125. package/dist/src/core/token-manager.d.ts +4 -0
  126. package/dist/src/core/token-manager.js +70 -16
  127. package/dist/src/core/typecheck-shim.d.ts +0 -21
  128. package/dist/src/core/typecheck-shim.js +26 -4
  129. package/dist/src/core/wire-utils.js +37 -44
  130. package/dist/src/types/commands/index.d.ts +1 -1
  131. package/dist/src/types/commands/options.d.ts +105 -0
  132. package/dist/src/types/furnace.d.ts +12 -1
  133. package/dist/src/utils/elapsed.d.ts +0 -2
  134. package/dist/src/utils/elapsed.js +1 -1
  135. package/dist/src/utils/fs.d.ts +0 -5
  136. package/dist/src/utils/fs.js +1 -1
  137. package/dist/src/utils/regex.d.ts +0 -6
  138. package/dist/src/utils/regex.js +3 -3
  139. package/dist/src/utils/validation.d.ts +0 -8
  140. package/dist/src/utils/validation.js +2 -2
  141. package/package.json +6 -4
@@ -20,7 +20,7 @@
20
20
  * Exposed as a named constant so test code and external checks can
21
21
  * reference the exact attribute name without hardcoding the string.
22
22
  */
23
- export const FURNACE_CHROME_DOC_SENTINEL = 'data-furnace-chrome-doc';
23
+ const FURNACE_CHROME_DOC_SENTINEL = 'data-furnace-chrome-doc';
24
24
  /**
25
25
  * XHTML shell for a top-level chrome document.
26
26
  *
@@ -1,4 +1,4 @@
1
- import type { ResolvedTestStyle } from './create.js';
1
+ import type { ResolvedTestStyle } from '../../types/furnace.js';
2
2
  export interface DryRunPlanInput {
3
3
  componentName: string;
4
4
  localized: boolean;
@@ -1,6 +1,5 @@
1
1
  import type { FurnaceCreateOptions } from '../../types/commands/index.js';
2
- /** Resolved test-harness selection for a `furnace create` run. */
3
- export type ResolvedTestStyle = 'mochikit' | 'browser-chrome' | 'xpcshell' | 'none';
2
+ import type { ResolvedTestStyle } from '../../types/furnace.js';
4
3
  /**
5
4
  * Collapses `--with-tests`, `--xpcshell`, and `--test-style` into the single
6
5
  * scaffold dispatch used inside the mutation phase.
@@ -1,16 +1,13 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  import { join } from 'node:path';
3
3
  import { getProjectPaths, loadConfig } from '../../core/config.js';
4
- import { applyAllComponents, applyCustomComponent, applyOverrideComponent, computeComponentChecksums, prefixChecksums, } from '../../core/furnace-apply.js';
4
+ import { applyAllComponents, computeComponentChecksums, prefixChecksums, } from '../../core/furnace-apply.js';
5
5
  import { logApplyResult } from '../../core/furnace-apply-output.js';
6
6
  import { furnaceConfigExists, getFurnacePaths, loadFurnaceConfig, updateFurnaceState, } from '../../core/furnace-config.js';
7
- import { resolveFtlDir } from '../../core/furnace-constants.js';
8
- import { resolveFurnaceMarkerComment } from '../../core/furnace-marker.js';
9
- import { recordFurnaceRollbackFailure, runFurnaceMutation, } from '../../core/furnace-operation.js';
10
- import { createRollbackJournal, restoreRollbackJournalOrThrow, } from '../../core/furnace-rollback.js';
7
+ import { reportJsconfigPathsSync } from '../../core/furnace-jsconfig.js';
8
+ import { runFurnaceMutation } from '../../core/furnace-operation.js';
11
9
  import { findOverrideBaseVersionDrift, formatOverrideBaseVersionDriftError, formatOverrideBaseVersionDriftWarning, } from '../../core/furnace-version-drift.js';
12
10
  import { FurnaceError } from '../../errors/furnace.js';
13
- import { toError } from '../../utils/errors.js';
14
11
  import { pathExists } from '../../utils/fs.js';
15
12
  import { info, intro, note, outro, spinner, warn } from '../../utils/logger.js';
16
13
  import { runDeployValidation } from './validation-output.js';
@@ -116,115 +113,42 @@ async function persistSingleComponentState(projectRoot, appliedEntry, furnacePat
116
113
  lastApply: new Date().toISOString(),
117
114
  }));
118
115
  }
119
- /**
120
- * True when an applied-result carries any signal that the deploy did not
121
- * complete cleanly. Any such signal must trigger the rollback journal and
122
- * must suppress state persistence, so both call sites read from here.
123
- *
124
- * An apply failure on a single-component deploy means one of:
125
- * - `result.errors` has an entry (the apply body threw)
126
- * - an `applied[].stepErrors` entry is present (a registration step
127
- * failed after the copy succeeded)
128
- *
129
- * Both are treated identically for atomicity purposes: rollback runs,
130
- * state stays on the previous checkpoint, and the caller raises a deploy
131
- * failure so the operator sees the error.
132
- */
133
- function namedDeployHasFailures(result) {
134
- return result.errors.length > 0 || getStepFailureCount(result) > 0;
135
- }
136
- async function restoreNamedDeployRollback(rollbackJournal, name, projectRoot) {
137
- if (!rollbackJournal)
138
- return;
139
- try {
140
- await restoreRollbackJournalOrThrow(rollbackJournal, `Furnace deploy failed for "${name}"`);
141
- }
142
- catch (rollbackError) {
143
- if (projectRoot) {
144
- await recordFurnaceRollbackFailure(projectRoot, 'deploy-rollback', `component "${name}": ${toError(rollbackError).message}`);
145
- }
146
- throw rollbackError;
147
- }
148
- }
149
116
  /**
150
117
  * Applies a single named override or custom component in targeted deploy mode.
151
118
  *
152
- * Atomicity contract: the helper owns a single rollback journal for the
153
- * deploy. If any apply path fails (thrown error or step error), the journal
154
- * restores the engine to its pre-deploy state and the returned `result` is
155
- * still reported as failed to the caller. The caller must consult
156
- * {@link shouldPersistNamedDeployState} before touching furnace-state.json
157
- * partial checksums must never be persisted on top of a rollback.
119
+ * Delegates to {@link applyAllComponents} with a `componentName` filter so
120
+ * targeted deploys run the exact same pipeline as deploy-all including
121
+ * workspace-deletion detection, engine orphan undeploy, and jar.mn /
122
+ * customElements.js re-sync. The previous implementation called the
123
+ * per-component apply helpers directly and never pruned: renaming a helper
124
+ * file in the workspace left the old deployed file and its stale jar.mn
125
+ * line in the engine (field report D1).
126
+ *
127
+ * `persistState: false` is load-bearing: the batch persist path *replaces*
128
+ * `appliedChecksums` wholesale with only this run's entries, which for a
129
+ * named deploy would wipe every other component's state. Named deploy keeps
130
+ * its per-component state merge ({@link persistSingleComponentState}) and
131
+ * its atomicity gate ({@link shouldPersistNamedDeployState}) at the call
132
+ * site. Rollback on failure happens inside `applyAllComponents`; the
133
+ * journal returned on success is ignored (the deploy keeps its files).
158
134
  *
159
135
  * @param name - Component name to apply
160
- * @param engineDir - Firefox engine source directory
161
- * @param furnacePaths - Resolved Furnace workspace paths
162
136
  * @param config - Loaded Furnace configuration
163
137
  * @param isDryRun - Whether file writes should be skipped
164
138
  * @returns Apply result for the named component, or `stock` for stock-only entries
165
139
  */
166
- async function applyNamedComponent(name, engineDir, furnacePaths, config, ftlDir, isDryRun, operationContext, projectRoot, markerComment) {
167
- const rollbackJournal = isDryRun ? undefined : createRollbackJournal();
168
- if (rollbackJournal && operationContext) {
169
- operationContext.registerJournal(rollbackJournal);
170
- }
171
- const result = {
172
- applied: [],
173
- skipped: [],
174
- errors: [],
175
- actions: [],
176
- };
177
- const overrideConfig = config.overrides[name];
178
- const customConfig = config.custom[name];
179
- if (overrideConfig) {
180
- const componentDir = join(furnacePaths.overridesDir, name);
181
- if (!(await pathExists(componentDir))) {
182
- throw new FurnaceError(`Component directory not found: components/overrides/${name}`, name);
183
- }
184
- try {
185
- const { affectedPaths: filesAffected, actions } = await applyOverrideComponent(engineDir, name, componentDir, overrideConfig, ftlDir, isDryRun, rollbackJournal);
186
- if (isDryRun && actions) {
187
- result.actions = actions;
188
- }
189
- result.applied.push({ name, type: 'override', filesAffected });
190
- }
191
- catch (error) {
192
- result.errors.push({ name, error: toError(error).message });
140
+ async function applyNamedComponent(name, config, isDryRun, projectRoot, operationContext) {
141
+ if (!(name in config.overrides) && !(name in config.custom)) {
142
+ if (config.stock.includes(name)) {
143
+ return 'stock';
193
144
  }
194
- if (!isDryRun && namedDeployHasFailures(result)) {
195
- await restoreNamedDeployRollback(rollbackJournal, name, projectRoot);
196
- }
197
- return result;
145
+ throw new FurnaceError(`Component "${name}" not found in furnace.json.`, name);
198
146
  }
199
- if (customConfig) {
200
- const componentDir = join(furnacePaths.customDir, name);
201
- if (!(await pathExists(componentDir))) {
202
- throw new FurnaceError(`Component directory not found: components/custom/${name}`, name);
203
- }
204
- try {
205
- const { affectedPaths: filesAffected, stepErrors, actions, } = await applyCustomComponent(engineDir, name, componentDir, customConfig, ftlDir, isDryRun, rollbackJournal, markerComment !== undefined ? { markerComment } : {});
206
- if (isDryRun && actions) {
207
- result.actions = actions;
208
- }
209
- result.applied.push({
210
- name,
211
- type: 'custom',
212
- filesAffected,
213
- ...(stepErrors.length > 0 ? { stepErrors } : {}),
214
- });
215
- }
216
- catch (error) {
217
- result.errors.push({ name, error: toError(error).message });
218
- }
219
- if (!isDryRun && namedDeployHasFailures(result)) {
220
- await restoreNamedDeployRollback(rollbackJournal, name, projectRoot);
221
- }
222
- return result;
223
- }
224
- if (config.stock.includes(name)) {
225
- return 'stock';
226
- }
227
- throw new FurnaceError(`Component "${name}" not found in furnace.json.`, name);
147
+ return applyAllComponents(projectRoot, isDryRun, {
148
+ componentName: name,
149
+ persistState: false,
150
+ ...(operationContext ? { operationContext } : {}),
151
+ });
228
152
  }
229
153
  /**
230
154
  * Prints the deploy summary after apply and validation complete.
@@ -293,7 +217,7 @@ export async function furnaceDeployCommand(projectRoot, name, options = {}) {
293
217
  throw new FurnaceError('No furnace.json found. Run "fireforge furnace create" or "fireforge furnace override" to get started.');
294
218
  }
295
219
  const config = await loadFurnaceConfig(projectRoot);
296
- const [furnacePaths, ftlDir] = [getFurnacePaths(projectRoot), resolveFtlDir(config.ftlBasePath)];
220
+ const furnacePaths = getFurnacePaths(projectRoot);
297
221
  const overrideCount = Object.keys(config.overrides).length;
298
222
  const customCount = Object.keys(config.custom).length;
299
223
  if (overrideCount === 0 && customCount === 0) {
@@ -306,14 +230,6 @@ export async function furnaceDeployCommand(projectRoot, name, options = {}) {
306
230
  // the plan before deciding whether to refresh the override or acknowledge
307
231
  // the new baseline in furnace.json.
308
232
  const forgeConfig = await loadConfig(projectRoot);
309
- // 2026-04-26 eval Finding 6: when `markerComment` is unset in
310
- // fireforge.json, default it to `binaryName.toUpperCase()` so the
311
- // furnace-emitted edits to upstream files satisfy
312
- // `lintModificationComments` on the next `lint`/`export` round-trip.
313
- // The lint rule keys on the same uppercased binaryName, so the
314
- // implicit default is identical to what the rule expects. Threaded
315
- // through `applyNamedComponent` below.
316
- const resolvedMarkerComment = resolveFurnaceMarkerComment(forgeConfig);
317
233
  const driftEntries = findOverrideBaseVersionDrift(config, forgeConfig.firefox.version);
318
234
  const force = options.force ?? false;
319
235
  const scopedDrift = name ? driftEntries.filter((entry) => entry.name === name) : driftEntries;
@@ -326,7 +242,7 @@ export async function furnaceDeployCommand(projectRoot, name, options = {}) {
326
242
  // `furnace deploy` runs only contend on the actual mutation.
327
243
  const applyOutcome = await runFurnaceMutation(projectRoot, 'deploy-rollback', async (ctx) => {
328
244
  if (name) {
329
- const namedApplyResult = await applyNamedComponent(name, paths.engine, furnacePaths, config, ftlDir, isDryRun, ctx, projectRoot, resolvedMarkerComment);
245
+ const namedApplyResult = await applyNamedComponent(name, config, isDryRun, projectRoot, ctx);
330
246
  if (namedApplyResult === 'stock') {
331
247
  return { kind: 'stock' };
332
248
  }
@@ -354,6 +270,12 @@ export async function furnaceDeployCommand(projectRoot, name, options = {}) {
354
270
  const result = applyOutcome.result;
355
271
  applySpinner.stop(isDryRun ? 'Planned actions calculated' : 'Components applied');
356
272
  logApplyResult(result, isDryRun);
273
+ // Keep the consumer jsconfig's chrome-module `paths` in step with the
274
+ // deployed module set (field report D3). Only after a clean apply —
275
+ // a rolled-back deploy must not advance the typecheck mapping either.
276
+ if (result.errors.length === 0 && getStepFailureCount(result) === 0) {
277
+ await reportJsconfigPathsSync(projectRoot, config, isDryRun);
278
+ }
357
279
  // --- Step 2: Validate (read-only, runs even in dry-run to show what would fail) ---
358
280
  if (options.skipValidate) {
359
281
  const applyErrors = result.errors.length + getStepFailureCount(result);
@@ -158,6 +158,54 @@ async function refreshSingleOverride(projectRoot, name, options = {}) {
158
158
  }, { dryRun });
159
159
  return { results, currentVersion };
160
160
  }
161
+ /**
162
+ * Refreshes every override sequentially, tallying merged/conflict/
163
+ * unchanged file counts. Per-override errors are expected (warned and
164
+ * recorded as failures) and do not abort the batch; only an error that
165
+ * escapes this function entirely warrants the caller's journal rollback.
166
+ */
167
+ async function runBatchRefresh(projectRoot, overrideNames, options) {
168
+ let totalMerged = 0;
169
+ let totalConflicts = 0;
170
+ let totalUnchanged = 0;
171
+ let totalSkipped = 0;
172
+ const conflictComponents = [];
173
+ const failedOverrides = [];
174
+ for (const overrideName of overrideNames) {
175
+ try {
176
+ const { results } = await refreshSingleOverride(projectRoot, overrideName, options);
177
+ if (results.length === 0) {
178
+ totalSkipped++;
179
+ continue;
180
+ }
181
+ for (const r of results) {
182
+ if (r.status === 'merged')
183
+ totalMerged++;
184
+ else if (r.status === 'conflict') {
185
+ totalConflicts++;
186
+ if (!conflictComponents.includes(overrideName)) {
187
+ conflictComponents.push(overrideName);
188
+ }
189
+ }
190
+ else if (r.status === 'unchanged')
191
+ totalUnchanged++;
192
+ }
193
+ }
194
+ catch (error) {
195
+ const message = toError(error).message;
196
+ warn(`${overrideName}: ${message}`);
197
+ failedOverrides.push({ name: overrideName, message });
198
+ }
199
+ }
200
+ return {
201
+ totalMerged,
202
+ totalConflicts,
203
+ totalUnchanged,
204
+ totalSkipped,
205
+ conflictComponents,
206
+ failedOverrides,
207
+ };
208
+ }
161
209
  /**
162
210
  * Runs the furnace refresh command to merge upstream Firefox changes into
163
211
  * an override component using three-way merge.
@@ -208,12 +256,6 @@ export async function furnaceRefreshCommand(projectRoot, name, options = {}) {
208
256
  outro('Done');
209
257
  return;
210
258
  }
211
- let totalMerged = 0;
212
- let totalConflicts = 0;
213
- let totalUnchanged = 0;
214
- let totalSkipped = 0;
215
- const conflictComponents = [];
216
- const failedOverrides = [];
217
259
  // Snapshot furnace.json before the batch loop so an unexpected failure
218
260
  // (process crash, unhandled error) can be recovered from. Per-component
219
261
  // errors caught below are expected and do not trigger a restore — only
@@ -223,33 +265,9 @@ export async function furnaceRefreshCommand(projectRoot, name, options = {}) {
223
265
  const furnacePaths = getFurnacePaths(projectRoot);
224
266
  await snapshotFile(batchJournal, furnacePaths.furnaceConfig);
225
267
  }
268
+ let tally;
226
269
  try {
227
- for (const overrideName of overrideNames) {
228
- try {
229
- const { results } = await refreshSingleOverride(projectRoot, overrideName, options);
230
- if (results.length === 0) {
231
- totalSkipped++;
232
- continue;
233
- }
234
- for (const r of results) {
235
- if (r.status === 'merged')
236
- totalMerged++;
237
- else if (r.status === 'conflict') {
238
- totalConflicts++;
239
- if (!conflictComponents.includes(overrideName)) {
240
- conflictComponents.push(overrideName);
241
- }
242
- }
243
- else if (r.status === 'unchanged')
244
- totalUnchanged++;
245
- }
246
- }
247
- catch (error) {
248
- const message = toError(error).message;
249
- warn(`${overrideName}: ${message}`);
250
- failedOverrides.push({ name: overrideName, message });
251
- }
252
- }
270
+ tally = await runBatchRefresh(projectRoot, overrideNames, options);
253
271
  }
254
272
  catch (error) {
255
273
  // Unexpected batch-level failure: restore furnace.json to its
@@ -259,6 +277,8 @@ export async function furnaceRefreshCommand(projectRoot, name, options = {}) {
259
277
  }
260
278
  throw error;
261
279
  }
280
+ const { totalMerged, totalConflicts, totalUnchanged, totalSkipped } = tally;
281
+ const { conflictComponents, failedOverrides } = tally;
262
282
  const summary = `${overrideNames.length} override(s) processed, ${totalSkipped} already up-to-date\n` +
263
283
  `${totalMerged} file(s) merged, ${totalUnchanged} unchanged, ${totalConflicts} conflict(s), ` +
264
284
  `${failedOverrides.length} failed`;
@@ -3,6 +3,7 @@ import { getProjectPaths, loadConfig } from '../../core/config.js';
3
3
  import { applyAllComponents } from '../../core/furnace-apply.js';
4
4
  import { logApplyResult } from '../../core/furnace-apply-output.js';
5
5
  import { furnaceConfigExists, loadFurnaceConfig } from '../../core/furnace-config.js';
6
+ import { reportJsconfigPathsSync } from '../../core/furnace-jsconfig.js';
6
7
  import { runFurnaceMutation } from '../../core/furnace-operation.js';
7
8
  import { findOverrideBaseVersionDrift, formatOverrideBaseVersionDriftWarning, } from '../../core/furnace-version-drift.js';
8
9
  import { FurnaceError } from '../../errors/furnace.js';
@@ -68,6 +69,7 @@ export async function furnaceSyncCommand(projectRoot, options = {}) {
68
69
  if (totalFailures > 0) {
69
70
  throw new FurnaceError(`${totalFailures} component${totalFailures === 1 ? '' : 's'} failed to apply cleanly`);
70
71
  }
72
+ await reportJsconfigPathsSync(projectRoot, config, false);
71
73
  outro(`Sync complete — ${result.applied.length} applied, ${result.skipped.length} skipped`);
72
74
  }
73
75
  else {
@@ -200,6 +200,108 @@ function buildUntilFilenameSet(patches, until) {
200
200
  }
201
201
  return set;
202
202
  }
203
+ /**
204
+ * Runs the manifest consistency check, scoped to the `--until` subset:
205
+ * global (manifest-level) issues always block, per-patch issues only
206
+ * block when the patch is in scope. Throws GeneralError with the repair
207
+ * hint when anything in scope is broken.
208
+ */
209
+ async function assertScopedManifestConsistency(patchesDir, untilFilenameSet, until) {
210
+ const manifestConsistencyIssues = await validatePatchesManifestConsistency(patchesDir);
211
+ const scopedManifestIssues = until !== undefined
212
+ ? manifestConsistencyIssues.filter((issue) =>
213
+ // Global (manifest-level) issues have no specific filename to scope
214
+ // against — a missing or unparseable patches.json blocks any
215
+ // import. Per-patch issues only block when the patch is in scope.
216
+ issue.code === 'manifest-missing' ||
217
+ issue.code === 'manifest-invalid' ||
218
+ untilFilenameSet.has(issue.filename))
219
+ : manifestConsistencyIssues;
220
+ if (scopedManifestIssues.length > 0) {
221
+ const issueSummary = scopedManifestIssues.map((issue) => issue.message).join('\n ');
222
+ throw new GeneralError('Patch manifest consistency check failed. Repair patches/patches.json before importing.\n' +
223
+ ` ${issueSummary}\n\n` +
224
+ 'Run "fireforge doctor --repair-patches-manifest" to rebuild the manifest from on-disk patch files.');
225
+ }
226
+ }
227
+ /**
228
+ * Prints advisory version-compatibility warnings for every in-scope patch
229
+ * whose recorded source version differs meaningfully from the configured
230
+ * Firefox version. Advisory only — never blocks the import.
231
+ */
232
+ async function warnVersionCompatibility(projectRoot, manifest, untilFilenameSet, until) {
233
+ if (!manifest)
234
+ return;
235
+ const config = await loadConfig(projectRoot);
236
+ const currentVersion = config.firefox.version;
237
+ for (const patch of manifest.patches) {
238
+ // Scope the advisory warnings too: an operator running with --until
239
+ // doesn't need to see version warnings for patches outside the range.
240
+ if (until !== undefined && !untilFilenameSet.has(patch.filename))
241
+ continue;
242
+ const warning = checkVersionCompatibility(getPatchSourceVersion(patch), currentVersion);
243
+ if (warning) {
244
+ warn(`${patch.filename}: ${warning}`);
245
+ }
246
+ }
247
+ }
248
+ /**
249
+ * Patch-integrity gate: surfaces orphaned-modification issues scoped to
250
+ * the `--until` range and decides whether the import may proceed —
251
+ * `--force` continues with a warning, non-TTY refuses loudly, and an
252
+ * interactive operator is prompted. Returns false when the import should
253
+ * stop (the cancel outro has been printed).
254
+ */
255
+ async function gateImportIntegrity(paths, untilFilenameSet, until, forceImport) {
256
+ const allIntegrityIssues = await validatePatchIntegrity(paths.patches, paths.engine);
257
+ const integrityIssues = until !== undefined
258
+ ? allIntegrityIssues.filter((issue) => untilFilenameSet.has(issue.filename))
259
+ : allIntegrityIssues;
260
+ if (integrityIssues.length > 0) {
261
+ warn('\nPatch integrity issues detected:');
262
+ for (const issue of integrityIssues) {
263
+ warn(` ${issue.filename}: ${issue.message}`);
264
+ }
265
+ info('Run "fireforge doctor" for more details.');
266
+ if (forceImport) {
267
+ warn('Continuing because --force was provided. Integrity issues were not resolved.\n');
268
+ }
269
+ else if (!process.stdin.isTTY) {
270
+ throw new GeneralError(`Refusing to import while ${integrityIssues.length} patch integrity issue(s) are unresolved. ` +
271
+ `Fix the issues reported above (see "fireforge doctor") or re-run with --force to continue anyway.`);
272
+ }
273
+ else {
274
+ const shouldContinue = await confirm({
275
+ message: 'Patch integrity issues detected. Continuing may fail with cascading errors during patch application. Continue anyway?',
276
+ initialValue: false,
277
+ });
278
+ if (isCancel(shouldContinue) || !shouldContinue) {
279
+ outro('Import cancelled — fix the integrity issues and re-run');
280
+ return false;
281
+ }
282
+ }
283
+ }
284
+ return true;
285
+ }
286
+ /**
287
+ * Dry-run rendering: lists the in-scope patches (or the bare count when no
288
+ * manifest exists) and prints the dry-run outro.
289
+ */
290
+ function renderImportDryRun(manifest, untilFilenameSet, until, patchCount) {
291
+ if (manifest) {
292
+ const patches = until !== undefined
293
+ ? manifest.patches.filter((p) => untilFilenameSet.has(p.filename))
294
+ : manifest.patches;
295
+ info(`\n[dry-run] Would apply ${patches.length} patch(es) in order:`);
296
+ for (const patch of patches) {
297
+ info(` ${patch.filename} (${patch.filesAffected.length} file${patch.filesAffected.length === 1 ? '' : 's'})`);
298
+ }
299
+ }
300
+ else {
301
+ info(`\n[dry-run] Would apply ${patchCount} patch(es)`);
302
+ }
303
+ outro('Dry run complete — no changes made');
304
+ }
203
305
  /**
204
306
  * Runs the import command to apply patches.
205
307
  * @param projectRoot - Root directory of the project
@@ -245,37 +347,8 @@ export async function importCommand(projectRoot, options = {}) {
245
347
  const untilFilenameSet = buildUntilFilenameSet(manifest?.patches ?? [], options.until);
246
348
  const scopedPatchCount = options.until !== undefined ? untilFilenameSet.size : patchCount;
247
349
  info(`Found ${scopedPatchCount} patch${scopedPatchCount === 1 ? '' : 'es'} to apply${options.until !== undefined ? ` (up to ${options.until})` : ''}`);
248
- const manifestConsistencyIssues = await validatePatchesManifestConsistency(paths.patches);
249
- const scopedManifestIssues = options.until !== undefined
250
- ? manifestConsistencyIssues.filter((issue) =>
251
- // Global (manifest-level) issues have no specific filename to scope
252
- // against — a missing or unparseable patches.json blocks any
253
- // import. Per-patch issues only block when the patch is in scope.
254
- issue.code === 'manifest-missing' ||
255
- issue.code === 'manifest-invalid' ||
256
- untilFilenameSet.has(issue.filename))
257
- : manifestConsistencyIssues;
258
- if (scopedManifestIssues.length > 0) {
259
- const issueSummary = scopedManifestIssues.map((issue) => issue.message).join('\n ');
260
- throw new GeneralError('Patch manifest consistency check failed. Repair patches/patches.json before importing.\n' +
261
- ` ${issueSummary}\n\n` +
262
- 'Run "fireforge doctor --repair-patches-manifest" to rebuild the manifest from on-disk patch files.');
263
- }
264
- // Version compatibility warnings (advisory only)
265
- if (manifest) {
266
- const config = await loadConfig(projectRoot);
267
- const currentVersion = config.firefox.version;
268
- for (const patch of manifest.patches) {
269
- // Scope the advisory warnings too: an operator running with --until
270
- // doesn't need to see version warnings for patches outside the range.
271
- if (options.until !== undefined && !untilFilenameSet.has(patch.filename))
272
- continue;
273
- const warning = checkVersionCompatibility(getPatchSourceVersion(patch), currentVersion);
274
- if (warning) {
275
- warn(`${patch.filename}: ${warning}`);
276
- }
277
- }
278
- }
350
+ await assertScopedManifestConsistency(paths.patches, untilFilenameSet, options.until);
351
+ await warnVersionCompatibility(projectRoot, manifest, untilFilenameSet, options.until);
279
352
  // Validate patch integrity (detect orphaned modification patches). Warn
280
353
  // and prompt the operator to confirm before proceeding — the legacy
281
354
  // warn-and-continue behaviour hid the real root cause because import
@@ -286,49 +359,11 @@ export async function importCommand(projectRoot, options = {}) {
286
359
  // integrity problems should not block importing an earlier good subset,
287
360
  // which is exactly what operators reach for when the tail of the queue
288
361
  // is broken and they want to keep working against an earlier checkpoint.
289
- const allIntegrityIssues = await validatePatchIntegrity(paths.patches, paths.engine);
290
- const integrityIssues = options.until !== undefined
291
- ? allIntegrityIssues.filter((issue) => untilFilenameSet.has(issue.filename))
292
- : allIntegrityIssues;
293
- if (integrityIssues.length > 0) {
294
- warn('\nPatch integrity issues detected:');
295
- for (const issue of integrityIssues) {
296
- warn(` ${issue.filename}: ${issue.message}`);
297
- }
298
- info('Run "fireforge doctor" for more details.');
299
- if (forceImport) {
300
- warn('Continuing because --force was provided. Integrity issues were not resolved.\n');
301
- }
302
- else if (!process.stdin.isTTY) {
303
- throw new GeneralError(`Refusing to import while ${integrityIssues.length} patch integrity issue(s) are unresolved. ` +
304
- `Fix the issues reported above (see "fireforge doctor") or re-run with --force to continue anyway.`);
305
- }
306
- else {
307
- const shouldContinue = await confirm({
308
- message: 'Patch integrity issues detected. Continuing may fail with cascading errors during patch application. Continue anyway?',
309
- initialValue: false,
310
- });
311
- if (isCancel(shouldContinue) || !shouldContinue) {
312
- outro('Import cancelled — fix the integrity issues and re-run');
313
- return;
314
- }
315
- }
316
- }
317
- // Dry-run: list patches that would be applied and exit
362
+ const integrityOk = await gateImportIntegrity(paths, untilFilenameSet, options.until, forceImport);
363
+ if (!integrityOk)
364
+ return;
318
365
  if (isDryRun) {
319
- if (manifest) {
320
- const patches = options.until !== undefined
321
- ? manifest.patches.filter((p) => untilFilenameSet.has(p.filename))
322
- : manifest.patches;
323
- info(`\n[dry-run] Would apply ${patches.length} patch(es) in order:`);
324
- for (const patch of patches) {
325
- info(` ${patch.filename} (${patch.filesAffected.length} file${patch.filesAffected.length === 1 ? '' : 's'})`);
326
- }
327
- }
328
- else {
329
- info(`\n[dry-run] Would apply ${patchCount} patch(es)`);
330
- }
331
- outro('Dry run complete — no changes made');
366
+ renderImportDryRun(manifest, untilFilenameSet, options.until, patchCount);
332
367
  return;
333
368
  }
334
369
  await checkUncommittedPatchFiles(paths.engine, paths.patches, forceImport);
@@ -1,5 +1,5 @@
1
1
  import { getProjectPaths } from '../core/config.js';
2
- import type { LintCommandOptions } from './lint.js';
2
+ import type { LintCommandOptions } from '../types/commands/index.js';
3
3
  /**
4
4
  * Lints each patch in the queue as its own isolated diff, honouring
5
5
  * per-patch `lintIgnore` entries. Cross-patch rules still run once over