@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
@@ -77,6 +77,27 @@ export declare function hashObjectBatch(repoDir: string, fullPaths: string[]): P
77
77
  * @returns File content or null if file doesn't exist at that ref
78
78
  */
79
79
  export declare function getFileContentAtRef(repoDir: string, filePath: string, ref?: string): Promise<string | null>;
80
+ /**
81
+ * Batched equivalent of {@link getFileContentAtRef}: reads every path at
82
+ * `ref` through one `git cat-file --batch` process instead of one
83
+ * `git show` per file. A Firefox-sized ownership scan paid 1,493 of those,
84
+ * about 18 ms each and almost all of it spawn overhead.
85
+ *
86
+ * The first request line is `ref` itself, so an unresolvable ref throws a
87
+ * {@link GitError} exactly as the per-file helper does, rather than being
88
+ * read as "every path missing". A path reported `missing` maps to `null`,
89
+ * as the per-file helper returns for a path absent at that ref. Anything
90
+ * the batch cannot answer as a plain blob (a path containing a newline,
91
+ * which the line protocol cannot carry, or an object that is not a blob)
92
+ * is fetched through {@link getFileContentAtRef}, so the result for every
93
+ * path is what that helper returns.
94
+ *
95
+ * @param repoDir - Repository directory
96
+ * @param filePaths - Paths relative to the repository root
97
+ * @param ref - Git ref to read from. Defaults to HEAD.
98
+ * @returns Content (or `null` when absent at `ref`) for every input path
99
+ */
100
+ export declare function getFilesContentAtRef(repoDir: string, filePaths: readonly string[], ref?: string): Promise<Map<string, string | null>>;
80
101
  /**
81
102
  * Checks if a file is binary by looking for NUL bytes in the first 8KB.
82
103
  * Uses the same heuristic as git.
@@ -4,6 +4,7 @@ import { join } from 'node:path';
4
4
  import { GitError } from '../errors/git.js';
5
5
  import { removeFile } from '../utils/fs.js';
6
6
  import { exec } from '../utils/process.js';
7
+ import { execWithInput } from '../utils/process-input.js';
7
8
  import { chunkPathspecs, ensureGit, git } from './git-base.js';
8
9
  /**
9
10
  * Restores a tracked path from HEAD, including staged changes.
@@ -175,6 +176,80 @@ export async function getFileContentAtRef(repoDir, filePath, ref = 'HEAD') {
175
176
  }
176
177
  return result.stdout;
177
178
  }
179
+ /**
180
+ * Batched equivalent of {@link getFileContentAtRef}: reads every path at
181
+ * `ref` through one `git cat-file --batch` process instead of one
182
+ * `git show` per file. A Firefox-sized ownership scan paid 1,493 of those,
183
+ * about 18 ms each and almost all of it spawn overhead.
184
+ *
185
+ * The first request line is `ref` itself, so an unresolvable ref throws a
186
+ * {@link GitError} exactly as the per-file helper does, rather than being
187
+ * read as "every path missing". A path reported `missing` maps to `null`,
188
+ * as the per-file helper returns for a path absent at that ref. Anything
189
+ * the batch cannot answer as a plain blob (a path containing a newline,
190
+ * which the line protocol cannot carry, or an object that is not a blob)
191
+ * is fetched through {@link getFileContentAtRef}, so the result for every
192
+ * path is what that helper returns.
193
+ *
194
+ * @param repoDir - Repository directory
195
+ * @param filePaths - Paths relative to the repository root
196
+ * @param ref - Git ref to read from. Defaults to HEAD.
197
+ * @returns Content (or `null` when absent at `ref`) for every input path
198
+ */
199
+ export async function getFilesContentAtRef(repoDir, filePaths, ref = 'HEAD') {
200
+ const contents = new Map();
201
+ const unique = [...new Set(filePaths)];
202
+ const batchable = unique.filter((path) => !path.includes('\n'));
203
+ const fallback = unique.filter((path) => path.includes('\n'));
204
+ if (batchable.length > 0) {
205
+ await ensureGit();
206
+ const requests = [ref, ...batchable.map((path) => `${ref}:${path}`)];
207
+ const result = await execWithInput('git', ['cat-file', '--batch'], requests.join('\n') + '\n', {
208
+ cwd: repoDir,
209
+ });
210
+ if (result.exitCode !== 0) {
211
+ throw new GitError(result.stderr.trim() || 'git cat-file --batch failed', 'cat-file --batch');
212
+ }
213
+ const out = result.stdout;
214
+ let offset = 0;
215
+ for (const [index, request] of requests.entries()) {
216
+ const newline = out.indexOf(0x0a, offset);
217
+ if (newline === -1) {
218
+ throw new GitError(`Truncated cat-file --batch output at ${request}`, 'cat-file --batch');
219
+ }
220
+ const header = out.toString('utf8', offset, newline);
221
+ offset = newline + 1;
222
+ const path = index === 0 ? undefined : batchable[index - 1];
223
+ if (header.endsWith(' missing') || header.endsWith(' ambiguous')) {
224
+ if (path === undefined) {
225
+ throw new GitError(`Cannot resolve ${ref}: ${header}`, `cat-file --batch ${ref}`);
226
+ }
227
+ if (header.endsWith(' missing'))
228
+ contents.set(path, null);
229
+ else
230
+ fallback.push(path);
231
+ continue;
232
+ }
233
+ const match = /^[0-9a-f]+ (\S+) (\d+)$/.exec(header);
234
+ if (!match) {
235
+ throw new GitError(`Unexpected cat-file --batch header: ${header}`, 'cat-file --batch');
236
+ }
237
+ const size = Number(match[2]);
238
+ const body = out.subarray(offset, offset + size);
239
+ offset += size + 1;
240
+ if (path === undefined)
241
+ continue;
242
+ if (match[1] === 'blob')
243
+ contents.set(path, body.toString('utf8'));
244
+ else
245
+ fallback.push(path);
246
+ }
247
+ }
248
+ for (const path of fallback) {
249
+ contents.set(path, await getFileContentAtRef(repoDir, path, ref));
250
+ }
251
+ return contents;
252
+ }
178
253
  /**
179
254
  * Checks if a file is binary by looking for NUL bytes in the first 8KB.
180
255
  * Uses the same heuristic as git.
@@ -39,6 +39,20 @@ export declare function resolveMaxUntrackedFilesPerDir(): number;
39
39
  * files
40
40
  */
41
41
  export declare function expandUntrackedDirectoryEntries(repoDir: string, entries: GitStatusEntry[]): Promise<GitStatusEntry[]>;
42
+ /**
43
+ * Lists the untracked files under each collapsed `dir/` status entry with
44
+ * one `git ls-files` per ARG_MAX chunk, instead of one spawn per directory
45
+ * (a Firefox-sized worktree scan paid 73 of them). The result maps every
46
+ * requested directory to its files in git's order, which is exactly what
47
+ * {@link getUntrackedFilesInDir} returns for that directory alone. `-z`
48
+ * keeps non-ASCII names literal: the newline form C-quotes them under
49
+ * git's default `core.quotePath`, and a quoted path names no file.
50
+ *
51
+ * @param repoDir - Repository directory
52
+ * @param dirs - Collapsed untracked directories, each ending in `/`
53
+ * @returns Files per directory (every input key present, possibly empty)
54
+ */
55
+ export declare function listUntrackedFilesInDirs(repoDir: string, dirs: readonly string[]): Promise<Map<string, string[]>>;
42
56
  /**
43
57
  * Gets all untracked files (including files inside untracked directories).
44
58
  * @param repoDir - Repository directory
@@ -1,6 +1,6 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  import { warn } from '../utils/logger.js';
3
- import { git } from './git-base.js';
3
+ import { chunkPathspecs, git } from './git-base.js';
4
4
  /**
5
5
  * True when a porcelain status column carries a rename or copy, i.e. the
6
6
  * record is followed by a second NUL-terminated field holding the original
@@ -95,12 +95,16 @@ export function resolveMaxUntrackedFilesPerDir() {
95
95
  export async function expandUntrackedDirectoryEntries(repoDir, entries) {
96
96
  const expanded = [];
97
97
  const maxPerDir = resolveMaxUntrackedFilesPerDir();
98
+ const collapsed = entries
99
+ .filter((entry) => entry.isUntracked && entry.file.endsWith('/'))
100
+ .map((entry) => entry.file);
101
+ const untrackedByDir = await listUntrackedFilesInDirs(repoDir, collapsed);
98
102
  for (const entry of entries) {
99
103
  if (!entry.isUntracked || !entry.file.endsWith('/')) {
100
104
  expanded.push(entry);
101
105
  continue;
102
106
  }
103
- const individualFiles = await getUntrackedFilesInDir(repoDir, entry.file);
107
+ const individualFiles = untrackedByDir.get(entry.file) ?? [];
104
108
  if (individualFiles.length > maxPerDir) {
105
109
  warn(`Untracked directory ${entry.file} contains ${individualFiles.length} files — only the first ${maxPerDir} are listed. Add a .gitignore entry or clean the directory.`);
106
110
  }
@@ -118,6 +122,41 @@ export async function expandUntrackedDirectoryEntries(repoDir, entries) {
118
122
  }
119
123
  return expanded;
120
124
  }
125
+ /**
126
+ * Lists the untracked files under each collapsed `dir/` status entry with
127
+ * one `git ls-files` per ARG_MAX chunk, instead of one spawn per directory
128
+ * (a Firefox-sized worktree scan paid 73 of them). The result maps every
129
+ * requested directory to its files in git's order, which is exactly what
130
+ * {@link getUntrackedFilesInDir} returns for that directory alone. `-z`
131
+ * keeps non-ASCII names literal: the newline form C-quotes them under
132
+ * git's default `core.quotePath`, and a quoted path names no file.
133
+ *
134
+ * @param repoDir - Repository directory
135
+ * @param dirs - Collapsed untracked directories, each ending in `/`
136
+ * @returns Files per directory (every input key present, possibly empty)
137
+ */
138
+ export async function listUntrackedFilesInDirs(repoDir, dirs) {
139
+ const byDir = new Map(dirs.map((dir) => [dir, []]));
140
+ if (byDir.size === 0)
141
+ return byDir;
142
+ for (const chunk of chunkPathspecs([...byDir.keys()])) {
143
+ const output = await git(['ls-files', '--others', '--exclude-standard', '-z', '--', ...chunk], repoDir);
144
+ for (const file of output.split('\0')) {
145
+ if (file.length === 0)
146
+ continue;
147
+ // Collapsed status directories never nest, so the first matching
148
+ // ancestor is the only one.
149
+ for (let slash = file.indexOf('/'); slash !== -1; slash = file.indexOf('/', slash + 1)) {
150
+ const bucket = byDir.get(file.slice(0, slash + 1));
151
+ if (bucket !== undefined) {
152
+ bucket.push(file);
153
+ break;
154
+ }
155
+ }
156
+ }
157
+ }
158
+ return byDir;
159
+ }
121
160
  /**
122
161
  * Gets all untracked files (including files inside untracked directories).
123
162
  * @param repoDir - Repository directory
@@ -52,5 +52,6 @@ export declare function assertBrandingMozconfigAgreement(engineDir: string, mozc
52
52
  * @param configsDir - Path to the configs directory
53
53
  * @param engineDir - Path to the engine directory
54
54
  * @param config - FireForge configuration
55
+ * @returns Whether engine/mozconfig was rewritten (its content changed)
55
56
  */
56
- export declare function generateMozconfig(configsDir: string, engineDir: string, config: FireForgeConfig): Promise<void>;
57
+ export declare function generateMozconfig(configsDir: string, engineDir: string, config: FireForgeConfig): Promise<boolean>;
@@ -114,6 +114,7 @@ export async function assertBrandingMozconfigAgreement(engineDir, mozconfigPath,
114
114
  * @param configsDir - Path to the configs directory
115
115
  * @param engineDir - Path to the engine directory
116
116
  * @param config - FireForge configuration
117
+ * @returns Whether engine/mozconfig was rewritten (its content changed)
117
118
  */
118
119
  export async function generateMozconfig(configsDir, engineDir, config) {
119
120
  const platform = getPlatform();
@@ -161,4 +162,5 @@ export async function generateMozconfig(configsDir, engineDir, config) {
161
162
  // write, before anything consumes mozconfig) keeps `generateMozconfig`
162
163
  // the single source of truth for both the render and the sanity-check.
163
164
  await assertBrandingMozconfigAgreement(engineDir, outputPath, config);
165
+ return wrote;
164
166
  }
@@ -26,6 +26,18 @@ export interface FuzzyApplyResult {
26
26
  /** List of `.rej` files created (when falling through to --reject). */
27
27
  rejectFiles?: string[];
28
28
  }
29
+ /**
30
+ * The context-reduction ladder shared by the real apply and the dry-run
31
+ * replay: step 0 is an exact apply, step k passes `-C(3-k)`.
32
+ * @param maxFuzz - Maximum context-reduction steps (validated by callers)
33
+ * @returns One `git apply` argument list per step, in order
34
+ */
35
+ export declare function contextReductionSteps(maxFuzz: number): string[][];
36
+ /**
37
+ * Validates a `maxFuzz` value.
38
+ * @throws InvalidArgumentError when `maxFuzz` is not a non-negative integer
39
+ */
40
+ export declare function assertValidMaxFuzz(maxFuzz: number): void;
29
41
  /**
30
42
  * Attempts to apply a patch with escalating context reduction.
31
43
  *
@@ -23,6 +23,29 @@ import { exec } from '../utils/process.js';
23
23
  import { ensureGit } from './git-base.js';
24
24
  /** Git's default diff context width, the ceiling for reduction steps. */
25
25
  const GIT_DEFAULT_CONTEXT = 3;
26
+ /**
27
+ * The context-reduction ladder shared by the real apply and the dry-run
28
+ * replay: step 0 is an exact apply, step k passes `-C(3-k)`.
29
+ * @param maxFuzz - Maximum context-reduction steps (validated by callers)
30
+ * @returns One `git apply` argument list per step, in order
31
+ */
32
+ export function contextReductionSteps(maxFuzz) {
33
+ const maxSteps = Math.min(maxFuzz, GIT_DEFAULT_CONTEXT);
34
+ const steps = [];
35
+ for (let step = 0; step <= maxSteps; step++) {
36
+ steps.push(step > 0 ? [`-C${GIT_DEFAULT_CONTEXT - step}`] : []);
37
+ }
38
+ return steps;
39
+ }
40
+ /**
41
+ * Validates a `maxFuzz` value.
42
+ * @throws InvalidArgumentError when `maxFuzz` is not a non-negative integer
43
+ */
44
+ export function assertValidMaxFuzz(maxFuzz) {
45
+ if (!Number.isInteger(maxFuzz) || maxFuzz < 0) {
46
+ throw new InvalidArgumentError(`maxFuzz must be a non-negative integer, got ${maxFuzz}.`, 'maxFuzz');
47
+ }
48
+ }
26
49
  // ── Implementation ──
27
50
  /**
28
51
  * Attempts to apply a patch with escalating context reduction.
@@ -41,14 +64,12 @@ const GIT_DEFAULT_CONTEXT = 3;
41
64
  * (including the exact-match one) and fall straight through to `--reject`
42
65
  */
43
66
  export async function applyPatchWithFuzz(patchPath, engineDir, maxFuzz = 3) {
44
- if (!Number.isInteger(maxFuzz) || maxFuzz < 0) {
45
- throw new InvalidArgumentError(`maxFuzz must be a non-negative integer, got ${maxFuzz}.`, 'maxFuzz');
46
- }
67
+ assertValidMaxFuzz(maxFuzz);
47
68
  await ensureGit();
48
69
  // Try exact match first, then escalate context reduction.
49
- const maxSteps = Math.min(maxFuzz, GIT_DEFAULT_CONTEXT);
50
- for (let step = 0; step <= maxSteps; step++) {
51
- const contextArgs = step > 0 ? [`-C${GIT_DEFAULT_CONTEXT - step}`] : [];
70
+ const ladder = contextReductionSteps(maxFuzz);
71
+ const maxSteps = ladder.length - 1;
72
+ for (const [step, contextArgs] of ladder.entries()) {
52
73
  const check = await exec('git', ['apply', '--check', ...contextArgs, '--', patchPath], {
53
74
  cwd: engineDir,
54
75
  });
@@ -63,6 +63,19 @@ export interface PatchedContentContext {
63
63
  getAffectingPatches: (filePath: string) => readonly PatchInfo[];
64
64
  /** Patch body text, memoized per patch path across the whole batch. */
65
65
  readPatchBody: (patch: PatchInfo) => Promise<string>;
66
+ /**
67
+ * Reads the HEAD content of every path in one `git cat-file --batch`
68
+ * and holds it for the next {@link computePatched} of each path, which
69
+ * otherwise spawns one `git show` per file. Call it before a loop.
70
+ */
71
+ prefetchBase: (filePaths: readonly string[]) => Promise<void>;
72
+ /**
73
+ * Hashes every live file in one `git hash-object` per ARG_MAX chunk and
74
+ * holds the hashes for {@link liveBlobHash}.
75
+ */
76
+ prefetchLiveBlobHashes: (fullPaths: readonly string[]) => Promise<void>;
77
+ /** The live blob hash of a file, from the prefetch or a single hash-object. */
78
+ liveBlobHash: (fullPath: string) => Promise<string | undefined>;
66
79
  }
67
80
  /**
68
81
  * Builds a batched patched-content context that loads the manifest and
@@ -11,7 +11,7 @@ import { pathExists, readText, writeFileAtomic, writeText } from '../utils/fs.js
11
11
  import { verbose } from '../utils/logger.js';
12
12
  import { isContainedRelativePath } from '../utils/paths.js';
13
13
  import { applyPatchIdempotent, reversePatch } from './git.js';
14
- import { getFileContentAtRef } from './git-file-ops.js';
14
+ import { getFileContentAtRef, getFilesContentAtRef, hashObjectBatch } from './git-file-ops.js';
15
15
  import { discoverPatches } from './patch-files.js';
16
16
  import { loadPatchesManifest } from './patch-manifest.js';
17
17
  import { extractAffectedFiles, extractConflictingFiles, isNewFileInPatch, parseDiffSections, } from './patch-parse.js';
@@ -395,12 +395,37 @@ export async function createPatchedContentContext(patchesDir, engineDir) {
395
395
  return body;
396
396
  };
397
397
  const getAffectingPatches = (filePath) => affectingByFile.get(filePath) ?? [];
398
+ // Prefetched HEAD content, consumed on first use so a large scan does not
399
+ // hold every base file for the life of the context.
400
+ const baseContent = new Map();
401
+ const takeBase = async (filePath) => {
402
+ if (baseContent.has(filePath)) {
403
+ const content = baseContent.get(filePath) ?? null;
404
+ baseContent.delete(filePath);
405
+ return content;
406
+ }
407
+ return getFileContentAtRef(engineDir, filePath);
408
+ };
409
+ const liveHashes = new Map();
398
410
  return {
399
411
  manifestPatches: manifest?.patches ?? [],
400
412
  getAffectingPatches,
401
413
  readPatchBody,
414
+ prefetchBase: async (filePaths) => {
415
+ const wanted = filePaths.filter((file) => !baseContent.has(file));
416
+ for (const [file, content] of await getFilesContentAtRef(engineDir, wanted)) {
417
+ baseContent.set(file, content);
418
+ }
419
+ },
420
+ prefetchLiveBlobHashes: async (fullPaths) => {
421
+ const wanted = fullPaths.filter((path) => !liveHashes.has(path));
422
+ for (const [path, hash] of await hashObjectBatch(engineDir, wanted)) {
423
+ liveHashes.set(path, hash);
424
+ }
425
+ },
426
+ liveBlobHash: async (fullPath) => liveHashes.get(fullPath) ?? (await hashObjectBatch(engineDir, [fullPath])).get(fullPath),
402
427
  computePatched: async (filePath) => {
403
- let content = await getFileContentAtRef(engineDir, filePath);
428
+ let content = await takeBase(filePath);
404
429
  for (const patch of getAffectingPatches(filePath)) {
405
430
  content = applyPatchTextToContent(content, await readPatchBody(patch), filePath);
406
431
  }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Parsed `SourceFile`s reused across programs, keyed by file name and the
3
+ * parse options TypeScript asked for. The language service shares files
4
+ * across programs the same way (its document registry): a `SourceFile` is
5
+ * parsed and bound once, and every program that includes it reuses both.
6
+ * Only valid while the files cannot change and every program uses the same
7
+ * compiler options, which holds within one lint run.
8
+ */
9
+ export type CheckJsSourceFileCache = Map<string, import('typescript').SourceFile>;
10
+ /**
11
+ * Returns `host` with a shared-parse `getSourceFile` when a cache is given,
12
+ * or `host` itself when not.
13
+ * @param host - The program's compiler host
14
+ * @param cache - Shared parse cache, or undefined for an unshared program
15
+ * @param shimPath - Path the host serves the composed shim at
16
+ * @param shimSource - The composed shim text
17
+ */
18
+ export declare function withSharedSourceFiles(host: import('typescript').CompilerHost, cache: CheckJsSourceFileCache | undefined, shimPath: string, shimSource: string): import('typescript').CompilerHost;
@@ -0,0 +1,45 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Parsed-source sharing across checkJs programs.
4
+ *
5
+ * Its own module rather than part of `patch-lint-checkjs.ts` only because
6
+ * that file sits on a 500-line budget.
7
+ */
8
+ import { sha256Hex } from '../utils/hash.js';
9
+ /**
10
+ * Wraps a host's `getSourceFile` so each file is parsed at most once per
11
+ * cache. The shim is keyed by its content as well, since the shim text is
12
+ * composed per call rather than read from disk.
13
+ */
14
+ function cachingGetSourceFile(cache, shimPath, shimSource, getSourceFile) {
15
+ const shimDigest = sha256Hex(shimSource);
16
+ return (fileName, languageVersionOrOptions, onError, shouldCreate) => {
17
+ const parse = typeof languageVersionOrOptions === 'object'
18
+ ? `${languageVersionOrOptions.languageVersion}|${String(languageVersionOrOptions.impliedNodeFormat)}`
19
+ : String(languageVersionOrOptions);
20
+ const key = fileName === shimPath ? `${fileName}\0${parse}\0${shimDigest}` : `${fileName}\0${parse}`;
21
+ const cached = cache.get(key);
22
+ if (cached !== undefined)
23
+ return cached;
24
+ const created = getSourceFile(fileName, languageVersionOrOptions, onError, shouldCreate);
25
+ if (created !== undefined)
26
+ cache.set(key, created);
27
+ return created;
28
+ };
29
+ }
30
+ /**
31
+ * Returns `host` with a shared-parse `getSourceFile` when a cache is given,
32
+ * or `host` itself when not.
33
+ * @param host - The program's compiler host
34
+ * @param cache - Shared parse cache, or undefined for an unshared program
35
+ * @param shimPath - Path the host serves the composed shim at
36
+ * @param shimSource - The composed shim text
37
+ */
38
+ export function withSharedSourceFiles(host, cache, shimPath, shimSource) {
39
+ if (cache === undefined)
40
+ return host;
41
+ return {
42
+ ...host,
43
+ getSourceFile: cachingGetSourceFile(cache, shimPath, shimSource, host.getSourceFile.bind(host)),
44
+ };
45
+ }
@@ -26,6 +26,7 @@
26
26
  */
27
27
  import type { PatchLintIssue } from '../types/commands/index.js';
28
28
  import type { PatchLintCheckJsCompilerOptions, PatchLintConfig, PatchLintSeverityGate } from '../types/config.js';
29
+ import { type CheckJsSourceFileCache } from './patch-lint-checkjs-cache.js';
29
30
  /** How a checkJs run controls reporting and resolution. See module docs. */
30
31
  export interface CheckJsMode {
31
32
  strict: boolean;
@@ -76,6 +77,11 @@ export interface RunCheckJsGroupedInput {
76
77
  * targets.
77
78
  */
78
79
  rootScope?: ReadonlySet<string>;
80
+ /**
81
+ * Parsed source files shared across several programs built with the same
82
+ * compiler options and shim in one run. See {@link runCheckJsTestFilesGrouped}.
83
+ */
84
+ sourceFileCache?: CheckJsSourceFileCache;
79
85
  }
80
86
  /**
81
87
  * Builds the checkJs program once over `resolutionOwned` and returns its
@@ -30,6 +30,7 @@ import { toError } from '../utils/errors.js';
30
30
  import { pathExists } from '../utils/fs.js';
31
31
  import { verbose } from '../utils/logger.js';
32
32
  import { normalizePathSlashes } from '../utils/paths.js';
33
+ import { withSharedSourceFiles } from './patch-lint-checkjs-cache.js';
33
34
  import { collectUnmanagedCompanions, retargetUnmanagedCompanionHints, } from './patch-lint-unmanaged-companion.js';
34
35
  import { composeShimSource, SHIM_FILENAME, SUPPRESSED_DIAGNOSTIC_CODES, TEST_HARNESS_SHIM, UNDEFINED_IDENTIFIER_CODES, UNDEFINED_IDENTIFIER_HINT, } from './typecheck-shim.js';
35
36
  // ---------------------------------------------------------------------------
@@ -340,7 +341,7 @@ export async function runCheckJsGrouped(input) {
340
341
  });
341
342
  },
342
343
  };
343
- const program = ts.createProgram(rootFiles, options, host);
344
+ const program = ts.createProgram(rootFiles, options, withSharedSourceFiles(host, input.sourceFileCache, shimPath, shimSource));
344
345
  const byFile = groupOwnedDiagnostics(ts, [...program.getSemanticDiagnostics(), ...program.getSyntacticDiagnostics()], relByAbsolute, input.mode?.undefinedIdentifiers ?? 'warning');
345
346
  verbose(`checkJs: analyzed ${rootFiles.length - 1} file(s) across ${byFile.size} owning file(s)`);
346
347
  return { byFile, global: [] };
@@ -461,6 +462,11 @@ export async function runCheckJsTestFilesGrouped(repoDir, testFiles, patchLint,
461
462
  const mode = modeFromPatchLintConfig(patchLint);
462
463
  const seenGlobal = new Set();
463
464
  const files = [...testFiles].sort((a, b) => a.localeCompare(b));
465
+ // Every test program below shares options and shim, and mostly the same
466
+ // files: TypeScript's lib declarations, the shim, and the directory's
467
+ // head.js helpers. Parsing them once instead of once per test file took
468
+ // a 467-test queue's pass from 66 s to a fraction of that.
469
+ const sourceFileCache = new Map();
464
470
  const companions = await collectUnmanagedCompanions(repoDir, files, testFiles);
465
471
  for (const file of files) {
466
472
  // Under a rootScope only the scoped files get their own program, but
@@ -487,6 +493,7 @@ export async function runCheckJsTestFilesGrouped(repoDir, testFiles, patchLint,
487
493
  projectRoot,
488
494
  mode,
489
495
  builtinShimSuffix: TEST_HARNESS_SHIM,
496
+ sourceFileCache,
490
497
  });
491
498
  const own = result.byFile.get(file);
492
499
  if (own && own.length > 0) {
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import type { PatchLintIssue, PatchMetadata } from '../types/commands/index.js';
13
13
  import type { FireForgeConfig, PatchPolicyConfig } from '../types/config.js';
14
+ import type { ForwardRegistrationScope } from './patch-lint-forward-registration-extended.js';
14
15
  /**
15
16
  * One patch's contribution to {@link PatchQueueContext}.
16
17
  *
@@ -39,7 +40,7 @@ export interface PatchQueueEntry {
39
40
  * reconstructable `GIT binary patch`, or a payload-less
40
41
  * `Binary files … differ` stub. `newFiles` is text-only by design (a
41
42
  * binary authors no imports, so the import rules gain nothing from it and
42
- * `extractNewFileContent` correctly refuses to decode one), but "does a
43
+ * `extractNewFileContentFromDiff` correctly refuses to decode one), but "does a
43
44
  * later patch create this path" is a question about existence after apply,
44
45
  * not about content, and answering it from `newFiles` made every binary
45
46
  * creation invisible to the staged-registration rules.
@@ -76,6 +77,11 @@ export interface PatchQueueContext {
76
77
  * Absent = current behavior (hint always printed).
77
78
  */
78
79
  patchPolicy?: PatchPolicyConfig;
80
+ /**
81
+ * Which carriers the forward-registration rule checks, from
82
+ * `patchLint.forwardRegistration`. Absent = `support-files` only.
83
+ */
84
+ forwardRegistration?: ForwardRegistrationScope;
79
85
  }
80
86
  /**
81
87
  * Builds a {@link PatchQueueContext} by reading every .patch file in the
@@ -11,7 +11,6 @@
11
11
  * module.
12
12
  */
13
13
  import { basename } from 'node:path';
14
- import { toError } from '../utils/errors.js';
15
14
  import { readText } from '../utils/fs.js';
16
15
  import { verbose } from '../utils/logger.js';
17
16
  import { stripJsComments } from '../utils/regex.js';
@@ -24,7 +23,7 @@ import { lintPatchQueueModuleRegistrations } from './patch-lint-module-registrat
24
23
  import { describeRegistrationFailure, findStagedRegistrationFailure, isLaterOwner, quoteRegistrationLine, } from './patch-lint-staged-registration.js';
25
24
  import { loadPatchesManifest } from './patch-manifest-io.js';
26
25
  import { categoryRangeForOrder, categoryRangeLabel } from './patch-policy.js';
27
- import { extractNewFileContent } from './patch-transform.js';
26
+ import { extractNewFileContentsFromDiff } from './patch-transform.js';
28
27
  /**
29
28
  * Builds a {@link PatchQueueContext} by reading every .patch file in the
30
29
  * directory and extracting new-file content for each creation.
@@ -50,15 +49,11 @@ export async function buildPatchQueueContext(patchesDir, config) {
50
49
  for (const patch of patches) {
51
50
  const diff = await readText(patch.path);
52
51
  const newFilePaths = detectNewFilesInDiff(diff);
53
- const newFiles = new Map();
54
- for (const newFile of newFilePaths) {
55
- try {
56
- const content = await extractNewFileContent(patch.path, newFile);
57
- newFiles.set(newFile, content);
58
- }
59
- catch (error) {
60
- verbose(`Skipping forward-import scan for ${newFile} in ${patch.filename}: ${toError(error).message}`);
61
- }
52
+ // One parse of the body already in hand. Extracting per file re-read
53
+ // the whole patch from disk for every file it creates.
54
+ const { contents: newFiles, refused } = extractNewFileContentsFromDiff(diff, newFilePaths);
55
+ for (const newFile of refused) {
56
+ verbose(`Skipping forward-import scan for ${newFile} in ${patch.filename}: binary section cannot be extracted as text`);
62
57
  }
63
58
  // Added-line content for every file the patch modifies but does not
64
59
  // create. Fed to the forward-import rule so imports introduced into
@@ -87,7 +82,13 @@ export async function buildPatchQueueContext(patchesDir, config) {
87
82
  }
88
83
  // Sort by order so rules can rely on entries being in apply order.
89
84
  entries.sort((a, b) => a.order - b.order || a.filename.localeCompare(b.filename));
90
- return { entries, ...(config?.patchPolicy ? { patchPolicy: config.patchPolicy } : {}) };
85
+ return {
86
+ entries,
87
+ ...(config?.patchPolicy ? { patchPolicy: config.patchPolicy } : {}),
88
+ ...(config?.patchLint?.forwardRegistration !== undefined
89
+ ? { forwardRegistration: config.patchLint.forwardRegistration }
90
+ : {}),
91
+ };
91
92
  }
92
93
  // The creators map (split out of `lintPatchQueueDuplicateCreations` so
93
94
  // `status --ownership` can consume the structured data) lives in
@@ -0,0 +1,38 @@
1
+ import type { PatchQueueForwardRegistrationEntry, PatchQueueView } from './patch-lint-queue-types.js';
2
+ /** Which carriers `forward-registration` checks. */
3
+ export type ForwardRegistrationScope = 'support-files' | 'extended';
4
+ /** The carrier kind a forward registration was found in. */
5
+ export type ForwardRegistrationKind = 'support-files' | 'jar-mn' | 'moz-build' | 'test-manifest' | 'custom-elements';
6
+ /** One undeclared forward registration: a line naming a file a later patch creates. */
7
+ export interface ForwardRegistration {
8
+ /** The patch that adds the registration line. */
9
+ patch: string;
10
+ /** Carrier kind. */
11
+ kind: ForwardRegistrationKind;
12
+ /** Engine-relative path of the carrier file (the manifest, jar.mn, …). */
13
+ file: string;
14
+ /** The added line, whitespace-trimmed, that names the target. */
15
+ line: string;
16
+ /** The token on that line that names the target, as written. */
17
+ token: string;
18
+ /** Engine-relative path of the file the later patch creates. */
19
+ creates: string;
20
+ /** The later patch that creates it. */
21
+ owner: string;
22
+ /** How many distinct added lines in the carrier name the same target. */
23
+ occurrences: number;
24
+ /** Paste-and-run declaration that records the stage as intentional. */
25
+ command: string;
26
+ }
27
+ /** The discharge command, in the exact shape the support-files arm prints. */
28
+ export declare function formatRegistrationDeclaration(patch: string, file: string, line: string, creates: string, owner: string): string;
29
+ /**
30
+ * Finds every undeclared forward registration in the extended carriers:
31
+ * a registration line a patch adds that names a file only a later-ordered
32
+ * patch creates. Creators come from `createdFiles` (the `new file mode`
33
+ * sections), never `filesAffected`.
34
+ *
35
+ * @param ctx - Queue in application order
36
+ * @returns One record per (patch, carrier, created file), first line quoted
37
+ */
38
+ export declare function findExtendedForwardRegistrations(ctx: PatchQueueView<PatchQueueForwardRegistrationEntry>): ForwardRegistration[];