@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
@@ -0,0 +1,65 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Validation for `patchLint.fileSizeThresholds`.
4
+ *
5
+ * Split out of `config-validate.ts`, which is at its per-file line budget.
6
+ */
7
+ import { ConfigError } from '../errors/config.js';
8
+ import { isObject } from '../utils/validation.js';
9
+ import { DEFAULT_FILE_SIZE_THRESHOLDS } from './patch-lint-file-size.js';
10
+ /**
11
+ * Parses one `patchLint.fileSizeThresholds.<tier>` triple. Every field is
12
+ * optional and merges over the built-in default; values must be positive
13
+ * integers and stay ordered `notice <= warning <= error`, because an
14
+ * out-of-order triple would silently disable a band instead of failing.
15
+ */
16
+ function parseFileSizeTier(raw, field) {
17
+ if (!isObject(raw)) {
18
+ throw new ConfigError(`Config field "${field}" must be a plain object`);
19
+ }
20
+ const out = {};
21
+ for (const key of ['notice', 'warning', 'error']) {
22
+ const value = raw[key];
23
+ if (value === undefined)
24
+ continue;
25
+ if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) {
26
+ throw new ConfigError(`Config field "${field}.${key}" must be a positive integer`);
27
+ }
28
+ out[key] = value;
29
+ }
30
+ for (const key of Object.keys(raw)) {
31
+ if (!['notice', 'warning', 'error'].includes(key)) {
32
+ throw new ConfigError(`Config field "${field}" has unknown key "${key}"`);
33
+ }
34
+ }
35
+ return out;
36
+ }
37
+ /**
38
+ * Parses the `patchLint.fileSizeThresholds` block. Ordering is validated
39
+ * against the MERGED triple (overrides on top of the defaults), so setting
40
+ * only `warning` cannot land below the default `notice`.
41
+ */
42
+ export function parsePatchLintFileSizeThresholds(raw) {
43
+ if (!isObject(raw)) {
44
+ throw new ConfigError('Config field "patchLint.fileSizeThresholds" must be a plain object');
45
+ }
46
+ const out = {};
47
+ for (const tier of ['general', 'test']) {
48
+ const value = raw[tier];
49
+ if (value === undefined)
50
+ continue;
51
+ const parsed = parseFileSizeTier(value, `patchLint.fileSizeThresholds.${tier}`);
52
+ const merged = { ...DEFAULT_FILE_SIZE_THRESHOLDS[tier], ...parsed };
53
+ if (merged.notice > merged.warning || merged.warning > merged.error) {
54
+ throw new ConfigError(`Config field "patchLint.fileSizeThresholds.${tier}" must satisfy notice <= warning <= error ` +
55
+ `(resolved: ${String(merged.notice)}/${String(merged.warning)}/${String(merged.error)})`);
56
+ }
57
+ out[tier] = parsed;
58
+ }
59
+ for (const key of Object.keys(raw)) {
60
+ if (key !== 'general' && key !== 'test') {
61
+ throw new ConfigError(`Config field "patchLint.fileSizeThresholds" has unknown key "${key}"`);
62
+ }
63
+ }
64
+ return out;
65
+ }
@@ -8,6 +8,7 @@ import { parseObject } from '../utils/parse.js';
8
8
  import { isContainedRelativePath, isExplicitAbsolutePath } from '../utils/paths.js';
9
9
  import { describeProductVersionIncompatibility, FIREFOX_PRODUCTS, isObject, isValidAppId, isValidFirefoxCandidate, isValidFirefoxProduct, isValidFirefoxVersion, isValidProjectLicense, PROJECT_LICENSES, } from '../utils/validation.js';
10
10
  import { SUPPORTED_CONFIG_ROOT_KEYS } from './config-paths.js';
11
+ import { parsePatchLintFileSizeThresholds } from './config-validate-file-size.js';
11
12
  import { parsePatchPolicyBlock } from './config-validate-patch-policy.js';
12
13
  import { parseExternalToolchainsBlock, parseTestBlock } from './config-validate-test-toolchains.js';
13
14
  /**
@@ -323,6 +324,7 @@ function optionalPatchLintBoolean(rec, key) {
323
324
  }
324
325
  /** Severity-gate fields, each parsed and assigned identically. */
325
326
  const PATCH_LINT_SEVERITY_GATE_KEYS = [
327
+ 'prettier',
326
328
  'jsdocClassMethods',
327
329
  'testAssertionFloor',
328
330
  'chromeScriptJsDoc',
@@ -359,6 +361,10 @@ function parsePatchLintBlock(rec) {
359
361
  }
360
362
  out.rawColorAllowlist = rawColorAllowlist;
361
363
  }
364
+ const fileSizeThresholds = rec.raw('fileSizeThresholds');
365
+ if (fileSizeThresholds !== undefined) {
366
+ out.fileSizeThresholds = parsePatchLintFileSizeThresholds(fileSizeThresholds);
367
+ }
362
368
  for (const key of PATCH_LINT_SEVERITY_GATE_KEYS) {
363
369
  const gate = parseSeverityGate(rec.raw(key), `patchLint.${key}`);
364
370
  if (gate !== undefined)
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Preflight census of ORPHANED test-harness helper processes.
3
+ *
4
+ * Stopping a long mochitest run mid-flight does not necessarily take its
5
+ * helpers with it. A downstream incident left `xpcshell` (the harness
6
+ * httpd, pegged at 100% CPU), `pywebsocket`, `ssltunnel` and `moz-http2`
7
+ * alive for an hour. Every subsequent run crawled — a three-second suite
8
+ * took six minutes of wall clock and audio-start waits timed out — and
9
+ * nothing in FireForge's output connected the slowness to the survivors.
10
+ * The hour was spent mis-attributing it to the change under test.
11
+ *
12
+ * Visibility alone would have collapsed that hour, which is why the census
13
+ * runs by default and the kill does not. Two rules keep it honest:
14
+ *
15
+ * - The census runs at PREFLIGHT, before this run spawns anything, so
16
+ * every matching process is by construction a survivor of an earlier
17
+ * run. No age heuristic is needed, and none is used.
18
+ * - A match must be anchored to a Firefox OBJDIR. `xpcshell` and
19
+ * `server.js` are far too generic on their own; the same rule
20
+ * `mochitest-server-port.ts` applies to the httpd holder applies here,
21
+ * for the same reason — FireForge has no business reporting, let alone
22
+ * terminating, a process it cannot attribute to the harness.
23
+ *
24
+ * `--reap-orphans` opts into termination. Without it the census is
25
+ * report-only, exactly like the `Orphaned harness workers` doctor check
26
+ * (which covers a DIFFERENT shape: reparented Python multiprocessing
27
+ * workers, matched on PPID 1 and accumulated CPU time).
28
+ */
29
+ /** One surviving harness helper process. */
30
+ export interface OrphanedHarnessProcess {
31
+ pid: number;
32
+ ppid: number;
33
+ /** Raw `ps` ELAPSED column. */
34
+ elapsed: string;
35
+ /** Parsed elapsed seconds since the process started. */
36
+ elapsedSeconds: number;
37
+ command: string;
38
+ }
39
+ /**
40
+ * Scans `ps -axo pid=,ppid=,etime=,command=` output for surviving harness
41
+ * helpers. Pure — fixture-testable without spawning anything.
42
+ *
43
+ * @param psOutput - Raw `ps` output
44
+ * @param objDir - Absolute objdir of this project, when known; widens the
45
+ * provenance test beyond the generic `obj-…` path segment
46
+ * @param selfPid - This process's pid, excluded so FireForge cannot report
47
+ * itself
48
+ */
49
+ export declare function findOrphanedHarnessProcesses(psOutput: string, objDir?: string, selfPid?: number): OrphanedHarnessProcess[];
50
+ /**
51
+ * Renders the census. Names every process with its elapsed time, because
52
+ * "a leftover harness process exists" and "a leftover harness process has
53
+ * been burning a core for 58 minutes" are different findings.
54
+ */
55
+ export declare function formatOrphanReport(orphans: readonly OrphanedHarnessProcess[]): string;
56
+ /**
57
+ * Preflight census of surviving harness helpers, run before a test
58
+ * dispatch.
59
+ *
60
+ * Best-effort by construction: a host without a usable `ps` (Windows, a
61
+ * locked-down container) logs at verbose and runs exactly as before. It
62
+ * never refuses a run — unlike the server-port preflight, a survivor here
63
+ * degrades performance rather than making the run dispatch against the
64
+ * wrong server, so the correct response is to say so loudly, not to stop.
65
+ *
66
+ * @param objDir - Absolute objdir of this project, when known
67
+ * @param options - `reap` terminates each recognized survivor (SIGTERM,
68
+ * then SIGKILL for anything that stays)
69
+ * @returns The census, empty when nothing was found or nothing could be probed
70
+ */
71
+ export declare function reportOrphanedHarnessProcesses(objDir: string | undefined, options?: {
72
+ reap?: boolean;
73
+ }): Promise<OrphanedHarnessProcess[]>;
@@ -0,0 +1,186 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Preflight census of ORPHANED test-harness helper processes.
4
+ *
5
+ * Stopping a long mochitest run mid-flight does not necessarily take its
6
+ * helpers with it. A downstream incident left `xpcshell` (the harness
7
+ * httpd, pegged at 100% CPU), `pywebsocket`, `ssltunnel` and `moz-http2`
8
+ * alive for an hour. Every subsequent run crawled — a three-second suite
9
+ * took six minutes of wall clock and audio-start waits timed out — and
10
+ * nothing in FireForge's output connected the slowness to the survivors.
11
+ * The hour was spent mis-attributing it to the change under test.
12
+ *
13
+ * Visibility alone would have collapsed that hour, which is why the census
14
+ * runs by default and the kill does not. Two rules keep it honest:
15
+ *
16
+ * - The census runs at PREFLIGHT, before this run spawns anything, so
17
+ * every matching process is by construction a survivor of an earlier
18
+ * run. No age heuristic is needed, and none is used.
19
+ * - A match must be anchored to a Firefox OBJDIR. `xpcshell` and
20
+ * `server.js` are far too generic on their own; the same rule
21
+ * `mochitest-server-port.ts` applies to the httpd holder applies here,
22
+ * for the same reason — FireForge has no business reporting, let alone
23
+ * terminating, a process it cannot attribute to the harness.
24
+ *
25
+ * `--reap-orphans` opts into termination. Without it the census is
26
+ * report-only, exactly like the `Orphaned harness workers` doctor check
27
+ * (which covers a DIFFERENT shape: reparented Python multiprocessing
28
+ * workers, matched on PPID 1 and accumulated CPU time).
29
+ */
30
+ import { toError } from '../utils/errors.js';
31
+ import { info, verbose, warn } from '../utils/logger.js';
32
+ import { exec } from '../utils/process.js';
33
+ import { parsePsDuration } from '../utils/ps-duration.js';
34
+ /**
35
+ * Harness helper executables/scripts. Every one of these is started by the
36
+ * mochitest/xpcshell harness and is expected to die with it; none of them
37
+ * is a thing a developer runs by hand.
38
+ */
39
+ const HARNESS_HELPER_PATTERN = /\b(?:xpcshell|pywebsocket\w*|websocket_server\.py|ssltunnel|moz-http2|http2_server|httpd\.js|server\.js|runtests\.py|runxpcshelltests\.py)\b/;
40
+ /** Objdir provenance: an explicit objdir path, a `obj-…` path segment, or `_tests/`. */
41
+ function isObjdirAnchored(command, objDir) {
42
+ if (objDir !== undefined && objDir.length > 0 && command.includes(objDir))
43
+ return true;
44
+ if (/[/\\]obj-[^/\\\s]*[/\\]/.test(command))
45
+ return true;
46
+ return /[/\\]_tests[/\\]/.test(command);
47
+ }
48
+ /**
49
+ * Scans `ps -axo pid=,ppid=,etime=,command=` output for surviving harness
50
+ * helpers. Pure — fixture-testable without spawning anything.
51
+ *
52
+ * @param psOutput - Raw `ps` output
53
+ * @param objDir - Absolute objdir of this project, when known; widens the
54
+ * provenance test beyond the generic `obj-…` path segment
55
+ * @param selfPid - This process's pid, excluded so FireForge cannot report
56
+ * itself
57
+ */
58
+ export function findOrphanedHarnessProcesses(psOutput, objDir, selfPid = process.pid) {
59
+ const found = [];
60
+ for (const line of psOutput.split('\n')) {
61
+ const match = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.+)$/.exec(line);
62
+ if (!match)
63
+ continue;
64
+ const pid = Number(match[1]);
65
+ const ppid = Number(match[2]);
66
+ const elapsed = match[3] ?? '';
67
+ const command = (match[4] ?? '').trim();
68
+ if (pid === selfPid || ppid === selfPid)
69
+ continue;
70
+ if (!HARNESS_HELPER_PATTERN.test(command))
71
+ continue;
72
+ if (!isObjdirAnchored(command, objDir))
73
+ continue;
74
+ const elapsedSeconds = parsePsDuration(elapsed);
75
+ found.push({
76
+ pid,
77
+ ppid,
78
+ elapsed,
79
+ elapsedSeconds: Number.isNaN(elapsedSeconds) ? 0 : elapsedSeconds,
80
+ command,
81
+ });
82
+ }
83
+ return found;
84
+ }
85
+ /** Grace period between SIGTERM and SIGKILL when reaping. */
86
+ const REAP_GRACE_MS = 500;
87
+ /** Longest command excerpt carried into the report line. */
88
+ const COMMAND_EXCERPT_LIMIT = 160;
89
+ /**
90
+ * Renders the census. Names every process with its elapsed time, because
91
+ * "a leftover harness process exists" and "a leftover harness process has
92
+ * been burning a core for 58 minutes" are different findings.
93
+ */
94
+ export function formatOrphanReport(orphans) {
95
+ const rows = orphans
96
+ .map((p) => ` PID ${String(p.pid)} (up ${p.elapsed}): ${p.command.slice(0, COMMAND_EXCERPT_LIMIT)}`)
97
+ .join('\n');
98
+ const pids = orphans.map((p) => String(p.pid)).join(' ');
99
+ return (`${String(orphans.length)} harness helper process(es) from an EARLIER run are still alive ` +
100
+ `(this preflight runs before the current run spawns anything, so none of these belong to ` +
101
+ `it):\n${rows}\n` +
102
+ `Survivors like these slow every later run without appearing anywhere in its output — a ` +
103
+ `three-second suite taking minutes of wall clock is the usual symptom. Terminate them with ` +
104
+ `"kill ${pids}", or re-run with --reap-orphans to have FireForge do it.`);
105
+ }
106
+ /** Thin exec wrapper for the process listing (tests mock `exec` instead). */
107
+ async function listSystemProcesses() {
108
+ const result = await exec('ps', ['-axo', 'pid=,ppid=,etime=,command='], { timeout: 10000 });
109
+ if (result.exitCode !== 0) {
110
+ throw new Error(`ps exited ${String(result.exitCode)}`);
111
+ }
112
+ return result.stdout;
113
+ }
114
+ /**
115
+ * Preflight census of surviving harness helpers, run before a test
116
+ * dispatch.
117
+ *
118
+ * Best-effort by construction: a host without a usable `ps` (Windows, a
119
+ * locked-down container) logs at verbose and runs exactly as before. It
120
+ * never refuses a run — unlike the server-port preflight, a survivor here
121
+ * degrades performance rather than making the run dispatch against the
122
+ * wrong server, so the correct response is to say so loudly, not to stop.
123
+ *
124
+ * @param objDir - Absolute objdir of this project, when known
125
+ * @param options - `reap` terminates each recognized survivor (SIGTERM,
126
+ * then SIGKILL for anything that stays)
127
+ * @returns The census, empty when nothing was found or nothing could be probed
128
+ */
129
+ export async function reportOrphanedHarnessProcesses(objDir, options = {}) {
130
+ if (process.platform === 'win32')
131
+ return [];
132
+ let psOutput;
133
+ try {
134
+ psOutput = await listSystemProcesses();
135
+ }
136
+ catch (error) {
137
+ verbose(`Orphan preflight: could not scan processes (${toError(error).message}); skipping.`);
138
+ return [];
139
+ }
140
+ const orphans = findOrphanedHarnessProcesses(psOutput, objDir);
141
+ if (orphans.length === 0) {
142
+ verbose('Orphan preflight: no surviving harness helper processes.');
143
+ return [];
144
+ }
145
+ warn(formatOrphanReport(orphans));
146
+ if (options.reap === true) {
147
+ await reapOrphanedHarnessProcesses(orphans);
148
+ }
149
+ return orphans;
150
+ }
151
+ /**
152
+ * Terminates each census entry: SIGTERM first, then SIGKILL for anything
153
+ * still alive. Failures are reported, never thrown — a process that exited
154
+ * between the census and the signal is the common case, not an error.
155
+ */
156
+ async function reapOrphanedHarnessProcesses(orphans) {
157
+ for (const orphan of orphans) {
158
+ try {
159
+ process.kill(orphan.pid, 'SIGTERM');
160
+ }
161
+ catch (error) {
162
+ verbose(`--reap-orphans: SIGTERM to ${String(orphan.pid)} failed (${toError(error).message}).`);
163
+ continue;
164
+ }
165
+ // Grace period before escalating: the httpd shape in the field
166
+ // incident ignored SIGTERM while spinning, but an ordinary helper exits
167
+ // promptly and must not be SIGKILLed for being slow by a millisecond.
168
+ await new Promise((resolve) => setTimeout(resolve, REAP_GRACE_MS));
169
+ let alive = true;
170
+ try {
171
+ process.kill(orphan.pid, 0);
172
+ }
173
+ catch {
174
+ alive = false;
175
+ }
176
+ if (alive) {
177
+ try {
178
+ process.kill(orphan.pid, 'SIGKILL');
179
+ }
180
+ catch (error) {
181
+ verbose(`--reap-orphans: SIGKILL to ${String(orphan.pid)} failed (${toError(error).message}).`);
182
+ }
183
+ }
184
+ info(`--reap-orphans: terminated PID ${String(orphan.pid)}.`);
185
+ }
186
+ }
@@ -25,6 +25,20 @@
25
25
  * echoed verbatim, always.
26
26
  * - The hold buffer is bounded; on overflow the block is flushed verbatim
27
27
  * and the filter returns to pass-through, so output is never lost.
28
+ *
29
+ * The COLLAPSE is test-only, deliberately, and not an oversight — the two
30
+ * reasons are worth stating because from outside it reads like one:
31
+ * 1. Recognition requires a previously-seen SUITE_END shutdown marker, and
32
+ * `mach build` never prints one. Wiring the option into the build
33
+ * dispatch would therefore be a silent no-op.
34
+ * 2. Relaxing that gate for builds would be wrong on its own terms. The
35
+ * marker is what separates teardown noise from a traceback raised while
36
+ * work was still happening; a build has no equivalent boundary, so
37
+ * FireForge cannot tell the known-cosmetic case from a real build-time
38
+ * failure and must not withhold the block.
39
+ * The build path instead RECOGNIZES the signature and says so beside the
40
+ * verbatim traceback — see {@link KNOWN_TEARDOWN_NOISE_BUILD_NOTE} and
41
+ * `runProtectedMachBuild`.
28
42
  */
29
43
  /**
30
44
  * True when CAPTURED output carries the documented mozsystemmonitor
@@ -76,6 +90,13 @@ export interface TeardownNoiseContext {
76
90
  }
77
91
  /** Fresh per-run context — hand the same instance to both stream filters. */
78
92
  export declare function createTeardownNoiseContext(): TeardownNoiseContext;
93
+ /**
94
+ * Build-phase note for the SAME recognized signature. The build path does
95
+ * not COLLAPSE the traceback (see the module doc's "test-only" note) — it
96
+ * prints verbatim and this line is added beside it, so one signature reads
97
+ * the same in both phases without a build losing output it may need.
98
+ */
99
+ export declare const KNOWN_TEARDOWN_NOISE_BUILD_NOTE: string;
79
100
  /** One line replaces the whole recognized traceback block in the echo. */
80
101
  export declare const KNOWN_TEARDOWN_NOISE_ANNOTATION: string;
81
102
  /** Chunk-safe, line-buffered echo filter. See module doc. */
@@ -26,6 +26,20 @@
26
26
  * echoed verbatim, always.
27
27
  * - The hold buffer is bounded; on overflow the block is flushed verbatim
28
28
  * and the filter returns to pass-through, so output is never lost.
29
+ *
30
+ * The COLLAPSE is test-only, deliberately, and not an oversight — the two
31
+ * reasons are worth stating because from outside it reads like one:
32
+ * 1. Recognition requires a previously-seen SUITE_END shutdown marker, and
33
+ * `mach build` never prints one. Wiring the option into the build
34
+ * dispatch would therefore be a silent no-op.
35
+ * 2. Relaxing that gate for builds would be wrong on its own terms. The
36
+ * marker is what separates teardown noise from a traceback raised while
37
+ * work was still happening; a build has no equivalent boundary, so
38
+ * FireForge cannot tell the known-cosmetic case from a real build-time
39
+ * failure and must not withhold the block.
40
+ * The build path instead RECOGNIZES the signature and says so beside the
41
+ * verbatim traceback — see {@link KNOWN_TEARDOWN_NOISE_BUILD_NOTE} and
42
+ * `runProtectedMachBuild`.
29
43
  */
30
44
  const TRACEBACK_HEADER_PATTERN = /^Traceback \(most recent call last\)/;
31
45
  const CHAINED_EXCEPTION_CONNECTOR_PATTERN = /^(?:During handling of the above exception, another exception occurred:|The above exception was the direct cause of the following exception:)\s*$/;
@@ -93,9 +107,18 @@ export function isKnownTeardownNoiseLine(line) {
93
107
  export function createTeardownNoiseContext() {
94
108
  return { shutdownSeen: false };
95
109
  }
110
+ /**
111
+ * Build-phase note for the SAME recognized signature. The build path does
112
+ * not COLLAPSE the traceback (see the module doc's "test-only" note) — it
113
+ * prints verbatim and this line is added beside it, so one signature reads
114
+ * the same in both phases without a build losing output it may need.
115
+ */
116
+ export const KNOWN_TEARDOWN_NOISE_BUILD_NOTE = '[FireForge] The mozsystemmonitor traceback above is the known upstream ' +
117
+ 'SystemResourceMonitor AttributeError — cosmetic, and not a build failure. It is left ' +
118
+ 'verbatim here (unlike a test run, which collapses it); see docs/testing.md.';
96
119
  /** One line replaces the whole recognized traceback block in the echo. */
97
120
  export const KNOWN_TEARDOWN_NOISE_ANNOTATION = '[FireForge] Known upstream mozsystemmonitor teardown noise (SystemResourceMonitor ' +
98
- 'AttributeError at harness shutdown) — not a test failure. See FireForge docs.\n';
121
+ 'AttributeError at harness shutdown) — not a test failure. See docs/testing.md.\n';
99
122
  /** Bounds on the held traceback block before flushing verbatim. */
100
123
  const HOLD_LINE_LIMIT = 100;
101
124
  const HOLD_BYTE_LIMIT = 16 * 1024;
@@ -23,6 +23,13 @@ export interface MachOptions {
23
23
  * stdout/stderr stay raw — the harness classifier depends on the raw
24
24
  * traceback. Unrecognized tracebacks always echo verbatim. Opted into by
25
25
  * the test dispatchers only.
26
+ *
27
+ * Consumed by {@link runMachCapture} ALONE. `runMachInheritCapture` accepts
28
+ * it in the type and ignores it — the inherit path has no per-chunk hook,
29
+ * only a mirror stream that feeds the terminal and the run log the same
30
+ * string, so filtering there would strip the traceback from the raw log
31
+ * too. The build path uses the recognition NOTE instead; see
32
+ * `mach-known-noise-filter.ts`.
26
33
  */
27
34
  annotateKnownTeardownNoise?: boolean;
28
35
  /**
@@ -2,12 +2,12 @@
2
2
  import { basename, join } from 'node:path';
3
3
  import { MachNotFoundError } from '../errors/build.js';
4
4
  import { pathExists } from '../utils/fs.js';
5
- import { warn } from '../utils/logger.js';
5
+ import { info, warn } from '../utils/logger.js';
6
6
  import { exec, execInherit, execInheritCapture, execSmokeRun, execStream, } from '../utils/process.js';
7
7
  import { createSiblingLockPath, withFileLock } from './file-lock.js';
8
8
  import { ensureFirefoxIgnorefileCompatibility } from './firefox-ignorefile.js';
9
9
  import { explainMachError } from './mach-error-hints.js';
10
- import { createKnownTeardownNoiseFilter, createTeardownNoiseContext, } from './mach-known-noise-filter.js';
10
+ import { createKnownTeardownNoiseFilter, createTeardownNoiseContext, hasKnownTeardownNoise, KNOWN_TEARDOWN_NOISE_BUILD_NOTE, } from './mach-known-noise-filter.js';
11
11
  import { getPython } from './mach-python.js';
12
12
  import { installMachResourceGuard } from './mach-resource-shim.js';
13
13
  import { teeToRunLog, writeToActiveRunLog } from './run-log.js';
@@ -193,6 +193,20 @@ function surfaceMachErrorHints(result) {
193
193
  * the same crash family the same number of times.
194
194
  */
195
195
  const DEFAULT_BUILD_CRASH_RETRIES = 2;
196
+ /**
197
+ * Names the known mozsystemmonitor teardown traceback when a BUILD carried
198
+ * it. The test path collapses this signature in the echo; a build cannot
199
+ * (see `mach-known-noise-filter.ts`), so it reached operators as an
200
+ * unexplained traceback in a build log — a recognized, documented upstream
201
+ * defect wearing the appearance of a new one. The output is left verbatim
202
+ * and this one line is added beside it, so the same signature reads the same
203
+ * in both phases.
204
+ */
205
+ function noteKnownTeardownNoiseInBuild(result) {
206
+ if (!hasKnownTeardownNoise(`${result.stdout}\n${result.stderr}`))
207
+ return;
208
+ info(KNOWN_TEARDOWN_NOISE_BUILD_NOTE);
209
+ }
196
210
  /**
197
211
  * The single protected path every FireForge mach build dispatch routes
198
212
  * through — `build`, `build --ui`, and the pre-test `--build` step all use
@@ -222,6 +236,7 @@ export async function runProtectedMachBuild(kind, engineDir, options = {}) {
222
236
  const { env } = await installMachResourceGuard(engineDir);
223
237
  const result = await runMachInheritCapture(args, engineDir, { env });
224
238
  if (result.exitCode === 0) {
239
+ noteKnownTeardownNoiseInBuild(result);
225
240
  return { ...result, attempts: attempt };
226
241
  }
227
242
  const signature = detectHarnessCrashSignature(`${result.stdout}\n${result.stderr}`);
@@ -233,6 +248,7 @@ export async function runProtectedMachBuild(kind, engineDir, options = {}) {
233
248
  continue;
234
249
  }
235
250
  surfaceMachErrorHints(result);
251
+ noteKnownTeardownNoiseInBuild(result);
236
252
  return { ...result, attempts: attempt, ...(signature ? { crashSignature: signature } : {}) };
237
253
  }
238
254
  }
@@ -3,19 +3,19 @@
3
3
  * Patch orchestration — coordinates patch discovery, application, and validation.
4
4
  * Pure parsing, content transformation, and lock management are in separate modules.
5
5
  */
6
- import { lstat, readlink, realpath } from 'node:fs/promises';
6
+ import { lstat, readFile, readlink, realpath, rm } from 'node:fs/promises';
7
7
  import { basename, dirname, join, resolve, sep } from 'node:path';
8
8
  import { PatchError } from '../errors/patch.js';
9
9
  import { toError } from '../utils/errors.js';
10
- import { pathExists, readText, writeText } from '../utils/fs.js';
10
+ import { pathExists, readText, writeFileAtomic, writeText } from '../utils/fs.js';
11
11
  import { verbose } from '../utils/logger.js';
12
12
  import { isContainedRelativePath } from '../utils/paths.js';
13
13
  import { applyPatchIdempotent, reversePatch } from './git.js';
14
14
  import { getFileContentFromHead } from './git-file-ops.js';
15
15
  import { discoverPatches } from './patch-files.js';
16
16
  import { loadPatchesManifest } from './patch-manifest.js';
17
- import { extractAffectedFiles, extractConflictingFiles, isNewFileInPatch } from './patch-parse.js';
18
- import { applyPatchTextToContent, extractNewFileContent } from './patch-transform.js';
17
+ import { extractAffectedFiles, extractConflictingFiles, isNewFileInPatch, parseDiffSections, } from './patch-parse.js';
18
+ import { applyPatchTextToContent, extractNewFileContentFromDiff } from './patch-transform.js';
19
19
  // Re-export from split modules so existing import sites continue working
20
20
  export { PatchError } from '../errors/patch.js';
21
21
  export { countPatches, discoverPatches, getAllTargetFilesFromPatch, getTargetFileFromPatch, isNewFilePatch, } from './patch-files.js';
@@ -53,28 +53,57 @@ async function applySinglePatch(patch, engineDir, protectedFiles) {
53
53
  const applyError = toError(error);
54
54
  // Check if this is a resolvable "new file" conflict
55
55
  let resolvedNewFiles = false;
56
- // Save original content for files we might overwrite, so we can restore on failure
56
+ // Save original BYTES of files we might overwrite, so we can restore on
57
+ // failure. Raw buffers rather than decoded text: the same snapshot has to
58
+ // restore a binary target byte-for-byte, and a Buffer round-trips text
59
+ // losslessly where a utf-8 decode of a binary file does not.
57
60
  const savedContents = new Map();
61
+ // New-file sections keyed by target, so the binary discrimination below
62
+ // reads the ALREADY-parsed section instead of re-scanning the body per
63
+ // file.
64
+ const newFileSections = new Map(parseDiffSections(patchContent)
65
+ .filter((section) => section.isNewFile)
66
+ .map((section) => [section.targetPath, section]));
58
67
  try {
59
68
  for (const file of affectedFiles) {
60
- if (isNewFileInPatch(patchContent, file)) {
61
- const targetPath = join(engineDir, file);
62
- if (await pathExists(targetPath)) {
63
- savedContents.set(file, await readText(targetPath));
64
- const content = await extractNewFileContent(patch.path, file);
65
- await writeText(targetPath, content);
66
- resolvedNewFiles = true;
69
+ if (!isNewFileInPatch(patchContent, file))
70
+ continue;
71
+ const targetPath = join(engineDir, file);
72
+ if (!(await pathExists(targetPath)))
73
+ continue;
74
+ savedContents.set(file, await readFile(targetPath));
75
+ const section = newFileSections.get(file);
76
+ if (section?.isBinary === true) {
77
+ // A binary new file cannot be resolved by writing extracted text —
78
+ // the payload is base85, whose alphabet includes '+'. It does not
79
+ // need to be: `git apply` decodes a `GIT binary patch` itself. So
80
+ // the resolution is to REMOVE the blocking file and let the retry
81
+ // below create it from the payload, which is exactly what the
82
+ // text branch achieves by overwriting.
83
+ if (!section.hasBinaryDelta) {
84
+ // `Binary files … differ` records THAT the bytes changed and
85
+ // carries none of them, so nothing can recreate the file. Fail
86
+ // honestly rather than deleting a file we cannot put back.
87
+ throw new PatchError(`Cannot resolve the new-file conflict for ${file}: the patch's binary section ` +
88
+ 'carries no replayable payload (`Binary files … differ` rather than `GIT binary ' +
89
+ 'patch`), so the file cannot be recreated. Re-export the patch with a binary-aware ' +
90
+ 'diff.', file);
67
91
  }
92
+ await rm(targetPath, { force: true });
68
93
  }
94
+ else {
95
+ await writeText(targetPath, extractNewFileContentFromDiff(patchContent, file));
96
+ }
97
+ resolvedNewFiles = true;
69
98
  }
70
99
  }
71
100
  catch (extractError) {
72
- // extractNewFileContent threw for a LATER target (e.g. a binary
73
- // section) after earlier targets were already overwritten — restore
74
- // them before reporting failure, instead of letting the exception
75
- // skip the restore loop entirely and crash the whole import.
101
+ // Resolution threw for a LATER target after earlier targets were
102
+ // already overwritten or removed — restore them before reporting
103
+ // failure, instead of letting the exception skip the restore loop
104
+ // entirely and crash the whole import.
76
105
  for (const [file, originalContent] of savedContents) {
77
- await writeText(join(engineDir, file), originalContent);
106
+ await writeFileAtomic(join(engineDir, file), originalContent);
78
107
  }
79
108
  return { patch, success: false, error: toError(extractError).message };
80
109
  }
@@ -91,9 +120,9 @@ async function applySinglePatch(patch, engineDir, protectedFiles) {
91
120
  }
92
121
  catch (retryError) {
93
122
  verbose(`Auto-resolved new-file retry failed for ${patch.filename}: ${toError(retryError).message}`);
94
- // Restore original file content before falling through to --reject
123
+ // Restore original file bytes before falling through to --reject
95
124
  for (const [file, originalContent] of savedContents) {
96
- await writeText(join(engineDir, file), originalContent);
125
+ await writeFileAtomic(join(engineDir, file), originalContent);
97
126
  }
98
127
  }
99
128
  }
@@ -138,7 +167,7 @@ async function rollbackPatches(results, engineDir) {
138
167
  if (result.autoResolvedOriginals) {
139
168
  for (const [file, originalContent] of result.autoResolvedOriginals) {
140
169
  try {
141
- await writeText(join(engineDir, file), originalContent);
170
+ await writeFileAtomic(join(engineDir, file), originalContent);
142
171
  verbose(`Restored pre-existing content of ${file} after rollback`);
143
172
  }
144
173
  catch (restoreError) {
@@ -30,6 +30,7 @@ import { toError } from '../utils/errors.js';
30
30
  import { pathExists } from '../utils/fs.js';
31
31
  import { verbose } from '../utils/logger.js';
32
32
  import { normalizePathSlashes } from '../utils/paths.js';
33
+ import { collectUnmanagedCompanions, retargetUnmanagedCompanionHints, } from './patch-lint-unmanaged-companion.js';
33
34
  import { composeShimSource, SHIM_FILENAME, SUPPRESSED_DIAGNOSTIC_CODES, TEST_HARNESS_SHIM, UNDEFINED_IDENTIFIER_CODES, UNDEFINED_IDENTIFIER_HINT, } from './typecheck-shim.js';
34
35
  // ---------------------------------------------------------------------------
35
36
  // Public API
@@ -464,6 +465,7 @@ export async function runCheckJsTestFilesGrouped(repoDir, testFiles, patchLint,
464
465
  const mode = modeFromPatchLintConfig(patchLint);
465
466
  const seenGlobal = new Set();
466
467
  const files = [...testFiles].sort((a, b) => a.localeCompare(b));
468
+ const companions = await collectUnmanagedCompanions(repoDir, files, testFiles);
467
469
  for (const file of files) {
468
470
  // Under a rootScope only the scoped files get their own program, but
469
471
  // head.js helper discovery still spans the full owned set so a scoped
@@ -483,7 +485,10 @@ export async function runCheckJsTestFilesGrouped(repoDir, testFiles, patchLint,
483
485
  const result = await runCheckJsGrouped(repoDir, roots, patchLint.checkJsTestShim, projectRoot, mode, TEST_HARNESS_SHIM);
484
486
  const own = result.byFile.get(file);
485
487
  if (own && own.length > 0) {
486
- merged.byFile.set(file, [...(merged.byFile.get(file) ?? []), ...own]);
488
+ merged.byFile.set(file, [
489
+ ...(merged.byFile.get(file) ?? []),
490
+ ...retargetUnmanagedCompanionHints(own, companions),
491
+ ]);
487
492
  }
488
493
  for (const globalIssue of result.global) {
489
494
  if (seenGlobal.has(globalIssue.message))