@hominis/fireforge 0.44.4 → 0.45.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 +40 -0
- package/dist/build-info.json +4 -4
- package/dist/src/commands/doctor-source-pin.d.ts +29 -0
- package/dist/src/commands/doctor-source-pin.js +81 -0
- package/dist/src/commands/doctor.js +2 -0
- package/dist/src/commands/export-flow.js +3 -18
- package/dist/src/commands/patch/move-files-into.js +3 -6
- package/dist/src/commands/patch/split-plan.js +3 -6
- package/dist/src/commands/re-export-adjacent.d.ts +8 -0
- package/dist/src/commands/re-export-adjacent.js +26 -1
- package/dist/src/commands/re-export-drift.d.ts +8 -0
- package/dist/src/commands/re-export-drift.js +61 -0
- package/dist/src/commands/re-export-files.js +3 -6
- package/dist/src/commands/re-export-options.js +19 -5
- package/dist/src/commands/re-export-register.js +5 -1
- package/dist/src/commands/re-export-scan.js +3 -6
- package/dist/src/commands/re-export.js +16 -16
- package/dist/src/commands/test-build-phase.js +2 -1
- package/dist/src/commands/test-diagnose.js +14 -5
- package/dist/src/commands/test-register.js +1 -0
- package/dist/src/commands/test-run.js +4 -3
- package/dist/src/commands/test-stale-gate.js +5 -2
- package/dist/src/commands/test.js +9 -2
- package/dist/src/core/build-audit.js +44 -2
- package/dist/src/core/build-jar-escalation.d.ts +64 -0
- package/dist/src/core/build-jar-escalation.js +127 -0
- package/dist/src/core/build-prepare.d.ts +12 -1
- package/dist/src/core/build-prepare.js +36 -3
- package/dist/src/core/config-validate-file-size.d.ts +12 -0
- package/dist/src/core/config-validate-file-size.js +65 -0
- package/dist/src/core/config-validate.js +6 -0
- package/dist/src/core/harness-orphans.d.ts +73 -0
- package/dist/src/core/harness-orphans.js +186 -0
- package/dist/src/core/mach-known-noise-filter.d.ts +21 -0
- package/dist/src/core/mach-known-noise-filter.js +24 -1
- package/dist/src/core/mach.d.ts +7 -0
- package/dist/src/core/mach.js +18 -2
- package/dist/src/core/patch-apply.js +49 -20
- package/dist/src/core/patch-lint-checkjs.js +6 -1
- package/dist/src/core/patch-lint-file-size.d.ts +52 -0
- package/dist/src/core/patch-lint-file-size.js +53 -0
- package/dist/src/core/patch-lint-prettier.d.ts +36 -0
- package/dist/src/core/patch-lint-prettier.js +126 -0
- package/dist/src/core/patch-lint-unmanaged-companion.d.ts +106 -0
- package/dist/src/core/patch-lint-unmanaged-companion.js +214 -0
- package/dist/src/core/patch-lint.js +23 -24
- package/dist/src/core/patch-transform.d.ts +21 -0
- package/dist/src/core/patch-transform.js +38 -0
- package/dist/src/core/smoke-patterns.js +12 -0
- package/dist/src/core/test-harness-crash.js +61 -48
- package/dist/src/core/test-harness-verdict-notes.d.ts +55 -0
- package/dist/src/core/test-harness-verdict-notes.js +48 -0
- package/dist/src/core/test-stale-check.d.ts +23 -6
- package/dist/src/core/test-stale-check.js +75 -2
- package/dist/src/core/test-stall-triage.js +37 -19
- package/dist/src/errors/build.d.ts +28 -0
- package/dist/src/errors/build.js +49 -0
- package/dist/src/types/commands/options.d.ts +19 -0
- package/dist/src/types/commands/patches.d.ts +17 -6
- package/dist/src/types/config.d.ts +41 -0
- package/dist/src/utils/fs.d.ts +4 -2
- package/dist/src/utils/fs.js +3 -1
- package/docs/configuration.md +47 -0
- package/docs/exit-codes.md +4 -2
- package/docs/testing.md +62 -10
- package/package.json +1 -1
|
@@ -11,6 +11,7 @@ import { elapsedSince } from '../utils/elapsed.js';
|
|
|
11
11
|
import { toError } from '../utils/errors.js';
|
|
12
12
|
import { cancel, info, intro, isCancel, outro, spinner, success, warn } from '../utils/logger.js';
|
|
13
13
|
import { loadScanFilesAssignments, withDryRunPurityGuard, withDryRunReExportLock, } from './re-export-bulk-scan.js';
|
|
14
|
+
import { warnUnseenExpectedDrift } from './re-export-drift.js';
|
|
14
15
|
import { reExportFilesInPlace } from './re-export-files.js';
|
|
15
16
|
import { buildReExportLintContext, finishReExportLint } from './re-export-lint.js';
|
|
16
17
|
import { applyReExportFilesPositionalFolding, validateReExportOptionCombinations, } from './re-export-options.js';
|
|
@@ -123,6 +124,8 @@ export async function reExportCommand(projectRoot, patches, options) {
|
|
|
123
124
|
binaryName: config.binaryName,
|
|
124
125
|
furnacePrefixes: await collectFurnaceManagedPrefixes(paths.root),
|
|
125
126
|
refuseAdjacentUnmanaged: options.refuseAdjacentUnmanaged === true,
|
|
127
|
+
approvedUnmanaged: new Set((options.expectUnmanaged ?? []).map((file) => normalizeEngineRelativeInput(file, '--expect-unmanaged'))),
|
|
128
|
+
approvedSeen: new Set(),
|
|
126
129
|
refusals: [],
|
|
127
130
|
};
|
|
128
131
|
const driftCtx = buildForeignDriftContext(options);
|
|
@@ -155,6 +158,7 @@ export async function reExportCommand(projectRoot, patches, options) {
|
|
|
155
158
|
}));
|
|
156
159
|
await finishReExportLint(lintCtx);
|
|
157
160
|
warnUnseenExpectedDrift(driftCtx);
|
|
161
|
+
warnUnseenApprovedUnmanaged(adjacentCtx);
|
|
158
162
|
throwRunLevelRefusals(adjacentCtx, driftCtx, progress);
|
|
159
163
|
if (reExported === 0 && selectedPatches.length > 0) {
|
|
160
164
|
progress.error('Re-export failed');
|
|
@@ -198,26 +202,22 @@ function buildForeignDriftContext(options) {
|
|
|
198
202
|
};
|
|
199
203
|
}
|
|
200
204
|
/**
|
|
201
|
-
* Names `--expect` paths that never
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
205
|
+
* Names `--expect-unmanaged` paths that were never met this run. A typo'd
|
|
206
|
+
* carve-out silently leaves the refusal armed for the path it was meant to
|
|
207
|
+
* admit — and, worse, reads as an approval that is in force when it is not.
|
|
208
|
+
* A warning rather than a refusal: an approved path legitimately goes unmet
|
|
209
|
+
* when the reviewed file has since been adopted into a patch, which is the
|
|
210
|
+
* outcome the carve-out exists to be retired by.
|
|
206
211
|
*/
|
|
207
|
-
function
|
|
208
|
-
const unseen = [...
|
|
212
|
+
function warnUnseenApprovedUnmanaged(adjacentCtx) {
|
|
213
|
+
const unseen = [...adjacentCtx.approvedUnmanaged].filter((file) => !adjacentCtx.approvedSeen.has(file));
|
|
209
214
|
if (unseen.length === 0)
|
|
210
215
|
return;
|
|
211
|
-
|
|
212
|
-
warn(`--expect path(s) were not evaluated because no selected patch reached the drift check (for example, an earlier refusal): ${unseen.join(', ')}`);
|
|
213
|
-
return;
|
|
214
|
-
}
|
|
215
|
-
warn('--expect path(s) showed no drift this run: ' +
|
|
216
|
+
warn('--expect-unmanaged path(s) were not met as adjacent unmanaged files this run: ' +
|
|
216
217
|
`${unseen.join(', ')}. ` +
|
|
217
|
-
'Causes, in rough order of likelihood: a typo in the path; the
|
|
218
|
-
'
|
|
219
|
-
'
|
|
220
|
-
'converged before this run looked.');
|
|
218
|
+
'Causes, in rough order of likelihood: a typo in the path; the file is now owned by a ' +
|
|
219
|
+
'patch (the carve-out can be dropped); or no selected patch is adjacent to it. The ' +
|
|
220
|
+
'refusal stayed armed for everything else either way.');
|
|
221
221
|
}
|
|
222
222
|
/**
|
|
223
223
|
* Turns the run's collected `--refuse-adjacent-unmanaged` and
|
|
@@ -43,7 +43,8 @@ async function runPreTestBuild(projectRoot, paths, projectConfig, harnessRetries
|
|
|
43
43
|
// Warning severity, not info: agent-facing output filters
|
|
44
44
|
// keep warnings and errors only, and this is the line that explains an
|
|
45
45
|
// otherwise unexplained multi-minute build.
|
|
46
|
-
notice(
|
|
46
|
+
notice(preparation.fullBuildReason ??
|
|
47
|
+
'A jar.mn registration changed since the last successful build; escalating this pre-test build to a full mach build so new install-manifest destinations are created.');
|
|
47
48
|
}
|
|
48
49
|
const s = spinner(preparation.fullBuildRequired
|
|
49
50
|
? 'Running required full build...'
|
|
@@ -7,11 +7,12 @@
|
|
|
7
7
|
* `test-harness-crash.ts` for single and sharded invocations. Split out of
|
|
8
8
|
* `test.ts` to keep both files within the per-file line budget.
|
|
9
9
|
*/
|
|
10
|
+
import { getActiveRunLogPath } from '../core/run-log.js';
|
|
10
11
|
import { describeChangedPrefNoise } from '../core/test-changed-prefs.js';
|
|
11
12
|
import { buildGreenSummaryRejectedMessage, buildHarnessCrashMessage, buildNoTestsRanMessage, buildSilentSegfaultMessage, headedNoOutputTimeoutHint, isSilentSegfault, } from '../core/test-harness-crash.js';
|
|
12
13
|
import { buildHarnessEarlyExitMessage, classifyHarnessEarlyExit, completePostRebuildFailureContext, prependPostRebuildFailureContext, } from '../core/test-harness-output.js';
|
|
13
14
|
import { GeneralError } from '../errors/base.js';
|
|
14
|
-
import {
|
|
15
|
+
import { TestFailureError } from '../errors/build.js';
|
|
15
16
|
import { toError } from '../utils/errors.js';
|
|
16
17
|
import { info } from '../utils/logger.js';
|
|
17
18
|
import { getPlatform } from '../utils/platform.js';
|
|
@@ -132,7 +133,13 @@ function buildMochitestHttp3ServerMessage() {
|
|
|
132
133
|
* broader ones, so a fork-owned module failure is not reported as a stale
|
|
133
134
|
* build.
|
|
134
135
|
*/
|
|
135
|
-
function handleNonZeroTestExit(result, normalizedPaths, appdirInjectionAttempted, binaryName, postRebuildContext
|
|
136
|
+
function handleNonZeroTestExit(result, normalizedPaths, appdirInjectionAttempted, binaryName, postRebuildContext,
|
|
137
|
+
/**
|
|
138
|
+
* The classified verdict, when one was reached. Carries the failing-test
|
|
139
|
+
* evidence the final throw names; every branch ABOVE that throw is a
|
|
140
|
+
* different diagnosis and does not use it.
|
|
141
|
+
*/
|
|
142
|
+
verdict) {
|
|
136
143
|
if (result.exitCode === 0 || result.exitCode === 130)
|
|
137
144
|
return;
|
|
138
145
|
const combinedOutput = `${result.stdout}\n${result.stderr}`;
|
|
@@ -194,8 +201,10 @@ function handleNonZeroTestExit(result, normalizedPaths, appdirInjectionAttempted
|
|
|
194
201
|
// fail (exit code stays 5), so this only says WHICH KIND of failure it is.
|
|
195
202
|
// Every branch above is a different diagnosis and outranks it.
|
|
196
203
|
const changedPrefNoise = describeChangedPrefNoise(combinedOutput);
|
|
197
|
-
|
|
198
|
-
|
|
204
|
+
const failureBlocks = formatFailureBlocks(verdict?.realFailureBlocks);
|
|
205
|
+
const logPath = getActiveRunLogPath();
|
|
206
|
+
throw new TestFailureError(withContext(`Tests failed with exit code ${result.exitCode}.` +
|
|
207
|
+
(changedPrefNoise !== undefined ? `\n\n${changedPrefNoise}` : '')), 'mach test', failureBlocks, logPath);
|
|
199
208
|
}
|
|
200
209
|
/**
|
|
201
210
|
* Applies the harness-run verdict for a single (non-sharded) invocation:
|
|
@@ -259,7 +268,7 @@ function applySingleRunOutcome(outcome, normalizedPaths, binaryName, postRebuild
|
|
|
259
268
|
outcome.verdict.secondaryHarnessSignature.line);
|
|
260
269
|
}
|
|
261
270
|
}
|
|
262
|
-
handleNonZeroTestExit(outcome.result, normalizedPaths, outcome.appdirInjectionAttempted, binaryName, postRebuildContext);
|
|
271
|
+
handleNonZeroTestExit(outcome.result, normalizedPaths, outcome.appdirInjectionAttempted, binaryName, postRebuildContext, outcome.verdict);
|
|
263
272
|
}
|
|
264
273
|
/**
|
|
265
274
|
* Shard-mode adapter over {@link handleNonZeroTestExit}: produces the
|
|
@@ -67,6 +67,7 @@ export function registerTest(program, { getProjectRoot, withErrorHandling }) {
|
|
|
67
67
|
.option('--allow-stale-build', 'Allow tests to run even when packageable engine files changed since the last successful FireForge build')
|
|
68
68
|
.option('--allow-stale-components', 'Run tests despite components.conf changes that only a full "fireforge build" compiles in (the packaged child process will resolve the OLD StaticComponents table)')
|
|
69
69
|
.option('--kill-stale-marionette', 'Terminate a recognized stale browser from this objdir or one holding the Marionette port before running tests')
|
|
70
|
+
.option('--reap-orphans', 'Terminate harness helper processes (httpd, pywebsocket, ssltunnel, moz-http2) from this objdir that survived an earlier run. The census itself runs on every test dispatch and is report-only; this flag opts into the kill.')
|
|
70
71
|
.option('--canary [path]', 'Run one short browser-chrome harness canary. Uses test.canaryPath from fireforge.json when no path is supplied.')
|
|
71
72
|
.option('--doctor', 'Run a marionette handshake preflight before tests (exit 1 on FAIL). With no paths, runs the preflight only.')
|
|
72
73
|
.option('--mach-arg <arg>', 'Forward this argument verbatim to `mach test` (repeatable). Escape valve for upstream xpcshell/mochitest flags FireForge does not model.', ...stringListOption())
|
|
@@ -12,11 +12,12 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { probeDisplaySleepState } from '../core/display-state.js';
|
|
14
14
|
import { mochitestWithOutput, testWithOutput, xpcshellTestWithOutput, } from '../core/mach.js';
|
|
15
|
+
import { getActiveRunLogPath } from '../core/run-log.js';
|
|
15
16
|
import { changedPrefNoiseVerdictNote } from '../core/test-changed-prefs.js';
|
|
16
17
|
import { buildHarnessCrashMessage, classifyHarnessRun, headedDisplayAsleepVerdictNote, headedNoOutputTimeoutHint, } from '../core/test-harness-crash.js';
|
|
17
18
|
import { retryAfterXpcshellSymlinkRepair } from '../core/test-xpcshell-retry.js';
|
|
18
19
|
import { withXpcshellProfileDir } from '../core/xpcshell-profile-dir.js';
|
|
19
|
-
import {
|
|
20
|
+
import { TestFailureError } from '../errors/build.js';
|
|
20
21
|
import { info, note, warn } from '../utils/logger.js';
|
|
21
22
|
import { getPlatform } from '../utils/platform.js';
|
|
22
23
|
import { maybeInjectAppdirArg } from './test-appdir.js';
|
|
@@ -193,8 +194,8 @@ export function finalizeShardedOutcome(summary) {
|
|
|
193
194
|
emitHarnessVerdict(summary.aggregate, { passed: summary.passed, total: summary.total });
|
|
194
195
|
if (summary.passed < summary.total) {
|
|
195
196
|
const failing = summary.shards.filter(({ outcome }) => outcome.verdict.kind !== 'tests-ran-ok');
|
|
196
|
-
throw new
|
|
197
|
+
throw new TestFailureError(`${failing.length} of ${summary.total} sharded test run(s) did not pass: ` +
|
|
197
198
|
`${failing.map(({ label }) => label).join(', ')}. ` +
|
|
198
|
-
'See the per-shard diagnosis above. Use --no-shard to reproduce the combined single-invocation behaviour.', 'mach test');
|
|
199
|
+
'See the per-shard diagnosis above. Use --no-shard to reproduce the combined single-invocation behaviour.', 'mach test', undefined, getActiveRunLogPath());
|
|
199
200
|
}
|
|
200
201
|
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* stays wiring; the probes and refusal copy live in
|
|
8
8
|
* `src/core/test-stale-check.ts`.
|
|
9
9
|
*/
|
|
10
|
-
import { checkStaleBuildForTest, checkStaticComponentsStale, findUncoveredRequestPaths, formatStaleBuildWarning, formatStaticComponentsRefusal, formatTestCoverageRefusal, } from '../core/test-stale-check.js';
|
|
10
|
+
import { checkStaleBuildForTest, checkStaticComponentsStale, findChangedTestManifestsForPaths, findUncoveredRequestPaths, formatStaleBuildWarning, formatStaticComponentsRefusal, formatTestCoverageRefusal, } from '../core/test-stale-check.js';
|
|
11
11
|
import { GeneralError } from '../errors/base.js';
|
|
12
12
|
import { warn } from '../utils/logger.js';
|
|
13
13
|
/**
|
|
@@ -37,7 +37,10 @@ export async function enforceStaleBuildGate(projectRoot, engineDir, options, nor
|
|
|
37
37
|
const recordedCoverage = stale.baseline?.testPackagingCoverage;
|
|
38
38
|
const uncovered = findUncoveredRequestPaths(recordedCoverage, normalizedPaths);
|
|
39
39
|
if (uncovered.length > 0) {
|
|
40
|
-
|
|
40
|
+
// The refusal is correct on its own; naming the manifest that gained
|
|
41
|
+
// an entry is the triage step it otherwise leaves to the reader.
|
|
42
|
+
const changedManifests = await findChangedTestManifestsForPaths(engineDir, stale.baseline, uncovered);
|
|
43
|
+
throw new GeneralError(formatTestCoverageRefusal(uncovered, Array.isArray(recordedCoverage) ? recordedCoverage : [], changedManifests));
|
|
41
44
|
}
|
|
42
45
|
}
|
|
43
46
|
await enforceStaticComponentsGate(engineDir, stale.baseline, options);
|
|
@@ -3,6 +3,7 @@ import { join, resolve } from 'node:path';
|
|
|
3
3
|
import { getProjectPaths, loadConfig } from '../core/config.js';
|
|
4
4
|
import { assertEngineExists } from '../core/engine-precondition.js';
|
|
5
5
|
import { assertEngineGenerationUnchanged, snapshotEngineGeneration, } from '../core/engine-session-lock.js';
|
|
6
|
+
import { reportOrphanedHarnessProcesses } from '../core/harness-orphans.js';
|
|
6
7
|
import { hasBuildArtifacts, hasRunnableBundle } from '../core/mach.js';
|
|
7
8
|
import { assertBuildArtifacts } from '../core/mach-build-artifacts.js';
|
|
8
9
|
import { assertMarionettePortAvailable, ensureLaunchableBrowserNotRunning, ensureMarionettePortAvailable, extractForwardedMarionettePort, forwardedMachArgsIncludeMarionetteClient, shouldAutoForwardMarionettePortToMach, } from '../core/marionette-port.js';
|
|
@@ -224,7 +225,7 @@ async function ensureTestMarionettePortAvailable(port, binaryName, options, skip
|
|
|
224
225
|
}
|
|
225
226
|
await assertMarionettePortAvailable(port, { binaryName });
|
|
226
227
|
}
|
|
227
|
-
async function ensureTestBrowserEnvironment(engineDir, launchablePath, xpcshellOnly, binaryName, options) {
|
|
228
|
+
async function ensureTestBrowserEnvironment(engineDir, launchablePath, xpcshellOnly, binaryName, options, objDir) {
|
|
228
229
|
// A timed-out mochitest can leave the built app alive after its Marionette
|
|
229
230
|
// listener has disappeared. The port probe cannot see that case, but the
|
|
230
231
|
// survivor can still steal focus and wedge every later headed run.
|
|
@@ -242,6 +243,12 @@ async function ensureTestBrowserEnvironment(engineDir, launchablePath, xpcshellO
|
|
|
242
243
|
killStaleServer: options.killStaleMarionette === true,
|
|
243
244
|
});
|
|
244
245
|
}
|
|
246
|
+
// Helpers that outlived an earlier run (httpd, pywebsocket, ssltunnel,
|
|
247
|
+
// moz-http2) slow every later run without appearing in its output. Both
|
|
248
|
+
// harnesses are affected, so this census is not gated on xpcshellOnly.
|
|
249
|
+
await reportOrphanedHarnessProcesses(objDir, {
|
|
250
|
+
reap: options.reapOrphans === true,
|
|
251
|
+
});
|
|
245
252
|
const forwardedPort = options.machArg
|
|
246
253
|
? extractForwardedMarionettePort(options.machArg)
|
|
247
254
|
: undefined;
|
|
@@ -359,7 +366,7 @@ async function runTestCommandBody(projectRoot, testPaths, options = {}) {
|
|
|
359
366
|
// documented `--mach-arg --marionette-port=NNNN` route still hits the
|
|
360
367
|
// wrapper preflight refusing on 2828 before the forwarded arg reaches
|
|
361
368
|
// mach.
|
|
362
|
-
const { forwardedPort, effectivePort } = await ensureTestBrowserEnvironment(paths.engine, launchablePath, xpcshellOnly, projectConfig.binaryName, options);
|
|
369
|
+
const { forwardedPort, effectivePort } = await ensureTestBrowserEnvironment(paths.engine, launchablePath, xpcshellOnly, projectConfig.binaryName, options, buildCheck.objDir);
|
|
363
370
|
if (options.doctor) {
|
|
364
371
|
const doctorOutcome = await runDoctorPreflight({
|
|
365
372
|
engineDir: paths.engine,
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
*/
|
|
36
36
|
import { readdir, stat } from 'node:fs/promises';
|
|
37
37
|
import { basename, join } from 'node:path';
|
|
38
|
+
import { toError } from '../utils/errors.js';
|
|
38
39
|
import { pathExists } from '../utils/fs.js';
|
|
39
40
|
import { info, verbose, warn } from '../utils/logger.js';
|
|
40
41
|
import { normalizePathSlashes } from '../utils/paths.js';
|
|
@@ -43,6 +44,8 @@ import { collectSameBasenameCandidates, findRegisteredTarget, resolveArtifactByR
|
|
|
43
44
|
import { countTrailingSegmentMatches, GENERIC_PATH_SEGMENTS, isTestPath, resolveBestArtifact, } from './build-audit-resolve.js';
|
|
44
45
|
import { resolveArtifactByKnownTransform } from './build-audit-transforms.js';
|
|
45
46
|
import { collectChangedEnginePaths } from './engine-changes.js';
|
|
47
|
+
import { loadPatchesManifest } from './patch-manifest-io.js';
|
|
48
|
+
import { buildPatchClaims } from './status-classify.js';
|
|
46
49
|
/** Path extensions that are conventionally packaged into the Firefox bundle. */
|
|
47
50
|
const PACKAGEABLE_EXTENSIONS = [
|
|
48
51
|
'.js',
|
|
@@ -442,6 +445,39 @@ function relativeManifestPath(engineDir, manifest) {
|
|
|
442
445
|
}
|
|
443
446
|
return manifest;
|
|
444
447
|
}
|
|
448
|
+
/**
|
|
449
|
+
* Path → owning patch filenames, from the patches manifest's
|
|
450
|
+
* `filesAffected`. Best-effort: a project with no queue (or an unreadable
|
|
451
|
+
* manifest) yields an empty map and the notices render exactly as before.
|
|
452
|
+
*/
|
|
453
|
+
async function resolveAuditOwnership(projectRoot) {
|
|
454
|
+
try {
|
|
455
|
+
const patchesDir = join(projectRoot, 'patches');
|
|
456
|
+
const manifest = await loadPatchesManifest(patchesDir);
|
|
457
|
+
if (!manifest)
|
|
458
|
+
return new Map();
|
|
459
|
+
return buildPatchClaims(manifest.patches);
|
|
460
|
+
}
|
|
461
|
+
catch (error) {
|
|
462
|
+
verbose(`Audit: ownership lookup unavailable (${toError(error).message}).`);
|
|
463
|
+
return new Map();
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* Renders the ownership suffix for one audit notice: the owning patch, or
|
|
468
|
+
* an explicit `unmanaged` mark. An empty map (no queue, unreadable
|
|
469
|
+
* manifest) renders nothing rather than claiming everything is unmanaged —
|
|
470
|
+
* "unmanaged" must mean "checked and unowned", never "could not check".
|
|
471
|
+
*/
|
|
472
|
+
function describeOwnership(ownersByPath, source) {
|
|
473
|
+
if (ownersByPath.size === 0)
|
|
474
|
+
return '';
|
|
475
|
+
const owners = ownersByPath.get(source);
|
|
476
|
+
if (owners === undefined || owners.length === 0) {
|
|
477
|
+
return ' Ownership: unmanaged (no patch claims this path).';
|
|
478
|
+
}
|
|
479
|
+
return ` Ownership: ${owners.join(', ')}.`;
|
|
480
|
+
}
|
|
445
481
|
/**
|
|
446
482
|
* Runs the post-build audit. Emits per-file warnings for missing or
|
|
447
483
|
* stale artifacts and a summary info line at the end. Always returns
|
|
@@ -453,7 +489,6 @@ function relativeManifestPath(engineDir, manifest) {
|
|
|
453
489
|
* @returns Summary of artifact status counts.
|
|
454
490
|
*/
|
|
455
491
|
export async function auditBuildArtifacts(projectRoot, engineDir, baseline) {
|
|
456
|
-
void projectRoot;
|
|
457
492
|
const summary = {
|
|
458
493
|
updated: 0,
|
|
459
494
|
stale: 0,
|
|
@@ -472,6 +507,13 @@ export async function auditBuildArtifacts(projectRoot, engineDir, baseline) {
|
|
|
472
507
|
if (changed.length === 0) {
|
|
473
508
|
return summary;
|
|
474
509
|
}
|
|
510
|
+
// Ownership, resolved once for the whole audit. The notices key on "was
|
|
511
|
+
// touched", which on a shared checkout means any concurrent session's
|
|
512
|
+
// dirty files — so an operator gets a registration-shaped warning about a
|
|
513
|
+
// file they never edited and has to census ownership by hand to dismiss
|
|
514
|
+
// it. FireForge already resolves this for `status --ownership`; carrying
|
|
515
|
+
// it here turns a three-line mystery into three dismissible lines.
|
|
516
|
+
const ownersByPath = await resolveAuditOwnership(projectRoot);
|
|
475
517
|
const ctx = { engineDir, distRoot, testsRoot, testsPackaged };
|
|
476
518
|
for (const source of changed) {
|
|
477
519
|
const result = await auditSinglePath(source, ctx);
|
|
@@ -482,7 +524,7 @@ export async function auditBuildArtifacts(projectRoot, engineDir, baseline) {
|
|
|
482
524
|
status: result.status,
|
|
483
525
|
});
|
|
484
526
|
if (result.warning)
|
|
485
|
-
warn(result.warning);
|
|
527
|
+
warn(`${result.warning}${describeOwnership(ownersByPath, result.source)}`);
|
|
486
528
|
}
|
|
487
529
|
info(`Packaged: ${summary.updated} updated, ${summary.stale} stale, ${summary.missing} missing, ${summary.skipped} skipped`);
|
|
488
530
|
return summary;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decides whether a changed `jar.mn` really forces the pre-test build to
|
|
3
|
+
* escalate from `mach build faster` to a full `mach build`.
|
|
4
|
+
*
|
|
5
|
+
* The 0.41.0 rule was `any changed jar.mn ⇒ full build`, which costs ~10
|
|
6
|
+
* minutes against ~20 s for a chrome-only slice and is paid TWICE per
|
|
7
|
+
* green/no-change pair on a Furnace-touching slice. A downstream experiment
|
|
8
|
+
* (two clean runs, 2026-08-29, against 0.44.4) showed the rule is over-broad
|
|
9
|
+
* for the common case: a jar-only registration of a NEW content file added
|
|
10
|
+
* to an EXISTING `dist/bin` manifest was installed by a plain
|
|
11
|
+
* `fireforge build --ui` both times — `faster/install_dist_bin_browser` named
|
|
12
|
+
* the destination, the file landed in `dist/bin/…/content/browser/` and in
|
|
13
|
+
* the `.app` bundle, and a plain `fireforge test` fetched it over
|
|
14
|
+
* `chrome://` at runtime. Neither run escalated, and neither needed to.
|
|
15
|
+
*
|
|
16
|
+
* What the experiment did NOT exercise, and what therefore still escalates:
|
|
17
|
+
*
|
|
18
|
+
* - a NEW `jar.mn` file. The install manifest for a manifest the backend
|
|
19
|
+
* has never seen does not exist yet, and `mach build faster` consumes
|
|
20
|
+
* install manifests rather than regenerating the backend that writes
|
|
21
|
+
* them.
|
|
22
|
+
* - a manifest whose jar declaration carries a bracketed base-directory
|
|
23
|
+
* prefix (`[some/dir] name.jar:`), which redirects the install
|
|
24
|
+
* destination away from the default chrome root. That redirect is the
|
|
25
|
+
* "non-dist/bin target" half nobody has run.
|
|
26
|
+
*
|
|
27
|
+
* Everything else about the rule is unchanged, and every failure to read or
|
|
28
|
+
* parse a manifest escalates: this narrows a conservative rule, so its
|
|
29
|
+
* error direction stays the slow-but-correct one.
|
|
30
|
+
*/
|
|
31
|
+
import type { BuildBaseline } from './build-baseline-types.js';
|
|
32
|
+
/** Why one changed `jar.mn` still forces a full build. */
|
|
33
|
+
export interface JarEscalationCause {
|
|
34
|
+
/** Engine-relative manifest path. */
|
|
35
|
+
path: string;
|
|
36
|
+
/** Operator-facing reason, printed with the escalation notice. */
|
|
37
|
+
reason: string;
|
|
38
|
+
}
|
|
39
|
+
/** Outcome of {@link evaluateJarManifestEscalation}. */
|
|
40
|
+
export interface JarEscalationDecision {
|
|
41
|
+
/** True when at least one changed manifest still needs a full build. */
|
|
42
|
+
escalate: boolean;
|
|
43
|
+
/** One entry per manifest that forces the escalation. */
|
|
44
|
+
causes: JarEscalationCause[];
|
|
45
|
+
/** Manifests the narrowing cleared; kept for the verbose explanation. */
|
|
46
|
+
cleared: string[];
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Evaluates every changed `jar.mn` against the narrowed rule.
|
|
50
|
+
*
|
|
51
|
+
* @param engineDir - Absolute engine directory
|
|
52
|
+
* @param changedPaths - Engine-relative paths changed since the last
|
|
53
|
+
* successful build (non-`jar.mn` entries are ignored)
|
|
54
|
+
* @param baseline - Last successful build's baseline (reserved: the
|
|
55
|
+
* fingerprint map already gated which paths reach here)
|
|
56
|
+
* @returns The escalation decision with per-manifest causes
|
|
57
|
+
*/
|
|
58
|
+
export declare function evaluateJarManifestEscalation(engineDir: string, changedPaths: readonly string[], baseline: BuildBaseline | undefined): Promise<JarEscalationDecision>;
|
|
59
|
+
/**
|
|
60
|
+
* Renders the operator-facing escalation notice. Names the manifest AND the
|
|
61
|
+
* reason: "a jar.mn changed" is the sentence that made the previous rule
|
|
62
|
+
* impossible to argue with from outside.
|
|
63
|
+
*/
|
|
64
|
+
export declare function formatJarEscalationNotice(decision: JarEscalationDecision): string;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
/**
|
|
3
|
+
* Decides whether a changed `jar.mn` really forces the pre-test build to
|
|
4
|
+
* escalate from `mach build faster` to a full `mach build`.
|
|
5
|
+
*
|
|
6
|
+
* The 0.41.0 rule was `any changed jar.mn ⇒ full build`, which costs ~10
|
|
7
|
+
* minutes against ~20 s for a chrome-only slice and is paid TWICE per
|
|
8
|
+
* green/no-change pair on a Furnace-touching slice. A downstream experiment
|
|
9
|
+
* (two clean runs, 2026-08-29, against 0.44.4) showed the rule is over-broad
|
|
10
|
+
* for the common case: a jar-only registration of a NEW content file added
|
|
11
|
+
* to an EXISTING `dist/bin` manifest was installed by a plain
|
|
12
|
+
* `fireforge build --ui` both times — `faster/install_dist_bin_browser` named
|
|
13
|
+
* the destination, the file landed in `dist/bin/…/content/browser/` and in
|
|
14
|
+
* the `.app` bundle, and a plain `fireforge test` fetched it over
|
|
15
|
+
* `chrome://` at runtime. Neither run escalated, and neither needed to.
|
|
16
|
+
*
|
|
17
|
+
* What the experiment did NOT exercise, and what therefore still escalates:
|
|
18
|
+
*
|
|
19
|
+
* - a NEW `jar.mn` file. The install manifest for a manifest the backend
|
|
20
|
+
* has never seen does not exist yet, and `mach build faster` consumes
|
|
21
|
+
* install manifests rather than regenerating the backend that writes
|
|
22
|
+
* them.
|
|
23
|
+
* - a manifest whose jar declaration carries a bracketed base-directory
|
|
24
|
+
* prefix (`[some/dir] name.jar:`), which redirects the install
|
|
25
|
+
* destination away from the default chrome root. That redirect is the
|
|
26
|
+
* "non-dist/bin target" half nobody has run.
|
|
27
|
+
*
|
|
28
|
+
* Everything else about the rule is unchanged, and every failure to read or
|
|
29
|
+
* parse a manifest escalates: this narrows a conservative rule, so its
|
|
30
|
+
* error direction stays the slow-but-correct one.
|
|
31
|
+
*/
|
|
32
|
+
import { join } from 'node:path';
|
|
33
|
+
import { readText } from '../utils/fs.js';
|
|
34
|
+
import { verbose } from '../utils/logger.js';
|
|
35
|
+
import { isJarManifestPath } from './build-audit.js';
|
|
36
|
+
import { getUntrackedFilesInDir } from './git-status.js';
|
|
37
|
+
/**
|
|
38
|
+
* A jar declaration line: an optional bracketed base-directory prefix, then
|
|
39
|
+
* the jar name. Matched at column zero because entry lines inside a section
|
|
40
|
+
* are indented — an indented `foo.jar:` is content, not a declaration.
|
|
41
|
+
*/
|
|
42
|
+
const JAR_DECLARATION_PATTERN = /^(\[[^\]]*\]\s*)?[^\s[\]]+\.jar:\s*$/;
|
|
43
|
+
/** True when the jar declaration redirects the install base directory. */
|
|
44
|
+
function declaresNonDefaultBase(line) {
|
|
45
|
+
const match = JAR_DECLARATION_PATTERN.exec(line);
|
|
46
|
+
if (!match)
|
|
47
|
+
return false;
|
|
48
|
+
return match[1] !== undefined;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* True when a changed `jar.mn` is NEW to the last successful build.
|
|
52
|
+
*
|
|
53
|
+
* Untracked in the engine repo is the reliable tell: a fork's engine tree is
|
|
54
|
+
* a checkout of upstream with patches applied to the WORKTREE, so a manifest
|
|
55
|
+
* a patch created is untracked and one that shipped upstream is tracked. The
|
|
56
|
+
* baseline's fingerprints cannot answer this on their own — they record only
|
|
57
|
+
* the paths that were DIRTY at the last build, so a clean pre-existing
|
|
58
|
+
* manifest has no entry either.
|
|
59
|
+
*/
|
|
60
|
+
async function isNewJarManifest(engineDir, path) {
|
|
61
|
+
const untracked = await getUntrackedFilesInDir(engineDir, path);
|
|
62
|
+
return untracked.includes(path);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Evaluates every changed `jar.mn` against the narrowed rule.
|
|
66
|
+
*
|
|
67
|
+
* @param engineDir - Absolute engine directory
|
|
68
|
+
* @param changedPaths - Engine-relative paths changed since the last
|
|
69
|
+
* successful build (non-`jar.mn` entries are ignored)
|
|
70
|
+
* @param baseline - Last successful build's baseline (reserved: the
|
|
71
|
+
* fingerprint map already gated which paths reach here)
|
|
72
|
+
* @returns The escalation decision with per-manifest causes
|
|
73
|
+
*/
|
|
74
|
+
export async function evaluateJarManifestEscalation(engineDir, changedPaths, baseline) {
|
|
75
|
+
void baseline;
|
|
76
|
+
const causes = [];
|
|
77
|
+
const cleared = [];
|
|
78
|
+
for (const path of changedPaths.filter(isJarManifestPath)) {
|
|
79
|
+
let isNew;
|
|
80
|
+
try {
|
|
81
|
+
isNew = await isNewJarManifest(engineDir, path);
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
// Fail closed: an unanswerable "is this new?" keeps the old rule.
|
|
85
|
+
verbose(`jar escalation: could not classify engine/${path} (${String(error)}); escalating.`);
|
|
86
|
+
causes.push({ path, reason: 'could not determine whether the manifest is new' });
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (isNew) {
|
|
90
|
+
causes.push({ path, reason: 'new jar.mn (no install manifest exists for it yet)' });
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
let body;
|
|
94
|
+
try {
|
|
95
|
+
body = await readText(join(engineDir, path));
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
verbose(`jar escalation: could not read engine/${path} (${String(error)}); escalating.`);
|
|
99
|
+
causes.push({ path, reason: 'manifest could not be read' });
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const redirected = body
|
|
103
|
+
.split(/\r?\n/)
|
|
104
|
+
.filter((line) => declaresNonDefaultBase(line))
|
|
105
|
+
.map((line) => line.trim());
|
|
106
|
+
if (redirected.length > 0) {
|
|
107
|
+
causes.push({
|
|
108
|
+
path,
|
|
109
|
+
reason: `jar declaration redirects the install base directory (${redirected[0] ?? ''})`,
|
|
110
|
+
});
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
cleared.push(path);
|
|
114
|
+
}
|
|
115
|
+
return { escalate: causes.length > 0, causes, cleared };
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Renders the operator-facing escalation notice. Names the manifest AND the
|
|
119
|
+
* reason: "a jar.mn changed" is the sentence that made the previous rule
|
|
120
|
+
* impossible to argue with from outside.
|
|
121
|
+
*/
|
|
122
|
+
export function formatJarEscalationNotice(decision) {
|
|
123
|
+
const detail = decision.causes.map((c) => `engine/${c.path} (${c.reason})`).join('; ');
|
|
124
|
+
return (`Escalating this pre-test build to a full mach build so new install-manifest destinations ` +
|
|
125
|
+
`are created: ${detail}. A changed jar.mn that merely adds entries to an existing ` +
|
|
126
|
+
`dist/bin manifest no longer escalates.`);
|
|
127
|
+
}
|
|
@@ -14,6 +14,11 @@ export interface BuildPreparation {
|
|
|
14
14
|
reconfigured: boolean;
|
|
15
15
|
/** A changed packaging manifest cannot be safely handled by `mach build faster`. */
|
|
16
16
|
fullBuildRequired?: boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Operator-facing explanation for {@link fullBuildRequired}, naming the
|
|
19
|
+
* manifest and why it still escalates. Absent when no escalation applies.
|
|
20
|
+
*/
|
|
21
|
+
fullBuildReason?: string;
|
|
17
22
|
}
|
|
18
23
|
/** Options for {@link prepareBuildEnvironment}. */
|
|
19
24
|
export interface PrepareBuildOptions {
|
|
@@ -31,7 +36,13 @@ export interface PrepareBuildOptions {
|
|
|
31
36
|
*/
|
|
32
37
|
refuseUnexportedDrift?: boolean | undefined;
|
|
33
38
|
}
|
|
34
|
-
/**
|
|
39
|
+
/**
|
|
40
|
+
* Packaging manifests whose graph/destination directories MAY need a full
|
|
41
|
+
* build. Path-shape only: {@link evaluateJarManifestEscalation} decides
|
|
42
|
+
* whether a given changed manifest actually forces one (a new manifest, or
|
|
43
|
+
* one that redirects its install base directory), so an entry added to an
|
|
44
|
+
* existing `dist/bin` manifest no longer costs a ~10-minute build.
|
|
45
|
+
*/
|
|
35
46
|
export declare function requiresFullBuildForIncrementalTest(path: string): boolean;
|
|
36
47
|
/**
|
|
37
48
|
* Describes an exit code in the shell's 128+N signal convention. Truncated
|
|
@@ -12,6 +12,7 @@ import { pathExists } from '../utils/fs.js';
|
|
|
12
12
|
import { info, notice, spinner, verbose, warn } from '../utils/logger.js';
|
|
13
13
|
import { isBrandingSetup, setupBranding } from './branding.js';
|
|
14
14
|
import { isBuildInputPath, isJarManifestPath } from './build-audit.js';
|
|
15
|
+
import { evaluateJarManifestEscalation, formatJarEscalationNotice, } from './build-jar-escalation.js';
|
|
15
16
|
import { findUnexportedDriftAtRisk, formatUnexportedDriftWarning, } from './build-overwrite-guard.js';
|
|
16
17
|
import { collectChangedEnginePaths, dropPathsMatchingFingerprints } from './engine-changes.js';
|
|
17
18
|
import { applyAllComponents } from './furnace-apply.js';
|
|
@@ -23,7 +24,13 @@ import { generateMozconfig, runMachCapture } from './mach.js';
|
|
|
23
24
|
import { explainMachError } from './mach-error-hints.js';
|
|
24
25
|
/** Path fragments of files whose edits invalidate the recursive-make backend. */
|
|
25
26
|
const BACKEND_INVALIDATING_SUFFIXES = ['moz.build', 'moz.configure', 'Makefile.in'];
|
|
26
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* Packaging manifests whose graph/destination directories MAY need a full
|
|
29
|
+
* build. Path-shape only: {@link evaluateJarManifestEscalation} decides
|
|
30
|
+
* whether a given changed manifest actually forces one (a new manifest, or
|
|
31
|
+
* one that redirects its install base directory), so an entry added to an
|
|
32
|
+
* existing `dist/bin` manifest no longer costs a ~10-minute build.
|
|
33
|
+
*/
|
|
27
34
|
export function requiresFullBuildForIncrementalTest(path) {
|
|
28
35
|
return isJarManifestPath(path);
|
|
29
36
|
}
|
|
@@ -107,6 +114,24 @@ function buildConfigureFailureError(captured) {
|
|
|
107
114
|
(hints.length > 0 ? `\n\n${hints.map((hint) => `Hint: ${hint}`).join('\n')}` : '') +
|
|
108
115
|
'\n\nBuild stopped because continuing would hide the real configure failure.', 'mach configure');
|
|
109
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* Runs the narrowed `jar.mn` escalation rule over this run's changed build
|
|
119
|
+
* inputs and returns the operator-facing reason when a full build is still
|
|
120
|
+
* required, or undefined when the narrowing cleared every changed manifest.
|
|
121
|
+
*
|
|
122
|
+
* Extracted so `prepareBuildEnvironment` stays under the complexity budget.
|
|
123
|
+
*
|
|
124
|
+
* @param engineDir - Absolute engine directory
|
|
125
|
+
* @param changed - Build inputs changed since the last successful build
|
|
126
|
+
* @param baseline - Last successful build's baseline
|
|
127
|
+
*/
|
|
128
|
+
async function decideJarEscalation(engineDir, changed, baseline) {
|
|
129
|
+
const decision = await evaluateJarManifestEscalation(engineDir, changed.filter(requiresFullBuildForIncrementalTest), baseline);
|
|
130
|
+
if (decision.cleared.length > 0) {
|
|
131
|
+
verbose(`jar escalation: ${String(decision.cleared.length)} changed jar.mn add entries to an existing dist/bin manifest; not escalating for ${decision.cleared.join(', ')}.`);
|
|
132
|
+
}
|
|
133
|
+
return decision.escalate ? formatJarEscalationNotice(decision) : undefined;
|
|
134
|
+
}
|
|
110
135
|
/**
|
|
111
136
|
* Returns true when the file path matches a pattern that forces
|
|
112
137
|
* `mach configure` to regenerate the backend. Exported for testing.
|
|
@@ -177,10 +202,13 @@ export async function prepareBuildEnvironment(projectRoot, paths, config, option
|
|
|
177
202
|
// work against a stale recursive-make backend.
|
|
178
203
|
let reconfigured = false;
|
|
179
204
|
let fullBuildRequired = false;
|
|
205
|
+
let fullBuildReason;
|
|
180
206
|
if (options.previousBaseline) {
|
|
181
207
|
const changed = await collectChangedBuildInputs(paths.engine, options.previousBaseline);
|
|
182
208
|
const invalidating = changed.filter(isBackendInvalidatingFile);
|
|
183
|
-
|
|
209
|
+
const jarEscalation = await decideJarEscalation(paths.engine, changed, options.previousBaseline);
|
|
210
|
+
fullBuildRequired = jarEscalation !== undefined;
|
|
211
|
+
fullBuildReason = jarEscalation;
|
|
184
212
|
if (invalidating.length > 0) {
|
|
185
213
|
// Warning severity: this is the "why is this run slow" explanation,
|
|
186
214
|
// and an info-level line does not survive an agent output filter that
|
|
@@ -300,5 +328,10 @@ export async function prepareBuildEnvironment(projectRoot, paths, config, option
|
|
|
300
328
|
mozconfigSpinner.error('Failed to generate mozconfig');
|
|
301
329
|
throw error;
|
|
302
330
|
}
|
|
303
|
-
return {
|
|
331
|
+
return {
|
|
332
|
+
furnaceApplied,
|
|
333
|
+
reconfigured,
|
|
334
|
+
fullBuildRequired,
|
|
335
|
+
...(fullBuildReason !== undefined ? { fullBuildReason } : {}),
|
|
336
|
+
};
|
|
304
337
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validation for `patchLint.fileSizeThresholds`.
|
|
3
|
+
*
|
|
4
|
+
* Split out of `config-validate.ts`, which is at its per-file line budget.
|
|
5
|
+
*/
|
|
6
|
+
import type { PatchLintFileSizeThresholds } from '../types/config.js';
|
|
7
|
+
/**
|
|
8
|
+
* Parses the `patchLint.fileSizeThresholds` block. Ordering is validated
|
|
9
|
+
* against the MERGED triple (overrides on top of the defaults), so setting
|
|
10
|
+
* only `warning` cannot land below the default `notice`.
|
|
11
|
+
*/
|
|
12
|
+
export declare function parsePatchLintFileSizeThresholds(raw: unknown): PatchLintFileSizeThresholds;
|