@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
@@ -12,7 +12,7 @@
12
12
  */
13
13
  import { getProjectPaths, loadConfig } from '../../core/config.js';
14
14
  import { getFurnacePaths, updateFurnaceState } from '../../core/furnace-config.js';
15
- import { getHead, isGitRepository, resetChanges } from '../../core/git.js';
15
+ import { getHead, isGitRepository, isMissingHeadError, resetChanges } from '../../core/git.js';
16
16
  import { discoverPatches } from '../../core/patch-files.js';
17
17
  import { loadPatchesManifest } from '../../core/patch-manifest.js';
18
18
  import { hasActiveRebaseSession, saveRebaseSession } from '../../core/rebase-session.js';
@@ -40,6 +40,24 @@ async function handleFreshStart(projectRoot, options) {
40
40
  if (!(await isGitRepository(paths.engine))) {
41
41
  throw new GeneralError('Engine directory is not a git repository. Run "fireforge download" to initialize.');
42
42
  }
43
+ // 2026-04-24 eval Finding 11: `rebase --dry-run` used to print
44
+ // "Dry run complete" without validating that the engine had a valid
45
+ // HEAD. A previous `download --force` abort could leave `.git/`
46
+ // initialized but unborn (no baseline commit); the real rebase then
47
+ // failed immediately with `fatal: ambiguous argument 'HEAD'` on the
48
+ // first `git rev-parse HEAD` call. Replicate the same baseline check
49
+ // here so dry-run mirrors the real-run preconditions and operators
50
+ // cannot mistake a broken baseline for a ready-to-rebase tree.
51
+ try {
52
+ await getHead(paths.engine);
53
+ }
54
+ catch (err) {
55
+ if (isMissingHeadError(err)) {
56
+ throw new GeneralError('Engine repository has no baseline commit yet — a previous "fireforge download" was interrupted before git created the initial Firefox source commit. ' +
57
+ 'Re-run "fireforge download --force" to recreate the baseline repository cleanly, then retry the rebase.');
58
+ }
59
+ throw err;
60
+ }
43
61
  const config = await loadConfig(projectRoot);
44
62
  const currentVersion = config.firefox.version;
45
63
  const manifest = await loadPatchesManifest(paths.patches);
@@ -70,11 +70,39 @@ async function printUnregisteredWarnings(files, projectRoot, binaryName) {
70
70
  if (newFiles.length === 0)
71
71
  return;
72
72
  const registrableFiles = newFiles.filter((f) => matchesRegistrablePattern(f.file, binaryName));
73
- const registrationChecks = await Promise.all(registrableFiles.map(async (f) => ({
74
- file: f.file,
75
- registered: await isFileRegistered(projectRoot, f.file),
76
- })));
77
- const unregistered = registrationChecks.filter((f) => !f.registered);
73
+ // `isFileRegistered` throws `GeneralError("Manifest not found: ...")` when a
74
+ // rule sees a file whose parent manifest does not yet exist on disk — e.g.
75
+ // a brand-new `browser/modules/<binary>/` directory with no `moz.build`.
76
+ // `status` is a read-only reporter; before 0.18.1 the rejected promise
77
+ // bubbled through `Promise.all` and exited status with code 1, breaking the
78
+ // "use status --unmanaged to discover new files before running register"
79
+ // workflow. We now bucket missing-manifest cases into a distinct warning
80
+ // list while still surfacing the same actionable signal. Other error
81
+ // shapes continue to propagate (permission denied, corrupt file, etc.) so
82
+ // we do not silently hide anything surprising.
83
+ const registrationChecks = await Promise.all(registrableFiles.map(async (f) => {
84
+ try {
85
+ return {
86
+ file: f.file,
87
+ registered: await isFileRegistered(projectRoot, f.file),
88
+ manifestMissing: false,
89
+ manifestMissingMessage: undefined,
90
+ };
91
+ }
92
+ catch (err) {
93
+ if (err instanceof GeneralError && /^Manifest not found:/i.test(err.message)) {
94
+ return {
95
+ file: f.file,
96
+ registered: false,
97
+ manifestMissing: true,
98
+ manifestMissingMessage: err.message,
99
+ };
100
+ }
101
+ throw err;
102
+ }
103
+ }));
104
+ const unregistered = registrationChecks.filter((f) => !f.registered && !f.manifestMissing);
105
+ const manifestMissing = registrationChecks.filter((f) => f.manifestMissing);
78
106
  if (unregistered.length > 0) {
79
107
  info('');
80
108
  warn('Potentially unregistered files:');
@@ -82,6 +110,17 @@ async function printUnregisteredWarnings(files, projectRoot, binaryName) {
82
110
  info(` ${f.file} — run 'fireforge register ${f.file}'`);
83
111
  }
84
112
  }
113
+ if (manifestMissing.length > 0) {
114
+ info('');
115
+ warn('Files whose registration manifest does not exist yet:');
116
+ for (const f of manifestMissing) {
117
+ // `manifestMissingMessage` is always the specific
118
+ // "Manifest not found: <path>" string when manifestMissing is
119
+ // true (see the catch branch above that sets them together).
120
+ info(` ${f.file} — ${f.manifestMissingMessage}`);
121
+ info(` Create the parent manifest, then run 'fireforge register ${f.file}'.`);
122
+ }
123
+ }
85
124
  }
86
125
  /**
87
126
  * Renders raw worktree status as machine-parseable porcelain-style output.
@@ -4,7 +4,7 @@ import { prepareBuildEnvironment } from '../core/build-prepare.js';
4
4
  import { getProjectPaths, loadConfig } from '../core/config.js';
5
5
  import { buildArtifactMismatchMessage, buildUI, hasBuildArtifacts, testWithOutput, } from '../core/mach.js';
6
6
  import { assertMarionettePortAvailable } from '../core/marionette-port.js';
7
- import { reportMarionettePreflight, runMarionettePreflight } from '../core/marionette-preflight.js';
7
+ import { formatMarionettePreflightLine, reportMarionettePreflight, runMarionettePreflight, } from '../core/marionette-preflight.js';
8
8
  import { checkStaleBuildForTest, formatStaleBuildWarning } from '../core/test-stale-check.js';
9
9
  import { operatorAlreadySetAppPath, resolveXpcshellAppdirArg, } from '../core/xpcshell-appdir.js';
10
10
  import { GeneralError } from '../errors/base.js';
@@ -82,17 +82,22 @@ function hasXpcshellAppdirSignal(output) {
82
82
  return /Failed to load resource:\/\/\/modules\//i.test(output);
83
83
  }
84
84
  function buildXpcshellAppdirMessage(injectionAttempted) {
85
+ const isMacos = process.platform === 'darwin';
86
+ const macosNote = isMacos
87
+ ? 'Detected: macOS host. On macOS the xpcshell harness binds `-a` to `<obj>/dist/<App>.app/Contents/Resources` by default and frequently ignores `--app-path` overrides when the `.app` bundle is present — the surest fix is the `<appname>-appdir` migration below rather than trying to force a different path.\n\n'
88
+ : '';
85
89
  const triggerLines = injectionAttempted
86
- ? 'FireForge auto-injected `--app-path=<absolute>` against the resolved obj-dir before mach test ran, but the failure persists. The injected path either does not match the appdir layout your harness expects, or the harness was built against a layout FireForge cannot probe (omni.ja-packed tree, alternate `dist/` shape).\n\n'
90
+ ? 'FireForge auto-injected `--app-path=<absolute>` against the resolved obj-dir before mach test ran, but the failure persists. The injected path either does not match the appdir layout your harness expects, or (on macOS) the harness bound `-a` to the `.app/Contents/Resources` default and ignored the override.\n\n'
87
91
  : 'Likely triggers:\n' +
88
92
  ' - The nearest xpcshell.toml sets `firefox-appdir = "browser"` but the harness reads `<appname>-appdir` instead — the literal `firefox-appdir` directive is silently ignored on rebranded forks (appname != "firefox").\n' +
89
93
  ' - FireForge could not find an xpcshell.toml above the test path, so the auto-injection never ran.\n\n';
90
94
  return ('xpcshell failed to load core resource:///modules/*.sys.mjs imports.\n\n' +
91
95
  'This is the canonical symptom of xpcshell running with the wrong app directory: the runtime resolves `resource:///modules/` against the parent of the expected app root, so every `ChromeUtils.importESModule("resource:///modules/…")` throws.\n\n' +
96
+ macosNote +
92
97
  triggerLines +
93
98
  'Options:\n' +
94
- ' - Add `<appname>-appdir = "browser"` alongside `firefox-appdir = "browser"` in the xpcshell.toml [DEFAULT] so the harness reads the appname-keyed value directly.\n' +
95
- ' - Pass overrides through `fireforge test <path> --mach-arg="--app-path=<absolute>"` to inject the path verbatim (operator overrides always win over auto-injection).\n' +
99
+ ' - Add `<appname>-appdir = "browser"` alongside `firefox-appdir = "browser"` in the xpcshell.toml [DEFAULT] so the harness reads the appname-keyed value directly. This is the most reliable fix on rebranded macOS builds.\n' +
100
+ ' - Pass overrides through `fireforge test <path> --mach-arg="--app-path=<absolute>"` to inject the path verbatim (operator overrides always win over auto-injection, but see the macOS caveat above).\n' +
96
101
  ' - Remove `firefox-appdir = "browser"` from the xpcshell.toml [DEFAULT] and move browser-chrome dependencies into a browser-chrome mochitest (see `fireforge furnace create --test-style=browser-chrome`).\n' +
97
102
  ' - If the test only touches toolkit chrome (chrome://global/*), drop the `firefox-appdir` setting entirely — toolkit chrome is registered without it.');
98
103
  }
@@ -228,26 +233,32 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
228
233
  // no paths are supplied this is the only step — it's the fastest way to tell
229
234
  // marionette-wedged apart from test-discovery-failure.
230
235
  if (options.doctor) {
236
+ // Write the "Running marionette preflight..." banner via
237
+ // `process.stdout.write` directly before `info()` so non-TTY captures
238
+ // always see the banner even if clack's renderer defers output in
239
+ // pipe mode. `info()` is still called so TTY users keep the normal
240
+ // clack box-drawing framing.
241
+ process.stdout.write('Running marionette preflight...\n');
231
242
  info('Running marionette preflight...');
232
243
  const preflight = await runMarionettePreflight(paths.engine);
244
+ // 2026-04-24 eval Finding 7: the pre-0.18.1 code used
245
+ // `success()` + `outro()` + a direct `process.stdout.write` as a
246
+ // belt-and-suspenders but still reproducibly dropped the PASS summary
247
+ // under non-TTY capture (observed: `tee`-wrapped eval output saw only
248
+ // the intro). The fix writes the authoritative PASS/FAIL line via
249
+ // `process.stdout.write` as the very first output after the probe
250
+ // returns, so the captured stream has an unambiguous summary no
251
+ // matter what clack does on top. The clack-rendered banner
252
+ // (`info`/`warn`) is retained so TTY users keep the visual framing.
253
+ const directLine = formatMarionettePreflightLine(preflight);
254
+ process.stdout.write(`${directLine}\n`);
233
255
  reportMarionettePreflight(preflight);
234
256
  if (testPaths.length === 0) {
235
257
  if (!preflight.ok) {
236
258
  throw new GeneralError('Marionette preflight reported FAIL — see output above.');
237
259
  }
238
- // Belt-and-suspenders: write the PASS footer via `success()`
239
- // AND `outro()` AND a direct stdout write. The eval
240
- // reproducibly captured the intro + info line but nothing
241
- // after the preflight returned, which we believe is a
242
- // non-TTY clack rendering quirk that occasionally swallows
243
- // the last log line before process exit. `success()` routes
244
- // through a different clack entry point than `info()`, and
245
- // `process.stdout.write` bypasses clack entirely so the
246
- // PASS status is always visible in the captured output.
247
- const summary = `Marionette preflight: PASS (${preflight.durationMs}ms)`;
248
- success(summary);
260
+ success(directLine);
249
261
  outro('Test completed');
250
- process.stdout.write(`${summary}\n`);
251
262
  return;
252
263
  }
253
264
  if (!preflight.ok) {
@@ -14,19 +14,74 @@
14
14
  * Exits non-zero when any error-severity finding is reported so CI can
15
15
  * treat the output as pass/fail.
16
16
  */
17
+ import { join } from 'node:path';
17
18
  import { getProjectPaths } from '../core/config.js';
18
19
  import { buildPatchQueueContext, lintPatchQueue } from '../core/patch-lint.js';
19
20
  import { loadPatchesManifest, validatePatchesManifestConsistency } from '../core/patch-manifest.js';
21
+ import { collectPatchRegistrationReferences } from '../core/patch-registration-refs.js';
20
22
  import { GeneralError } from '../errors/base.js';
21
- import { pathExists } from '../utils/fs.js';
23
+ import { pathExists, readText } from '../utils/fs.js';
22
24
  import { info, intro, outro, success, warn } from '../utils/logger.js';
23
25
  /**
24
- * Reports duplicate `filesAffected` entries across patches the manifest
25
- * consistency check only flags per-patch duplicates and orphan files, not
26
- * the case where two different patches claim the same path. `verify`
27
- * surfaces that here so it can be caught before `export`, `re-export`, or
28
- * `rebase` hit it.
26
+ * Walks each patch body in the manifest, extracts the set of
27
+ * component-shaped registration references it adds (widget paths
28
+ * implied by jar.mn + customElements.js; FTL paths implied by locale
29
+ * jar.mn), and confirms every reference is either created by some
30
+ * patch in the queue OR present as a tracked file in engine/. Any
31
+ * unreachable reference is a dangling-registration error — the patch
32
+ * registers a file that nothing in the world supplies, which fails at
33
+ * install time.
29
34
  */
35
+ async function detectDanglingRegistrations(patchesDir, engineDir, patches) {
36
+ // Aggregate the set of all paths that any patch in the queue is
37
+ // responsible for (per `filesAffected`). We deliberately do NOT parse
38
+ // individual patch bodies for new-file creations here: `filesAffected`
39
+ // is already the contract manifest callers rely on, and
40
+ // `validatePatchesManifestConsistency` has already ensured the two
41
+ // are in sync. Using that list keeps this validator fast.
42
+ const coveredByPatches = new Set();
43
+ for (const patch of patches) {
44
+ for (const file of patch.filesAffected) {
45
+ coveredByPatches.add(file);
46
+ }
47
+ }
48
+ const issues = [];
49
+ for (const patch of patches) {
50
+ const patchPath = join(patchesDir, patch.filename);
51
+ if (!(await pathExists(patchPath)))
52
+ continue;
53
+ let body;
54
+ try {
55
+ body = await readText(patchPath);
56
+ }
57
+ catch {
58
+ // Bad file read is surfaced by the manifest consistency check
59
+ // already — skipping here avoids double-reporting the same issue.
60
+ continue;
61
+ }
62
+ const refs = collectPatchRegistrationReferences(body);
63
+ if (refs.length === 0)
64
+ continue;
65
+ for (const ref of refs) {
66
+ if (coveredByPatches.has(ref.targetPath))
67
+ continue;
68
+ // Engine existence check: if the target file is already present
69
+ // in engine/ (e.g. upstream Firefox ships it, or a separate
70
+ // baseline branch has it), the registration is not dangling.
71
+ // We cannot sanely probe "is this tracked by git" without a git
72
+ // round-trip; existence on disk is a close-enough proxy for
73
+ // verify's read-only context.
74
+ if (await pathExists(join(engineDir, ref.targetPath)))
75
+ continue;
76
+ issues.push({
77
+ patchFilename: patch.filename,
78
+ targetPath: ref.targetPath,
79
+ source: ref.source,
80
+ });
81
+ }
82
+ }
83
+ return issues;
84
+ }
30
85
  function detectCrossPatchFileClaims(manifestPatches) {
31
86
  const claims = new Map();
32
87
  for (const patch of manifestPatches) {
@@ -97,6 +152,26 @@ export async function verifyCommand(projectRoot) {
97
152
  warningCount += 1;
98
153
  }
99
154
  }
155
+ // 4. Registration-consequence consistency: walk each patch body and
156
+ // confirm that every widget / locale registration it adds has a
157
+ // corresponding file body covered by the patch queue OR present in
158
+ // the engine working tree. 2026-04-24 eval Finding 1: a patch
159
+ // produced by `export-all --exclude-furnace` referenced
160
+ // `toolkit/content/widgets/moz-qa-panel/*.mjs` via jar.mn /
161
+ // customElements.js edits, but the source files themselves were
162
+ // excluded from the patch. `verify` used to report "clean"; it now
163
+ // flags each dangling reference as a `dangling-registration` error
164
+ // naming the specific patch and target path.
165
+ if (manifest) {
166
+ const registrationIssues = await detectDanglingRegistrations(paths.patches, paths.engine, manifest.patches);
167
+ if (registrationIssues.length > 0) {
168
+ warn(`Dangling registration references (${registrationIssues.length}):`);
169
+ for (const issue of registrationIssues) {
170
+ warn(` ${issue.patchFilename}: registers ${issue.targetPath} via ${issue.source}, but no patch body or engine file supplies it`);
171
+ errorCount += 1;
172
+ }
173
+ }
174
+ }
100
175
  if (errorCount === 0 && warningCount === 0) {
101
176
  success('Patch queue is consistent.');
102
177
  outro('Verify clean');
@@ -1,3 +1,5 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ import { delimiter, dirname } from 'node:path';
1
3
  import { getProjectPaths, loadConfig } from '../core/config.js';
2
4
  import { warnIfFurnaceStale } from '../core/furnace-staleness.js';
3
5
  import { buildArtifactMismatchMessage, generateMozconfig, hasBuildArtifacts, hasRunnableBundle, watchWithOutput, } from '../core/mach.js';
@@ -5,8 +7,8 @@ import { GeneralError } from '../errors/base.js';
5
7
  import { AmbiguousBuildArtifactsError, BuildError } from '../errors/build.js';
6
8
  import { toError } from '../utils/errors.js';
7
9
  import { pathExists } from '../utils/fs.js';
8
- import { info, intro, outro, spinner } from '../utils/logger.js';
9
- import { exec, executableExists } from '../utils/process.js';
10
+ import { info, intro, outro, spinner, verbose } from '../utils/logger.js';
11
+ import { exec, findExecutable } from '../utils/process.js';
10
12
  const WATCHMAN_PROBE_TIMEOUT_MS = 5000;
11
13
  /**
12
14
  * Probes watchman by running `watchman --version`. A binary that exists
@@ -55,14 +57,21 @@ function buildWatchmanConfigureTimeMessage() {
55
57
  /**
56
58
  * Builds the generic unsupported-watch failure message.
57
59
  * @param exitCode - Exit code returned by `mach watch`
60
+ * @param watchmanPath - Optional absolute path to the resolved watchman binary; surfaced in the guidance so the operator can see whether FireForge actually found one.
58
61
  * @returns User-facing failure guidance
59
62
  */
60
- function buildUnsupportedWatchMessage(exitCode) {
63
+ function buildUnsupportedWatchMessage(exitCode, watchmanPath) {
64
+ const watchmanLine = watchmanPath
65
+ ? ` - FireForge resolved watchman at ${watchmanPath} and prepended its directory to the mach subprocess PATH. If mach still did not see it, ensure that path is stable between runs.\n`
66
+ : '';
61
67
  return (`Watch failed with exit code ${exitCode}. Check the output above for details.\n\n` +
62
68
  'Common causes:\n' +
63
69
  ' - watchman is not installed or not in PATH right now\n' +
64
70
  ' - watchman was installed only after the current obj-* directory was configured; delete obj-* and rebuild\n' +
65
- ' - mach watch is unsupported in the current objdir or build environment');
71
+ ' - mach watch is unsupported in the current objdir or build environment\n' +
72
+ watchmanLine +
73
+ '\n' +
74
+ 'If the failure referenced `watch-project` / `FasterBuildException: timed out`, watchman is likely reachable via `which watchman` from your shell but missing from the subprocess PATH. FireForge now prepends the resolved watchman directory automatically; confirm your watchman install is on a stable path (e.g. /opt/homebrew/bin/watchman on macOS).');
66
75
  }
67
76
  /**
68
77
  * Detects the Firefox-side output produced when watchman was missing at configure time.
@@ -86,7 +95,17 @@ export async function watchCommand(projectRoot) {
86
95
  if (!(await pathExists(paths.engine))) {
87
96
  throw new GeneralError('Firefox source not found. Run "fireforge download" first.');
88
97
  }
89
- if (!(await executableExists('watchman'))) {
98
+ // Resolve the watchman binary to an absolute path up-front so we can
99
+ // (a) refuse fast when it is missing AND (b) prepend its directory to
100
+ // the mach subprocess PATH. 2026-04-24 eval Finding 12: on macOS,
101
+ // `which watchman` from the interactive shell returns
102
+ // `/opt/homebrew/bin/watchman`, but the Node subprocess PATH
103
+ // frequently omits `/opt/homebrew/bin`, so the shell probe passed and
104
+ // mach's `watch-project` call then timed out because its own PATH
105
+ // lookup for watchman failed. Threading the directory through the
106
+ // subprocess env fixes it.
107
+ const watchmanPath = await findExecutable('watchman');
108
+ if (!watchmanPath) {
90
109
  throw new GeneralError('Watch mode requires watchman to be installed and available in PATH.\n\n' +
91
110
  'Install watchman first, then rerun "fireforge watch".');
92
111
  }
@@ -145,9 +164,26 @@ export async function watchCommand(projectRoot) {
145
164
  }
146
165
  info('Starting watch mode...');
147
166
  info('Press Ctrl+C to stop\n');
167
+ // Compose the subprocess env: start from the parent process env, then
168
+ // prepend the resolved watchman directory to PATH so the mach
169
+ // subprocess sees the same binary our probe just validated. Without
170
+ // this, a watchman install on `/opt/homebrew/bin` (the default
171
+ // homebrew prefix on Apple Silicon) is absent from the PATH Node
172
+ // inherits on spawn, and `mach watch` fails at the `watch-project`
173
+ // subscription step.
174
+ const watchmanDir = dirname(watchmanPath);
175
+ const existingPath = process.env['PATH'] ?? '';
176
+ const pathSegments = existingPath.split(delimiter).filter((segment) => segment.length > 0);
177
+ const watchmanEnv = pathSegments.includes(watchmanDir)
178
+ ? { ...process.env }
179
+ : {
180
+ ...process.env,
181
+ PATH: [watchmanDir, ...pathSegments].join(delimiter),
182
+ };
183
+ verbose(`watch: resolved watchman at ${watchmanPath}; forwarding directory in subprocess PATH.`);
148
184
  let result;
149
185
  try {
150
- result = await watchWithOutput(paths.engine);
186
+ result = await watchWithOutput(paths.engine, { env: watchmanEnv });
151
187
  }
152
188
  catch (error) {
153
189
  throw new BuildError('Watch process failed to start', 'mach watch', error instanceof Error ? error : undefined);
@@ -158,7 +194,7 @@ export async function watchCommand(projectRoot) {
158
194
  throw new GeneralError(buildWatchmanConfigureTimeMessage());
159
195
  }
160
196
  // 130 is SIGINT (Ctrl+C), which is expected
161
- throw new BuildError(buildUnsupportedWatchMessage(result.exitCode), 'mach watch');
197
+ throw new BuildError(buildUnsupportedWatchMessage(result.exitCode, watchmanPath), 'mach watch');
162
198
  }
163
199
  outro('Watch mode stopped');
164
200
  }
@@ -4,6 +4,20 @@ export declare const CUSTOM_ELEMENTS_JS = "toolkit/content/customElements.js";
4
4
  export declare const JAR_MN = "toolkit/content/jar.mn";
5
5
  /** Default Fluent localization directory for toolkit global components, relative to engine root */
6
6
  export declare const FTL_DIR = "toolkit/locales/en-US/toolkit/global";
7
+ /**
8
+ * Suffix for the per-binary xpcshell scaffold parent directory. Components
9
+ * created with `furnace create --with-tests --xpcshell` land at
10
+ * `browser/base/content/test/<binaryName>${XPCSHELL_TEST_DIR_SUFFIX}/<component>/`.
11
+ * Centralised so `create` / `remove` / `rename` / `validate` all agree on
12
+ * the path template (2026-04-24 eval Finding 5).
13
+ */
14
+ export declare const XPCSHELL_TEST_DIR_SUFFIX = "-xpcshell";
15
+ /**
16
+ * Returns the engine-relative directory that holds xpcshell scaffolds for
17
+ * a given binary. Matches the form `create-xpcshell.ts` writes and the
18
+ * path `remove.ts` / `rename.ts` / `validate.ts` must clean up.
19
+ */
20
+ export declare function xpcshellTestParentDir(binaryName: string): string;
7
21
  /** File extensions that constitute a Furnace component's source files. */
8
22
  export declare const COMPONENT_FILE_EXTENSIONS: readonly [".mjs", ".css", ".ftl"];
9
23
  /** Returns true when `fileName` has one of the standard component file extensions. */
@@ -5,6 +5,22 @@ export const CUSTOM_ELEMENTS_JS = 'toolkit/content/customElements.js';
5
5
  export const JAR_MN = 'toolkit/content/jar.mn';
6
6
  /** Default Fluent localization directory for toolkit global components, relative to engine root */
7
7
  export const FTL_DIR = 'toolkit/locales/en-US/toolkit/global';
8
+ /**
9
+ * Suffix for the per-binary xpcshell scaffold parent directory. Components
10
+ * created with `furnace create --with-tests --xpcshell` land at
11
+ * `browser/base/content/test/<binaryName>${XPCSHELL_TEST_DIR_SUFFIX}/<component>/`.
12
+ * Centralised so `create` / `remove` / `rename` / `validate` all agree on
13
+ * the path template (2026-04-24 eval Finding 5).
14
+ */
15
+ export const XPCSHELL_TEST_DIR_SUFFIX = '-xpcshell';
16
+ /**
17
+ * Returns the engine-relative directory that holds xpcshell scaffolds for
18
+ * a given binary. Matches the form `create-xpcshell.ts` writes and the
19
+ * path `remove.ts` / `rename.ts` / `validate.ts` must clean up.
20
+ */
21
+ export function xpcshellTestParentDir(binaryName) {
22
+ return `browser/base/content/test/${binaryName}${XPCSHELL_TEST_DIR_SUFFIX}`;
23
+ }
8
24
  /** File extensions that constitute a Furnace component's source files. */
9
25
  export const COMPONENT_FILE_EXTENSIONS = ['.mjs', '.css', '.ftl'];
10
26
  /** Returns true when `fileName` has one of the standard component file extensions. */
@@ -1,8 +1,10 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
+ import { readdir } from 'node:fs/promises';
2
3
  import { join } from 'node:path';
3
4
  import { pathExists } from '../utils/fs.js';
4
- import { loadConfig } from './config.js';
5
+ import { getProjectPaths, loadConfig } from './config.js';
5
6
  import { getFurnacePaths, loadFurnaceConfig } from './furnace-config.js';
7
+ import { xpcshellTestParentDir } from './furnace-constants.js';
6
8
  import { detectComposesCycles, validateComposesReferences } from './furnace-graph-utils.js';
7
9
  import { validateAccessibility, validateCompatibility, validateJarMnEntries, validateRegistrationPatterns, validateStructure, validateTokenLink, } from './furnace-validate-checks.js';
8
10
  import { findOverrideBaseVersionDrift, } from './furnace-version-drift.js';
@@ -175,6 +177,70 @@ export async function validateAllComponents(root) {
175
177
  existing.push(issue);
176
178
  results.set(issue.component, existing);
177
179
  }
180
+ // 2026-04-24 eval Finding 5: orphan xpcshell scaffold detection.
181
+ // `furnace create --with-tests --xpcshell` scaffolds a test directory
182
+ // at `browser/base/content/test/<binary>-xpcshell/<name>/`, and prior
183
+ // `furnace remove` + `furnace rename` flows did not touch that tree.
184
+ // A leftover scaffold whose `<name>` is not in furnace.json is almost
185
+ // always the aftermath of one of those incomplete flows; flag it as
186
+ // an `orphan-xpcshell-scaffold` error so operators know to delete or
187
+ // re-create the scaffold instead of discovering the mismatch only at
188
+ // test run time. Missing engine or missing scaffold parent directory
189
+ // both degrade silently — this check never introduces noise on a
190
+ // project that never used xpcshell scaffolding.
191
+ try {
192
+ const orphanIssues = await findOrphanXpcshellScaffolds(root, config);
193
+ for (const issue of orphanIssues) {
194
+ const existing = results.get(issue.component) ?? [];
195
+ existing.push(issue);
196
+ results.set(issue.component, existing);
197
+ }
198
+ }
199
+ catch {
200
+ // Validation degrades gracefully — the absence of an engine
201
+ // directory, permission denial reading the scaffold tree, or any
202
+ // other transient fs issue should never cascade into false
203
+ // "orphan" reports.
204
+ }
178
205
  return results;
179
206
  }
207
+ /**
208
+ * Scans the per-binary xpcshell scaffold directory for entries whose
209
+ * component name is not present in furnace.json, and returns an
210
+ * `orphan-xpcshell-scaffold` issue for each one.
211
+ */
212
+ async function findOrphanXpcshellScaffolds(root, config) {
213
+ const forgeConfig = await loadConfig(root);
214
+ const paths = getProjectPaths(root);
215
+ const parentRel = xpcshellTestParentDir(forgeConfig.binaryName);
216
+ const parentAbs = join(paths.engine, parentRel);
217
+ if (!(await pathExists(parentAbs)))
218
+ return [];
219
+ let entries;
220
+ try {
221
+ const dirents = await readdir(parentAbs, { withFileTypes: true });
222
+ entries = dirents.filter((d) => d.isDirectory()).map((d) => d.name);
223
+ }
224
+ catch {
225
+ return [];
226
+ }
227
+ const known = new Set([
228
+ ...Object.keys(config.custom),
229
+ ...Object.keys(config.overrides),
230
+ ...config.stock,
231
+ ]);
232
+ const issues = [];
233
+ for (const entry of entries) {
234
+ if (known.has(entry))
235
+ continue;
236
+ issues.push({
237
+ component: entry,
238
+ severity: 'error',
239
+ check: 'orphan-xpcshell-scaffold',
240
+ message: `Stale xpcshell test scaffold at ${parentRel}/${entry}/ — no matching component is declared in furnace.json. ` +
241
+ 'Delete the scaffold directory manually, or re-run `fireforge furnace create --with-tests --xpcshell` for an existing component with the same name.',
242
+ });
243
+ }
244
+ return issues;
245
+ }
180
246
  //# sourceMappingURL=furnace-validate.js.map
@@ -1,6 +1,31 @@
1
- /** Default timeout for `git add -A` on large trees (10 minutes). */
1
+ /**
2
+ * Environment variable that overrides the monolithic `git add -A` timeout
3
+ * (milliseconds). 2026-04-24 eval Finding 10: operators on slow or loaded
4
+ * filesystems legitimately exceeded the 10-minute default during a
5
+ * 140.10.0esr baseline indexing pass; making the cap overridable lets
6
+ * them retry without recompiling.
7
+ */
8
+ export declare const GIT_ADD_TIMEOUT_ENV_VAR = "FIREFORGE_GIT_ADD_TIMEOUT_MS";
9
+ /**
10
+ * Environment variable that overrides the per-chunk `git add -- <dir>`
11
+ * timeout (milliseconds). Paired with {@link GIT_ADD_TIMEOUT_ENV_VAR} so
12
+ * both the monolithic attempt and the chunked fallback can be extended.
13
+ */
14
+ export declare const GIT_ADD_CHUNK_TIMEOUT_ENV_VAR = "FIREFORGE_GIT_ADD_CHUNK_TIMEOUT_MS";
15
+ /**
16
+ * Resolved timeout for monolithic `git add -A`. Prefers
17
+ * {@link GIT_ADD_TIMEOUT_ENV_VAR} when present (and a positive
18
+ * integer) so operators on slow hosts can extend the default without
19
+ * rebuilding FireForge.
20
+ */
2
21
  export declare const GIT_ADD_TIMEOUT_MS: number;
3
- /** Timeout for chunked `git add` per top-level directory (20 minutes). */
22
+ /**
23
+ * Resolved timeout for each chunk of the chunked fallback path. Grew
24
+ * from 20 to 30 minutes in 0.18.1 because the fallback is already the
25
+ * last line of defence before aborting — erring on the side of "complete
26
+ * the indexing" over "fail fast" matches the real-world recovery
27
+ * workflow.
28
+ */
4
29
  export declare const GIT_ADD_CHUNK_TIMEOUT_MS: number;
5
30
  /**
6
31
  * Structured git status entry derived from `git status --porcelain=v1 -z`.
@@ -1,10 +1,48 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  import { GitError, GitNotFoundError } from '../errors/git.js';
3
3
  import { exec, executableExists } from '../utils/process.js';
4
+ /**
5
+ * Environment variable that overrides the monolithic `git add -A` timeout
6
+ * (milliseconds). 2026-04-24 eval Finding 10: operators on slow or loaded
7
+ * filesystems legitimately exceeded the 10-minute default during a
8
+ * 140.10.0esr baseline indexing pass; making the cap overridable lets
9
+ * them retry without recompiling.
10
+ */
11
+ export const GIT_ADD_TIMEOUT_ENV_VAR = 'FIREFORGE_GIT_ADD_TIMEOUT_MS';
12
+ /**
13
+ * Environment variable that overrides the per-chunk `git add -- <dir>`
14
+ * timeout (milliseconds). Paired with {@link GIT_ADD_TIMEOUT_ENV_VAR} so
15
+ * both the monolithic attempt and the chunked fallback can be extended.
16
+ */
17
+ export const GIT_ADD_CHUNK_TIMEOUT_ENV_VAR = 'FIREFORGE_GIT_ADD_CHUNK_TIMEOUT_MS';
4
18
  /** Default timeout for `git add -A` on large trees (10 minutes). */
5
- export const GIT_ADD_TIMEOUT_MS = 10 * 60_000;
6
- /** Timeout for chunked `git add` per top-level directory (20 minutes). */
7
- export const GIT_ADD_CHUNK_TIMEOUT_MS = 20 * 60_000;
19
+ const DEFAULT_GIT_ADD_TIMEOUT_MS = 10 * 60_000;
20
+ /** Default timeout for chunked `git add` per top-level directory (30 minutes). */
21
+ const DEFAULT_GIT_ADD_CHUNK_TIMEOUT_MS = 30 * 60_000;
22
+ function resolveTimeoutFromEnv(envVar, fallbackMs) {
23
+ const raw = process.env[envVar];
24
+ if (raw === undefined || raw.length === 0)
25
+ return fallbackMs;
26
+ const parsed = Number.parseInt(raw, 10);
27
+ if (!Number.isFinite(parsed) || parsed <= 0)
28
+ return fallbackMs;
29
+ return parsed;
30
+ }
31
+ /**
32
+ * Resolved timeout for monolithic `git add -A`. Prefers
33
+ * {@link GIT_ADD_TIMEOUT_ENV_VAR} when present (and a positive
34
+ * integer) so operators on slow hosts can extend the default without
35
+ * rebuilding FireForge.
36
+ */
37
+ export const GIT_ADD_TIMEOUT_MS = resolveTimeoutFromEnv(GIT_ADD_TIMEOUT_ENV_VAR, DEFAULT_GIT_ADD_TIMEOUT_MS);
38
+ /**
39
+ * Resolved timeout for each chunk of the chunked fallback path. Grew
40
+ * from 20 to 30 minutes in 0.18.1 because the fallback is already the
41
+ * last line of defence before aborting — erring on the side of "complete
42
+ * the indexing" over "fail fast" matches the real-world recovery
43
+ * workflow.
44
+ */
45
+ export const GIT_ADD_CHUNK_TIMEOUT_MS = resolveTimeoutFromEnv(GIT_ADD_CHUNK_TIMEOUT_ENV_VAR, DEFAULT_GIT_ADD_CHUNK_TIMEOUT_MS);
8
46
  /**
9
47
  * Ensures git is available in the system.
10
48
  * @throws GitNotFoundError if git is not installed