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