@hominis/fireforge 0.18.0 → 0.18.2
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 +55 -34
- package/dist/src/commands/doctor.js +13 -1
- package/dist/src/commands/export-all.js +63 -1
- package/dist/src/commands/export-flow.d.ts +4 -0
- package/dist/src/commands/export-flow.js +8 -0
- package/dist/src/commands/export.js +26 -2
- 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/patch/index.d.ts +5 -3
- package/dist/src/commands/patch/index.js +10 -4
- package/dist/src/commands/patch/lint-ignore.d.ts +39 -0
- package/dist/src/commands/patch/lint-ignore.js +200 -0
- package/dist/src/commands/patch/tier.d.ts +34 -0
- package/dist/src/commands/patch/tier.js +134 -0
- package/dist/src/commands/re-export-files.js +88 -45
- package/dist/src/commands/re-export.js +49 -6
- 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-diff.js +34 -2
- 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-export.d.ts +77 -2
- package/dist/src/core/patch-export.js +82 -3
- 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 +82 -32
- 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/dist/src/types/commands/index.d.ts +1 -1
- package/dist/src/types/commands/options.d.ts +67 -0
- package/dist/src/types/commands/patches.d.ts +6 -5
- package/package.json +1 -1
|
@@ -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
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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,
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
6
|
-
/**
|
|
7
|
-
|
|
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
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
-
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { basename, join } from 'node:path';
|
|
5
5
|
import { GitError } from '../errors/git.js';
|
|
@@ -35,6 +35,16 @@ export async function getFileDiff(repoDir, filePath) {
|
|
|
35
35
|
*/
|
|
36
36
|
export async function generateNewFileDiff(repoDir, filePath) {
|
|
37
37
|
const fullPath = join(repoDir, filePath);
|
|
38
|
+
// Defensive check: a directory here means a caller bypassed the
|
|
39
|
+
// expansion layers and handed the leaf reader a path it cannot
|
|
40
|
+
// read. Surface it with an actionable message naming the offending
|
|
41
|
+
// path rather than the raw `EISDIR` that `readText` would throw —
|
|
42
|
+
// recurring bug class (see the belt-and-suspenders note in
|
|
43
|
+
// `getDiffForFilesAgainstHead`).
|
|
44
|
+
const fileStat = await stat(fullPath);
|
|
45
|
+
if (fileStat.isDirectory()) {
|
|
46
|
+
throw new GitError(`expected a file but found a directory at '${filePath}' — caller must expand directory entries before diffing`, `hash-object ${filePath}`);
|
|
47
|
+
}
|
|
38
48
|
const content = await readText(fullPath);
|
|
39
49
|
// Compute the abbreviated git blob hash for the index line
|
|
40
50
|
let blobHash = '0000000000';
|
|
@@ -212,7 +222,29 @@ export async function getDiffForFilesAgainstHead(repoDir, files) {
|
|
|
212
222
|
}
|
|
213
223
|
continue;
|
|
214
224
|
}
|
|
215
|
-
|
|
225
|
+
const fullPath = join(repoDir, file);
|
|
226
|
+
if (!(await pathExists(fullPath))) {
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
// Second defence against the EISDIR regression: a non-HEAD path
|
|
230
|
+
// that exists on disk is usually a new file, but can also be a
|
|
231
|
+
// directory that arrived without the trailing slash
|
|
232
|
+
// `expandUntrackedDirectoryEntries` would have produced (caller
|
|
233
|
+
// stripped it, submodule entry, tracked-file-replaced-by-dir).
|
|
234
|
+
// Expand it via the same helper used by the slash branch and
|
|
235
|
+
// recurse so each contained file is diffed individually; fail
|
|
236
|
+
// loud when the directory has no readable content rather than
|
|
237
|
+
// silently skipping it.
|
|
238
|
+
const fileStat = await stat(fullPath);
|
|
239
|
+
if (fileStat.isDirectory()) {
|
|
240
|
+
const innerFiles = await getUntrackedFilesInDir(repoDir, file);
|
|
241
|
+
if (innerFiles.length === 0) {
|
|
242
|
+
throw new GitError(`'${file}' is a directory with no untracked content (submodule or gitignored?) — cannot diff as a file`, `ls-files --others -- ${file}`);
|
|
243
|
+
}
|
|
244
|
+
const innerDiff = await getDiffForFilesAgainstHead(repoDir, innerFiles);
|
|
245
|
+
if (innerDiff.trim()) {
|
|
246
|
+
diffs.push(innerDiff);
|
|
247
|
+
}
|
|
216
248
|
continue;
|
|
217
249
|
}
|
|
218
250
|
const diff = await generateNewFileDiff(repoDir, file);
|
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.
|