@hominis/fireforge 0.26.0 → 0.27.1

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.
Files changed (41) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +1 -1
  3. package/dist/src/commands/doctor/post-rebase-audit.d.ts +2 -0
  4. package/dist/src/commands/doctor/post-rebase-audit.js +86 -0
  5. package/dist/src/commands/doctor.js +3 -0
  6. package/dist/src/commands/download.js +4 -1
  7. package/dist/src/commands/manifest.js +2 -0
  8. package/dist/src/commands/re-export-files.js +44 -26
  9. package/dist/src/commands/re-export.js +53 -14
  10. package/dist/src/commands/rebase/conflict-summary.d.ts +12 -0
  11. package/dist/src/commands/rebase/conflict-summary.js +38 -0
  12. package/dist/src/commands/rebase/patch-loop.js +24 -6
  13. package/dist/src/commands/setup-support.js +6 -2
  14. package/dist/src/commands/setup.js +1 -0
  15. package/dist/src/commands/source.d.ts +9 -0
  16. package/dist/src/commands/source.js +92 -0
  17. package/dist/src/commands/verify.js +27 -0
  18. package/dist/src/core/branding.js +54 -7
  19. package/dist/src/core/config-validate.js +1 -1
  20. package/dist/src/core/firefox-extract.d.ts +1 -1
  21. package/dist/src/core/firefox-extract.js +13 -1
  22. package/dist/src/core/firefox.d.ts +2 -1
  23. package/dist/src/core/firefox.js +2 -2
  24. package/dist/src/core/furnace-registration-ast.js +32 -4
  25. package/dist/src/core/furnace-registration-validate.d.ts +7 -0
  26. package/dist/src/core/furnace-registration-validate.js +48 -6
  27. package/dist/src/core/furnace-validate-registration.js +8 -17
  28. package/dist/src/core/git.js +46 -16
  29. package/dist/src/core/patch-artifact-normalize.d.ts +9 -0
  30. package/dist/src/core/patch-artifact-normalize.js +13 -0
  31. package/dist/src/core/patch-export-update.js +2 -1
  32. package/dist/src/core/patch-export.js +3 -2
  33. package/dist/src/core/status-classify.js +19 -4
  34. package/dist/src/types/commands/index.d.ts +1 -1
  35. package/dist/src/types/commands/options.d.ts +22 -1
  36. package/dist/src/types/config.d.ts +1 -1
  37. package/dist/src/utils/elapsed.d.ts +4 -0
  38. package/dist/src/utils/elapsed.js +15 -0
  39. package/dist/src/utils/validation.d.ts +2 -2
  40. package/dist/src/utils/validation.js +5 -5
  41. package/package.json +2 -2
@@ -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)) {
@@ -67,7 +67,7 @@ export function validateConfig(data) {
67
67
  throw new ConfigError('Config field "firefox.version" must be a valid Firefox version (e.g., "145.0")');
68
68
  }
69
69
  const firefoxProduct = requireConfigString(firefoxRec, 'product', 'firefox.product');
70
- const validProducts = ['firefox', 'firefox-esr', 'firefox-beta'];
70
+ const validProducts = ['firefox', 'firefox-esr', 'firefox-beta', 'firefox-devedition'];
71
71
  if (!validProducts.includes(firefoxProduct)) {
72
72
  throw new ConfigError(`Config field "firefox.product" must be one of: ${validProducts.join(', ')}`);
73
73
  }
@@ -6,7 +6,7 @@
6
6
  * @param archivePath - Path to the archive
7
7
  * @param destDir - Destination directory
8
8
  */
9
- export declare function extractTarXz(archivePath: string, destDir: string): Promise<void>;
9
+ export declare function extractTarXz(archivePath: string, destDir: string, onProgress?: (message: string) => void): Promise<void>;
10
10
  /**
11
11
  * Gets the Firefox version from an existing source directory.
12
12
  * @param engineDir - Path to the engine directory
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { join } from 'node:path';
6
6
  import { ExtractionError } from '../errors/download.js';
7
+ import { elapsedSince } from '../utils/elapsed.js';
7
8
  import { ensureDir, pathExists } from '../utils/fs.js';
8
9
  import { exec, executableExists } from '../utils/process.js';
9
10
  /**
@@ -11,15 +12,26 @@ import { exec, executableExists } from '../utils/process.js';
11
12
  * @param archivePath - Path to the archive
12
13
  * @param destDir - Destination directory
13
14
  */
14
- export async function extractTarXz(archivePath, destDir) {
15
+ export async function extractTarXz(archivePath, destDir, onProgress) {
15
16
  if (!(await executableExists('tar'))) {
16
17
  throw new ExtractionError(archivePath, new Error('The "tar" command was not found. Please install tar (or ensure it is on your PATH) and try again.'));
17
18
  }
18
19
  await ensureDir(destDir);
20
+ const startedAt = Date.now();
21
+ onProgress?.(`Extracting source archive (${elapsedSince(startedAt)} elapsed)...`);
22
+ const heartbeat = onProgress
23
+ ? setInterval(() => {
24
+ onProgress(`Extracting source archive (${elapsedSince(startedAt)} elapsed)...`);
25
+ }, 15_000)
26
+ : null;
27
+ heartbeat?.unref();
19
28
  const result = await exec('tar', ['-xf', archivePath, '-C', destDir]);
29
+ if (heartbeat)
30
+ clearInterval(heartbeat);
20
31
  if (result.exitCode !== 0) {
21
32
  throw new ExtractionError(archivePath, new Error(`tar exited with code ${result.exitCode}:\n${result.stderr}`));
22
33
  }
34
+ onProgress?.(`Source archive extracted (${elapsedSince(startedAt)} elapsed)`);
23
35
  }
24
36
  /**
25
37
  * Gets the Firefox version from an existing source directory.
@@ -34,6 +34,7 @@ export declare function getTarballFilename(version: string, product?: FirefoxPro
34
34
  export type FirefoxSourcePhase = 'download' | 'extract';
35
35
  /** Callback fired at phase transitions during {@link downloadFirefoxSource}. */
36
36
  export type FirefoxSourcePhaseCallback = (phase: FirefoxSourcePhase) => void;
37
+ export type FirefoxSourceProgressCallback = (message: string) => void;
37
38
  /**
38
39
  * Downloads and extracts Firefox source.
39
40
  * @param version - Firefox version to download
@@ -45,4 +46,4 @@ export type FirefoxSourcePhaseCallback = (phase: FirefoxSourcePhase) => void;
45
46
  * between phases (`'download'` → `'extract'`). Fires exactly once per
46
47
  * phase even if the cached archive path skips the wire entirely.
47
48
  */
48
- export declare function downloadFirefoxSource(version: string, product: FirefoxProduct, destDir: string, cacheDir: string, onProgress?: ProgressCallback, onPhase?: FirefoxSourcePhaseCallback, expectedSha256?: string): Promise<void>;
49
+ export declare function downloadFirefoxSource(version: string, product: FirefoxProduct, destDir: string, cacheDir: string, onProgress?: ProgressCallback, onPhase?: FirefoxSourcePhaseCallback, expectedSha256?: string, onPhaseProgress?: FirefoxSourceProgressCallback): Promise<void>;
@@ -44,7 +44,7 @@ export function getTarballFilename(version, product = 'firefox') {
44
44
  * between phases (`'download'` → `'extract'`). Fires exactly once per
45
45
  * phase even if the cached archive path skips the wire entirely.
46
46
  */
47
- export async function downloadFirefoxSource(version, product, destDir, cacheDir, onProgress, onPhase, expectedSha256) {
47
+ export async function downloadFirefoxSource(version, product, destDir, cacheDir, onProgress, onPhase, expectedSha256, onPhaseProgress) {
48
48
  const archive = resolveArchive(version, product);
49
49
  const tarballPath = join(cacheDir, archive.filename);
50
50
  // Ensure cache directory exists
@@ -56,7 +56,7 @@ export async function downloadFirefoxSource(version, product, destDir, cacheDir,
56
56
  onPhase?.('extract');
57
57
  const tempDir = `${destDir}.tmp-${randomUUID()}`;
58
58
  try {
59
- await extractTarXz(tarballPath, tempDir);
59
+ await extractTarXz(tarballPath, tempDir, onPhaseProgress);
60
60
  }
61
61
  catch (error) {
62
62
  await removeDir(tempDir);
@@ -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
  }
@@ -30,3 +30,10 @@ export declare function validateTagName(tagName: string): void;
30
30
  * @param isESModule - Whether the module uses ESM (Pattern B) or not (Pattern A)
31
31
  */
32
32
  export declare function validateRegistrationPlacement(result: string, tagName: string, isESModule: boolean): void;
33
+ /**
34
+ * Returns whether a tag appears in the correct customElements.js placement.
35
+ * ESM entries may either appear textually inside/after DOMContentLoaded or
36
+ * inside an array declared before DOMContentLoaded and consumed by a for-of
37
+ * loop inside the listener, as Firefox 152 Beta does for acornElements.
38
+ */
39
+ export declare function isTagInCorrectCustomElementsPlacement(content: string, tagName: string, isESModule: boolean): boolean;
@@ -4,6 +4,7 @@
4
4
  * Used after both AST and legacy code paths to avoid duplicating logic.
5
5
  */
6
6
  import { FurnaceError } from '../errors/furnace.js';
7
+ import { stripJsComments } from '../utils/regex.js';
7
8
  /**
8
9
  * Regex for valid custom element tag names. A valid name is lowercase, starts
9
10
  * with a letter, and contains one or more hyphen-separated groups where each
@@ -36,17 +37,58 @@ export function validateTagName(tagName) {
36
37
  * @param isESModule - Whether the module uses ESM (Pattern B) or not (Pattern A)
37
38
  */
38
39
  export function validateRegistrationPlacement(result, tagName, isESModule) {
39
- const dclPattern = /document\.addEventListener\(\s*["']DOMContentLoaded["']/;
40
40
  const insertedPos = result.lastIndexOf(`"${tagName}"`);
41
41
  if (insertedPos === -1)
42
42
  return;
43
- const contentBeforeTag = result.slice(0, insertedPos);
44
- const hasDCLBefore = dclPattern.test(contentBeforeTag);
45
- if (isESModule && !hasDCLBefore) {
43
+ if (!isTagInCorrectCustomElementsPlacement(result, tagName, isESModule)) {
44
+ if (!isESModule) {
45
+ 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);
46
+ }
46
47
  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
48
  }
48
- if (!isESModule && hasDCLBefore) {
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);
49
+ }
50
+ /**
51
+ * Returns whether a tag appears in the correct customElements.js placement.
52
+ * ESM entries may either appear textually inside/after DOMContentLoaded or
53
+ * inside an array declared before DOMContentLoaded and consumed by a for-of
54
+ * loop inside the listener, as Firefox 152 Beta does for acornElements.
55
+ */
56
+ export function isTagInCorrectCustomElementsPlacement(content, tagName, isESModule) {
57
+ const stripped = stripJsComments(content);
58
+ const tagPattern = new RegExp(`["']${escapeRegex(tagName)}["']`);
59
+ const dclMatch = /document\.addEventListener\(\s*["']DOMContentLoaded["']/.exec(stripped);
60
+ if (!dclMatch) {
61
+ return !isESModule && tagPattern.test(stripped);
62
+ }
63
+ const beforeDcl = stripped.slice(0, dclMatch.index);
64
+ const afterDcl = stripped.slice(dclMatch.index);
65
+ const tagBeforeDcl = tagPattern.test(beforeDcl);
66
+ const tagAfterDcl = tagPattern.test(afterDcl);
67
+ if (!isESModule) {
68
+ return tagBeforeDcl && !tagAfterDcl;
50
69
  }
70
+ return (tagAfterDcl || isTagInArrayConsumedInsideDOMContentLoaded(stripped, dclMatch.index, tagName));
71
+ }
72
+ function isTagInArrayConsumedInsideDOMContentLoaded(content, domContentLoadedIdx, tagName) {
73
+ const beforeDcl = content.slice(0, domContentLoadedIdx);
74
+ const afterDcl = content.slice(domContentLoadedIdx);
75
+ const consumedArrays = new Set();
76
+ const forOfPattern = /for\s*\(\s*(?:let|const|var)\s*\[[^)]*\]\s+of\s+([A-Za-z_$][\w$]*)\s*\)/g;
77
+ let match;
78
+ while ((match = forOfPattern.exec(afterDcl)) !== null) {
79
+ if (match[1])
80
+ consumedArrays.add(match[1]);
81
+ }
82
+ for (const arrayName of consumedArrays) {
83
+ const declarationPattern = new RegExp(`(?:const|let|var)\\s+${escapeRegex(arrayName)}\\s*=\\s*\\[([\\s\\S]*?)\\];`);
84
+ const declaration = declarationPattern.exec(beforeDcl);
85
+ if (declaration?.[1] && new RegExp(`["']${escapeRegex(tagName)}["']`).test(declaration[1])) {
86
+ return true;
87
+ }
88
+ }
89
+ return false;
90
+ }
91
+ function escapeRegex(value) {
92
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
51
93
  }
52
94
  //# sourceMappingURL=furnace-registration-validate.js.map
@@ -9,6 +9,7 @@ import { stripJsComments } from '../utils/regex.js';
9
9
  import { getProjectPaths, loadConfig } from './config.js';
10
10
  import { getFurnacePaths } from './furnace-config.js';
11
11
  import { CUSTOM_ELEMENTS_JS, FTL_DIR, JAR_MN } from './furnace-constants.js';
12
+ import { isTagInCorrectCustomElementsPlacement } from './furnace-registration-validate.js';
12
13
  import { getTokensCssPath } from './token-manager.js';
13
14
  /**
14
15
  * Validates that all Furnace-managed .mjs components are registered in the
@@ -27,20 +28,13 @@ export async function validateRegistrationPatterns(root, config) {
27
28
  return issues;
28
29
  }
29
30
  const content = await readText(filePath);
30
- // Find the DOMContentLoaded block boundary (handles multi-line addEventListener)
31
- const dclMatch = /document\.addEventListener\(\s*["']DOMContentLoaded["']/.exec(content);
32
- if (!dclMatch) {
33
- return issues;
34
- }
35
- const domContentLoadedIdx = dclMatch.index;
36
31
  // Get all custom component tag names that use .mjs (all custom components do)
37
32
  for (const [name, customConfig] of Object.entries(config.custom)) {
38
33
  if (!customConfig.register)
39
34
  continue;
40
- // Check if this tag is referenced before the DOMContentLoaded block
41
- const contentBeforeDCL = stripJsComments(content.slice(0, domContentLoadedIdx));
42
- const tagPattern = new RegExp(`"${name}"`);
43
- if (tagPattern.test(contentBeforeDCL)) {
35
+ const stripped = stripJsComments(content);
36
+ const tagPattern = new RegExp(`["']${escapeRegex(name)}["']`);
37
+ if (tagPattern.test(stripped) && !isTagInCorrectCustomElementsPlacement(content, name, true)) {
44
38
  issues.push({
45
39
  component: name,
46
40
  severity: 'error',
@@ -51,6 +45,9 @@ export async function validateRegistrationPatterns(root, config) {
51
45
  }
52
46
  return issues;
53
47
  }
48
+ function escapeRegex(value) {
49
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
50
+ }
54
51
  /**
55
52
  * Checks registration consistency for a single custom component.
56
53
  *
@@ -150,13 +147,7 @@ export async function checkRegistrationConsistency(root, name, config, ftlDir) {
150
147
  status.customElementsPresent =
151
148
  ceContent.includes(`"${name}"`) || ceContent.includes(`'${name}'`);
152
149
  if (status.customElementsPresent) {
153
- // Check it's in the correct block (after DOMContentLoaded)
154
- const dclMatch = /document\.addEventListener\(\s*["']DOMContentLoaded["']/.exec(ceContent);
155
- if (dclMatch) {
156
- const afterDcl = ceContent.slice(dclMatch.index);
157
- status.customElementsCorrectBlock =
158
- afterDcl.includes(`"${name}"`) || afterDcl.includes(`'${name}'`);
159
- }
150
+ status.customElementsCorrectBlock = isTagInCorrectCustomElementsPlacement(ceContent, name, true);
160
151
  }
161
152
  }
162
153
  return status;
@@ -2,6 +2,7 @@
2
2
  import { readdir, stat } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { GitError, GitIndexingTimeoutError, GitIndexLockError, PatchApplyError, } from '../errors/git.js';
5
+ import { elapsedSince } from '../utils/elapsed.js';
5
6
  import { toError } from '../utils/errors.js';
6
7
  import { pathExists, removeFile } from '../utils/fs.js';
7
8
  import { verbose } from '../utils/logger.js';
@@ -105,12 +106,8 @@ async function isPathIgnored(dir, relativePath) {
105
106
  * saw. The typed error carries the environment-variable override so the
106
107
  * operator can extend the budget and re-run.
107
108
  */
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();
109
+ async function stageAllFilesChunked(dir, scan, options = {}) {
110
+ const { directories, topLevelFiles: topLevelCandidates } = scan;
114
111
  async function runChunk(args, label) {
115
112
  try {
116
113
  await git(args, dir, {
@@ -126,29 +123,30 @@ async function stageAllFilesChunked(dir, options = {}) {
126
123
  throw error;
127
124
  }
128
125
  }
126
+ let stagedDirectories = 0;
129
127
  for (const dirName of directories) {
128
+ stagedDirectories++;
130
129
  if (await isPathIgnored(dir, dirName)) {
131
- options.onProgress?.(`Skipping gitignored: ${dirName}/`);
130
+ options.onProgress?.(`Skipping gitignored directory ${stagedDirectories}/${directories.length}: ${dirName}/`);
132
131
  continue;
133
132
  }
134
- options.onProgress?.(`Staging directory: ${dirName}/...`);
133
+ options.onProgress?.(`Staging directory ${stagedDirectories}/${directories.length}: ${dirName}/...`);
135
134
  await runChunk(['add', '--', dirName], dirName);
136
135
  }
137
136
  // Stage any top-level files (excluding gitignored ones — `git add`
138
137
  // on an explicit ignored path errors out, which would otherwise
139
138
  // abort the chunked fallback after the monolithic path has already
140
139
  // timed out).
141
- const topLevelCandidates = entries.filter((e) => e.isFile()).map((e) => e.name);
142
140
  const topLevelFiles = [];
143
141
  for (const name of topLevelCandidates) {
144
142
  if (await isPathIgnored(dir, name)) {
145
- options.onProgress?.(`Skipping gitignored: ${name}`);
143
+ options.onProgress?.(`Skipping gitignored top-level file: ${name}`);
146
144
  continue;
147
145
  }
148
146
  topLevelFiles.push(name);
149
147
  }
150
148
  if (topLevelFiles.length > 0) {
151
- options.onProgress?.('Staging top-level files...');
149
+ options.onProgress?.(`Staging ${topLevelFiles.length} top-level file(s)...`);
152
150
  await runChunk(['add', '--', ...topLevelFiles], 'top-level files');
153
151
  }
154
152
  }
@@ -161,6 +159,20 @@ async function stageAllFilesChunked(dir, options = {}) {
161
159
  * SIGINT'd mid-way assuming the process had stalled.
162
160
  */
163
161
  const GIT_ADD_HEARTBEAT_MS = 15_000;
162
+ const GIT_COMMIT_HEARTBEAT_MS = 15_000;
163
+ async function scanTopLevelSource(dir) {
164
+ const entries = await readdir(dir, { withFileTypes: true });
165
+ return {
166
+ directories: entries
167
+ .filter((entry) => entry.isDirectory() && entry.name !== '.git')
168
+ .map((entry) => entry.name)
169
+ .sort(),
170
+ topLevelFiles: entries
171
+ .filter((entry) => entry.isFile())
172
+ .map((entry) => entry.name)
173
+ .sort(),
174
+ };
175
+ }
164
176
  /**
165
177
  * Stages all files in the repository.
166
178
  * Tries a monolithic `git add -A` first; if that times out, falls back to
@@ -169,6 +181,9 @@ const GIT_ADD_HEARTBEAT_MS = 15_000;
169
181
  export async function stageAllFiles(dir, options = {}) {
170
182
  const timeout = options.timeout ?? GIT_ADD_TIMEOUT_MS;
171
183
  const reportProgress = options.onProgress;
184
+ reportProgress?.('Scanning Firefox source tree before indexing...');
185
+ const scan = await scanTopLevelSource(dir);
186
+ 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
187
  // 2026-04-26 eval Finding 5: the pre-fix heartbeat used a single
173
188
  // `heartbeatStartedAt` set at function entry and reported cumulative
174
189
  // elapsed for the whole `stageAllFiles` invocation. After a
@@ -191,7 +206,9 @@ export async function stageAllFiles(dir, options = {}) {
191
206
  heartbeatTimer?.unref();
192
207
  try {
193
208
  try {
209
+ 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
210
  await git(['add', '-A'], dir, { timeout, env: GIT_ADD_ENV });
211
+ reportProgress?.('Monolithic git add -A completed.');
195
212
  return;
196
213
  }
197
214
  catch (error) {
@@ -215,7 +232,7 @@ export async function stageAllFiles(dir, options = {}) {
215
232
  phase = 'chunked';
216
233
  phaseStartedAt = Date.now();
217
234
  try {
218
- await stageAllFilesChunked(dir, options);
235
+ await stageAllFilesChunked(dir, scan, options);
219
236
  }
220
237
  catch (error) {
221
238
  if (error instanceof GitIndexingTimeoutError)
@@ -228,6 +245,21 @@ export async function stageAllFiles(dir, options = {}) {
228
245
  clearInterval(heartbeatTimer);
229
246
  }
230
247
  }
248
+ async function createInitialSourceCommit(dir, reportProgress) {
249
+ const startedAt = Date.now();
250
+ reportProgress(`Creating initial Firefox source commit (${elapsedSince(startedAt)} elapsed)...`);
251
+ const heartbeat = setInterval(() => {
252
+ reportProgress(`Creating initial Firefox source commit (${elapsedSince(startedAt)} elapsed)...`);
253
+ }, GIT_COMMIT_HEARTBEAT_MS);
254
+ heartbeat.unref();
255
+ try {
256
+ await git(['commit', '-m', 'Initial Firefox source'], dir);
257
+ }
258
+ finally {
259
+ clearInterval(heartbeat);
260
+ }
261
+ reportProgress(`Initial Firefox source commit created (${elapsedSince(startedAt)} elapsed).`);
262
+ }
231
263
  /**
232
264
  * Initializes a new git repository with an orphan branch.
233
265
  * @param dir - Directory to initialize
@@ -264,9 +296,8 @@ export async function initRepository(dir, branchName = 'main', options = {}) {
264
296
  throw await maybeWrapIndexLockError(dir, error);
265
297
  }
266
298
  // Create initial commit
267
- reportProgress('Creating initial Firefox source commit...');
268
299
  try {
269
- await git(['commit', '-m', 'Initial Firefox source'], dir);
300
+ await createInitialSourceCommit(dir, reportProgress);
270
301
  }
271
302
  catch (error) {
272
303
  throw await maybeWrapIndexLockError(dir, error);
@@ -301,9 +332,8 @@ export async function resumeRepository(dir, options = {}) {
301
332
  throw await maybeWrapIndexLockError(dir, error);
302
333
  }
303
334
  // Create initial commit
304
- reportProgress('Creating initial Firefox source commit...');
305
335
  try {
306
- await git(['commit', '-m', 'Initial Firefox source'], dir);
336
+ await createInitialSourceCommit(dir, reportProgress);
307
337
  }
308
338
  catch (error) {
309
339
  throw await maybeWrapIndexLockError(dir, error);
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Normalizes generated patch files for repository whitespace checks.
3
+ *
4
+ * Unified diffs conventionally encode a blank context line as a physical line
5
+ * containing one space. `git apply` also accepts the same hunk as an empty
6
+ * physical line, while repository-level `git diff --check` flags the
7
+ * single-space artifact as trailing whitespace in `patches/*.patch`.
8
+ */
9
+ export declare function normalizePatchArtifact(content: string): string;
@@ -0,0 +1,13 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Normalizes generated patch files for repository whitespace checks.
4
+ *
5
+ * Unified diffs conventionally encode a blank context line as a physical line
6
+ * containing one space. `git apply` also accepts the same hunk as an empty
7
+ * physical line, while repository-level `git diff --check` flags the
8
+ * single-space artifact as trailing whitespace in `patches/*.patch`.
9
+ */
10
+ export function normalizePatchArtifact(content) {
11
+ return content.replace(/^ $/gm, '');
12
+ }
13
+ //# sourceMappingURL=patch-artifact-normalize.js.map
@@ -4,6 +4,7 @@ import { toError } from '../utils/errors.js';
4
4
  import { pathExists, readText, writeText } from '../utils/fs.js';
5
5
  import { warn } from '../utils/logger.js';
6
6
  import { withPatchDirectoryLock } from './patch-apply.js';
7
+ import { normalizePatchArtifact } from './patch-artifact-normalize.js';
7
8
  import { loadPatchesManifest, savePatchesManifest } from './patch-manifest.js';
8
9
  import { buildProjectedManifest, enforcePatchPolicy } from './patch-policy.js';
9
10
  /**
@@ -38,7 +39,7 @@ export async function updatePatchAndMetadata(patchesDir, filename, newContent, u
38
39
  }
39
40
  let patchWritten = false;
40
41
  try {
41
- await writeText(patchPath, newContent);
42
+ await writeText(patchPath, normalizePatchArtifact(newContent));
42
43
  patchWritten = true;
43
44
  await savePatchesManifest(patchesDir, manifest);
44
45
  }