@hominis/fireforge 0.15.8 → 0.16.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 +85 -0
- package/README.md +16 -3
- package/dist/src/cli.d.ts +4 -1
- package/dist/src/cli.js +6 -3
- package/dist/src/commands/download.js +9 -0
- package/dist/src/commands/export-all.js +46 -0
- package/dist/src/commands/export-shared.d.ts +6 -1
- package/dist/src/commands/export-shared.js +7 -2
- package/dist/src/commands/export.js +10 -1
- package/dist/src/commands/furnace/diff.js +22 -2
- package/dist/src/commands/furnace/override.js +35 -12
- package/dist/src/commands/furnace/preview.js +33 -1
- package/dist/src/commands/furnace/rename.js +14 -3
- package/dist/src/commands/lint.d.ts +20 -0
- package/dist/src/commands/lint.js +167 -45
- package/dist/src/commands/package.js +16 -5
- package/dist/src/commands/re-export-files.js +6 -2
- package/dist/src/commands/re-export.js +62 -4
- package/dist/src/commands/register.js +2 -18
- package/dist/src/commands/run.js +23 -2
- package/dist/src/commands/status.js +25 -3
- package/dist/src/commands/test.js +6 -24
- package/dist/src/commands/token.js +14 -1
- package/dist/src/commands/watch.js +14 -2
- package/dist/src/core/branding.d.ts +23 -0
- package/dist/src/core/branding.js +39 -0
- package/dist/src/core/browser-wire.js +68 -23
- package/dist/src/core/mach-build-artifacts.d.ts +41 -0
- package/dist/src/core/mach-build-artifacts.js +70 -0
- package/dist/src/core/mach-error-hints.js +15 -0
- package/dist/src/core/mach-mozconfig.d.ts +25 -0
- package/dist/src/core/mach-mozconfig.js +66 -0
- package/dist/src/core/mach.d.ts +12 -1
- package/dist/src/core/mach.js +14 -1
- package/dist/src/core/manifest-rules.js +22 -1
- package/dist/src/core/patch-lint.d.ts +6 -1
- package/dist/src/core/patch-lint.js +14 -1
- package/dist/src/types/commands/options.d.ts +10 -0
- package/dist/src/types/commands/patches.d.ts +22 -0
- package/dist/src/utils/fs.d.ts +12 -0
- package/dist/src/utils/fs.js +12 -0
- package/dist/src/utils/paths.d.ts +19 -0
- package/dist/src/utils/paths.js +33 -0
- package/package.json +1 -1
|
@@ -25,6 +25,26 @@ export interface LintCommandOptions {
|
|
|
25
25
|
* rejected up-front rather than silently ignored.
|
|
26
26
|
*/
|
|
27
27
|
onlyIntroduced?: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Lint each patch in the queue as its own isolated diff, rather than
|
|
30
|
+
* the aggregate `git diff HEAD` across all applied patches.
|
|
31
|
+
*
|
|
32
|
+
* Motivating case: running `fireforge lint` (no args) on a repo where
|
|
33
|
+
* `fireforge import` or `fireforge rebase` has just applied the full
|
|
34
|
+
* patch queue produces an aggregate diff (every patch's changes
|
|
35
|
+
* summed). The patch-size advisory rules (`large-patch-lines`,
|
|
36
|
+
* `large-patch-files`) then fire against the sum — e.g. "Patch is
|
|
37
|
+
* 37529 lines" on a queue of 22 individually-fine patches — which
|
|
38
|
+
* reads as a task-specific regression when it is really an artefact
|
|
39
|
+
* of the aggregation. `--per-patch` rescopes the diff to each patch's
|
|
40
|
+
* own `filesAffected`, honours the patch's own `lintIgnore`, and runs
|
|
41
|
+
* the cross-patch rules once over the whole queue so queue-level
|
|
42
|
+
* findings (duplicate creations, forward imports) still surface.
|
|
43
|
+
*
|
|
44
|
+
* Mutually exclusive with passing explicit file paths — the two
|
|
45
|
+
* scope contracts are different.
|
|
46
|
+
*/
|
|
47
|
+
perPatch?: boolean;
|
|
28
48
|
}
|
|
29
49
|
/**
|
|
30
50
|
* Runs the lint command to check engine changes against patch quality rules.
|
|
@@ -8,40 +8,36 @@ import { getModifiedFilesInDir, getUntrackedFiles, getUntrackedFilesInDir, } fro
|
|
|
8
8
|
import { extractAffectedFiles } from '../core/patch-apply.js';
|
|
9
9
|
import { buildPatchQueueContext, lintExportedPatch, lintPatchQueue } from '../core/patch-lint.js';
|
|
10
10
|
import { collectDiffFilePaths, tagLintIssues } from '../core/patch-lint-diff-tag.js';
|
|
11
|
+
import { loadPatchesManifest } from '../core/patch-manifest.js';
|
|
11
12
|
import { GeneralError } from '../errors/base.js';
|
|
12
13
|
import { pathExists } from '../utils/fs.js';
|
|
13
14
|
import { info, intro, outro, success, warn } from '../utils/logger.js';
|
|
15
|
+
import { stripEnginePrefix } from '../utils/paths.js';
|
|
14
16
|
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
17
|
+
* Resolves the diff the lint command should run against. Returns `null` when
|
|
18
|
+
* there is nothing to lint (e.g. no matching files, clean tree, or empty
|
|
19
|
+
* diff content) — callers treat that as the early-exit signal and stop.
|
|
20
|
+
*
|
|
21
|
+
* Extracted from {@link lintCommand} so that function stays under the
|
|
22
|
+
* per-function LOC budget as the command grows; the two file-mode and
|
|
23
|
+
* aggregate-mode branches share no state with the post-lint reporting
|
|
24
|
+
* pipeline, so the split is a pure rename rather than a refactor.
|
|
19
25
|
*/
|
|
20
|
-
|
|
21
|
-
intro('FireForge Lint');
|
|
22
|
-
// `--only-introduced` scopes the exit code to `--since`-tagged issues, so
|
|
23
|
-
// without a revision to anchor the diff there is no "introduced" subset
|
|
24
|
-
// to scope to — reject the combination up-front so a misconfigured CI
|
|
25
|
-
// invocation fails loud instead of silently treating every error as
|
|
26
|
-
// cumulative and passing.
|
|
27
|
-
if (options.onlyIntroduced && !options.since) {
|
|
28
|
-
throw new GeneralError('--only-introduced requires --since <git-rev> so introduced-vs-cumulative can be distinguished.');
|
|
29
|
-
}
|
|
30
|
-
const paths = getProjectPaths(projectRoot);
|
|
31
|
-
if (!(await pathExists(paths.engine))) {
|
|
32
|
-
throw new GeneralError('Firefox source not found. Run "fireforge download" first.');
|
|
33
|
-
}
|
|
34
|
-
if (!(await isGitRepository(paths.engine))) {
|
|
35
|
-
throw new GeneralError('Engine directory is not a git repository. Run "fireforge download" to initialize.');
|
|
36
|
-
}
|
|
37
|
-
let diff;
|
|
26
|
+
async function resolveLintDiff(engineDir, files) {
|
|
38
27
|
if (files.length > 0) {
|
|
39
|
-
// Collect specific files/directories
|
|
40
28
|
const collectedFiles = new Set();
|
|
41
29
|
let fileStatuses;
|
|
42
30
|
let untrackedFiles;
|
|
43
|
-
|
|
44
|
-
|
|
31
|
+
// Strip a leading `engine/` segment up-front so the rest of the lookup
|
|
32
|
+
// pipeline (directory stat, modified-files-in-dir, status probe) all
|
|
33
|
+
// see the engine-relative form. Without this, passing
|
|
34
|
+
// `engine/browser/base/content/foo.js` fell through to "No modified
|
|
35
|
+
// files found in the specified paths." because git sees every path
|
|
36
|
+
// relative to engine/. The same normalization runs in `register`,
|
|
37
|
+
// `test`, and `export` via `stripEnginePrefix`.
|
|
38
|
+
const normalizedFiles = files.map((inputPath) => stripEnginePrefix(inputPath));
|
|
39
|
+
for (const inputPath of normalizedFiles) {
|
|
40
|
+
const fullInputPath = join(engineDir, inputPath);
|
|
45
41
|
let isDirectory = false;
|
|
46
42
|
try {
|
|
47
43
|
const fileStat = await stat(fullInputPath);
|
|
@@ -52,47 +48,87 @@ export async function lintCommand(projectRoot, files, options = {}) {
|
|
|
52
48
|
}
|
|
53
49
|
if (isDirectory) {
|
|
54
50
|
const dirPath = inputPath.endsWith('/') ? inputPath.slice(0, -1) : inputPath;
|
|
55
|
-
const modifiedFiles = await getModifiedFilesInDir(
|
|
56
|
-
const dirUntrackedFiles = await getUntrackedFilesInDir(
|
|
51
|
+
const modifiedFiles = await getModifiedFilesInDir(engineDir, dirPath);
|
|
52
|
+
const dirUntrackedFiles = await getUntrackedFilesInDir(engineDir, dirPath);
|
|
57
53
|
for (const f of modifiedFiles)
|
|
58
54
|
collectedFiles.add(f);
|
|
59
55
|
for (const f of dirUntrackedFiles)
|
|
60
56
|
collectedFiles.add(f);
|
|
61
57
|
}
|
|
62
58
|
else {
|
|
63
|
-
if (!fileStatuses)
|
|
64
|
-
fileStatuses = await getStatusWithCodes(
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
untrackedFiles = await getUntrackedFiles(paths.engine);
|
|
68
|
-
}
|
|
59
|
+
if (!fileStatuses)
|
|
60
|
+
fileStatuses = await getStatusWithCodes(engineDir);
|
|
61
|
+
if (!untrackedFiles)
|
|
62
|
+
untrackedFiles = await getUntrackedFiles(engineDir);
|
|
69
63
|
const hasStatus = fileStatuses.some((s) => s.file === inputPath) || untrackedFiles.includes(inputPath);
|
|
70
|
-
if (hasStatus)
|
|
64
|
+
if (hasStatus)
|
|
71
65
|
collectedFiles.add(inputPath);
|
|
72
|
-
}
|
|
73
66
|
}
|
|
74
67
|
}
|
|
75
68
|
if (collectedFiles.size === 0) {
|
|
76
69
|
info('No modified files found in the specified paths.');
|
|
77
70
|
outro('Nothing to lint');
|
|
78
|
-
return;
|
|
71
|
+
return null;
|
|
79
72
|
}
|
|
80
|
-
diff = await getDiffForFilesAgainstHead(
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
// Lint all changes
|
|
84
|
-
if (!(await hasChanges(paths.engine))) {
|
|
85
|
-
info('No changes to lint.');
|
|
73
|
+
const diff = await getDiffForFilesAgainstHead(engineDir, [...collectedFiles].sort());
|
|
74
|
+
if (!diff.trim()) {
|
|
75
|
+
info('No diff content to lint.');
|
|
86
76
|
outro('Nothing to lint');
|
|
87
|
-
return;
|
|
77
|
+
return null;
|
|
88
78
|
}
|
|
89
|
-
diff
|
|
79
|
+
return diff;
|
|
90
80
|
}
|
|
81
|
+
if (!(await hasChanges(engineDir))) {
|
|
82
|
+
info('No changes to lint.');
|
|
83
|
+
outro('Nothing to lint');
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
const diff = await getAllDiff(engineDir);
|
|
91
87
|
if (!diff.trim()) {
|
|
92
88
|
info('No diff content to lint.');
|
|
93
89
|
outro('Nothing to lint');
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
return diff;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Runs the lint command to check engine changes against patch quality rules.
|
|
96
|
+
* @param projectRoot - Root directory of the project
|
|
97
|
+
* @param files - Optional file/directory paths to lint (relative to engine/)
|
|
98
|
+
* @param options - Additional lint options such as `--since` diff-scoping
|
|
99
|
+
*/
|
|
100
|
+
export async function lintCommand(projectRoot, files, options = {}) {
|
|
101
|
+
intro('FireForge Lint');
|
|
102
|
+
// `--only-introduced` scopes the exit code to `--since`-tagged issues, so
|
|
103
|
+
// without a revision to anchor the diff there is no "introduced" subset
|
|
104
|
+
// to scope to — reject the combination up-front so a misconfigured CI
|
|
105
|
+
// invocation fails loud instead of silently treating every error as
|
|
106
|
+
// cumulative and passing.
|
|
107
|
+
if (options.onlyIntroduced && !options.since) {
|
|
108
|
+
throw new GeneralError('--only-introduced requires --since <git-rev> so introduced-vs-cumulative can be distinguished.');
|
|
109
|
+
}
|
|
110
|
+
// `--per-patch` rescopes the diff from "aggregate engine state" to "each
|
|
111
|
+
// patch's own filesAffected". Mixing in explicit file paths would produce
|
|
112
|
+
// an ambiguous set — is the file list an additional filter, or does it
|
|
113
|
+
// replace the per-patch scope? Reject up-front so the operator gets a
|
|
114
|
+
// clear error rather than a silently-narrowed result.
|
|
115
|
+
if (options.perPatch && files.length > 0) {
|
|
116
|
+
throw new GeneralError('--per-patch cannot be combined with explicit file paths. Pass either --per-patch or a file list, not both.');
|
|
117
|
+
}
|
|
118
|
+
const paths = getProjectPaths(projectRoot);
|
|
119
|
+
if (!(await pathExists(paths.engine))) {
|
|
120
|
+
throw new GeneralError('Firefox source not found. Run "fireforge download" first.');
|
|
121
|
+
}
|
|
122
|
+
if (!(await isGitRepository(paths.engine))) {
|
|
123
|
+
throw new GeneralError('Engine directory is not a git repository. Run "fireforge download" to initialize.');
|
|
124
|
+
}
|
|
125
|
+
if (options.perPatch) {
|
|
126
|
+
await lintPerPatch(projectRoot, paths);
|
|
94
127
|
return;
|
|
95
128
|
}
|
|
129
|
+
const diff = await resolveLintDiff(paths.engine, files);
|
|
130
|
+
if (diff === null)
|
|
131
|
+
return;
|
|
96
132
|
const config = await loadConfig(projectRoot);
|
|
97
133
|
const filesAffected = extractAffectedFiles(diff);
|
|
98
134
|
// Build patch queue context once so it can be shared between the
|
|
@@ -110,6 +146,19 @@ export async function lintCommand(projectRoot, files, options = {}) {
|
|
|
110
146
|
if (ctx) {
|
|
111
147
|
issues.push(...lintPatchQueue(ctx));
|
|
112
148
|
}
|
|
149
|
+
// When a queue manifest exists AND files were NOT scoped explicitly, the
|
|
150
|
+
// "diff" we just linted is every applied patch summed together. Patch-
|
|
151
|
+
// size rules (`large-patch-lines`, `large-patch-files`) then fire against
|
|
152
|
+
// the aggregate rather than any individual patch, producing counts like
|
|
153
|
+
// "Patch is 37529 lines" that read as a task-specific regression but are
|
|
154
|
+
// really an artefact of aggregation. Surface a one-line note pointing at
|
|
155
|
+
// `--per-patch` so the operator knows the per-patch scope exists before
|
|
156
|
+
// they read the error message as "my queue is broken".
|
|
157
|
+
const aggregateHintApplicable = files.length === 0 && ctx !== undefined && ctx.entries.length > 1;
|
|
158
|
+
if (aggregateHintApplicable &&
|
|
159
|
+
issues.some((i) => i.check === 'large-patch-lines' || i.check === 'large-patch-files')) {
|
|
160
|
+
info('NOTE: aggregate diff across all applied patches. Use `fireforge lint --per-patch` to lint each patch individually; patch-size rules fire against the sum in aggregate mode.');
|
|
161
|
+
}
|
|
113
162
|
if (issues.length === 0) {
|
|
114
163
|
success('No lint issues found.');
|
|
115
164
|
outro('Lint passed');
|
|
@@ -164,13 +213,83 @@ export async function lintCommand(projectRoot, files, options = {}) {
|
|
|
164
213
|
}
|
|
165
214
|
outro('Lint passed with warnings');
|
|
166
215
|
}
|
|
216
|
+
/**
|
|
217
|
+
* Lints each patch in the queue as its own isolated diff, honouring
|
|
218
|
+
* per-patch `lintIgnore` entries. Cross-patch rules still run once over
|
|
219
|
+
* the whole queue so queue-level findings (duplicate creations, forward
|
|
220
|
+
* imports) are not lost by the rescoping.
|
|
221
|
+
*
|
|
222
|
+
* Kept separate from {@link lintCommand}'s aggregate path because the
|
|
223
|
+
* two scopes have genuinely different contracts — the aggregate path
|
|
224
|
+
* reports what `git diff HEAD` looks like right now, the per-patch
|
|
225
|
+
* path reports what each patch's own slice of that diff looks like.
|
|
226
|
+
* Sharing a loop would hide the distinction and force the caller to
|
|
227
|
+
* decide semantics mid-function.
|
|
228
|
+
*/
|
|
229
|
+
async function lintPerPatch(projectRoot, paths) {
|
|
230
|
+
const manifest = await loadPatchesManifest(paths.patches);
|
|
231
|
+
if (!manifest || manifest.patches.length === 0) {
|
|
232
|
+
info('No patches in manifest — nothing to lint per-patch.');
|
|
233
|
+
outro('Nothing to lint');
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const config = await loadConfig(projectRoot);
|
|
237
|
+
const ctx = await buildPatchQueueContext(paths.patches);
|
|
238
|
+
const issues = [];
|
|
239
|
+
let linted = 0;
|
|
240
|
+
for (const patch of manifest.patches) {
|
|
241
|
+
const existing = [];
|
|
242
|
+
for (const f of patch.filesAffected) {
|
|
243
|
+
if (await pathExists(join(paths.engine, f)))
|
|
244
|
+
existing.push(f);
|
|
245
|
+
}
|
|
246
|
+
if (existing.length === 0)
|
|
247
|
+
continue;
|
|
248
|
+
const diff = await getDiffForFilesAgainstHead(paths.engine, existing);
|
|
249
|
+
if (!diff.trim())
|
|
250
|
+
continue;
|
|
251
|
+
const ignore = patch.lintIgnore?.length ? new Set(patch.lintIgnore) : undefined;
|
|
252
|
+
const patchIssues = await lintExportedPatch(paths.engine, existing, diff, config, ctx, ignore);
|
|
253
|
+
for (const issue of patchIssues) {
|
|
254
|
+
issues.push({ ...issue, file: `${patch.filename} :: ${issue.file}` });
|
|
255
|
+
}
|
|
256
|
+
linted++;
|
|
257
|
+
}
|
|
258
|
+
// Cross-patch rules over the whole queue — rescoping per-patch would
|
|
259
|
+
// lose these findings, so they run exactly once against the full
|
|
260
|
+
// context.
|
|
261
|
+
issues.push(...lintPatchQueue(ctx));
|
|
262
|
+
if (issues.length === 0) {
|
|
263
|
+
success(`No lint issues found across ${linted} patch(es).`);
|
|
264
|
+
outro('Lint passed');
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
const errors = issues.filter((i) => i.severity === 'error');
|
|
268
|
+
const warnings = issues.filter((i) => i.severity === 'warning');
|
|
269
|
+
const notices = issues.filter((i) => i.severity === 'notice');
|
|
270
|
+
for (const issue of notices)
|
|
271
|
+
info(`NOTICE [${issue.check}] ${issue.file}: ${issue.message}`);
|
|
272
|
+
for (const issue of warnings)
|
|
273
|
+
warn(`[${issue.check}] ${issue.file}: ${issue.message}`);
|
|
274
|
+
for (const issue of errors)
|
|
275
|
+
warn(`ERROR [${issue.check}] ${issue.file}: ${issue.message}`);
|
|
276
|
+
info(`\nLint (per-patch over ${linted} patch(es)): ${errors.length} error(s), ${warnings.length} warning(s)`);
|
|
277
|
+
if (errors.length > 0) {
|
|
278
|
+
outro('Lint failed');
|
|
279
|
+
throw new GeneralError(`Patch lint found ${errors.length} error(s) across ${linted} patch(es). Fix these before exporting.`);
|
|
280
|
+
}
|
|
281
|
+
outro('Lint passed with warnings');
|
|
282
|
+
}
|
|
167
283
|
/** Registers the lint command on the CLI program. */
|
|
168
284
|
export function registerLint(program, { getProjectRoot, withErrorHandling }) {
|
|
169
285
|
program
|
|
170
286
|
.command('lint [paths...]')
|
|
171
|
-
.description('Lint engine changes against patch quality rules'
|
|
287
|
+
.description('Lint engine changes against patch quality rules. Default: aggregate diff against HEAD ' +
|
|
288
|
+
'(every applied patch summed). Use --per-patch for per-patch scope, or pass explicit ' +
|
|
289
|
+
'file paths to narrow to those.')
|
|
172
290
|
.option('--since <git-rev>', 'Tag issues as [introduced] or [cumulative] based on whether the file changed since <git-rev> (e.g. HEAD, a branch, a SHA)')
|
|
173
291
|
.option('--only-introduced', 'Fail only on issues tagged [introduced] (requires --since). Cumulative errors still print but do not set a non-zero exit.')
|
|
292
|
+
.option('--per-patch', "Lint each patch in the queue as its own isolated diff. Rescopes patch-size rules so they fire against individual patches rather than the aggregate. Honours each patch's `lintIgnore` entries.")
|
|
174
293
|
.action(withErrorHandling(async (paths, options) => {
|
|
175
294
|
const lintOptions = {};
|
|
176
295
|
if (options.since !== undefined) {
|
|
@@ -179,6 +298,9 @@ export function registerLint(program, { getProjectRoot, withErrorHandling }) {
|
|
|
179
298
|
if (options.onlyIntroduced !== undefined) {
|
|
180
299
|
lintOptions.onlyIntroduced = options.onlyIntroduced;
|
|
181
300
|
}
|
|
301
|
+
if (options.perPatch !== undefined) {
|
|
302
|
+
lintOptions.perPatch = options.perPatch;
|
|
303
|
+
}
|
|
182
304
|
await lintCommand(getProjectRoot(), paths, lintOptions);
|
|
183
305
|
}));
|
|
184
306
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { validateBrandOverride } from '../core/brand-validation.js';
|
|
2
2
|
import { prepareBuildEnvironment } from '../core/build-prepare.js';
|
|
3
3
|
import { getProjectPaths, loadConfig } from '../core/config.js';
|
|
4
|
-
import { buildArtifactMismatchMessage, hasBuildArtifacts,
|
|
4
|
+
import { buildArtifactMismatchMessage, hasBuildArtifacts, machPackageCapture, } from '../core/mach.js';
|
|
5
|
+
import { explainMachError } from '../core/mach-error-hints.js';
|
|
5
6
|
import { GeneralError } from '../errors/base.js';
|
|
6
7
|
import { AmbiguousBuildArtifactsError, BuildError } from '../errors/build.js';
|
|
7
8
|
import { pathExists } from '../utils/fs.js';
|
|
@@ -49,9 +50,16 @@ export async function packageCommand(projectRoot, options) {
|
|
|
49
50
|
info('Creating distribution package...');
|
|
50
51
|
info('This may take a while.\n');
|
|
51
52
|
const startTime = Date.now();
|
|
52
|
-
let
|
|
53
|
+
let result;
|
|
53
54
|
try {
|
|
54
|
-
|
|
55
|
+
// `machPackageCapture` streams output live AND captures the tail for
|
|
56
|
+
// post-run diagnostics. Previously `machPackage` inherited stdio
|
|
57
|
+
// only, so a targeted hint translator could not see the failure text.
|
|
58
|
+
// The captured stderr is fed through `explainMachError` below so
|
|
59
|
+
// recognised failure modes (notably the `packager.py` NoneType trip
|
|
60
|
+
// the evaluator hit on `hominis/`) get an actionable hint prepended
|
|
61
|
+
// to the raw mach output the operator already saw.
|
|
62
|
+
result = await machPackageCapture(paths.engine);
|
|
55
63
|
}
|
|
56
64
|
catch (error) {
|
|
57
65
|
throw new BuildError('Package process failed to start', 'mach package', error instanceof Error ? error : undefined);
|
|
@@ -60,9 +68,12 @@ export async function packageCommand(projectRoot, options) {
|
|
|
60
68
|
const minutes = Math.floor(duration / 60000);
|
|
61
69
|
const seconds = Math.floor((duration % 60000) / 1000);
|
|
62
70
|
const timeStr = minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
|
|
63
|
-
if (exitCode !== 0) {
|
|
71
|
+
if (result.exitCode !== 0) {
|
|
64
72
|
error(`Packaging failed after ${timeStr}`);
|
|
65
|
-
|
|
73
|
+
const combinedOutput = `${result.stdout}\n${result.stderr}`;
|
|
74
|
+
const hints = explainMachError(combinedOutput);
|
|
75
|
+
const hintBlock = hints.length > 0 ? `\n\nHint:\n${hints.map((h) => ` ${h}`).join('\n')}` : '';
|
|
76
|
+
throw new BuildError(`Packaging failed with exit code ${result.exitCode}.${hintBlock}`, 'mach package');
|
|
66
77
|
}
|
|
67
78
|
info(`\nPackage created in obj-*/dist/`);
|
|
68
79
|
outro(`Packaging completed in ${timeStr}!`);
|
|
@@ -71,8 +71,12 @@ export async function reExportFilesInPlace(paths, selectedPatches, options, conf
|
|
|
71
71
|
'Remove those paths from --files or modify them before retrying.', '--files');
|
|
72
72
|
}
|
|
73
73
|
// Run the per-patch lint against the projected diff. This mirrors what
|
|
74
|
-
// runPatchLint does in the standard re-export path.
|
|
75
|
-
|
|
74
|
+
// runPatchLint does in the standard re-export path. The target patch's
|
|
75
|
+
// `lintIgnore` threads through so a shrink of an advisory-noisy-but-
|
|
76
|
+
// intentional patch (branding bundle, localised-resource pack) does not
|
|
77
|
+
// have to choose between `--skip-lint` (blunt) and the full rebase path.
|
|
78
|
+
const ignoreChecks = target.lintIgnore?.length ? new Set(target.lintIgnore) : undefined;
|
|
79
|
+
await runPatchLint(paths.engine, actualProjectedFiles, projectedDiff, config, options.skipLint, undefined, ignoreChecks);
|
|
76
80
|
// Project the cross-patch context: replace the target entry with its
|
|
77
81
|
// would-be shrunken self (new diff + new newFiles + new
|
|
78
82
|
// modifiedFileAdditions). The projected entry must repopulate both
|
|
@@ -6,7 +6,7 @@ import { isGitRepository } from '../core/git.js';
|
|
|
6
6
|
import { getDiffForFilesAgainstHead } from '../core/git-diff.js';
|
|
7
7
|
import { getModifiedFilesInDir, getUntrackedFilesInDir } from '../core/git-status.js';
|
|
8
8
|
import { updatePatchAndMetadata } from '../core/patch-export.js';
|
|
9
|
-
import { getClaimedFiles, loadPatchesManifest, resolvePatchIdentifier, } from '../core/patch-manifest.js';
|
|
9
|
+
import { getClaimedFiles, loadPatchesManifest, resolvePatchIdentifier, stampPatchVersions, } from '../core/patch-manifest.js';
|
|
10
10
|
import { GeneralError, InvalidArgumentError } from '../errors/base.js';
|
|
11
11
|
import { toError } from '../utils/errors.js';
|
|
12
12
|
import { pathExists } from '../utils/fs.js';
|
|
@@ -59,6 +59,31 @@ async function reExportSinglePatch(patch, paths, manifest, options, isDryRun, co
|
|
|
59
59
|
if (options.scan) {
|
|
60
60
|
currentFilesAffected = await scanPatchFiles(currentFilesAffected, paths.engine, manifest, patch.filename, isDryRun);
|
|
61
61
|
}
|
|
62
|
+
else if (options.files === undefined) {
|
|
63
|
+
// Finding #16: when neither `--scan` nor `--files` is set and some
|
|
64
|
+
// of the manifest's claimed files no longer exist on disk, the
|
|
65
|
+
// re-export silently writes a refreshed body whose filesAffected
|
|
66
|
+
// still names the vanished paths. That is the documented contract,
|
|
67
|
+
// but it is also a footgun — a later `verify` then fails on
|
|
68
|
+
// manifest-consistency with no obvious trigger. Emit one advisory
|
|
69
|
+
// warning up-front when we can detect the drift cheaply, so the
|
|
70
|
+
// operator has a chance to re-run with `--scan` or `--files`
|
|
71
|
+
// before the stale filesAffected lands in patches.json.
|
|
72
|
+
const missingFiles = [];
|
|
73
|
+
for (const file of currentFilesAffected) {
|
|
74
|
+
if (!(await pathExists(join(paths.engine, file)))) {
|
|
75
|
+
missingFiles.push(file);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (missingFiles.length > 0) {
|
|
79
|
+
warn(`${patch.filename}: some files in patches.json no longer exist on disk ` +
|
|
80
|
+
`(${missingFiles.join(', ')}). Without --scan, re-export keeps the manifest's ` +
|
|
81
|
+
`filesAffected unchanged and the missing entries will be preserved — ` +
|
|
82
|
+
`\`fireforge verify\` may flag manifest inconsistency after this run.\n` +
|
|
83
|
+
` Re-run with --scan to reconcile filesAffected with the current worktree, ` +
|
|
84
|
+
`or pass --files <paths> to set the list explicitly.`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
62
87
|
// --- Explicit file-subset path ---
|
|
63
88
|
// When --files is given, the target filesAffected is authoritative — drop
|
|
64
89
|
// anything not in the list, add anything new. This is the surgical repair
|
|
@@ -97,7 +122,14 @@ async function reExportSinglePatch(patch, paths, manifest, options, isDryRun, co
|
|
|
97
122
|
warn(`Skipped ${patch.filename}: no changes (files unchanged from HEAD)`);
|
|
98
123
|
return false;
|
|
99
124
|
}
|
|
100
|
-
|
|
125
|
+
// Thread the patch's own `lintIgnore` list through so the per-patch
|
|
126
|
+
// suppression honoured by export/export-all is also honoured here.
|
|
127
|
+
// Without this, `re-export` could not refresh an advisory-noisy but
|
|
128
|
+
// intentional patch (a cohesive branding bundle, a localised-resource
|
|
129
|
+
// pack) without either `--skip-lint` (too blunt) or falling through to
|
|
130
|
+
// the full `rebase` flow (which internally skips the lint pipeline).
|
|
131
|
+
const ignoreChecks = patch.lintIgnore?.length ? new Set(patch.lintIgnore) : undefined;
|
|
132
|
+
await runPatchLint(paths.engine, existingFiles, diffContent, config, options.skipLint, undefined, ignoreChecks);
|
|
101
133
|
if (isDryRun) {
|
|
102
134
|
info(`[dry-run] ${patch.filename}: ${existingFiles.length} file(s)`);
|
|
103
135
|
}
|
|
@@ -219,13 +251,16 @@ export async function reExportCommand(projectRoot, patches, options) {
|
|
|
219
251
|
}
|
|
220
252
|
const config = await loadConfig(projectRoot);
|
|
221
253
|
let reExported = 0;
|
|
254
|
+
const reExportedFilenames = [];
|
|
222
255
|
const progress = spinner('Preparing re-export...');
|
|
223
256
|
for (const patch of selectedPatches) {
|
|
224
257
|
progress.message(`Re-exporting ${patch.filename}...`);
|
|
225
258
|
try {
|
|
226
259
|
const exported = await reExportSinglePatch(patch, paths, manifest, options, isDryRun, config);
|
|
227
|
-
if (exported)
|
|
260
|
+
if (exported) {
|
|
228
261
|
reExported++;
|
|
262
|
+
reExportedFilenames.push(patch.filename);
|
|
263
|
+
}
|
|
229
264
|
}
|
|
230
265
|
catch (error) {
|
|
231
266
|
warn(`Failed to re-export ${patch.filename}`);
|
|
@@ -236,14 +271,34 @@ export async function reExportCommand(projectRoot, patches, options) {
|
|
|
236
271
|
progress.error('Re-export failed');
|
|
237
272
|
throw new GeneralError('All selected patches failed to re-export. Check the errors above.');
|
|
238
273
|
}
|
|
274
|
+
// `--stamp` only fires on a run where every selected patch refreshed
|
|
275
|
+
// cleanly. A partial success would leave some patches with a stale body
|
|
276
|
+
// but a new version — the opposite of the "what I tested, what the
|
|
277
|
+
// manifest says" invariant `sourceEsrVersion` exists to record. A
|
|
278
|
+
// non-empty `reExportedFilenames` with fewer entries than `selectedPatches`
|
|
279
|
+
// means a lint failure or missing-file skip landed somewhere in the loop,
|
|
280
|
+
// which we refuse to version-stamp through.
|
|
281
|
+
const shouldStamp = options.stamp === true && !isDryRun && reExported > 0 && reExported === selectedPatches.length;
|
|
282
|
+
if (shouldStamp) {
|
|
283
|
+
await stampPatchVersions(paths.patches, reExportedFilenames, config.firefox.version);
|
|
284
|
+
}
|
|
239
285
|
if (isDryRun) {
|
|
240
286
|
progress.stop('Dry run complete');
|
|
241
287
|
success(`[dry-run] Would re-export ${reExported} of ${selectedPatches.length} patch(es)`);
|
|
288
|
+
if (options.stamp === true) {
|
|
289
|
+
info(`[dry-run] Would stamp sourceEsrVersion=${config.firefox.version} on ${reExported} patch(es)`);
|
|
290
|
+
}
|
|
242
291
|
outro('Dry run complete');
|
|
243
292
|
}
|
|
244
293
|
else {
|
|
245
294
|
progress.stop('Re-export complete');
|
|
246
295
|
success(`Re-exported ${reExported} of ${selectedPatches.length} patch(es)`);
|
|
296
|
+
if (shouldStamp) {
|
|
297
|
+
success(`Stamped sourceEsrVersion=${config.firefox.version} on ${reExportedFilenames.length} patch(es)`);
|
|
298
|
+
}
|
|
299
|
+
else if (options.stamp === true && reExported !== selectedPatches.length) {
|
|
300
|
+
warn('--stamp was requested but some patches failed or were skipped; refusing to stamp a partial set.');
|
|
301
|
+
}
|
|
247
302
|
outro('Re-export complete');
|
|
248
303
|
}
|
|
249
304
|
}
|
|
@@ -251,7 +306,9 @@ export async function reExportCommand(projectRoot, patches, options) {
|
|
|
251
306
|
export function registerReExport(program, { getProjectRoot, withErrorHandling }) {
|
|
252
307
|
program
|
|
253
308
|
.command('re-export [patches...]')
|
|
254
|
-
.description('
|
|
309
|
+
.description('Refresh existing patch bodies (and filesAffected with --scan) from the current engine ' +
|
|
310
|
+
'state. Does NOT change sourceEsrVersion by default — use --stamp or run rebase for ' +
|
|
311
|
+
'version stamping.')
|
|
255
312
|
.option('-a, --all', 'Re-export all patches')
|
|
256
313
|
.option('-s, --scan', 'Scan directories for new/removed files and update filesAffected')
|
|
257
314
|
.option('--files <paths>', 'Restrict the re-exported filesAffected to this comma-separated list (single target patch only)', (value) => value
|
|
@@ -262,6 +319,7 @@ export function registerReExport(program, { getProjectRoot, withErrorHandling })
|
|
|
262
319
|
.option('--skip-lint', 'Skip patch lint checks (downgrade errors to warnings)')
|
|
263
320
|
.option('-y, --yes', 'Skip confirmation when --files shrinks a patch (required for non-TTY)')
|
|
264
321
|
.option('--force-unsafe', 'Bypass cross-patch lint refusal when --files shrinks a patch')
|
|
322
|
+
.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.")
|
|
265
323
|
.action(withErrorHandling(async (patches, options) => {
|
|
266
324
|
await reExportCommand(getProjectRoot(), patches, pickDefined(options));
|
|
267
325
|
}));
|
|
@@ -6,23 +6,7 @@ import { InvalidArgumentError } from '../errors/base.js';
|
|
|
6
6
|
import { pathExists } from '../utils/fs.js';
|
|
7
7
|
import { info, intro, outro, success, warn } from '../utils/logger.js';
|
|
8
8
|
import { pickDefined } from '../utils/options.js';
|
|
9
|
-
|
|
10
|
-
* Strips a leading `engine/` segment (either separator flavour) from a
|
|
11
|
-
* user-supplied path so operators can pass either a repo-root-relative
|
|
12
|
-
* path (`engine/browser/base/content/foo.xhtml`) or an engine-relative
|
|
13
|
-
* path (`browser/base/content/foo.xhtml`). The engine-relative form is
|
|
14
|
-
* what the manifest writers expect; without this normalisation, the
|
|
15
|
-
* former failed with a misleading "File not found in engine" pointing
|
|
16
|
-
* at a doubled path like `engine/engine/browser/...` that operators
|
|
17
|
-
* had no way to spot from the error message alone.
|
|
18
|
-
*/
|
|
19
|
-
function normalizeEngineRelativePath(filePath) {
|
|
20
|
-
if (filePath.startsWith('engine/'))
|
|
21
|
-
return filePath.slice('engine/'.length);
|
|
22
|
-
if (filePath.startsWith('engine\\'))
|
|
23
|
-
return filePath.slice('engine\\'.length);
|
|
24
|
-
return filePath;
|
|
25
|
-
}
|
|
9
|
+
import { stripEnginePrefix } from '../utils/paths.js';
|
|
26
10
|
/**
|
|
27
11
|
* Registers a file in the appropriate build manifest.
|
|
28
12
|
*
|
|
@@ -50,7 +34,7 @@ export async function registerCommand(projectRoot, filePath, options = {}) {
|
|
|
50
34
|
// the former from the output of tab completion or `git status`, and
|
|
51
35
|
// the mismatch used to produce a "File not found" error that named
|
|
52
36
|
// the original path with no hint that dropping `engine/` would fix it.
|
|
53
|
-
const engineRelativePath =
|
|
37
|
+
const engineRelativePath = stripEnginePrefix(filePath);
|
|
54
38
|
// Verify the file exists in engine/ (skip for dry-run)
|
|
55
39
|
if (!options.dryRun) {
|
|
56
40
|
const paths = getProjectPaths(projectRoot);
|
package/dist/src/commands/run.js
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
import { createWriteStream } from 'node:fs';
|
|
3
3
|
import { readdir, readFile } from 'node:fs/promises';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
-
import { getProjectPaths } from '../core/config.js';
|
|
5
|
+
import { getProjectPaths, loadConfig } from '../core/config.js';
|
|
6
6
|
import { warnIfFurnaceStale } from '../core/furnace-staleness.js';
|
|
7
|
-
import { buildArtifactMismatchMessage, hasBuildArtifacts, run, runMachSmoke, } from '../core/mach.js';
|
|
7
|
+
import { buildArtifactMismatchMessage, hasBuildArtifacts, hasRunnableBundle, run, runMachSmoke, } from '../core/mach.js';
|
|
8
8
|
import { compileAllowlistFromFile, compileAllowlistFromStrings, matchesAllowlist, matchesSmokeError, } from '../core/smoke-patterns.js';
|
|
9
9
|
import { GeneralError, InvalidArgumentError } from '../errors/base.js';
|
|
10
10
|
import { AmbiguousBuildArtifactsError, BuildError } from '../errors/build.js';
|
|
@@ -94,6 +94,27 @@ export async function runCommand(projectRoot, options = {}) {
|
|
|
94
94
|
throw new GeneralError(`Run requires a completed build. ${detail}\n\n` +
|
|
95
95
|
"Run 'fireforge build' first, then rerun 'fireforge run'.");
|
|
96
96
|
}
|
|
97
|
+
// `hasBuildArtifacts` only checks for an `obj-*/dist/` directory; a
|
|
98
|
+
// build that configured but hasn't yet produced the launchable binary
|
|
99
|
+
// (common in a long real Firefox compile that the operator stopped
|
|
100
|
+
// and restarted) passes that check, and `mach run` then fails on the
|
|
101
|
+
// missing binary path. `hasRunnableBundle` narrows the probe to the
|
|
102
|
+
// actual executable so `fireforge run` refuses with a targeted
|
|
103
|
+
// message before handing control to mach. `fireforge watch` stays
|
|
104
|
+
// permissive and instead surfaces the same information as a banner
|
|
105
|
+
// suffix; watch is supposed to drive rebuilds of partially-built
|
|
106
|
+
// trees, so blocking there would defeat the feature.
|
|
107
|
+
if (buildCheck.objDir) {
|
|
108
|
+
const config = await loadConfig(projectRoot);
|
|
109
|
+
const bundleCheck = await hasRunnableBundle(paths.engine, config.binaryName, buildCheck.objDir);
|
|
110
|
+
if (!bundleCheck.runnable) {
|
|
111
|
+
const expected = bundleCheck.expectedPath ?? `dist/bin/${config.binaryName}`;
|
|
112
|
+
throw new GeneralError(`Run requires a completed build that produced the launchable bundle. ` +
|
|
113
|
+
`Build artifacts exist in ${buildCheck.objDir}/ but the expected binary at ${expected} is missing — ` +
|
|
114
|
+
`the build may have aborted or is still in progress.\n\n` +
|
|
115
|
+
"Run 'fireforge build' and wait for it to finish before retrying 'fireforge run'.");
|
|
116
|
+
}
|
|
117
|
+
}
|
|
97
118
|
// Warn if Furnace components changed since the last apply
|
|
98
119
|
await warnIfFurnaceStale(projectRoot);
|
|
99
120
|
// Clean stale profile state to prevent silent startup failures
|
|
@@ -12,7 +12,7 @@ import { buildPatchQueueContext, collectNewFileCreatorsByPath } from '../core/pa
|
|
|
12
12
|
import { loadPatchesManifest } from '../core/patch-manifest.js';
|
|
13
13
|
import { GeneralError } from '../errors/base.js';
|
|
14
14
|
import { toError } from '../utils/errors.js';
|
|
15
|
-
import { pathExists, readText } from '../utils/fs.js';
|
|
15
|
+
import { FIREFORGE_TMP_PATH_PATTERN, pathExists, readText } from '../utils/fs.js';
|
|
16
16
|
import { info, intro, outro, verbose, warn } from '../utils/logger.js';
|
|
17
17
|
/**
|
|
18
18
|
* Status code descriptions for git status.
|
|
@@ -164,6 +164,21 @@ async function expandDirectoryEntries(files, engineDir) {
|
|
|
164
164
|
}
|
|
165
165
|
return { entries: expanded, truncations };
|
|
166
166
|
}
|
|
167
|
+
/**
|
|
168
|
+
* Strips entries whose path matches the atomic-temp-file shape
|
|
169
|
+
* FireForge's own `writeText` produces (see
|
|
170
|
+
* {@link import('../utils/fs.js').FIREFORGE_TMP_PATH_PATTERN}). Those
|
|
171
|
+
* files only exist for the duration of a write + rename and should
|
|
172
|
+
* never appear in `status` output; filtering them here keeps every
|
|
173
|
+
* status mode (default, raw, unmanaged, ownership, json) symmetric so
|
|
174
|
+
* the operator never sees a `.mozconfig.fireforge-tmp-<pid>-<uuid>`
|
|
175
|
+
* entry mid-write. Files named for unrelated reasons (e.g. a user's
|
|
176
|
+
* `.bashrc.fireforge-tmp-backup` without the PID+UUID tail) do not
|
|
177
|
+
* match the pattern and pass through unfiltered.
|
|
178
|
+
*/
|
|
179
|
+
function filterFireForgeTempFiles(files) {
|
|
180
|
+
return files.filter((entry) => !FIREFORGE_TMP_PATH_PATTERN.test(entry.file));
|
|
181
|
+
}
|
|
167
182
|
/**
|
|
168
183
|
* Classifies files into patch-backed, unmanaged, or branding buckets.
|
|
169
184
|
*/
|
|
@@ -277,7 +292,11 @@ export async function statusCommand(projectRoot, options = {}) {
|
|
|
277
292
|
const ownershipExpansion = (await isGitRepository(paths.engine))
|
|
278
293
|
? await expandDirectoryEntries(await getStatusWithCodes(paths.engine), paths.engine)
|
|
279
294
|
: { entries: [], truncations: [] };
|
|
280
|
-
|
|
295
|
+
// Filter atomic-write temp files (Finding #18) so a mid-flight
|
|
296
|
+
// `.fireforge-tmp-<pid>-<uuid>` artefact never shows up in any
|
|
297
|
+
// status mode. The pattern is tight enough to let legitimately
|
|
298
|
+
// similar names through.
|
|
299
|
+
const rawFilesOwnership = filterFireForgeTempFiles(ownershipExpansion.entries);
|
|
281
300
|
renderTruncationBanner(ownershipExpansion.truncations);
|
|
282
301
|
// Only walk the patch bodies when the directory actually exists.
|
|
283
302
|
// Fresh projects with no patch queue yet pass through with an empty
|
|
@@ -313,7 +332,10 @@ export async function statusCommand(projectRoot, options = {}) {
|
|
|
313
332
|
throw new GeneralError('Engine directory is not a git repository. Run "fireforge download" to initialize.');
|
|
314
333
|
}
|
|
315
334
|
const rawFiles = await getStatusWithCodes(paths.engine);
|
|
316
|
-
const { entries:
|
|
335
|
+
const { entries: expanded, truncations } = await expandDirectoryEntries(rawFiles, paths.engine);
|
|
336
|
+
// Strip atomic-write temp files (Finding #18) before every mode
|
|
337
|
+
// branch so raw / unmanaged / default / json all agree.
|
|
338
|
+
const files = filterFireForgeTempFiles(expanded);
|
|
317
339
|
renderTruncationBanner(truncations);
|
|
318
340
|
if (files.length === 0) {
|
|
319
341
|
info('No modified files');
|