@hominis/fireforge 0.26.0 → 0.27.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.27.0
4
+
5
+ - Improved `download --force` git indexing progress with phase, count, and heartbeat output.
6
+ - Added `re-export --files --allow-shrink` so patch ownership shrinkage is refused unless explicitly acknowledged, with clearer dry-run previews.
7
+ - Surfaced likely new sibling files during plain re-export and aligned verify/status ownership reporting for unowned worktree changes.
8
+ - Preserved patch-owned branding `configure.sh` settings during build preflight.
9
+ - Added custom element registration support for Furnace validate/apply.
10
+
3
11
  ## 0.26.0
4
12
 
5
13
  - Added targeted `re-export --scan --scan-file <path>` for reviewed single-patch new-file assignment without broad sibling collection.
@@ -92,6 +92,41 @@ function buildFilesModeMetadataUpdates(actualProjectedFiles, options, effectiveL
92
92
  }
93
93
  return updates;
94
94
  }
95
+ async function confirmFilesModeProjection(args) {
96
+ const { target, retained, removed, added, actualProjectedFiles, missingFiles, options, conflicts, } = args;
97
+ const isDryRun = options.dryRun === true;
98
+ const summary = [
99
+ `re-export ${target.filename} with --files scope`,
100
+ `current files (${target.filesAffected.length}): ${target.filesAffected.join(', ') || '(none)'}`,
101
+ `retained files (${retained.length}): ${retained.join(', ') || '(none)'}`,
102
+ `projected files (${actualProjectedFiles.length}): ${actualProjectedFiles.join(', ') || '(none)'}`,
103
+ ];
104
+ if (removed.length > 0) {
105
+ summary.push(`removed files (${removed.length}; become unmanaged): ${removed.join(', ')}`);
106
+ }
107
+ if (added.length > 0) {
108
+ summary.push(`newly included files (${added.length}): ${added.join(', ')}`);
109
+ }
110
+ if (missingFiles.length > 0) {
111
+ summary.push(`missing on disk (will be dropped): ${missingFiles.join(', ')}`);
112
+ }
113
+ if (!isDryRun && removed.length > 0 && options.allowShrink !== true) {
114
+ warn(`Refusing to shrink ${target.filename} without --allow-shrink.`);
115
+ for (const line of summary) {
116
+ info(` ${line}`);
117
+ }
118
+ throw new InvalidArgumentError(`Refusing to re-export ${target.filename} with --files because it would remove ${removed.length} existing patch-owned file${removed.length === 1 ? '' : 's'}. Run again with --allow-shrink after reviewing the dry-run output.`, '--allow-shrink');
119
+ }
120
+ return confirmDestructive({
121
+ operation: 're-export-files',
122
+ title: `Re-export ${target.filename} with --files`,
123
+ summary,
124
+ yes: removed.length === 0 && missingFiles.length === 0 ? true : options.yes === true,
125
+ dryRun: isDryRun,
126
+ unsafeOverride: options.forceUnsafe === true,
127
+ conflicts,
128
+ });
129
+ }
95
130
  /**
96
131
  * Handles `re-export --files` end-to-end: computes the projected diff,
97
132
  * runs the per-patch and cross-patch lint against a context in which the
@@ -106,7 +141,6 @@ function buildFilesModeMetadataUpdates(actualProjectedFiles, options, effectiveL
106
141
  * of the projected state.
107
142
  */
108
143
  export async function reExportFilesInPlace(paths, selectedPatches, options, config) {
109
- const isDryRun = options.dryRun === true;
110
144
  const target = selectedPatches[0];
111
145
  if (!target) {
112
146
  throw new InvalidArgumentError('--files requires a target patch.', '--files');
@@ -118,6 +152,7 @@ export async function reExportFilesInPlace(paths, selectedPatches, options, conf
118
152
  const requested = [...new Set(filesOption)].sort();
119
153
  const removed = target.filesAffected.filter((f) => !requested.includes(f));
120
154
  const added = requested.filter((f) => !target.filesAffected.includes(f));
155
+ const retained = target.filesAffected.filter((f) => requested.includes(f));
121
156
  // Filter out paths that no longer exist on disk; we cannot include
122
157
  // them in the new diff because getDiffForFilesAgainstHead would fail.
123
158
  // Missing files are still dropped from the manifest so the resulting
@@ -175,31 +210,14 @@ export async function reExportFilesInPlace(paths, selectedPatches, options, conf
175
210
  forceUnsafe: options.forceUnsafe === true,
176
211
  });
177
212
  }
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.
182
- const summary = [
183
- `re-export ${target.filename} with --files scope`,
184
- `current files (${target.filesAffected.length}): ${target.filesAffected.join(', ') || '(none)'}`,
185
- `new files (${actualProjectedFiles.length}): ${actualProjectedFiles.join(', ') || '(none)'}`,
186
- ];
187
- if (removed.length > 0) {
188
- summary.push(`would drop (become unmanaged): ${removed.join(', ')}`);
189
- }
190
- if (added.length > 0) {
191
- summary.push(`would add: ${added.join(', ')}`);
192
- }
193
- if (missingFiles.length > 0) {
194
- summary.push(`missing on disk (will be dropped): ${missingFiles.join(', ')}`);
195
- }
196
- const decision = await confirmDestructive({
197
- operation: 're-export-files',
198
- title: `Re-export ${target.filename} with --files`,
199
- summary,
200
- yes: removed.length === 0 && missingFiles.length === 0 ? true : options.yes === true,
201
- dryRun: isDryRun,
202
- unsafeOverride: options.forceUnsafe === true,
213
+ const decision = await confirmFilesModeProjection({
214
+ target,
215
+ retained,
216
+ removed,
217
+ added,
218
+ actualProjectedFiles,
219
+ missingFiles,
220
+ options,
203
221
  conflicts,
204
222
  });
205
223
  if (decision === 'cancelled') {
@@ -1,12 +1,13 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
- import { join } from 'node:path';
2
+ import { dirname, join } from 'node:path';
3
3
  import { multiselect } from '@clack/prompts';
4
4
  import { Option } from 'commander';
5
5
  import { getProjectPaths, loadConfig } from '../core/config.js';
6
6
  import { isGitRepository } from '../core/git.js';
7
7
  import { getDiffForFilesAgainstHead } from '../core/git-diff.js';
8
+ import { getModifiedFilesInDir, getUntrackedFilesInDir } from '../core/git-status.js';
8
9
  import { updatePatchAndMetadata } from '../core/patch-export.js';
9
- import { loadPatchesManifest, resolvePatchIdentifier, stampPatchVersions, } from '../core/patch-manifest.js';
10
+ import { getClaimedFiles, loadPatchesManifest, resolvePatchIdentifier, stampPatchVersions, } from '../core/patch-manifest.js';
10
11
  import { buildProjectedManifest, enforcePatchPolicy } from '../core/patch-policy.js';
11
12
  import { GeneralError, InvalidArgumentError } from '../errors/base.js';
12
13
  import { toError } from '../utils/errors.js';
@@ -24,6 +25,49 @@ async function findMissingFiles(engineDir, files) {
24
25
  }
25
26
  return missingFiles;
26
27
  }
28
+ async function findLikelyNewSiblingFiles(args) {
29
+ const { currentFilesAffected, engineDir, manifest, patchFilename } = args;
30
+ const parentDirs = [...new Set(currentFilesAffected.map((file) => dirname(file)))];
31
+ const currentSet = new Set(currentFilesAffected);
32
+ const claimedByOthers = getClaimedFiles(manifest, patchFilename);
33
+ const candidates = new Set();
34
+ for (const dir of parentDirs) {
35
+ const [modifiedFiles, untrackedFiles] = await Promise.all([
36
+ getModifiedFilesInDir(engineDir, dir),
37
+ getUntrackedFilesInDir(engineDir, dir),
38
+ ]);
39
+ for (const file of [...modifiedFiles, ...untrackedFiles]) {
40
+ if (currentSet.has(file) || claimedByOthers.has(file))
41
+ continue;
42
+ candidates.add(file);
43
+ }
44
+ }
45
+ return [...candidates].sort();
46
+ }
47
+ async function warnPlainReExportFileDrift(args) {
48
+ const { patch, paths, manifest, currentFilesAffected } = args;
49
+ const missingFiles = await findMissingFiles(paths.engine, currentFilesAffected);
50
+ if (missingFiles.length > 0) {
51
+ warn(`${patch.filename}: some files in patches.json no longer exist on disk ` +
52
+ `(${missingFiles.join(', ')}). Without --scan, re-export keeps the manifest's ` +
53
+ `filesAffected unchanged and the missing entries will be preserved — ` +
54
+ `\`fireforge verify\` may flag manifest inconsistency after this run.\n` +
55
+ ` Re-run with --scan to reconcile filesAffected with the current worktree, ` +
56
+ `or pass --files <paths> to set the list explicitly.`);
57
+ }
58
+ const likelyNewFiles = await findLikelyNewSiblingFiles({
59
+ currentFilesAffected,
60
+ engineDir: paths.engine,
61
+ manifest,
62
+ patchFilename: patch.filename,
63
+ });
64
+ if (likelyNewFiles.length === 0)
65
+ return;
66
+ warn(`${patch.filename}: found ${likelyNewFiles.length} unowned changed sibling file${likelyNewFiles.length === 1 ? '' : 's'} near this patch. Plain re-export keeps filesAffected unchanged; add reviewed files explicitly with --scan-file.`);
67
+ for (const file of likelyNewFiles) {
68
+ info(` ${file} — fireforge re-export ${patch.filename} --scan --scan-file ${file}`);
69
+ }
70
+ }
27
71
  async function reExportSinglePatch(patch, paths, manifest, options, isDryRun, config) {
28
72
  let currentFilesAffected = [...patch.filesAffected];
29
73
  // --- Scan for new/removed files ---
@@ -61,15 +105,7 @@ async function reExportSinglePatch(patch, paths, manifest, options, isDryRun, co
61
105
  // warning up-front when we can detect the drift cheaply, so the
62
106
  // operator has a chance to re-run with `--scan` or `--files`
63
107
  // before the stale filesAffected lands in patches.json.
64
- const missingFiles = await findMissingFiles(paths.engine, currentFilesAffected);
65
- if (missingFiles.length > 0) {
66
- warn(`${patch.filename}: some files in patches.json no longer exist on disk ` +
67
- `(${missingFiles.join(', ')}). Without --scan, re-export keeps the manifest's ` +
68
- `filesAffected unchanged and the missing entries will be preserved — ` +
69
- `\`fireforge verify\` may flag manifest inconsistency after this run.\n` +
70
- ` Re-run with --scan to reconcile filesAffected with the current worktree, ` +
71
- `or pass --files <paths> to set the list explicitly.`);
72
- }
108
+ await warnPlainReExportFileDrift({ patch, paths, manifest, currentFilesAffected });
73
109
  }
74
110
  // --- Explicit file-subset path ---
75
111
  // When --files is given, the target filesAffected is authoritative — drop
@@ -370,7 +406,8 @@ export function registerReExport(program, { getProjectRoot, withErrorHandling })
370
406
  .filter((v) => v.length > 0))
371
407
  .option('--dry-run', 'Show what would change without writing')
372
408
  .option('--skip-lint', 'Skip patch lint checks (downgrade errors to warnings)')
373
- .option('-y, --yes', 'Skip confirmation when --files shrinks a patch (required for non-TTY)')
409
+ .option('--allow-shrink', 'Allow --files to remove paths currently owned by the patch. Required before --yes can bypass the shrink confirmation.')
410
+ .option('-y, --yes', 'Skip confirmation prompts (required for non-TTY destructive writes)')
374
411
  .option('--force-unsafe', 'Bypass cross-patch lint refusal when --files shrinks a patch')
375
412
  .option('--stamp', "After every selected patch refreshes cleanly, stamp each re-exported patch's sourceEsrVersion in patches.json to firefox.version from fireforge.json. No effect on a partial run.")
376
413
  .addOption(new Option('--tier <tier>', 'Force a tier override on the selected patch (only "branding" recognised). Mutually exclusive with --all.').choices(['branding']))
@@ -16,6 +16,8 @@
16
16
  */
17
17
  import { join } from 'node:path';
18
18
  import { getProjectPaths, loadConfig } from '../core/config.js';
19
+ import { isGitRepository } from '../core/git.js';
20
+ import { expandUntrackedDirectoryEntries, getWorkingTreeStatus } from '../core/git-status.js';
19
21
  import { buildPatchQueueContext, lintPatchQueue } from '../core/patch-lint.js';
20
22
  import { loadPatchesManifest, validatePatchesManifestConsistency } from '../core/patch-manifest.js';
21
23
  import { evaluatePatchPolicy } from '../core/patch-policy.js';
@@ -100,6 +102,15 @@ function detectCrossPatchFileClaims(manifestPatches) {
100
102
  }
101
103
  return results;
102
104
  }
105
+ async function detectUnownedWorktreeChanges(engineDir, claimedFiles) {
106
+ if (!(await pathExists(engineDir)) || !(await isGitRepository(engineDir))) {
107
+ return [];
108
+ }
109
+ const entries = await expandUntrackedDirectoryEntries(engineDir, await getWorkingTreeStatus(engineDir));
110
+ return [
111
+ ...new Set(entries.map((entry) => entry.file).filter((file) => !claimedFiles.has(file))),
112
+ ].sort();
113
+ }
103
114
  /**
104
115
  * Collects the same queue-health findings reported by `fireforge verify`
105
116
  * without printing. Used by doctor recovery paths that need a read-only
@@ -177,6 +188,22 @@ export async function collectPatchQueueHealth(projectRoot) {
177
188
  warningCount += lintWarnings;
178
189
  }
179
190
  if (manifest) {
191
+ const claimedFiles = new Set();
192
+ for (const patch of manifest.patches) {
193
+ for (const file of patch.filesAffected) {
194
+ claimedFiles.add(file);
195
+ }
196
+ }
197
+ const unownedWorktreeChanges = await detectUnownedWorktreeChanges(paths.engine, claimedFiles);
198
+ if (unownedWorktreeChanges.length > 0) {
199
+ groups.push({
200
+ title: `Unowned worktree changes (${unownedWorktreeChanges.length})`,
201
+ issues: unownedWorktreeChanges.map((file) => `${file} is changed in engine/ but is not listed in any patch filesAffected entry`),
202
+ errorCount: 0,
203
+ warningCount: unownedWorktreeChanges.length,
204
+ });
205
+ warningCount += unownedWorktreeChanges.length;
206
+ }
180
207
  const registrationIssues = await detectDanglingRegistrations(paths.patches, paths.engine, manifest.patches);
181
208
  if (registrationIssues.length > 0) {
182
209
  groups.push({
@@ -54,6 +54,11 @@ export class BrandingMozconfigMismatchError extends FireForgeError {
54
54
  }
55
55
  }
56
56
  const MOZ_APP_VENDOR_IMPLY_REGEX = /imply_option\("MOZ_APP_VENDOR",\s*"[^"]*"\)/;
57
+ const BRANDING_CONFIGURE_MANAGED_KEYS = new Set([
58
+ 'MOZ_APP_DISPLAYNAME',
59
+ 'MOZ_APP_VENDOR',
60
+ 'MOZ_MACBUNDLE_ID',
61
+ ]);
57
62
  /**
58
63
  * Sets up the custom branding directory for the browser.
59
64
  *
@@ -91,16 +96,58 @@ export async function setupBranding(engineDir, config) {
91
96
  */
92
97
  async function createConfigureScript(brandingDir, config, vendorPlacement) {
93
98
  const configureShPath = join(brandingDir, 'configure.sh');
94
- await writeTextIfChanged(configureShPath, buildConfigureScriptContent(config, vendorPlacement));
99
+ const existing = (await pathExists(configureShPath))
100
+ ? await readText(configureShPath)
101
+ : undefined;
102
+ await writeTextIfChanged(configureShPath, buildConfigureScriptContent(config, vendorPlacement, existing));
95
103
  }
96
- function buildConfigureScriptContent(config, vendorPlacement) {
104
+ function buildConfigureScriptContent(config, vendorPlacement, existingContent) {
97
105
  const header = getLicenseHeader(config.license ?? DEFAULT_LICENSE, 'hash');
98
- const lines = [`MOZ_APP_DISPLAYNAME="${escapeShellValue(config.name)}"`];
106
+ const managedLines = [`MOZ_APP_DISPLAYNAME="${escapeShellValue(config.name)}"`];
107
+ if (vendorPlacement === 'branding-configure') {
108
+ managedLines.push(`MOZ_APP_VENDOR="${escapeShellValue(config.vendor)}"`);
109
+ }
110
+ managedLines.push(`MOZ_MACBUNDLE_ID="${escapeShellValue(config.appId)}"`);
111
+ const preservedLines = existingContent ? extractPreservedConfigureLines(existingContent) : [];
112
+ const body = [...managedLines, ...preservedLines].join('\n');
113
+ return `${header}\n\n${body}\n`;
114
+ }
115
+ function extractPreservedConfigureLines(content) {
116
+ return content.split(/\r?\n/).filter((line) => {
117
+ const trimmed = line.trim();
118
+ if (trimmed.length === 0)
119
+ return false;
120
+ if (/^#\s*SPDX-License-Identifier:/i.test(trimmed))
121
+ return false;
122
+ const keyMatch = /^([A-Za-z_][A-Za-z0-9_]*)=/.exec(trimmed);
123
+ if (keyMatch && BRANDING_CONFIGURE_MANAGED_KEYS.has(keyMatch[1] ?? ''))
124
+ return false;
125
+ return true;
126
+ });
127
+ }
128
+ function parseConfigureAssignments(content) {
129
+ const assignments = new Map();
130
+ for (const line of content.split(/\r?\n/)) {
131
+ const match = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line.trim());
132
+ if (match?.[1] && match[2] !== undefined) {
133
+ assignments.set(match[1], match[2]);
134
+ }
135
+ }
136
+ return assignments;
137
+ }
138
+ function isConfigureScriptCurrent(content, config, vendorPlacement) {
139
+ const assignments = parseConfigureAssignments(content);
140
+ if (assignments.get('MOZ_APP_DISPLAYNAME') !== `"${escapeShellValue(config.name)}"`) {
141
+ return false;
142
+ }
143
+ if (assignments.get('MOZ_MACBUNDLE_ID') !== `"${escapeShellValue(config.appId)}"`) {
144
+ return false;
145
+ }
146
+ const vendorValue = assignments.get('MOZ_APP_VENDOR');
99
147
  if (vendorPlacement === 'branding-configure') {
100
- lines.push(`MOZ_APP_VENDOR="${escapeShellValue(config.vendor)}"`);
148
+ return vendorValue === `"${escapeShellValue(config.vendor)}"`;
101
149
  }
102
- lines.push(`MOZ_MACBUNDLE_ID="${escapeShellValue(config.appId)}"`);
103
- return `${header}\n\n${lines.join('\n')}\n`;
150
+ return vendorValue === undefined;
104
151
  }
105
152
  /**
106
153
  * Updates the brand.properties localization file.
@@ -270,7 +317,7 @@ export async function isBrandingSetup(engineDir, config) {
270
317
  }
271
318
  const vendorPlacement = await resolveVendorPlacement(engineDir);
272
319
  const configureContent = await readText(configureShPath);
273
- if (configureContent !== buildConfigureScriptContent(config, vendorPlacement)) {
320
+ if (!isConfigureScriptCurrent(configureContent, config, vendorPlacement)) {
274
321
  return false;
275
322
  }
276
323
  if (await pathExists(propsPath)) {
@@ -81,6 +81,30 @@ function isInsideDOMContentLoaded(ancestors, content) {
81
81
  }
82
82
  return false;
83
83
  }
84
+ function collectArrayDeclarations(ast) {
85
+ const arrays = new Map();
86
+ walkAST(ast, {
87
+ enter(node) {
88
+ if (node.type !== 'VariableDeclarator')
89
+ return;
90
+ const declarator = node;
91
+ if (declarator.id.type !== 'Identifier' || declarator.init?.type !== 'ArrayExpression') {
92
+ return;
93
+ }
94
+ arrays.set(declarator.id.name, declarator.init);
95
+ },
96
+ });
97
+ return arrays;
98
+ }
99
+ function resolveForOfArray(right, declaredArrays) {
100
+ if (right.type === 'ArrayExpression') {
101
+ return right;
102
+ }
103
+ if (right.type === 'Identifier') {
104
+ return declaredArrays.get(right.name);
105
+ }
106
+ return undefined;
107
+ }
84
108
  function selectRegistrationTarget(targets, isESModule, tagName) {
85
109
  const target = isESModule
86
110
  ? targets.find((candidate) => candidate.insideDCL)
@@ -116,6 +140,7 @@ function buildRegistrationEntry(referenceEntry, tagName, modulePath, markerComme
116
140
  function addRegistrationAST(content, tagName, modulePath, isESModule, markerComment) {
117
141
  validateTagName(tagName);
118
142
  const ast = parseScript(content);
143
+ const declaredArrays = collectArrayDeclarations(ast);
119
144
  const ancestors = [];
120
145
  // Collect all ForOfStatement nodes with ArrayExpression rights
121
146
  const forOfs = [];
@@ -124,8 +149,8 @@ function addRegistrationAST(content, tagName, modulePath, isESModule, markerComm
124
149
  ancestors.push(node);
125
150
  if (node.type === 'ForOfStatement') {
126
151
  const forOf = node;
127
- if (forOf.right.type === 'ArrayExpression') {
128
- const array = forOf.right;
152
+ const array = resolveForOfArray(forOf.right, declaredArrays);
153
+ if (array) {
129
154
  forOfs.push({
130
155
  array,
131
156
  insideDCL: isInsideDOMContentLoaded(ancestors, content),
@@ -262,6 +287,9 @@ function addRegistrationRegexFallback(content, tagName, modulePath, isESModule,
262
287
  const insertPos = firstMatch.index;
263
288
  return content.slice(0, insertPos) + newEntry + '\n' + content.slice(insertPos);
264
289
  }
290
+ function hasRecognizableRegistrationLoop(content) {
291
+ return /for\s*\(\s*(?:let|const|var)\s*\[[^)]*\]\s+of\s+(?:\[|[A-Za-z_$][\w$]*)/.test(content);
292
+ }
265
293
  /**
266
294
  * Adds a custom element registration entry to customElements.js.
267
295
  *
@@ -303,7 +331,7 @@ export async function addCustomElementRegistration(engineDir, tagName, modulePat
303
331
  // assumption is violated the AST path errors with a confusing
304
332
  // "Could not find DOMContentLoaded block" message — fail fast here with
305
333
  // actionable guidance instead.
306
- if (!/for\s*\(\s*(?:let|const|var)\s*\[/.test(content)) {
334
+ if (!hasRecognizableRegistrationLoop(content)) {
307
335
  throw new FurnaceError(`${CUSTOM_ELEMENTS_JS} does not contain a recognizable registration loop; refusing to mutate. ` +
308
336
  'Run "fireforge reset --force" to restore the engine, or inspect the file manually.', tagName);
309
337
  }
@@ -359,7 +387,7 @@ export async function validateCustomElementRegistration(engineDir, tagName, modu
359
387
  return;
360
388
  }
361
389
  const isESModule = modulePath.endsWith('.mjs');
362
- if (!/for\s*\(\s*(?:let|const|var)\s*\[/.test(content)) {
390
+ if (!hasRecognizableRegistrationLoop(content)) {
363
391
  throw new FurnaceError(`${CUSTOM_ELEMENTS_JS} does not contain a recognizable registration loop; refusing to mutate. ` +
364
392
  'Run "fireforge reset --force" to restore the engine, or inspect the file manually.', tagName);
365
393
  }
@@ -42,11 +42,36 @@ export function validateRegistrationPlacement(result, tagName, isESModule) {
42
42
  return;
43
43
  const contentBeforeTag = result.slice(0, insertedPos);
44
44
  const hasDCLBefore = dclPattern.test(contentBeforeTag);
45
- if (isESModule && !hasDCLBefore) {
45
+ if (isESModule && !hasDCLBefore && !isTagInArrayConsumedInsideDOMContentLoaded(result, tagName)) {
46
46
  throw new FurnaceError(`${tagName} was registered in the loadSubScript block (Pattern A) instead of the DOMContentLoaded/importESModule block (Pattern B). This will cause the component to fail at runtime. The customElements.js file structure may have changed upstream — manual intervention required.`, tagName);
47
47
  }
48
48
  if (!isESModule && hasDCLBefore) {
49
49
  throw new FurnaceError(`${tagName} was registered in the DOMContentLoaded/importESModule block (Pattern B) instead of the loadSubScript block (Pattern A). This will cause the component to fail at runtime. The customElements.js file structure may have changed upstream — manual intervention required.`, tagName);
50
50
  }
51
51
  }
52
+ function isTagInArrayConsumedInsideDOMContentLoaded(content, tagName) {
53
+ const dclMatch = /document\.addEventListener\(\s*["']DOMContentLoaded["']/.exec(content);
54
+ if (!dclMatch)
55
+ return false;
56
+ const beforeDcl = content.slice(0, dclMatch.index);
57
+ const afterDcl = content.slice(dclMatch.index);
58
+ const consumedArrays = new Set();
59
+ const forOfPattern = /for\s*\(\s*(?:let|const|var)\s*\[[^)]*\]\s+of\s+([A-Za-z_$][\w$]*)\s*\)/g;
60
+ let match;
61
+ while ((match = forOfPattern.exec(afterDcl)) !== null) {
62
+ if (match[1])
63
+ consumedArrays.add(match[1]);
64
+ }
65
+ for (const arrayName of consumedArrays) {
66
+ const declarationPattern = new RegExp(`(?:const|let|var)\\s+${escapeRegex(arrayName)}\\s*=\\s*\\[([\\s\\S]*?)\\];`);
67
+ const declaration = declarationPattern.exec(beforeDcl);
68
+ if (declaration?.[1] && new RegExp(`["']${escapeRegex(tagName)}["']`).test(declaration[1])) {
69
+ return true;
70
+ }
71
+ }
72
+ return false;
73
+ }
74
+ function escapeRegex(value) {
75
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
76
+ }
52
77
  //# sourceMappingURL=furnace-registration-validate.js.map
@@ -40,7 +40,8 @@ export async function validateRegistrationPatterns(root, config) {
40
40
  // Check if this tag is referenced before the DOMContentLoaded block
41
41
  const contentBeforeDCL = stripJsComments(content.slice(0, domContentLoadedIdx));
42
42
  const tagPattern = new RegExp(`"${name}"`);
43
- if (tagPattern.test(contentBeforeDCL)) {
43
+ if (tagPattern.test(contentBeforeDCL) &&
44
+ !isArrayDeclaredBeforeDclAndConsumedInsideDcl(content, domContentLoadedIdx, name)) {
44
45
  issues.push({
45
46
  component: name,
46
47
  severity: 'error',
@@ -51,6 +52,28 @@ export async function validateRegistrationPatterns(root, config) {
51
52
  }
52
53
  return issues;
53
54
  }
55
+ function isArrayDeclaredBeforeDclAndConsumedInsideDcl(content, domContentLoadedIdx, tagName) {
56
+ const contentBeforeDCL = stripJsComments(content.slice(0, domContentLoadedIdx));
57
+ const contentAfterDCL = stripJsComments(content.slice(domContentLoadedIdx));
58
+ const consumedArrays = new Set();
59
+ const forOfPattern = /for\s*\(\s*(?:let|const|var)\s*\[[^)]*\]\s+of\s+([A-Za-z_$][\w$]*)\s*\)/g;
60
+ let match;
61
+ while ((match = forOfPattern.exec(contentAfterDCL)) !== null) {
62
+ if (match[1])
63
+ consumedArrays.add(match[1]);
64
+ }
65
+ for (const arrayName of consumedArrays) {
66
+ const declarationPattern = new RegExp(`(?:const|let|var)\\s+${escapeRegex(arrayName)}\\s*=\\s*\\[([\\s\\S]*?)\\];`);
67
+ const declaration = declarationPattern.exec(contentBeforeDCL);
68
+ if (declaration?.[1] && new RegExp(`["']${escapeRegex(tagName)}["']`).test(declaration[1])) {
69
+ return true;
70
+ }
71
+ }
72
+ return false;
73
+ }
74
+ function escapeRegex(value) {
75
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
76
+ }
54
77
  /**
55
78
  * Checks registration consistency for a single custom component.
56
79
  *
@@ -105,12 +105,8 @@ async function isPathIgnored(dir, relativePath) {
105
105
  * saw. The typed error carries the environment-variable override so the
106
106
  * operator can extend the budget and re-run.
107
107
  */
108
- async function stageAllFilesChunked(dir, options = {}) {
109
- const entries = await readdir(dir, { withFileTypes: true });
110
- const directories = entries
111
- .filter((e) => e.isDirectory() && e.name !== '.git')
112
- .map((e) => e.name)
113
- .sort();
108
+ async function stageAllFilesChunked(dir, scan, options = {}) {
109
+ const { directories, topLevelFiles: topLevelCandidates } = scan;
114
110
  async function runChunk(args, label) {
115
111
  try {
116
112
  await git(args, dir, {
@@ -126,29 +122,30 @@ async function stageAllFilesChunked(dir, options = {}) {
126
122
  throw error;
127
123
  }
128
124
  }
125
+ let stagedDirectories = 0;
129
126
  for (const dirName of directories) {
127
+ stagedDirectories++;
130
128
  if (await isPathIgnored(dir, dirName)) {
131
- options.onProgress?.(`Skipping gitignored: ${dirName}/`);
129
+ options.onProgress?.(`Skipping gitignored directory ${stagedDirectories}/${directories.length}: ${dirName}/`);
132
130
  continue;
133
131
  }
134
- options.onProgress?.(`Staging directory: ${dirName}/...`);
132
+ options.onProgress?.(`Staging directory ${stagedDirectories}/${directories.length}: ${dirName}/...`);
135
133
  await runChunk(['add', '--', dirName], dirName);
136
134
  }
137
135
  // Stage any top-level files (excluding gitignored ones — `git add`
138
136
  // on an explicit ignored path errors out, which would otherwise
139
137
  // abort the chunked fallback after the monolithic path has already
140
138
  // timed out).
141
- const topLevelCandidates = entries.filter((e) => e.isFile()).map((e) => e.name);
142
139
  const topLevelFiles = [];
143
140
  for (const name of topLevelCandidates) {
144
141
  if (await isPathIgnored(dir, name)) {
145
- options.onProgress?.(`Skipping gitignored: ${name}`);
142
+ options.onProgress?.(`Skipping gitignored top-level file: ${name}`);
146
143
  continue;
147
144
  }
148
145
  topLevelFiles.push(name);
149
146
  }
150
147
  if (topLevelFiles.length > 0) {
151
- options.onProgress?.('Staging top-level files...');
148
+ options.onProgress?.(`Staging ${topLevelFiles.length} top-level file(s)...`);
152
149
  await runChunk(['add', '--', ...topLevelFiles], 'top-level files');
153
150
  }
154
151
  }
@@ -161,6 +158,19 @@ async function stageAllFilesChunked(dir, options = {}) {
161
158
  * SIGINT'd mid-way assuming the process had stalled.
162
159
  */
163
160
  const GIT_ADD_HEARTBEAT_MS = 15_000;
161
+ async function scanTopLevelSource(dir) {
162
+ const entries = await readdir(dir, { withFileTypes: true });
163
+ return {
164
+ directories: entries
165
+ .filter((entry) => entry.isDirectory() && entry.name !== '.git')
166
+ .map((entry) => entry.name)
167
+ .sort(),
168
+ topLevelFiles: entries
169
+ .filter((entry) => entry.isFile())
170
+ .map((entry) => entry.name)
171
+ .sort(),
172
+ };
173
+ }
164
174
  /**
165
175
  * Stages all files in the repository.
166
176
  * Tries a monolithic `git add -A` first; if that times out, falls back to
@@ -169,6 +179,9 @@ const GIT_ADD_HEARTBEAT_MS = 15_000;
169
179
  export async function stageAllFiles(dir, options = {}) {
170
180
  const timeout = options.timeout ?? GIT_ADD_TIMEOUT_MS;
171
181
  const reportProgress = options.onProgress;
182
+ reportProgress?.('Scanning Firefox source tree before indexing...');
183
+ const scan = await scanTopLevelSource(dir);
184
+ reportProgress?.(`Source scan complete: ${scan.directories.length} top-level director${scan.directories.length === 1 ? 'y' : 'ies'}, ${scan.topLevelFiles.length} top-level file${scan.topLevelFiles.length === 1 ? '' : 's'}`);
172
185
  // 2026-04-26 eval Finding 5: the pre-fix heartbeat used a single
173
186
  // `heartbeatStartedAt` set at function entry and reported cumulative
174
187
  // elapsed for the whole `stageAllFiles` invocation. After a
@@ -191,7 +204,9 @@ export async function stageAllFiles(dir, options = {}) {
191
204
  heartbeatTimer?.unref();
192
205
  try {
193
206
  try {
207
+ reportProgress?.(`Starting monolithic git add -A for ${scan.directories.length} director${scan.directories.length === 1 ? 'y' : 'ies'} and ${scan.topLevelFiles.length} top-level file${scan.topLevelFiles.length === 1 ? '' : 's'}...`);
194
208
  await git(['add', '-A'], dir, { timeout, env: GIT_ADD_ENV });
209
+ reportProgress?.('Monolithic git add -A completed.');
195
210
  return;
196
211
  }
197
212
  catch (error) {
@@ -215,7 +230,7 @@ export async function stageAllFiles(dir, options = {}) {
215
230
  phase = 'chunked';
216
231
  phaseStartedAt = Date.now();
217
232
  try {
218
- await stageAllFilesChunked(dir, options);
233
+ await stageAllFilesChunked(dir, scan, options);
219
234
  }
220
235
  catch (error) {
221
236
  if (error instanceof GitIndexingTimeoutError)
@@ -25,6 +25,14 @@ function getPrimaryStatusCode(status) {
25
25
  }
26
26
  return status;
27
27
  }
28
+ function isGeneratedBrandingPath(file, binaryName) {
29
+ const normalized = file.replace(/\\/g, '/');
30
+ const brandingRoot = `browser/branding/${binaryName}`;
31
+ return (normalized === 'browser/moz.configure' ||
32
+ normalized === `${brandingRoot}/configure.sh` ||
33
+ normalized === `${brandingRoot}/locales/en-US/brand.properties` ||
34
+ normalized === `${brandingRoot}/locales/en-US/brand.ftl`);
35
+ }
28
36
  /**
29
37
  * Classifies files into patch-backed, unmanaged, branding, furnace, or
30
38
  * conflict buckets.
@@ -63,8 +71,17 @@ export async function classifyFiles(files, engineDir, patchesDir, binaryName, fu
63
71
  }
64
72
  const results = [];
65
73
  for (const entry of files) {
66
- // Branding check first
67
- if (isBrandingManagedPath(entry.file, binaryName)) {
74
+ const owners = patchClaims.get(entry.file);
75
+ const primaryCode = getPrimaryStatusCode(entry.status);
76
+ // Branding paths are tool-managed for generated edits, but a brand-new
77
+ // unowned branding asset must not disappear from `status --unmanaged`.
78
+ // The Hominis Firefox 152 side-grade added Assets.car under the active
79
+ // branding tree; classifying every branding path before checking
80
+ // ownership hid that new patch candidate as "branding" even though no
81
+ // patch claimed it yet.
82
+ const isUnownedNewFile = owners === undefined && (primaryCode === '?' || primaryCode === 'A');
83
+ if (isBrandingManagedPath(entry.file, binaryName) &&
84
+ (!isUnownedNewFile || isGeneratedBrandingPath(entry.file, binaryName))) {
68
85
  results.push({ ...entry, classification: 'branding' });
69
86
  continue;
70
87
  }
@@ -82,7 +99,6 @@ export async function classifyFiles(files, engineDir, patchesDir, binaryName, fu
82
99
  continue;
83
100
  }
84
101
  }
85
- const owners = patchClaims.get(entry.file);
86
102
  // Multiple patches claim this file — surface the cross-patch
87
103
  // ownership conflict regardless of whether the current content
88
104
  // matches any single claim. `--ownership` reports the same state
@@ -102,7 +118,6 @@ export async function classifyFiles(files, engineDir, patchesDir, binaryName, fu
102
118
  continue;
103
119
  }
104
120
  // File is claimed by exactly one patch — compare content.
105
- const primaryCode = getPrimaryStatusCode(entry.status);
106
121
  if (primaryCode === 'D') {
107
122
  // Deleted file: patch-backed only if patch expects deletion
108
123
  const expected = await computePatchedContent(patchesDir, engineDir, entry.file);
@@ -184,6 +184,12 @@ export interface ReExportOptions {
184
184
  skipLint?: boolean;
185
185
  /** Skip confirmation prompt on shrink (required for non-TTY) */
186
186
  yes?: boolean;
187
+ /**
188
+ * Explicitly allow `--files` to remove paths that are currently owned by
189
+ * the patch. Without this acknowledgement, non-dry-run shrinks are refused
190
+ * before the interactive/`--yes` confirmation path.
191
+ */
192
+ allowShrink?: boolean;
187
193
  /** Bypass cross-patch lint refusal on projected shrink state */
188
194
  forceUnsafe?: boolean;
189
195
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hominis/fireforge",
3
- "version": "0.26.0",
3
+ "version": "0.27.0",
4
4
  "description": "FireForge — a build tool for customizing Firefox",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",
@@ -53,7 +53,7 @@
53
53
  "dependencies": {
54
54
  "@clack/prompts": "^1.2.0",
55
55
  "acorn": "^8.14.0",
56
- "commander": "^14.0.0",
56
+ "commander": "^15.0.0",
57
57
  "estree-walker": "^3.0.3",
58
58
  "magic-string": "^0.30.17",
59
59
  "picocolors": "^1.1.0"