@hominis/fireforge 0.44.3 → 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 (83) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/dist/build-info.json +4 -4
  3. package/dist/src/commands/build.js +9 -4
  4. package/dist/src/commands/doctor-source-pin.d.ts +29 -0
  5. package/dist/src/commands/doctor-source-pin.js +81 -0
  6. package/dist/src/commands/doctor.js +2 -0
  7. package/dist/src/commands/export-flow.js +3 -18
  8. package/dist/src/commands/furnace/create-browser-test.d.ts +9 -0
  9. package/dist/src/commands/furnace/create-browser-test.js +6 -6
  10. package/dist/src/commands/furnace/create-dry-run.d.ts +10 -0
  11. package/dist/src/commands/furnace/create-dry-run.js +16 -11
  12. package/dist/src/commands/furnace/create-xpcshell.js +2 -2
  13. package/dist/src/commands/furnace/create.js +5 -0
  14. package/dist/src/commands/patch/move-files-into.js +3 -6
  15. package/dist/src/commands/patch/split-plan.js +3 -6
  16. package/dist/src/commands/re-export-adjacent.d.ts +8 -0
  17. package/dist/src/commands/re-export-adjacent.js +26 -1
  18. package/dist/src/commands/re-export-drift.d.ts +8 -0
  19. package/dist/src/commands/re-export-drift.js +61 -0
  20. package/dist/src/commands/re-export-files.js +3 -6
  21. package/dist/src/commands/re-export-options.js +19 -5
  22. package/dist/src/commands/re-export-register.js +5 -1
  23. package/dist/src/commands/re-export-scan.js +3 -6
  24. package/dist/src/commands/re-export.js +16 -16
  25. package/dist/src/commands/test-build-phase.js +3 -2
  26. package/dist/src/commands/test-diagnose.js +14 -5
  27. package/dist/src/commands/test-register.js +1 -0
  28. package/dist/src/commands/test-run.js +4 -3
  29. package/dist/src/commands/test-stale-gate.js +5 -2
  30. package/dist/src/commands/test.js +9 -2
  31. package/dist/src/core/build-audit.d.ts +20 -0
  32. package/dist/src/core/build-audit.js +69 -3
  33. package/dist/src/core/build-baseline-types.d.ts +28 -0
  34. package/dist/src/core/build-baseline.d.ts +16 -1
  35. package/dist/src/core/build-baseline.js +38 -2
  36. package/dist/src/core/build-jar-escalation.d.ts +64 -0
  37. package/dist/src/core/build-jar-escalation.js +127 -0
  38. package/dist/src/core/build-prepare.d.ts +12 -1
  39. package/dist/src/core/build-prepare.js +68 -6
  40. package/dist/src/core/config-validate-file-size.d.ts +12 -0
  41. package/dist/src/core/config-validate-file-size.js +65 -0
  42. package/dist/src/core/config-validate.js +6 -0
  43. package/dist/src/core/engine-changes.d.ts +18 -0
  44. package/dist/src/core/engine-changes.js +34 -0
  45. package/dist/src/core/furnace-constants.d.ts +21 -0
  46. package/dist/src/core/furnace-constants.js +26 -1
  47. package/dist/src/core/harness-orphans.d.ts +73 -0
  48. package/dist/src/core/harness-orphans.js +186 -0
  49. package/dist/src/core/license-headers.d.ts +9 -5
  50. package/dist/src/core/license-headers.js +33 -7
  51. package/dist/src/core/mach-known-noise-filter.d.ts +21 -0
  52. package/dist/src/core/mach-known-noise-filter.js +24 -1
  53. package/dist/src/core/mach.d.ts +7 -0
  54. package/dist/src/core/mach.js +18 -2
  55. package/dist/src/core/patch-apply.js +49 -20
  56. package/dist/src/core/patch-lint-checkjs.js +6 -1
  57. package/dist/src/core/patch-lint-file-size.d.ts +52 -0
  58. package/dist/src/core/patch-lint-file-size.js +53 -0
  59. package/dist/src/core/patch-lint-prettier.d.ts +36 -0
  60. package/dist/src/core/patch-lint-prettier.js +126 -0
  61. package/dist/src/core/patch-lint-unmanaged-companion.d.ts +106 -0
  62. package/dist/src/core/patch-lint-unmanaged-companion.js +214 -0
  63. package/dist/src/core/patch-lint.js +23 -24
  64. package/dist/src/core/patch-transform.d.ts +21 -0
  65. package/dist/src/core/patch-transform.js +38 -0
  66. package/dist/src/core/smoke-patterns.js +12 -0
  67. package/dist/src/core/test-harness-crash.js +61 -48
  68. package/dist/src/core/test-harness-verdict-notes.d.ts +55 -0
  69. package/dist/src/core/test-harness-verdict-notes.js +48 -0
  70. package/dist/src/core/test-stale-check.d.ts +23 -6
  71. package/dist/src/core/test-stale-check.js +77 -15
  72. package/dist/src/core/test-stall-triage.js +37 -19
  73. package/dist/src/errors/build.d.ts +28 -0
  74. package/dist/src/errors/build.js +49 -0
  75. package/dist/src/types/commands/options.d.ts +19 -0
  76. package/dist/src/types/commands/patches.d.ts +17 -6
  77. package/dist/src/types/config.d.ts +41 -0
  78. package/dist/src/utils/fs.d.ts +4 -2
  79. package/dist/src/utils/fs.js +3 -1
  80. package/docs/configuration.md +47 -0
  81. package/docs/exit-codes.md +4 -2
  82. package/docs/testing.md +83 -0
  83. package/package.json +1 -1
@@ -64,6 +64,26 @@ async function findAdjacentUnmanagedFiles(args) {
64
64
  }
65
65
  return [...candidates].sort();
66
66
  }
67
+ /**
68
+ * Splits the candidates into the ones that refuse and the ones a
69
+ * `--expect-unmanaged` carve-out admits. Approved paths are recorded as seen
70
+ * so an unused carve-out can be reported, and are RETURNED rather than
71
+ * dropped: an exception nobody can see is how a carve-out quietly widens.
72
+ */
73
+ function partitionApproved(files, ctx) {
74
+ const refusing = [];
75
+ const approved = [];
76
+ for (const file of files) {
77
+ if (ctx.approvedUnmanaged.has(file)) {
78
+ ctx.approvedSeen.add(file);
79
+ approved.push(file);
80
+ }
81
+ else {
82
+ refusing.push(file);
83
+ }
84
+ }
85
+ return { refusing, approved };
86
+ }
67
87
  /**
68
88
  * Emits the scan-less advisory for one patch: missing manifest files and
69
89
  * unmanaged files adjacent to the patch's ownership. When
@@ -83,13 +103,18 @@ export async function reportAdjacentUnmanagedFiles(args) {
83
103
  ` Re-run with --scan to reconcile filesAffected with the current worktree, ` +
84
104
  `or pass --files <paths> to set the list explicitly.`);
85
105
  }
86
- const unmanagedFiles = await findAdjacentUnmanagedFiles({
106
+ const allUnmanaged = await findAdjacentUnmanagedFiles({
87
107
  currentFilesAffected,
88
108
  engineDir: paths.engine,
89
109
  manifest,
90
110
  patchFilename: patch.filename,
91
111
  ctx,
92
112
  });
113
+ const { refusing: unmanagedFiles, approved } = partitionApproved(allUnmanaged, ctx);
114
+ if (approved.length > 0) {
115
+ info(`${patch.filename}: ${String(approved.length)} adjacent unmanaged file(s) admitted by ` +
116
+ `--expect-unmanaged (${approved.join(', ')}) — reported, not refused.`);
117
+ }
93
118
  if (unmanagedFiles.length === 0)
94
119
  return false;
95
120
  const firstFew = unmanagedFiles.slice(0, 3).join(', ');
@@ -72,3 +72,11 @@ export declare function reportForeignDrift(args: {
72
72
  newDiffContent: string;
73
73
  ctx: ForeignDriftContext;
74
74
  }): Promise<boolean>;
75
+ /**
76
+ * Names `--expect` paths that never drifted this run. A typo'd
77
+ * `--expect` path silently degrades the flag back to refusing the slice it
78
+ * was meant to admit, so surface the mismatch — but only as a warning: an
79
+ * expected file legitimately shows no drift in several shapes, all named
80
+ * below.
81
+ */
82
+ export declare function warnUnseenExpectedDrift(driftCtx: ForeignDriftContext): void;
@@ -271,3 +271,64 @@ function refuseUnreadableBaseline(ctx, patchFilename, detail) {
271
271
  warn(`${patchFilename}: skipped (--refuse-foreign-drift) — ${detail}; the drift comparison cannot run, refusing fail-closed.`);
272
272
  return true;
273
273
  }
274
+ /**
275
+ * States, verbatim in the notice, WHAT the `--expect` whitelist is compared
276
+ * against and WHEN. This notice is the only feedback an operator gets that
277
+ * the drift belt looked at the files they named, and a downstream report
278
+ * showed the previous copy actively misleading: it warned "showed no drift"
279
+ * over an edit that WAS captured correctly, and none of the causes it named
280
+ * held, so the operator's only recourse was `grep -c` on the written body.
281
+ *
282
+ * The measurement, stated exactly:
283
+ *
284
+ * - COMPARED: for each file already in the patch's `filesAffected`, the
285
+ * `+`/`-` payload lines the REFRESHED body carries that the OLD patch
286
+ * body did not (`computeForeignDrift`). It is a content comparison, and
287
+ * it is ONE-DIRECTIONAL: excess in the new body only.
288
+ * - WHEN: per patch, on the body about to be written, BEFORE the write —
289
+ * never against post-write state.
290
+ *
291
+ * The one-directional half is what the earlier copy hid, and it is the
292
+ * cause the report's first shape actually hit: deleting a line from an
293
+ * engine file makes the refreshed body DROP that file's `+` line. Nothing
294
+ * is absorbed, so by design there is no drift to see — the belt exists to
295
+ * catch a concurrent session's edits being silently swallowed INTO a body,
296
+ * not every difference between two bodies. Naming it is the fix; widening
297
+ * the comparison would turn every ordinary line removal into a refusal.
298
+ */
299
+ const EXPECT_MEASUREMENT_NOTE = '--expect is evaluated against CONTENT, before the write: for each file already in the ' +
300
+ "patch's filesAffected, the payload lines the refreshed body carries that the old patch " +
301
+ 'body did not. It never looks at post-write state.';
302
+ /**
303
+ * Names `--expect` paths that never drifted this run. A typo'd
304
+ * `--expect` path silently degrades the flag back to refusing the slice it
305
+ * was meant to admit, so surface the mismatch — but only as a warning: an
306
+ * expected file legitimately shows no drift in several shapes, all named
307
+ * below.
308
+ */
309
+ export function warnUnseenExpectedDrift(driftCtx) {
310
+ const unseen = [...driftCtx.expectedDriftFiles].filter((file) => !driftCtx.expectedSeen.has(file));
311
+ if (unseen.length === 0)
312
+ return;
313
+ if (driftCtx.evaluationRuns === 0) {
314
+ warn(`--expect path(s) were not evaluated because no selected patch reached the drift check (for example, an earlier refusal): ${unseen.join(', ')}`);
315
+ return;
316
+ }
317
+ warn('--expect path(s) showed no drift this run: ' +
318
+ `${unseen.join(', ')}.\n` +
319
+ `${EXPECT_MEASUREMENT_NOTE}\n` +
320
+ 'Causes, in rough order of likelihood:\n' +
321
+ ' 1. Your edit only REMOVED lines from the body (you deleted a line from the engine ' +
322
+ 'file). The comparison is one-directional — it reports lines ABSORBED into the body — ' +
323
+ 'so a correctly captured deletion is not drift and never will be. Confirm the capture ' +
324
+ 'with "grep -c" on the written patch body.\n' +
325
+ " 2. A typo in the path, or a path not in the patch's filesAffected (files adopted " +
326
+ 'this run via --scan/--scan-file are excluded from the comparison by design).\n' +
327
+ ' 3. Adoption already captured it: a "re-export <p> --scan --scan-file <f>" run also ' +
328
+ "captures that patch's other drifted files, so a scan-less re-export afterwards " +
329
+ 'legitimately sees nothing left to absorb. Informational — a chain that treats this ' +
330
+ 'warning as an error will false-red on it.\n' +
331
+ ' 4. The slice was already captured by an earlier export in this session.\n' +
332
+ ' 5. The engine content already matches the patch body — a concurrent session may have ' +
333
+ 'exported the same change, so the drift converged before this run looked.');
334
+ }
@@ -7,11 +7,11 @@ import { getDiffForFilesAgainstHead } from '../core/git-diff.js';
7
7
  import { listTrackedInHead } from '../core/git-file-ops.js';
8
8
  import { extractAffectedFiles } from '../core/patch-apply.js';
9
9
  import { updatePatchAndMetadata } from '../core/patch-export.js';
10
- import { buildModifiedFileAdditionsFromDiff, buildPatchQueueContext, detectNewFilesInDiff, formatPatchLintIssue, lintPatchQueue, } from '../core/patch-lint.js';
10
+ import { buildModifiedFileAdditionsFromDiff, buildPatchQueueContext, formatPatchLintIssue, lintPatchQueue, } from '../core/patch-lint.js';
11
11
  import { computeProjectedLintRegressions } from '../core/patch-lint-projection.js';
12
12
  import { loadPatchesManifest } from '../core/patch-manifest.js';
13
13
  import { buildProjectedManifest, enforcePatchPolicy } from '../core/patch-policy.js';
14
- import { extractNewFileContentFromDiff } from '../core/patch-transform.js';
14
+ import { buildNewFileTextProjection } from '../core/patch-transform.js';
15
15
  import { InvalidArgumentError } from '../errors/base.js';
16
16
  import { pathExists } from '../utils/fs.js';
17
17
  import { info, outro, success, warn } from '../utils/logger.js';
@@ -42,10 +42,7 @@ function resolveEffectiveTierAndLintIgnore(target, options) {
42
42
  */
43
43
  async function runProjectedCrossPatchLint(patchesDir, targetFilename, projectedDiff) {
44
44
  const baseCtx = await buildPatchQueueContext(patchesDir);
45
- const projectedNewFiles = new Map();
46
- for (const path of detectNewFilesInDiff(projectedDiff)) {
47
- projectedNewFiles.set(path, extractNewFileContentFromDiff(projectedDiff, path));
48
- }
45
+ const projectedNewFiles = buildNewFileTextProjection(projectedDiff);
49
46
  const projectedModifiedFileAdditions = buildModifiedFileAdditionsFromDiff(projectedDiff);
50
47
  const projectedEntries = baseCtx.entries.map((entry) => {
51
48
  if (entry.filename !== targetFilename)
@@ -45,6 +45,24 @@ export function applyReExportFilesPositionalFolding(patches, options) {
45
45
  }
46
46
  return { patches: normalized.patches, options: normalized.options };
47
47
  }
48
+ /**
49
+ * Each carve-out flag is inert without the refusal it modifies, and a
50
+ * silently-inert whitelist is the worst shape: the operator believes an
51
+ * exception is in force while the refusal it was meant to relax was never
52
+ * armed. Refuse the combination instead of ignoring it.
53
+ */
54
+ function validateWhitelistFlagDependencies(options) {
55
+ if (options.expect !== undefined &&
56
+ options.expect.length > 0 &&
57
+ options.refuseForeignDrift !== true) {
58
+ throw new InvalidArgumentError('--expect names files whose drift is expected under --refuse-foreign-drift and has no effect without it. Pass --refuse-foreign-drift, or drop --expect.', '--expect');
59
+ }
60
+ if (options.expectUnmanaged !== undefined &&
61
+ options.expectUnmanaged.length > 0 &&
62
+ options.refuseAdjacentUnmanaged !== true) {
63
+ throw new InvalidArgumentError('--expect-unmanaged names approved exceptions to --refuse-adjacent-unmanaged and has no effect without it. Pass --refuse-adjacent-unmanaged, or drop --expect-unmanaged.', '--expect-unmanaged');
64
+ }
65
+ }
48
66
  /** Validates mutually exclusive `re-export` targeting and metadata options. */
49
67
  export function validateReExportOptionCombinations(patches, options) {
50
68
  if (options.files !== undefined) {
@@ -83,11 +101,7 @@ export function validateReExportOptionCombinations(patches, options) {
83
101
  if (options.refuseForeignDrift === true && (options.scan || options.files !== undefined)) {
84
102
  throw new InvalidArgumentError('--refuse-foreign-drift applies to the scan-less path only and cannot be combined with --scan or --files (those explicitly capture the current engine state).', '--refuse-foreign-drift');
85
103
  }
86
- if (options.expect !== undefined &&
87
- options.expect.length > 0 &&
88
- options.refuseForeignDrift !== true) {
89
- throw new InvalidArgumentError('--expect names files whose drift is expected under --refuse-foreign-drift and has no effect without it. Pass --refuse-foreign-drift, or drop --expect.', '--expect');
90
- }
104
+ validateWhitelistFlagDependencies(options);
91
105
  const usingTierFlag = options.tier !== undefined;
92
106
  const usingLintIgnoreFlag = options.lintIgnore !== undefined && options.lintIgnore.length > 0;
93
107
  if (options.all && (usingTierFlag || usingLintIgnoreFlag)) {
@@ -29,6 +29,7 @@ export function registerReExport(program, { getProjectRoot, withErrorHandling })
29
29
  .option('--refuse-adjacent-unmanaged', 'Refuse a scan-less re-export (non-zero exit, patch not written) when unmanaged files exist adjacent to the patch ownership, instead of warning. Mutually exclusive with --scan and --files.')
30
30
  .option('--refuse-foreign-drift', "Refuse a scan-less re-export (non-zero exit, patch not written) when the refreshed body would absorb engine lines not present in the old patch body — protects multi-session checkouts from silently capturing another session's uncommitted edits. Mutually exclusive with --scan and --files.")
31
31
  .option('--expect <path>', "With --refuse-foreign-drift, name an engine-relative file whose drift is this session's intended edit: drift confined to --expect files proceeds, drift anywhere else still refuses. Repeatable, and the whitelist applies across EVERY patch in this invocation, not per patch — so pass all the patches and all their --expect paths in one call rather than looping one patch at a time.", ...stringListOption())
32
+ .option('--expect-unmanaged <path>', 'With --refuse-adjacent-unmanaged, name an engine-relative file that is a REVIEWED, recorded exception to the adjacency refusal: an approved unmanaged path is still listed, but no longer refuses. Repeatable, and the carve-out applies across EVERY patch in this invocation, not per patch. Use it to keep the belt armed for everything else instead of dropping --refuse-adjacent-unmanaged entirely.', ...stringListOption())
32
33
  .option('--dry-run', 'Show what would change without writing')
33
34
  .option('--skip-lint', 'Skip patch lint checks (downgrade errors to warnings)')
34
35
  .option('--no-cache', 'Bypass per-patch lint result cache reads and writes for this re-export')
@@ -40,7 +41,7 @@ export function registerReExport(program, { getProjectRoot, withErrorHandling })
40
41
  .addOption(new Option('--tier <tier>', 'Force a tier override on the selected patch (only "branding" recognised). Mutually exclusive with --all.').choices(['branding']))
41
42
  .option('--lint-ignore <check-id>', 'Append a lint check ID to the patch\'s PatchMetadata.lintIgnore (union, de-duped, repeatable). Mutually exclusive with --all. Use "fireforge patch lint-ignore" for --remove / --clear.', ...stringListOption());
42
43
  addWaitLockOption(reExport).action(withErrorHandling(async (patches, options) => {
43
- const { tier, lintIgnore, scanFile, scanFiles, cache, expect, ...rest } = options;
44
+ const { tier, lintIgnore, scanFile, scanFiles, cache, expect, expectUnmanaged, ...rest } = options;
44
45
  const projectRoot = getProjectRoot();
45
46
  await withEngineSessionLock(projectRoot, 're-export', () => reExportCommand(projectRoot, patches, {
46
47
  ...pickDefined(rest),
@@ -49,6 +50,9 @@ export function registerReExport(program, { getProjectRoot, withErrorHandling })
49
50
  ...(tier !== undefined ? { tier: tier } : {}),
50
51
  ...(lintIgnore !== undefined && lintIgnore.length > 0 ? { lintIgnore } : {}),
51
52
  ...(expect !== undefined && expect.length > 0 ? { expect } : {}),
53
+ ...(expectUnmanaged !== undefined && expectUnmanaged.length > 0
54
+ ? { expectUnmanaged }
55
+ : {}),
52
56
  ...(cache === false ? { noCache: true } : {}),
53
57
  }), { waitLockSeconds: resolveWaitLockSeconds(options.waitLock) });
54
58
  }));
@@ -5,10 +5,10 @@ import { getDiffForFilesAgainstHead } from '../core/git-diff.js';
5
5
  import { listTrackedInHead } from '../core/git-file-ops.js';
6
6
  import { getModifiedFilesInDir, getUntrackedFilesInDir } from '../core/git-status.js';
7
7
  import { extractAffectedFiles } from '../core/patch-apply.js';
8
- import { buildModifiedFileAdditionsFromDiff, buildPatchQueueContext, detectNewFilesInDiff, lintPatchQueue, } from '../core/patch-lint.js';
8
+ import { buildModifiedFileAdditionsFromDiff, buildPatchQueueContext, lintPatchQueue, } from '../core/patch-lint.js';
9
9
  import { computeProjectedLintRegressions } from '../core/patch-lint-projection.js';
10
10
  import { getClaimedFiles } from '../core/patch-manifest.js';
11
- import { extractNewFileContentFromDiff } from '../core/patch-transform.js';
11
+ import { buildNewFileTextProjection } from '../core/patch-transform.js';
12
12
  import { GeneralError, InvalidArgumentError } from '../errors/base.js';
13
13
  import { mapWithConcurrency } from '../utils/concurrency.js';
14
14
  import { pathExists } from '../utils/fs.js';
@@ -176,10 +176,7 @@ export async function assertScanAdoptionsHaveNoForwardImports(args) {
176
176
  const candidateDiff = await getDiffForFilesAgainstHead(engineDir, [...added]);
177
177
  if (!candidateDiff.trim())
178
178
  return;
179
- const candidateNewFiles = new Map();
180
- for (const path of detectNewFilesInDiff(candidateDiff)) {
181
- candidateNewFiles.set(path, extractNewFileContentFromDiff(candidateDiff, path));
182
- }
179
+ const candidateNewFiles = buildNewFileTextProjection(candidateDiff);
183
180
  const candidateAdditions = buildModifiedFileAdditionsFromDiff(candidateDiff);
184
181
  const baseCtx = await buildPatchQueueContext(patchesDir);
185
182
  const projectedEntries = baseCtx.entries.map((entry) => {
@@ -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...'
@@ -84,7 +85,7 @@ async function runPreTestBuild(projectRoot, paths, projectConfig, harnessRetries
84
85
  ? 'refresh'
85
86
  : extend !== undefined
86
87
  ? 'carry-forward'
87
- : 'auto');
88
+ : 'auto', buildKind);
88
89
  }
89
90
  catch (baselineError) {
90
91
  verbose(`Could not persist build baseline: ${toError(baselineError).message}`);
@@ -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,
@@ -1,4 +1,24 @@
1
1
  import type { BuildBaseline } from './build-baseline-types.js';
2
+ /**
3
+ * True for the build-input manifests the build consumes but never packages
4
+ * (`jar.mn`, `moz.build`, `moz.configure`, `Makefile.in`, `mozbuild.in`).
5
+ * Excluded from the packaging audit and from `packageableFingerprints`;
6
+ * fingerprinted separately as `buildInputFingerprints` so `build-prepare`
7
+ * can tell "dirty against HEAD" from "changed since the last successful
8
+ * build".
9
+ *
10
+ * @param sourcePath Engine-relative POSIX path.
11
+ */
12
+ export declare function isBuildInputPath(sourcePath: string): boolean;
13
+ /**
14
+ * True for a chrome packaging manifest (`jar.mn`). Its install-manifest
15
+ * destinations are the one build input whose change escalates a pre-test
16
+ * `mach build faster` to a full build, and whose fingerprint is therefore
17
+ * refreshed only by a full build.
18
+ *
19
+ * @param sourcePath Engine-relative POSIX path.
20
+ */
21
+ export declare function isJarManifestPath(sourcePath: string): boolean;
2
22
  /** Result of a single artifact lookup. */
3
23
  export interface AuditEntry {
4
24
  /** Engine-relative source file path (POSIX separators). */
@@ -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',
@@ -74,6 +77,30 @@ const BUILD_INPUT_BASENAMES = new Set([
74
77
  'Makefile.in',
75
78
  'mozbuild.in',
76
79
  ]);
80
+ /**
81
+ * True for the build-input manifests the build consumes but never packages
82
+ * (`jar.mn`, `moz.build`, `moz.configure`, `Makefile.in`, `mozbuild.in`).
83
+ * Excluded from the packaging audit and from `packageableFingerprints`;
84
+ * fingerprinted separately as `buildInputFingerprints` so `build-prepare`
85
+ * can tell "dirty against HEAD" from "changed since the last successful
86
+ * build".
87
+ *
88
+ * @param sourcePath Engine-relative POSIX path.
89
+ */
90
+ export function isBuildInputPath(sourcePath) {
91
+ return BUILD_INPUT_BASENAMES.has(basename(sourcePath));
92
+ }
93
+ /**
94
+ * True for a chrome packaging manifest (`jar.mn`). Its install-manifest
95
+ * destinations are the one build input whose change escalates a pre-test
96
+ * `mach build faster` to a full build, and whose fingerprint is therefore
97
+ * refreshed only by a full build.
98
+ *
99
+ * @param sourcePath Engine-relative POSIX path.
100
+ */
101
+ export function isJarManifestPath(sourcePath) {
102
+ return sourcePath === 'jar.mn' || sourcePath.endsWith('/jar.mn');
103
+ }
77
104
  /**
78
105
  * Decides whether a source path should be packaged. Returns true for paths
79
106
  * whose extension or directory fragment matches a known-packaged pattern,
@@ -88,7 +115,7 @@ export function isPackageablePath(sourcePath) {
88
115
  if (sourcePath.includes(fragment))
89
116
  return false;
90
117
  }
91
- if (BUILD_INPUT_BASENAMES.has(basename(sourcePath)))
118
+ if (isBuildInputPath(sourcePath))
92
119
  return false;
93
120
  // `.inc.xhtml` fragments are consumed via `#include` from a registered
94
121
  // chrome document and resolved at packaging time — they never ship as a
@@ -418,6 +445,39 @@ function relativeManifestPath(engineDir, manifest) {
418
445
  }
419
446
  return manifest;
420
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
+ }
421
481
  /**
422
482
  * Runs the post-build audit. Emits per-file warnings for missing or
423
483
  * stale artifacts and a summary info line at the end. Always returns
@@ -429,7 +489,6 @@ function relativeManifestPath(engineDir, manifest) {
429
489
  * @returns Summary of artifact status counts.
430
490
  */
431
491
  export async function auditBuildArtifacts(projectRoot, engineDir, baseline) {
432
- void projectRoot;
433
492
  const summary = {
434
493
  updated: 0,
435
494
  stale: 0,
@@ -448,6 +507,13 @@ export async function auditBuildArtifacts(projectRoot, engineDir, baseline) {
448
507
  if (changed.length === 0) {
449
508
  return summary;
450
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);
451
517
  const ctx = { engineDir, distRoot, testsRoot, testsPackaged };
452
518
  for (const source of changed) {
453
519
  const result = await auditSinglePath(source, ctx);
@@ -458,7 +524,7 @@ export async function auditBuildArtifacts(projectRoot, engineDir, baseline) {
458
524
  status: result.status,
459
525
  });
460
526
  if (result.warning)
461
- warn(result.warning);
527
+ warn(`${result.warning}${describeOwnership(ownersByPath, result.source)}`);
462
528
  }
463
529
  info(`Packaged: ${summary.updated} updated, ${summary.stale} stale, ${summary.missing} missing, ${summary.skipped} skipped`);
464
530
  return summary;