@hominis/fireforge 0.21.4 → 0.23.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.
@@ -15,7 +15,7 @@
15
15
  * Lives in a sibling module to keep `doctor-furnace.ts` under the
16
16
  * per-file LOC budget.
17
17
  */
18
- import { readdir } from 'node:fs/promises';
18
+ import { readdir, rm } from 'node:fs/promises';
19
19
  import { join } from 'node:path';
20
20
  import { getFurnacePaths, loadFurnaceConfig, writeFurnaceConfig } from '../core/furnace-config.js';
21
21
  import { toError } from '../utils/errors.js';
@@ -120,6 +120,37 @@ async function repairOrphanOverrides(projectRoot, orphans) {
120
120
  }
121
121
  return { restored, unrecoverable };
122
122
  }
123
+ async function repairCustomOrphans(projectRoot, customNames) {
124
+ const deleted = [];
125
+ const retained = [];
126
+ const errors = [];
127
+ if (customNames.length === 0)
128
+ return { deleted, retained, errors };
129
+ const furnacePaths = getFurnacePaths(projectRoot);
130
+ for (const name of customNames) {
131
+ const dir = join(furnacePaths.customDir, name);
132
+ let entries;
133
+ try {
134
+ entries = await readdir(dir, { withFileTypes: true });
135
+ }
136
+ catch (err) {
137
+ errors.push(`${name}: ${toError(err).message}`);
138
+ continue;
139
+ }
140
+ if (entries.length > 0) {
141
+ retained.push(name);
142
+ continue;
143
+ }
144
+ try {
145
+ await rm(dir);
146
+ deleted.push(name);
147
+ }
148
+ catch (err) {
149
+ errors.push(`${name}: ${toError(err).message}`);
150
+ }
151
+ }
152
+ return { deleted, retained, errors };
153
+ }
123
154
  export const furnaceManifestSyncCheck = {
124
155
  name: 'Furnace manifest sync',
125
156
  dependsOn: ['Furnace configuration'],
@@ -142,6 +173,7 @@ export const furnaceManifestSyncCheck = {
142
173
  if (repairResult.writeError) {
143
174
  return failure('Furnace manifest sync', `Repair failed while writing furnace.json: ${repairResult.writeError}`, 'Fix the underlying filesystem error and retry the doctor command.');
144
175
  }
176
+ const customRepair = await repairCustomOrphans(ctx.projectRoot, orphans.customNames);
145
177
  const { restored, unrecoverable } = repairResult;
146
178
  const restoreDetail = restored.length > 0
147
179
  ? `Re-registered ${restored.length} override${restored.length === 1 ? '' : 's'} (${restored.join(', ')}) from their override.json sidecars.`
@@ -149,10 +181,16 @@ export const furnaceManifestSyncCheck = {
149
181
  const unrecoverableDetail = unrecoverable.length > 0
150
182
  ? ` Could not recover ${unrecoverable.length} override${unrecoverable.length === 1 ? '' : 's'} without a valid override.json (${unrecoverable.join(', ')}) — delete components/overrides/<name> or re-run "fireforge furnace override" to restore the entry.`
151
183
  : '';
152
- const customDetail = customCount > 0
153
- ? ` ${customCount} custom ${customCount === 1 ? 'directory requires' : 'directories require'} manual action: re-run "fireforge furnace create" or delete components/custom/<name>/ to reconcile.`
184
+ const customDetail = customRepair.deleted.length > 0
185
+ ? ` Deleted ${customRepair.deleted.length} empty custom orphan ${customRepair.deleted.length === 1 ? 'directory' : 'directories'} (${customRepair.deleted.join(', ')}).`
186
+ : '';
187
+ const retainedCustomDetail = customRepair.retained.length > 0
188
+ ? ` ${customRepair.retained.length} non-empty custom orphan ${customRepair.retained.length === 1 ? 'directory requires' : 'directories require'} manual action (${customRepair.retained.join(', ')}): re-run "fireforge furnace create" or delete components/custom/<name>/ to reconcile.`
189
+ : '';
190
+ const customErrorDetail = customRepair.errors.length > 0
191
+ ? ` Could not inspect or delete ${customRepair.errors.length} custom orphan ${customRepair.errors.length === 1 ? 'directory' : 'directories'} (${customRepair.errors.join('; ')}).`
154
192
  : '';
155
- return warning('Furnace manifest sync', `${restoreDetail}${unrecoverableDetail}${customDetail}`.trim() ||
193
+ return warning('Furnace manifest sync', `${restoreDetail}${unrecoverableDetail}${customDetail}${retainedCustomDetail}${customErrorDetail}`.trim() ||
156
194
  'Nothing to repair (orphans surfaced but all were already recoverable).');
157
195
  },
158
196
  };
@@ -1,4 +1,4 @@
1
- import { configExists, getProjectPaths, loadConfig, loadState } from '../core/config.js';
1
+ import { configExists, getProjectPaths, loadConfig, loadState, updateState, } from '../core/config.js';
2
2
  import { furnaceConfigExists as checkFurnaceConfigExists } from '../core/furnace-config.js';
3
3
  import { getCurrentBranch, getHead, isGitRepository, isMissingHeadError } from '../core/git.js';
4
4
  import { ensureGit } from '../core/git-base.js';
@@ -13,6 +13,7 @@ import { findExecutable } from '../utils/process.js';
13
13
  import { failure, ok, warning } from './doctor-check-core.js';
14
14
  import { FURNACE_DOCTOR_CHECKS } from './doctor-furnace.js';
15
15
  import { inspectEngineWorkingTree } from './doctor-working-tree.js';
16
+ import { collectPatchQueueHealth } from './verify.js';
16
17
  /**
17
18
  * Runs a single check definition, converting thrown errors into
18
19
  * DoctorCheck failure rows. Always returns an array so the caller can
@@ -184,9 +185,21 @@ const DOCTOR_CHECKS = [
184
185
  {
185
186
  name: 'Pending Resolution',
186
187
  skipIf: (ctx) => !ctx.state.pendingResolution,
187
- run: (ctx) => {
188
+ run: async (ctx) => {
188
189
  const patchFilename = ctx.state.pendingResolution?.patchFilename ?? 'unknown';
189
- return failure('Pending Resolution', `You are currently resolving a conflict for patch ${patchFilename}.`, 'Build and Export commands may behave unexpectedly until "fireforge resolve" is completed.');
190
+ if (ctx.options.clearResolution) {
191
+ const health = await collectPatchQueueHealth(ctx.projectRoot);
192
+ if (health.errorCount > 0) {
193
+ return failure('Pending Resolution', `Refusing to clear pending resolution for ${patchFilename}: patch queue health check found ${health.errorCount} error(s).`, 'Run "fireforge verify" for details, fix the queue, then retry "fireforge doctor --clear-resolution".');
194
+ }
195
+ await updateState(ctx.projectRoot, (current) => {
196
+ const next = { ...current };
197
+ delete next.pendingResolution;
198
+ return next;
199
+ });
200
+ return ok('Pending Resolution');
201
+ }
202
+ return failure('Pending Resolution', `You are currently resolving a conflict for patch ${patchFilename}.`, 'Build and Export commands may behave unexpectedly until "fireforge resolve" is completed. If the queue now verifies cleanly, run "fireforge doctor --clear-resolution" to discard the stale marker.');
190
203
  },
191
204
  },
192
205
  {
@@ -452,6 +465,7 @@ export function registerDoctor(program, { getProjectRoot, withErrorHandling }) {
452
465
  .description('Diagnose project issues')
453
466
  .option('--repair-patches-manifest', 'Rebuild patches/patches.json from the current patch files before reporting results')
454
467
  .option('--repair-furnace', 'Reconcile furnace state: clear stale furnace-state.json entries, re-run furnace apply to fix engine drift, and clear the pending-repair marker set by a failed preview teardown')
468
+ .option('--clear-resolution', 'Clear stale pendingResolution state after the patch queue health check reports no errors')
455
469
  .action(withErrorHandling(async (options) => {
456
470
  const result = await doctorCommand(getProjectRoot(), options);
457
471
  if (result.exitCode !== 0) {
@@ -161,6 +161,12 @@ async function resolveLintDiff(engineDir, files, binaryName, furnacePrefixes) {
161
161
  }
162
162
  return diff;
163
163
  }
164
+ function buildMaxWarningsMessage(count, maxWarnings, scope) {
165
+ const scoped = scope ? ` ${scope}` : '';
166
+ const base = `Patch lint found ${count} warning(s)${scoped}, exceeding --max-warnings ${maxWarnings}.`;
167
+ return (base +
168
+ ' If this is a release gate and the warnings are historical patch-size advisories, run with --per-patch to identify the owning patch and split/re-export that patch, or add a scoped lintIgnore entry only after review.');
169
+ }
164
170
  /**
165
171
  * Filters aggregate-mode lint issues against per-patch `lintIgnore`
166
172
  * lists drawn from the manifest. An issue is dropped when at least one
@@ -373,7 +379,7 @@ export async function lintCommand(projectRoot, files, options = {}) {
373
379
  }
374
380
  if (options.maxWarnings !== undefined && warnings.length > options.maxWarnings) {
375
381
  outro('Lint failed');
376
- throw new GeneralError(`Patch lint found ${warnings.length} warning(s), exceeding --max-warnings ${options.maxWarnings}.`);
382
+ throw new GeneralError(buildMaxWarningsMessage(warnings.length, options.maxWarnings));
377
383
  }
378
384
  // Notices are advisory and don't count as warnings — emitting "passed
379
385
  // with warnings" when only notices fired contradicts the preceding
@@ -493,7 +499,7 @@ async function lintPerPatch(projectRoot, paths, options = {}) {
493
499
  }
494
500
  if (options.maxWarnings !== undefined && warnings.length > options.maxWarnings) {
495
501
  outro('Lint failed');
496
- throw new GeneralError(`Patch lint found ${warnings.length} warning(s) across ${linted} patch(es), exceeding --max-warnings ${options.maxWarnings}.`);
502
+ throw new GeneralError(buildMaxWarningsMessage(warnings.length, options.maxWarnings, `across ${linted} patch(es)`));
497
503
  }
498
504
  if (warnings.length > 0) {
499
505
  outro('Lint passed with warnings');
@@ -6,10 +6,11 @@
6
6
  * This command renumbers all patches to sequential ordinals (1, 2, 3, …)
7
7
  * in a single atomic operation, preserving relative order.
8
8
  */
9
- import { getProjectPaths } from '../../core/config.js';
9
+ import { getProjectPaths, loadConfig } from '../../core/config.js';
10
10
  import { appendHistory, confirmDestructive } from '../../core/destructive.js';
11
11
  import { withPatchDirectoryLock } from '../../core/patch-lock.js';
12
12
  import { loadPatchesManifest, renumberPatchesInManifest, } from '../../core/patch-manifest.js';
13
+ import { applyRenameMapToManifest, enforcePatchPolicy } from '../../core/patch-policy.js';
13
14
  import { GeneralError } from '../../errors/base.js';
14
15
  import { toError } from '../../utils/errors.js';
15
16
  import { pathExists } from '../../utils/fs.js';
@@ -44,6 +45,7 @@ function computeCompactRenameMap(patches) {
44
45
  export async function patchCompactCommand(projectRoot, options = {}) {
45
46
  intro(options.dryRun ? 'FireForge patch compact (dry run)' : 'FireForge patch compact');
46
47
  const paths = getProjectPaths(projectRoot);
48
+ const config = await loadConfig(projectRoot);
47
49
  if (!(await pathExists(paths.patches))) {
48
50
  throw new GeneralError('Patches directory not found.');
49
51
  }
@@ -62,12 +64,19 @@ export async function patchCompactCommand(projectRoot, options = {}) {
62
64
  for (const [oldFilename, entry] of sorted) {
63
65
  summary.push(` ${oldFilename} → ${entry.newFilename} (order ${entry.newOrder})`);
64
66
  }
67
+ enforcePatchPolicy({
68
+ config,
69
+ manifest: applyRenameMapToManifest(manifest, renameMap),
70
+ command: 'patch compact',
71
+ forceUnsafe: options.forceUnsafe === true,
72
+ });
65
73
  const decision = await confirmDestructive({
66
74
  operation: 'patch-compact',
67
75
  title: `Compact ${manifest.patches.length} patches (${renameMap.size} rename(s))`,
68
76
  summary,
69
77
  yes: options.yes === true,
70
78
  dryRun: options.dryRun === true,
79
+ unsafeOverride: options.forceUnsafe === true,
71
80
  });
72
81
  if (decision === 'dry-run') {
73
82
  outro('Dry run complete — no changes made');
@@ -87,6 +96,12 @@ export async function patchCompactCommand(projectRoot, options = {}) {
87
96
  info('Patch queue was compacted by another process. Nothing to do.');
88
97
  return;
89
98
  }
99
+ enforcePatchPolicy({
100
+ config,
101
+ manifest: applyRenameMapToManifest(currentManifest, currentRenameMap),
102
+ command: 'patch compact',
103
+ forceUnsafe: options.forceUnsafe === true,
104
+ });
90
105
  await renumberPatchesInManifest(paths.patches, currentRenameMap);
91
106
  const historyEntry = {
92
107
  operation: 'patch-compact',
@@ -100,6 +115,7 @@ export async function patchCompactCommand(projectRoot, options = {}) {
100
115
  })),
101
116
  },
102
117
  ...(options.yes === true ? { yes: true } : {}),
118
+ ...(options.forceUnsafe === true ? { unsafeOverride: true } : {}),
103
119
  result: 'ok',
104
120
  };
105
121
  try {
@@ -125,6 +141,7 @@ export function registerPatchCompact(parent, context) {
125
141
  .description('Close ordinal gaps in the patch queue (renumber sequentially)')
126
142
  .option('--dry-run', 'Show what would happen without writing')
127
143
  .option('-y, --yes', 'Skip confirmation prompt (required for non-TTY)')
144
+ .option('--force-unsafe', 'Bypass force-mode patchPolicy refusals')
128
145
  .action(withErrorHandling(async (options) => {
129
146
  await patchCompactCommand(getProjectRoot(), pickDefined(options));
130
147
  }));
@@ -175,9 +175,10 @@ export async function reExportFilesInPlace(paths, selectedPatches, options, conf
175
175
  forceUnsafe: options.forceUnsafe === true,
176
176
  });
177
177
  }
178
- // Shrinks are destructive (previously-owned files become unmanaged).
179
- // Additive-only changes still deserve a prompt because --files asserts
180
- // an authoritative file set.
178
+ // Shrinks are destructive (previously-owned files become unmanaged), so
179
+ // they keep the explicit confirmation gate. Additive-only scopes are safe
180
+ // to run non-interactively after lint/policy projection because no existing
181
+ // patch ownership is being dropped.
181
182
  const summary = [
182
183
  `re-export ${target.filename} with --files scope`,
183
184
  `current files (${target.filesAffected.length}): ${target.filesAffected.join(', ') || '(none)'}`,
@@ -196,7 +197,7 @@ export async function reExportFilesInPlace(paths, selectedPatches, options, conf
196
197
  operation: 're-export-files',
197
198
  title: `Re-export ${target.filename} with --files`,
198
199
  summary,
199
- yes: options.yes === true,
200
+ yes: removed.length === 0 && missingFiles.length === 0 ? true : options.yes === true,
200
201
  dryRun: isDryRun,
201
202
  unsafeOverride: options.forceUnsafe === true,
202
203
  conflicts,
@@ -2,11 +2,11 @@
2
2
  import { join } from 'node:path';
3
3
  import { prepareBuildEnvironment } from '../core/build-prepare.js';
4
4
  import { getProjectPaths, loadConfig } from '../core/config.js';
5
- import { buildArtifactMismatchMessage, buildUI, hasBuildArtifacts, hasRunnableBundle, testWithOutput, } from '../core/mach.js';
5
+ import { buildArtifactMismatchMessage, buildUI, hasBuildArtifacts, hasRunnableBundle, testWithOutput, withBuildLock, } from '../core/mach.js';
6
6
  import { assertMarionettePortAvailable, extractForwardedMarionettePort, forwardedMachArgsIncludeMarionetteClient, shouldAutoForwardMarionettePortToMach, } from '../core/marionette-port.js';
7
7
  import { formatMarionettePreflightLine, reportMarionettePreflight, runMarionettePreflight, } from '../core/marionette-preflight.js';
8
8
  import { checkStaleBuildForTest, formatStaleBuildWarning } from '../core/test-stale-check.js';
9
- import { operatorAlreadySetAppPath, resolveXpcshellAppdirArg, } from '../core/xpcshell-appdir.js';
9
+ import { findNearestXpcshellManifest, operatorAlreadySetAppPath, resolveXpcshellAppdirArg, } from '../core/xpcshell-appdir.js';
10
10
  import { GeneralError } from '../errors/base.js';
11
11
  import { AmbiguousBuildArtifactsError, BuildError } from '../errors/build.js';
12
12
  import { pathExists } from '../utils/fs.js';
@@ -36,6 +36,43 @@ function buildStaleBuildMessage() {
36
36
  'The failing output referenced missing branding or distribution resources, which usually means the current obj-* build does not match recent engine or branding changes.\n\n' +
37
37
  'Re-run "fireforge build --ui" or "fireforge test --build" and then retry.');
38
38
  }
39
+ async function classifyTestHarnesses(engineDir, normalizedPaths) {
40
+ const result = { xpcshell: [], nonXpcshell: [] };
41
+ for (const testPath of normalizedPaths) {
42
+ const manifest = await findNearestXpcshellManifest(engineDir, testPath);
43
+ if (manifest) {
44
+ result.xpcshell.push(testPath);
45
+ }
46
+ else {
47
+ result.nonXpcshell.push(testPath);
48
+ }
49
+ }
50
+ return result;
51
+ }
52
+ function buildMixedHarnessMessage(classification) {
53
+ return ('FireForge cannot run xpcshell and browser/mochitest paths in the same mach invocation.\n\n' +
54
+ 'Split this into separate `fireforge test` commands so each manifest selects its own harness:\n' +
55
+ ` - xpcshell: ${classification.xpcshell.join(', ')}\n` +
56
+ ` - browser/mochitest: ${classification.nonXpcshell.join(', ')}`);
57
+ }
58
+ function filterRedundantXpcshellFlavorArgs(machArgs, classification) {
59
+ if (classification.xpcshell.length === 0 || classification.nonXpcshell.length > 0) {
60
+ return [...machArgs];
61
+ }
62
+ const filtered = [];
63
+ for (let i = 0; i < machArgs.length; i += 1) {
64
+ const arg = machArgs[i] ?? '';
65
+ if (/^--flavor=xpcshell(?:-tests)?$/.test(arg)) {
66
+ continue;
67
+ }
68
+ if (arg === '--flavor' && /^xpcshell(?:-tests)?$/.test(machArgs[i + 1] ?? '')) {
69
+ i += 1;
70
+ continue;
71
+ }
72
+ filtered.push(arg);
73
+ }
74
+ return filtered;
75
+ }
39
76
  function hasStaleBuildArtifactsSignal(output) {
40
77
  // Deliberately narrow: only fire on branding-specific resource paths
41
78
  // that are always a stale-artifact symptom. The earlier pattern also
@@ -101,6 +138,36 @@ function buildXpcshellAppdirMessage(injectionAttempted) {
101
138
  ' - Remove `firefox-appdir = "browser"` from the xpcshell.toml [DEFAULT] and move browser-chrome dependencies into a browser-chrome mochitest (see `fireforge furnace create --test-style=browser-chrome`).\n' +
102
139
  ' - If the test only touches toolkit chrome (chrome://global/*), drop the `firefox-appdir` setting entirely — toolkit chrome is registered without it.');
103
140
  }
141
+ function buildMochitestSymlinkMessage() {
142
+ return ('mach failed while preparing mochitest harness symlinks before the requested tests ran.\n\n' +
143
+ 'This usually means the objdir contains stale harness setup from an earlier run. Re-run with `fireforge test --build` to refresh the harness state, or remove the stale mochitest symlink in the active obj-* directory before retrying.');
144
+ }
145
+ async function resolveLaunchablePathForTests(engineDir, binaryName, objDir) {
146
+ if (!objDir)
147
+ return undefined;
148
+ const bundleCheck = await hasRunnableBundle(engineDir, binaryName, objDir);
149
+ if (!bundleCheck.runnable) {
150
+ const expectedSuffix = bundleCheck.expectedPath
151
+ ? ` (expected at engine/${bundleCheck.expectedPath})`
152
+ : '';
153
+ throw new GeneralError(`Tests require a complete launchable build${expectedSuffix}. ` +
154
+ 'The obj-*/dist/ tree exists but the launchable binary is missing — typically the result of an interrupted or partially failed `fireforge build`.\n\n' +
155
+ 'Run "fireforge build" again and let it finish before retrying "fireforge test".');
156
+ }
157
+ return bundleCheck.expectedPath;
158
+ }
159
+ async function runPreTestBuild(projectRoot, paths, projectConfig) {
160
+ await withBuildLock(projectRoot, async () => {
161
+ await prepareBuildEnvironment(projectRoot, paths, projectConfig);
162
+ const s = spinner('Running incremental build...');
163
+ const buildResult = await buildUI(paths.engine);
164
+ if (buildResult.exitCode !== 0) {
165
+ s.error('Pre-test build failed');
166
+ throw new BuildError('Pre-test build failed', 'mach build faster');
167
+ }
168
+ s.stop('Build complete');
169
+ });
170
+ }
104
171
  // Detects the `AttributeError: 'MochitestDesktop' object has no attribute
105
172
  // 'http3Server'` teardown crash. The attribute is lazy-initialized inside
106
173
  // harness code paths that presume chrome://branding resolves correctly; a
@@ -153,6 +220,9 @@ function handleNonZeroTestExit(result, normalizedPaths, appdirInjectionAttempted
153
220
  if (hasMochitestHttp3ServerSignal(combinedOutput)) {
154
221
  throw new GeneralError(buildMochitestHttp3ServerMessage());
155
222
  }
223
+ if (/FileExistsError/i.test(combinedOutput) && /mochitest/i.test(combinedOutput)) {
224
+ throw new GeneralError(buildMochitestSymlinkMessage());
225
+ }
156
226
  if (/invalid filename/i.test(combinedOutput) ||
157
227
  /chrome:\/\/mochitests.*not found/i.test(combinedOutput)) {
158
228
  info('Hint: The test file may not be registered in browser.toml or jar.mn.');
@@ -201,29 +271,10 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
201
271
  // here so `test --doctor` against an incomplete build surfaces the
202
272
  // missing-bundle path instead of a cryptic `Browser process exited
203
273
  // during spawn (exit code 1, signal none). stderr tail: (empty)`.
204
- let launchablePath;
205
- if (buildCheck.objDir) {
206
- const bundleCheck = await hasRunnableBundle(paths.engine, projectConfig.binaryName, buildCheck.objDir);
207
- launchablePath = bundleCheck.expectedPath;
208
- if (!bundleCheck.runnable) {
209
- const expectedSuffix = bundleCheck.expectedPath
210
- ? ` (expected at engine/${bundleCheck.expectedPath})`
211
- : '';
212
- throw new GeneralError(`Tests require a complete launchable build${expectedSuffix}. ` +
213
- 'The obj-*/dist/ tree exists but the launchable binary is missing — typically the result of an interrupted or partially failed `fireforge build`.\n\n' +
214
- 'Run "fireforge build" again and let it finish before retrying "fireforge test".');
215
- }
216
- }
274
+ const launchablePath = await resolveLaunchablePathForTests(paths.engine, projectConfig.binaryName, buildCheck.objDir);
217
275
  // Run incremental build if requested
218
276
  if (options.build) {
219
- await prepareBuildEnvironment(projectRoot, paths, projectConfig);
220
- const s = spinner('Running incremental build...');
221
- const buildResult = await buildUI(paths.engine);
222
- if (buildResult.exitCode !== 0) {
223
- s.error('Pre-test build failed');
224
- throw new BuildError('Pre-test build failed', 'mach build faster');
225
- }
226
- s.stop('Build complete');
277
+ await runPreTestBuild(projectRoot, paths, projectConfig);
227
278
  info('');
228
279
  }
229
280
  else {
@@ -309,6 +360,13 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
309
360
  // previous case-insensitive + leading-whitespace-tolerant contract.
310
361
  const normalizedPaths = testPaths.map((p) => stripEnginePrefix(p).trim());
311
362
  await assertTestPathsExist(paths.engine, normalizedPaths);
363
+ const classification = await classifyTestHarnesses(paths.engine, normalizedPaths);
364
+ if (classification.xpcshell.length > 0 && classification.nonXpcshell.length > 0) {
365
+ throw new GeneralError(buildMixedHarnessMessage(classification));
366
+ }
367
+ const forwardedMachArgs = options.machArg && options.machArg.length > 0
368
+ ? filterRedundantXpcshellFlavorArgs(options.machArg, classification)
369
+ : [];
312
370
  // Build extra args
313
371
  const extraArgs = [];
314
372
  if (options.headless) {
@@ -319,8 +377,8 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
319
377
  // above for the motivating case). Appended AFTER --headless so mach sees
320
378
  // the FireForge-managed flags first and the escape-valve ones last, which
321
379
  // keeps the override precedence predictable.
322
- if (options.machArg && options.machArg.length > 0) {
323
- extraArgs.push(...options.machArg);
380
+ if (forwardedMachArgs.length > 0) {
381
+ extraArgs.push(...forwardedMachArgs);
324
382
  }
325
383
  // Auto-forward the Marionette port to mach when `--marionette-port` is
326
384
  // set. `--setpref=marionette.port=<n>` configures where the browser
@@ -15,6 +15,24 @@
15
15
  */
16
16
  import { Command } from 'commander';
17
17
  import type { CommandContext } from '../types/cli.js';
18
+ interface VerifyIssueGroup {
19
+ title: string;
20
+ issues: string[];
21
+ errorCount: number;
22
+ warningCount: number;
23
+ }
24
+ export interface PatchQueueHealth {
25
+ hasPatchesDirectory: boolean;
26
+ groups: VerifyIssueGroup[];
27
+ errorCount: number;
28
+ warningCount: number;
29
+ }
30
+ /**
31
+ * Collects the same queue-health findings reported by `fireforge verify`
32
+ * without printing. Used by doctor recovery paths that need a read-only
33
+ * "is this queue healthy?" decision before clearing stale state.
34
+ */
35
+ export declare function collectPatchQueueHealth(projectRoot: string): Promise<PatchQueueHealth>;
18
36
  /**
19
37
  * Runs the `verify` command: manifest consistency + cross-patch lint.
20
38
  * Read-only; exits non-zero on any error-severity finding.
@@ -29,3 +47,4 @@ export declare function verifyCommand(projectRoot: string): Promise<void>;
29
47
  * @param context - Shared CLI registration context
30
48
  */
31
49
  export declare function registerVerify(program: Command, { getProjectRoot, withErrorHandling }: CommandContext): void;
50
+ export {};
@@ -101,100 +101,129 @@ function detectCrossPatchFileClaims(manifestPatches) {
101
101
  return results;
102
102
  }
103
103
  /**
104
- * Runs the `verify` command: manifest consistency + cross-patch lint.
105
- * Read-only; exits non-zero on any error-severity finding.
106
- *
107
- * @param projectRoot - Project root directory
104
+ * Collects the same queue-health findings reported by `fireforge verify`
105
+ * without printing. Used by doctor recovery paths that need a read-only
106
+ * "is this queue healthy?" decision before clearing stale state.
108
107
  */
109
- export async function verifyCommand(projectRoot) {
110
- intro('FireForge Verify');
108
+ export async function collectPatchQueueHealth(projectRoot) {
111
109
  const paths = getProjectPaths(projectRoot);
112
110
  const config = await loadConfig(projectRoot);
113
111
  if (!(await pathExists(paths.patches))) {
114
- info('No patches directory. Nothing to verify.');
115
- outro('Verify clean');
116
- return;
112
+ return {
113
+ hasPatchesDirectory: false,
114
+ groups: [],
115
+ errorCount: 0,
116
+ warningCount: 0,
117
+ };
117
118
  }
119
+ const groups = [];
118
120
  let errorCount = 0;
119
121
  let warningCount = 0;
120
- // 1. Manifest consistency: orphan patch files, missing entries,
121
- // files-affected mismatch, duplicate entries, unparseable manifest.
122
122
  const consistencyIssues = await validatePatchesManifestConsistency(paths.patches);
123
123
  if (consistencyIssues.length > 0) {
124
- warn(`Manifest consistency issues (${consistencyIssues.length}):`);
125
- for (const issue of consistencyIssues) {
126
- warn(` [${issue.code}] ${issue.message}`);
127
- errorCount += 1;
128
- }
124
+ const issues = consistencyIssues.map((issue) => `[${issue.code}] ${issue.message}`);
125
+ groups.push({
126
+ title: `Manifest consistency issues (${consistencyIssues.length})`,
127
+ issues,
128
+ errorCount: consistencyIssues.length,
129
+ warningCount: 0,
130
+ });
131
+ errorCount += consistencyIssues.length;
129
132
  }
130
- // 2. Cross-patch file claims: two or more manifest entries listing the
131
- // same path in filesAffected. Not caught by per-patch consistency.
132
133
  const manifest = await loadPatchesManifest(paths.patches);
133
134
  if (manifest) {
134
135
  const policyIssues = evaluatePatchPolicy(config, manifest);
135
136
  if (policyIssues.length > 0) {
136
- warn(`Patch policy issues (${policyIssues.length}):`);
137
- for (const issue of policyIssues) {
138
- const label = issue.severity === 'error' ? 'ERROR' : 'WARN';
139
- warn(` ${label} [${issue.code}] ${issue.filename}: ${issue.message}`);
140
- if (issue.severity === 'error')
141
- errorCount += 1;
142
- else
143
- warningCount += 1;
144
- }
137
+ const policyErrors = policyIssues.filter((issue) => issue.severity === 'error').length;
138
+ const policyWarnings = policyIssues.length - policyErrors;
139
+ groups.push({
140
+ title: `Patch policy issues (${policyIssues.length})`,
141
+ issues: policyIssues.map((issue) => {
142
+ const label = issue.severity === 'error' ? 'ERROR' : 'WARN';
143
+ return `${label} [${issue.code}] ${issue.filename}: ${issue.message}`;
144
+ }),
145
+ errorCount: policyErrors,
146
+ warningCount: policyWarnings,
147
+ });
148
+ errorCount += policyErrors;
149
+ warningCount += policyWarnings;
145
150
  }
146
151
  const crossClaims = detectCrossPatchFileClaims(manifest.patches);
147
152
  if (crossClaims.length > 0) {
148
- warn(`Cross-patch filesAffected conflicts (${crossClaims.length}):`);
149
- for (const claim of crossClaims) {
150
- warn(` ${claim.path} claimed by: ${claim.filenames.join(', ')}`);
151
- errorCount += 1;
152
- }
153
+ groups.push({
154
+ title: `Cross-patch filesAffected conflicts (${crossClaims.length})`,
155
+ issues: crossClaims.map((claim) => `${claim.path} claimed by: ${claim.filenames.join(', ')}`),
156
+ errorCount: crossClaims.length,
157
+ warningCount: 0,
158
+ });
159
+ errorCount += crossClaims.length;
153
160
  }
154
161
  }
155
- // 3. Cross-patch lint: duplicate /dev/null creation + forward imports.
156
162
  const ctx = await buildPatchQueueContext(paths.patches);
157
163
  const lintIssues = lintPatchQueue(ctx);
158
164
  if (lintIssues.length > 0) {
159
- warn(`Cross-patch lint issues (${lintIssues.length}):`);
160
- for (const issue of lintIssues) {
161
- const label = issue.severity === 'error' ? 'ERROR' : issue.severity === 'warning' ? 'WARN' : 'NOTICE';
162
- warn(` ${label} [${issue.check}] ${issue.file}: ${issue.message}`);
163
- if (issue.severity === 'error')
164
- errorCount += 1;
165
- else if (issue.severity === 'warning')
166
- warningCount += 1;
167
- }
165
+ const lintErrors = lintIssues.filter((issue) => issue.severity === 'error').length;
166
+ const lintWarnings = lintIssues.filter((issue) => issue.severity === 'warning').length;
167
+ groups.push({
168
+ title: `Cross-patch lint issues (${lintIssues.length})`,
169
+ issues: lintIssues.map((issue) => {
170
+ const label = issue.severity === 'error' ? 'ERROR' : issue.severity === 'warning' ? 'WARN' : 'NOTICE';
171
+ return `${label} [${issue.check}] ${issue.file}: ${issue.message}`;
172
+ }),
173
+ errorCount: lintErrors,
174
+ warningCount: lintWarnings,
175
+ });
176
+ errorCount += lintErrors;
177
+ warningCount += lintWarnings;
168
178
  }
169
- // 4. Registration-consequence consistency: walk each patch body and
170
- // confirm that every widget / locale registration it adds has a
171
- // corresponding file body covered by the patch queue OR present in
172
- // the engine working tree. 2026-04-24 eval Finding 1: a patch
173
- // produced by `export-all --exclude-furnace` referenced
174
- // `toolkit/content/widgets/moz-qa-panel/*.mjs` via jar.mn /
175
- // customElements.js edits, but the source files themselves were
176
- // excluded from the patch. `verify` used to report "clean"; it now
177
- // flags each dangling reference as a `dangling-registration` error
178
- // naming the specific patch and target path.
179
179
  if (manifest) {
180
180
  const registrationIssues = await detectDanglingRegistrations(paths.patches, paths.engine, manifest.patches);
181
181
  if (registrationIssues.length > 0) {
182
- warn(`Dangling registration references (${registrationIssues.length}):`);
183
- for (const issue of registrationIssues) {
184
- warn(` ${issue.patchFilename}: registers ${issue.targetPath} via ${issue.source}, but no patch body or engine file supplies it`);
185
- errorCount += 1;
186
- }
182
+ groups.push({
183
+ title: `Dangling registration references (${registrationIssues.length})`,
184
+ issues: registrationIssues.map((issue) => `${issue.patchFilename}: registers ${issue.targetPath} via ${issue.source}, but no patch body or engine file supplies it`),
185
+ errorCount: registrationIssues.length,
186
+ warningCount: 0,
187
+ });
188
+ errorCount += registrationIssues.length;
189
+ }
190
+ }
191
+ return {
192
+ hasPatchesDirectory: true,
193
+ groups,
194
+ errorCount,
195
+ warningCount,
196
+ };
197
+ }
198
+ /**
199
+ * Runs the `verify` command: manifest consistency + cross-patch lint.
200
+ * Read-only; exits non-zero on any error-severity finding.
201
+ *
202
+ * @param projectRoot - Project root directory
203
+ */
204
+ export async function verifyCommand(projectRoot) {
205
+ intro('FireForge Verify');
206
+ const health = await collectPatchQueueHealth(projectRoot);
207
+ if (!health.hasPatchesDirectory) {
208
+ info('No patches directory. Nothing to verify.');
209
+ outro('Verify clean');
210
+ return;
211
+ }
212
+ for (const group of health.groups) {
213
+ warn(`${group.title}:`);
214
+ for (const issue of group.issues) {
215
+ warn(` ${issue}`);
187
216
  }
188
217
  }
189
- if (errorCount === 0 && warningCount === 0) {
218
+ if (health.errorCount === 0 && health.warningCount === 0) {
190
219
  success('Patch queue is consistent.');
191
220
  outro('Verify clean');
192
221
  return;
193
222
  }
194
- info(`\nVerify: ${errorCount} error(s), ${warningCount} warning(s)`);
195
- if (errorCount > 0) {
223
+ info(`\nVerify: ${health.errorCount} error(s), ${health.warningCount} warning(s)`);
224
+ if (health.errorCount > 0) {
196
225
  outro('Verify failed');
197
- throw new GeneralError(`fireforge verify found ${errorCount} error(s). Fix these before running export/import/rebase.`);
226
+ throw new GeneralError(`fireforge verify found ${health.errorCount} error(s). Fix these before running export/import/rebase.`);
198
227
  }
199
228
  outro('Verify passed with warnings');
200
229
  }