@hominis/fireforge 0.18.0 → 0.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -2
- package/README.md +20 -13
- package/dist/src/commands/doctor.js +13 -1
- package/dist/src/commands/export-all.js +63 -1
- package/dist/src/commands/furnace/create-xpcshell.js +4 -2
- package/dist/src/commands/furnace/preview.js +38 -0
- package/dist/src/commands/furnace/remove.js +67 -1
- package/dist/src/commands/furnace/rename-xpcshell.d.ts +35 -0
- package/dist/src/commands/furnace/rename-xpcshell.js +97 -0
- package/dist/src/commands/furnace/rename.js +9 -0
- package/dist/src/commands/rebase/index.js +19 -1
- package/dist/src/commands/status.js +44 -5
- package/dist/src/commands/test.js +27 -16
- package/dist/src/commands/verify.js +81 -6
- package/dist/src/commands/watch.js +43 -7
- package/dist/src/core/furnace-constants.d.ts +14 -0
- package/dist/src/core/furnace-constants.js +16 -0
- package/dist/src/core/furnace-validate.js +67 -1
- package/dist/src/core/git-base.d.ts +27 -2
- package/dist/src/core/git-base.js +41 -3
- package/dist/src/core/git.js +53 -14
- package/dist/src/core/mach.d.ts +14 -2
- package/dist/src/core/mach.js +12 -2
- package/dist/src/core/marionette-preflight.d.ts +16 -0
- package/dist/src/core/marionette-preflight.js +19 -0
- package/dist/src/core/patch-lint-diff-tag.d.ts +20 -0
- package/dist/src/core/patch-lint-diff-tag.js +25 -0
- package/dist/src/core/patch-lint.js +5 -4
- package/dist/src/core/patch-registration-refs.d.ts +42 -0
- package/dist/src/core/patch-registration-refs.js +117 -0
- package/dist/src/core/xpcshell-appdir.d.ts +19 -5
- package/dist/src/core/xpcshell-appdir.js +46 -20
- package/dist/src/errors/git.d.ts +20 -0
- package/dist/src/errors/git.js +39 -0
- package/package.json +1 -1
package/dist/src/core/git.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
// SPDX-License-Identifier: EUPL-1.2
|
|
2
2
|
import { readdir, stat } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
-
import { GitError, GitIndexLockError, PatchApplyError } from '../errors/git.js';
|
|
4
|
+
import { GitError, GitIndexingTimeoutError, GitIndexLockError, PatchApplyError, } from '../errors/git.js';
|
|
5
5
|
import { toError } from '../utils/errors.js';
|
|
6
6
|
import { pathExists, removeFile } from '../utils/fs.js';
|
|
7
7
|
import { verbose } from '../utils/logger.js';
|
|
8
8
|
import { exec } from '../utils/process.js';
|
|
9
|
-
import { configureGitPerformance, ensureGit, git, GIT_ADD_CHUNK_TIMEOUT_MS, GIT_ADD_TIMEOUT_MS, } from './git-base.js';
|
|
9
|
+
import { configureGitPerformance, ensureGit, git, GIT_ADD_CHUNK_TIMEOUT_ENV_VAR, GIT_ADD_CHUNK_TIMEOUT_MS, GIT_ADD_TIMEOUT_MS, } from './git-base.js';
|
|
10
10
|
import { getWorkingTreeStatus } from './git-status.js';
|
|
11
11
|
// ── Functions that remain in this file ──
|
|
12
12
|
/**
|
|
@@ -38,12 +38,22 @@ export async function ensureOriginRemote(dir) {
|
|
|
38
38
|
const GIT_ADD_ENV = { GIT_INDEX_THREADS: '0' };
|
|
39
39
|
/**
|
|
40
40
|
* Returns true when the error looks like a process killed by the spawn timeout
|
|
41
|
-
* (SIGTERM → exit code 143)
|
|
41
|
+
* (SIGTERM → exit code 143) OR an AbortError raised by
|
|
42
|
+
* `AbortSignal.timeout`. The AbortSignal path is the one observed during
|
|
43
|
+
* the 2026-04-24 eval (Finding 10): Node's `child_process` layer
|
|
44
|
+
* rejects with an AbortError when the signal fires, so the timeout
|
|
45
|
+
* detection here needs to recognise that shape too.
|
|
42
46
|
*/
|
|
43
47
|
function isTimeoutError(error) {
|
|
48
|
+
if (error instanceof Error && error.name === 'AbortError')
|
|
49
|
+
return true;
|
|
44
50
|
if (!(error instanceof GitError))
|
|
45
51
|
return false;
|
|
46
|
-
|
|
52
|
+
if (/SIGTERM|timed out|exit code 143/i.test(error.message))
|
|
53
|
+
return true;
|
|
54
|
+
if (error.cause instanceof Error && error.cause.name === 'AbortError')
|
|
55
|
+
return true;
|
|
56
|
+
return false;
|
|
47
57
|
}
|
|
48
58
|
/**
|
|
49
59
|
* Removes `.git/index.lock` left behind by a killed git process.
|
|
@@ -59,6 +69,12 @@ async function cleanupIndexLock(dir) {
|
|
|
59
69
|
* Stages every file by walking top-level directories one at a time.
|
|
60
70
|
* This avoids a single monolithic `git add -A` that may time out on
|
|
61
71
|
* very large (~300 K file) trees like Firefox.
|
|
72
|
+
*
|
|
73
|
+
* 2026-04-24 eval Finding 10: a chunked pass that hits its own timeout
|
|
74
|
+
* now raises a typed {@link GitIndexingTimeoutError} rather than the
|
|
75
|
+
* opaque `AbortError: The operation was aborted` the caller otherwise
|
|
76
|
+
* saw. The typed error carries the environment-variable override so the
|
|
77
|
+
* operator can extend the budget and re-run.
|
|
62
78
|
*/
|
|
63
79
|
async function stageAllFilesChunked(dir, options = {}) {
|
|
64
80
|
const entries = await readdir(dir, { withFileTypes: true });
|
|
@@ -66,21 +82,30 @@ async function stageAllFilesChunked(dir, options = {}) {
|
|
|
66
82
|
.filter((e) => e.isDirectory() && e.name !== '.git')
|
|
67
83
|
.map((e) => e.name)
|
|
68
84
|
.sort();
|
|
85
|
+
async function runChunk(args, label) {
|
|
86
|
+
try {
|
|
87
|
+
await git(args, dir, {
|
|
88
|
+
timeout: GIT_ADD_CHUNK_TIMEOUT_MS,
|
|
89
|
+
env: GIT_ADD_ENV,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
if (isTimeoutError(error)) {
|
|
94
|
+
throw new GitIndexingTimeoutError('chunked', GIT_ADD_CHUNK_TIMEOUT_MS, GIT_ADD_CHUNK_TIMEOUT_ENV_VAR, error instanceof Error ? error : undefined);
|
|
95
|
+
}
|
|
96
|
+
verbose(`Chunked staging failed on ${label}: ${toError(error).message}`);
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
69
100
|
for (const dirName of directories) {
|
|
70
101
|
options.onProgress?.(`Staging directory: ${dirName}/...`);
|
|
71
|
-
await
|
|
72
|
-
timeout: GIT_ADD_CHUNK_TIMEOUT_MS,
|
|
73
|
-
env: GIT_ADD_ENV,
|
|
74
|
-
});
|
|
102
|
+
await runChunk(['add', '--', dirName], dirName);
|
|
75
103
|
}
|
|
76
104
|
// Stage any top-level files
|
|
77
105
|
const topLevelFiles = entries.filter((e) => e.isFile()).map((e) => e.name);
|
|
78
106
|
if (topLevelFiles.length > 0) {
|
|
79
107
|
options.onProgress?.('Staging top-level files...');
|
|
80
|
-
await
|
|
81
|
-
timeout: GIT_ADD_CHUNK_TIMEOUT_MS,
|
|
82
|
-
env: GIT_ADD_ENV,
|
|
83
|
-
});
|
|
108
|
+
await runChunk(['add', '--', ...topLevelFiles], 'top-level files');
|
|
84
109
|
}
|
|
85
110
|
}
|
|
86
111
|
/**
|
|
@@ -122,11 +147,25 @@ export async function stageAllFiles(dir, options = {}) {
|
|
|
122
147
|
if (!isTimeoutError(error)) {
|
|
123
148
|
throw await maybeWrapIndexLockError(dir, error);
|
|
124
149
|
}
|
|
125
|
-
|
|
150
|
+
// 2026-04-24 eval Finding 10: the fallback transition used to be
|
|
151
|
+
// an implementation detail invisible to operators watching the
|
|
152
|
+
// spinner. Emit a loud, one-line banner so non-TTY log scrapers
|
|
153
|
+
// and TTY operators both see that the monolithic attempt lost and
|
|
154
|
+
// the chunked pass is starting. This was the missing signal in
|
|
155
|
+
// the eval log where the heartbeat went quiet for ~600s between
|
|
156
|
+
// the monolithic timeout and the chunked-pass failure.
|
|
157
|
+
options.onProgress?.(`Monolithic git add reached the ${Math.round(timeout / 1000)}s timeout; falling back to chunked staging. This pass may take several more minutes on a large tree.`);
|
|
126
158
|
}
|
|
127
159
|
// The killed process may have left an index lock
|
|
128
160
|
await cleanupIndexLock(dir);
|
|
129
|
-
|
|
161
|
+
try {
|
|
162
|
+
await stageAllFilesChunked(dir, options);
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
if (error instanceof GitIndexingTimeoutError)
|
|
166
|
+
throw error;
|
|
167
|
+
throw error;
|
|
168
|
+
}
|
|
130
169
|
}
|
|
131
170
|
finally {
|
|
132
171
|
if (heartbeatTimer)
|
package/dist/src/core/mach.d.ts
CHANGED
|
@@ -168,9 +168,21 @@ export declare function watch(engineDir: string): Promise<number>;
|
|
|
168
168
|
/**
|
|
169
169
|
* Runs mach watch while preserving stdin and capturing emitted output.
|
|
170
170
|
* @param engineDir - Path to the engine directory
|
|
171
|
+
* @param options - Optional environment overrides merged into the mach subprocess env
|
|
171
172
|
* @returns Captured output and exit code
|
|
172
|
-
|
|
173
|
-
|
|
173
|
+
*
|
|
174
|
+
* 2026-04-24 eval Finding 12: the pre-0.18.1 shape accepted no options
|
|
175
|
+
* and so never forwarded the detected watchman path into the mach
|
|
176
|
+
* subprocess env. `fireforge watch` could locate `watchman` via PATH
|
|
177
|
+
* (the probe's `which` succeeded) but the mach subprocess spawned with
|
|
178
|
+
* the parent's PATH only — on macOS that typically omits
|
|
179
|
+
* `/opt/homebrew/bin`, so `mach watch` failed at the `watch-project`
|
|
180
|
+
* subscription step. Accepting `env` here lets the caller prepend the
|
|
181
|
+
* resolved watchman directory to PATH in a way mach inherits.
|
|
182
|
+
*/
|
|
183
|
+
export declare function watchWithOutput(engineDir: string, options?: {
|
|
184
|
+
env?: Record<string, string>;
|
|
185
|
+
}): Promise<MachCommandResult>;
|
|
174
186
|
/**
|
|
175
187
|
* Runs mach test with the given test paths.
|
|
176
188
|
* @param engineDir - Path to the engine directory
|
package/dist/src/core/mach.js
CHANGED
|
@@ -280,10 +280,20 @@ export async function watch(engineDir) {
|
|
|
280
280
|
/**
|
|
281
281
|
* Runs mach watch while preserving stdin and capturing emitted output.
|
|
282
282
|
* @param engineDir - Path to the engine directory
|
|
283
|
+
* @param options - Optional environment overrides merged into the mach subprocess env
|
|
283
284
|
* @returns Captured output and exit code
|
|
285
|
+
*
|
|
286
|
+
* 2026-04-24 eval Finding 12: the pre-0.18.1 shape accepted no options
|
|
287
|
+
* and so never forwarded the detected watchman path into the mach
|
|
288
|
+
* subprocess env. `fireforge watch` could locate `watchman` via PATH
|
|
289
|
+
* (the probe's `which` succeeded) but the mach subprocess spawned with
|
|
290
|
+
* the parent's PATH only — on macOS that typically omits
|
|
291
|
+
* `/opt/homebrew/bin`, so `mach watch` failed at the `watch-project`
|
|
292
|
+
* subscription step. Accepting `env` here lets the caller prepend the
|
|
293
|
+
* resolved watchman directory to PATH in a way mach inherits.
|
|
284
294
|
*/
|
|
285
|
-
export async function watchWithOutput(engineDir) {
|
|
286
|
-
return runMachInheritCapture(['watch'], engineDir);
|
|
295
|
+
export async function watchWithOutput(engineDir, options = {}) {
|
|
296
|
+
return runMachInheritCapture(['watch'], engineDir, options.env ? { env: options.env } : {});
|
|
287
297
|
}
|
|
288
298
|
/**
|
|
289
299
|
* Runs mach test with the given test paths.
|
|
@@ -44,3 +44,19 @@ export interface MarionettePreflightOptions {
|
|
|
44
44
|
export declare function runMarionettePreflight(engineDir: string, options?: MarionettePreflightOptions): Promise<MarionettePreflightResult>;
|
|
45
45
|
/** Renders a PASS/FAIL banner to the CLI using the shared logger helpers. */
|
|
46
46
|
export declare function reportMarionettePreflight(result: MarionettePreflightResult): void;
|
|
47
|
+
/**
|
|
48
|
+
* Formats the PASS/FAIL banner as a plain string for direct
|
|
49
|
+
* `process.stdout.write` use — bypasses the clack logger entirely so
|
|
50
|
+
* operators running `fireforge test --doctor` under a non-TTY (pipe,
|
|
51
|
+
* CI, `tee`-wrapped capture) always see the final line even when the
|
|
52
|
+
* clack renderer swallows trailing log output just before process exit.
|
|
53
|
+
*
|
|
54
|
+
* 2026-04-24 eval Finding 7 reproducibly captured only the `"Running
|
|
55
|
+
* marionette preflight..."` intro and no PASS line at all — the
|
|
56
|
+
* `success()` + `outro()` + direct `stdout.write` belt-and-suspenders
|
|
57
|
+
* we used to ship still lost the summary under some non-TTY flush
|
|
58
|
+
* races. Returning the raw string here lets the caller compose a single
|
|
59
|
+
* authoritative write without any clack layer between the probe and
|
|
60
|
+
* the captured log.
|
|
61
|
+
*/
|
|
62
|
+
export declare function formatMarionettePreflightLine(result: MarionettePreflightResult): string;
|
|
@@ -288,4 +288,23 @@ export function reportMarionettePreflight(result) {
|
|
|
288
288
|
warn(`Marionette preflight: FAIL (${result.durationMs}ms) — ${result.detail}`);
|
|
289
289
|
}
|
|
290
290
|
}
|
|
291
|
+
/**
|
|
292
|
+
* Formats the PASS/FAIL banner as a plain string for direct
|
|
293
|
+
* `process.stdout.write` use — bypasses the clack logger entirely so
|
|
294
|
+
* operators running `fireforge test --doctor` under a non-TTY (pipe,
|
|
295
|
+
* CI, `tee`-wrapped capture) always see the final line even when the
|
|
296
|
+
* clack renderer swallows trailing log output just before process exit.
|
|
297
|
+
*
|
|
298
|
+
* 2026-04-24 eval Finding 7 reproducibly captured only the `"Running
|
|
299
|
+
* marionette preflight..."` intro and no PASS line at all — the
|
|
300
|
+
* `success()` + `outro()` + direct `stdout.write` belt-and-suspenders
|
|
301
|
+
* we used to ship still lost the summary under some non-TTY flush
|
|
302
|
+
* races. Returning the raw string here lets the caller compose a single
|
|
303
|
+
* authoritative write without any clack layer between the probe and
|
|
304
|
+
* the captured log.
|
|
305
|
+
*/
|
|
306
|
+
export function formatMarionettePreflightLine(result) {
|
|
307
|
+
const status = result.ok ? 'PASS' : 'FAIL';
|
|
308
|
+
return `Marionette preflight: ${status} (${result.durationMs}ms) — ${result.detail}`;
|
|
309
|
+
}
|
|
291
310
|
//# sourceMappingURL=marionette-preflight.js.map
|
|
@@ -18,6 +18,13 @@ import type { PatchLintIssue } from '../types/commands/index.js';
|
|
|
18
18
|
* @param rev Git revision to diff against (e.g. `HEAD`, a branch, a SHA).
|
|
19
19
|
*/
|
|
20
20
|
export declare function collectDiffFilePaths(engineDir: string, rev: string): Promise<Set<string>>;
|
|
21
|
+
/**
|
|
22
|
+
* Synthetic "file" value used by aggregate patch-size rules
|
|
23
|
+
* (`large-patch-files` / `large-patch-lines`) to flag that a finding
|
|
24
|
+
* describes the whole diff rather than a single path. Exported so callers
|
|
25
|
+
* can keep the tagging contract visible in one place.
|
|
26
|
+
*/
|
|
27
|
+
export declare const AGGREGATE_PATCH_FILE = "(patch)";
|
|
21
28
|
/**
|
|
22
29
|
* Annotates a list of lint issues with `introduced` / `cumulative` tags
|
|
23
30
|
* based on whether the issue's file is part of the supplied diff set.
|
|
@@ -27,6 +34,19 @@ export declare function collectDiffFilePaths(engineDir: string, rev: string): Pr
|
|
|
27
34
|
* describe queue-wide state — are always `cumulative` under `--since`
|
|
28
35
|
* because they describe drift accumulated across many commits, not a
|
|
29
36
|
* single current-task edit.
|
|
37
|
+
*
|
|
38
|
+
* Aggregate patch-size rules emit `issue.file === AGGREGATE_PATCH_FILE`,
|
|
39
|
+
* which is a synthetic placeholder that will never appear in a real
|
|
40
|
+
* `diffFiles` set. Without special-casing, `large-patch-files` /
|
|
41
|
+
* `large-patch-lines` were always tagged `[cumulative]` under
|
|
42
|
+
* `--only-introduced` even when the diff WAS the aggregate the rules
|
|
43
|
+
* measured — the eval (Finding #4) reported a stack of 20+ imported
|
|
44
|
+
* patches whose aggregate-size warnings printed as `[cumulative]` under
|
|
45
|
+
* `lint --since HEAD --only-introduced`, which reads as "this pre-existed"
|
|
46
|
+
* to an operator asking "what did this diff introduce?" We promote the
|
|
47
|
+
* aggregate tag to `introduced` whenever the diff set has any content —
|
|
48
|
+
* non-empty `diffFiles` means the operator asked about a specific diff
|
|
49
|
+
* scope and the aggregate-rule finding describes exactly that scope.
|
|
30
50
|
* @param issues Issues returned by the lint orchestrator.
|
|
31
51
|
* @param diffFiles File paths touched since the user's revision.
|
|
32
52
|
*/
|
|
@@ -58,6 +58,13 @@ export async function collectDiffFilePaths(engineDir, rev) {
|
|
|
58
58
|
}
|
|
59
59
|
return files;
|
|
60
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* Synthetic "file" value used by aggregate patch-size rules
|
|
63
|
+
* (`large-patch-files` / `large-patch-lines`) to flag that a finding
|
|
64
|
+
* describes the whole diff rather than a single path. Exported so callers
|
|
65
|
+
* can keep the tagging contract visible in one place.
|
|
66
|
+
*/
|
|
67
|
+
export const AGGREGATE_PATCH_FILE = '(patch)';
|
|
61
68
|
/**
|
|
62
69
|
* Annotates a list of lint issues with `introduced` / `cumulative` tags
|
|
63
70
|
* based on whether the issue's file is part of the supplied diff set.
|
|
@@ -67,15 +74,33 @@ export async function collectDiffFilePaths(engineDir, rev) {
|
|
|
67
74
|
* describe queue-wide state — are always `cumulative` under `--since`
|
|
68
75
|
* because they describe drift accumulated across many commits, not a
|
|
69
76
|
* single current-task edit.
|
|
77
|
+
*
|
|
78
|
+
* Aggregate patch-size rules emit `issue.file === AGGREGATE_PATCH_FILE`,
|
|
79
|
+
* which is a synthetic placeholder that will never appear in a real
|
|
80
|
+
* `diffFiles` set. Without special-casing, `large-patch-files` /
|
|
81
|
+
* `large-patch-lines` were always tagged `[cumulative]` under
|
|
82
|
+
* `--only-introduced` even when the diff WAS the aggregate the rules
|
|
83
|
+
* measured — the eval (Finding #4) reported a stack of 20+ imported
|
|
84
|
+
* patches whose aggregate-size warnings printed as `[cumulative]` under
|
|
85
|
+
* `lint --since HEAD --only-introduced`, which reads as "this pre-existed"
|
|
86
|
+
* to an operator asking "what did this diff introduce?" We promote the
|
|
87
|
+
* aggregate tag to `introduced` whenever the diff set has any content —
|
|
88
|
+
* non-empty `diffFiles` means the operator asked about a specific diff
|
|
89
|
+
* scope and the aggregate-rule finding describes exactly that scope.
|
|
70
90
|
* @param issues Issues returned by the lint orchestrator.
|
|
71
91
|
* @param diffFiles File paths touched since the user's revision.
|
|
72
92
|
*/
|
|
73
93
|
export function tagLintIssues(issues, diffFiles) {
|
|
94
|
+
const hasDiffContent = diffFiles.size > 0;
|
|
74
95
|
for (const issue of issues) {
|
|
75
96
|
if (!issue.file) {
|
|
76
97
|
issue.tag = 'cumulative';
|
|
77
98
|
continue;
|
|
78
99
|
}
|
|
100
|
+
if (issue.file === AGGREGATE_PATCH_FILE) {
|
|
101
|
+
issue.tag = hasDiffContent ? 'introduced' : 'cumulative';
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
79
104
|
issue.tag = diffFiles.has(issue.file) ? 'introduced' : 'cumulative';
|
|
80
105
|
}
|
|
81
106
|
return issues;
|
|
@@ -8,6 +8,7 @@ import { loadFurnaceConfig } from './furnace-config.js';
|
|
|
8
8
|
import { containsUpstreamLicenseText, getLicenseHeader, hasAnyLicenseHeader, hasAnyLicenseHeaderAnyStyle, } from './license-headers.js';
|
|
9
9
|
import { runCheckJs } from './patch-lint-checkjs.js';
|
|
10
10
|
import { detectNewFilesInDiff, extractAddedLinesPerFile } from './patch-lint-diff.js';
|
|
11
|
+
import { AGGREGATE_PATCH_FILE } from './patch-lint-diff-tag.js';
|
|
11
12
|
import { validateExportJsDoc } from './patch-lint-jsdoc.js';
|
|
12
13
|
import { resolvePatchOwnedSysMjs } from './patch-lint-ownership.js';
|
|
13
14
|
// ---------------------------------------------------------------------------
|
|
@@ -503,7 +504,7 @@ export function lintPatchSize(filesAffected, lineCount, patchTier) {
|
|
|
503
504
|
const issues = [];
|
|
504
505
|
if (filesAffected.length > 5) {
|
|
505
506
|
issues.push({
|
|
506
|
-
file:
|
|
507
|
+
file: AGGREGATE_PATCH_FILE,
|
|
507
508
|
check: 'large-patch-files',
|
|
508
509
|
message: `Patch affects ${filesAffected.length} files (recommended: ≤5). Consider splitting into smaller, focused patches.`,
|
|
509
510
|
severity: 'warning',
|
|
@@ -526,7 +527,7 @@ export function lintPatchSize(filesAffected, lineCount, patchTier) {
|
|
|
526
527
|
: PATCH_LINE_THRESHOLDS.general;
|
|
527
528
|
if (lineCount >= thresholds.error) {
|
|
528
529
|
issues.push({
|
|
529
|
-
file:
|
|
530
|
+
file: AGGREGATE_PATCH_FILE,
|
|
530
531
|
check: 'large-patch-lines',
|
|
531
532
|
message: `Patch is ${lineCount} lines (hard limit: ${thresholds.error}). Consider splitting into smaller, focused patches.`,
|
|
532
533
|
severity: 'error',
|
|
@@ -534,7 +535,7 @@ export function lintPatchSize(filesAffected, lineCount, patchTier) {
|
|
|
534
535
|
}
|
|
535
536
|
else if (lineCount >= thresholds.warning) {
|
|
536
537
|
issues.push({
|
|
537
|
-
file:
|
|
538
|
+
file: AGGREGATE_PATCH_FILE,
|
|
538
539
|
check: 'large-patch-lines',
|
|
539
540
|
message: `Patch is ${lineCount} lines (soft limit: ${thresholds.warning}, hard limit: ${thresholds.error}). Consider splitting into smaller, focused patches.`,
|
|
540
541
|
severity: 'warning',
|
|
@@ -542,7 +543,7 @@ export function lintPatchSize(filesAffected, lineCount, patchTier) {
|
|
|
542
543
|
}
|
|
543
544
|
else if (lineCount >= thresholds.notice) {
|
|
544
545
|
issues.push({
|
|
545
|
-
file:
|
|
546
|
+
file: AGGREGATE_PATCH_FILE,
|
|
546
547
|
check: 'large-patch-lines',
|
|
547
548
|
message: `Patch is ${lineCount} lines (soft limit: ${thresholds.warning}, hard limit: ${thresholds.error}). Consider splitting into smaller, focused patches.`,
|
|
548
549
|
severity: 'notice',
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extracts furnace-shaped registration references from a patch body.
|
|
3
|
+
*
|
|
4
|
+
* 2026-04-24 eval Finding 1: `export-all --exclude-furnace` can land a
|
|
5
|
+
* patch that registers a furnace component (via edits to
|
|
6
|
+
* `toolkit/content/customElements.js`, `toolkit/content/jar.mn`, or
|
|
7
|
+
* `toolkit/locales/jar.mn`) without including the component's source
|
|
8
|
+
* files in the patch. `fireforge verify` then reports "Verify clean" for
|
|
9
|
+
* the broken queue. This module provides a pattern-scoped scan so
|
|
10
|
+
* `verify` can cross-check registrations against available file bodies.
|
|
11
|
+
*
|
|
12
|
+
* The scan is deliberately narrow: it only matches component-shaped
|
|
13
|
+
* references (widget tag names, locale fluent names). Unrelated jar.mn
|
|
14
|
+
* or customElements.js edits pass through without spurious warnings.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* A referenced engine path extracted from a registration hunk, together
|
|
18
|
+
* with where it came from. The `source` field lets `verify` point
|
|
19
|
+
* operators at the specific consequence file whose hunk introduced the
|
|
20
|
+
* reference.
|
|
21
|
+
*/
|
|
22
|
+
export interface PatchRegistrationReference {
|
|
23
|
+
/** Engine-relative path that the registration hunk adds a reference to. */
|
|
24
|
+
targetPath: string;
|
|
25
|
+
/** The registration file that contained the added hunk. */
|
|
26
|
+
source: string;
|
|
27
|
+
/** Raw hunk line that produced the reference, for diagnostic context. */
|
|
28
|
+
lineText: string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Walks a unified-diff patch body and returns the set of
|
|
32
|
+
* component-shaped engine paths that the patch ADDS a registration for.
|
|
33
|
+
*
|
|
34
|
+
* Returns the empty array when no registration hunks are present OR
|
|
35
|
+
* when the registration hunks do not mention any component-shaped
|
|
36
|
+
* paths — that leaves the scan silent on the vast majority of patches
|
|
37
|
+
* (branding tweaks, behavioural fixes, module additions) so it only
|
|
38
|
+
* fires when a furnace-managed component is being newly registered.
|
|
39
|
+
*
|
|
40
|
+
* @param patchBody - Full unified-diff body of the patch file.
|
|
41
|
+
*/
|
|
42
|
+
export declare function collectPatchRegistrationReferences(patchBody: string): PatchRegistrationReference[];
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
/**
|
|
3
|
+
* Extracts furnace-shaped registration references from a patch body.
|
|
4
|
+
*
|
|
5
|
+
* 2026-04-24 eval Finding 1: `export-all --exclude-furnace` can land a
|
|
6
|
+
* patch that registers a furnace component (via edits to
|
|
7
|
+
* `toolkit/content/customElements.js`, `toolkit/content/jar.mn`, or
|
|
8
|
+
* `toolkit/locales/jar.mn`) without including the component's source
|
|
9
|
+
* files in the patch. `fireforge verify` then reports "Verify clean" for
|
|
10
|
+
* the broken queue. This module provides a pattern-scoped scan so
|
|
11
|
+
* `verify` can cross-check registrations against available file bodies.
|
|
12
|
+
*
|
|
13
|
+
* The scan is deliberately narrow: it only matches component-shaped
|
|
14
|
+
* references (widget tag names, locale fluent names). Unrelated jar.mn
|
|
15
|
+
* or customElements.js edits pass through without spurious warnings.
|
|
16
|
+
*/
|
|
17
|
+
/** Canonical file paths that registration-shaped diffs touch. */
|
|
18
|
+
const REGISTRATION_FILE_PATHS = new Set([
|
|
19
|
+
'toolkit/content/customElements.js',
|
|
20
|
+
'toolkit/content/jar.mn',
|
|
21
|
+
'toolkit/locales/jar.mn',
|
|
22
|
+
]);
|
|
23
|
+
/**
|
|
24
|
+
* Walks a unified-diff patch body and returns the set of
|
|
25
|
+
* component-shaped engine paths that the patch ADDS a registration for.
|
|
26
|
+
*
|
|
27
|
+
* Returns the empty array when no registration hunks are present OR
|
|
28
|
+
* when the registration hunks do not mention any component-shaped
|
|
29
|
+
* paths — that leaves the scan silent on the vast majority of patches
|
|
30
|
+
* (branding tweaks, behavioural fixes, module additions) so it only
|
|
31
|
+
* fires when a furnace-managed component is being newly registered.
|
|
32
|
+
*
|
|
33
|
+
* @param patchBody - Full unified-diff body of the patch file.
|
|
34
|
+
*/
|
|
35
|
+
export function collectPatchRegistrationReferences(patchBody) {
|
|
36
|
+
if (!patchBody)
|
|
37
|
+
return [];
|
|
38
|
+
const refs = [];
|
|
39
|
+
let currentFile;
|
|
40
|
+
// Walk line-by-line. The canonical unified-diff header line is
|
|
41
|
+
// `diff --git a/<path> b/<path>` — we key the file state off the `b/`
|
|
42
|
+
// path because that names the target side and is stable against
|
|
43
|
+
// renames. Additional diff metadata lines (index/---/+++/@@) are
|
|
44
|
+
// ignored for the purposes of tracking the current file.
|
|
45
|
+
const lines = patchBody.split(/\r?\n/);
|
|
46
|
+
for (const line of lines) {
|
|
47
|
+
const diffHeader = /^diff --git a\/(.+?) b\/(.+)$/.exec(line);
|
|
48
|
+
if (diffHeader?.[2]) {
|
|
49
|
+
currentFile = diffHeader[2];
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (!currentFile)
|
|
53
|
+
continue;
|
|
54
|
+
if (!REGISTRATION_FILE_PATHS.has(currentFile))
|
|
55
|
+
continue;
|
|
56
|
+
if (!line.startsWith('+'))
|
|
57
|
+
continue;
|
|
58
|
+
// Skip the `+++ b/<path>` header line — only real hunk adds count.
|
|
59
|
+
if (line.startsWith('+++'))
|
|
60
|
+
continue;
|
|
61
|
+
const added = line.slice(1);
|
|
62
|
+
const extracted = extractTargetPathsFromRegistrationLine(currentFile, added);
|
|
63
|
+
for (const target of extracted) {
|
|
64
|
+
refs.push({ targetPath: target, source: currentFile, lineText: added });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return refs;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Per-source extractor. Each registration file has a distinct syntactic
|
|
71
|
+
* shape; we scope the match to that file so a jar.mn regex does not
|
|
72
|
+
* accidentally match a customElements.js line.
|
|
73
|
+
*/
|
|
74
|
+
function extractTargetPathsFromRegistrationLine(sourceFile, added) {
|
|
75
|
+
if (sourceFile === 'toolkit/content/jar.mn') {
|
|
76
|
+
// Example (added line, leading `+` already stripped):
|
|
77
|
+
// ` content/global/elements/moz-qa-panel.mjs (widgets/moz-qa-panel/moz-qa-panel.mjs)`
|
|
78
|
+
// The parenthesised second half is the repo-relative path Firefox's
|
|
79
|
+
// packaging system reads. Widget registrations always live under
|
|
80
|
+
// `widgets/<tag>/<file>` — the enclosing tree is
|
|
81
|
+
// `toolkit/content/widgets/`. Reconstruct the engine-relative
|
|
82
|
+
// target path so callers can check it against patch bodies.
|
|
83
|
+
const widgetMatch = /\(\s*(widgets\/[^\s)]+)\s*\)/.exec(added);
|
|
84
|
+
if (widgetMatch?.[1]) {
|
|
85
|
+
return [`toolkit/content/${widgetMatch[1]}`];
|
|
86
|
+
}
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
if (sourceFile === 'toolkit/locales/jar.mn') {
|
|
90
|
+
// Example:
|
|
91
|
+
// ` locale/@AB_CD@/toolkit/global/moz-qa-panel.ftl (%toolkit/global/moz-qa-panel.ftl)`
|
|
92
|
+
// The `%`-prefixed repo-relative reference points at
|
|
93
|
+
// `toolkit/locales/en-US/<rel>`, which is the canonical FTL path.
|
|
94
|
+
const localeMatch = /\(%\s*([^\s)]+\.ftl)\s*\)/.exec(added);
|
|
95
|
+
if (localeMatch?.[1]) {
|
|
96
|
+
return [`toolkit/locales/en-US/${localeMatch[1]}`];
|
|
97
|
+
}
|
|
98
|
+
return [];
|
|
99
|
+
}
|
|
100
|
+
if (sourceFile === 'toolkit/content/customElements.js') {
|
|
101
|
+
// Example:
|
|
102
|
+
// ` ["moz-qa-panel", "chrome://global/content/elements/moz-qa-panel.mjs"],`
|
|
103
|
+
// The chrome URL maps back to
|
|
104
|
+
// `toolkit/content/widgets/<tag>/<tag>.mjs` by convention: the
|
|
105
|
+
// packager rewrites `chrome://global/content/elements/<file>` to the
|
|
106
|
+
// widget tree root. The tag name is the identifier we key off.
|
|
107
|
+
const elementMatch = /\[\s*"([a-z][a-z0-9-]*)"\s*,\s*"chrome:\/\/global\/content\/elements\/([a-zA-Z0-9_-]+)\.mjs"\s*\]/.exec(added);
|
|
108
|
+
if (elementMatch?.[1] && elementMatch[2]) {
|
|
109
|
+
const tag = elementMatch[1];
|
|
110
|
+
const fileStem = elementMatch[2];
|
|
111
|
+
return [`toolkit/content/widgets/${tag}/${fileStem}.mjs`];
|
|
112
|
+
}
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
return [];
|
|
116
|
+
}
|
|
117
|
+
//# sourceMappingURL=patch-registration-refs.js.map
|
|
@@ -96,11 +96,25 @@ export declare function readMozinfoAppname(objDirPath: string): Promise<string>;
|
|
|
96
96
|
* (which fails with a different error than the original `firefox-appdir`
|
|
97
97
|
* symptom and confuses triage).
|
|
98
98
|
*
|
|
99
|
-
* Probe order
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
99
|
+
* Probe order differs by host platform:
|
|
100
|
+
*
|
|
101
|
+
* - **macOS (`darwin`)**: prefer `<objDir>/dist/<App>.app/Contents/Resources/
|
|
102
|
+
* <value>` FIRST, then fall back to `<objDir>/dist/bin/<value>`.
|
|
103
|
+
* 2026-04-24 eval Finding 8: on macOS `dist/bin` is symlinked to
|
|
104
|
+
* `dist/<App>.app/Contents/MacOS/` (the *binaries* directory), so
|
|
105
|
+
* `dist/bin/browser` actually resolves to `<App>.app/Contents/MacOS/
|
|
106
|
+
* browser/`. That is NOT where `resource:///modules/` is rooted — on
|
|
107
|
+
* macOS, `-a` for xpcshell must point at the `.app/Contents/Resources/
|
|
108
|
+
* <value>` subtree where modules / chrome.manifest live. Returning
|
|
109
|
+
* `dist/bin/browser` caused the injected `--app-path` to look
|
|
110
|
+
* successful (the info log showed it) but pointed at a directory
|
|
111
|
+
* without the modules tree, so every `resource:///modules/…` import
|
|
112
|
+
* still threw.
|
|
113
|
+
* - **non-macOS**: keep the historical order — `dist/bin/<value>` first,
|
|
114
|
+
* `.app/Contents/Resources/<value>` as fallback.
|
|
115
|
+
*
|
|
116
|
+
* On both platforms the final `.app` fallback iterates every `*.app`
|
|
117
|
+
* entry because a rebranded fork may pick an arbitrary app name.
|
|
104
118
|
*/
|
|
105
119
|
export declare function resolveAbsoluteAppPath(objDirAbs: string, relativeAppdir: string): Promise<string | null>;
|
|
106
120
|
/**
|
|
@@ -164,34 +164,60 @@ export async function readMozinfoAppname(objDirPath) {
|
|
|
164
164
|
* (which fails with a different error than the original `firefox-appdir`
|
|
165
165
|
* symptom and confuses triage).
|
|
166
166
|
*
|
|
167
|
-
* Probe order
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
167
|
+
* Probe order differs by host platform:
|
|
168
|
+
*
|
|
169
|
+
* - **macOS (`darwin`)**: prefer `<objDir>/dist/<App>.app/Contents/Resources/
|
|
170
|
+
* <value>` FIRST, then fall back to `<objDir>/dist/bin/<value>`.
|
|
171
|
+
* 2026-04-24 eval Finding 8: on macOS `dist/bin` is symlinked to
|
|
172
|
+
* `dist/<App>.app/Contents/MacOS/` (the *binaries* directory), so
|
|
173
|
+
* `dist/bin/browser` actually resolves to `<App>.app/Contents/MacOS/
|
|
174
|
+
* browser/`. That is NOT where `resource:///modules/` is rooted — on
|
|
175
|
+
* macOS, `-a` for xpcshell must point at the `.app/Contents/Resources/
|
|
176
|
+
* <value>` subtree where modules / chrome.manifest live. Returning
|
|
177
|
+
* `dist/bin/browser` caused the injected `--app-path` to look
|
|
178
|
+
* successful (the info log showed it) but pointed at a directory
|
|
179
|
+
* without the modules tree, so every `resource:///modules/…` import
|
|
180
|
+
* still threw.
|
|
181
|
+
* - **non-macOS**: keep the historical order — `dist/bin/<value>` first,
|
|
182
|
+
* `.app/Contents/Resources/<value>` as fallback.
|
|
183
|
+
*
|
|
184
|
+
* On both platforms the final `.app` fallback iterates every `*.app`
|
|
185
|
+
* entry because a rebranded fork may pick an arbitrary app name.
|
|
172
186
|
*/
|
|
173
187
|
export async function resolveAbsoluteAppPath(objDirAbs, relativeAppdir) {
|
|
174
188
|
const distBinCandidate = join(objDirAbs, 'dist', 'bin', relativeAppdir);
|
|
175
|
-
if (await pathExists(distBinCandidate))
|
|
176
|
-
return distBinCandidate;
|
|
177
189
|
const distDir = join(objDirAbs, 'dist');
|
|
178
|
-
|
|
190
|
+
const isMacos = process.platform === 'darwin';
|
|
191
|
+
async function probeMacAppBundle() {
|
|
192
|
+
if (!(await pathExists(distDir)))
|
|
193
|
+
return null;
|
|
194
|
+
let entries;
|
|
195
|
+
try {
|
|
196
|
+
entries = await readdir(distDir);
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
for (const entry of entries) {
|
|
202
|
+
if (!entry.endsWith('.app'))
|
|
203
|
+
continue;
|
|
204
|
+
const candidate = join(distDir, entry, 'Contents', 'Resources', relativeAppdir);
|
|
205
|
+
if (await pathExists(candidate))
|
|
206
|
+
return candidate;
|
|
207
|
+
}
|
|
179
208
|
return null;
|
|
180
|
-
let entries;
|
|
181
|
-
try {
|
|
182
|
-
entries = await readdir(distDir);
|
|
183
209
|
}
|
|
184
|
-
|
|
210
|
+
if (isMacos) {
|
|
211
|
+
const appBundle = await probeMacAppBundle();
|
|
212
|
+
if (appBundle)
|
|
213
|
+
return appBundle;
|
|
214
|
+
if (await pathExists(distBinCandidate))
|
|
215
|
+
return distBinCandidate;
|
|
185
216
|
return null;
|
|
186
217
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
const candidate = join(distDir, entry, 'Contents', 'Resources', relativeAppdir);
|
|
191
|
-
if (await pathExists(candidate))
|
|
192
|
-
return candidate;
|
|
193
|
-
}
|
|
194
|
-
return null;
|
|
218
|
+
if (await pathExists(distBinCandidate))
|
|
219
|
+
return distBinCandidate;
|
|
220
|
+
return probeMacAppBundle();
|
|
195
221
|
}
|
|
196
222
|
/**
|
|
197
223
|
* Top-level resolver. Walks every test path, reads the nearest
|
package/dist/src/errors/git.d.ts
CHANGED
|
@@ -39,3 +39,23 @@ export declare class GitIndexLockError extends GitError {
|
|
|
39
39
|
constructor(lockPath: string, ageMs?: number | undefined);
|
|
40
40
|
get userMessage(): string;
|
|
41
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Error thrown when `git add` (monolithic or chunked) exceeds the
|
|
44
|
+
* configured timeout while indexing the Firefox source tree.
|
|
45
|
+
*
|
|
46
|
+
* 2026-04-24 eval Finding 10: a 140.10.0esr bump on a previously-working
|
|
47
|
+
* 140.9.0esr workspace aborted after ~854s with a generic
|
|
48
|
+
* `AbortError: The operation was aborted`. The root cause was the
|
|
49
|
+
* `git add` timeout firing, but the surfaced error was indistinguishable
|
|
50
|
+
* from any other AbortError and gave the operator no actionable
|
|
51
|
+
* direction. This typed error carries the elapsed budget and the
|
|
52
|
+
* environment-variable override so the recovery path is
|
|
53
|
+
* self-documenting.
|
|
54
|
+
*/
|
|
55
|
+
export declare class GitIndexingTimeoutError extends GitError {
|
|
56
|
+
readonly phase: 'monolithic' | 'chunked';
|
|
57
|
+
readonly timeoutMs: number;
|
|
58
|
+
readonly envVar: string;
|
|
59
|
+
constructor(phase: 'monolithic' | 'chunked', timeoutMs: number, envVar: string, cause?: Error);
|
|
60
|
+
get userMessage(): string;
|
|
61
|
+
}
|