@hominis/fireforge 0.21.3 → 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.
@@ -7,7 +7,7 @@ import { expandUntrackedDirectoryEntries, getWorkingTreeStatus } from '../core/g
7
7
  import { measureTokenCoverage } from '../core/token-coverage.js';
8
8
  import { getTokensCssPath } from '../core/token-manager.js';
9
9
  import { GeneralError } from '../errors/base.js';
10
- import { pathExists } from '../utils/fs.js';
10
+ import { pathExists, readText } from '../utils/fs.js';
11
11
  import { info, intro, outro, success, warn } from '../utils/logger.js';
12
12
  /**
13
13
  * Measures design token coverage across modified CSS files.
@@ -31,6 +31,9 @@ export async function tokenCoverageCommand(projectRoot) {
31
31
  // and the file-extension filter could not see the .css inside.
32
32
  const rawStatus = await getWorkingTreeStatus(paths.engine);
33
33
  const expandedStatus = await expandUntrackedDirectoryEntries(paths.engine, rawStatus);
34
+ const statusTokenCssFiles = expandedStatus
35
+ .filter((f) => f.file === tokensCssPath)
36
+ .map((f) => f.file);
34
37
  const statusCssFiles = expandedStatus
35
38
  .filter((f) => f.file.endsWith('.css') && f.file !== tokensCssPath)
36
39
  .map((f) => f.file);
@@ -44,11 +47,27 @@ export async function tokenCoverageCommand(projectRoot) {
44
47
  // De-dupe so a file that is both a custom deploy target AND modified is
45
48
  // scanned exactly once.
46
49
  const cssFiles = [...new Set([...statusCssFiles, ...furnaceCssFiles])];
47
- if (cssFiles.length === 0) {
50
+ const tokenSourceFiles = [...new Set(statusTokenCssFiles)];
51
+ if (cssFiles.length === 0 && tokenSourceFiles.length === 0) {
48
52
  info('No modified CSS files');
49
53
  outro('Nothing to measure');
50
54
  return;
51
55
  }
56
+ const tokenPrefix = await resolveTokenPrefix(projectRoot, config.binaryName);
57
+ const tokenSourceResults = await validateTokenSourceFiles(paths.engine, tokenSourceFiles, tokenPrefix);
58
+ for (const result of tokenSourceResults) {
59
+ const detail = `${result.tokenDeclarations} token declaration${result.tokenDeclarations === 1 ? '' : 's'}`;
60
+ if (result.unknownDeclarations.length === 0) {
61
+ success(`${result.file} token source valid (${detail})`);
62
+ }
63
+ else {
64
+ warn(`${result.file} token source has ${result.unknownDeclarations.length} declaration${result.unknownDeclarations.length === 1 ? '' : 's'} outside prefix ${tokenPrefix}: ${result.unknownDeclarations.join(', ')}`);
65
+ }
66
+ }
67
+ if (cssFiles.length === 0) {
68
+ outro(`${tokenSourceResults.length} token source file${tokenSourceResults.length === 1 ? '' : 's'} validated`);
69
+ return;
70
+ }
52
71
  const report = await measureTokenCoverage(paths.engine, cssFiles);
53
72
  // Per-file breakdown
54
73
  for (const entry of report.files) {
@@ -73,6 +92,46 @@ export async function tokenCoverageCommand(projectRoot) {
73
92
  }
74
93
  outro(`${report.filesScanned} CSS file${report.filesScanned === 1 ? '' : 's'} scanned`);
75
94
  }
95
+ async function resolveTokenPrefix(projectRoot, binaryName) {
96
+ try {
97
+ const furnaceConfig = await loadFurnaceConfig(projectRoot);
98
+ if (furnaceConfig.tokenPrefix) {
99
+ return furnaceConfig.tokenPrefix;
100
+ }
101
+ }
102
+ catch {
103
+ // Fall through to the convention used by furnace init. A broken
104
+ // furnace.json is already surfaced by collectFurnaceCustomCssFiles.
105
+ }
106
+ return `--${binaryName}-`;
107
+ }
108
+ async function validateTokenSourceFiles(engineDir, tokenSourceFiles, tokenPrefix) {
109
+ const results = [];
110
+ for (const file of tokenSourceFiles) {
111
+ const filePath = join(engineDir, file);
112
+ if (!(await pathExists(filePath)))
113
+ continue;
114
+ const css = (await readText(filePath)).replace(/\/\*[\s\S]*?\*\//g, '');
115
+ const declarations = new Set();
116
+ const declarationPattern = /(^|[;{\s])(--[\w-]+)\s*:/g;
117
+ let match;
118
+ while ((match = declarationPattern.exec(css)) !== null) {
119
+ const declaration = match[2];
120
+ if (declaration)
121
+ declarations.add(declaration);
122
+ }
123
+ const tokenDeclarations = [...declarations].filter((name) => name.startsWith(tokenPrefix));
124
+ const unknownDeclarations = [...declarations]
125
+ .filter((name) => !name.startsWith(tokenPrefix))
126
+ .sort();
127
+ results.push({
128
+ file,
129
+ tokenDeclarations: tokenDeclarations.length,
130
+ unknownDeclarations,
131
+ });
132
+ }
133
+ return results;
134
+ }
76
135
  /**
77
136
  * Returns engine-relative `.css` paths deployed by every Furnace custom
78
137
  * component registered in `furnace.json`. Only files that actually exist
@@ -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
  }
@@ -11,7 +11,7 @@ import type { FireForgeConfig } from '../types/config.js';
11
11
  /** Default patch filename contract used when a policy omits `filenamePattern`. */
12
12
  export declare const DEFAULT_PATCH_POLICY_FILENAME_PATTERN = "^(?<order>\\d{3})-(?<category>[a-z][a-z0-9-]*)-(?<slug>[a-z0-9-]+)\\.patch$";
13
13
  /** Stable issue codes returned by patch policy evaluation. */
14
- export type PatchPolicyIssueCode = 'filename-pattern' | 'filename-captures' | 'filename-metadata-mismatch' | 'category-range' | 'reserved-range' | 'reserved-documentation' | 'reserved-files' | 'description-required' | 'numeric-gap';
14
+ export type PatchPolicyIssueCode = 'filename-pattern' | 'filename-captures' | 'filename-metadata-mismatch' | 'order-collision' | 'category-range' | 'reserved-range' | 'reserved-documentation' | 'reserved-files' | 'description-required' | 'numeric-gap';
15
15
  /** A single patch policy validation finding. */
16
16
  export interface PatchPolicyIssue {
17
17
  code: PatchPolicyIssueCode;
@@ -260,6 +260,28 @@ function evaluateGaps(cfg, patches, severity) {
260
260
  }
261
261
  return issues;
262
262
  }
263
+ function evaluateOrderCollisions(patches, severity) {
264
+ const byOrder = new Map();
265
+ for (const patch of patches) {
266
+ const matches = byOrder.get(patch.order) ?? [];
267
+ matches.push(patch);
268
+ byOrder.set(patch.order, matches);
269
+ }
270
+ const issues = [];
271
+ for (const [order, matches] of [...byOrder.entries()].sort((a, b) => a[0] - b[0])) {
272
+ if (matches.length <= 1)
273
+ continue;
274
+ const filenames = matches.map((patch) => patch.filename).sort((a, b) => a.localeCompare(b));
275
+ issues.push({
276
+ code: 'order-collision',
277
+ filename: String(order).padStart(3, '0'),
278
+ severity,
279
+ message: `patchPolicy requires unique numeric orders; order ${String(order).padStart(3, '0')} ` +
280
+ `is used by: ${filenames.join(', ')}.`,
281
+ });
282
+ }
283
+ return issues;
284
+ }
263
285
  /** Evaluates an entire patch manifest against the configured policy. */
264
286
  export function evaluatePatchPolicy(config, manifest) {
265
287
  const cfg = policy(config);
@@ -267,6 +289,7 @@ export function evaluatePatchPolicy(config, manifest) {
267
289
  return [];
268
290
  const severity = issueSeverity(config);
269
291
  const issues = manifest.patches.flatMap((patch) => evaluatePatchMetadata(cfg, patch, severity));
292
+ issues.push(...evaluateOrderCollisions(manifest.patches, severity));
270
293
  issues.push(...evaluateGaps(cfg, manifest.patches, severity));
271
294
  return issues;
272
295
  }
@@ -70,7 +70,7 @@ export interface ExportOptions {
70
70
  * be superseded and which files caused the coverage.
71
71
  */
72
72
  dryRun?: boolean;
73
- /** Place the new patch at a specific ordinal, shifting subsequent patches. */
73
+ /** Place the new patch at this exact unused order without renumbering existing patches. */
74
74
  order?: number;
75
75
  /** Place the new patch immediately before the named patch. */
76
76
  before?: string;
@@ -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.3",
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",