@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
@@ -0,0 +1,105 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * `rebase --dry-run`: replays the patch queue against the engine's base
4
+ * commit without touching `engine/`.
5
+ *
6
+ * The replay runs on a private index seeded from HEAD (`GIT_INDEX_FILE`
7
+ * plus `git read-tree HEAD`) and applies each patch there with
8
+ * `git apply --cached`, in queue order, so a later patch sees what the
9
+ * earlier ones did, exactly as the real loop's worktree does. Each patch
10
+ * walks the same context-reduction ladder as {@link applyPatchWithFuzz}.
11
+ * The worktree and the real index are never written. The private index is
12
+ * deleted when the replay ends.
13
+ */
14
+ import { mkdtemp, rm } from 'node:fs/promises';
15
+ import { tmpdir } from 'node:os';
16
+ import { join } from 'node:path';
17
+ import { GitError } from '../errors/git.js';
18
+ import { exec } from '../utils/process.js';
19
+ import { ensureGit } from './git-base.js';
20
+ import { assertValidMaxFuzz, contextReductionSteps } from './patch-apply-fuzz.js';
21
+ /**
22
+ * Parses the file names out of `git apply --check` failure output. Git
23
+ * reports a context mismatch as `error: patch failed: <file>:<line>` and
24
+ * `error: <file>: patch does not apply`, and a missing or pre-existing
25
+ * target as `error: <file>: does not exist in index` /
26
+ * `error: <file>: already exists in index`.
27
+ */
28
+ export function parseApplyCheckFailures(stderr) {
29
+ const files = new Set();
30
+ for (const line of stderr.split('\n')) {
31
+ const failed = /^error: patch failed: (.+):\d+$/.exec(line);
32
+ if (failed?.[1]) {
33
+ files.add(failed[1]);
34
+ continue;
35
+ }
36
+ const named = /^error: (.+?): (?:patch does not apply|does not exist in index|already exists in index)$/.exec(line);
37
+ if (named?.[1])
38
+ files.add(named[1]);
39
+ }
40
+ return [...files];
41
+ }
42
+ /**
43
+ * Replays `patches` onto the engine's HEAD tree in a private index.
44
+ * @param engineDir - Engine repository root
45
+ * @param patches - Patches in queue order
46
+ * @param maxFuzz - Maximum context-reduction steps, as for the real rebase
47
+ * @returns One verdict per patch
48
+ */
49
+ export async function replayQueueIndexOnly(engineDir, patches, maxFuzz) {
50
+ assertValidMaxFuzz(maxFuzz);
51
+ await ensureGit();
52
+ const scratch = await mkdtemp(join(tmpdir(), 'fireforge-rebase-dry-run-'));
53
+ const env = { GIT_INDEX_FILE: join(scratch, 'index') };
54
+ try {
55
+ const seeded = await exec('git', ['read-tree', 'HEAD'], { cwd: engineDir, env });
56
+ if (seeded.exitCode !== 0) {
57
+ throw new GitError(seeded.stderr.trim() || 'Could not seed a private index from HEAD', 'read-tree HEAD');
58
+ }
59
+ const ladder = contextReductionSteps(maxFuzz);
60
+ const verdicts = [];
61
+ let firstRejectIndex;
62
+ for (const patch of patches) {
63
+ let verdict;
64
+ let exactFailure = '';
65
+ for (const [step, contextArgs] of ladder.entries()) {
66
+ const check = await exec('git', ['apply', '--cached', '--check', ...contextArgs, '--', patch.path], { cwd: engineDir, env });
67
+ if (check.exitCode !== 0) {
68
+ if (step === 0)
69
+ exactFailure = check.stderr;
70
+ continue;
71
+ }
72
+ const apply = await exec('git', ['apply', '--cached', ...contextArgs, '--', patch.path], {
73
+ cwd: engineDir,
74
+ env,
75
+ });
76
+ if (apply.exitCode !== 0)
77
+ continue;
78
+ verdict =
79
+ step === 0
80
+ ? { filename: patch.filename, outcome: 'clean' }
81
+ : {
82
+ filename: patch.filename,
83
+ outcome: 'reduced-context',
84
+ step,
85
+ contextArg: contextArgs[0] ?? '',
86
+ };
87
+ break;
88
+ }
89
+ if (verdict === undefined) {
90
+ verdict = {
91
+ filename: patch.filename,
92
+ outcome: 'reject',
93
+ files: parseApplyCheckFailures(exactFailure),
94
+ detail: exactFailure.trim(),
95
+ };
96
+ firstRejectIndex ??= verdicts.length;
97
+ }
98
+ verdicts.push(verdict);
99
+ }
100
+ return { verdicts, firstRejectIndex };
101
+ }
102
+ finally {
103
+ await rm(scratch, { recursive: true, force: true });
104
+ }
105
+ }
@@ -0,0 +1,50 @@
1
+ /** Directory-name prefix of every FireForge-owned temporary run profile. */
2
+ export declare const RUN_PROFILE_PREFIX = "fireforge-run-profile-";
3
+ /** The profile a run launches with, and how to release it afterwards. */
4
+ export interface RunProfile {
5
+ /** Program parameters to append to `mach run` (empty for mach's default). */
6
+ args: string[];
7
+ /** Absolute profile directory, when one was chosen. */
8
+ dir?: string;
9
+ /** Whether FireForge created the directory and will delete it. */
10
+ temporary: boolean;
11
+ /** Removes a temporary profile. A no-op otherwise. Never throws. */
12
+ dispose: () => Promise<void>;
13
+ }
14
+ /** Inputs to {@link resolveRunProfile}. */
15
+ export interface RunProfileRequest {
16
+ /** `--profile <path>`: launch with this directory, left in place. */
17
+ profile?: string | undefined;
18
+ /** `--temp-profile`: launch with a fresh directory removed afterwards. */
19
+ tempProfile?: boolean | undefined;
20
+ /**
21
+ * Whether this is a `--smoke-exit` run. A smoke run with neither flag gets
22
+ * a temporary profile: the step exists to prove what the build does on a
23
+ * clean start, and the developer's profile-default carries whatever state
24
+ * the last session left.
25
+ */
26
+ smoke: boolean;
27
+ /** Base directory for temporary profiles (defaults to the OS tmpdir). */
28
+ tempRoot?: string | undefined;
29
+ }
30
+ /**
31
+ * Removes every temporary run profile this process still holds. Called by
32
+ * the CLI entry point's signal handler after child shutdown. Never throws.
33
+ */
34
+ export declare function removeActiveRunProfiles(): Promise<void>;
35
+ /**
36
+ * Chooses the profile for a run.
37
+ * @param request - The run's profile flags
38
+ * @returns The profile arguments and their release handle
39
+ */
40
+ export declare function resolveRunProfile(request: RunProfileRequest): Promise<RunProfile>;
41
+ /**
42
+ * Removes temporary run profiles whose owning FireForge process is gone.
43
+ * The `finally` that releases a profile covers a clean exit, a failed run
44
+ * and a forwarded SIGINT/SIGTERM, but not a SIGKILL of FireForge itself;
45
+ * this sweep is the backstop for that case. A directory whose marker is
46
+ * missing or unreadable is left alone: nothing proves it is abandoned.
47
+ * @param tempRoot - Base directory to sweep (defaults to the OS tmpdir)
48
+ * @returns The number of directories removed
49
+ */
50
+ export declare function sweepAbandonedRunProfiles(tempRoot?: string): Promise<number>;
@@ -0,0 +1,131 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Profile selection for `fireforge run`.
4
+ *
5
+ * `mach run` on desktop has no `--profile` option of its own: its parser's
6
+ * `--profile/-P` belongs to the Android run parser. On desktop a profile
7
+ * reaches Firefox as a program parameter (`-profile <dir>`), and mach skips
8
+ * creating the objdir's `tmp/profile-default` when it sees one among the
9
+ * params.
10
+ * Mach's `--temp-profile` does mint a fresh directory, but inside the objdir
11
+ * and never removes it, so FireForge owns the temporary profile instead:
12
+ * it creates the directory, seeds the two prefs mach would have written,
13
+ * passes it as `-profile`, and deletes it when the run ends.
14
+ */
15
+ import { mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises';
16
+ import { tmpdir } from 'node:os';
17
+ import { join, resolve } from 'node:path';
18
+ import { InvalidArgumentError } from '../errors/base.js';
19
+ import { isProcessAlive, toError } from '../utils/errors.js';
20
+ import { verbose, warn } from '../utils/logger.js';
21
+ /** Directory-name prefix of every FireForge-owned temporary run profile. */
22
+ export const RUN_PROFILE_PREFIX = 'fireforge-run-profile-';
23
+ /** Marker file naming the pid of the FireForge process that owns the profile. */
24
+ const OWNER_MARKER = '.fireforge-run-owner';
25
+ /**
26
+ * The prefs `mach run` writes into the profiles it creates itself. A profile
27
+ * passed with `-profile` gets none of them, and a first-run default-browser
28
+ * prompt would sit in a headed smoke window.
29
+ */
30
+ const SEEDED_USER_JS = 'user_pref("browser.shell.checkDefaultBrowser", false);\n' +
31
+ 'user_pref("browser.aboutConfig.showWarning", false);\n';
32
+ /**
33
+ * Temporary profiles this process created and has not yet removed. The
34
+ * command's own `finally` loses the race to the entry point's signal
35
+ * handler, whose `process.exit` never lets it run, so the handler removes
36
+ * these itself once the browser tree has shut down.
37
+ */
38
+ const activeTemporaryProfiles = new Set();
39
+ async function removeTemporaryProfile(dir) {
40
+ try {
41
+ await rm(dir, { recursive: true, force: true });
42
+ activeTemporaryProfiles.delete(dir);
43
+ }
44
+ catch (error) {
45
+ warn(`Could not remove temporary run profile ${dir}: ${toError(error).message}`);
46
+ }
47
+ }
48
+ /**
49
+ * Removes every temporary run profile this process still holds. Called by
50
+ * the CLI entry point's signal handler after child shutdown. Never throws.
51
+ */
52
+ export async function removeActiveRunProfiles() {
53
+ await Promise.all([...activeTemporaryProfiles].map((dir) => removeTemporaryProfile(dir)));
54
+ }
55
+ const NO_PROFILE = {
56
+ args: [],
57
+ temporary: false,
58
+ dispose: () => Promise.resolve(),
59
+ };
60
+ /**
61
+ * Chooses the profile for a run.
62
+ * @param request - The run's profile flags
63
+ * @returns The profile arguments and their release handle
64
+ */
65
+ export async function resolveRunProfile(request) {
66
+ if (request.profile !== undefined && request.tempProfile === true) {
67
+ throw new InvalidArgumentError('--profile and --temp-profile are mutually exclusive: name a profile or ask for a fresh one.', 'profile');
68
+ }
69
+ if (request.profile !== undefined) {
70
+ const dir = resolve(request.profile);
71
+ return { args: ['-profile', dir], dir, temporary: false, dispose: NO_PROFILE.dispose };
72
+ }
73
+ if (request.tempProfile !== true && !request.smoke) {
74
+ return NO_PROFILE;
75
+ }
76
+ const dir = await mkdtemp(join(request.tempRoot ?? tmpdir(), RUN_PROFILE_PREFIX));
77
+ activeTemporaryProfiles.add(dir);
78
+ // The owner marker goes in first, so a FireForge killed past this point
79
+ // leaves a directory the next run can prove abandoned.
80
+ await writeFile(join(dir, OWNER_MARKER), `${process.pid}\n`);
81
+ await writeFile(join(dir, 'user.js'), SEEDED_USER_JS);
82
+ verbose(`Temporary run profile: ${dir}`);
83
+ return {
84
+ args: ['-profile', dir],
85
+ dir,
86
+ temporary: true,
87
+ dispose: () => removeTemporaryProfile(dir),
88
+ };
89
+ }
90
+ /**
91
+ * Removes temporary run profiles whose owning FireForge process is gone.
92
+ * The `finally` that releases a profile covers a clean exit, a failed run
93
+ * and a forwarded SIGINT/SIGTERM, but not a SIGKILL of FireForge itself;
94
+ * this sweep is the backstop for that case. A directory whose marker is
95
+ * missing or unreadable is left alone: nothing proves it is abandoned.
96
+ * @param tempRoot - Base directory to sweep (defaults to the OS tmpdir)
97
+ * @returns The number of directories removed
98
+ */
99
+ export async function sweepAbandonedRunProfiles(tempRoot = tmpdir()) {
100
+ let entries;
101
+ try {
102
+ entries = await readdir(tempRoot);
103
+ }
104
+ catch {
105
+ return 0;
106
+ }
107
+ let removed = 0;
108
+ for (const entry of entries) {
109
+ if (!entry.startsWith(RUN_PROFILE_PREFIX))
110
+ continue;
111
+ const dir = join(tempRoot, entry);
112
+ let pid;
113
+ try {
114
+ pid = Number.parseInt((await readFile(join(dir, OWNER_MARKER), 'utf8')).trim(), 10);
115
+ }
116
+ catch {
117
+ continue;
118
+ }
119
+ if (!Number.isInteger(pid) || pid <= 0 || pid === process.pid || isProcessAlive(pid)) {
120
+ continue;
121
+ }
122
+ try {
123
+ await rm(dir, { recursive: true, force: true });
124
+ removed += 1;
125
+ }
126
+ catch (error) {
127
+ verbose(`Could not remove abandoned run profile ${dir}: ${toError(error).message}`);
128
+ }
129
+ }
130
+ return removed;
131
+ }
@@ -26,7 +26,7 @@
26
26
  */
27
27
  import type { PatchedContentContext } from './patch-apply.js';
28
28
  /** Batched patch lookup, injected by callers that classify many files. */
29
- export type AffectingPatchLookup = Pick<PatchedContentContext, 'getAffectingPatches' | 'readPatchBody'>;
29
+ export type AffectingPatchLookup = Pick<PatchedContentContext, 'getAffectingPatches' | 'readPatchBody'> & Partial<Pick<PatchedContentContext, 'liveBlobHash'>>;
30
30
  /**
31
31
  * Classification result for a binary comparison. Structurally assignable
32
32
  * to `ClassifiedFile` without importing it (which would create a cycle
@@ -111,7 +111,9 @@ export async function classifyBinaryOwnedFile(args) {
111
111
  }
112
112
  if (section.hasBinaryDelta && section.indexNewHash !== undefined) {
113
113
  const fullPath = join(engineDir, entry.file);
114
- const live = (await hashObjectBatch(engineDir, [fullPath])).get(fullPath);
114
+ const live = args.lookup?.liveBlobHash !== undefined
115
+ ? await args.lookup.liveBlobHash(fullPath)
116
+ : (await hashObjectBatch(engineDir, [fullPath])).get(fullPath);
115
117
  if (live !== undefined) {
116
118
  // Prefix-compare: recorded hashes may be abbreviated (≥7 chars).
117
119
  const matches = live.startsWith(section.indexNewHash) || section.indexNewHash.startsWith(live);
@@ -13,6 +13,7 @@ import { readText } from '../utils/fs.js';
13
13
  import { verbose } from '../utils/logger.js';
14
14
  import { normalizePathSlashes } from '../utils/paths.js';
15
15
  import { isBrandingManagedPath } from './branding.js';
16
+ import { isBinaryFile } from './git-file-ops.js';
16
17
  import { createPatchedContentContext } from './patch-apply.js';
17
18
  import { classifyBinaryOwnedFile } from './status-binary.js';
18
19
  /**
@@ -141,6 +142,7 @@ export async function classifyFiles(files, engineDir, patchesDir, binaryName, fu
141
142
  // expected-vs-actual content comparison then routes them into `unmanaged`
142
143
  // when the content matches neither owner's expectation.
143
144
  const patchClaims = buildPatchClaims(ctx.manifestPatches);
145
+ await prefetchSingleOwnerContent(files, engineDir, patchClaims, ctx);
144
146
  const deps = {
145
147
  engineDir,
146
148
  patchesDir,
@@ -149,12 +151,37 @@ export async function classifyFiles(files, engineDir, patchesDir, binaryName, fu
149
151
  patchClaims,
150
152
  ctx,
151
153
  };
152
- // Bounded pool over per-file classification (each single-owner file
153
- // spawns git). Order preserved by the mapper. Per-file failures settle
154
+ // Bounded pool over per-file classification. Order preserved by the mapper. Per-file failures settle
154
155
  // inside classifySingleOwnerFile's catch, so one bad file never rejects
155
156
  // the batch.
156
157
  return mapWithConcurrency(files, CLASSIFY_CONCURRENCY, (entry) => classifyEntry(entry, deps));
157
158
  }
159
+ /**
160
+ * Fetches, in two git processes, what the per-file comparison would
161
+ * otherwise spawn git for file by file: the HEAD content of every
162
+ * single-owner path (one `cat-file --batch`) and the live blob hash of every
163
+ * one that is binary on disk (one `hash-object` per ARG_MAX chunk). On a
164
+ * 1,600-file Firefox scan that was ~1,550 `git show` and `hash-object`
165
+ * spawns. Fetching for a path the classifier ends up routing elsewhere
166
+ * (a branding path, say) costs a few bytes, never a wrong answer.
167
+ */
168
+ async function prefetchSingleOwnerContent(files, engineDir, patchClaims, ctx) {
169
+ const singleOwner = files.filter((entry) => patchClaims.get(entry.file)?.length === 1);
170
+ if (singleOwner.length === 0)
171
+ return;
172
+ await ctx.prefetchBase(singleOwner.map((entry) => entry.file));
173
+ const present = singleOwner.filter((entry) => getPrimaryStatusCode(entry.status) !== 'D');
174
+ const binary = await mapWithConcurrency(present, CLASSIFY_CONCURRENCY, async (entry) => {
175
+ try {
176
+ return (await isBinaryFile(engineDir, entry.file)) ? join(engineDir, entry.file) : undefined;
177
+ }
178
+ catch {
179
+ // The per-file path probes again and settles the failure itself.
180
+ return undefined;
181
+ }
182
+ });
183
+ await ctx.prefetchLiveBlobHashes(binary.filter((path) => path !== undefined));
184
+ }
158
185
  /** Concurrency bound for per-file classification (matches import's guard). */
159
186
  const CLASSIFY_CONCURRENCY = 8;
160
187
  /** Classifies one status entry. Extracted so the pool worker stays flat. */
@@ -27,6 +27,12 @@
27
27
  */
28
28
  /** Tools the preflight knows how to probe. */
29
29
  export type ToolchainTool = 'cbindgen' | 'rustc';
30
+ /**
31
+ * Resolves the mach state directory the same way mach itself does:
32
+ * `$MOZBUILD_STATE_PATH`, else `~/.mozbuild` (same resolution as
33
+ * `mach-resource-shim.ts`).
34
+ */
35
+ export declare function mozbuildStateDir(): string;
30
36
  /** One probed candidate binary for a tool. */
31
37
  export interface ToolchainCandidate {
32
38
  /** Binary path (or bare name for the PATH candidate). */
@@ -37,7 +37,7 @@ import { verbose } from '../utils/logger.js';
37
37
  * `$MOZBUILD_STATE_PATH`, else `~/.mozbuild` (same resolution as
38
38
  * `mach-resource-shim.ts`).
39
39
  */
40
- function mozbuildStateDir() {
40
+ export function mozbuildStateDir() {
41
41
  return process.env['MOZBUILD_STATE_PATH'] ?? join(homedir(), '.mozbuild');
42
42
  }
43
43
  /** Where each tool's minimum is declared inside the Firefox tree. */
@@ -26,6 +26,16 @@ import type { TypecheckProjectResult } from '../types/typecheck.js';
26
26
  * in CI logs.
27
27
  */
28
28
  export declare const CHECK_JS_DISABLED_NOTICE = "Project sets \"checkJs: false\" \u2014 skipping (override the jsconfig to enable typecheck).";
29
+ /** Run-level options for {@link runTypecheck}. */
30
+ export interface RunTypecheckOptions {
31
+ /**
32
+ * Directory for the per-project incremental build info. Defaults to
33
+ * `<projectRoot>/.fireforge/typecheck`.
34
+ */
35
+ cacheDir?: string;
36
+ /** Build every project from scratch and write no build info. */
37
+ noCache?: boolean;
38
+ }
29
39
  /**
30
40
  * Runs `fireforge typecheck` against every project listed in `cfg.projects`.
31
41
  *
@@ -55,7 +65,7 @@ export declare const CHECK_JS_DISABLED_NOTICE = "Project sets \"checkJs: false\"
55
65
  * in declared order. The CLI is responsible for printing the issues
56
66
  * and computing the exit code.
57
67
  */
58
- export declare function runTypecheck(projectRoot: string, cfg: TypecheckConfig): Promise<TypecheckProjectResult[]>;
68
+ export declare function runTypecheck(projectRoot: string, cfg: TypecheckConfig, runOptions?: RunTypecheckOptions): Promise<TypecheckProjectResult[]>;
59
69
  /**
60
70
  * Converts an absolute path to a path relative to the project root,
61
71
  * for display in CLI output. Falls back to the absolute path when
@@ -18,10 +18,13 @@
18
18
  * without installing it, the function returns a clear error pointing
19
19
  * at `npm install typescript`.
20
20
  */
21
- import { dirname, isAbsolute, relative, resolve } from 'node:path';
21
+ import { mkdir, readdir, rm } from 'node:fs/promises';
22
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
22
23
  import { toError } from '../utils/errors.js';
23
24
  import { pathExists } from '../utils/fs.js';
25
+ import { sha256Hex } from '../utils/hash.js';
24
26
  import { verbose } from '../utils/logger.js';
27
+ import { getPackageVersion } from '../utils/package-root.js';
25
28
  import { normalizePathSlashes } from '../utils/paths.js';
26
29
  import { composeShimSource, SHIM_FILENAME, SUPPRESSED_DIAGNOSTIC_CODES, UNDEFINED_IDENTIFIER_CODES, UNDEFINED_IDENTIFIER_HINT, } from './typecheck-shim.js';
27
30
  /**
@@ -60,7 +63,7 @@ export const CHECK_JS_DISABLED_NOTICE = 'Project sets "checkJs: false" — skipp
60
63
  * in declared order. The CLI is responsible for printing the issues
61
64
  * and computing the exit code.
62
65
  */
63
- export async function runTypecheck(projectRoot, cfg) {
66
+ export async function runTypecheck(projectRoot, cfg, runOptions = {}) {
64
67
  // Dynamic import, so typescript stays a dev dependency. Same pattern
65
68
  // as `patch-lint-checkjs.ts`. The empty-projects-array case is
66
69
  // already rejected by config-validate, so we don't gate the import
@@ -133,7 +136,9 @@ export async function runTypecheck(projectRoot, cfg) {
133
136
  });
134
137
  continue;
135
138
  }
136
- results.push(await runTypecheckForProject(ts, projectRoot, projectPath, shimSource, cfg.undefinedIdentifiers ?? 'warning'));
139
+ results.push(await runTypecheckForProject(ts, projectRoot, projectPath, shimSource, cfg.undefinedIdentifiers ?? 'warning', runOptions.noCache === true
140
+ ? undefined
141
+ : (runOptions.cacheDir ?? join(projectRoot, '.fireforge', 'typecheck'))));
137
142
  }
138
143
  return results;
139
144
  }
@@ -151,7 +156,7 @@ function resolveProjectExtraShim(cfg, projectPath) {
151
156
  return cfg.extraShim;
152
157
  }
153
158
  /** Runs typecheck for a single jsconfig path, isolating its failures. */
154
- async function runTypecheckForProject(ts, projectRoot, projectPath, shimSource, undefinedIdentifiers = 'warning') {
159
+ async function runTypecheckForProject(ts, projectRoot, projectPath, shimSource, undefinedIdentifiers = 'warning', cacheDir) {
155
160
  const absConfig = resolve(projectRoot, projectPath);
156
161
  if (!(await pathExists(absConfig))) {
157
162
  return {
@@ -223,12 +228,12 @@ async function runTypecheckForProject(ts, projectRoot, projectPath, shimSource,
223
228
  noEmit: true,
224
229
  allowJs: parsed.options.allowJs ?? true,
225
230
  checkJs: parsed.options.checkJs ?? true,
226
- // No incremental sidecar, ever. A user jsconfig under `engine/` that sets
227
- // `incremental` (or names a `tsBuildInfoFile`) would have this command
228
- // drop a `.tsbuildinfo` inside the primary engine checkout: a second
229
- // writer that invalidates a concurrent `fireforge test`'s engine
230
- // fingerprint. This command emits nothing, so the sidecar buys nothing
231
- // either.
231
+ // Never the jsconfig's own incremental sidecar: a user jsconfig under
232
+ // `engine/` that sets `incremental` (or names a `tsBuildInfoFile`) would
233
+ // have this command drop a `.tsbuildinfo` inside the primary engine
234
+ // checkout, a second writer that invalidates a concurrent
235
+ // `fireforge test`'s engine fingerprint. FireForge's own build info
236
+ // lives under `.fireforge/typecheck/` instead (see below).
232
237
  incremental: false,
233
238
  // skipLibCheck is not forced. The user owns it via their jsconfig.
234
239
  };
@@ -267,7 +272,38 @@ async function runTypecheckForProject(ts, projectRoot, projectPath, shimSource,
267
272
  return defaultHost.readFile(fileName);
268
273
  },
269
274
  };
270
- const program = ts.createProgram(rootFiles, options, host);
275
+ // Incremental build info, keyed by everything outside the program's own
276
+ // files that can change a verdict: the composed shim, the TypeScript
277
+ // version, the compiler options, the undefined-identifier gate and the
278
+ // FireForge version. A change to any of them selects a different file,
279
+ // so a stale verdict is never replayed. Files inside the program are
280
+ // tracked by TypeScript's own per-file hashes in the build info.
281
+ const buildInfoFile = cacheDir === undefined
282
+ ? undefined
283
+ : await prepareBuildInfoFile(cacheDir, projectPath, {
284
+ shim: sha256Hex(shimSource),
285
+ typescript: ts.version,
286
+ fireforge: getPackageVersion(),
287
+ undefinedIdentifiers,
288
+ options,
289
+ });
290
+ let program;
291
+ let writeBuildInfo = () => undefined;
292
+ if (buildInfoFile === undefined) {
293
+ program = ts.createProgram(rootFiles, options, host);
294
+ }
295
+ else {
296
+ const builder = ts.createIncrementalProgram({
297
+ rootNames: rootFiles,
298
+ options: { ...options, incremental: true, tsBuildInfoFile: buildInfoFile },
299
+ host: withSourceFileVersions(host),
300
+ });
301
+ program = builder;
302
+ // Under noEmit, emit() writes the build info and nothing else.
303
+ writeBuildInfo = () => {
304
+ builder.emit();
305
+ };
306
+ }
271
307
  // Collect the full diagnostic set. patchLint reads only semantic +
272
308
  // syntactic, which is fine for hygiene but wrong for CI: a misconfigured
273
309
  // `lib: ["es2015"]` or a missing `paths` target should fail
@@ -298,6 +334,7 @@ async function runTypecheckForProject(ts, projectRoot, projectPath, shimSource,
298
334
  }
299
335
  issues.push(diagnosticToIssue(ts, diag, absConfig, projectPath));
300
336
  }
337
+ writeBuildInfo();
301
338
  verbose(`typecheck: ${projectPath} — analyzed ${parsed.fileNames.length} file(s), found ${issues.length} issue(s)`);
302
339
  return {
303
340
  project: projectPath,
@@ -305,6 +342,43 @@ async function runTypecheckForProject(ts, projectRoot, projectPath, shimSource,
305
342
  filesChecked: parsed.fileNames.length,
306
343
  };
307
344
  }
345
+ /**
346
+ * Stamps every source file with a content-hash version, which the
347
+ * incremental builder requires and a plain compiler host never sets.
348
+ * `ts.createIncrementalCompilerHost` does the same internally, but the
349
+ * shim here is served by a custom host rather than read from disk.
350
+ */
351
+ function withSourceFileVersions(host) {
352
+ const getSourceFile = host.getSourceFile.bind(host);
353
+ return {
354
+ ...host,
355
+ getSourceFile(...args) {
356
+ const file = getSourceFile(...args);
357
+ if (file !== undefined) {
358
+ file.version = sha256Hex(file.text);
359
+ }
360
+ return file;
361
+ },
362
+ };
363
+ }
364
+ /**
365
+ * Resolves the build-info path for one project and key, creating the cache
366
+ * directory and removing the project's build info for any other key: a
367
+ * shim or TypeScript change leaves the old file unreachable anyway.
368
+ * @returns Absolute build-info path
369
+ */
370
+ async function prepareBuildInfoFile(cacheDir, projectPath, keyInputs) {
371
+ const slug = projectPath.replace(/[^A-Za-z0-9._-]+/g, '_');
372
+ const key = sha256Hex(JSON.stringify(keyInputs)).slice(0, 16);
373
+ const fileName = `${slug}-${key}.tsbuildinfo`;
374
+ await mkdir(cacheDir, { recursive: true });
375
+ for (const entry of await readdir(cacheDir)) {
376
+ if (entry !== fileName && entry.startsWith(`${slug}-`) && entry.endsWith('.tsbuildinfo')) {
377
+ await rm(join(cacheDir, entry), { force: true });
378
+ }
379
+ }
380
+ return join(cacheDir, fileName);
381
+ }
308
382
  /**
309
383
  * Converts a TS Diagnostic to a TypecheckIssue. The `fallbackFile`
310
384
  * is used when the diagnostic carries no source file (typical for
@@ -31,3 +31,12 @@ export declare class NoRebaseSessionError extends RebaseError {
31
31
  export declare class CorruptRebaseSessionError extends RebaseError {
32
32
  constructor(sessionPath: string, reason: string);
33
33
  }
34
+ /**
35
+ * Thrown by `rebase --dry-run` when the replay rejects at least one patch.
36
+ * A dry run that exited 0 regardless used to read as "the rebase is ready".
37
+ */
38
+ export declare class RebaseDryRunRejectError extends RebaseError {
39
+ readonly rejected: readonly string[];
40
+ constructor(rejected: readonly string[]);
41
+ get userMessage(): string;
42
+ }
@@ -49,3 +49,23 @@ export class CorruptRebaseSessionError extends RebaseError {
49
49
  'or delete the file to start over.');
50
50
  }
51
51
  }
52
+ /**
53
+ * Thrown by `rebase --dry-run` when the replay rejects at least one patch.
54
+ * A dry run that exited 0 regardless used to read as "the rebase is ready".
55
+ */
56
+ export class RebaseDryRunRejectError extends RebaseError {
57
+ rejected;
58
+ constructor(rejected) {
59
+ super(`Dry run: ${rejected.length} patch(es) would not apply: ${rejected.join(', ')}. ` +
60
+ 'engine/ was not modified.');
61
+ this.rejected = rejected;
62
+ }
63
+ get userMessage() {
64
+ return (`Rebase Error: ${this.message}` +
65
+ remedies([
66
+ 'The per-patch lines above name the files that did not apply',
67
+ 'Run "fireforge rebase" to start the real rebase; it stops at the first reject for manual resolution',
68
+ 'Later rejects may cascade from an earlier one; resolve in queue order',
69
+ ]));
70
+ }
71
+ }
@@ -16,12 +16,14 @@
16
16
  * @packageDocumentation
17
17
  */
18
18
  export { loadConfig, validateConfig } from './core/config.js';
19
+ export type { FindForwardRegistrationsOptions, ForwardRegistration, ForwardRegistrationKind, ForwardRegistrationScope, } from './core/forward-registrations.js';
20
+ export { findForwardRegistrations } from './core/forward-registrations.js';
19
21
  export type { ApplyAllComponentsResult } from './core/furnace-apply.js';
20
22
  export { applyAllComponents } from './core/furnace-apply.js';
21
23
  export { ensureFurnaceConfig, loadFurnaceConfig, loadFurnaceState, saveFurnaceState, validateFurnaceConfig, } from './core/furnace-config.js';
22
24
  export { validateAllComponents, validateComponent } from './core/furnace-validate.js';
23
25
  export type { PatchSizeTierDecision } from './core/patch-lint.js';
24
- export { countNonBinaryDiffLines, getPatchSizeThresholds, resolvePatchSizeTier, } from './core/patch-lint.js';
26
+ export { countNonBinaryDiffLines, filesMeasuredByFileTooLarge, getPatchSizeThresholds, resolvePatchSizeTier, } from './core/patch-lint.js';
25
27
  export type { AddTokenOptions, AddTokenResult, TokenMode } from './core/token-manager.js';
26
28
  export { addToken, getTokensCssPath, validateTokenAdd } from './core/token-manager.js';
27
29
  export { CancellationError, CommandError, FireForgeError, GeneralError, InvalidArgumentError, ResolutionError, } from './errors/base.js';
package/dist/src/index.js CHANGED
@@ -17,10 +17,11 @@
17
17
  * @packageDocumentation
18
18
  */
19
19
  export { loadConfig, validateConfig } from './core/config.js';
20
+ export { findForwardRegistrations } from './core/forward-registrations.js';
20
21
  export { applyAllComponents } from './core/furnace-apply.js';
21
22
  export { ensureFurnaceConfig, loadFurnaceConfig, loadFurnaceState, saveFurnaceState, validateFurnaceConfig, } from './core/furnace-config.js';
22
23
  export { validateAllComponents, validateComponent } from './core/furnace-validate.js';
23
- export { countNonBinaryDiffLines, getPatchSizeThresholds, resolvePatchSizeTier, } from './core/patch-lint.js';
24
+ export { countNonBinaryDiffLines, filesMeasuredByFileTooLarge, getPatchSizeThresholds, resolvePatchSizeTier, } from './core/patch-lint.js';
24
25
  export { addToken, getTokensCssPath, validateTokenAdd } from './core/token-manager.js';
25
26
  export { CancellationError, CommandError, FireForgeError, GeneralError, InvalidArgumentError, ResolutionError, } from './errors/base.js';
26
27
  export { ExitCode } from './errors/codes.js';