@hominis/fireforge 0.34.1 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +17 -4
  3. package/dist/bin/fireforge.d.ts +4 -0
  4. package/dist/bin/fireforge.js +4 -0
  5. package/dist/src/commands/build.js +13 -0
  6. package/dist/src/commands/discard.js +6 -2
  7. package/dist/src/commands/download.js +23 -2
  8. package/dist/src/commands/run.js +14 -2
  9. package/dist/src/commands/test.js +46 -6
  10. package/dist/src/core/build-prepare.js +19 -5
  11. package/dist/src/core/firefox-cache.js +7 -4
  12. package/dist/src/core/firefox-extract.d.ts +21 -1
  13. package/dist/src/core/firefox-extract.js +117 -7
  14. package/dist/src/core/git.js +2 -2
  15. package/dist/src/core/license-headers.d.ts +16 -0
  16. package/dist/src/core/license-headers.js +19 -0
  17. package/dist/src/core/mach-error-hints.js +27 -0
  18. package/dist/src/core/mach-resource-shim.d.ts +42 -7
  19. package/dist/src/core/mach-resource-shim.js +227 -25
  20. package/dist/src/core/patch-apply.js +51 -21
  21. package/dist/src/core/patch-lint.js +12 -1
  22. package/dist/src/core/patch-manifest-io.js +7 -7
  23. package/dist/src/core/patch-manifest-query.d.ts +6 -0
  24. package/dist/src/core/patch-manifest-query.js +9 -2
  25. package/dist/src/core/test-harness-crash.js +12 -0
  26. package/dist/src/core/test-path-scope.d.ts +54 -0
  27. package/dist/src/core/test-path-scope.js +133 -0
  28. package/dist/src/core/toolchain-preflight.d.ts +64 -0
  29. package/dist/src/core/toolchain-preflight.js +195 -0
  30. package/dist/src/core/typecheck-shim.d.ts +4 -1
  31. package/dist/src/core/typecheck-shim.js +48 -17
  32. package/dist/src/errors/download.js +2 -0
  33. package/dist/src/types/commands/options.d.ts +7 -0
  34. package/package.json +1 -1
@@ -3,8 +3,8 @@
3
3
  * Patch orchestration — coordinates patch discovery, application, and validation.
4
4
  * Pure parsing, content transformation, and lock management are in separate modules.
5
5
  */
6
- import { lstat } from 'node:fs/promises';
7
- import { join, resolve } from 'node:path';
6
+ import { lstat, readlink, realpath } from 'node:fs/promises';
7
+ import { basename, dirname, join, resolve, sep } from 'node:path';
8
8
  import { PatchError } from '../errors/patch.js';
9
9
  import { toError } from '../utils/errors.js';
10
10
  import { pathExists, readText, writeText } from '../utils/fs.js';
@@ -159,33 +159,63 @@ export async function validatePatches(patchesDir, engineDir) {
159
159
  return { valid: errors.length === 0, errors };
160
160
  }
161
161
  async function validatePatchTargets(patch, affectedFiles, engineDir) {
162
+ // realpath the engine root once so containment is checked against the
163
+ // physical tree (the root itself may sit behind a symlink, e.g. /tmp on
164
+ // macOS). An unresolvable engine dir skips the symlink checks — a missing
165
+ // engine surfaces through `git apply --check` with a better message.
166
+ const engineRoot = engineDir ? await realpath(engineDir).catch(() => null) : null;
162
167
  for (const file of affectedFiles) {
163
168
  if (!isContainedRelativePath(file)) {
164
169
  throw new PatchError(`Patch targets a path outside engine/: ${file}`, patch.filename);
165
170
  }
166
- // When the engine directory is known, verify that existing target paths
167
- // are not symlinks pointing outside the engine tree. A crafted patch
168
- // could otherwise write through a symlink to an arbitrary location.
169
- if (engineDir) {
170
- const targetPath = join(engineDir, file);
171
- try {
172
- const stats = await lstat(targetPath);
173
- if (stats.isSymbolicLink()) {
174
- const realPath = resolve(engineDir, file);
175
- const resolvedEngine = resolve(engineDir);
176
- if (!realPath.startsWith(resolvedEngine + '/') && realPath !== resolvedEngine) {
177
- throw new PatchError(`Patch targets a symlink that resolves outside engine/: ${file}`, patch.filename);
178
- }
179
- }
180
- }
181
- catch (error) {
182
- // File doesn't exist yet (new file) or stat fails — skip check
183
- if (error instanceof PatchError)
184
- throw error;
171
+ // Verify that a write to the target would physically land inside the
172
+ // engine tree. A crafted patch could otherwise write through a symlink
173
+ // (the target itself, a dangling link, or a symlinked parent directory)
174
+ // to an arbitrary location.
175
+ if (engineDir && engineRoot) {
176
+ const destination = await resolvePatchWriteDestination(join(engineDir, file));
177
+ if (destination !== engineRoot && !destination.startsWith(engineRoot + sep)) {
178
+ throw new PatchError(`Patch targets a path that resolves outside engine/ (symlink escape): ${file}`, patch.filename);
185
179
  }
186
180
  }
187
181
  }
188
182
  }
183
+ /**
184
+ * Resolves where a write to `targetPath` would physically land, following
185
+ * symlinks on every path component that already exists.
186
+ *
187
+ * - Existing target: `realpath` resolves it fully.
188
+ * - Dangling symlink: `realpath` rejects it, but a write through the link
189
+ * would still be created at the link target, so the target is resolved
190
+ * against the link's (real) parent directory. The link target is taken
191
+ * textually — a loop or nested dangling link fails at apply time anyway.
192
+ * - Not-yet-existing file: resolved against the nearest existing ancestor's
193
+ * real path. Components below that ancestor do not exist, so they cannot
194
+ * be symlinks and are appended verbatim.
195
+ */
196
+ async function resolvePatchWriteDestination(targetPath) {
197
+ try {
198
+ return await realpath(targetPath);
199
+ }
200
+ catch {
201
+ // Fall through: the path (or its symlink target) does not exist.
202
+ }
203
+ try {
204
+ const stats = await lstat(targetPath);
205
+ if (stats.isSymbolicLink()) {
206
+ const linkTarget = await readlink(targetPath);
207
+ return resolve(await resolvePatchWriteDestination(dirname(targetPath)), linkTarget);
208
+ }
209
+ }
210
+ catch {
211
+ // The path component itself does not exist.
212
+ }
213
+ const parent = dirname(targetPath);
214
+ if (parent === targetPath) {
215
+ return targetPath;
216
+ }
217
+ return join(await resolvePatchWriteDestination(parent), basename(targetPath));
218
+ }
189
219
  /**
190
220
  * Decides whether a patch filename matches an `--until` identifier.
191
221
  *
@@ -2,7 +2,7 @@
2
2
  import { dirname, join } from 'node:path';
3
3
  import { pathExists, readText } from '../utils/fs.js';
4
4
  import { stripJsComments } from '../utils/regex.js';
5
- import { containsUpstreamLicenseText, getLicenseHeader, hasAnyLicenseHeader, hasAnyLicenseHeaderAnyStyle, } from './license-headers.js';
5
+ import { containsUpstreamLicenseText, getLicenseHeader, hasAnyLicenseHeader, hasAnyLicenseHeaderAnyStyle, hasUpstreamMplBlockHeader, } from './license-headers.js';
6
6
  import { invokePatchLintCheckJs } from './patch-lint-checkjs.js';
7
7
  import { lintChromeScriptJsDocForFile } from './patch-lint-chrome-jsdoc.js';
8
8
  import { lintPatchedCss } from './patch-lint-css.js';
@@ -251,6 +251,17 @@ export async function lintNewFileHeaders(repoDir, newFiles, config) {
251
251
  // browser/base/content) do not need `--skip-lint` to land it.
252
252
  if (license === 'MPL-2.0' && hasAnyLicenseHeader(content, style))
253
253
  continue;
254
+ // Accept the verbatim upstream MPL block header on new JS files
255
+ // REGARDLESS of the project license: a file copied from the Firefox
256
+ // tree legitimately keeps Mozilla's header for provenance in an
257
+ // EUPL/GPL/0BSD project too. The carve-out above is gated on the
258
+ // project being MPL-2.0 itself, which made this documented case dead
259
+ // code for every other license — consumers carried repo-side audit
260
+ // workarounds (recorded 2026-07-04 in the Hominis FORGE.md). Only
261
+ // the block-comment form is accepted; the `// `-style MPL header is
262
+ // FireForge-generated, not upstream provenance.
263
+ if (style === 'js' && hasUpstreamMplBlockHeader(content))
264
+ continue;
254
265
  issues.push({
255
266
  file,
256
267
  check: 'missing-license-header',
@@ -13,7 +13,7 @@ import { join } from 'node:path';
13
13
  import { FireForgeError } from '../errors/base.js';
14
14
  import { ExitCode } from '../errors/codes.js';
15
15
  import { toError } from '../utils/errors.js';
16
- import { pathExists, readJson, removeFile, writeJson } from '../utils/fs.js';
16
+ import { pathExistsStrict, readJson, removeFile, writeJson } from '../utils/fs.js';
17
17
  import { warn } from '../utils/logger.js';
18
18
  import { isArray, isObject } from '../utils/validation.js';
19
19
  import { validatePatchesManifest } from './patch-manifest-validate.js';
@@ -25,7 +25,7 @@ export const PATCHES_MANIFEST = 'patches.json';
25
25
  */
26
26
  export async function loadPatchesManifestState(patchesDir) {
27
27
  const manifestPath = join(patchesDir, PATCHES_MANIFEST);
28
- if (!(await pathExists(manifestPath))) {
28
+ if (!(await pathExistsStrict(manifestPath))) {
29
29
  return { exists: false, manifest: null, parseError: undefined };
30
30
  }
31
31
  try {
@@ -70,7 +70,7 @@ export async function savePatchesManifest(patchesDir, manifest) {
70
70
  */
71
71
  export async function mutatePatchRowsInManifest(patchesDir, filenames, mutator) {
72
72
  const manifestPath = join(patchesDir, PATCHES_MANIFEST);
73
- if (!(await pathExists(manifestPath)))
73
+ if (!(await pathExistsStrict(manifestPath)))
74
74
  return null;
75
75
  const rawManifest = await readJson(manifestPath);
76
76
  const beforeManifest = validatePatchesManifest(rawManifest);
@@ -252,7 +252,7 @@ export async function renumberPatchesInManifest(patchesDir, renameMap) {
252
252
  try {
253
253
  for (const [oldFilename, entry] of renameMap) {
254
254
  const oldPath = join(patchesDir, oldFilename);
255
- if (!(await pathExists(oldPath))) {
255
+ if (!(await pathExistsStrict(oldPath))) {
256
256
  throw new Error(`Cannot renumber: patch file is missing on disk: ${oldFilename}`);
257
257
  }
258
258
  const stagedName = `.fireforge-renumber-${stagingId}-${oldFilename}`;
@@ -280,7 +280,7 @@ export async function renumberPatchesInManifest(patchesDir, renameMap) {
280
280
  for (const stagedEntry of stagedRenames) {
281
281
  const { staged, toEntry } = stagedEntry;
282
282
  const targetPath = join(patchesDir, toEntry.newFilename);
283
- if (await pathExists(targetPath)) {
283
+ if (await pathExistsStrict(targetPath)) {
284
284
  throw new Error(`Cannot renumber: target patch filename already exists on disk: ${toEntry.newFilename}`);
285
285
  }
286
286
  await rename(join(patchesDir, staged), targetPath);
@@ -291,7 +291,7 @@ export async function renumberPatchesInManifest(patchesDir, renameMap) {
291
291
  // described: manifest rewrote to new filenames while the old
292
292
  // files stayed on disk. If the assert ever fires, the Phase 2
293
293
  // rollback will undo prior moves before re-throwing.
294
- if (!(await pathExists(targetPath))) {
294
+ if (!(await pathExistsStrict(targetPath))) {
295
295
  throw new Error(`Rename postcondition failed: expected ${toEntry.newFilename} to exist after rename, but it was not found on disk.`);
296
296
  }
297
297
  completedFinalRenames.push(stagedEntry);
@@ -428,7 +428,7 @@ export async function removePatchFileAndManifest(patchesDir, filename) {
428
428
  const originalManifest = await loadPatchesManifest(patchesDir);
429
429
  const removedFromManifest = await removePatchFromManifest(patchesDir, filename);
430
430
  try {
431
- if (await pathExists(patchPath)) {
431
+ if (await pathExistsStrict(patchPath)) {
432
432
  await removeFile(patchPath);
433
433
  }
434
434
  }
@@ -42,6 +42,12 @@ export declare function validatePatchIntegrity(patchesDir: string, engineDir: st
42
42
  /**
43
43
  * Stamps multiple patches with a new source version in a single
44
44
  * manifest read-modify-write cycle.
45
+ *
46
+ * Acquires the shared patch-directory lock for the read-modify-write, so a
47
+ * concurrent export/reorder/metadata update cannot interleave with the stamp.
48
+ * The lock is not reentrant — callers must NOT invoke this while already
49
+ * holding {@link withPatchDirectoryLock} on the same patches directory.
50
+ *
45
51
  * @param patchesDir - Path to the patches directory
46
52
  * @param filenames - Patch filenames to update
47
53
  * @param newVersion - Version string to set (e.g. "140.9.0esr")
@@ -5,6 +5,7 @@
5
5
  import { readText } from '../utils/fs.js';
6
6
  import { fileExistsInHead } from './git-file-ops.js';
7
7
  import { discoverPatches, getAllTargetFilesFromPatch } from './patch-files.js';
8
+ import { withPatchDirectoryLock } from './patch-lock.js';
8
9
  import { loadPatchesManifest, mutatePatchRowsInManifest } from './patch-manifest-io.js';
9
10
  import { isNewFileInPatch } from './patch-parse.js';
10
11
  /**
@@ -101,12 +102,18 @@ export async function validatePatchIntegrity(patchesDir, engineDir) {
101
102
  /**
102
103
  * Stamps multiple patches with a new source version in a single
103
104
  * manifest read-modify-write cycle.
105
+ *
106
+ * Acquires the shared patch-directory lock for the read-modify-write, so a
107
+ * concurrent export/reorder/metadata update cannot interleave with the stamp.
108
+ * The lock is not reentrant — callers must NOT invoke this while already
109
+ * holding {@link withPatchDirectoryLock} on the same patches directory.
110
+ *
104
111
  * @param patchesDir - Path to the patches directory
105
112
  * @param filenames - Patch filenames to update
106
113
  * @param newVersion - Version string to set (e.g. "140.9.0esr")
107
114
  */
108
115
  export async function stampPatchVersions(patchesDir, filenames, newVersion, newProduct) {
109
- await mutatePatchRowsInManifest(patchesDir, filenames, (patch, rawPatch) => {
116
+ await withPatchDirectoryLock(patchesDir, () => mutatePatchRowsInManifest(patchesDir, filenames, (patch, rawPatch) => {
110
117
  if (patch.sourceEsrVersion !== newVersion ||
111
118
  rawPatch['sourceVersion'] !== newVersion ||
112
119
  (newProduct !== undefined && rawPatch['sourceProduct'] !== newProduct)) {
@@ -119,6 +126,6 @@ export async function stampPatchVersions(patchesDir, filenames, newVersion, newP
119
126
  };
120
127
  }
121
128
  return null;
122
- });
129
+ }));
123
130
  }
124
131
  //# sourceMappingURL=patch-manifest-query.js.map
@@ -75,6 +75,13 @@ const STARTUP_TRACEBACK_SIGNALS = [
75
75
  /HOST_VM_INFO64/,
76
76
  /\(ipc\/mig\) array not large enough/,
77
77
  /psutil\.[A-Za-z]*Error/,
78
+ // Field report (0.34.1): on a degraded host every collector sample is
79
+ // rejected, aggregation has nothing, and mozbuild's log_resource_usage
80
+ // dies on usage["io"].read_bytes AFTER a fully successful compile
81
+ // ("Error running mach" with complete artifacts). Environmental, not a
82
+ // build regression — the protected build retries it (incremental retry
83
+ // is cheap and the guard keeps the retry green).
84
+ /AttributeError: 'NoneType' object has no attribute 'read_bytes'/,
78
85
  ...DEGRADED_READING_CRASH_SIGNALS,
79
86
  ];
80
87
  function findLine(output, patterns) {
@@ -102,6 +109,11 @@ const NOISE_LINE_PATTERNS = [
102
109
  /_collect failed(?!.*_DegradedReading)/i,
103
110
  /FireForge: host resource monitor degraded/i,
104
111
  /warnings\.warn\(/,
112
+ // mozsystemmonitor's parent-side drain loop rejecting a malformed sample
113
+ // (field report 0.34.1); mach sometimes reprints the warning text without
114
+ // the `UserWarning:` token, and a run that completes despite the stream
115
+ // must never classify as crash on it.
116
+ /failed to read the received data/i,
105
117
  ];
106
118
  /** Matches the caught/telemetry context that marks a traceback as benign. */
107
119
  const BENIGN_TRACEBACK_CONTEXT = /telemetry|glean/i;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Directory-argument scope analysis for `fireforge test`.
3
+ *
4
+ * mozbuild's test resolver matches command-line paths by STRING PREFIX,
5
+ * so `fireforge test browser/base/content/test/hominis` silently also
6
+ * ran the sibling directory `browser/base/content/test/hominis-tiles`
7
+ * (152.0b7 → 153.0b8 source-refresh drill: 1224 tests instead of ~200,
8
+ * with no indication the scope widened). A trailing separator makes the
9
+ * prefix match exact — `hominis/` cannot prefix `hominis-tiles/…`.
10
+ *
11
+ * FireForge therefore treats a directory argument as meaning EXACTLY
12
+ * that directory: {@link analyzeTestPathScopes} returns a trailing-`/`
13
+ * dispatch form for every existing directory, plus the sibling
14
+ * directories the raw prefix would have swept in so the command layer
15
+ * can tell the operator what was excluded. Note FireForge already
16
+ * rejects non-existent paths up front, so raw prefix-widening was never
17
+ * something an operator could invoke deliberately — it only ever
18
+ * happened by accident.
19
+ */
20
+ /** A sibling directory the raw mach prefix match would have included. */
21
+ export interface SiblingPrefixMatch {
22
+ /** Engine-relative path of the sibling directory. */
23
+ path: string;
24
+ /** Recursive count of `browser_*` / `test_*` JS files under it. */
25
+ testFileCount: number;
26
+ }
27
+ /** Scope analysis for one `fireforge test` path argument. */
28
+ export interface TestPathScope {
29
+ /** The path as the operator passed it (engine-relative, normalized). */
30
+ requestedPath: string;
31
+ /**
32
+ * The form to hand to mach: directories gain a trailing `/` so
33
+ * mozbuild's prefix match is exact; files pass through unchanged.
34
+ */
35
+ dispatchPath: string;
36
+ /** True when the path resolved to a directory under engine/. */
37
+ isDirectory: boolean;
38
+ /** Recursive test-file count inside the requested directory (0 for files). */
39
+ testFileCount: number;
40
+ /** Sibling directories a raw prefix match would also have selected. */
41
+ siblingPrefixMatches: SiblingPrefixMatch[];
42
+ }
43
+ /**
44
+ * Analyzes every test path argument of a run.
45
+ *
46
+ * @param engineDir - Path to the engine directory
47
+ * @param requestedPaths - Engine-relative test path arguments
48
+ */
49
+ export declare function analyzeTestPathScopes(engineDir: string, requestedPaths: readonly string[]): Promise<TestPathScope[]>;
50
+ /**
51
+ * Formats the sibling-exclusion notice for one scope, or undefined when
52
+ * nothing needs saying (file arg, or no prefix-matching siblings).
53
+ */
54
+ export declare function formatScopeNotice(scope: TestPathScope): string | undefined;
@@ -0,0 +1,133 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Directory-argument scope analysis for `fireforge test`.
4
+ *
5
+ * mozbuild's test resolver matches command-line paths by STRING PREFIX,
6
+ * so `fireforge test browser/base/content/test/hominis` silently also
7
+ * ran the sibling directory `browser/base/content/test/hominis-tiles`
8
+ * (152.0b7 → 153.0b8 source-refresh drill: 1224 tests instead of ~200,
9
+ * with no indication the scope widened). A trailing separator makes the
10
+ * prefix match exact — `hominis/` cannot prefix `hominis-tiles/…`.
11
+ *
12
+ * FireForge therefore treats a directory argument as meaning EXACTLY
13
+ * that directory: {@link analyzeTestPathScopes} returns a trailing-`/`
14
+ * dispatch form for every existing directory, plus the sibling
15
+ * directories the raw prefix would have swept in so the command layer
16
+ * can tell the operator what was excluded. Note FireForge already
17
+ * rejects non-existent paths up front, so raw prefix-widening was never
18
+ * something an operator could invoke deliberately — it only ever
19
+ * happened by accident.
20
+ */
21
+ import { readdir, stat } from 'node:fs/promises';
22
+ import { basename, dirname, join } from 'node:path';
23
+ /** Test implementation files as mozbuild manifests name them. */
24
+ const TEST_FILE_PATTERN = /^(?:browser|test)_.*\.m?js$/;
25
+ /**
26
+ * Recursively counts test-implementation files under a directory.
27
+ * Returns 0 when the directory cannot be read — the count feeds an
28
+ * informational notice, never a decision.
29
+ */
30
+ async function countTestFiles(dir) {
31
+ let count = 0;
32
+ let entries;
33
+ try {
34
+ entries = await readdir(dir, { withFileTypes: true });
35
+ }
36
+ catch {
37
+ return 0;
38
+ }
39
+ for (const entry of entries) {
40
+ if (entry.isDirectory()) {
41
+ count += await countTestFiles(join(dir, entry.name));
42
+ }
43
+ else if (entry.isFile() && TEST_FILE_PATTERN.test(entry.name)) {
44
+ count += 1;
45
+ }
46
+ }
47
+ return count;
48
+ }
49
+ /** Probes whether `path` is a directory, without throwing. */
50
+ async function isDirectorySafe(path) {
51
+ try {
52
+ return (await stat(path)).isDirectory();
53
+ }
54
+ catch {
55
+ return false;
56
+ }
57
+ }
58
+ /**
59
+ * Analyzes one engine-relative test path argument. See the module doc
60
+ * comment for the semantics.
61
+ */
62
+ async function analyzeTestPathScope(engineDir, requestedPath) {
63
+ const stripped = requestedPath.replace(/\/+$/, '');
64
+ const absolute = join(engineDir, stripped);
65
+ if (!(await isDirectorySafe(absolute))) {
66
+ return {
67
+ requestedPath,
68
+ dispatchPath: requestedPath,
69
+ isDirectory: false,
70
+ testFileCount: 0,
71
+ siblingPrefixMatches: [],
72
+ };
73
+ }
74
+ const siblingPrefixMatches = [];
75
+ const base = basename(stripped);
76
+ const parentRel = dirname(stripped);
77
+ try {
78
+ const siblings = await readdir(dirname(absolute), { withFileTypes: true });
79
+ for (const entry of siblings) {
80
+ if (!entry.isDirectory())
81
+ continue;
82
+ if (entry.name === base || !entry.name.startsWith(base))
83
+ continue;
84
+ const siblingRel = parentRel === '.' ? entry.name : `${parentRel}/${entry.name}`;
85
+ const testFileCount = await countTestFiles(join(dirname(absolute), entry.name));
86
+ // A prefix-matching sibling with no test files never surfaces in a
87
+ // mach run, so listing it would only add noise.
88
+ if (testFileCount > 0) {
89
+ siblingPrefixMatches.push({ path: siblingRel, testFileCount });
90
+ }
91
+ }
92
+ }
93
+ catch {
94
+ // Unreadable parent: skip the sibling probe; exactness via the
95
+ // trailing separator is preserved regardless.
96
+ }
97
+ return {
98
+ requestedPath,
99
+ dispatchPath: `${stripped}/`,
100
+ isDirectory: true,
101
+ testFileCount: await countTestFiles(absolute),
102
+ siblingPrefixMatches,
103
+ };
104
+ }
105
+ /**
106
+ * Analyzes every test path argument of a run.
107
+ *
108
+ * @param engineDir - Path to the engine directory
109
+ * @param requestedPaths - Engine-relative test path arguments
110
+ */
111
+ export async function analyzeTestPathScopes(engineDir, requestedPaths) {
112
+ const scopes = [];
113
+ for (const requestedPath of requestedPaths) {
114
+ scopes.push(await analyzeTestPathScope(engineDir, requestedPath));
115
+ }
116
+ return scopes;
117
+ }
118
+ /**
119
+ * Formats the sibling-exclusion notice for one scope, or undefined when
120
+ * nothing needs saying (file arg, or no prefix-matching siblings).
121
+ */
122
+ export function formatScopeNotice(scope) {
123
+ if (!scope.isDirectory || scope.siblingPrefixMatches.length === 0)
124
+ return undefined;
125
+ const siblings = scope.siblingPrefixMatches
126
+ .map((s) => `${s.path}/ (${s.testFileCount} test file${s.testFileCount === 1 ? '' : 's'})`)
127
+ .join(', ');
128
+ return (`Selected exactly ${scope.dispatchPath} (${scope.testFileCount} test file${scope.testFileCount === 1 ? '' : 's'}). ` +
129
+ `Excluded ${scope.siblingPrefixMatches.length} sibling director${scope.siblingPrefixMatches.length === 1 ? 'y' : 'ies'} ` +
130
+ `that a raw prefix match would also have run: ${siblings}. ` +
131
+ 'Pass them as separate paths to include them.');
132
+ }
133
+ //# sourceMappingURL=test-path-scope.js.map
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Toolchain-minimum awareness for Firefox source hops.
3
+ *
4
+ * When `fireforge download --force` moves the engine to a new Firefox
5
+ * MAJOR version, the tree's declared toolchain minimums (cbindgen, Rust)
6
+ * frequently move with it — and the first `fireforge build` then dies a
7
+ * few seconds into `mach configure` with a message whose remediation
8
+ * text names `./mach bootstrap`, the wrong tool for a FireForge-managed
9
+ * repo (152.0b7 → 153.0b8 source-refresh drill: `ERROR: cbindgen version
10
+ * 0.29.1 is too old. At least version 0.29.4 is required.`).
11
+ *
12
+ * Two layers:
13
+ * 1. {@link formatMajorVersionHopNotice} — a post-download nudge when
14
+ * the downloaded major differs from the previously downloaded one.
15
+ * 2. {@link runToolchainPreflight} — a cheap pre-build probe comparing
16
+ * the minimums the tree itself declares against the host binaries
17
+ * `mach configure` will resolve. Deliberately FAIL-SOFT: it reports
18
+ * a mismatch only when a minimum was positively parsed AND the host
19
+ * binary was found AND its version is definitively lower. Any
20
+ * uncertainty (file moved upstream, unparseable output, binary not
21
+ * on PATH) skips silently — the mach-error-hints translator still
22
+ * catches the real configure failure downstream.
23
+ */
24
+ /** Tools the preflight knows how to probe. */
25
+ export type ToolchainTool = 'cbindgen' | 'rustc';
26
+ /** One definitive host-vs-tree mismatch found by the preflight. */
27
+ export interface ToolchainMismatch {
28
+ tool: ToolchainTool;
29
+ hostVersion: string;
30
+ minimumVersion: string;
31
+ /** Engine-relative file the minimum was parsed from. */
32
+ declaredIn: string;
33
+ }
34
+ /**
35
+ * Returns the one-line notice to print after a download that hopped the
36
+ * Firefox MAJOR version, or undefined when no notice is warranted (first
37
+ * download, same major, or unparseable versions — an unparseable version
38
+ * must not spam every download with a false hint).
39
+ *
40
+ * @param previousVersion - `downloadedVersion` recorded in state before this download
41
+ * @param newVersion - Version that was just downloaded
42
+ */
43
+ export declare function formatMajorVersionHopNotice(previousVersion: string | undefined, newVersion: string): string | undefined;
44
+ /**
45
+ * Reads the toolchain minimums the engine tree itself declares. Any file
46
+ * that is missing or no longer matches the expected declaration shape
47
+ * yields undefined for that tool — never an error.
48
+ */
49
+ export declare function readDeclaredToolchainMinimums(engineDir: string): Promise<Partial<Record<ToolchainTool, string>>>;
50
+ /**
51
+ * Compares the tree-declared toolchain minimums against the host binaries
52
+ * `mach configure` will resolve. Returns only DEFINITIVE mismatches; every
53
+ * uncertain probe passes silently (see module header for the rationale).
54
+ *
55
+ * @param engineDir - Path to the engine directory
56
+ */
57
+ export declare function runToolchainPreflight(engineDir: string): Promise<ToolchainMismatch[]>;
58
+ /**
59
+ * Formats the fail-fast message for definitive preflight mismatches,
60
+ * naming `fireforge bootstrap` as the remedy (mach's own configure error
61
+ * suggests `./mach bootstrap`, which is the wrong entry point for a
62
+ * FireForge-managed repo).
63
+ */
64
+ export declare function formatToolchainMismatchMessage(mismatches: ToolchainMismatch[]): string;