@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.
- package/CHANGELOG.md +12 -0
- package/README.md +1 -1
- package/dist/src/commands/doctor/post-rebase-audit.d.ts +2 -0
- package/dist/src/commands/doctor/post-rebase-audit.js +86 -0
- package/dist/src/commands/doctor.js +3 -0
- package/dist/src/commands/download.js +4 -1
- package/dist/src/commands/manifest.js +2 -0
- package/dist/src/commands/re-export-files.js +44 -26
- package/dist/src/commands/re-export.js +53 -14
- package/dist/src/commands/rebase/conflict-summary.d.ts +12 -0
- package/dist/src/commands/rebase/conflict-summary.js +38 -0
- package/dist/src/commands/rebase/patch-loop.js +24 -6
- package/dist/src/commands/setup-support.js +6 -2
- package/dist/src/commands/setup.js +1 -0
- package/dist/src/commands/source.d.ts +9 -0
- package/dist/src/commands/source.js +92 -0
- package/dist/src/commands/verify.js +27 -0
- package/dist/src/core/branding.js +54 -7
- package/dist/src/core/config-validate.js +1 -1
- package/dist/src/core/firefox-extract.d.ts +1 -1
- package/dist/src/core/firefox-extract.js +13 -1
- package/dist/src/core/firefox.d.ts +2 -1
- package/dist/src/core/firefox.js +2 -2
- package/dist/src/core/furnace-registration-ast.js +32 -4
- package/dist/src/core/furnace-registration-validate.d.ts +7 -0
- package/dist/src/core/furnace-registration-validate.js +48 -6
- package/dist/src/core/furnace-validate-registration.js +8 -17
- package/dist/src/core/git.js +46 -16
- package/dist/src/core/patch-artifact-normalize.d.ts +9 -0
- package/dist/src/core/patch-artifact-normalize.js +13 -0
- package/dist/src/core/patch-export-update.js +2 -1
- package/dist/src/core/patch-export.js +3 -2
- package/dist/src/core/status-classify.js +19 -4
- package/dist/src/types/commands/index.d.ts +1 -1
- package/dist/src/types/commands/options.d.ts +22 -1
- package/dist/src/types/config.d.ts +1 -1
- package/dist/src/utils/elapsed.d.ts +4 -0
- package/dist/src/utils/elapsed.js +15 -0
- package/dist/src/utils/validation.d.ts +2 -2
- package/dist/src/utils/validation.js +5 -5
- 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
|
|
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
|
|
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
|
-
|
|
148
|
+
return vendorValue === `"${escapeShellValue(config.vendor)}"`;
|
|
101
149
|
}
|
|
102
|
-
|
|
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
|
|
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>;
|
package/dist/src/core/firefox.js
CHANGED
|
@@ -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
|
-
|
|
128
|
-
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
41
|
-
const
|
|
42
|
-
|
|
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
|
-
|
|
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;
|
package/dist/src/core/git.js
CHANGED
|
@@ -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
|
|
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?.(
|
|
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
|
|
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
|
|
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
|
}
|