@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
@@ -35,6 +35,34 @@ export interface BuildBaseline {
35
35
  * a tracked deletion.
36
36
  */
37
37
  packageableFingerprints?: Record<string, string>;
38
+ /**
39
+ * Content hash per build-input manifest (`jar.mn`, `moz.build`,
40
+ * `moz.configure`, `Makefile.in`, `mozbuild.in`) that was dirty
41
+ * (modified-against-HEAD or untracked) when the recorded build succeeded.
42
+ * Same key/value shape as {@link BuildBaseline.packageableFingerprints}.
43
+ *
44
+ * Consumed by `build-prepare`: a dirty build input whose live hash still
45
+ * matches its entry was already consumed by the recorded build, so it
46
+ * neither re-runs `mach configure` nor escalates a pre-test build to a
47
+ * full `mach build`. Without this the persistent worktree diff every
48
+ * fork carries (imported patches + Furnace-applied components) made a
49
+ * patch-touched `jar.mn` "changed" on EVERY run, and each `test --build`
50
+ * after a full build paid the full build again.
51
+ *
52
+ * Refresh rules, per entry kind:
53
+ * - backend inputs (`moz.build` & co) are refreshed on every successful
54
+ * write — every baseline-writing path ran the auto-configure preflight
55
+ * against the previous baseline, so a changed one was reconfigured;
56
+ * - `jar.mn` entries are refreshed only by a FULL build and carried
57
+ * forward verbatim from the previous record on a `faster` write, since
58
+ * `fireforge build --ui` never escalates and recording a `jar.mn` no
59
+ * full build has installed would hide the next escalation.
60
+ *
61
+ * Absent on baselines written before the field existed; `build-prepare`
62
+ * then falls back to the path-only comparison (every dirty build input
63
+ * counts as changed).
64
+ */
65
+ buildInputFingerprints?: Record<string, string>;
38
66
  /**
39
67
  * What the packaged test runtime produced by the recorded build covers.
40
68
  *
@@ -16,6 +16,15 @@
16
16
  * subsequent run still audits against the last known-good tree.
17
17
  */
18
18
  import { type BuildBaseline, type TestPackagingCoverage } from './build-baseline-types.js';
19
+ /**
20
+ * Which mach build produced the baseline being written. Decides whether
21
+ * the `jar.mn` half of {@link BuildBaseline.buildInputFingerprints} is
22
+ * refreshed (`'full'`) or carried forward from the previous record
23
+ * (`'faster'`). `fireforge build --ui` and a non-escalated `test --build`
24
+ * are `'faster'`; `fireforge build` and an escalated `test --build` are
25
+ * `'full'`.
26
+ */
27
+ export type BaselineBuildKind = 'full' | 'faster';
19
28
  /** Name of the last-build marker file under `.fireforge/`. */
20
29
  export declare const BUILD_BASELINE_FILENAME = "last-build.json";
21
30
  /**
@@ -56,5 +65,11 @@ export declare function readBuildBaseline(projectRoot: string): Promise<BuildBas
56
65
  * keeps the previous anchor: needed by `--extend-coverage`, whose union
57
66
  * can EVALUATE to `'full'` while the build that produced it was still a
58
67
  * scoped `mach build faster` that did not rebake the compiled table.
68
+ * @param buildKind - Which mach build produced this baseline (default
69
+ * `'full'`). A `'faster'` write carries the previous record's `jar.mn`
70
+ * fingerprints forward instead of refreshing them — see
71
+ * {@link BuildBaseline.buildInputFingerprints}. Every caller that ran
72
+ * `mach build faster` MUST say so, or the next pre-test build skips an
73
+ * escalation no full build has honoured.
59
74
  */
60
- export declare function writeBuildBaseline(projectRoot: string, engineDir: string, binaryName: string, testPackagingCoverage?: TestPackagingCoverage, previousBaseline?: BuildBaseline, recordedBy?: string, staticComponentsHandling?: 'auto' | 'refresh' | 'carry-forward'): Promise<void>;
75
+ export declare function writeBuildBaseline(projectRoot: string, engineDir: string, binaryName: string, testPackagingCoverage?: TestPackagingCoverage, previousBaseline?: BuildBaseline, recordedBy?: string, staticComponentsHandling?: 'auto' | 'refresh' | 'carry-forward', buildKind?: BaselineBuildKind): Promise<void>;
@@ -23,7 +23,7 @@ import { mapWithConcurrency } from '../utils/concurrency.js';
23
23
  import { getNodeErrorCode, toError } from '../utils/errors.js';
24
24
  import { pathExists, readJson, writeJson } from '../utils/fs.js';
25
25
  import { verbose } from '../utils/logger.js';
26
- import { isPackageablePath, isXpcomManifestPath } from './build-audit.js';
26
+ import { isBuildInputPath, isJarManifestPath, isPackageablePath, isXpcomManifestPath, } from './build-audit.js';
27
27
  import { DELETED_FILE_FINGERPRINT, } from './build-baseline-types.js';
28
28
  import { FIREFORGE_DIR } from './config-paths.js';
29
29
  import { hashEngineFile } from './coverage-extend.js';
@@ -90,8 +90,14 @@ export async function readBuildBaseline(projectRoot) {
90
90
  * keeps the previous anchor: needed by `--extend-coverage`, whose union
91
91
  * can EVALUATE to `'full'` while the build that produced it was still a
92
92
  * scoped `mach build faster` that did not rebake the compiled table.
93
+ * @param buildKind - Which mach build produced this baseline (default
94
+ * `'full'`). A `'faster'` write carries the previous record's `jar.mn`
95
+ * fingerprints forward instead of refreshing them — see
96
+ * {@link BuildBaseline.buildInputFingerprints}. Every caller that ran
97
+ * `mach build faster` MUST say so, or the next pre-test build skips an
98
+ * escalation no full build has honoured.
93
99
  */
94
- export async function writeBuildBaseline(projectRoot, engineDir, binaryName, testPackagingCoverage, previousBaseline, recordedBy, staticComponentsHandling = 'auto') {
100
+ export async function writeBuildBaseline(projectRoot, engineDir, binaryName, testPackagingCoverage, previousBaseline, recordedBy, staticComponentsHandling = 'auto', buildKind = 'full') {
95
101
  let engineHeadSha = '';
96
102
  try {
97
103
  engineHeadSha = await getHead(engineDir);
@@ -106,6 +112,7 @@ export async function writeBuildBaseline(projectRoot, engineDir, binaryName, tes
106
112
  }
107
113
  }
108
114
  const packageableFingerprints = await collectPackageableFingerprints(engineDir);
115
+ const buildInputFingerprints = await collectBuildInputFingerprints(engineDir, previousBaseline, buildKind);
109
116
  // A full-coverage write (and the legacy no-claim `fireforge build` shape)
110
117
  // just recompiled the StaticComponents table, so it anchors the table to
111
118
  // the current engine state. A scoped write did not — carry the previous
@@ -121,6 +128,7 @@ export async function writeBuildBaseline(projectRoot, engineDir, binaryName, tes
121
128
  builtAt: new Date().toISOString(),
122
129
  binaryName,
123
130
  ...(packageableFingerprints !== undefined ? { packageableFingerprints } : {}),
131
+ ...(buildInputFingerprints !== undefined ? { buildInputFingerprints } : {}),
124
132
  ...(testPackagingCoverage !== undefined ? { testPackagingCoverage } : {}),
125
133
  ...(mozconfigHash !== undefined ? { mozconfigHash } : {}),
126
134
  ...(staticComponentsBaseline !== undefined ? { staticComponentsBaseline } : {}),
@@ -143,6 +151,34 @@ export async function writeBuildBaseline(projectRoot, engineDir, binaryName, tes
143
151
  async function collectPackageableFingerprints(engineDir) {
144
152
  return collectDirtyFingerprints(engineDir, isPackageablePath, 'packageable fingerprint');
145
153
  }
154
+ /**
155
+ * Content fingerprints of the dirty build-input manifests
156
+ * ({@link isBuildInputPath}) this build consumed. Backend inputs
157
+ * (`moz.build` & co) are always taken from the live tree: the
158
+ * auto-configure preflight reconfigured against them before this build
159
+ * ran. `jar.mn` entries are taken from the live tree only after a FULL
160
+ * build; a `faster` write carries the previous record's `jar.mn` entries
161
+ * forward verbatim, because `mach build faster` is exactly the build the
162
+ * `jar.mn` escalation exists to bypass, and recording a `jar.mn` it did
163
+ * not install would silence the next escalation. Returns `undefined` on
164
+ * probe failure so the field is omitted rather than written as `{}`.
165
+ */
166
+ async function collectBuildInputFingerprints(engineDir, previousBaseline, buildKind) {
167
+ const live = await collectDirtyFingerprints(engineDir, isBuildInputPath, 'build-input fingerprint');
168
+ if (live === undefined || buildKind === 'full') {
169
+ return live;
170
+ }
171
+ const fingerprints = {};
172
+ for (const [path, hash] of Object.entries(live)) {
173
+ if (!isJarManifestPath(path))
174
+ fingerprints[path] = hash;
175
+ }
176
+ for (const [path, hash] of Object.entries(previousBaseline?.buildInputFingerprints ?? {})) {
177
+ if (isJarManifestPath(path))
178
+ fingerprints[path] = hash;
179
+ }
180
+ return fingerprints;
181
+ }
146
182
  /**
147
183
  * Anchor for the compiled StaticComponents table: the engine HEAD SHA of
148
184
  * this (full) build plus content fingerprints of the `components.conf`
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Decides whether a changed `jar.mn` really forces the pre-test build to
3
+ * escalate from `mach build faster` to a full `mach build`.
4
+ *
5
+ * The 0.41.0 rule was `any changed jar.mn ⇒ full build`, which costs ~10
6
+ * minutes against ~20 s for a chrome-only slice and is paid TWICE per
7
+ * green/no-change pair on a Furnace-touching slice. A downstream experiment
8
+ * (two clean runs, 2026-08-29, against 0.44.4) showed the rule is over-broad
9
+ * for the common case: a jar-only registration of a NEW content file added
10
+ * to an EXISTING `dist/bin` manifest was installed by a plain
11
+ * `fireforge build --ui` both times — `faster/install_dist_bin_browser` named
12
+ * the destination, the file landed in `dist/bin/…/content/browser/` and in
13
+ * the `.app` bundle, and a plain `fireforge test` fetched it over
14
+ * `chrome://` at runtime. Neither run escalated, and neither needed to.
15
+ *
16
+ * What the experiment did NOT exercise, and what therefore still escalates:
17
+ *
18
+ * - a NEW `jar.mn` file. The install manifest for a manifest the backend
19
+ * has never seen does not exist yet, and `mach build faster` consumes
20
+ * install manifests rather than regenerating the backend that writes
21
+ * them.
22
+ * - a manifest whose jar declaration carries a bracketed base-directory
23
+ * prefix (`[some/dir] name.jar:`), which redirects the install
24
+ * destination away from the default chrome root. That redirect is the
25
+ * "non-dist/bin target" half nobody has run.
26
+ *
27
+ * Everything else about the rule is unchanged, and every failure to read or
28
+ * parse a manifest escalates: this narrows a conservative rule, so its
29
+ * error direction stays the slow-but-correct one.
30
+ */
31
+ import type { BuildBaseline } from './build-baseline-types.js';
32
+ /** Why one changed `jar.mn` still forces a full build. */
33
+ export interface JarEscalationCause {
34
+ /** Engine-relative manifest path. */
35
+ path: string;
36
+ /** Operator-facing reason, printed with the escalation notice. */
37
+ reason: string;
38
+ }
39
+ /** Outcome of {@link evaluateJarManifestEscalation}. */
40
+ export interface JarEscalationDecision {
41
+ /** True when at least one changed manifest still needs a full build. */
42
+ escalate: boolean;
43
+ /** One entry per manifest that forces the escalation. */
44
+ causes: JarEscalationCause[];
45
+ /** Manifests the narrowing cleared; kept for the verbose explanation. */
46
+ cleared: string[];
47
+ }
48
+ /**
49
+ * Evaluates every changed `jar.mn` against the narrowed rule.
50
+ *
51
+ * @param engineDir - Absolute engine directory
52
+ * @param changedPaths - Engine-relative paths changed since the last
53
+ * successful build (non-`jar.mn` entries are ignored)
54
+ * @param baseline - Last successful build's baseline (reserved: the
55
+ * fingerprint map already gated which paths reach here)
56
+ * @returns The escalation decision with per-manifest causes
57
+ */
58
+ export declare function evaluateJarManifestEscalation(engineDir: string, changedPaths: readonly string[], baseline: BuildBaseline | undefined): Promise<JarEscalationDecision>;
59
+ /**
60
+ * Renders the operator-facing escalation notice. Names the manifest AND the
61
+ * reason: "a jar.mn changed" is the sentence that made the previous rule
62
+ * impossible to argue with from outside.
63
+ */
64
+ export declare function formatJarEscalationNotice(decision: JarEscalationDecision): string;
@@ -0,0 +1,127 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Decides whether a changed `jar.mn` really forces the pre-test build to
4
+ * escalate from `mach build faster` to a full `mach build`.
5
+ *
6
+ * The 0.41.0 rule was `any changed jar.mn ⇒ full build`, which costs ~10
7
+ * minutes against ~20 s for a chrome-only slice and is paid TWICE per
8
+ * green/no-change pair on a Furnace-touching slice. A downstream experiment
9
+ * (two clean runs, 2026-08-29, against 0.44.4) showed the rule is over-broad
10
+ * for the common case: a jar-only registration of a NEW content file added
11
+ * to an EXISTING `dist/bin` manifest was installed by a plain
12
+ * `fireforge build --ui` both times — `faster/install_dist_bin_browser` named
13
+ * the destination, the file landed in `dist/bin/…/content/browser/` and in
14
+ * the `.app` bundle, and a plain `fireforge test` fetched it over
15
+ * `chrome://` at runtime. Neither run escalated, and neither needed to.
16
+ *
17
+ * What the experiment did NOT exercise, and what therefore still escalates:
18
+ *
19
+ * - a NEW `jar.mn` file. The install manifest for a manifest the backend
20
+ * has never seen does not exist yet, and `mach build faster` consumes
21
+ * install manifests rather than regenerating the backend that writes
22
+ * them.
23
+ * - a manifest whose jar declaration carries a bracketed base-directory
24
+ * prefix (`[some/dir] name.jar:`), which redirects the install
25
+ * destination away from the default chrome root. That redirect is the
26
+ * "non-dist/bin target" half nobody has run.
27
+ *
28
+ * Everything else about the rule is unchanged, and every failure to read or
29
+ * parse a manifest escalates: this narrows a conservative rule, so its
30
+ * error direction stays the slow-but-correct one.
31
+ */
32
+ import { join } from 'node:path';
33
+ import { readText } from '../utils/fs.js';
34
+ import { verbose } from '../utils/logger.js';
35
+ import { isJarManifestPath } from './build-audit.js';
36
+ import { getUntrackedFilesInDir } from './git-status.js';
37
+ /**
38
+ * A jar declaration line: an optional bracketed base-directory prefix, then
39
+ * the jar name. Matched at column zero because entry lines inside a section
40
+ * are indented — an indented `foo.jar:` is content, not a declaration.
41
+ */
42
+ const JAR_DECLARATION_PATTERN = /^(\[[^\]]*\]\s*)?[^\s[\]]+\.jar:\s*$/;
43
+ /** True when the jar declaration redirects the install base directory. */
44
+ function declaresNonDefaultBase(line) {
45
+ const match = JAR_DECLARATION_PATTERN.exec(line);
46
+ if (!match)
47
+ return false;
48
+ return match[1] !== undefined;
49
+ }
50
+ /**
51
+ * True when a changed `jar.mn` is NEW to the last successful build.
52
+ *
53
+ * Untracked in the engine repo is the reliable tell: a fork's engine tree is
54
+ * a checkout of upstream with patches applied to the WORKTREE, so a manifest
55
+ * a patch created is untracked and one that shipped upstream is tracked. The
56
+ * baseline's fingerprints cannot answer this on their own — they record only
57
+ * the paths that were DIRTY at the last build, so a clean pre-existing
58
+ * manifest has no entry either.
59
+ */
60
+ async function isNewJarManifest(engineDir, path) {
61
+ const untracked = await getUntrackedFilesInDir(engineDir, path);
62
+ return untracked.includes(path);
63
+ }
64
+ /**
65
+ * Evaluates every changed `jar.mn` against the narrowed rule.
66
+ *
67
+ * @param engineDir - Absolute engine directory
68
+ * @param changedPaths - Engine-relative paths changed since the last
69
+ * successful build (non-`jar.mn` entries are ignored)
70
+ * @param baseline - Last successful build's baseline (reserved: the
71
+ * fingerprint map already gated which paths reach here)
72
+ * @returns The escalation decision with per-manifest causes
73
+ */
74
+ export async function evaluateJarManifestEscalation(engineDir, changedPaths, baseline) {
75
+ void baseline;
76
+ const causes = [];
77
+ const cleared = [];
78
+ for (const path of changedPaths.filter(isJarManifestPath)) {
79
+ let isNew;
80
+ try {
81
+ isNew = await isNewJarManifest(engineDir, path);
82
+ }
83
+ catch (error) {
84
+ // Fail closed: an unanswerable "is this new?" keeps the old rule.
85
+ verbose(`jar escalation: could not classify engine/${path} (${String(error)}); escalating.`);
86
+ causes.push({ path, reason: 'could not determine whether the manifest is new' });
87
+ continue;
88
+ }
89
+ if (isNew) {
90
+ causes.push({ path, reason: 'new jar.mn (no install manifest exists for it yet)' });
91
+ continue;
92
+ }
93
+ let body;
94
+ try {
95
+ body = await readText(join(engineDir, path));
96
+ }
97
+ catch (error) {
98
+ verbose(`jar escalation: could not read engine/${path} (${String(error)}); escalating.`);
99
+ causes.push({ path, reason: 'manifest could not be read' });
100
+ continue;
101
+ }
102
+ const redirected = body
103
+ .split(/\r?\n/)
104
+ .filter((line) => declaresNonDefaultBase(line))
105
+ .map((line) => line.trim());
106
+ if (redirected.length > 0) {
107
+ causes.push({
108
+ path,
109
+ reason: `jar declaration redirects the install base directory (${redirected[0] ?? ''})`,
110
+ });
111
+ continue;
112
+ }
113
+ cleared.push(path);
114
+ }
115
+ return { escalate: causes.length > 0, causes, cleared };
116
+ }
117
+ /**
118
+ * Renders the operator-facing escalation notice. Names the manifest AND the
119
+ * reason: "a jar.mn changed" is the sentence that made the previous rule
120
+ * impossible to argue with from outside.
121
+ */
122
+ export function formatJarEscalationNotice(decision) {
123
+ const detail = decision.causes.map((c) => `engine/${c.path} (${c.reason})`).join('; ');
124
+ return (`Escalating this pre-test build to a full mach build so new install-manifest destinations ` +
125
+ `are created: ${detail}. A changed jar.mn that merely adds entries to an existing ` +
126
+ `dist/bin manifest no longer escalates.`);
127
+ }
@@ -14,6 +14,11 @@ export interface BuildPreparation {
14
14
  reconfigured: boolean;
15
15
  /** A changed packaging manifest cannot be safely handled by `mach build faster`. */
16
16
  fullBuildRequired?: boolean;
17
+ /**
18
+ * Operator-facing explanation for {@link fullBuildRequired}, naming the
19
+ * manifest and why it still escalates. Absent when no escalation applies.
20
+ */
21
+ fullBuildReason?: string;
17
22
  }
18
23
  /** Options for {@link prepareBuildEnvironment}. */
19
24
  export interface PrepareBuildOptions {
@@ -31,7 +36,13 @@ export interface PrepareBuildOptions {
31
36
  */
32
37
  refuseUnexportedDrift?: boolean | undefined;
33
38
  }
34
- /** Packaging manifests whose graph/destination directories need a full build. */
39
+ /**
40
+ * Packaging manifests whose graph/destination directories MAY need a full
41
+ * build. Path-shape only: {@link evaluateJarManifestEscalation} decides
42
+ * whether a given changed manifest actually forces one (a new manifest, or
43
+ * one that redirects its install base directory), so an entry added to an
44
+ * existing `dist/bin` manifest no longer costs a ~10-minute build.
45
+ */
35
46
  export declare function requiresFullBuildForIncrementalTest(path: string): boolean;
36
47
  /**
37
48
  * Describes an exit code in the shell's 128+N signal convention. Truncated
@@ -11,8 +11,10 @@ import { toError } from '../utils/errors.js';
11
11
  import { pathExists } from '../utils/fs.js';
12
12
  import { info, notice, spinner, verbose, warn } from '../utils/logger.js';
13
13
  import { isBrandingSetup, setupBranding } from './branding.js';
14
+ import { isBuildInputPath, isJarManifestPath } from './build-audit.js';
15
+ import { evaluateJarManifestEscalation, formatJarEscalationNotice, } from './build-jar-escalation.js';
14
16
  import { findUnexportedDriftAtRisk, formatUnexportedDriftWarning, } from './build-overwrite-guard.js';
15
- import { collectChangedEnginePaths } from './engine-changes.js';
17
+ import { collectChangedEnginePaths, dropPathsMatchingFingerprints } from './engine-changes.js';
16
18
  import { applyAllComponents } from './furnace-apply.js';
17
19
  import { furnaceConfigExists, getFurnacePaths, loadFurnaceConfig, loadFurnaceState, } from './furnace-config.js';
18
20
  import { runFurnaceMutation } from './furnace-operation.js';
@@ -22,9 +24,43 @@ import { generateMozconfig, runMachCapture } from './mach.js';
22
24
  import { explainMachError } from './mach-error-hints.js';
23
25
  /** Path fragments of files whose edits invalidate the recursive-make backend. */
24
26
  const BACKEND_INVALIDATING_SUFFIXES = ['moz.build', 'moz.configure', 'Makefile.in'];
25
- /** Packaging manifests whose graph/destination directories need a full build. */
27
+ /**
28
+ * Packaging manifests whose graph/destination directories MAY need a full
29
+ * build. Path-shape only: {@link evaluateJarManifestEscalation} decides
30
+ * whether a given changed manifest actually forces one (a new manifest, or
31
+ * one that redirects its install base directory), so an entry added to an
32
+ * existing `dist/bin` manifest no longer costs a ~10-minute build.
33
+ */
26
34
  export function requiresFullBuildForIncrementalTest(path) {
27
- return path === 'jar.mn' || path.endsWith('/jar.mn');
35
+ return isJarManifestPath(path);
36
+ }
37
+ /**
38
+ * Build-input manifests changed since the last SUCCESSFUL build — not
39
+ * merely dirty against HEAD. `collectChangedEnginePaths` unions the
40
+ * baseline-to-HEAD diff with every worktree modification, and a fork's
41
+ * worktree is permanently dirty (imported patches, Furnace-applied
42
+ * components), so on its own it reports a patch-touched `jar.mn` or
43
+ * `moz.build` as changed on every run: `mach configure` re-ran and the
44
+ * pre-test build escalated to a full `mach build` after each full build
45
+ * that had already consumed exactly this content. The baseline's
46
+ * `buildInputFingerprints` say what the last successful build saw;
47
+ * anything byte-identical to that is dropped here. A baseline without the
48
+ * field (written before it existed) keeps every dirty input, which is the
49
+ * old behaviour.
50
+ *
51
+ * @param engineDir - Path to the engine directory
52
+ * @param baseline - Last successful build's baseline
53
+ * @returns Engine-relative build-input paths that still need acting on
54
+ */
55
+ async function collectChangedBuildInputs(engineDir, baseline) {
56
+ const changed = await collectChangedEnginePaths(engineDir, baseline, 'Auto-configure');
57
+ const dirtyInputs = changed.filter(isBuildInputPath);
58
+ const stillChanged = await dropPathsMatchingFingerprints(engineDir, dirtyInputs, baseline.buildInputFingerprints);
59
+ const unchangedSinceBuild = dirtyInputs.filter((path) => !stillChanged.includes(path));
60
+ if (unchangedSinceBuild.length > 0) {
61
+ verbose(`Auto-configure: ${unchangedSinceBuild.length} dirty build input(s) byte-identical to the last successful build, not treated as changed: ${unchangedSinceBuild.join(', ')}`);
62
+ }
63
+ return stillChanged;
28
64
  }
29
65
  /**
30
66
  * Extracts the tail of captured `mach configure` output so the underlying
@@ -78,6 +114,24 @@ function buildConfigureFailureError(captured) {
78
114
  (hints.length > 0 ? `\n\n${hints.map((hint) => `Hint: ${hint}`).join('\n')}` : '') +
79
115
  '\n\nBuild stopped because continuing would hide the real configure failure.', 'mach configure');
80
116
  }
117
+ /**
118
+ * Runs the narrowed `jar.mn` escalation rule over this run's changed build
119
+ * inputs and returns the operator-facing reason when a full build is still
120
+ * required, or undefined when the narrowing cleared every changed manifest.
121
+ *
122
+ * Extracted so `prepareBuildEnvironment` stays under the complexity budget.
123
+ *
124
+ * @param engineDir - Absolute engine directory
125
+ * @param changed - Build inputs changed since the last successful build
126
+ * @param baseline - Last successful build's baseline
127
+ */
128
+ async function decideJarEscalation(engineDir, changed, baseline) {
129
+ const decision = await evaluateJarManifestEscalation(engineDir, changed.filter(requiresFullBuildForIncrementalTest), baseline);
130
+ if (decision.cleared.length > 0) {
131
+ verbose(`jar escalation: ${String(decision.cleared.length)} changed jar.mn add entries to an existing dist/bin manifest; not escalating for ${decision.cleared.join(', ')}.`);
132
+ }
133
+ return decision.escalate ? formatJarEscalationNotice(decision) : undefined;
134
+ }
81
135
  /**
82
136
  * Returns true when the file path matches a pattern that forces
83
137
  * `mach configure` to regenerate the backend. Exported for testing.
@@ -148,10 +202,13 @@ export async function prepareBuildEnvironment(projectRoot, paths, config, option
148
202
  // work against a stale recursive-make backend.
149
203
  let reconfigured = false;
150
204
  let fullBuildRequired = false;
205
+ let fullBuildReason;
151
206
  if (options.previousBaseline) {
152
- const changed = await collectChangedEnginePaths(paths.engine, options.previousBaseline, 'Auto-configure');
207
+ const changed = await collectChangedBuildInputs(paths.engine, options.previousBaseline);
153
208
  const invalidating = changed.filter(isBackendInvalidatingFile);
154
- fullBuildRequired = changed.some(requiresFullBuildForIncrementalTest);
209
+ const jarEscalation = await decideJarEscalation(paths.engine, changed, options.previousBaseline);
210
+ fullBuildRequired = jarEscalation !== undefined;
211
+ fullBuildReason = jarEscalation;
155
212
  if (invalidating.length > 0) {
156
213
  // Warning severity: this is the "why is this run slow" explanation,
157
214
  // and an info-level line does not survive an agent output filter that
@@ -271,5 +328,10 @@ export async function prepareBuildEnvironment(projectRoot, paths, config, option
271
328
  mozconfigSpinner.error('Failed to generate mozconfig');
272
329
  throw error;
273
330
  }
274
- return { furnaceApplied, reconfigured, fullBuildRequired };
331
+ return {
332
+ furnaceApplied,
333
+ reconfigured,
334
+ fullBuildRequired,
335
+ ...(fullBuildReason !== undefined ? { fullBuildReason } : {}),
336
+ };
275
337
  }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Validation for `patchLint.fileSizeThresholds`.
3
+ *
4
+ * Split out of `config-validate.ts`, which is at its per-file line budget.
5
+ */
6
+ import type { PatchLintFileSizeThresholds } from '../types/config.js';
7
+ /**
8
+ * Parses the `patchLint.fileSizeThresholds` block. Ordering is validated
9
+ * against the MERGED triple (overrides on top of the defaults), so setting
10
+ * only `warning` cannot land below the default `notice`.
11
+ */
12
+ export declare function parsePatchLintFileSizeThresholds(raw: unknown): PatchLintFileSizeThresholds;
@@ -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)
@@ -20,3 +20,21 @@ import type { BuildBaseline } from './build-baseline-types.js';
20
20
  * @returns Sorted engine-relative POSIX paths
21
21
  */
22
22
  export declare function collectChangedEnginePaths(engineDir: string, baseline: BuildBaseline | undefined, contextLabel: string): Promise<string[]>;
23
+ /**
24
+ * Drops every path whose live content still matches the fingerprint the
25
+ * last successful build recorded for it, keeping the rest. Shared by the
26
+ * stale-build preflight (`packageableFingerprints`) and the auto-configure /
27
+ * full-build escalation in `build-prepare` (`buildInputFingerprints`).
28
+ *
29
+ * A path with no recorded entry, an unreadable file, or a differing hash
30
+ * is kept — "cannot prove unchanged" must never turn into "unchanged".
31
+ * With no fingerprint set at all (a baseline written before the field
32
+ * existed) every path is kept, which is the path-only comparison the
33
+ * fingerprints refine.
34
+ *
35
+ * @param engineDir - Path to the engine directory
36
+ * @param paths - Candidate engine-relative POSIX paths
37
+ * @param fingerprints - Recorded hashes keyed by engine-relative path
38
+ * @returns The subset of `paths` that cannot be proven unchanged
39
+ */
40
+ export declare function dropPathsMatchingFingerprints(engineDir: string, paths: readonly string[], fingerprints: Record<string, string> | undefined): Promise<string[]>;
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { toError } from '../utils/errors.js';
8
8
  import { verbose } from '../utils/logger.js';
9
+ import { hashEngineFile } from './coverage-extend.js';
9
10
  import { hasChanges, isMissingHeadError } from './git.js';
10
11
  import { git } from './git-base.js';
11
12
  import { getUntrackedFiles } from './git-status.js';
@@ -59,3 +60,36 @@ export async function collectChangedEnginePaths(engineDir, baseline, contextLabe
59
60
  }
60
61
  return [...collected].sort();
61
62
  }
63
+ /**
64
+ * Drops every path whose live content still matches the fingerprint the
65
+ * last successful build recorded for it, keeping the rest. Shared by the
66
+ * stale-build preflight (`packageableFingerprints`) and the auto-configure /
67
+ * full-build escalation in `build-prepare` (`buildInputFingerprints`).
68
+ *
69
+ * A path with no recorded entry, an unreadable file, or a differing hash
70
+ * is kept — "cannot prove unchanged" must never turn into "unchanged".
71
+ * With no fingerprint set at all (a baseline written before the field
72
+ * existed) every path is kept, which is the path-only comparison the
73
+ * fingerprints refine.
74
+ *
75
+ * @param engineDir - Path to the engine directory
76
+ * @param paths - Candidate engine-relative POSIX paths
77
+ * @param fingerprints - Recorded hashes keyed by engine-relative path
78
+ * @returns The subset of `paths` that cannot be proven unchanged
79
+ */
80
+ export async function dropPathsMatchingFingerprints(engineDir, paths, fingerprints) {
81
+ if (!fingerprints)
82
+ return [...paths];
83
+ const kept = [];
84
+ for (const path of paths) {
85
+ const recorded = fingerprints[path];
86
+ if (recorded === undefined) {
87
+ kept.push(path);
88
+ continue;
89
+ }
90
+ const live = await hashEngineFile(engineDir, path);
91
+ if (live === undefined || live !== recorded)
92
+ kept.push(path);
93
+ }
94
+ return kept;
95
+ }