@hominis/fireforge 0.34.2 → 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.
@@ -38,6 +38,22 @@ export declare function getLicenseHeader(license: ProjectLicense, style: Comment
38
38
  * @param style - Comment syntax of the file
39
39
  */
40
40
  export declare function hasAnyLicenseHeader(content: string, style: CommentStyle): boolean;
41
+ /**
42
+ * Returns true if `content` starts with the verbatim upstream Mozilla
43
+ * MPL-2.0 block header (`/* This Source Code Form … *\/`), optionally
44
+ * preceded by editor-directive comments — the exact shape a JS file
45
+ * copied from the Firefox source tree carries.
46
+ *
47
+ * Deliberately independent of the project license: a new JS file that
48
+ * legitimately keeps its upstream MPL block header (copied-from-upstream
49
+ * provenance) is valid in an EUPL/GPL/0BSD project too. Before 0.35.0
50
+ * this acceptance only existed behind a `license === 'MPL-2.0'` gate in
51
+ * patch-lint, making it dead code for every other project license and
52
+ * forcing repo-side audit workarounds.
53
+ *
54
+ * @param content - File content to check
55
+ */
56
+ export declare function hasUpstreamMplBlockHeader(content: string): boolean;
41
57
  /**
42
58
  * Returns true if `content` starts with any known license header in any
43
59
  * comment style (js, css, hash).
@@ -119,6 +119,25 @@ export function hasAnyLicenseHeader(content, style) {
119
119
  }
120
120
  return false;
121
121
  }
122
+ /**
123
+ * Returns true if `content` starts with the verbatim upstream Mozilla
124
+ * MPL-2.0 block header (`/* This Source Code Form … *\/`), optionally
125
+ * preceded by editor-directive comments — the exact shape a JS file
126
+ * copied from the Firefox source tree carries.
127
+ *
128
+ * Deliberately independent of the project license: a new JS file that
129
+ * legitimately keeps its upstream MPL block header (copied-from-upstream
130
+ * provenance) is valid in an EUPL/GPL/0BSD project too. Before 0.35.0
131
+ * this acceptance only existed behind a `license === 'MPL-2.0'` gate in
132
+ * patch-lint, making it dead code for every other project license and
133
+ * forcing repo-side audit workarounds.
134
+ *
135
+ * @param content - File content to check
136
+ */
137
+ export function hasUpstreamMplBlockHeader(content) {
138
+ const blockHeader = getLicenseHeader('MPL-2.0', 'css');
139
+ return (content.startsWith(blockHeader) || stripLeadingEditorDirectives(content).startsWith(blockHeader));
140
+ }
122
141
  /**
123
142
  * Returns true if `content` starts with any known license header in any
124
143
  * comment style (js, css, hash).
@@ -56,6 +56,33 @@ export const MACH_ERROR_HINTS = [
56
56
  'remove any `pub type basic_string___self_view = …<_CharT>;` line from ' +
57
57
  '`<objdir>/release/build/gecko-profiler-*/out/gecko/bindings.rs`.',
58
58
  },
59
+ {
60
+ // Firefox declares per-release toolchain minimums in-tree
61
+ // (`build/moz.configure/bindgen.configure` for cbindgen; mozboot's
62
+ // MINIMUM_RUST_VERSION for rustc/cargo), and a source hop can move
63
+ // them. The 152.0b7 → 153.0b8 source-refresh drill hit exactly this:
64
+ // the first post-hop build died ~8s into `mach configure` with
65
+ // "ERROR: cbindgen version 0.29.1 is too old. At least version
66
+ // 0.29.4 is required." — and mach's own remediation text names
67
+ // "./mach bootstrap", the wrong entry point for a FireForge-managed
68
+ // repo. Pattern matches configure's cbindgen die() shape.
69
+ pattern: /\bversion [\d.]+ is too old\.?\s+At least version [\d.]+ is required/i,
70
+ hint: 'A toolchain component is older than the minimum this Firefox source declares — typical ' +
71
+ 'after "fireforge download --force" moved the engine to a new Firefox major version. ' +
72
+ 'Run "fireforge bootstrap" (not mach\'s suggested "./mach bootstrap") to update the ' +
73
+ 'toolchain, then retry the build.',
74
+ },
75
+ {
76
+ // Same family, rust.configure's die() shapes: "Rust compiler {v} is
77
+ // too old." / "Cargo package manager {v} is too old." — the minimum
78
+ // is only named further down that message, so the pattern keys on
79
+ // the first line.
80
+ pattern: /\b(?:Rust compiler|Cargo package manager) [\d.]+(?:[\w.-]*)? is too old/i,
81
+ hint: "The Rust toolchain is older than the minimum this Firefox source declares (mozboot's " +
82
+ 'MINIMUM_RUST_VERSION) — typical after "fireforge download --force" moved the engine to a ' +
83
+ 'new Firefox major version. Run "fireforge bootstrap" to update the toolchain, then retry ' +
84
+ 'the build.',
85
+ },
59
86
  {
60
87
  // When `mach build` fails mid-compile, mach's own shutdown pipeline still
61
88
  // runs its trailing "Config object not found by mach. / Configure
@@ -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
@@ -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;