@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
@@ -66,13 +66,30 @@ export declare const FULL_SUITE_REQUEST = "(entire suite)";
66
66
  */
67
67
  export declare function findUncoveredRequestPaths(coverage: TestPackagingCoverage | undefined, requestedPaths: readonly string[]): string[];
68
68
  /**
69
- * Formats the refusal shown when a non-`--build` run requests paths the
70
- * packaged runtime's scoped `test --build` coverage does not include —
71
- * enforced on every such run, with or without `--allow-stale-build`. Kept
72
- * separate from the matcher so tests can pin structure and copy
73
- * independently (same split as {@link formatStaleBuildWarning}).
69
+ * Test manifests changed since the recorded build that sit in (or above)
70
+ * the directory of an uncovered request path — i.e. the manifests that
71
+ * EXPLAIN why this run needs coverage the build never claimed.
72
+ *
73
+ * Best-effort: a probe failure returns an empty list, so the refusal
74
+ * degrades to exactly its previous text rather than failing differently.
75
+ *
76
+ * @param engineDir - Absolute engine directory
77
+ * @param baseline - Last successful build's baseline
78
+ * @param uncovered - Uncovered request paths from
79
+ * {@link findUncoveredRequestPaths}
80
+ * @returns Engine-relative manifest paths, capped at
81
+ * {@link STALE_PATHS_LIMIT}
82
+ */
83
+ export declare function findChangedTestManifestsForPaths(engineDir: string, baseline: BuildBaseline | undefined, uncovered: readonly string[]): Promise<string[]>;
84
+ /**
85
+ * @param uncovered - Request paths the recorded packaging does not cover
86
+ * @param coverage - The recorded scoped coverage claim
87
+ * @param changedManifests - Test manifests changed since the recorded
88
+ * build that explain an uncovered path (from
89
+ * {@link findChangedTestManifestsForPaths}); omitted or empty renders
90
+ * nothing
74
91
  */
75
- export declare function formatTestCoverageRefusal(uncovered: string[], coverage: string[]): string;
92
+ export declare function formatTestCoverageRefusal(uncovered: string[], coverage: string[], changedManifests?: readonly string[]): string;
76
93
  /** Result of the compiled-StaticComponents staleness probe. */
77
94
  export interface StaticComponentsStaleResult {
78
95
  /** True when at least one `components.conf` genuinely diverged from the anchor. */
@@ -27,7 +27,7 @@ import { verbose, warn } from '../utils/logger.js';
27
27
  import { isPackageablePath, isXpcomManifestPath } from './build-audit.js';
28
28
  import { readBuildBaseline } from './build-baseline.js';
29
29
  import { hashEngineFile } from './coverage-extend.js';
30
- import { collectChangedEnginePaths } from './engine-changes.js';
30
+ import { collectChangedEnginePaths, dropPathsMatchingFingerprints } from './engine-changes.js';
31
31
  export { isXpcomManifestPath };
32
32
  /** Cap on the number of changed paths rendered inline. */
33
33
  const STALE_PATHS_LIMIT = 10;
@@ -60,18 +60,7 @@ export async function checkStaleBuildForTest(projectRoot, engineDir) {
60
60
  // and the stale check fires immediately after a successful one. The
61
61
  // fingerprints capture "these files had this content when the build ran";
62
62
  // a path stays stale only when its live hash diverges.
63
- const fingerprints = baseline.packageableFingerprints;
64
- if (fingerprints) {
65
- const staleAfterHashCheck = [];
66
- for (const path of packageable) {
67
- const recorded = fingerprints[path];
68
- const live = await hashEngineFile(engineDir, path);
69
- if (recorded === undefined || live === undefined || recorded !== live) {
70
- staleAfterHashCheck.push(path);
71
- }
72
- }
73
- packageable = staleAfterHashCheck;
74
- }
63
+ packageable = await dropPathsMatchingFingerprints(engineDir, packageable, baseline.packageableFingerprints);
75
64
  if (packageable.length === 0) {
76
65
  return { stale: false, changedPaths: [], truncated: 0, baseline };
77
66
  }
@@ -158,7 +147,74 @@ function normalizeCoveragePath(path) {
158
147
  * separate from the matcher so tests can pin structure and copy
159
148
  * independently (same split as {@link formatStaleBuildWarning}).
160
149
  */
161
- export function formatTestCoverageRefusal(uncovered, coverage) {
150
+ /**
151
+ * Basenames of the test manifests whose entries decide what a packaged test
152
+ * runtime stages. A manifest that gained an entry is the single most common
153
+ * reason a run needs coverage the last build never claimed — and it is a
154
+ * fact FireForge already holds but never said, leaving the operator to
155
+ * rediscover it after a correct refusal.
156
+ */
157
+ const TEST_MANIFEST_BASENAMES = new Set([
158
+ 'xpcshell.toml',
159
+ 'xpcshell.ini',
160
+ 'browser.toml',
161
+ 'browser.ini',
162
+ 'mochitest.toml',
163
+ 'mochitest.ini',
164
+ 'chrome.toml',
165
+ 'chrome.ini',
166
+ 'a11y.toml',
167
+ 'a11y.ini',
168
+ ]);
169
+ /** True for a test manifest path (by basename). */
170
+ function isTestManifestPath(path) {
171
+ const base = path.slice(path.lastIndexOf('/') + 1);
172
+ return TEST_MANIFEST_BASENAMES.has(base);
173
+ }
174
+ /**
175
+ * Test manifests changed since the recorded build that sit in (or above)
176
+ * the directory of an uncovered request path — i.e. the manifests that
177
+ * EXPLAIN why this run needs coverage the build never claimed.
178
+ *
179
+ * Best-effort: a probe failure returns an empty list, so the refusal
180
+ * degrades to exactly its previous text rather than failing differently.
181
+ *
182
+ * @param engineDir - Absolute engine directory
183
+ * @param baseline - Last successful build's baseline
184
+ * @param uncovered - Uncovered request paths from
185
+ * {@link findUncoveredRequestPaths}
186
+ * @returns Engine-relative manifest paths, capped at
187
+ * {@link STALE_PATHS_LIMIT}
188
+ */
189
+ export async function findChangedTestManifestsForPaths(engineDir, baseline, uncovered) {
190
+ let changed;
191
+ try {
192
+ changed = await collectChangedEnginePaths(engineDir, baseline, 'Coverage refusal');
193
+ }
194
+ catch (error) {
195
+ verbose(`Coverage refusal: manifest probe failed — ${toError(error).message}`);
196
+ return [];
197
+ }
198
+ const requestDirs = uncovered
199
+ .filter((p) => p !== FULL_SUITE_REQUEST)
200
+ .map((p) => toManifestGranule(normalizeCoveragePath(p)));
201
+ const hits = changed.filter((path) => {
202
+ if (!isTestManifestPath(path))
203
+ return false;
204
+ const dir = path.slice(0, Math.max(0, path.lastIndexOf('/')));
205
+ return requestDirs.some((req) => req === dir || req.startsWith(`${dir}/`));
206
+ });
207
+ return hits.slice(0, STALE_PATHS_LIMIT);
208
+ }
209
+ /**
210
+ * @param uncovered - Request paths the recorded packaging does not cover
211
+ * @param coverage - The recorded scoped coverage claim
212
+ * @param changedManifests - Test manifests changed since the recorded
213
+ * build that explain an uncovered path (from
214
+ * {@link findChangedTestManifestsForPaths}); omitted or empty renders
215
+ * nothing
216
+ */
217
+ export function formatTestCoverageRefusal(uncovered, coverage, changedManifests = []) {
162
218
  const cap = (paths) => {
163
219
  const head = paths.slice(0, STALE_PATHS_LIMIT);
164
220
  const truncated = paths.length - head.length;
@@ -177,10 +233,16 @@ export function formatTestCoverageRefusal(uncovered, coverage) {
177
233
  const rebuildHint = rebuildTargets.length > 0
178
234
  ? `Rerun "fireforge test --build ${rebuildTargets.slice(0, STALE_PATHS_LIMIT).join(' ')}" to package them, or run "fireforge build" for full coverage.${extendHint}`
179
235
  : 'Run "fireforge build" (or a path-less "fireforge test --build") for full coverage first.';
236
+ // Why THIS run needs new coverage, when a changed manifest explains it: a
237
+ // manifest that gained an entry is not covered by a build claim recorded
238
+ // for other paths, and a browser-chrome claim never covers xpcshell.
239
+ const manifestLine = changedManifests.length > 0
240
+ ? ` This run needs coverage the recorded build never claimed because a test manifest changed since it: ${changedManifests.map((m) => `engine/${m}`).join(', ')} — a manifest that gained an entry stages new support fixtures, and a claim recorded for other paths (or another harness) does not cover them.`
241
+ : '';
180
242
  return (`The packaged test runtime was produced by a scoped "fireforge test --build" covering only: ${cap(coverage)}.\n` +
181
243
  `The requested run needs ${cap(uncovered)}, which that packaging does not cover — support ` +
182
244
  'fixtures for those manifests may be missing from obj-*/_tests/, and the run can hang ' +
183
- `rather than fail. ${rebuildHint} ` +
245
+ `rather than fail.${manifestLine} ${rebuildHint} ` +
184
246
  '(--allow-stale-build does not bypass this check — it accepts stale content, not missing coverage.)');
185
247
  }
186
248
  /**
@@ -38,33 +38,51 @@ const NO_OUTPUT_STALL_CONTROL_STEP = 'First, run a known-good control test. If t
38
38
  * its manifest from `server.js` on 8888, and a survivor of an
39
39
  * interrupted run keeps the port. Its probe is exact, and the preflight
40
40
  * in `mochitest-server-port.ts` now refuses it before a run starts.
41
- * - (3) is a single downstream report (one consumer, 3/3 reproduce and 3/3
42
- * restore on one variable) whose MECHANISM was never root-caused. It is
43
- * stated as the correlation it is. An earlier revision asserted "stalls
44
- * first paint" as fact; that was a causal story written on top of the
45
- * correlation, and a doubtful one — a failed image load in Gecko fires
46
- * `error` and does not gate the document load event, and the harness's
47
- * browser-ready handshake does not wait on image decode. Do not restore
48
- * a mechanism claim here without a root cause to cite.
41
+ * - (3) is now MECHANICAL and root-caused, replacing the correlation an
42
+ * earlier revision recorded. A `chrome://` (or `resource://`) URL that
43
+ * resolves to nothing reaches `CheckForBrokenChromeURL`
44
+ * (netwerk/base/nsNetUtil.cpp), which outside automation only
45
+ * `printf_stderr`s `Missing chrome or resource URL: <uri>` — but under
46
+ * `xpc::IsInAutomation()` the same condition is
47
+ * `MOZ_CRASH_UNSAFE_PRINTF("Missing chrome or resource URLs: %s")`. A
48
+ * downstream reproduction symbolicated the faulting frame as
49
+ * `CheckForBrokenChromeURL`, which is the established diagnosis the bar
50
+ * in `mach-error-hints.ts` asks for. The discredited "stalls first
51
+ * paint" story an even earlier revision asserted must not come back: a
52
+ * failed image load in Gecko fires `error` and does not gate the
53
+ * document load event.
49
54
  *
50
- * Detection (a named signature, as with the module-resolution SIGSEGV) is
51
- * not specified for (3) because no log line is known to key on. The bar for
52
- * adding one is the bar `mach-error-hints.ts` states: an established
53
- * diagnosis, never a plausible guess.
55
+ * Detection by log line is deliberately NOT specified for (3), and the
56
+ * reason is the mechanism itself: in the reproduction the crash landed in a
57
+ * CONTENT process (`plugin-container`, a child of the browser) with the
58
+ * crash reporter compiled out, so the harness log carried neither the crash
59
+ * message nor a crash line — only the no-output timeout, `Ran 0 checks`,
60
+ * and mochitest's "Can't trigger Breakpad, just killing process". Under
61
+ * automation the message is in the process that DIED, not in the log, so
62
+ * the census points at the two artefacts that do carry it: the OS crash
63
+ * report (whose faulting frame names `CheckForBrokenChromeURL`) and the
64
+ * out-of-automation smoke probe (which prints the non-crashing spelling
65
+ * and, since 0.45.0, counts it as an unallowed error so the exit code
66
+ * agrees with the capture).
54
67
  */
55
68
  const NO_OUTPUT_STALL_TRIAGE = [
56
69
  ' 1. A sleeping or locked display on an unattended HEADED run (macOS). The browser never ' +
57
70
  'paints and never reaches its first test.',
58
71
  ' 2. A headless SWGL compositor failure — re-run with --headless to separate this from (1): ' +
59
72
  'if --headless passes, the stall was display/compositor-side, not product-side.',
60
- ' 3. Product-side startup: the browser never reaches the state the harness waits for. One ' +
61
- 'downstream report correlates this stall with a broken `chrome://` image URI on the ' +
62
- 'startup page (reproduced and un-reproduced on that one variable); the mechanism was ' +
63
- 'never root-caused, so treat it as a lead, not an explanation. Discriminate with ' +
73
+ ' 3. A broken `chrome://` or `resource://` URL reached from the startup document. Under ' +
74
+ 'automation this is a MOZ_CRASH in whichever process opened the channel ' +
75
+ '(CheckForBrokenChromeURL, netwerk/base/nsNetUtil.cpp), so the browser dies before the ' +
76
+ 'harness sees anything. Expect the log to name NOTHING: with the crash in a content ' +
77
+ 'process and the crash reporter compiled out, the only artefacts are the OS crash report ' +
78
+ '(macOS: ~/Library/Logs/DiagnosticReports/plugin-container-*.ips — the faulting frame ' +
79
+ 'reads CheckForBrokenChromeURL) and the smoke probe below. Discriminate with ' +
64
80
  '"fireforge run --smoke-exit 60 --headless --capture-console <file>", which launches the ' +
65
- 'same browser OUTSIDE the test harness: a clean window with no unallowed console errors ' +
66
- 'means product-side startup is healthy and the stall is harness- or test-side; a hang, a ' +
67
- 'non-zero exit, or console errors in <file> put it in the startup document.',
81
+ 'same browser OUTSIDE automation, where the same condition only PRINTS ' +
82
+ '"Missing chrome or resource URL: <uri>" — a line the probe counts as an unallowed ' +
83
+ 'error, so its exit code and its capture now agree. A clean window with no unallowed ' +
84
+ 'console errors means product-side startup is healthy and the stall is harness- or ' +
85
+ 'test-side.',
68
86
  ' 4. A stale mochitest httpd holding the harness server port (127.0.0.1:8888). A fresh ' +
69
87
  "browser connects to THAT server, which cannot serve this run's manifest, so the run " +
70
88
  'stalls before TEST_START. Probe with "lsof -nP -iTCP:8888": any listener that is not ' +
@@ -9,6 +9,34 @@ export declare class BuildError extends FireForgeError {
9
9
  constructor(message: string, command?: string | undefined, cause?: Error);
10
10
  get userMessage(): string;
11
11
  }
12
+ /**
13
+ * A test SUITE went red — assertions failed, or sharded runs did not all
14
+ * pass. Distinct from its {@link BuildError} parent only in what it TELLS
15
+ * the operator, deliberately not in its exit code: `docs/exit-codes.md`
16
+ * documents 5 as "a suite that FAILED", and consumers key CI on it.
17
+ *
18
+ * The parent's remedies are all build remedies — "check the build output",
19
+ * "fireforge bootstrap", "delete obj-* directories" — and none of them apply
20
+ * to a failing assertion. Worse, the first points at the wrong half of the
21
+ * log, which is exactly the half a `tail` keeps. Ground truth for a test red
22
+ * is the harness's own output, so the remedies here name the failing tests,
23
+ * the `FIREFORGE-VERDICT:` line, and the run log that survives a pipe.
24
+ *
25
+ * Note that `machineErrorCode` derives the `--json` error code from the class
26
+ * name, so this reports as `test-failure` rather than `build`.
27
+ */
28
+ export declare class TestFailureError extends BuildError {
29
+ /** Rendered TEST-UNEXPECTED blocks, when the verdict carried any. */
30
+ readonly failureBlocks?: string | undefined;
31
+ /** The run's complete raw log, when one was opened. */
32
+ readonly logPath?: string | undefined;
33
+ constructor(message: string, command?: string,
34
+ /** Rendered TEST-UNEXPECTED blocks, when the verdict carried any. */
35
+ failureBlocks?: string | undefined,
36
+ /** The run's complete raw log, when one was opened. */
37
+ logPath?: string | undefined);
38
+ get userMessage(): string;
39
+ }
12
40
  /**
13
41
  * Error thrown when mach is not available.
14
42
  */
@@ -23,6 +23,55 @@ export class BuildError extends FireForgeError {
23
23
  return msg;
24
24
  }
25
25
  }
26
+ /**
27
+ * A test SUITE went red — assertions failed, or sharded runs did not all
28
+ * pass. Distinct from its {@link BuildError} parent only in what it TELLS
29
+ * the operator, deliberately not in its exit code: `docs/exit-codes.md`
30
+ * documents 5 as "a suite that FAILED", and consumers key CI on it.
31
+ *
32
+ * The parent's remedies are all build remedies — "check the build output",
33
+ * "fireforge bootstrap", "delete obj-* directories" — and none of them apply
34
+ * to a failing assertion. Worse, the first points at the wrong half of the
35
+ * log, which is exactly the half a `tail` keeps. Ground truth for a test red
36
+ * is the harness's own output, so the remedies here name the failing tests,
37
+ * the `FIREFORGE-VERDICT:` line, and the run log that survives a pipe.
38
+ *
39
+ * Note that `machineErrorCode` derives the `--json` error code from the class
40
+ * name, so this reports as `test-failure` rather than `build`.
41
+ */
42
+ export class TestFailureError extends BuildError {
43
+ failureBlocks;
44
+ logPath;
45
+ constructor(message, command,
46
+ /** Rendered TEST-UNEXPECTED blocks, when the verdict carried any. */
47
+ failureBlocks,
48
+ /** The run's complete raw log, when one was opened. */
49
+ logPath) {
50
+ super(message, command);
51
+ this.failureBlocks = failureBlocks;
52
+ this.logPath = logPath;
53
+ }
54
+ get userMessage() {
55
+ let msg = `Test Failure: ${this.message}`;
56
+ if (this.command) {
57
+ msg += `\n\nCommand: ${this.command}`;
58
+ }
59
+ if (this.failureBlocks) {
60
+ msg += `\n\n${this.failureBlocks}`;
61
+ }
62
+ msg += '\n\nTo fix this:\n';
63
+ msg += this.failureBlocks
64
+ ? ' 1. Fix the failing assertions listed above, then re-run just those test paths\n'
65
+ : ' 1. Find the TEST-UNEXPECTED-FAIL lines in the output above — they name what broke\n';
66
+ msg +=
67
+ ' 2. Read the "FIREFORGE-VERDICT:" line for the run\'s ground truth (checks, unexpected)\n';
68
+ msg +=
69
+ this.logPath !== undefined
70
+ ? ` 3. The complete raw output is at ${this.logPath} — it survives a piped/tailed terminal`
71
+ : ' 3. Re-run without piping the output, so the failure lines are not cut off by "tail"';
72
+ return msg;
73
+ }
74
+ }
26
75
  /**
27
76
  * Error thrown when mach is not available.
28
77
  */
@@ -322,6 +322,16 @@ export interface ReExportOptions {
322
322
  * else still refuses.
323
323
  */
324
324
  expect?: string[];
325
+ /**
326
+ * Engine-relative files that are an APPROVED unmanaged exception
327
+ * (`--expect-unmanaged`, repeatable; requires
328
+ * `--refuse-adjacent-unmanaged`). The adjacency refusal is all-or-nothing
329
+ * without it: a project with a reviewed, deliberately unmanaged path beside
330
+ * a patch's ownership has to drop the flag entirely and hand-read the
331
+ * notice, which is a weaker belt than the one being disarmed. Approved
332
+ * paths are still LISTED — they are carved out of the refusal, not hidden.
333
+ */
334
+ expectUnmanaged?: string[];
325
335
  }
326
336
  /**
327
337
  * Options for the `fireforge patch tier` subcommand. Sets or clears the
@@ -601,6 +611,15 @@ export interface TestOptions {
601
611
  marionettePort?: number;
602
612
  /** Kill a recognized stale browser process holding the Marionette port, then continue. */
603
613
  killStaleMarionette?: boolean;
614
+ /**
615
+ * Terminate harness helper processes (`xpcshell` httpd, `pywebsocket`,
616
+ * `ssltunnel`, `moz-http2`) that survived an earlier run in this
617
+ * project's objdir. The census itself runs on EVERY test dispatch and is
618
+ * report-only; this flag opts into the kill. Survivors slow later runs
619
+ * without appearing in their output, so the visibility is the default and
620
+ * the termination is the choice.
621
+ */
622
+ reapOrphans?: boolean;
604
623
  /** Permit tests against packageable engine edits newer than the last successful build. */
605
624
  allowStaleBuild?: boolean;
606
625
  /**
@@ -32,13 +32,24 @@ export interface PatchResult {
32
32
  /** Whether the patch was auto-resolved (new file vs existing file conflict) */
33
33
  autoResolved?: boolean;
34
34
  /**
35
- * Pre-existing content of files the auto-resolve overwrote, keyed by
36
- * engine-relative path. Kept for the RUN's duration (not just the retry):
37
- * if a later patch fails and `rollbackPatches` reverse-applies this one,
38
- * reversing a new-file patch DELETES the file — these snapshots are the
39
- * only way to restore what was there before the auto-resolve.
35
+ * Pre-existing BYTES of files the auto-resolve overwrote (or, for a binary
36
+ * new file, removed), keyed by engine-relative path. Kept for the RUN's
37
+ * duration (not just the retry): if a later patch fails and
38
+ * `rollbackPatches` reverse-applies this one, reversing a new-file patch
39
+ * DELETES the file — these snapshots are the only way to restore what was
40
+ * there before the auto-resolve.
41
+ *
42
+ * Raw bytes rather than decoded text, because the same snapshot has to
43
+ * restore a binary target byte-for-byte.
44
+ *
45
+ * Typed `Uint8Array`, not `Buffer`: this interface is published, and the
46
+ * emitted `.d.ts` naming a Node global made a consumer without
47
+ * `@types/node` fail to compile against the package (`TS2591` in the
48
+ * pack smoke test). Every `Buffer` assigns to it — `Buffer` extends
49
+ * `Uint8Array` — and `fs.writeFile` accepts it, so nothing at the call
50
+ * sites changes.
40
51
  */
41
- autoResolvedOriginals?: Map<string, string>;
52
+ autoResolvedOriginals?: Map<string, Uint8Array>;
42
53
  }
43
54
  /**
44
55
  * Extended patch information with metadata.
@@ -226,6 +226,35 @@ export interface PatchLintCheckJsCompilerOptions {
226
226
  /** Module-resolution `paths` mapping (pattern → targets, engine-relative). */
227
227
  paths?: Record<string, string[]>;
228
228
  }
229
+ /**
230
+ * Line-count thresholds for one `file-too-large` tier.
231
+ *
232
+ * Every field is optional; an omitted field keeps the built-in default.
233
+ * The three must stay ordered `notice <= warning <= error`, which the
234
+ * config validator enforces — an out-of-order triple silently disables a
235
+ * band rather than failing anything.
236
+ */
237
+ export interface PatchLintFileSizeTier {
238
+ notice?: number;
239
+ warning?: number;
240
+ error?: number;
241
+ }
242
+ /**
243
+ * Overrides for the `file-too-large` thresholds, per file class.
244
+ *
245
+ * These were module constants until 0.45.0, and nothing in `fireforge.json`
246
+ * could move them. That is a problem specifically because the recommended
247
+ * gate posture is `--max-warnings 0`: under it the `warning` band is not a
248
+ * soft limit at all, so a project whose controllers legitimately sit a few
249
+ * lines over 750 has a hard failure with no dial, at exactly the moment
250
+ * someone is deciding whether to restructure a file.
251
+ */
252
+ export interface PatchLintFileSizeThresholds {
253
+ /** Non-test files. Defaults: notice 500, warning 750, error 900. */
254
+ general?: PatchLintFileSizeTier;
255
+ /** Test files. Defaults: notice 1200, warning 1400, error 1600. */
256
+ test?: PatchLintFileSizeTier;
257
+ }
229
258
  /**
230
259
  * Configuration for patch lint rules.
231
260
  */
@@ -272,6 +301,18 @@ export interface PatchLintConfig {
272
301
  checkJsTestShim?: string;
273
302
  /** File paths exempt from the raw-color-value check (exact or basename match) */
274
303
  rawColorAllowlist?: string[];
304
+ /**
305
+ * Per-tier overrides for the `file-too-large` line-count thresholds.
306
+ * Unset fields keep the built-in defaults.
307
+ */
308
+ fileSizeThresholds?: PatchLintFileSizeThresholds;
309
+ /**
310
+ * Run the project's Prettier over patch-owned `.sys.mjs` modules, from
311
+ * inside `engine/` so the engine's own `.prettierrc*`/`.prettierignore`
312
+ * decide. Default `'off'`: when off, formatting is explicitly out of
313
+ * scope for the per-patch tier.
314
+ */
315
+ prettier?: PatchLintSeverityGate;
275
316
  /** Enforce JSDoc on class-method exports in patch-owned .sys.mjs files. Default: 'off'. */
276
317
  jsdocClassMethods?: PatchLintSeverityGate;
277
318
  /** Require ≥1 assertion in any patch-touched browser_*.js test file (new or modified). Default: 'off'. */
@@ -76,9 +76,11 @@ export declare function writeTextIfChanged(path: string, content: string): Promi
76
76
  * Writes content atomically using a temp-file-and-rename strategy.
77
77
  * Temp files are created in the destination directory so rename stays atomic.
78
78
  * @param path - Destination file path
79
- * @param content - Content to write
79
+ * @param content - Content to write. `Uint8Array` rather than `Buffer` so
80
+ * published types never name a Node global (every `Buffer` still assigns
81
+ * to it, and `fs.writeFile` accepts both).
80
82
  */
81
- export declare function writeFileAtomic(path: string, content: string | Buffer): Promise<void>;
83
+ export declare function writeFileAtomic(path: string, content: string | Uint8Array): Promise<void>;
82
84
  /**
83
85
  * Copies a directory recursively.
84
86
  * @param src - Source directory path
@@ -165,7 +165,9 @@ export async function writeTextIfChanged(path, content) {
165
165
  * Writes content atomically using a temp-file-and-rename strategy.
166
166
  * Temp files are created in the destination directory so rename stays atomic.
167
167
  * @param path - Destination file path
168
- * @param content - Content to write
168
+ * @param content - Content to write. `Uint8Array` rather than `Buffer` so
169
+ * published types never name a Node global (every `Buffer` still assigns
170
+ * to it, and `fs.writeFile` accepts both).
169
171
  */
170
172
  export async function writeFileAtomic(path, content) {
171
173
  await ensureParentDir(path);
@@ -7,6 +7,24 @@
7
7
  whose behaviour needs more than a schema line, plus the environment variables
8
8
  FireForge reads.
9
9
 
10
+ ## `firefox` — the source pin
11
+
12
+ `firefox.version` (with `firefox.product`, and optionally `firefox.sha256` and
13
+ `firefox.candidate`) is the source pin `fireforge download` resolves an archive
14
+ from. `fireforge source set` writes it.
15
+
16
+ The pin lives in `fireforge.json` beside hand-maintained policy sections, which
17
+ is worth knowing when reverting that file: `git checkout -- fireforge.json`
18
+ after an accidental reformat also reverts an **uncommitted** pin, and the tree
19
+ is then building a different version than the config claims.
20
+
21
+ `fireforge doctor` reports the pin beside what the checkout actually is — the
22
+ engine's own `browser/config/version.txt` and the version the last download
23
+ recorded — and warns when they disagree. It is a report, not a lock: a tree
24
+ may legitimately be mid-migration. `product`, `sha256` and `candidate` have no
25
+ recorded counterpart on disk, so the check does not claim to have verified
26
+ them.
27
+
10
28
  ## External toolchains
11
29
 
12
30
  Projects with generated asset prerequisites can declare opt-in
@@ -33,6 +51,35 @@ shim that tracks upstream WebIDL additions per release.
33
51
  - `patchLint.checkJsTestShim` points at a project `.d.ts` whose typed
34
52
  declarations (e.g. a real `TestUtils` interface) override the loose
35
53
  baseline, so calls to nonexistent harness members fail at export time.
54
+ - `patchLint.prettier` (`'off'` | `'warning'` | `'error'`, default `'off'`)
55
+ runs the project's configured Prettier over patch-owned `.sys.mjs`
56
+ modules. It runs from inside `engine/`, so `engine/.prettierrc*` and
57
+ `engine/.prettierignore` decide — the same command run from the repo root
58
+ can report a false pass when the root `.prettierignore` excludes
59
+ `engine/`. Prettier is resolved from `engine/node_modules`, then the
60
+ project's, then `npx --no-install`. **Left `'off'`, formatting is out of
61
+ scope for the per-patch tier**; no other tier checks `.sys.mjs`
62
+ formatting either.
63
+ - `patchLint.fileSizeThresholds` tunes the `file-too-large` line counts per
64
+ file class:
65
+
66
+ ```json
67
+ {
68
+ "patchLint": {
69
+ "fileSizeThresholds": {
70
+ "general": { "notice": 500, "warning": 800, "error": 900 },
71
+ "test": { "warning": 1500 }
72
+ }
73
+ }
74
+ }
75
+ ```
76
+
77
+ Every field is optional and merges over the defaults (general
78
+ `500/750/900`, test `1200/1400/1600`). The merged triple must satisfy
79
+ `notice <= warning <= error`, which is validated at config load. Note that
80
+ under the recommended gate posture `--max-warnings 0` the `warning` band is
81
+ a hard failure, not a soft limit — which is what these thresholds exist to
82
+ let you move.
36
83
 
37
84
  ## `patchPolicy`
38
85
 
@@ -14,7 +14,7 @@ they mean and which class produces each.
14
14
  | 2 | `CONFIG_ERROR` | `fireforge.json` missing or invalid. | `ConfigError` and subclasses |
15
15
  | 3 | `DOWNLOAD_ERROR` | Could not download or extract Firefox source. | `DownloadError` and subclasses |
16
16
  | 4 | `GIT_ERROR` | A git operation failed. | `GitError` and subclasses |
17
- | 5 | `BUILD_ERROR` | `mach build` failed. | `BuildError` and subclasses |
17
+ | 5 | `BUILD_ERROR` | `mach build` failed, or a test suite went red. | `BuildError` and subclasses, `TestFailureError` |
18
18
  | 6 | `PATCH_ERROR` | A patch failed to apply, or the patch queue could not be mutated. | `PatchError` |
19
19
  | 7 | `MISSING_DEPENDENCY` | A required tool (python3, git, tar) was not found. | `PythonNotFoundError`, `GitNotFoundError`, `MachNotFoundError` |
20
20
  | 8 | `INVALID_ARGUMENT` | The flags were wrong. Usage problem, not an environment one. | `InvalidArgumentError` |
@@ -35,7 +35,9 @@ change would have helped — escalate it, do not retry.
35
35
 
36
36
  **Red vs never-ran vs thrown-away.** These are three different facts and,
37
37
  before 0.44.0, two of them shared exit 1. `BUILD_ERROR` (5) is a suite that
38
- FAILED. `LOCK_TIMEOUT` (15) is a run that never started, because another
38
+ FAILED — thrown as `TestFailureError` since 0.45.0, which keeps the code but
39
+ carries test remedies rather than build ones (`--json` reports it as
40
+ `test-failure`). `LOCK_TIMEOUT` (15) is a run that never started, because another
39
41
  FireForge process held the lock for the whole budget — nothing about the
40
42
  request was wrong, so re-queue it. `INCONCLUSIVE` (14) is a suite whose
41
43
  result was DISCARDED, because `engine/` moved underneath it — the code may