@hominis/fireforge 0.47.1 → 0.48.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 (82) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/dist/bin/fireforge.js +5 -0
  3. package/dist/build-info.json +4 -4
  4. package/dist/src/commands/doctor-macos-sdk.d.ts +8 -0
  5. package/dist/src/commands/doctor-macos-sdk.js +156 -0
  6. package/dist/src/commands/doctor.js +2 -0
  7. package/dist/src/commands/download.js +64 -3
  8. package/dist/src/commands/import.js +5 -2
  9. package/dist/src/commands/lint-per-patch.js +44 -6
  10. package/dist/src/commands/lint-per-run-checkjs.d.ts +3 -0
  11. package/dist/src/commands/lint-per-run-checkjs.js +1 -0
  12. package/dist/src/commands/lint.js +10 -0
  13. package/dist/src/commands/re-export-lint.d.ts +17 -2
  14. package/dist/src/commands/re-export-lint.js +33 -4
  15. package/dist/src/commands/re-export.js +4 -2
  16. package/dist/src/commands/rebase/confirm.d.ts +12 -1
  17. package/dist/src/commands/rebase/confirm.js +6 -3
  18. package/dist/src/commands/rebase/index.js +77 -13
  19. package/dist/src/commands/run.js +43 -7
  20. package/dist/src/commands/status.js +5 -3
  21. package/dist/src/commands/typecheck.d.ts +5 -0
  22. package/dist/src/commands/typecheck.js +7 -1
  23. package/dist/src/core/build-prepare.d.ts +2 -0
  24. package/dist/src/core/build-prepare.js +67 -35
  25. package/dist/src/core/config-paths.d.ts +1 -1
  26. package/dist/src/core/config-paths.js +1 -0
  27. package/dist/src/core/config-validate.js +7 -0
  28. package/dist/src/core/discard-baseline.js +2 -1
  29. package/dist/src/core/forward-registrations.d.ts +33 -0
  30. package/dist/src/core/forward-registrations.js +16 -0
  31. package/dist/src/core/git-file-ops.d.ts +21 -0
  32. package/dist/src/core/git-file-ops.js +75 -0
  33. package/dist/src/core/git-status.d.ts +14 -0
  34. package/dist/src/core/git-status.js +41 -2
  35. package/dist/src/core/mach-mozconfig.d.ts +2 -1
  36. package/dist/src/core/mach-mozconfig.js +2 -0
  37. package/dist/src/core/patch-apply-fuzz.d.ts +12 -0
  38. package/dist/src/core/patch-apply-fuzz.js +27 -6
  39. package/dist/src/core/patch-apply.d.ts +13 -0
  40. package/dist/src/core/patch-apply.js +27 -2
  41. package/dist/src/core/patch-lint-checkjs-cache.d.ts +18 -0
  42. package/dist/src/core/patch-lint-checkjs-cache.js +45 -0
  43. package/dist/src/core/patch-lint-checkjs.d.ts +6 -0
  44. package/dist/src/core/patch-lint-checkjs.js +8 -1
  45. package/dist/src/core/patch-lint-cross.d.ts +7 -1
  46. package/dist/src/core/patch-lint-cross.js +13 -12
  47. package/dist/src/core/patch-lint-forward-registration-extended.d.ts +38 -0
  48. package/dist/src/core/patch-lint-forward-registration-extended.js +240 -0
  49. package/dist/src/core/patch-lint-forward-registration.d.ts +18 -5
  50. package/dist/src/core/patch-lint-forward-registration.js +83 -37
  51. package/dist/src/core/patch-lint.d.ts +11 -0
  52. package/dist/src/core/patch-lint.js +20 -2
  53. package/dist/src/core/patch-transform.d.ts +14 -13
  54. package/dist/src/core/patch-transform.js +29 -17
  55. package/dist/src/core/rebase-dry-run.d.ts +48 -0
  56. package/dist/src/core/rebase-dry-run.js +105 -0
  57. package/dist/src/core/run-profile.d.ts +50 -0
  58. package/dist/src/core/run-profile.js +131 -0
  59. package/dist/src/core/status-binary.d.ts +1 -1
  60. package/dist/src/core/status-binary.js +3 -1
  61. package/dist/src/core/status-classify.js +29 -2
  62. package/dist/src/core/toolchain-preflight.d.ts +6 -0
  63. package/dist/src/core/toolchain-preflight.js +1 -1
  64. package/dist/src/core/typecheck.d.ts +11 -1
  65. package/dist/src/core/typecheck.js +85 -11
  66. package/dist/src/errors/rebase.d.ts +9 -0
  67. package/dist/src/errors/rebase.js +20 -0
  68. package/dist/src/index.d.ts +3 -1
  69. package/dist/src/index.js +2 -1
  70. package/dist/src/types/commands/options.d.ts +15 -0
  71. package/dist/src/types/config.d.ts +10 -0
  72. package/dist/src/utils/process-input.d.ts +27 -0
  73. package/dist/src/utils/process-input.js +49 -0
  74. package/dist/src/utils/process.d.ts +2 -0
  75. package/dist/src/utils/process.js +2 -1
  76. package/dist/src/utils/validation.d.ts +11 -0
  77. package/dist/src/utils/validation.js +43 -0
  78. package/docs/configuration.md +81 -0
  79. package/docs/exit-codes.md +1 -1
  80. package/docs/lifecycle-invariants.md +4 -3
  81. package/docs/patch-workflow.md +35 -1
  82. package/package.json +7 -3
package/CHANGELOG.md CHANGED
@@ -1,5 +1,59 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.48.0
4
+
5
+ ### Engine lifecycle
6
+
7
+ - `download --force` asks before it discards work. It replaced `engine/` and deleted the backup without checking for applied patches, unexported edits or local commits. When the engine has changed paths or a HEAD off the recorded base commit, it now names the loss (changed paths, how many match the patch queue) and prompts. A non-interactive run refuses with exit 8 and prints the `--yes` form. A clean engine is replaced silently, as before.
8
+
9
+ ### Builds
10
+
11
+ - `fireforge build` regenerates `engine/mozconfig` before its auto-configure, and a mozconfig that differs from the last successful build's is itself a configure trigger. Configure used to read the previous mozconfig, so a fix in `configs/*.mozconfig` (such as pinning `--with-macos-sdk` after the Xcode 27.0 update) could never take effect. The notice names the trigger. A baseline without `mozconfigHash` falls back to whether this run rewrote the file.
12
+
13
+ ### Smoke runs
14
+
15
+ - **Default change:** `run --smoke-exit` launches on a fresh temporary profile unless `--profile <path>` names one. It ran against the developer's live profile, so leftover profile state could fail the release gate with nothing wrong in the tree.
16
+ - New `run --profile <path>` and `run --temp-profile`, with or without `--smoke-exit`. FireForge creates the temporary profile, seeds the prefs `mach run` would, and removes it when the run ends, including on failure and SIGINT/SIGTERM. The next `run` sweeps one left by a SIGKILLed run.
17
+ - For the record: mach's own flags are not forwarded. Desktop `mach run` has no `--profile`, and its `--temp-profile` leaks a directory in the objdir on every run.
18
+
19
+ ### Rebase
20
+
21
+ - `rebase --dry-run` is real. It printed a patch count and exited 0 without testing a hunk. It now replays the queue onto the engine's HEAD in a throwaway index, walking the real run's context ladder, prints a verdict per patch (clean, reduced context, or reject with the files) and a tally, and exits 6 on any reject. `engine/` is never written.
22
+ - The "from" version is the semantically oldest patch stamp, not the lexically smallest, so `153.10.0esr` no longer counts as older than `153.2.0esr`. A queue with several stamps prints the spread.
23
+ - The header warns when `state.json`'s `downloadedVersion` is not the pinned target, meaning the download step has not run yet.
24
+
25
+ ### Performance
26
+
27
+ - The worktree ownership scan behind `status`, `verify`, `doctor`, `import` and `discard` runs four git processes instead of one or more per managed file. On the Hominis tree (1,606 managed files) that was 1,625 spawns. `status --ownership` went from 13 to 16 s down to 5.7 s, and `verify` from 15 s to 7.6 s. Output is byte-identical to 0.47.1.
28
+ - The batched untracked listing uses `ls-files -z`, so a non-ASCII name inside a collapsed directory is no longer expanded to a quoted path that names no file.
29
+
30
+ ### Typecheck and checkJs
31
+
32
+ - The per-test-file checkJs programs share one parse of each lib, shim and helper file per run. On the Hominis queue (467 programs) the pass went from 66 to 85 s down to 22 to 25 s, with identical findings. This speeds up `typecheck` and every `lint --per-patch`, `export` or `re-export` that misses the lint cache.
33
+ - `fireforge typecheck` keeps incremental build info per project in `.fireforge/typecheck/`, keyed so a shim, compiler or FireForge change never replays a stale verdict. New `typecheck --no-cache` checks from scratch and writes nothing. Nothing is written under `engine/`.
34
+ - On the Hominis tree `typecheck` went from 93.8 s to 30.2 s.
35
+ - For the record: the projects are not run in a worker pool. They took 6 of the 93.8 s; the per-file checkJs pass was the cost.
36
+
37
+ ### Re-export
38
+
39
+ - A one-patch `re-export` roots its checkJs program at the patches it re-exports, as `lint --per-patch --patches` already did, instead of type-checking the whole queue. Cross-patch imports still resolve. On Hominis a cold `re-export <patch> --dry-run` went from 76.7 s to 8.0 s.
40
+ - The queue lint context parses each patch once instead of once per created file: 3 to 4.4 s down to 0.3 s on 401 patches. Shared by `lint --per-patch`, `re-export`, `export` and `typecheck`.
41
+ - `re-export` names its lint scope before the wait and prints a line when it builds a checkJs program. The elapsed time now includes the context build.
42
+
43
+ ### Patch queue and lint
44
+
45
+ - New opt-in `patchLint.forwardRegistration: "extended"` widens `forward-registration` beyond test-manifest `support-files` to jar.mn lines, moz.build path tokens, test-manifest sections and `head`, and `customElements.js` chrome URLs. On the Hominis queue it finds 937 edges across 6 patches. The default (`"support-files"`) is unchanged. Each finding prints a `patch staged-dependency --add --kind registration` command, tested to quiet the queue without leaving `staged-dependency-unused` behind.
46
+ - New public `findForwardRegistrations(patchesDir, { scope })` returns the same census as structured records.
47
+ - New `lint --per-patch --notices summary|full`. `summary` prints one line per check instead of every NOTICE row (about 430 on a green Hominis queue). `full` stays the default and is unchanged. Errors, warnings and `--report` JSON are unaffected.
48
+
49
+ ### Programmatic API
50
+
51
+ - New public `filesMeasuredByFileTooLarge(patchText)`: the files the `file-too-large` rule measures, which the rule itself now uses. A size-waiver audit can use the rule's own predicate. It covers the JS files a patch creates, not every created file, so `detectNewFilesInDiff` was not exported for this.
52
+
53
+ ### Doctor
54
+
55
+ - New macOS `doctor` check: can the bootstrapped clang link against the SDK the build will use? Xcode 27.0 ships a `libSystem.tbd` the Mozilla clang/lld 21.1.8 rejects, and `mach configure` died with exit 5 without naming the cause. The check resolves the SDK from `engine/mozconfig` the way mach does, links a one-line C program, and on failure warns with clang's first error and the installed SDKs. It does nothing off macOS or without a bootstrapped clang.
56
+
3
57
  ## 0.47.0
4
58
 
5
59
  ### Test preflight
@@ -13,6 +13,7 @@ import { emitKilledVerdict } from '../src/commands/test-verdict.js';
13
13
  import { forceReleaseHeldLocksForSignal } from '../src/core/file-lock.js';
14
14
  import { forceReleaseFurnaceLocksForActiveOperations, isSignalRollbackInFlight, rollbackActiveOperationsForSignal, } from '../src/core/furnace-operation.js';
15
15
  import { closeActiveRunLog } from '../src/core/run-log.js';
16
+ import { removeActiveRunProfiles } from '../src/core/run-profile.js';
16
17
  import { waitForActiveCriticalSections } from '../src/core/signal-critical.js';
17
18
  import { CommandError } from '../src/errors/base.js';
18
19
  import { waitForActiveChildShutdown } from '../src/utils/process.js';
@@ -114,6 +115,10 @@ function installFurnaceSignalHandler(signal, exitCode) {
114
115
  // loses the race to the exit below. Only a kill FireForge never saw
115
116
  // may leave the file behind.
116
117
  .then(() => removeActivePgidFile().catch(() => undefined))
118
+ // A `run --temp-profile` (the `--smoke-exit` default) owes the removal
119
+ // of its profile directory; the command's finally loses this race too.
120
+ // Runs after child shutdown, so the browser no longer holds the files.
121
+ .then(() => removeActiveRunProfiles())
117
122
  .finally(() => {
118
123
  exitAfterStdioFlush(exitCode);
119
124
  });
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "version": "0.47.1",
4
- "commit": "11b30621b46142df7a8035ae7b9e8a13936787bb",
5
- "shortCommit": "11b30621b461",
3
+ "version": "0.48.0",
4
+ "commit": "9215c35e3907f14cc03379fb04c867ebd954085a",
5
+ "shortCommit": "9215c35e3907",
6
6
  "dirty": false,
7
7
  "dirtyHash": null,
8
- "buildTime": "2026-09-13T19:20:10.389Z"
8
+ "buildTime": "2026-09-22T21:34:33.874Z"
9
9
  }
@@ -0,0 +1,8 @@
1
+ import type { DoctorCheck } from '../types/commands/index.js';
2
+ import type { DoctorCheckDefinition } from './doctor-check-core.js';
3
+ /** The bootstrapped clang mach uses on macOS. */
4
+ export declare function bootstrappedClangPath(): string;
5
+ /** Runs the check for one engine directory. */
6
+ export declare function checkMacosSdkLink(engineDir: string): Promise<DoctorCheck>;
7
+ /** Registry entry for the doctor runner. */
8
+ export declare const MACOS_SDK_LINK_DOCTOR_CHECK: DoctorCheckDefinition;
@@ -0,0 +1,156 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Doctor check: can the bootstrapped clang link against the macOS SDK the
4
+ * build will use?
5
+ *
6
+ * An Xcode update can ship an SDK whose `.tbd` stubs the pinned toolchain
7
+ * cannot read (Xcode 27.0's `libSystem.tbd` lists `arm64e.x1-macos`
8
+ * targets that clang/lld 21.1.8 rejects as "unknown architecture"). `mach
9
+ * configure` then dies at "checking what kind of list files are supported
10
+ * by the linker" with exit 5, and neither `build` nor `doctor` named the
11
+ * cause: the operator had to read `config.log`. This check links a trivial
12
+ * C program with the bootstrapped clang against the SDK the mozconfig
13
+ * selects, and on failure names the mismatch and lists the SDKs installed.
14
+ *
15
+ * darwin only, and only when a bootstrapped clang exists. A warning, never
16
+ * a failure: the SDK may be one the operator is about to replace.
17
+ */
18
+ import { existsSync } from 'node:fs';
19
+ import { mkdtemp, readdir, rm, writeFile } from 'node:fs/promises';
20
+ import { tmpdir } from 'node:os';
21
+ import { join } from 'node:path';
22
+ import { mozbuildStateDir } from '../core/toolchain-preflight.js';
23
+ import { toError } from '../utils/errors.js';
24
+ import { exec } from '../utils/process.js';
25
+ import { ok, warning } from './doctor-check-core.js';
26
+ const CHECK_NAME = 'macOS SDK links with the bootstrapped clang';
27
+ /** The bootstrapped clang mach uses on macOS. */
28
+ export function bootstrappedClangPath() {
29
+ return join(mozbuildStateDir(), 'clang', 'bin', 'clang');
30
+ }
31
+ /**
32
+ * Sources a mozconfig with `ac_add_options` stubbed to print the
33
+ * `--with-macos-sdk=` value, which is how mach reads it. A mozconfig is a
34
+ * shell script: an SDK chosen by shell logic (the first of several
35
+ * candidates that exists) has no literal value to grep.
36
+ */
37
+ const SDK_FROM_MOZCONFIG_SCRIPT = [
38
+ 'ac_add_options() { for a in "$@"; do case "$a" in --with-macos-sdk=*) printf "%s\\n" "${a#--with-macos-sdk=}";; esac; done; }',
39
+ 'mk_add_options() { :; }',
40
+ '. "$1"',
41
+ ].join('\n');
42
+ /**
43
+ * Resolves the SDK the generated mozconfig selects (`--with-macos-sdk`),
44
+ * falling back to `xcrun --show-sdk-path`, which is what configure
45
+ * auto-detects when the mozconfig names none.
46
+ */
47
+ async function resolveSelectedSdk(engineDir) {
48
+ const mozconfig = join(engineDir, 'mozconfig');
49
+ if (existsSync(mozconfig)) {
50
+ const sourced = await exec('/bin/sh', ['-c', SDK_FROM_MOZCONFIG_SCRIPT, 'sh', mozconfig], {
51
+ cwd: engineDir,
52
+ env: { topsrcdir: engineDir },
53
+ timeout: 10_000,
54
+ });
55
+ if (sourced.exitCode !== 0) {
56
+ return {
57
+ error: `engine/mozconfig failed while selecting the SDK: ${firstLine(sourced.stderr) ?? `exit ${sourced.exitCode}`}`,
58
+ };
59
+ }
60
+ const selected = sourced.stdout
61
+ .split('\n')
62
+ .map((line) => line.trim())
63
+ .filter((line) => line.length > 0)
64
+ .pop();
65
+ if (selected !== undefined)
66
+ return { sdk: selected, source: 'mozconfig' };
67
+ }
68
+ const xcrun = await exec('xcrun', ['--show-sdk-path'], { timeout: 10_000 });
69
+ const path = xcrun.stdout.trim();
70
+ if (xcrun.exitCode !== 0 || path.length === 0) {
71
+ return { error: `xcrun --show-sdk-path failed: ${firstLine(xcrun.stderr) ?? 'no output'}` };
72
+ }
73
+ return { sdk: path, source: 'xcrun' };
74
+ }
75
+ function firstLine(text) {
76
+ return text
77
+ .split('\n')
78
+ .map((line) => line.trim())
79
+ .find((line) => line.length > 0);
80
+ }
81
+ /** Every macOS SDK directory on the host FireForge knows where to look for. */
82
+ async function listInstalledSdks() {
83
+ const roots = [mozbuildStateDir(), '/Library/Developer/CommandLineTools/SDKs'];
84
+ const developer = await exec('xcode-select', ['-p'], { timeout: 10_000 });
85
+ if (developer.exitCode === 0 && developer.stdout.trim().length > 0) {
86
+ roots.push(join(developer.stdout.trim(), 'Platforms/MacOSX.platform/Developer/SDKs'));
87
+ }
88
+ const found = [];
89
+ for (const root of roots) {
90
+ try {
91
+ for (const entry of await readdir(root)) {
92
+ if (/^MacOSX.*\.sdk$/.test(entry))
93
+ found.push(join(root, entry));
94
+ }
95
+ }
96
+ catch {
97
+ // A root that does not exist lists nothing.
98
+ }
99
+ }
100
+ return found;
101
+ }
102
+ /**
103
+ * Links `int main(void){return 0;}` with the bootstrapped clang (through
104
+ * its bundled lld, as the build does) against `sdk`, in a temp directory.
105
+ * @returns undefined on success, else the first error line clang printed
106
+ */
107
+ async function probeSdkLink(clang, sdk) {
108
+ const dir = await mkdtemp(join(tmpdir(), 'fireforge-sdk-probe-'));
109
+ try {
110
+ await writeFile(join(dir, 'probe.c'), 'int main(void) { return 0; }\n');
111
+ const lld = existsSync(join(clang, '..', 'ld64.lld')) ? ['-fuse-ld=lld'] : [];
112
+ const linked = await exec(clang, ['-isysroot', sdk, ...lld, '-o', join(dir, 'probe'), join(dir, 'probe.c')], { timeout: 30_000 });
113
+ if (linked.exitCode === 0)
114
+ return undefined;
115
+ const output = `${linked.stderr}\n${linked.stdout}`;
116
+ return (output
117
+ .split('\n')
118
+ .map((line) => line.trim())
119
+ .find((line) => /error:/.test(line)) ??
120
+ firstLine(output) ??
121
+ `exit ${linked.exitCode}`);
122
+ }
123
+ finally {
124
+ await rm(dir, { recursive: true, force: true });
125
+ }
126
+ }
127
+ /** Runs the check for one engine directory. */
128
+ export async function checkMacosSdkLink(engineDir) {
129
+ const selected = await resolveSelectedSdk(engineDir);
130
+ const fix = 'Pin an SDK the bootstrapped toolchain can link in configs/darwin.mozconfig ' +
131
+ '(ac_add_options --with-macos-sdk=<path>), then run fireforge build.';
132
+ if ('error' in selected) {
133
+ return warning(CHECK_NAME, selected.error, fix);
134
+ }
135
+ let failure;
136
+ try {
137
+ failure = await probeSdkLink(bootstrappedClangPath(), selected.sdk);
138
+ }
139
+ catch (error) {
140
+ failure = toError(error).message;
141
+ }
142
+ if (failure === undefined) {
143
+ return ok(CHECK_NAME, `OK (${selected.sdk}, from ${selected.source})`);
144
+ }
145
+ const installed = await listInstalledSdks();
146
+ return warning(CHECK_NAME, `The bootstrapped clang cannot link against ${selected.sdk} (from ${selected.source}): ${failure}. ` +
147
+ `mach configure fails at its linker probe the same way. Installed SDKs: ` +
148
+ (installed.length > 0 ? installed.join(', ') : 'none found') +
149
+ '.', fix);
150
+ }
151
+ /** Registry entry for the doctor runner. */
152
+ export const MACOS_SDK_LINK_DOCTOR_CHECK = {
153
+ name: CHECK_NAME,
154
+ skipIf: (ctx) => process.platform !== 'darwin' || !ctx.engineExists || !existsSync(bootstrappedClangPath()),
155
+ run: (ctx) => checkMacosSdkLink(ctx.paths.engine),
156
+ };
@@ -17,6 +17,7 @@ import { findExecutable } from '../utils/process.js';
17
17
  import { failure, ok, warning } from './doctor-check-core.js';
18
18
  import { EXTERNAL_TOOLCHAIN_DOCTOR_CHECK } from './doctor-external-toolchains.js';
19
19
  import { FURNACE_DOCTOR_CHECKS } from './doctor-furnace.js';
20
+ import { MACOS_SDK_LINK_DOCTOR_CHECK } from './doctor-macos-sdk.js';
20
21
  import { ORPHANED_HARNESS_DOCTOR_CHECK } from './doctor-orphaned-harness.js';
21
22
  import { PATCH_MANIFEST_CONSISTENCY_CHECK } from './doctor-patch-manifest.js';
22
23
  import { POST_REBASE_AUDIT_CHECK } from './doctor-post-rebase-audit.js';
@@ -258,6 +259,7 @@ const DOCTOR_CHECKS = [
258
259
  },
259
260
  },
260
261
  ORPHANED_HARNESS_DOCTOR_CHECK,
262
+ MACOS_SDK_LINK_DOCTOR_CHECK,
261
263
  {
262
264
  name: 'Patches directory exists',
263
265
  run: async (ctx) => {
@@ -9,7 +9,7 @@ import { downloadFirefoxSource, formatBytes, sweepOrphanedEngineWorkDirs, } from
9
9
  import { clearAppliedFurnaceState } from '../core/furnace-config.js';
10
10
  import { getHead, initRepository, isGitRepository, isMissingHeadError, resumeRepository, } from '../core/git.js';
11
11
  import { restoreTrackedPath } from '../core/git-file-ops.js';
12
- import { getDirtyFiles } from '../core/git-status.js';
12
+ import { getDirtyFiles, getWorkingTreeStatus } from '../core/git-status.js';
13
13
  import { loadPatchesManifest } from '../core/patch-manifest.js';
14
14
  import { formatMajorVersionHopNotice } from '../core/toolchain-preflight.js';
15
15
  import { EngineExistsError, PartialEngineExistsError } from '../errors/download.js';
@@ -17,6 +17,7 @@ import { toError } from '../utils/errors.js';
17
17
  import { checkDiskSpace, ensureDir, pathExists, pathExistsStrict, removeDir } from '../utils/fs.js';
18
18
  import { info, intro, outro, spinner, verbose, warn } from '../utils/logger.js';
19
19
  import { pickDefined } from '../utils/options.js';
20
+ import { confirmDirtyEngineReset } from './rebase/confirm.js';
20
21
  /**
21
22
  * Collects the set of patch-touched files from the manifest.
22
23
  * Returns an empty set when the patches directory or manifest is absent.
@@ -35,6 +36,47 @@ async function getPatchTouchedFiles(patchesDir) {
35
36
  }
36
37
  return files;
37
38
  }
39
+ /**
40
+ * Describes what a forced replacement would discard from an existing
41
+ * engine: changed paths against HEAD (tracked edits, applied patches, new
42
+ * untracked files; ignored paths such as the objdir are not work), and a
43
+ * HEAD that moved off the recorded base commit. Returns `undefined` when
44
+ * nothing would be lost, including an engine that is not a git repository
45
+ * or has no commit yet: that is the broken tree `--force` exists to replace.
46
+ */
47
+ async function describeForcedReplacementLoss(engineDir, patchesDir, baseCommit) {
48
+ if (!(await isGitRepository(engineDir)))
49
+ return undefined;
50
+ let head;
51
+ try {
52
+ head = await getHead(engineDir);
53
+ }
54
+ catch (error) {
55
+ if (isMissingHeadError(error))
56
+ return undefined;
57
+ throw error;
58
+ }
59
+ const entries = await getWorkingTreeStatus(engineDir);
60
+ const headMoved = baseCommit !== undefined && head !== baseCommit;
61
+ if (entries.length === 0 && !headMoved)
62
+ return undefined;
63
+ const parts = [];
64
+ if (entries.length > 0) {
65
+ const patchFiles = [...(await getPatchTouchedFiles(patchesDir))];
66
+ const claimed = entries.filter((entry) => entry.file.endsWith('/')
67
+ ? patchFiles.some((file) => file.startsWith(entry.file))
68
+ : patchFiles.includes(entry.file)).length;
69
+ const unclaimed = entries.length - claimed;
70
+ parts.push(`engine/ has ${entries.length} changed path(s) against HEAD: ` +
71
+ `${claimed} match the patch queue, ${unclaimed} do not` +
72
+ (unclaimed > 0 ? ' (unexported work that exists nowhere else).' : '.'));
73
+ }
74
+ if (headMoved) {
75
+ parts.push(`engine/ HEAD ${head.slice(0, 12)} is not the recorded base commit ${baseCommit.slice(0, 12)}, so commits made in engine/ are discarded too.`);
76
+ }
77
+ parts.push('--force replaces the whole directory.');
78
+ return parts.join(' ');
79
+ }
38
80
  /**
39
81
  * Restores patch-touched files to their committed (HEAD) state so that a
40
82
  * subsequent `fireforge import` does not see spurious uncommitted changes.
@@ -279,7 +321,8 @@ export async function downloadCommand(projectRoot, options) {
279
321
  const paths = getProjectPaths(projectRoot);
280
322
  // Captured before any state update so the post-download major-hop
281
323
  // notice compares against what was actually on disk until now.
282
- const previousVersion = (await loadState(projectRoot)).downloadedVersion;
324
+ const previousState = await loadState(projectRoot);
325
+ const previousVersion = previousState.downloadedVersion;
283
326
  info(`Firefox version: ${version}`);
284
327
  await checkDiskSpace(projectRoot, 5 * 1024 * 1024 * 1024, warn);
285
328
  // A legitimate holder of this lock runs for 10+ minutes (download +
@@ -381,6 +424,23 @@ export async function downloadCommand(projectRoot, options) {
381
424
  }
382
425
  throw new EngineExistsError(paths.engine);
383
426
  }
427
+ // The replacement deletes the old tree once the new one is active,
428
+ // so anything not in patches/ is gone for good. Ask before fetching.
429
+ const loss = await describeForcedReplacementLoss(paths.engine, paths.patches, previousState.baseCommit);
430
+ if (loss !== undefined &&
431
+ !(await confirmDirtyEngineReset({
432
+ engineDir: paths.engine,
433
+ yes: options.yes ?? false,
434
+ dirty: true,
435
+ refusalDetail: loss,
436
+ nonInteractiveCommand: 'fireforge download --force --yes',
437
+ argumentName: '--yes',
438
+ warningMessage: loss,
439
+ promptMessage: 'Discard the existing engine/ and download a fresh one?',
440
+ cancelMessage: 'Download cancelled; engine/ left untouched',
441
+ }))) {
442
+ return;
443
+ }
384
444
  replacementEngineDir = `${paths.engine}.replacement-${randomUUID()}`;
385
445
  backupEngineDir = `${paths.engine}.backup-${randomUUID()}`;
386
446
  installEngineDir = replacementEngineDir;
@@ -436,7 +496,8 @@ export function registerDownload(program, { getProjectRoot, withErrorHandling })
436
496
  program
437
497
  .command('download')
438
498
  .description('Download Firefox source')
439
- .option('-f, --force', 'Force re-download, removing existing source')
499
+ .option('-f, --force', 'Force re-download; discards engine/ including applied patches and unexported edits')
500
+ .option('-y, --yes', 'With --force: replace a dirty engine/ without the confirmation prompt')
440
501
  .action(withErrorHandling(async (options) => {
441
502
  await downloadCommand(getProjectRoot(), pickDefined(options));
442
503
  }));
@@ -30,13 +30,16 @@ function isSafeIoFallback(error) {
30
30
  const code = getNodeErrorCode(error);
31
31
  return code !== undefined && SAFE_IO_FALLBACK_CODES.has(code);
32
32
  }
33
- /** Concurrency bound for per-file classification (each call spawns git). */
33
+ /** Concurrency bound for per-file classification (reads and patch replays). */
34
34
  const UNMANAGED_CLASSIFY_CONCURRENCY = 8;
35
35
  async function getUnmanagedDirtyFiles(engineDir, patchesDir, dirtyFiles) {
36
36
  // One manifest+patch-discovery load for the whole batch (the per-call
37
37
  // computePatchedContent re-read everything for every file), and bounded
38
38
  // concurrency instead of an unbounded Promise.all over git spawns.
39
- const { computePatched: computeExpected } = await createPatchedContentContext(patchesDir, engineDir);
39
+ const { computePatched: computeExpected, prefetchBase } = await createPatchedContentContext(patchesDir, engineDir);
40
+ // One `git cat-file --batch` for every HEAD blob instead of a `git show`
41
+ // per dirty file.
42
+ await prefetchBase(dirtyFiles);
40
43
  const classifications = await mapWithConcurrency(dirtyFiles, UNMANAGED_CLASSIFY_CONCURRENCY, async (file) => {
41
44
  try {
42
45
  const [expected, exists] = await Promise.all([
@@ -138,7 +138,7 @@ const SUPPRESSED_SIZE_CHECKS = new Set([
138
138
  * filename-prefixed issue rows, and the cache writes, all in the same
139
139
  * sequence a serial run produced. Returns the run tallies.
140
140
  */
141
- async function applyPerPatchResults(subset, results, issues, checkJs, cache) {
141
+ async function applyPerPatchResults(subset, results, issues, checkJs, cache, heldNotices) {
142
142
  const totals = {
143
143
  linted: 0,
144
144
  skipped: 0,
@@ -190,6 +190,10 @@ async function applyPerPatchResults(subset, results, issues, checkJs, cache) {
190
190
  for (const suppressedIssue of result.suppressedIssues) {
191
191
  if (!SUPPRESSED_SIZE_CHECKS.has(suppressedIssue.check))
192
192
  continue;
193
+ if (heldNotices !== undefined) {
194
+ heldNotices.push({ check: suppressedIssue.check, patch: patch.filename, suppressed: true });
195
+ continue;
196
+ }
193
197
  info(`NOTICE [${suppressedIssue.check}] ${patch.filename}: suppressed by lintIgnore — ${suppressedIssue.message}`);
194
198
  }
195
199
  totals.suppressed += result.suppressedIssues.length;
@@ -232,7 +236,10 @@ async function lintSubsetConcurrently(subset, lintCtx) {
232
236
  * prints the per-patch summary, and applies the failure criteria
233
237
  * (errors, `--max-warnings`) by throwing GeneralError.
234
238
  */
235
- function reportPerPatchOutcome(issues, linted, skipped, options) {
239
+ function reportPerPatchOutcome(issues, linted, skipped, options, heldNotices) {
240
+ if (heldNotices !== undefined) {
241
+ reportNoticeSummary(heldNotices, issues);
242
+ }
236
243
  if (issues.length === 0) {
237
244
  if (linted === 0 && skipped > 0) {
238
245
  info(`No patches in the queue have been applied to engine/. Run "fireforge import" first if you want lint findings against the staged hunks; otherwise this is expected.`);
@@ -247,8 +254,10 @@ function reportPerPatchOutcome(issues, linted, skipped, options) {
247
254
  const errors = issues.filter((i) => i.severity === 'error');
248
255
  const warnings = issues.filter((i) => i.severity === 'warning');
249
256
  const notices = issues.filter((i) => i.severity === 'notice');
250
- for (const issue of notices)
251
- info(`NOTICE ${formatPatchLintIssue(issue)}`);
257
+ if (heldNotices === undefined) {
258
+ for (const issue of notices)
259
+ info(`NOTICE ${formatPatchLintIssue(issue)}`);
260
+ }
252
261
  for (const issue of warnings)
253
262
  warn(formatPatchLintIssue(issue));
254
263
  for (const issue of errors)
@@ -272,6 +281,34 @@ function reportPerPatchOutcome(issues, linted, skipped, options) {
272
281
  outro('Lint passed');
273
282
  }
274
283
  }
284
+ /**
285
+ * `--notices summary`: one line per check instead of one per finding, for
286
+ * a green queue whose hundreds of NOTICE lines are reviewed waivers and
287
+ * under-threshold measurements. The full list stays in `--report`.
288
+ * Errors and warnings are never summarised.
289
+ */
290
+ function reportNoticeSummary(heldNotices, issues) {
291
+ const byCheck = new Map();
292
+ const tally = (check, patch, suppressed) => {
293
+ const entry = byCheck.get(check) ?? { count: 0, suppressed: 0, patches: new Set() };
294
+ entry.count += 1;
295
+ if (suppressed)
296
+ entry.suppressed += 1;
297
+ entry.patches.add(patch);
298
+ byCheck.set(check, entry);
299
+ };
300
+ for (const held of heldNotices)
301
+ tally(held.check, held.patch, held.suppressed);
302
+ for (const issue of issues) {
303
+ if (issue.severity !== 'notice')
304
+ continue;
305
+ tally(issue.check, issue.patches?.[0] ?? issue.file.split(' :: ')[0] ?? issue.file, false);
306
+ }
307
+ for (const [check, entry] of [...byCheck].sort(([a], [b]) => a.localeCompare(b))) {
308
+ const suppressed = entry.suppressed > 0 ? `, ${entry.suppressed} of them suppressed by lintIgnore` : '';
309
+ info(`NOTICE [${check}]: ${entry.count} across ${entry.patches.size} patch(es)${suppressed} (see --report)`);
310
+ }
311
+ }
275
312
  /**
276
313
  * Resolves the `--patches <name…>` subset filter against the manifest,
277
314
  * matching each requested name tolerantly (exact filename, filename ±
@@ -394,7 +431,8 @@ async function lintPerPatchInner(projectRoot, paths, options = {}) {
394
431
  engineHeadSha,
395
432
  checkJs,
396
433
  });
397
- const { linted, skipped, cacheDirty, reusedCacheEntries, suppressed } = await applyPerPatchResults(subset, results, issues, checkJs, cache);
434
+ const heldNotices = options.notices === 'summary' ? [] : undefined;
435
+ const { linted, skipped, cacheDirty, reusedCacheEntries, suppressed } = await applyPerPatchResults(subset, results, issues, checkJs, cache, heldNotices);
398
436
  for (const issue of lintPatchQueue(ctx)) {
399
437
  if (isSubset && !subsetTouchedFiles.has(issue.file))
400
438
  continue;
@@ -411,5 +449,5 @@ async function lintPerPatchInner(projectRoot, paths, options = {}) {
411
449
  if (options.report !== undefined) {
412
450
  await writePerPatchLintReport(options.report, subset, results);
413
451
  }
414
- reportPerPatchOutcome(issues, linted, skipped, options);
452
+ reportPerPatchOutcome(issues, linted, skipped, options, heldNotices);
415
453
  }
@@ -52,6 +52,9 @@ export interface PerRunCheckJs {
52
52
  /** True when this file kind participates in the run's checkJs surface
53
53
  * (`.sys.mjs`, plus test scripts when `checkJsTestFiles` is on). */
54
54
  isRelevant: (file: string) => boolean;
55
+ /** The program's roots under `rootScopePatches`, or undefined for the
56
+ * whole queue. A file outside it is resolvable but never checked. */
57
+ rootScope: ReadonlySet<string> | undefined;
55
58
  }
56
59
  /**
57
60
  * Builds the per-run checkJs program controller when `patchLint.checkJs` is
@@ -97,5 +97,6 @@ export function buildPerRunCheckJs(projectRoot, paths, config, ctx, rootScopePat
97
97
  return issues;
98
98
  },
99
99
  isRelevant: (file) => file.endsWith('.sys.mjs') || (testFilesEnabled && isTestScriptFile(file)),
100
+ rootScope: scopedRoots,
100
101
  };
101
102
  }
@@ -236,6 +236,9 @@ function validateLintFlags(options) {
236
236
  if (options.report !== undefined && !options.perPatch) {
237
237
  throw new InvalidArgumentError('--report requires --per-patch.', '--report');
238
238
  }
239
+ if (options.notices !== undefined && !options.perPatch) {
240
+ throw new InvalidArgumentError('--notices requires --per-patch.', '--notices');
241
+ }
239
242
  }
240
243
  /**
241
244
  * Aggregate-mode patch-size softening: when the linted diff is every
@@ -499,6 +502,7 @@ export function registerLint(program, { getProjectRoot, withErrorHandling }) {
499
502
  .option('--max-warnings <n>', 'Fail when lint reports more than <n> warning(s); use 0 for warning-clean release gates.')
500
503
  .option('--no-cache', 'Bypass per-patch lint result cache reads and writes.')
501
504
  .option('--report <path>', 'With --per-patch, write a machine-readable JSON report (per-patch size metrics, tier, thresholds, issues, and lintIgnore-suppressed issues) to <path>.')
505
+ .option('--notices <mode>', 'With --per-patch: "full" (default) prints every NOTICE line; "summary" prints one line per check with its count and patch count. The full list stays in --report. Errors and warnings always print in full.')
502
506
  .action(withErrorHandling(async (paths, options) => {
503
507
  const lintOptions = {};
504
508
  if (options.since !== undefined) {
@@ -526,6 +530,12 @@ export function registerLint(program, { getProjectRoot, withErrorHandling }) {
526
530
  if (options.report !== undefined) {
527
531
  lintOptions.report = options.report;
528
532
  }
533
+ if (options.notices !== undefined) {
534
+ if (options.notices !== 'summary' && options.notices !== 'full') {
535
+ throw new InvalidArgumentError(`--notices must be "summary" or "full" (got "${options.notices}").`, '--notices');
536
+ }
537
+ lintOptions.notices = options.notices;
538
+ }
529
539
  await lintCommand(getProjectRoot(), paths, lintOptions);
530
540
  }));
531
541
  const lintCache = lint
@@ -44,8 +44,23 @@ export interface ReExportLintContext {
44
44
  /** The pre-existing-queue-errors note is printed once per invocation. */
45
45
  queueBaselineWarned: boolean;
46
46
  }
47
- /** Builds the once-per-invocation lint context. */
48
- export declare function buildReExportLintContext(projectRoot: string, paths: ProjectPaths, config: FireForgeConfig, noCache: boolean): Promise<ReExportLintContext>;
47
+ /**
48
+ * Builds the once-per-invocation lint context.
49
+ *
50
+ * The checkJs program is rooted at the patches being re-exported, exactly as
51
+ * `lint --per-patch --patches` roots it at its subset: the rest of the
52
+ * queue stays resolvable, so cross-patch imports type-check identically,
53
+ * but nothing else is parsed or checked. Unscoped, a one-patch re-export
54
+ * that missed the lint cache type-checked the whole queue (76 s on a
55
+ * 401-patch queue) to report on one patch.
56
+ *
57
+ * @param projectRoot - FireForge project root
58
+ * @param paths - Resolved project paths
59
+ * @param config - Loaded configuration
60
+ * @param noCache - Skip the per-patch lint result cache (`--no-cache`)
61
+ * @param scopePatches - Filenames of the patches this run re-exports
62
+ */
63
+ export declare function buildReExportLintContext(projectRoot: string, paths: ProjectPaths, config: FireForgeConfig, noCache: boolean, scopePatches: ReadonlySet<string>): Promise<ReExportLintContext>;
49
64
  /** Fresh lint payload carried to the post-write cache store. */
50
65
  export interface ReExportLintResult {
51
66
  issues: PatchLintIssue[];
@@ -32,15 +32,39 @@ import { reportPatchLintOutcome } from './export-shared.js';
32
32
  import { buildPerRunCheckJs } from './lint-per-run-checkjs.js';
33
33
  import { projectEntryBody } from './patch/entry-projection.js';
34
34
  import { formatForwardImportRemedy } from './re-export-scan.js';
35
- /** Builds the once-per-invocation lint context. */
36
- export async function buildReExportLintContext(projectRoot, paths, config, noCache) {
35
+ /**
36
+ * Builds the once-per-invocation lint context.
37
+ *
38
+ * The checkJs program is rooted at the patches being re-exported, exactly as
39
+ * `lint --per-patch --patches` roots it at its subset: the rest of the
40
+ * queue stays resolvable, so cross-patch imports type-check identically,
41
+ * but nothing else is parsed or checked. Unscoped, a one-patch re-export
42
+ * that missed the lint cache type-checked the whole queue (76 s on a
43
+ * 401-patch queue) to report on one patch.
44
+ *
45
+ * @param projectRoot - FireForge project root
46
+ * @param paths - Resolved project paths
47
+ * @param config - Loaded configuration
48
+ * @param noCache - Skip the per-patch lint result cache (`--no-cache`)
49
+ * @param scopePatches - Filenames of the patches this run re-exports
50
+ */
51
+ export async function buildReExportLintContext(projectRoot, paths, config, noCache, scopePatches) {
37
52
  const patchQueueCtx = (await pathExists(paths.patches))
38
53
  ? await buildPatchQueueContext(paths.patches, config)
39
54
  : undefined;
40
55
  const checkJs = patchQueueCtx
41
- ? buildPerRunCheckJs(projectRoot, paths, config, patchQueueCtx)
56
+ ? buildPerRunCheckJs(projectRoot, paths, config, patchQueueCtx, scopePatches)
42
57
  : undefined;
43
58
  const cache = noCache ? undefined : await loadPerPatchLintCache(projectRoot);
59
+ if (patchQueueCtx) {
60
+ // The queue rules and the projection below read every patch, so the
61
+ // wait is named rather than left behind one spinner line.
62
+ info(`Lint scope: queue rules over all ${patchQueueCtx.entries.length} patch(es); ` +
63
+ `checkJs rooted at the ${scopePatches.size} re-exported patch(es); ` +
64
+ (cache === undefined
65
+ ? 'lint cache off (--no-cache).'
66
+ : `lint cache ${Object.keys(cache.entries).length === 0 ? 'empty' : 'loaded'}.`));
67
+ }
44
68
  const engineHeadSha = cache ? await getPerPatchLintCacheHeadSha(paths.engine) : undefined;
45
69
  return {
46
70
  projectRoot,
@@ -123,7 +147,12 @@ export async function lintReExportedPatch(args) {
123
147
  lintOptions.onSuppressed = (suppressed) => suppressedIssues.push(...suppressed);
124
148
  if (checkJs) {
125
149
  const created = checkJsFilesInDiff(diffContent, checkJs);
126
- const allKnown = [...created].every((f) => checkJs.resolutionSet.has(f));
150
+ if (created.size > 0) {
151
+ info(`checkJs: type-checking ${created.size} file(s) of ${patch.filename} (lint cache ${cache ? 'miss' : 'off'}).`);
152
+ }
153
+ // A file the scoped program does not root at would be resolvable but
154
+ // never checked, so it takes the per-patch build too.
155
+ const allKnown = [...created].every((f) => checkJs.resolutionSet.has(f) && (checkJs.rootScope?.has(f) ?? true));
127
156
  if (allKnown) {
128
157
  // Slice this patch's findings out of the one queue-wide program.
129
158
  lintOptions.precomputedCheckJs = await checkJs.sliceFor(created);