@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.
- package/CHANGELOG.md +19 -0
- package/README.md +17 -4
- package/dist/bin/fireforge.d.ts +4 -0
- package/dist/bin/fireforge.js +4 -0
- package/dist/src/commands/build.js +13 -0
- package/dist/src/commands/discard.js +6 -2
- package/dist/src/commands/download.js +23 -2
- package/dist/src/commands/run.js +14 -2
- package/dist/src/commands/test.js +46 -6
- package/dist/src/core/build-prepare.js +19 -5
- package/dist/src/core/firefox-cache.js +7 -4
- package/dist/src/core/firefox-extract.d.ts +21 -1
- package/dist/src/core/firefox-extract.js +117 -7
- package/dist/src/core/git.js +2 -2
- package/dist/src/core/license-headers.d.ts +16 -0
- package/dist/src/core/license-headers.js +19 -0
- package/dist/src/core/mach-error-hints.js +27 -0
- package/dist/src/core/patch-apply.js +51 -21
- package/dist/src/core/patch-lint.js +12 -1
- package/dist/src/core/patch-manifest-io.js +7 -7
- package/dist/src/core/patch-manifest-query.d.ts +6 -0
- package/dist/src/core/patch-manifest-query.js +9 -2
- package/dist/src/core/test-path-scope.d.ts +54 -0
- package/dist/src/core/test-path-scope.js +133 -0
- package/dist/src/core/toolchain-preflight.d.ts +64 -0
- package/dist/src/core/toolchain-preflight.js +195 -0
- package/dist/src/core/typecheck-shim.d.ts +4 -1
- package/dist/src/core/typecheck-shim.js +48 -17
- package/dist/src/errors/download.js +2 -0
- package/dist/src/types/commands/options.d.ts +7 -0
- package/package.json +1 -1
|
@@ -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
|
-
//
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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;
|