@hominis/fireforge 0.30.1 → 0.32.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 (152) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +22 -0
  3. package/dist/src/commands/export-all.js +9 -16
  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 +46 -1
  10. package/dist/src/commands/export.js +52 -113
  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 +3 -1
  20. package/dist/src/commands/lint-per-patch.js +265 -74
  21. package/dist/src/commands/lint.d.ts +1 -58
  22. package/dist/src/commands/lint.js +193 -88
  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-files.js +4 -1
  42. package/dist/src/commands/re-export-scan.js +8 -1
  43. package/dist/src/commands/re-export.js +8 -1
  44. package/dist/src/commands/rebase/summary.d.ts +1 -5
  45. package/dist/src/commands/rebase/summary.js +1 -1
  46. package/dist/src/commands/status-output.js +77 -68
  47. package/dist/src/commands/test-diagnose.d.ts +23 -0
  48. package/dist/src/commands/test-diagnose.js +210 -0
  49. package/dist/src/commands/test-run.d.ts +68 -0
  50. package/dist/src/commands/test-run.js +97 -0
  51. package/dist/src/commands/test.js +214 -263
  52. package/dist/src/commands/token.js +15 -1
  53. package/dist/src/commands/wire.js +109 -78
  54. package/dist/src/core/build-audit.d.ts +1 -1
  55. package/dist/src/core/build-audit.js +2 -46
  56. package/dist/src/core/build-baseline-types.d.ts +38 -0
  57. package/dist/src/core/build-baseline-types.js +10 -0
  58. package/dist/src/core/build-baseline.d.ts +1 -31
  59. package/dist/src/core/build-prepare.d.ts +1 -1
  60. package/dist/src/core/build-prepare.js +2 -45
  61. package/dist/src/core/config-paths.d.ts +0 -8
  62. package/dist/src/core/config-paths.js +4 -4
  63. package/dist/src/core/config-state.d.ts +0 -6
  64. package/dist/src/core/config-state.js +1 -1
  65. package/dist/src/core/config-validate-patch-policy.js +12 -13
  66. package/dist/src/core/config-validate.js +74 -28
  67. package/dist/src/core/engine-changes.d.ts +24 -0
  68. package/dist/src/core/engine-changes.js +64 -0
  69. package/dist/src/core/firefox-cache.d.ts +0 -5
  70. package/dist/src/core/firefox-cache.js +1 -1
  71. package/dist/src/core/firefox-download.d.ts +0 -6
  72. package/dist/src/core/firefox-download.js +1 -1
  73. package/dist/src/core/furnace-apply-helpers.d.ts +1 -8
  74. package/dist/src/core/furnace-apply-helpers.js +11 -20
  75. package/dist/src/core/furnace-apply.d.ts +1 -1
  76. package/dist/src/core/furnace-apply.js +1 -1
  77. package/dist/src/core/furnace-checksum-utils.d.ts +7 -0
  78. package/dist/src/core/furnace-checksum-utils.js +15 -0
  79. package/dist/src/core/furnace-config-validate.d.ts +31 -0
  80. package/dist/src/core/furnace-config-validate.js +133 -0
  81. package/dist/src/core/furnace-config.d.ts +4 -32
  82. package/dist/src/core/furnace-config.js +15 -111
  83. package/dist/src/core/furnace-constants.d.ts +0 -10
  84. package/dist/src/core/furnace-constants.js +2 -2
  85. package/dist/src/core/furnace-css-fragments.d.ts +79 -0
  86. package/dist/src/core/furnace-css-fragments.js +243 -0
  87. package/dist/src/core/furnace-jsconfig.d.ts +63 -0
  88. package/dist/src/core/furnace-jsconfig.js +191 -0
  89. package/dist/src/core/furnace-validate-helpers.d.ts +16 -14
  90. package/dist/src/core/furnace-validate-helpers.js +40 -1
  91. package/dist/src/core/furnace-validate-registration.js +16 -1
  92. package/dist/src/core/furnace-validate.js +54 -2
  93. package/dist/src/core/git-base.d.ts +15 -0
  94. package/dist/src/core/git-base.js +32 -0
  95. package/dist/src/core/git-diff.d.ts +8 -0
  96. package/dist/src/core/git-diff.js +224 -59
  97. package/dist/src/core/git-file-ops.d.ts +39 -12
  98. package/dist/src/core/git-file-ops.js +84 -3
  99. package/dist/src/core/lint-cache.d.ts +0 -13
  100. package/dist/src/core/lint-cache.js +5 -5
  101. package/dist/src/core/mach.d.ts +22 -1
  102. package/dist/src/core/mach.js +27 -2
  103. package/dist/src/core/manifest-register.d.ts +5 -16
  104. package/dist/src/core/manifest-register.js +3 -1
  105. package/dist/src/core/patch-lint-checkjs.d.ts +75 -21
  106. package/dist/src/core/patch-lint-checkjs.js +263 -71
  107. package/dist/src/core/patch-lint-css.d.ts +23 -0
  108. package/dist/src/core/patch-lint-css.js +172 -0
  109. package/dist/src/core/patch-lint-jsdoc.js +63 -4
  110. package/dist/src/core/patch-lint-observer.d.ts +37 -0
  111. package/dist/src/core/patch-lint-observer.js +168 -0
  112. package/dist/src/core/patch-lint.d.ts +34 -11
  113. package/dist/src/core/patch-lint.js +24 -161
  114. package/dist/src/core/patch-manifest-io.d.ts +16 -0
  115. package/dist/src/core/patch-manifest-io.js +44 -2
  116. package/dist/src/core/patch-manifest-validate.d.ts +1 -8
  117. package/dist/src/core/patch-manifest-validate.js +1 -1
  118. package/dist/src/core/patch-manifest.d.ts +1 -1
  119. package/dist/src/core/patch-manifest.js +1 -1
  120. package/dist/src/core/patch-policy.d.ts +0 -4
  121. package/dist/src/core/patch-policy.js +10 -4
  122. package/dist/src/core/register-browser-content.d.ts +1 -1
  123. package/dist/src/core/register-module.d.ts +1 -1
  124. package/dist/src/core/register-result.d.ts +21 -0
  125. package/dist/src/core/register-result.js +9 -0
  126. package/dist/src/core/register-shared-css.d.ts +1 -1
  127. package/dist/src/core/register-test-manifest.d.ts +1 -1
  128. package/dist/src/core/test-harness-crash.d.ts +61 -0
  129. package/dist/src/core/test-harness-crash.js +140 -0
  130. package/dist/src/core/test-stale-check.d.ts +1 -1
  131. package/dist/src/core/test-stale-check.js +2 -46
  132. package/dist/src/core/test-xpcshell-retry.d.ts +9 -2
  133. package/dist/src/core/test-xpcshell-retry.js +10 -3
  134. package/dist/src/core/token-dark-mode.js +14 -26
  135. package/dist/src/core/token-manager.d.ts +4 -0
  136. package/dist/src/core/token-manager.js +70 -16
  137. package/dist/src/core/typecheck-shim.d.ts +3 -22
  138. package/dist/src/core/typecheck-shim.js +69 -7
  139. package/dist/src/core/wire-utils.js +37 -44
  140. package/dist/src/types/commands/index.d.ts +1 -1
  141. package/dist/src/types/commands/options.d.ts +122 -0
  142. package/dist/src/types/config.d.ts +11 -2
  143. package/dist/src/types/furnace.d.ts +12 -1
  144. package/dist/src/utils/elapsed.d.ts +0 -2
  145. package/dist/src/utils/elapsed.js +1 -1
  146. package/dist/src/utils/fs.d.ts +0 -5
  147. package/dist/src/utils/fs.js +1 -1
  148. package/dist/src/utils/regex.d.ts +0 -6
  149. package/dist/src/utils/regex.js +3 -3
  150. package/dist/src/utils/validation.d.ts +0 -8
  151. package/dist/src/utils/validation.js +2 -2
  152. package/package.json +6 -4
@@ -3,16 +3,15 @@ import { stat } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { Option } from 'commander';
5
5
  import { getProjectPaths, loadConfig } from '../core/config.js';
6
- import { appendHistory, confirmDestructive } from '../core/destructive.js';
6
+ import { appendHistory } from '../core/destructive.js';
7
7
  import { collectFurnaceManagedPrefixes } from '../core/furnace-config.js';
8
8
  import { getStatusWithCodes, isGitRepository } from '../core/git.js';
9
9
  import { generateBinaryFilePatch, generateFullFilePatch } from '../core/git-diff.js';
10
10
  import { isBinaryFile } from '../core/git-file-ops.js';
11
11
  import { getModifiedFilesInDir, getUntrackedFiles, getUntrackedFilesInDir, } from '../core/git-status.js';
12
12
  import { extractAffectedFiles } from '../core/patch-apply.js';
13
- import { commitExportedPatch, findAllPatchesForFiles } from '../core/patch-export.js';
14
- import { loadPatchesManifest } from '../core/patch-manifest.js';
15
- import { applyRenameMapToManifest, buildProjectedManifest, enforcePatchPolicy, } from '../core/patch-policy.js';
13
+ import { commitExportedPatch } from '../core/patch-export.js';
14
+ import { buildPatchQueueContext } from '../core/patch-lint.js';
16
15
  import { buildPatchSourceMetadata } from '../core/patch-source-metadata.js';
17
16
  import { GeneralError, InvalidArgumentError } from '../errors/base.js';
18
17
  import { toError } from '../utils/errors.js';
@@ -21,9 +20,9 @@ import { info, intro, outro, spinner, verbose, warn } from '../utils/logger.js';
21
20
  import { pickDefined } from '../utils/options.js';
22
21
  import { stripEnginePrefix } from '../utils/paths.js';
23
22
  import { parsePositiveIntegerFlag } from '../utils/validation.js';
24
- import { commitPlacementExport, placementSummary, projectPlacementForLint, renderDryRunPreview, resolvePlacementPlan, } from './export-flow.js';
25
- import { assertPlacementPreservesReservedRanges } from './export-placement-policy.js';
26
- import { autoFixLicenseHeaders, confirmSupersedePatches, guardOwnershipOverlap, promptExportPatchMetadata, runPatchLint, } from './export-shared.js';
23
+ import { commitPlacementExport, renderDryRunPreview } from './export-flow.js';
24
+ import { gatePlacementPlan, patchMetadataExtras } from './export-placement-gate.js';
25
+ import { autoFixLicenseHeaders, promptExportPatchMetadata, runPatchLint, runSupersedeAndOverlapGates, } from './export-shared.js';
27
26
  async function collectExportFiles(paths, files) {
28
27
  const collectedFiles = new Set();
29
28
  let fileStatuses;
@@ -100,20 +99,14 @@ async function generatePatchDiff(engineDir, allFiles) {
100
99
  return diffs.join('\n');
101
100
  }
102
101
  /**
103
- * Runs the export command to export file changes as a patch.
104
- * Accepts one or more file/directory paths and bundles them into a single patch.
105
- * @param projectRoot - Root directory of the project
106
- * @param files - File or directory paths to export (relative to engine/)
107
- * @param options - Export options
102
+ * Validation + diff phase of `exportCommand`: checks flag combinations and
103
+ * the engine checkout, collects the export file set (honouring
104
+ * `--exclude-furnace`), generates the diff, auto-fixes license headers,
105
+ * and prompts for patch metadata. Returns `null` when the operator
106
+ * cancelled the metadata prompt (the command ends silently, matching the
107
+ * prompt's own cancel handling).
108
108
  */
109
- // The command body is intentionally linear: validation → diff → placement
110
- // gate → dry-run/placement/default write. Splitting it further would
111
- // spread the error-handling (spinner.error, try/catch) across multiple
112
- // helpers and hurt readability more than it would help.
113
- // eslint-disable-next-line max-lines-per-function
114
- export async function exportCommand(projectRoot, files, options) {
115
- const isDryRun = options.dryRun === true;
116
- intro(isDryRun ? 'FireForge Export (dry run)' : 'FireForge Export');
109
+ async function prepareExport(projectRoot, files, options) {
117
110
  // Placement flags are mutually exclusive with each other.
118
111
  const placementFlagCount = [
119
112
  options.order !== undefined,
@@ -170,7 +163,23 @@ export async function exportCommand(projectRoot, files, options) {
170
163
  }
171
164
  const metadata = await promptExportPatchMetadata(options, isInteractive, 'export', config);
172
165
  if (!metadata)
166
+ return null;
167
+ return { paths, placementFlagCount, diff, config, isInteractive, metadata };
168
+ }
169
+ /**
170
+ * Runs the export command to export file changes as a patch.
171
+ * Accepts one or more file/directory paths and bundles them into a single patch.
172
+ * @param projectRoot - Root directory of the project
173
+ * @param files - File or directory paths to export (relative to engine/)
174
+ * @param options - Export options
175
+ */
176
+ export async function exportCommand(projectRoot, files, options) {
177
+ const isDryRun = options.dryRun === true;
178
+ intro(isDryRun ? 'FireForge Export (dry run)' : 'FireForge Export');
179
+ const prepared = await prepareExport(projectRoot, files, options);
180
+ if (!prepared)
173
181
  return;
182
+ const { paths, placementFlagCount, diff, config, isInteractive, metadata } = prepared;
174
183
  const { patchName, selectedCategory, description } = metadata;
175
184
  const s = spinner(isDryRun ? 'Planning export...' : 'Exporting patch...');
176
185
  try {
@@ -184,76 +193,29 @@ export async function exportCommand(projectRoot, files, options) {
184
193
  const exportIgnoreChecks = options.lintIgnore && options.lintIgnore.length > 0
185
194
  ? new Set(options.lintIgnore)
186
195
  : undefined;
187
- await runPatchLint(paths.engine, filesAffected, diff, config, options.skipLint, undefined, exportIgnoreChecks, options.tier);
196
+ const patchQueueCtx = (await pathExists(paths.patches))
197
+ ? await buildPatchQueueContext(paths.patches)
198
+ : undefined;
199
+ await runPatchLint(paths.engine, filesAffected, diff, config, options.skipLint, patchQueueCtx, exportIgnoreChecks, options.tier);
188
200
  // Resolve placement (if any flag was given). Placement is mutually
189
201
  // exclusive with supersede — the semantics overlap confusingly.
190
202
  let placementPlan = null;
191
203
  if (placementFlagCount > 0) {
192
- if (options.supersede) {
193
- throw new InvalidArgumentError('Placement flags (--order/--before/--after) cannot be combined with --supersede.', 'export placement');
194
- }
195
- placementPlan = await resolvePlacementPlan(paths.patches, options, selectedCategory, patchName);
196
- const currentManifest = await loadPatchesManifest(paths.patches);
197
- if (currentManifest !== null) {
198
- assertPlacementPreservesReservedRanges(placementPlan, currentManifest.patches, config, selectedCategory);
199
- }
200
- const conflicts = await projectPlacementForLint(paths.patches, placementPlan, diff);
201
- const renamed = currentManifest !== null
202
- ? applyRenameMapToManifest(currentManifest, placementPlan.renameMap)
203
- : buildProjectedManifest(null, []);
204
- enforcePatchPolicy({
204
+ const gated = await gatePlacementPlan({
205
+ patchesDir: paths.patches,
206
+ options,
207
+ selectedCategory,
208
+ patchName,
209
+ description,
210
+ filesAffected,
211
+ diff,
205
212
  config,
206
- manifest: buildProjectedManifest(renamed, [
207
- ...renamed.patches,
208
- {
209
- filename: placementPlan.newFilename,
210
- order: placementPlan.insertionOrder,
211
- category: selectedCategory,
212
- name: patchName,
213
- description,
214
- createdAt: new Date().toISOString(),
215
- ...buildPatchSourceMetadata(config.firefox),
216
- filesAffected,
217
- ...(options.tier !== undefined ? { tier: options.tier } : {}),
218
- ...(options.lintIgnore !== undefined && options.lintIgnore.length > 0
219
- ? { lintIgnore: options.lintIgnore }
220
- : {}),
221
- },
222
- ]),
223
- command: 'export',
224
- forceUnsafe: options.forceUnsafe === true,
213
+ isDryRun,
214
+ s,
225
215
  });
226
- const summary = placementSummary(placementPlan);
227
- const renameCount = placementPlan.renameMap.size;
228
- // Route through confirmDestructive when the operation is destructive
229
- // enough to warrant a prompt (more than one rename) OR when the user
230
- // asked for a dry-run. The dry-run branch must always print the
231
- // placement summary — previously, single-rename/no-rename dry-runs
232
- // exited silently with no filename or projected layout.
233
- if (renameCount > 1 || isDryRun) {
234
- s.stop();
235
- const decision = await confirmDestructive({
236
- operation: 'export-order',
237
- title: `Export with placement at order ${placementPlan.insertionOrder}`,
238
- summary,
239
- yes: options.yes === true,
240
- dryRun: isDryRun,
241
- unsafeOverride: options.forceUnsafe === true,
242
- conflicts,
243
- });
244
- if (decision === 'dry-run') {
245
- outro('Dry run complete — no changes made');
246
- return;
247
- }
248
- if (decision === 'cancelled') {
249
- outro('Export cancelled');
250
- return;
251
- }
252
- }
253
- else if (conflicts && options.forceUnsafe !== true) {
254
- s.stop();
255
- throw new InvalidArgumentError(`Refusing to run export: ${conflicts.reason}. Pass --force-unsafe to override.`, '--force-unsafe');
256
- }
216
+ if (gated === 'stop')
217
+ return;
218
+ placementPlan = gated;
257
219
  }
258
220
  // Dry-run path: compute the plan and print it, never write.
259
221
  if (isDryRun && !placementPlan) {
@@ -267,10 +229,7 @@ export async function exportCommand(projectRoot, files, options) {
267
229
  ...buildPatchSourceMetadata(config.firefox),
268
230
  explicitSupersede: options.supersede === true,
269
231
  allowOverlap: options.allowOverlap === true,
270
- ...(options.tier !== undefined ? { tier: options.tier } : {}),
271
- ...(options.lintIgnore !== undefined && options.lintIgnore.length > 0
272
- ? { lintIgnore: options.lintIgnore }
273
- : {}),
232
+ ...patchMetadataExtras(options),
274
233
  config,
275
234
  forceUnsafe: options.forceUnsafe === true,
276
235
  });
@@ -291,10 +250,7 @@ export async function exportCommand(projectRoot, files, options) {
291
250
  createdAt: new Date().toISOString(),
292
251
  ...buildPatchSourceMetadata(config.firefox),
293
252
  filesAffected,
294
- ...(options.tier !== undefined ? { tier: options.tier } : {}),
295
- ...(options.lintIgnore !== undefined && options.lintIgnore.length > 0
296
- ? { lintIgnore: options.lintIgnore }
297
- : {}),
253
+ ...patchMetadataExtras(options),
298
254
  };
299
255
  const committedPlan = await commitPlacementExport({
300
256
  patchesDir: paths.patches,
@@ -336,29 +292,15 @@ export async function exportCommand(projectRoot, files, options) {
336
292
  return;
337
293
  }
338
294
  // Default (no dry-run, no placement) path: the pre-existing behavior.
339
- // Check how many existing patches would be superseded
340
- const shouldProceed = await confirmSupersedePatches(paths.patches, filesAffected, options.supersede, isInteractive, s);
341
- if (!shouldProceed)
342
- return;
343
- // Overlap gate: pre-0.16.0 `export` only caught FULL-coverage
344
- // supersedes, so a second export targeting a shared file like
345
- // `browser/themes/shared/jar.inc.mn` happily created a queue where
346
- // two patches both listed the same file in `filesAffected`. `verify`
347
- // then failed immediately on "cross-patch filesAffected conflicts".
348
- // `confirmSupersedePatches` might already have confirmed full
349
- // supersedes above; pass their filenames through so we do not flag
350
- // a file claimed by a patch that is about to be removed.
351
- const willSupersede = await findAllPatchesForFiles(paths.patches, filesAffected);
352
- const supersedingFilenames = new Set(willSupersede.map((p) => p.filename));
353
- const shouldProceedPastOverlap = await guardOwnershipOverlap({
295
+ const shouldProceedPastGates = await runSupersedeAndOverlapGates({
354
296
  patchesDir: paths.patches,
355
297
  filesAffected,
356
- supersedingFilenames,
298
+ supersede: options.supersede,
357
299
  allowOverlap: options.allowOverlap === true,
358
300
  isInteractive,
359
301
  s,
360
302
  });
361
- if (!shouldProceedPastOverlap)
303
+ if (!shouldProceedPastGates)
362
304
  return;
363
305
  const { patchFilename, superseded } = await commitExportedPatch({
364
306
  patchesDir: paths.patches,
@@ -368,10 +310,7 @@ export async function exportCommand(projectRoot, files, options) {
368
310
  diff,
369
311
  filesAffected,
370
312
  ...buildPatchSourceMetadata(config.firefox),
371
- ...(options.tier !== undefined ? { tier: options.tier } : {}),
372
- ...(options.lintIgnore !== undefined && options.lintIgnore.length > 0
373
- ? { lintIgnore: options.lintIgnore }
374
- : {}),
313
+ ...patchMetadataExtras(options),
375
314
  config,
376
315
  policyCommand: 'export',
377
316
  forceUnsafe: options.forceUnsafe === true,
@@ -7,19 +7,6 @@
7
7
  * upstream Firefox (browser.xhtml, privatebrowsing/aboutPrivateBrowsing.html,
8
8
  * etc.) minus the fork-specific wiring. A fork author fills in the body.
9
9
  */
10
- /**
11
- * Sentinel attribute emitted on every `furnace chrome-doc create`-scaffolded
12
- * root element. Platform modules (`DevToolsStartup`, `PageActions`,
13
- * `SessionStore`, `DownloadsButton`, …) that observe
14
- * `browser-delayed-startup-finished` and walk INTO the window assume the
15
- * `browser.xhtml` DOM and throw on anything else. A fork-authored patch
16
- * to such a module can use `hasAttribute(...)` against this sentinel as
17
- * a cheap, fork-neutral guard to skip the walk on a custom chrome doc.
18
- *
19
- * Exposed as a named constant so test code and external checks can
20
- * reference the exact attribute name without hardcoding the string.
21
- */
22
- export declare const FURNACE_CHROME_DOC_SENTINEL = "data-furnace-chrome-doc";
23
10
  /**
24
11
  * XHTML shell for a top-level chrome document.
25
12
  *
@@ -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 {