@hominis/fireforge 0.26.0 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/dist/src/commands/re-export-files.js +44 -26
- package/dist/src/commands/re-export.js +49 -12
- package/dist/src/commands/verify.js +27 -0
- package/dist/src/core/branding.js +54 -7
- package/dist/src/core/furnace-registration-ast.js +32 -4
- package/dist/src/core/furnace-registration-validate.js +26 -1
- package/dist/src/core/furnace-validate-registration.js +24 -1
- package/dist/src/core/git.js +27 -12
- package/dist/src/core/status-classify.js +19 -4
- package/dist/src/types/commands/options.d.ts +6 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.27.0
|
|
4
|
+
|
|
5
|
+
- Improved `download --force` git indexing progress with phase, count, and heartbeat output.
|
|
6
|
+
- Added `re-export --files --allow-shrink` so patch ownership shrinkage is refused unless explicitly acknowledged, with clearer dry-run previews.
|
|
7
|
+
- Surfaced likely new sibling files during plain re-export and aligned verify/status ownership reporting for unowned worktree changes.
|
|
8
|
+
- Preserved patch-owned branding `configure.sh` settings during build preflight.
|
|
9
|
+
- Added custom element registration support for Furnace validate/apply.
|
|
10
|
+
|
|
3
11
|
## 0.26.0
|
|
4
12
|
|
|
5
13
|
- Added targeted `re-export --scan --scan-file <path>` for reviewed single-patch new-file assignment without broad sibling collection.
|
|
@@ -92,6 +92,41 @@ function buildFilesModeMetadataUpdates(actualProjectedFiles, options, effectiveL
|
|
|
92
92
|
}
|
|
93
93
|
return updates;
|
|
94
94
|
}
|
|
95
|
+
async function confirmFilesModeProjection(args) {
|
|
96
|
+
const { target, retained, removed, added, actualProjectedFiles, missingFiles, options, conflicts, } = args;
|
|
97
|
+
const isDryRun = options.dryRun === true;
|
|
98
|
+
const summary = [
|
|
99
|
+
`re-export ${target.filename} with --files scope`,
|
|
100
|
+
`current files (${target.filesAffected.length}): ${target.filesAffected.join(', ') || '(none)'}`,
|
|
101
|
+
`retained files (${retained.length}): ${retained.join(', ') || '(none)'}`,
|
|
102
|
+
`projected files (${actualProjectedFiles.length}): ${actualProjectedFiles.join(', ') || '(none)'}`,
|
|
103
|
+
];
|
|
104
|
+
if (removed.length > 0) {
|
|
105
|
+
summary.push(`removed files (${removed.length}; become unmanaged): ${removed.join(', ')}`);
|
|
106
|
+
}
|
|
107
|
+
if (added.length > 0) {
|
|
108
|
+
summary.push(`newly included files (${added.length}): ${added.join(', ')}`);
|
|
109
|
+
}
|
|
110
|
+
if (missingFiles.length > 0) {
|
|
111
|
+
summary.push(`missing on disk (will be dropped): ${missingFiles.join(', ')}`);
|
|
112
|
+
}
|
|
113
|
+
if (!isDryRun && removed.length > 0 && options.allowShrink !== true) {
|
|
114
|
+
warn(`Refusing to shrink ${target.filename} without --allow-shrink.`);
|
|
115
|
+
for (const line of summary) {
|
|
116
|
+
info(` ${line}`);
|
|
117
|
+
}
|
|
118
|
+
throw new InvalidArgumentError(`Refusing to re-export ${target.filename} with --files because it would remove ${removed.length} existing patch-owned file${removed.length === 1 ? '' : 's'}. Run again with --allow-shrink after reviewing the dry-run output.`, '--allow-shrink');
|
|
119
|
+
}
|
|
120
|
+
return confirmDestructive({
|
|
121
|
+
operation: 're-export-files',
|
|
122
|
+
title: `Re-export ${target.filename} with --files`,
|
|
123
|
+
summary,
|
|
124
|
+
yes: removed.length === 0 && missingFiles.length === 0 ? true : options.yes === true,
|
|
125
|
+
dryRun: isDryRun,
|
|
126
|
+
unsafeOverride: options.forceUnsafe === true,
|
|
127
|
+
conflicts,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
95
130
|
/**
|
|
96
131
|
* Handles `re-export --files` end-to-end: computes the projected diff,
|
|
97
132
|
* runs the per-patch and cross-patch lint against a context in which the
|
|
@@ -106,7 +141,6 @@ function buildFilesModeMetadataUpdates(actualProjectedFiles, options, effectiveL
|
|
|
106
141
|
* of the projected state.
|
|
107
142
|
*/
|
|
108
143
|
export async function reExportFilesInPlace(paths, selectedPatches, options, config) {
|
|
109
|
-
const isDryRun = options.dryRun === true;
|
|
110
144
|
const target = selectedPatches[0];
|
|
111
145
|
if (!target) {
|
|
112
146
|
throw new InvalidArgumentError('--files requires a target patch.', '--files');
|
|
@@ -118,6 +152,7 @@ export async function reExportFilesInPlace(paths, selectedPatches, options, conf
|
|
|
118
152
|
const requested = [...new Set(filesOption)].sort();
|
|
119
153
|
const removed = target.filesAffected.filter((f) => !requested.includes(f));
|
|
120
154
|
const added = requested.filter((f) => !target.filesAffected.includes(f));
|
|
155
|
+
const retained = target.filesAffected.filter((f) => requested.includes(f));
|
|
121
156
|
// Filter out paths that no longer exist on disk; we cannot include
|
|
122
157
|
// them in the new diff because getDiffForFilesAgainstHead would fail.
|
|
123
158
|
// Missing files are still dropped from the manifest so the resulting
|
|
@@ -175,31 +210,14 @@ export async function reExportFilesInPlace(paths, selectedPatches, options, conf
|
|
|
175
210
|
forceUnsafe: options.forceUnsafe === true,
|
|
176
211
|
});
|
|
177
212
|
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
];
|
|
187
|
-
if (removed.length > 0) {
|
|
188
|
-
summary.push(`would drop (become unmanaged): ${removed.join(', ')}`);
|
|
189
|
-
}
|
|
190
|
-
if (added.length > 0) {
|
|
191
|
-
summary.push(`would add: ${added.join(', ')}`);
|
|
192
|
-
}
|
|
193
|
-
if (missingFiles.length > 0) {
|
|
194
|
-
summary.push(`missing on disk (will be dropped): ${missingFiles.join(', ')}`);
|
|
195
|
-
}
|
|
196
|
-
const decision = await confirmDestructive({
|
|
197
|
-
operation: 're-export-files',
|
|
198
|
-
title: `Re-export ${target.filename} with --files`,
|
|
199
|
-
summary,
|
|
200
|
-
yes: removed.length === 0 && missingFiles.length === 0 ? true : options.yes === true,
|
|
201
|
-
dryRun: isDryRun,
|
|
202
|
-
unsafeOverride: options.forceUnsafe === true,
|
|
213
|
+
const decision = await confirmFilesModeProjection({
|
|
214
|
+
target,
|
|
215
|
+
retained,
|
|
216
|
+
removed,
|
|
217
|
+
added,
|
|
218
|
+
actualProjectedFiles,
|
|
219
|
+
missingFiles,
|
|
220
|
+
options,
|
|
203
221
|
conflicts,
|
|
204
222
|
});
|
|
205
223
|
if (decision === 'cancelled') {
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
-
import { join } from 'node:path';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
3
|
import { multiselect } from '@clack/prompts';
|
|
4
4
|
import { Option } from 'commander';
|
|
5
5
|
import { getProjectPaths, loadConfig } from '../core/config.js';
|
|
6
6
|
import { isGitRepository } from '../core/git.js';
|
|
7
7
|
import { getDiffForFilesAgainstHead } from '../core/git-diff.js';
|
|
8
|
+
import { getModifiedFilesInDir, getUntrackedFilesInDir } from '../core/git-status.js';
|
|
8
9
|
import { updatePatchAndMetadata } from '../core/patch-export.js';
|
|
9
|
-
import { loadPatchesManifest, resolvePatchIdentifier, stampPatchVersions, } from '../core/patch-manifest.js';
|
|
10
|
+
import { getClaimedFiles, loadPatchesManifest, resolvePatchIdentifier, stampPatchVersions, } from '../core/patch-manifest.js';
|
|
10
11
|
import { buildProjectedManifest, enforcePatchPolicy } from '../core/patch-policy.js';
|
|
11
12
|
import { GeneralError, InvalidArgumentError } from '../errors/base.js';
|
|
12
13
|
import { toError } from '../utils/errors.js';
|
|
@@ -24,6 +25,49 @@ async function findMissingFiles(engineDir, files) {
|
|
|
24
25
|
}
|
|
25
26
|
return missingFiles;
|
|
26
27
|
}
|
|
28
|
+
async function findLikelyNewSiblingFiles(args) {
|
|
29
|
+
const { currentFilesAffected, engineDir, manifest, patchFilename } = args;
|
|
30
|
+
const parentDirs = [...new Set(currentFilesAffected.map((file) => dirname(file)))];
|
|
31
|
+
const currentSet = new Set(currentFilesAffected);
|
|
32
|
+
const claimedByOthers = getClaimedFiles(manifest, patchFilename);
|
|
33
|
+
const candidates = new Set();
|
|
34
|
+
for (const dir of parentDirs) {
|
|
35
|
+
const [modifiedFiles, untrackedFiles] = await Promise.all([
|
|
36
|
+
getModifiedFilesInDir(engineDir, dir),
|
|
37
|
+
getUntrackedFilesInDir(engineDir, dir),
|
|
38
|
+
]);
|
|
39
|
+
for (const file of [...modifiedFiles, ...untrackedFiles]) {
|
|
40
|
+
if (currentSet.has(file) || claimedByOthers.has(file))
|
|
41
|
+
continue;
|
|
42
|
+
candidates.add(file);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return [...candidates].sort();
|
|
46
|
+
}
|
|
47
|
+
async function warnPlainReExportFileDrift(args) {
|
|
48
|
+
const { patch, paths, manifest, currentFilesAffected } = args;
|
|
49
|
+
const missingFiles = await findMissingFiles(paths.engine, currentFilesAffected);
|
|
50
|
+
if (missingFiles.length > 0) {
|
|
51
|
+
warn(`${patch.filename}: some files in patches.json no longer exist on disk ` +
|
|
52
|
+
`(${missingFiles.join(', ')}). Without --scan, re-export keeps the manifest's ` +
|
|
53
|
+
`filesAffected unchanged and the missing entries will be preserved — ` +
|
|
54
|
+
`\`fireforge verify\` may flag manifest inconsistency after this run.\n` +
|
|
55
|
+
` Re-run with --scan to reconcile filesAffected with the current worktree, ` +
|
|
56
|
+
`or pass --files <paths> to set the list explicitly.`);
|
|
57
|
+
}
|
|
58
|
+
const likelyNewFiles = await findLikelyNewSiblingFiles({
|
|
59
|
+
currentFilesAffected,
|
|
60
|
+
engineDir: paths.engine,
|
|
61
|
+
manifest,
|
|
62
|
+
patchFilename: patch.filename,
|
|
63
|
+
});
|
|
64
|
+
if (likelyNewFiles.length === 0)
|
|
65
|
+
return;
|
|
66
|
+
warn(`${patch.filename}: found ${likelyNewFiles.length} unowned changed sibling file${likelyNewFiles.length === 1 ? '' : 's'} near this patch. Plain re-export keeps filesAffected unchanged; add reviewed files explicitly with --scan-file.`);
|
|
67
|
+
for (const file of likelyNewFiles) {
|
|
68
|
+
info(` ${file} — fireforge re-export ${patch.filename} --scan --scan-file ${file}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
27
71
|
async function reExportSinglePatch(patch, paths, manifest, options, isDryRun, config) {
|
|
28
72
|
let currentFilesAffected = [...patch.filesAffected];
|
|
29
73
|
// --- Scan for new/removed files ---
|
|
@@ -61,15 +105,7 @@ async function reExportSinglePatch(patch, paths, manifest, options, isDryRun, co
|
|
|
61
105
|
// warning up-front when we can detect the drift cheaply, so the
|
|
62
106
|
// operator has a chance to re-run with `--scan` or `--files`
|
|
63
107
|
// before the stale filesAffected lands in patches.json.
|
|
64
|
-
|
|
65
|
-
if (missingFiles.length > 0) {
|
|
66
|
-
warn(`${patch.filename}: some files in patches.json no longer exist on disk ` +
|
|
67
|
-
`(${missingFiles.join(', ')}). Without --scan, re-export keeps the manifest's ` +
|
|
68
|
-
`filesAffected unchanged and the missing entries will be preserved — ` +
|
|
69
|
-
`\`fireforge verify\` may flag manifest inconsistency after this run.\n` +
|
|
70
|
-
` Re-run with --scan to reconcile filesAffected with the current worktree, ` +
|
|
71
|
-
`or pass --files <paths> to set the list explicitly.`);
|
|
72
|
-
}
|
|
108
|
+
await warnPlainReExportFileDrift({ patch, paths, manifest, currentFilesAffected });
|
|
73
109
|
}
|
|
74
110
|
// --- Explicit file-subset path ---
|
|
75
111
|
// When --files is given, the target filesAffected is authoritative — drop
|
|
@@ -370,7 +406,8 @@ export function registerReExport(program, { getProjectRoot, withErrorHandling })
|
|
|
370
406
|
.filter((v) => v.length > 0))
|
|
371
407
|
.option('--dry-run', 'Show what would change without writing')
|
|
372
408
|
.option('--skip-lint', 'Skip patch lint checks (downgrade errors to warnings)')
|
|
373
|
-
.option('-
|
|
409
|
+
.option('--allow-shrink', 'Allow --files to remove paths currently owned by the patch. Required before --yes can bypass the shrink confirmation.')
|
|
410
|
+
.option('-y, --yes', 'Skip confirmation prompts (required for non-TTY destructive writes)')
|
|
374
411
|
.option('--force-unsafe', 'Bypass cross-patch lint refusal when --files shrinks a patch')
|
|
375
412
|
.option('--stamp', "After every selected patch refreshes cleanly, stamp each re-exported patch's sourceEsrVersion in patches.json to firefox.version from fireforge.json. No effect on a partial run.")
|
|
376
413
|
.addOption(new Option('--tier <tier>', 'Force a tier override on the selected patch (only "branding" recognised). Mutually exclusive with --all.').choices(['branding']))
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { join } from 'node:path';
|
|
18
18
|
import { getProjectPaths, loadConfig } from '../core/config.js';
|
|
19
|
+
import { isGitRepository } from '../core/git.js';
|
|
20
|
+
import { expandUntrackedDirectoryEntries, getWorkingTreeStatus } from '../core/git-status.js';
|
|
19
21
|
import { buildPatchQueueContext, lintPatchQueue } from '../core/patch-lint.js';
|
|
20
22
|
import { loadPatchesManifest, validatePatchesManifestConsistency } from '../core/patch-manifest.js';
|
|
21
23
|
import { evaluatePatchPolicy } from '../core/patch-policy.js';
|
|
@@ -100,6 +102,15 @@ function detectCrossPatchFileClaims(manifestPatches) {
|
|
|
100
102
|
}
|
|
101
103
|
return results;
|
|
102
104
|
}
|
|
105
|
+
async function detectUnownedWorktreeChanges(engineDir, claimedFiles) {
|
|
106
|
+
if (!(await pathExists(engineDir)) || !(await isGitRepository(engineDir))) {
|
|
107
|
+
return [];
|
|
108
|
+
}
|
|
109
|
+
const entries = await expandUntrackedDirectoryEntries(engineDir, await getWorkingTreeStatus(engineDir));
|
|
110
|
+
return [
|
|
111
|
+
...new Set(entries.map((entry) => entry.file).filter((file) => !claimedFiles.has(file))),
|
|
112
|
+
].sort();
|
|
113
|
+
}
|
|
103
114
|
/**
|
|
104
115
|
* Collects the same queue-health findings reported by `fireforge verify`
|
|
105
116
|
* without printing. Used by doctor recovery paths that need a read-only
|
|
@@ -177,6 +188,22 @@ export async function collectPatchQueueHealth(projectRoot) {
|
|
|
177
188
|
warningCount += lintWarnings;
|
|
178
189
|
}
|
|
179
190
|
if (manifest) {
|
|
191
|
+
const claimedFiles = new Set();
|
|
192
|
+
for (const patch of manifest.patches) {
|
|
193
|
+
for (const file of patch.filesAffected) {
|
|
194
|
+
claimedFiles.add(file);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
const unownedWorktreeChanges = await detectUnownedWorktreeChanges(paths.engine, claimedFiles);
|
|
198
|
+
if (unownedWorktreeChanges.length > 0) {
|
|
199
|
+
groups.push({
|
|
200
|
+
title: `Unowned worktree changes (${unownedWorktreeChanges.length})`,
|
|
201
|
+
issues: unownedWorktreeChanges.map((file) => `${file} is changed in engine/ but is not listed in any patch filesAffected entry`),
|
|
202
|
+
errorCount: 0,
|
|
203
|
+
warningCount: unownedWorktreeChanges.length,
|
|
204
|
+
});
|
|
205
|
+
warningCount += unownedWorktreeChanges.length;
|
|
206
|
+
}
|
|
180
207
|
const registrationIssues = await detectDanglingRegistrations(paths.patches, paths.engine, manifest.patches);
|
|
181
208
|
if (registrationIssues.length > 0) {
|
|
182
209
|
groups.push({
|
|
@@ -54,6 +54,11 @@ export class BrandingMozconfigMismatchError extends FireForgeError {
|
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
56
|
const MOZ_APP_VENDOR_IMPLY_REGEX = /imply_option\("MOZ_APP_VENDOR",\s*"[^"]*"\)/;
|
|
57
|
+
const BRANDING_CONFIGURE_MANAGED_KEYS = new Set([
|
|
58
|
+
'MOZ_APP_DISPLAYNAME',
|
|
59
|
+
'MOZ_APP_VENDOR',
|
|
60
|
+
'MOZ_MACBUNDLE_ID',
|
|
61
|
+
]);
|
|
57
62
|
/**
|
|
58
63
|
* Sets up the custom branding directory for the browser.
|
|
59
64
|
*
|
|
@@ -91,16 +96,58 @@ export async function setupBranding(engineDir, config) {
|
|
|
91
96
|
*/
|
|
92
97
|
async function createConfigureScript(brandingDir, config, vendorPlacement) {
|
|
93
98
|
const configureShPath = join(brandingDir, 'configure.sh');
|
|
94
|
-
await
|
|
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)) {
|
|
@@ -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
|
}
|
|
@@ -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
|
*
|
package/dist/src/core/git.js
CHANGED
|
@@ -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
|
|
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?.(
|
|
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
|
-
|
|
67
|
-
|
|
74
|
+
const owners = patchClaims.get(entry.file);
|
|
75
|
+
const primaryCode = getPrimaryStatusCode(entry.status);
|
|
76
|
+
// Branding paths are tool-managed for generated edits, but a brand-new
|
|
77
|
+
// unowned branding asset must not disappear from `status --unmanaged`.
|
|
78
|
+
// The Hominis Firefox 152 side-grade added Assets.car under the active
|
|
79
|
+
// branding tree; classifying every branding path before checking
|
|
80
|
+
// ownership hid that new patch candidate as "branding" even though no
|
|
81
|
+
// patch claimed it yet.
|
|
82
|
+
const isUnownedNewFile = owners === undefined && (primaryCode === '?' || primaryCode === 'A');
|
|
83
|
+
if (isBrandingManagedPath(entry.file, binaryName) &&
|
|
84
|
+
(!isUnownedNewFile || isGeneratedBrandingPath(entry.file, binaryName))) {
|
|
68
85
|
results.push({ ...entry, classification: 'branding' });
|
|
69
86
|
continue;
|
|
70
87
|
}
|
|
@@ -82,7 +99,6 @@ export async function classifyFiles(files, engineDir, patchesDir, binaryName, fu
|
|
|
82
99
|
continue;
|
|
83
100
|
}
|
|
84
101
|
}
|
|
85
|
-
const owners = patchClaims.get(entry.file);
|
|
86
102
|
// Multiple patches claim this file — surface the cross-patch
|
|
87
103
|
// ownership conflict regardless of whether the current content
|
|
88
104
|
// matches any single claim. `--ownership` reports the same state
|
|
@@ -102,7 +118,6 @@ export async function classifyFiles(files, engineDir, patchesDir, binaryName, fu
|
|
|
102
118
|
continue;
|
|
103
119
|
}
|
|
104
120
|
// File is claimed by exactly one patch — compare content.
|
|
105
|
-
const primaryCode = getPrimaryStatusCode(entry.status);
|
|
106
121
|
if (primaryCode === 'D') {
|
|
107
122
|
// Deleted file: patch-backed only if patch expects deletion
|
|
108
123
|
const expected = await computePatchedContent(patchesDir, engineDir, entry.file);
|
|
@@ -184,6 +184,12 @@ export interface ReExportOptions {
|
|
|
184
184
|
skipLint?: boolean;
|
|
185
185
|
/** Skip confirmation prompt on shrink (required for non-TTY) */
|
|
186
186
|
yes?: boolean;
|
|
187
|
+
/**
|
|
188
|
+
* Explicitly allow `--files` to remove paths that are currently owned by
|
|
189
|
+
* the patch. Without this acknowledgement, non-dry-run shrinks are refused
|
|
190
|
+
* before the interactive/`--yes` confirmation path.
|
|
191
|
+
*/
|
|
192
|
+
allowShrink?: boolean;
|
|
187
193
|
/** Bypass cross-patch lint refusal on projected shrink state */
|
|
188
194
|
forceUnsafe?: boolean;
|
|
189
195
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hominis/fireforge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.0",
|
|
4
4
|
"description": "FireForge — a build tool for customizing Firefox",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/src/index.js",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"dependencies": {
|
|
54
54
|
"@clack/prompts": "^1.2.0",
|
|
55
55
|
"acorn": "^8.14.0",
|
|
56
|
-
"commander": "^
|
|
56
|
+
"commander": "^15.0.0",
|
|
57
57
|
"estree-walker": "^3.0.3",
|
|
58
58
|
"magic-string": "^0.30.17",
|
|
59
59
|
"picocolors": "^1.1.0"
|