@hominis/fireforge 0.47.1 → 0.49.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 (167) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/dist/bin/fireforge.js +5 -0
  3. package/dist/build-info.json +4 -4
  4. package/dist/src/cli.js +16 -28
  5. package/dist/src/commands/build.js +30 -4
  6. package/dist/src/commands/doctor-engine-eslint.d.ts +3 -0
  7. package/dist/src/commands/doctor-engine-eslint.js +12 -0
  8. package/dist/src/commands/doctor-macos-sdk.d.ts +8 -0
  9. package/dist/src/commands/doctor-macos-sdk.js +158 -0
  10. package/dist/src/commands/doctor-source-pin.js +9 -2
  11. package/dist/src/commands/doctor.js +4 -0
  12. package/dist/src/commands/download.js +64 -3
  13. package/dist/src/commands/furnace/apply.js +10 -4
  14. package/dist/src/commands/furnace/create-templates.js +0 -3
  15. package/dist/src/commands/import.js +5 -2
  16. package/dist/src/commands/lint-per-patch.js +44 -6
  17. package/dist/src/commands/lint-per-run-checkjs.d.ts +3 -0
  18. package/dist/src/commands/lint-per-run-checkjs.js +1 -0
  19. package/dist/src/commands/lint.js +14 -0
  20. package/dist/src/commands/manifest.d.ts +1 -2
  21. package/dist/src/commands/patch/delete.js +18 -4
  22. package/dist/src/commands/re-export-lint.d.ts +17 -2
  23. package/dist/src/commands/re-export-lint.js +33 -4
  24. package/dist/src/commands/re-export-options.js +7 -0
  25. package/dist/src/commands/re-export-register.js +1 -0
  26. package/dist/src/commands/re-export-removed.d.ts +4 -0
  27. package/dist/src/commands/re-export-removed.js +49 -0
  28. package/dist/src/commands/re-export-single.js +4 -1
  29. package/dist/src/commands/re-export.js +8 -2
  30. package/dist/src/commands/rebase/confirm.d.ts +12 -1
  31. package/dist/src/commands/rebase/confirm.js +6 -3
  32. package/dist/src/commands/rebase/index.js +77 -13
  33. package/dist/src/commands/run.js +43 -7
  34. package/dist/src/commands/status.js +5 -3
  35. package/dist/src/commands/test-browser-preflight.d.ts +28 -0
  36. package/dist/src/commands/test-browser-preflight.js +181 -0
  37. package/dist/src/commands/test-build-phase.js +30 -7
  38. package/dist/src/commands/test-checkjs.d.ts +3 -0
  39. package/dist/src/commands/test-checkjs.js +27 -0
  40. package/dist/src/commands/test-diagnose.js +3 -0
  41. package/dist/src/commands/test-harness-env.d.ts +5 -6
  42. package/dist/src/commands/test-harness-env.js +27 -7
  43. package/dist/src/commands/test-modes.d.ts +2 -4
  44. package/dist/src/commands/test-register.js +11 -5
  45. package/dist/src/commands/test-run.d.ts +2 -0
  46. package/dist/src/commands/test-run.js +21 -1
  47. package/dist/src/commands/test-verdict.d.ts +2 -2
  48. package/dist/src/commands/test.js +83 -211
  49. package/dist/src/commands/typecheck.d.ts +5 -0
  50. package/dist/src/commands/typecheck.js +7 -1
  51. package/dist/src/core/build-baseline-types.d.ts +12 -14
  52. package/dist/src/core/build-baseline-validate.d.ts +3 -0
  53. package/dist/src/core/build-baseline-validate.js +53 -0
  54. package/dist/src/core/build-baseline.d.ts +4 -3
  55. package/dist/src/core/build-baseline.js +23 -11
  56. package/dist/src/core/build-prepare.d.ts +2 -0
  57. package/dist/src/core/build-prepare.js +67 -35
  58. package/dist/src/core/component-source-guard.d.ts +20 -0
  59. package/dist/src/core/component-source-guard.js +68 -0
  60. package/dist/src/core/config-paths.d.ts +1 -1
  61. package/dist/src/core/config-paths.js +1 -0
  62. package/dist/src/core/config-validate.js +7 -0
  63. package/dist/src/core/coverage-extend.d.ts +5 -34
  64. package/dist/src/core/coverage-extend.js +26 -37
  65. package/dist/src/core/discard-baseline.js +2 -1
  66. package/dist/src/core/engine-write-boundary.d.ts +5 -0
  67. package/dist/src/core/engine-write-boundary.js +41 -0
  68. package/dist/src/core/forward-registrations.d.ts +33 -0
  69. package/dist/src/core/forward-registrations.js +16 -0
  70. package/dist/src/core/furnace-apply-helpers.js +8 -2
  71. package/dist/src/core/furnace-apply.js +7 -0
  72. package/dist/src/core/furnace-validate-accessibility.js +9 -12
  73. package/dist/src/core/furnace-validate.js +0 -43
  74. package/dist/src/core/gecko-dom-types.d.ts +2 -0
  75. package/dist/src/core/gecko-dom-types.js +17 -0
  76. package/dist/src/core/git-file-ops.d.ts +21 -0
  77. package/dist/src/core/git-file-ops.js +75 -0
  78. package/dist/src/core/git-status.d.ts +14 -0
  79. package/dist/src/core/git-status.js +41 -2
  80. package/dist/src/core/lit-template-regions.d.ts +4 -0
  81. package/dist/src/core/lit-template-regions.js +50 -0
  82. package/dist/src/core/mach-build-artifacts.d.ts +6 -6
  83. package/dist/src/core/mach-build-artifacts.js +13 -7
  84. package/dist/src/core/mach-mozconfig.d.ts +2 -1
  85. package/dist/src/core/mach-mozconfig.js +2 -0
  86. package/dist/src/core/mach-resource-shim.d.ts +1 -1
  87. package/dist/src/core/mach-resource-shim.js +2 -0
  88. package/dist/src/core/marionette-port.d.ts +2 -0
  89. package/dist/src/core/marionette-port.js +38 -53
  90. package/dist/src/core/mochitest-server-port.d.ts +1 -1
  91. package/dist/src/core/mochitest-server-port.js +20 -17
  92. package/dist/src/core/objdir-maintenance.d.ts +10 -0
  93. package/dist/src/core/objdir-maintenance.js +151 -0
  94. package/dist/src/core/patch-apply-fuzz.d.ts +12 -0
  95. package/dist/src/core/patch-apply-fuzz.js +27 -6
  96. package/dist/src/core/patch-apply.d.ts +13 -0
  97. package/dist/src/core/patch-apply.js +27 -2
  98. package/dist/src/core/patch-lint-checkjs-cache.d.ts +18 -0
  99. package/dist/src/core/patch-lint-checkjs-cache.js +45 -0
  100. package/dist/src/core/patch-lint-checkjs.d.ts +6 -0
  101. package/dist/src/core/patch-lint-checkjs.js +10 -2
  102. package/dist/src/core/patch-lint-cross.d.ts +7 -1
  103. package/dist/src/core/patch-lint-cross.js +13 -12
  104. package/dist/src/core/patch-lint-forward-registration-extended.d.ts +38 -0
  105. package/dist/src/core/patch-lint-forward-registration-extended.js +240 -0
  106. package/dist/src/core/patch-lint-forward-registration.d.ts +18 -5
  107. package/dist/src/core/patch-lint-forward-registration.js +83 -37
  108. package/dist/src/core/patch-lint.d.ts +11 -0
  109. package/dist/src/core/patch-lint.js +20 -2
  110. package/dist/src/core/patch-transform.d.ts +14 -13
  111. package/dist/src/core/patch-transform.js +29 -17
  112. package/dist/src/core/preflight-wait.d.ts +2 -0
  113. package/dist/src/core/preflight-wait.js +24 -0
  114. package/dist/src/core/process-owner.d.ts +9 -0
  115. package/dist/src/core/process-owner.js +43 -0
  116. package/dist/src/core/rebase-dry-run.d.ts +48 -0
  117. package/dist/src/core/rebase-dry-run.js +105 -0
  118. package/dist/src/core/run-profile.d.ts +50 -0
  119. package/dist/src/core/run-profile.js +131 -0
  120. package/dist/src/core/status-binary.d.ts +1 -1
  121. package/dist/src/core/status-binary.js +3 -1
  122. package/dist/src/core/status-classify.js +29 -2
  123. package/dist/src/core/test-harness-crash.d.ts +1 -1
  124. package/dist/src/core/test-harness-crash.js +5 -5
  125. package/dist/src/core/test-host-state.d.ts +14 -0
  126. package/dist/src/core/test-host-state.js +80 -0
  127. package/dist/src/core/test-profile-files.d.ts +17 -0
  128. package/dist/src/core/test-profile-files.js +75 -0
  129. package/dist/src/core/test-stale-check.d.ts +6 -7
  130. package/dist/src/core/test-stale-check.js +9 -32
  131. package/dist/src/core/token-category.d.ts +7 -26
  132. package/dist/src/core/token-category.js +68 -121
  133. package/dist/src/core/token-inventory.js +12 -7
  134. package/dist/src/core/toolchain-preflight.d.ts +6 -0
  135. package/dist/src/core/toolchain-preflight.js +1 -1
  136. package/dist/src/core/typecheck.d.ts +11 -1
  137. package/dist/src/core/typecheck.js +86 -12
  138. package/dist/src/errors/rebase.d.ts +9 -0
  139. package/dist/src/errors/rebase.js +20 -0
  140. package/dist/src/index.d.ts +3 -1
  141. package/dist/src/index.js +2 -1
  142. package/dist/src/types/commands/options.d.ts +29 -3
  143. package/dist/src/types/config.d.ts +10 -0
  144. package/dist/src/utils/assert.js +2 -7
  145. package/dist/src/utils/child-env.d.ts +0 -3
  146. package/dist/src/utils/child-env.js +0 -3
  147. package/dist/src/utils/concurrency.d.ts +2 -2
  148. package/dist/src/utils/concurrency.js +15 -4
  149. package/dist/src/utils/fs.js +11 -1
  150. package/dist/src/utils/options.d.ts +16 -1
  151. package/dist/src/utils/options.js +5 -3
  152. package/dist/src/utils/paths.js +3 -2
  153. package/dist/src/utils/process-group.d.ts +1 -2
  154. package/dist/src/utils/process-group.js +1 -2
  155. package/dist/src/utils/process-input.d.ts +27 -0
  156. package/dist/src/utils/process-input.js +46 -0
  157. package/dist/src/utils/process.d.ts +5 -2
  158. package/dist/src/utils/process.js +21 -8
  159. package/dist/src/utils/validation.d.ts +11 -0
  160. package/dist/src/utils/validation.js +43 -0
  161. package/docs/configuration.md +82 -0
  162. package/docs/exit-codes.md +1 -1
  163. package/docs/lifecycle-invariants.md +13 -8
  164. package/docs/machine-output.md +36 -19
  165. package/docs/patch-workflow.md +50 -1
  166. package/docs/testing.md +77 -12
  167. package/package.json +10 -5
@@ -0,0 +1,151 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ import { lstat, readdir, readFile, readlink, realpath, rename, rm, symlink, } from 'node:fs/promises';
3
+ import { dirname, join, resolve, sep } from 'node:path';
4
+ import { GeneralError } from '../errors/base.js';
5
+ import { getNodeErrorCode } from '../utils/errors.js';
6
+ import { writeText } from '../utils/fs.js';
7
+ /** Visits objdir entries without following directory symlinks into another tree. */
8
+ async function walk(dir, visit) {
9
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
10
+ const path = join(dir, entry.name);
11
+ if (entry.isDirectory())
12
+ await walk(path, visit);
13
+ else
14
+ await visit(path, entry.isSymbolicLink());
15
+ }
16
+ }
17
+ /** Refuses an objdir that writes through a directory alias into a different checkout. */
18
+ export async function assertLocalObjdir(engineDir, objDir) {
19
+ if (!/^[^/\\:]+$/.test(objDir) || objDir === '.' || objDir === '..') {
20
+ throw new GeneralError(`Objdir ${objDir} must be a local directory name.`);
21
+ }
22
+ const expected = resolve(engineDir, objDir);
23
+ if ((await realpath(expected)) !== resolve(await realpath(engineDir), objDir) ||
24
+ !(await lstat(expected)).isDirectory()) {
25
+ throw new GeneralError(`Objdir ${expected} resolves outside its local directory. Refusing to configure or rewrite it.`);
26
+ }
27
+ }
28
+ function replacePrefix(value, oldRoot, newRoot) {
29
+ return value === oldRoot || value.startsWith(oldRoot + sep)
30
+ ? newRoot + value.slice(oldRoot.length)
31
+ : value;
32
+ }
33
+ function isDependencyMetadata(path) {
34
+ return (/\.(?:pp|d)$/.test(path) ||
35
+ /[/\\]faster[/\\]/.test(path) ||
36
+ /[/\\]config\.statusd[/\\].*[/\\]config\.track$/.test(path) ||
37
+ /[/\\]\.fingerprint[/\\].*\.json$/.test(path));
38
+ }
39
+ /** Repairs copied links and dependency metadata BEFORE configure can consume config.track. */
40
+ export async function relocateObjdirProducts(engineDir, objDir, oldRoot) {
41
+ await assertLocalObjdir(engineDir, objDir);
42
+ const root = resolve(engineDir, objDir);
43
+ const newRoot = resolve(engineDir);
44
+ const canonicalRoot = await realpath(root);
45
+ // Configure writes these groups. Even a local objdir can contain a copied
46
+ // directory alias into a peer, so validate each parent before touching it.
47
+ for (const directory of ['config.statusd', 'config.statusd/substs', 'config.statusd/defines']) {
48
+ const path = join(root, directory);
49
+ try {
50
+ if (!(await lstat(path)).isDirectory() ||
51
+ (await realpath(path)) !== join(canonicalRoot, directory))
52
+ throw new GeneralError(`Partial-config directory ${path} is aliased. Refusing relocation before configure.`);
53
+ }
54
+ catch (error) {
55
+ if (getNodeErrorCode(error) !== 'ENOENT')
56
+ throw error;
57
+ }
58
+ }
59
+ await walk(root, async (path, link) => {
60
+ // Remove tracking symlinks too, without following their write targets.
61
+ if (/[/\\]config\.statusd[/\\].*[/\\]config\.track$/.test(path)) {
62
+ await rm(path);
63
+ return;
64
+ }
65
+ if (link) {
66
+ const target = await readlink(path);
67
+ const absolute = resolve(dirname(path), target);
68
+ const relocated = replacePrefix(absolute, oldRoot, newRoot);
69
+ if (relocated !== absolute) {
70
+ const temp = `${path}.relocate-${process.pid}`;
71
+ await symlink(relocated, temp);
72
+ try {
73
+ await rename(temp, path);
74
+ }
75
+ finally {
76
+ await rm(temp, { force: true });
77
+ }
78
+ }
79
+ }
80
+ else if (isDependencyMetadata(path)) {
81
+ const content = await readFile(path, 'utf8');
82
+ const rewritten = content.replaceAll(oldRoot + sep, newRoot + sep);
83
+ if (content !== rewritten)
84
+ await writeText(path, rewritten);
85
+ }
86
+ });
87
+ }
88
+ /** Finds a link still consulting the previous source tree after relocation/configure. */
89
+ export async function findStaleObjdirLink(engineDir, objDir, oldRoot) {
90
+ let violation;
91
+ await walk(resolve(engineDir, objDir), async (path, link) => {
92
+ if (!link || violation)
93
+ return;
94
+ const target = resolve(dirname(path), await readlink(path));
95
+ const canonical = await realpath(path).catch(() => target);
96
+ if ([target, canonical].some((value) => value === oldRoot || value.startsWith(oldRoot + sep))) {
97
+ violation = `${path} still links to the previous engine ${oldRoot}`;
98
+ }
99
+ });
100
+ return violation;
101
+ }
102
+ /** Prunes only dangling _tests symlinks; never descends through a symlink. */
103
+ export async function pruneDanglingTestLinks(engineDir, objDir) {
104
+ let removed = 0;
105
+ const root = join(engineDir, objDir, '_tests');
106
+ try {
107
+ if (!(await lstat(root)).isDirectory())
108
+ return 0;
109
+ await assertLocalObjdir(engineDir, objDir);
110
+ await walk(root, async (path, link) => {
111
+ if (!link)
112
+ return;
113
+ try {
114
+ await realpath(path);
115
+ }
116
+ catch (error) {
117
+ if (getNodeErrorCode(error) !== 'ENOENT')
118
+ throw error;
119
+ await rm(path);
120
+ removed++;
121
+ }
122
+ });
123
+ }
124
+ catch (error) {
125
+ if (getNodeErrorCode(error) !== 'ENOENT')
126
+ throw error;
127
+ }
128
+ return removed;
129
+ }
130
+ /** Names a partial-config group whose entire persisted value set was nulled. */
131
+ export async function assertHealthyPartialConfig(engineDir, objDir) {
132
+ for (const group of ['substs', 'defines']) {
133
+ const dir = join(engineDir, objDir, 'config.statusd', group);
134
+ let entries;
135
+ try {
136
+ entries = await readdir(dir, { withFileTypes: true });
137
+ }
138
+ catch (error) {
139
+ if (getNodeErrorCode(error) === 'ENOENT')
140
+ continue;
141
+ throw error;
142
+ }
143
+ const files = entries.filter((entry) => entry.isFile() && entry.name !== 'config.track');
144
+ if (files.length === 0)
145
+ continue;
146
+ const values = await Promise.all(files.map((entry) => readFile(join(dir, entry.name), 'utf8')));
147
+ if (values.every((value) => value.trim() === 'null')) {
148
+ throw new GeneralError(`All ${files.length} partial-config ${group} values in ${dir} are null. Repair with "cd engine && python3 ./mach configure", then retry the full build.`);
149
+ }
150
+ }
151
+ }
@@ -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,8 @@ 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 { withGeckoDomTypes } from './gecko-dom-types.js';
34
+ import { withSharedSourceFiles } from './patch-lint-checkjs-cache.js';
33
35
  import { collectUnmanagedCompanions, retargetUnmanagedCompanionHints, } from './patch-lint-unmanaged-companion.js';
34
36
  import { composeShimSource, SHIM_FILENAME, SUPPRESSED_DIAGNOSTIC_CODES, TEST_HARNESS_SHIM, UNDEFINED_IDENTIFIER_CODES, UNDEFINED_IDENTIFIER_HINT, } from './typecheck-shim.js';
35
37
  // ---------------------------------------------------------------------------
@@ -294,7 +296,7 @@ export async function runCheckJsGrouped(input) {
294
296
  // Custom compiler host: reads patch-owned files from disk, returns
295
297
  // the shim for the shim path, and returns empty content for
296
298
  // anything else to avoid reading the full Firefox tree.
297
- const defaultHost = ts.createCompilerHost(options);
299
+ const defaultHost = withGeckoDomTypes(ts, ts.createCompilerHost(options));
298
300
  // Files pulled in via a reviewed `paths` mapping: outside the owned set
299
301
  // but read from disk so the resolver's targets actually type-check.
300
302
  const pathsResolved = new Set();
@@ -340,7 +342,7 @@ export async function runCheckJsGrouped(input) {
340
342
  });
341
343
  },
342
344
  };
343
- const program = ts.createProgram(rootFiles, options, host);
345
+ const program = ts.createProgram(rootFiles, options, withSharedSourceFiles(host, input.sourceFileCache, shimPath, shimSource));
344
346
  const byFile = groupOwnedDiagnostics(ts, [...program.getSemanticDiagnostics(), ...program.getSyntacticDiagnostics()], relByAbsolute, input.mode?.undefinedIdentifiers ?? 'warning');
345
347
  verbose(`checkJs: analyzed ${rootFiles.length - 1} file(s) across ${byFile.size} owning file(s)`);
346
348
  return { byFile, global: [] };
@@ -461,6 +463,11 @@ export async function runCheckJsTestFilesGrouped(repoDir, testFiles, patchLint,
461
463
  const mode = modeFromPatchLintConfig(patchLint);
462
464
  const seenGlobal = new Set();
463
465
  const files = [...testFiles].sort((a, b) => a.localeCompare(b));
466
+ // Every test program below shares options and shim, and mostly the same
467
+ // files: TypeScript's lib declarations, the shim, and the directory's
468
+ // head.js helpers. Parsing them once instead of once per test file took
469
+ // a 467-test queue's pass from 66 s to a fraction of that.
470
+ const sourceFileCache = new Map();
464
471
  const companions = await collectUnmanagedCompanions(repoDir, files, testFiles);
465
472
  for (const file of files) {
466
473
  // Under a rootScope only the scoped files get their own program, but
@@ -487,6 +494,7 @@ export async function runCheckJsTestFilesGrouped(repoDir, testFiles, patchLint,
487
494
  projectRoot,
488
495
  mode,
489
496
  builtinShimSuffix: TEST_HARNESS_SHIM,
497
+ sourceFileCache,
490
498
  });
491
499
  const own = result.byFile.get(file);
492
500
  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[];