@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.
Files changed (35) hide show
  1. package/CHANGELOG.md +18 -2
  2. package/README.md +20 -13
  3. package/dist/src/commands/doctor.js +13 -1
  4. package/dist/src/commands/export-all.js +63 -1
  5. package/dist/src/commands/furnace/create-xpcshell.js +4 -2
  6. package/dist/src/commands/furnace/preview.js +38 -0
  7. package/dist/src/commands/furnace/remove.js +67 -1
  8. package/dist/src/commands/furnace/rename-xpcshell.d.ts +35 -0
  9. package/dist/src/commands/furnace/rename-xpcshell.js +97 -0
  10. package/dist/src/commands/furnace/rename.js +9 -0
  11. package/dist/src/commands/rebase/index.js +19 -1
  12. package/dist/src/commands/status.js +44 -5
  13. package/dist/src/commands/test.js +27 -16
  14. package/dist/src/commands/verify.js +81 -6
  15. package/dist/src/commands/watch.js +43 -7
  16. package/dist/src/core/furnace-constants.d.ts +14 -0
  17. package/dist/src/core/furnace-constants.js +16 -0
  18. package/dist/src/core/furnace-validate.js +67 -1
  19. package/dist/src/core/git-base.d.ts +27 -2
  20. package/dist/src/core/git-base.js +41 -3
  21. package/dist/src/core/git.js +53 -14
  22. package/dist/src/core/mach.d.ts +14 -2
  23. package/dist/src/core/mach.js +12 -2
  24. package/dist/src/core/marionette-preflight.d.ts +16 -0
  25. package/dist/src/core/marionette-preflight.js +19 -0
  26. package/dist/src/core/patch-lint-diff-tag.d.ts +20 -0
  27. package/dist/src/core/patch-lint-diff-tag.js +25 -0
  28. package/dist/src/core/patch-lint.js +5 -4
  29. package/dist/src/core/patch-registration-refs.d.ts +42 -0
  30. package/dist/src/core/patch-registration-refs.js +117 -0
  31. package/dist/src/core/xpcshell-appdir.d.ts +19 -5
  32. package/dist/src/core/xpcshell-appdir.js +46 -20
  33. package/dist/src/errors/git.d.ts +20 -0
  34. package/dist/src/errors/git.js +39 -0
  35. package/package.json +1 -1
@@ -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
- return /SIGTERM|timed out|exit code 143/i.test(error.message);
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 git(['add', '--', dirName], dir, {
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 git(['add', '--', ...topLevelFiles], dir, {
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
- options.onProgress?.('Monolithic git add timed out; falling back to chunked staging...');
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
- await stageAllFilesChunked(dir, options);
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)
@@ -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
- export declare function watchWithOutput(engineDir: string): Promise<MachCommandResult>;
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
@@ -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: '(patch)',
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: '(patch)',
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: '(patch)',
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: '(patch)',
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 matches the on-disk layouts FireForge supports today:
100
- * 1. `<objDir>/dist/bin/<value>` — Linux primary, also macOS via the
101
- * `dist/bin -> dist/<App>.app/Contents/MacOS/` symlink.
102
- * 2. `<objDir>/dist/<bundle>.app/Contents/Resources/<value>` — macOS
103
- * packaged layout, where `dist/bin/` may not exist as a directory.
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 matches the on-disk layouts FireForge supports today:
168
- * 1. `<objDir>/dist/bin/<value>` — Linux primary, also macOS via the
169
- * `dist/bin -> dist/<App>.app/Contents/MacOS/` symlink.
170
- * 2. `<objDir>/dist/<bundle>.app/Contents/Resources/<value>` — macOS
171
- * packaged layout, where `dist/bin/` may not exist as a directory.
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
- if (!(await pathExists(distDir)))
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
- catch {
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
- for (const entry of entries) {
188
- if (!entry.endsWith('.app'))
189
- continue;
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
@@ -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
+ }