@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.
Files changed (66) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/dist/build-info.json +4 -4
  3. package/dist/src/commands/doctor-source-pin.d.ts +29 -0
  4. package/dist/src/commands/doctor-source-pin.js +81 -0
  5. package/dist/src/commands/doctor.js +2 -0
  6. package/dist/src/commands/export-flow.js +3 -18
  7. package/dist/src/commands/patch/move-files-into.js +3 -6
  8. package/dist/src/commands/patch/split-plan.js +3 -6
  9. package/dist/src/commands/re-export-adjacent.d.ts +8 -0
  10. package/dist/src/commands/re-export-adjacent.js +26 -1
  11. package/dist/src/commands/re-export-drift.d.ts +8 -0
  12. package/dist/src/commands/re-export-drift.js +61 -0
  13. package/dist/src/commands/re-export-files.js +3 -6
  14. package/dist/src/commands/re-export-options.js +19 -5
  15. package/dist/src/commands/re-export-register.js +5 -1
  16. package/dist/src/commands/re-export-scan.js +3 -6
  17. package/dist/src/commands/re-export.js +16 -16
  18. package/dist/src/commands/test-build-phase.js +2 -1
  19. package/dist/src/commands/test-diagnose.js +14 -5
  20. package/dist/src/commands/test-register.js +1 -0
  21. package/dist/src/commands/test-run.js +4 -3
  22. package/dist/src/commands/test-stale-gate.js +5 -2
  23. package/dist/src/commands/test.js +9 -2
  24. package/dist/src/core/build-audit.js +44 -2
  25. package/dist/src/core/build-jar-escalation.d.ts +64 -0
  26. package/dist/src/core/build-jar-escalation.js +127 -0
  27. package/dist/src/core/build-prepare.d.ts +12 -1
  28. package/dist/src/core/build-prepare.js +36 -3
  29. package/dist/src/core/config-validate-file-size.d.ts +12 -0
  30. package/dist/src/core/config-validate-file-size.js +65 -0
  31. package/dist/src/core/config-validate.js +6 -0
  32. package/dist/src/core/harness-orphans.d.ts +73 -0
  33. package/dist/src/core/harness-orphans.js +186 -0
  34. package/dist/src/core/mach-known-noise-filter.d.ts +21 -0
  35. package/dist/src/core/mach-known-noise-filter.js +24 -1
  36. package/dist/src/core/mach.d.ts +7 -0
  37. package/dist/src/core/mach.js +18 -2
  38. package/dist/src/core/patch-apply.js +49 -20
  39. package/dist/src/core/patch-lint-checkjs.js +6 -1
  40. package/dist/src/core/patch-lint-file-size.d.ts +52 -0
  41. package/dist/src/core/patch-lint-file-size.js +53 -0
  42. package/dist/src/core/patch-lint-prettier.d.ts +36 -0
  43. package/dist/src/core/patch-lint-prettier.js +126 -0
  44. package/dist/src/core/patch-lint-unmanaged-companion.d.ts +106 -0
  45. package/dist/src/core/patch-lint-unmanaged-companion.js +214 -0
  46. package/dist/src/core/patch-lint.js +23 -24
  47. package/dist/src/core/patch-transform.d.ts +21 -0
  48. package/dist/src/core/patch-transform.js +38 -0
  49. package/dist/src/core/smoke-patterns.js +12 -0
  50. package/dist/src/core/test-harness-crash.js +61 -48
  51. package/dist/src/core/test-harness-verdict-notes.d.ts +55 -0
  52. package/dist/src/core/test-harness-verdict-notes.js +48 -0
  53. package/dist/src/core/test-stale-check.d.ts +23 -6
  54. package/dist/src/core/test-stale-check.js +75 -2
  55. package/dist/src/core/test-stall-triage.js +37 -19
  56. package/dist/src/errors/build.d.ts +28 -0
  57. package/dist/src/errors/build.js +49 -0
  58. package/dist/src/types/commands/options.d.ts +19 -0
  59. package/dist/src/types/commands/patches.d.ts +17 -6
  60. package/dist/src/types/config.d.ts +41 -0
  61. package/dist/src/utils/fs.d.ts +4 -2
  62. package/dist/src/utils/fs.js +3 -1
  63. package/docs/configuration.md +47 -0
  64. package/docs/exit-codes.md +4 -2
  65. package/docs/testing.md +62 -10
  66. 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 drifted this run. A typo'd
202
- * `--expect` path silently degrades the flag back to refusing the slice it
203
- * was meant to admit, so surface the mismatch — but only as a warning: an
204
- * expected file legitimately shows no drift when the slice was already
205
- * captured by an earlier export.
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 warnUnseenExpectedDrift(driftCtx) {
208
- const unseen = [...driftCtx.expectedDriftFiles].filter((file) => !driftCtx.expectedSeen.has(file));
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
- if (driftCtx.evaluationRuns === 0) {
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 slice was already ' +
218
- 'captured by an earlier export in this session; or the engine content already matches ' +
219
- 'the patch body — a concurrent session may have exported the same change, so the drift ' +
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('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.');
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 { BuildError } from '../errors/build.js';
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
- throw new BuildError(withContext(`Tests failed with exit code ${result.exitCode}. Check the output above for details.` +
198
- (changedPrefNoise !== undefined ? `\n\n${changedPrefNoise}` : '')), 'mach test');
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 { BuildError } from '../errors/build.js';
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 BuildError(`${failing.length} of ${summary.total} sharded test run(s) did not pass: ` +
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
- throw new GeneralError(formatTestCoverageRefusal(uncovered, Array.isArray(recordedCoverage) ? recordedCoverage : []));
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
- /** Packaging manifests whose graph/destination directories need a full build. */
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
- /** Packaging manifests whose graph/destination directories need a full build. */
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
- fullBuildRequired = changed.some(requiresFullBuildForIncrementalTest);
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 { furnaceApplied, reconfigured, fullBuildRequired };
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;