@hominis/fireforge 0.21.4 → 0.22.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) {
@@ -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
  }));
@@ -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
  }
@@ -552,6 +552,8 @@ export interface PatchCompactOptions {
552
552
  yes?: boolean;
553
553
  /** Print what would happen without writing anything. */
554
554
  dryRun?: boolean;
555
+ /** Bypass force-mode patchPolicy refusals. */
556
+ forceUnsafe?: boolean;
555
557
  }
556
558
  /**
557
559
  * Options for the status command.
@@ -584,6 +586,12 @@ export interface TokenAddOptions {
584
586
  */
585
587
  export interface DoctorOptions {
586
588
  repairPatchesManifest?: boolean;
589
+ /**
590
+ * Clear a stale `pendingResolution` marker, but only after the same
591
+ * read-only queue health checks used by `fireforge verify` report no
592
+ * error-severity findings.
593
+ */
594
+ clearResolution?: boolean;
587
595
  /**
588
596
  * Opt-in repair path for furnace-specific checks. When true, doctor will:
589
597
  * - clear stale `.fireforge/furnace-state.json` entries whose component is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hominis/fireforge",
3
- "version": "0.21.4",
3
+ "version": "0.22.0",
4
4
  "description": "FireForge — a build tool for customizing Firefox",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",