@hominis/fireforge 0.25.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,19 @@
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
+
11
+ ## 0.26.0
12
+
13
+ - Added targeted `re-export --scan --scan-file <path>` for reviewed single-patch new-file assignment without broad sibling collection.
14
+ - Added a FireForge-owned worktree whitespace gate that excludes generated `patches/*.patch` diff syntax from repository whitespace checks.
15
+ - Kept generated patch context lines unchanged while making release checks use the FireForge whitespace gate.
16
+
3
17
  ## 0.25.0
4
18
 
5
19
  - Kept `MOZ_APP_VENDOR` in `browser/moz.configure` for Firefox ESR 140 project-flag trees instead of generated branding `configure.sh`.
@@ -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') {
@@ -0,0 +1,35 @@
1
+ import type { PatchesManifest } from '../types/commands/index.js';
2
+ export interface ScanResult {
3
+ updated: string[];
4
+ added: string[];
5
+ removed: string[];
6
+ }
7
+ /** Normalizes repeatable `--scan-file` inputs into safe engine-relative paths. */
8
+ export declare function normalizeScanFiles(scanFiles: readonly string[] | undefined): string[] | undefined;
9
+ /** Scans either broad sibling directories or explicit `--scan-file` paths for re-export. */
10
+ export declare function scanPatchFilesForReExport(args: {
11
+ currentFilesAffected: string[];
12
+ engineDir: string;
13
+ manifest: PatchesManifest;
14
+ patchFilename: string;
15
+ isDryRun: boolean;
16
+ scanFiles?: readonly string[];
17
+ }): Promise<ScanResult>;
18
+ /**
19
+ * Confirms broad directory-scan additions before a mutating re-export writes
20
+ * them into patch ownership.
21
+ */
22
+ export declare function confirmBroadScanAdditions(args: {
23
+ patchFilename: string;
24
+ added: readonly string[];
25
+ isDryRun: boolean;
26
+ yes: boolean;
27
+ isInteractive: boolean;
28
+ }): Promise<boolean>;
29
+ /** Refuses explicit `--scan-file` additions that did not produce patch hunks. */
30
+ export declare function assertScanFileAdditionsHaveDiffHunks(args: {
31
+ diffContent: string;
32
+ patchFilename: string;
33
+ previousFilesAffected: readonly string[];
34
+ scanFiles: readonly string[] | undefined;
35
+ }): void;
@@ -0,0 +1,139 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ import { dirname, join } from 'node:path';
3
+ import { confirm } from '@clack/prompts';
4
+ import { getModifiedFilesInDir, getUntrackedFilesInDir } from '../core/git-status.js';
5
+ import { extractAffectedFiles } from '../core/patch-apply.js';
6
+ import { getClaimedFiles } from '../core/patch-manifest.js';
7
+ import { GeneralError, InvalidArgumentError } from '../errors/base.js';
8
+ import { pathExists } from '../utils/fs.js';
9
+ import { cancel, info, isCancel, warn } from '../utils/logger.js';
10
+ import { isContainedRelativePath, normalizePathSlashes, stripEnginePrefix, } from '../utils/paths.js';
11
+ const SCAN_ADD_COUNT_THRESHOLD = 3;
12
+ const SCAN_DIR_COUNT_THRESHOLD = 2;
13
+ /** Normalizes repeatable `--scan-file` inputs into safe engine-relative paths. */
14
+ export function normalizeScanFiles(scanFiles) {
15
+ const normalized = [...new Set(scanFiles ?? [])]
16
+ .map((file) => normalizeEngineRelativeInput(file, '--scan-file'))
17
+ .sort();
18
+ return normalized.length > 0 ? normalized : undefined;
19
+ }
20
+ function normalizeEngineRelativeInput(rawPath, flagName) {
21
+ const normalized = normalizePathSlashes(stripEnginePrefix(rawPath).trim());
22
+ if (normalized.length === 0) {
23
+ throw new InvalidArgumentError(`${flagName} requires a non-empty engine-relative path.`, flagName);
24
+ }
25
+ if (!isContainedRelativePath(normalized)) {
26
+ throw new InvalidArgumentError(`${flagName} path must stay within engine/: ${rawPath}`, flagName);
27
+ }
28
+ return normalized;
29
+ }
30
+ /** Scans either broad sibling directories or explicit `--scan-file` paths for re-export. */
31
+ export async function scanPatchFilesForReExport(args) {
32
+ const { scanFiles } = args;
33
+ if (scanFiles !== undefined) {
34
+ return scanPatchFilesTargeted({ ...args, scanFiles });
35
+ }
36
+ return scanPatchFiles(args);
37
+ }
38
+ async function scanPatchFiles(args) {
39
+ const { currentFilesAffected, engineDir, manifest, patchFilename, isDryRun } = args;
40
+ const parentDirs = [...new Set(currentFilesAffected.map((f) => dirname(f)))];
41
+ const claimedByOthers = getClaimedFiles(manifest, patchFilename);
42
+ const discoveredFiles = new Set();
43
+ for (const dir of parentDirs) {
44
+ const modifiedFiles = await getModifiedFilesInDir(engineDir, dir);
45
+ const untrackedFiles = await getUntrackedFilesInDir(engineDir, dir);
46
+ for (const f of [...modifiedFiles, ...untrackedFiles])
47
+ discoveredFiles.add(f);
48
+ }
49
+ const currentSet = new Set(currentFilesAffected);
50
+ const added = [...discoveredFiles]
51
+ .filter((f) => !currentSet.has(f) && !claimedByOthers.has(f))
52
+ .sort();
53
+ const removed = await findRemovedFiles(currentFilesAffected, engineDir);
54
+ return reportScanResult(currentFilesAffected, patchFilename, isDryRun, added, removed);
55
+ }
56
+ async function scanPatchFilesTargeted(args) {
57
+ const { currentFilesAffected, engineDir, manifest, patchFilename, isDryRun, scanFiles } = args;
58
+ const currentSet = new Set(currentFilesAffected);
59
+ const claimedByOthers = getClaimedFiles(manifest, patchFilename);
60
+ const added = [];
61
+ for (const file of scanFiles) {
62
+ if (claimedByOthers.has(file)) {
63
+ throw new InvalidArgumentError(`--scan-file path is already claimed by another patch: ${file}`, '--scan-file');
64
+ }
65
+ if (!(await pathExists(join(engineDir, file)))) {
66
+ throw new InvalidArgumentError(`--scan-file path not found in engine/: ${file}`, '--scan-file');
67
+ }
68
+ if (!currentSet.has(file))
69
+ added.push(file);
70
+ }
71
+ const removed = await findRemovedFiles(currentFilesAffected, engineDir);
72
+ return reportScanResult(currentFilesAffected, patchFilename, isDryRun, added.sort(), removed);
73
+ }
74
+ async function findRemovedFiles(files, engineDir) {
75
+ const removed = [];
76
+ for (const file of files) {
77
+ if (!(await pathExists(join(engineDir, file))))
78
+ removed.push(file);
79
+ }
80
+ return removed.sort();
81
+ }
82
+ function reportScanResult(currentFilesAffected, patchFilename, isDryRun, added, removed) {
83
+ for (const f of added)
84
+ info(` + ${f}`);
85
+ for (const f of removed)
86
+ info(` - ${f}`);
87
+ if (added.length === 0 && removed.length === 0) {
88
+ return { updated: currentFilesAffected, added: [], removed: [] };
89
+ }
90
+ const removedSet = new Set(removed);
91
+ const updated = [...currentFilesAffected.filter((f) => !removedSet.has(f)), ...added].sort();
92
+ info(` ${isDryRun ? 'Would update' : 'Updated'} ${patchFilename}: +${added.length} / -${removed.length} files`);
93
+ return { updated, added, removed };
94
+ }
95
+ /**
96
+ * Confirms broad directory-scan additions before a mutating re-export writes
97
+ * them into patch ownership.
98
+ */
99
+ export async function confirmBroadScanAdditions(args) {
100
+ const { patchFilename, added, isDryRun, yes, isInteractive } = args;
101
+ if (isDryRun || yes || !scanAdditionsNeedConfirmation(added))
102
+ return true;
103
+ const dirCount = new Set(added.map((f) => dirname(f))).size;
104
+ warn(`${patchFilename}: --scan would add ${String(added.length)} file(s) that span ${String(dirCount)} director${dirCount === 1 ? 'y' : 'ies'}. ` +
105
+ 'Broad scans can silently pull adjacent features into a patch — review the diff before continuing.');
106
+ if (!isInteractive) {
107
+ throw new GeneralError(`Refusing to broaden "${patchFilename}" via --scan in non-interactive mode. ` +
108
+ 'Pass --yes to acknowledge the expansion, or run with --dry-run first to review.');
109
+ }
110
+ const confirmed = await confirm({
111
+ message: `Proceed and broaden ${patchFilename} with ${String(added.length)} newly discovered file(s)?`,
112
+ initialValue: false,
113
+ });
114
+ if (isCancel(confirmed) || !confirmed) {
115
+ cancel(`Skipped ${patchFilename}`);
116
+ return false;
117
+ }
118
+ return true;
119
+ }
120
+ function scanAdditionsNeedConfirmation(added) {
121
+ if (added.length === 0)
122
+ return false;
123
+ if (added.length > SCAN_ADD_COUNT_THRESHOLD)
124
+ return true;
125
+ return new Set(added.map((f) => dirname(f))).size >= SCAN_DIR_COUNT_THRESHOLD;
126
+ }
127
+ /** Refuses explicit `--scan-file` additions that did not produce patch hunks. */
128
+ export function assertScanFileAdditionsHaveDiffHunks(args) {
129
+ const { diffContent, patchFilename, previousFilesAffected, scanFiles } = args;
130
+ if (scanFiles === undefined)
131
+ return;
132
+ const previous = new Set(previousFilesAffected);
133
+ const diffFiles = new Set(extractAffectedFiles(diffContent));
134
+ const noDiffScanFiles = scanFiles.filter((file) => !previous.has(file) && !diffFiles.has(file));
135
+ if (noDiffScanFiles.length === 0)
136
+ return;
137
+ throw new InvalidArgumentError(`Refusing to re-export ${patchFilename} with --scan-file because ${noDiffScanFiles.length} explicit added path${noDiffScanFiles.length === 1 ? '' : 's'} produced no diff hunks (${noDiffScanFiles.join(', ')}). Remove unchanged paths or modify them before retrying.`, '--scan-file');
138
+ }
139
+ //# sourceMappingURL=re-export-scan.js.map
@@ -1,6 +1,6 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  import { dirname, join } from 'node:path';
3
- import { confirm, multiselect } from '@clack/prompts';
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';
@@ -13,124 +13,85 @@ import { GeneralError, InvalidArgumentError } from '../errors/base.js';
13
13
  import { toError } from '../utils/errors.js';
14
14
  import { pathExists } from '../utils/fs.js';
15
15
  import { cancel, info, intro, isCancel, outro, spinner, success, warn } from '../utils/logger.js';
16
- /**
17
- * Threshold above which `--scan` must be explicitly confirmed. Values were
18
- * picked so the common "refresh after one-or-two-file tweak" case stays
19
- * frictionless while catching the eval finding #13 scenario where `--scan`
20
- * silently pulled in an entire sibling feature (xhtml + tests + theme CSS).
21
- */
22
- const SCAN_ADD_COUNT_THRESHOLD = 3;
23
- const SCAN_DIR_COUNT_THRESHOLD = 2;
24
16
  import { pickDefined } from '../utils/options.js';
25
17
  import { runPatchLint } from './export-shared.js';
26
18
  import { reExportFilesInPlace } from './re-export-files.js';
27
- async function scanPatchFiles(currentFilesAffected, engineDir, manifest, patchFilename, isDryRun) {
28
- const parentDirs = [...new Set(currentFilesAffected.map((f) => dirname(f)))];
29
- const claimedByOthers = getClaimedFiles(manifest, patchFilename);
30
- const discoveredFiles = new Set();
31
- for (const dir of parentDirs) {
32
- const modifiedFiles = await getModifiedFilesInDir(engineDir, dir);
33
- const untrackedFiles = await getUntrackedFilesInDir(engineDir, dir);
34
- for (const f of [...modifiedFiles, ...untrackedFiles]) {
35
- discoveredFiles.add(f);
36
- }
19
+ import { assertScanFileAdditionsHaveDiffHunks, confirmBroadScanAdditions, normalizeScanFiles, scanPatchFilesForReExport, } from './re-export-scan.js';
20
+ async function findMissingFiles(engineDir, files) {
21
+ const missingFiles = [];
22
+ for (const file of files) {
23
+ if (!(await pathExists(join(engineDir, file))))
24
+ missingFiles.push(file);
37
25
  }
26
+ return missingFiles;
27
+ }
28
+ async function findLikelyNewSiblingFiles(args) {
29
+ const { currentFilesAffected, engineDir, manifest, patchFilename } = args;
30
+ const parentDirs = [...new Set(currentFilesAffected.map((file) => dirname(file)))];
38
31
  const currentSet = new Set(currentFilesAffected);
39
- const added = [];
40
- for (const f of discoveredFiles) {
41
- if (!currentSet.has(f) && !claimedByOthers.has(f)) {
42
- added.push(f);
43
- }
44
- }
45
- const removed = [];
46
- for (const f of currentFilesAffected) {
47
- const filePath = join(engineDir, f);
48
- if (!(await pathExists(filePath))) {
49
- removed.push(f);
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);
50
43
  }
51
44
  }
52
- const sortedAdded = [...added].sort();
53
- const sortedRemoved = [...removed].sort();
54
- for (const f of sortedAdded) {
55
- info(` + ${f}`);
56
- }
57
- for (const f of sortedRemoved) {
58
- info(` - ${f}`);
59
- }
60
- if (added.length > 0 || removed.length > 0) {
61
- const removedSet = new Set(removed);
62
- const updated = [...currentFilesAffected.filter((f) => !removedSet.has(f)), ...added].sort();
63
- info(` ${isDryRun ? 'Would update' : 'Updated'} ${patchFilename}: +${added.length} / -${removed.length} files`);
64
- return { updated, added: sortedAdded, removed: sortedRemoved };
65
- }
66
- return { updated: currentFilesAffected, added: [], removed: [] };
67
- }
68
- /**
69
- * Returns true when the caller-confirmed threshold is exceeded for this
70
- * scan's additions. The heuristic treats "small, same-directory" additions
71
- * as friction-free (the common refresh case) and flags larger or
72
- * multi-directory expansions so operators see them before they land.
73
- *
74
- * Pre-0.16.0 `--scan` silently broadened patches to include any modified or
75
- * untracked file that shared a parent directory with the existing
76
- * filesAffected — in practice, pulling adjacent feature code into a patch
77
- * that had nothing to do with it. The gate below turns the broadening into
78
- * an explicit opt-in.
79
- */
80
- function scanAdditionsNeedConfirmation(added) {
81
- if (added.length === 0)
82
- return false;
83
- if (added.length > SCAN_ADD_COUNT_THRESHOLD)
84
- return true;
85
- const dirs = new Set(added.map((f) => dirname(f)));
86
- return dirs.size >= SCAN_DIR_COUNT_THRESHOLD;
45
+ return [...candidates].sort();
87
46
  }
88
- /**
89
- * Gate for broad `--scan` additions. Enforces explicit acknowledgement when
90
- * the scan would pull in more files than a narrow refresh. Dry-run always
91
- * proceeds (the preview is the whole point).
92
- *
93
- * @returns true if the caller should proceed; false if the user cancelled.
94
- */
95
- async function confirmBroadScanAdditions(args) {
96
- const { patchFilename, added, isDryRun, yes, isInteractive } = args;
97
- if (isDryRun)
98
- return true;
99
- if (!scanAdditionsNeedConfirmation(added))
100
- return true;
101
- if (yes)
102
- return true;
103
- warn(`${patchFilename}: --scan would add ${String(added.length)} file(s) that span ${String(new Set(added.map((f) => dirname(f))).size)} director${new Set(added.map((f) => dirname(f))).size === 1 ? 'y' : 'ies'}. ` +
104
- 'Broad scans can silently pull adjacent features into a patch — review the diff before continuing.');
105
- if (!isInteractive) {
106
- throw new GeneralError(`Refusing to broaden "${patchFilename}" via --scan in non-interactive mode. ` +
107
- 'Pass --yes to acknowledge the expansion, or run with --dry-run first to review.');
108
- }
109
- const confirmed = await confirm({
110
- message: `Proceed and broaden ${patchFilename} with ${String(added.length)} newly discovered file(s)?`,
111
- initialValue: false,
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,
112
63
  });
113
- if (isCancel(confirmed) || !confirmed) {
114
- cancel(`Skipped ${patchFilename}`);
115
- return false;
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}`);
116
69
  }
117
- return true;
118
70
  }
119
71
  async function reExportSinglePatch(patch, paths, manifest, options, isDryRun, config) {
120
72
  let currentFilesAffected = [...patch.filesAffected];
121
73
  // --- Scan for new/removed files ---
122
74
  if (options.scan) {
123
- const scanResult = await scanPatchFiles(currentFilesAffected, paths.engine, manifest, patch.filename, isDryRun);
124
- const isInteractive = process.stdin.isTTY && process.stdout.isTTY;
125
- const proceed = await confirmBroadScanAdditions({
75
+ const scanResult = await scanPatchFilesForReExport({
76
+ currentFilesAffected,
77
+ engineDir: paths.engine,
78
+ manifest,
126
79
  patchFilename: patch.filename,
127
- added: scanResult.added,
128
80
  isDryRun,
129
- yes: options.yes === true,
130
- isInteractive,
81
+ ...(options.scanFiles !== undefined ? { scanFiles: options.scanFiles } : {}),
131
82
  });
132
- if (!proceed) {
133
- return false;
83
+ if (options.scanFiles === undefined) {
84
+ const isInteractive = process.stdin.isTTY && process.stdout.isTTY;
85
+ const proceed = await confirmBroadScanAdditions({
86
+ patchFilename: patch.filename,
87
+ added: scanResult.added,
88
+ isDryRun,
89
+ yes: options.yes === true,
90
+ isInteractive,
91
+ });
92
+ if (!proceed) {
93
+ return false;
94
+ }
134
95
  }
135
96
  currentFilesAffected = scanResult.updated;
136
97
  }
@@ -144,20 +105,7 @@ async function reExportSinglePatch(patch, paths, manifest, options, isDryRun, co
144
105
  // warning up-front when we can detect the drift cheaply, so the
145
106
  // operator has a chance to re-run with `--scan` or `--files`
146
107
  // before the stale filesAffected lands in patches.json.
147
- const missingFiles = [];
148
- for (const file of currentFilesAffected) {
149
- if (!(await pathExists(join(paths.engine, file)))) {
150
- missingFiles.push(file);
151
- }
152
- }
153
- if (missingFiles.length > 0) {
154
- warn(`${patch.filename}: some files in patches.json no longer exist on disk ` +
155
- `(${missingFiles.join(', ')}). Without --scan, re-export keeps the manifest's ` +
156
- `filesAffected unchanged and the missing entries will be preserved — ` +
157
- `\`fireforge verify\` may flag manifest inconsistency after this run.\n` +
158
- ` Re-run with --scan to reconcile filesAffected with the current worktree, ` +
159
- `or pass --files <paths> to set the list explicitly.`);
160
- }
108
+ await warnPlainReExportFileDrift({ patch, paths, manifest, currentFilesAffected });
161
109
  }
162
110
  // --- Explicit file-subset path ---
163
111
  // When --files is given, the target filesAffected is authoritative — drop
@@ -175,13 +123,7 @@ async function reExportSinglePatch(patch, paths, manifest, options, isDryRun, co
175
123
  for (const f of removed)
176
124
  info(` - ${f}`);
177
125
  }
178
- const missingFiles = [];
179
- for (const file of currentFilesAffected) {
180
- const filePath = join(paths.engine, file);
181
- if (!(await pathExists(filePath))) {
182
- missingFiles.push(file);
183
- }
184
- }
126
+ const missingFiles = await findMissingFiles(paths.engine, currentFilesAffected);
185
127
  if (missingFiles.length === currentFilesAffected.length) {
186
128
  warn(`Skipped ${patch.filename}: all affected files missing`);
187
129
  warn(`Missing files: ${missingFiles.join(', ')}`);
@@ -193,6 +135,12 @@ async function reExportSinglePatch(patch, paths, manifest, options, isDryRun, co
193
135
  const missingSet = new Set(missingFiles);
194
136
  const existingFiles = currentFilesAffected.filter((f) => !missingSet.has(f));
195
137
  const diffContent = await getDiffForFilesAgainstHead(paths.engine, existingFiles);
138
+ assertScanFileAdditionsHaveDiffHunks({
139
+ diffContent,
140
+ patchFilename: patch.filename,
141
+ previousFilesAffected: patch.filesAffected,
142
+ scanFiles: options.scanFiles,
143
+ });
196
144
  if (!diffContent.trim()) {
197
145
  warn(`Skipped ${patch.filename}: no changes (files unchanged from HEAD)`);
198
146
  return false;
@@ -317,6 +265,15 @@ async function resolveSelectedPatches(patches, options, manifest) {
317
265
  * @param options - Re-export options
318
266
  */
319
267
  export async function reExportCommand(projectRoot, patches, options) {
268
+ const normalizedScanFiles = normalizeScanFiles(options.scanFiles);
269
+ if (normalizedScanFiles !== undefined) {
270
+ options = { ...options, scanFiles: normalizedScanFiles };
271
+ }
272
+ else if (options.scanFiles !== undefined) {
273
+ const cleanedOptions = { ...options };
274
+ delete cleanedOptions.scanFiles;
275
+ options = cleanedOptions;
276
+ }
320
277
  const isDryRun = options.dryRun === true;
321
278
  intro(isDryRun ? 'FireForge Re-export (dry run)' : 'FireForge Re-export');
322
279
  // --files is mutually exclusive with --scan and --all: they select
@@ -329,6 +286,14 @@ export async function reExportCommand(projectRoot, patches, options) {
329
286
  throw new InvalidArgumentError('--files operates on exactly one target patch. Pass a single patch identifier.', '--files');
330
287
  }
331
288
  }
289
+ if (options.scanFiles !== undefined) {
290
+ if (!options.scan) {
291
+ throw new InvalidArgumentError('--scan-file requires --scan.', '--scan-file');
292
+ }
293
+ if (options.all || patches.length !== 1) {
294
+ throw new InvalidArgumentError('--scan-file operates on exactly one target patch. Pass a single patch identifier.', '--scan-file');
295
+ }
296
+ }
332
297
  // --tier and --lint-ignore are per-patch metadata edits; combining them
333
298
  // with --all silently rewrites every patch's tier/ignore list, which is
334
299
  // virtually always wrong (different patches have different shapes).
@@ -434,21 +399,24 @@ export function registerReExport(program, { getProjectRoot, withErrorHandling })
434
399
  'version stamping.')
435
400
  .option('-a, --all', 'Re-export all patches')
436
401
  .option('-s, --scan', 'Scan directories for new/removed files and update filesAffected')
402
+ .option('--scan-file <path>', 'With --scan, add this explicit engine-relative file to one target patch without collecting adjacent files. Repeatable.', (value, prev) => [...prev, value], [])
437
403
  .option('--files <paths>', 'Restrict the re-exported filesAffected to this comma-separated list (single target patch only)', (value) => value
438
404
  .split(',')
439
405
  .map((v) => v.trim())
440
406
  .filter((v) => v.length > 0))
441
407
  .option('--dry-run', 'Show what would change without writing')
442
408
  .option('--skip-lint', 'Skip patch lint checks (downgrade errors to warnings)')
443
- .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)')
444
411
  .option('--force-unsafe', 'Bypass cross-patch lint refusal when --files shrinks a patch')
445
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.")
446
413
  .addOption(new Option('--tier <tier>', 'Force a tier override on the selected patch (only "branding" recognised). Mutually exclusive with --all.').choices(['branding']))
447
414
  .option('--lint-ignore <check-id>', 'Append a lint check ID to the patch\'s PatchMetadata.lintIgnore (union, de-duped, repeatable). Mutually exclusive with --all. Use "fireforge patch lint-ignore" for --remove / --clear.', (value, prev) => [...prev, value], [])
448
415
  .action(withErrorHandling(async (patches, options) => {
449
- const { tier, lintIgnore, ...rest } = options;
416
+ const { tier, lintIgnore, scanFile, ...rest } = options;
450
417
  await reExportCommand(getProjectRoot(), patches, {
451
418
  ...pickDefined(rest),
419
+ ...(scanFile !== undefined && scanFile.length > 0 ? { scanFiles: scanFile } : {}),
452
420
  ...(tier !== undefined ? { tier: tier } : {}),
453
421
  ...(lintIgnore !== undefined && lintIgnore.length > 0 ? { lintIgnore } : {}),
454
422
  });
@@ -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);
@@ -164,6 +164,12 @@ export interface ReExportOptions {
164
164
  all?: boolean;
165
165
  /** Scan directories for new/removed files and update filesAffected */
166
166
  scan?: boolean;
167
+ /**
168
+ * Explicit engine-relative files to add while scanning. Unlike broad
169
+ * `--scan`, this does not collect adjacent files from the same directory.
170
+ * Requires `--scan` and exactly one target patch.
171
+ */
172
+ scanFiles?: string[];
167
173
  /**
168
174
  * Restrict the re-exported patch's filesAffected to this explicit list.
169
175
  * Files currently in the patch but not in this list are dropped (shrink);
@@ -178,6 +184,12 @@ export interface ReExportOptions {
178
184
  skipLint?: boolean;
179
185
  /** Skip confirmation prompt on shrink (required for non-TTY) */
180
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;
181
193
  /** Bypass cross-patch lint refusal on projected shrink state */
182
194
  forceUnsafe?: boolean;
183
195
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hominis/fireforge",
3
- "version": "0.25.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",
@@ -29,13 +29,14 @@
29
29
  "test:watch": "vitest",
30
30
  "test:coverage": "vitest run --coverage && node ./scripts/check-coverage-thresholds.mjs",
31
31
  "test:firefox-full": "node ./scripts/run-full-firefox-integration.mjs",
32
+ "whitespace:check": "node ./scripts/check-worktree-whitespace.mjs",
32
33
  "pack:verify": "vitest run src/__tests__/wrapper-smoke.test.ts",
33
34
  "pack:dry-run": "npm pack --dry-run --json --silent",
34
35
  "format": "prettier --write --ignore-unknown \"src/**/*.ts\" \"bin/**/*.ts\" \"scripts/**/*.mjs\" \"package.json\" \"eslint.config.js\" \"vitest.config.ts\" \"tsconfig.json\" \"tsconfig.build.json\" \".lintstagedrc.json\" \".prettierignore\" \".prettierrc\" \".gitignore\" \"README.md\" \"CHANGELOG.md\"",
35
36
  "format:check": "prettier --check --ignore-unknown \"src/**/*.ts\" \"bin/**/*.ts\" \"scripts/**/*.mjs\" \"package.json\" \"eslint.config.js\" \"vitest.config.ts\" \"tsconfig.json\" \"tsconfig.build.json\" \".lintstagedrc.json\" \".prettierignore\" \".prettierrc\" \".gitignore\" \"README.md\" \"CHANGELOG.md\"",
36
37
  "prepare": "node -e \"import('./scripts/prepare.mjs').catch((error) => { if (error && typeof error === 'object' && 'code' in error && error.code === 'ERR_MODULE_NOT_FOUND') process.exit(0); throw error; })\"",
37
38
  "prepack": "npm run build",
38
- "release:check": "npm run format:check && npm run lint:ci && npm run typecheck && npm run test:coverage && npm run pack:verify && npm run pack:dry-run",
39
+ "release:check": "npm run format:check && npm run whitespace:check && npm run lint:ci && npm run typecheck && npm run test:coverage && npm run pack:verify && npm run pack:dry-run",
39
40
  "prepublishOnly": "npm run release:check"
40
41
  },
41
42
  "files": [
@@ -52,7 +53,7 @@
52
53
  "dependencies": {
53
54
  "@clack/prompts": "^1.2.0",
54
55
  "acorn": "^8.14.0",
55
- "commander": "^14.0.0",
56
+ "commander": "^15.0.0",
56
57
  "estree-walker": "^3.0.3",
57
58
  "magic-string": "^0.30.17",
58
59
  "picocolors": "^1.1.0"