@hominis/fireforge 0.31.0 → 0.32.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 (35) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/src/commands/export-all.js +4 -1
  3. package/dist/src/commands/export-shared.js +10 -1
  4. package/dist/src/commands/export.js +5 -1
  5. package/dist/src/commands/lint-per-patch.d.ts +2 -0
  6. package/dist/src/commands/lint-per-patch.js +206 -44
  7. package/dist/src/commands/lint.js +100 -7
  8. package/dist/src/commands/re-export-files.js +4 -1
  9. package/dist/src/commands/re-export.js +8 -1
  10. package/dist/src/commands/test-run.d.ts +10 -0
  11. package/dist/src/commands/test-run.js +13 -4
  12. package/dist/src/commands/test.js +46 -7
  13. package/dist/src/core/config-validate.js +26 -0
  14. package/dist/src/core/furnace-jsconfig.js +22 -2
  15. package/dist/src/core/git-base.d.ts +15 -0
  16. package/dist/src/core/git-base.js +32 -0
  17. package/dist/src/core/git-diff.d.ts +8 -0
  18. package/dist/src/core/git-diff.js +224 -59
  19. package/dist/src/core/git-file-ops.d.ts +39 -0
  20. package/dist/src/core/git-file-ops.js +82 -1
  21. package/dist/src/core/mach.d.ts +17 -0
  22. package/dist/src/core/mach.js +21 -0
  23. package/dist/src/core/patch-lint-checkjs.d.ts +75 -21
  24. package/dist/src/core/patch-lint-checkjs.js +213 -67
  25. package/dist/src/core/patch-lint-css.d.ts +23 -0
  26. package/dist/src/core/patch-lint-css.js +172 -0
  27. package/dist/src/core/patch-lint.d.ts +34 -11
  28. package/dist/src/core/patch-lint.js +19 -163
  29. package/dist/src/core/test-xpcshell-retry.d.ts +9 -2
  30. package/dist/src/core/test-xpcshell-retry.js +9 -4
  31. package/dist/src/core/typecheck-shim.d.ts +3 -1
  32. package/dist/src/core/typecheck-shim.js +43 -3
  33. package/dist/src/types/commands/options.d.ts +17 -0
  34. package/dist/src/types/config.d.ts +11 -2
  35. package/package.json +1 -1
@@ -7,8 +7,8 @@ import { toError } from '../utils/errors.js';
7
7
  import { pathExists, readText } from '../utils/fs.js';
8
8
  import { verbose } from '../utils/logger.js';
9
9
  import { exec } from '../utils/process.js';
10
- import { ensureGit, git } from './git-base.js';
11
- import { fileExistsInHead, isBinaryFile } from './git-file-ops.js';
10
+ import { chunkPathspecs, ensureGit, git } from './git-base.js';
11
+ import { fileExistsInHead, hashObjectBatch, isBinaryFile, listTrackedInHead, } from './git-file-ops.js';
12
12
  import { getUntrackedFiles, getUntrackedFilesInDir } from './git-status.js';
13
13
  async function execGitWithAllowedExitCodes(repoDir, args, allowedExitCodes = [0]) {
14
14
  const result = await exec('git', args, { cwd: repoDir });
@@ -28,35 +28,33 @@ export async function getFileDiff(repoDir, filePath) {
28
28
  return git(['diff', 'HEAD', '--', filePath], repoDir);
29
29
  }
30
30
  /**
31
- * Generates a unified diff for a new (untracked) file.
32
- * @param repoDir - Repository directory
31
+ * Abbreviates a full git blob hash to the 10-character form used in the
32
+ * synthesized `index 0000000000..<hash>` line, falling back to the all-zero
33
+ * placeholder when no usable hash is available. Mirrors the original per-file
34
+ * truncation exactly.
35
+ * @param fullHash - Full blob hash, or undefined when hashing failed
36
+ * @returns 10-character abbreviated hash or the zero placeholder
37
+ */
38
+ function abbreviateBlobHash(fullHash) {
39
+ if (fullHash !== undefined && fullHash.length >= 10) {
40
+ return fullHash.slice(0, 10);
41
+ }
42
+ return '0000000000';
43
+ }
44
+ /**
45
+ * Pure formatter for a new (untracked) file's unified diff. Extracted from
46
+ * {@link generateNewFileDiff} so the batched cold-run path in
47
+ * {@link getDiffForFilesAgainstHead} and the standalone path share one source of
48
+ * truth — the only thing that differs between them is where `blobHash` comes
49
+ * from (a single batched `git hash-object` vs a per-file one), never the
50
+ * formatting. Preserves the empty-file form, the trailing-newline handling, and
51
+ * the "No newline at end of file" marker byte-for-byte.
33
52
  * @param filePath - Path to the file (relative to repo)
53
+ * @param content - File content
54
+ * @param blobHash - Abbreviated blob hash for the index line
34
55
  * @returns Diff content in unified diff format
35
56
  */
36
- export async function generateNewFileDiff(repoDir, filePath) {
37
- const fullPath = join(repoDir, filePath);
38
- // Defensive check: a directory here means a caller bypassed the
39
- // expansion layers and handed the leaf reader a path it cannot
40
- // read. Surface it with an actionable message naming the offending
41
- // path rather than the raw `EISDIR` that `readText` would throw —
42
- // recurring bug class (see the belt-and-suspenders note in
43
- // `getDiffForFilesAgainstHead`).
44
- const fileStat = await stat(fullPath);
45
- if (fileStat.isDirectory()) {
46
- throw new GitError(`expected a file but found a directory at '${filePath}' — caller must expand directory entries before diffing`, `hash-object ${filePath}`);
47
- }
48
- const content = await readText(fullPath);
49
- // Compute the abbreviated git blob hash for the index line
50
- let blobHash = '0000000000';
51
- try {
52
- const fullHash = (await git(['hash-object', fullPath], repoDir)).trim();
53
- if (fullHash.length >= 10) {
54
- blobHash = fullHash.slice(0, 10);
55
- }
56
- }
57
- catch (error) {
58
- verbose(`git hash-object failed for ${filePath}; falling back to zero blob hash: ${toError(error).message}`);
59
- }
57
+ function buildNewFileDiffBody(filePath, content, blobHash) {
60
58
  // Handle empty files
61
59
  if (content.length === 0) {
62
60
  return [
@@ -91,6 +89,36 @@ export async function generateNewFileDiff(repoDir, filePath) {
91
89
  }
92
90
  return diffLines.join('\n') + '\n';
93
91
  }
92
+ /**
93
+ * Generates a unified diff for a new (untracked) file.
94
+ * @param repoDir - Repository directory
95
+ * @param filePath - Path to the file (relative to repo)
96
+ * @returns Diff content in unified diff format
97
+ */
98
+ export async function generateNewFileDiff(repoDir, filePath) {
99
+ const fullPath = join(repoDir, filePath);
100
+ // Defensive check: a directory here means a caller bypassed the
101
+ // expansion layers and handed the leaf reader a path it cannot
102
+ // read. Surface it with an actionable message naming the offending
103
+ // path rather than the raw `EISDIR` that `readText` would throw —
104
+ // recurring bug class (see the belt-and-suspenders note in
105
+ // `getDiffForFilesAgainstHead`).
106
+ const fileStat = await stat(fullPath);
107
+ if (fileStat.isDirectory()) {
108
+ throw new GitError(`expected a file but found a directory at '${filePath}' — caller must expand directory entries before diffing`, `hash-object ${filePath}`);
109
+ }
110
+ const content = await readText(fullPath);
111
+ // Compute the abbreviated git blob hash for the index line
112
+ let blobHash = '0000000000';
113
+ try {
114
+ const fullHash = (await git(['hash-object', fullPath], repoDir)).trim();
115
+ blobHash = abbreviateBlobHash(fullHash);
116
+ }
117
+ catch (error) {
118
+ verbose(`git hash-object failed for ${filePath}; falling back to zero blob hash: ${toError(error).message}`);
119
+ }
120
+ return buildNewFileDiffBody(filePath, content, blobHash);
121
+ }
94
122
  /**
95
123
  * Generates a patch for a file.
96
124
  * If the file is tracked in HEAD, it generates a standard contextual diff.
@@ -185,10 +213,93 @@ export async function getAllDiff(repoDir) {
185
213
  const combined = allDiffs.join('');
186
214
  return combined.endsWith('\n') ? combined : combined + '\n';
187
215
  }
216
+ /**
217
+ * Splits a combined `git diff` body into one string per file section,
218
+ * preserving exact bytes. A section runs from a column-0 `diff --git ` line up
219
+ * to (but not including) the next such line. Boundary detection is anchored to
220
+ * column 0 because every diff *body* line is prefixed by a space, `+`, `-`,
221
+ * `\`, or `@`, so a context or added line that merely contains the text
222
+ * `diff --git` can never be mistaken for a header. Paths are deliberately NOT
223
+ * parsed out of the header here — see {@link buildTrackedSections}.
224
+ * @param combined - Combined `git diff` stdout
225
+ * @returns File sections in git's emission order
226
+ */
227
+ function splitDiffSections(combined) {
228
+ const marker = 'diff --git ';
229
+ const sections = [];
230
+ let start = -1;
231
+ for (let i = 0; i < combined.length; i++) {
232
+ if ((i === 0 || combined[i - 1] === '\n') && combined.startsWith(marker, i)) {
233
+ if (start !== -1)
234
+ sections.push(combined.slice(start, i));
235
+ start = i;
236
+ }
237
+ }
238
+ if (start !== -1)
239
+ sections.push(combined.slice(start, combined.length));
240
+ return sections;
241
+ }
242
+ /**
243
+ * Runs one `git diff --no-renames HEAD` over the tracked files (chunked under
244
+ * ARG_MAX) and returns a `Map<path, section>` whose sections are byte-identical
245
+ * to the per-file `git diff HEAD -- <file>` they replace.
246
+ *
247
+ * `--no-renames` is load-bearing: a multi-path diff under a user's
248
+ * `diff.renames=true`/`=copies` could otherwise emit a single 2-path rename
249
+ * section (`a/<old> b/<new>`) that a single-path `git diff HEAD -- <file>` can
250
+ * never produce; `--no-renames` re-splits it into the same delete + add bytes
251
+ * the per-file loop emitted.
252
+ *
253
+ * Sections are attributed to paths by POSITION against a companion
254
+ * `git diff --no-renames HEAD -z --name-only` (raw, unquoted, NUL-delimited
255
+ * paths, emitted in the same order as the sections) — never by parsing the
256
+ * `diff --git` header, which is ambiguous or unparseable under `core.quotePath`
257
+ * (non-ASCII paths are C-quoted), paths containing spaces, or
258
+ * `diff.noprefix`/`diff.mnemonicPrefix`. If the section and name counts ever
259
+ * disagree (an unmodeled config), that chunk falls back to per-file
260
+ * {@link getFileDiff} so no file's diff is ever silently dropped.
261
+ * @param repoDir - Repository directory
262
+ * @param trackedFiles - Repo-relative files known to exist in HEAD
263
+ * @returns Map from path to its exact diff section (changed files only)
264
+ */
265
+ async function buildTrackedSections(repoDir, trackedFiles) {
266
+ const sectionsByPath = new Map();
267
+ for (const chunk of chunkPathspecs(trackedFiles)) {
268
+ const combined = await git(['diff', '--no-renames', 'HEAD', '--', ...chunk], repoDir);
269
+ const namesOutput = await git(['diff', '--no-renames', 'HEAD', '-z', '--name-only', '--', ...chunk], repoDir);
270
+ const names = namesOutput.split('\0').filter((name) => name.length > 0);
271
+ const sections = splitDiffSections(combined);
272
+ if (sections.length === names.length) {
273
+ for (let i = 0; i < names.length; i++) {
274
+ // Exact-path keys (raw git bytes, same encoding as the inputs) — never
275
+ // a substring/startsWith match, so `foo.txt` cannot capture
276
+ // `foo.txt.bak`'s section.
277
+ sectionsByPath.set(names[i], sections[i]);
278
+ }
279
+ continue;
280
+ }
281
+ // Counts disagree — recover the exact pre-batch per-file bytes for this
282
+ // chunk rather than risk dropping or mis-keying a section.
283
+ for (const file of chunk) {
284
+ const diff = await getFileDiff(repoDir, file);
285
+ if (diff.trim())
286
+ sectionsByPath.set(file, diff);
287
+ }
288
+ }
289
+ return sectionsByPath;
290
+ }
188
291
  /**
189
292
  * Builds a combined diff against HEAD for the provided files without touching
190
293
  * the real git index. Tracked files use `git diff HEAD`; untracked files use
191
294
  * synthesized new-file diffs.
295
+ *
296
+ * Performance: the work is batched into a handful of `git` invocations
297
+ * (one `ls-tree` to classify, one `diff` over all tracked files, one
298
+ * `hash-object` over all new text files) rather than the ~2 spawns per file the
299
+ * previous per-file loop issued — that fan-out dominated the cold-run cost on a
300
+ * Firefox-sized checkout (~700 serial spawns, ~99s). Binary, directory, and
301
+ * recursion paths stay per-file because they are rare and (for binary) mutate
302
+ * the index.
192
303
  * @param repoDir - Repository directory
193
304
  * @param files - File paths to diff (relative to repo root)
194
305
  * @returns Combined diff content
@@ -215,15 +326,24 @@ export async function getDiffForFilesAgainstHead(repoDir, files) {
215
326
  expandedFiles.push(file);
216
327
  }
217
328
  const uniqueFiles = [...new Set(expandedFiles)].sort();
218
- const diffs = [];
329
+ if (uniqueFiles.length === 0)
330
+ return '';
331
+ // Batch 1: classify tracked-vs-new for the whole set in one `ls-tree` pass,
332
+ // replacing one `fileExistsInHead` spawn per file.
333
+ const tracked = await listTrackedInHead(repoDir, uniqueFiles);
334
+ // Batch 2: one diff over every tracked file, split back to exact per-file
335
+ // sections keyed by path.
336
+ const trackedSections = await buildTrackedSections(repoDir, uniqueFiles.filter((file) => tracked.has(file)));
337
+ // Classify the non-tracked files. Directory and binary entries keep their
338
+ // per-file handling (rare, and binary patches mutate the index so they must
339
+ // stay serial); plain new text files are collected for a single batched
340
+ // `git hash-object`. Results land in `sectionByFile`, keyed by path, to be
341
+ // emitted in sorted order below.
342
+ const sectionByFile = new Map();
343
+ const newTextFiles = [];
219
344
  for (const file of uniqueFiles) {
220
- if (await fileExistsInHead(repoDir, file)) {
221
- const diff = await getFileDiff(repoDir, file);
222
- if (diff.trim()) {
223
- diffs.push(diff);
224
- }
345
+ if (tracked.has(file))
225
346
  continue;
226
- }
227
347
  const fullPath = join(repoDir, file);
228
348
  if (!(await pathExists(fullPath))) {
229
349
  continue;
@@ -244,17 +364,42 @@ export async function getDiffForFilesAgainstHead(repoDir, files) {
244
364
  throw new GitError(`'${file}' is a directory with no untracked content (submodule or gitignored?) — cannot diff as a file`, `ls-files --others -- ${file}`);
245
365
  }
246
366
  const innerDiff = await getDiffForFilesAgainstHead(repoDir, innerFiles);
247
- if (innerDiff.trim()) {
248
- diffs.push(innerDiff);
249
- }
367
+ if (innerDiff.trim())
368
+ sectionByFile.set(file, innerDiff);
250
369
  continue;
251
370
  }
252
- const diff = (await isBinaryFile(repoDir, file))
253
- ? await generateBinaryFilePatch(repoDir, file)
254
- : await generateNewFileDiff(repoDir, file);
255
- if (diff.trim()) {
256
- diffs.push(diff);
371
+ if (await isBinaryFile(repoDir, file)) {
372
+ const diff = await generateBinaryFilePatch(repoDir, file);
373
+ if (diff.trim())
374
+ sectionByFile.set(file, diff);
375
+ continue;
376
+ }
377
+ newTextFiles.push(file);
378
+ }
379
+ // Batch 3: blob hashes for every new text file in one `git hash-object`.
380
+ // A miss (rare: a path became unreadable after the stat above) falls back to
381
+ // the zero hash with the same verbose log the per-file path emitted.
382
+ const blobHashes = await hashObjectBatch(repoDir, newTextFiles.map((file) => join(repoDir, file)));
383
+ for (const file of newTextFiles) {
384
+ const fullPath = join(repoDir, file);
385
+ const fullHash = blobHashes.get(fullPath);
386
+ if (fullHash === undefined) {
387
+ verbose(`git hash-object failed for ${file}; falling back to zero blob hash`);
257
388
  }
389
+ const body = buildNewFileDiffBody(file, await readText(fullPath), abbreviateBlobHash(fullHash));
390
+ if (body.trim())
391
+ sectionByFile.set(file, body);
392
+ }
393
+ // Reassemble in the sorted `uniqueFiles` order — NOT git's section order,
394
+ // which uses git's own collation and diverges from JS `.sort()` for
395
+ // non-ASCII paths. Driving emission off `uniqueFiles` (as the previous
396
+ // per-file loop did) keeps the combined output byte-identical. Do not change
397
+ // this to push sections in git's emission order.
398
+ const diffs = [];
399
+ for (const file of uniqueFiles) {
400
+ const section = trackedSections.get(file) ?? sectionByFile.get(file);
401
+ if (section && section.trim())
402
+ diffs.push(section);
258
403
  }
259
404
  if (diffs.length === 0) {
260
405
  return '';
@@ -275,6 +420,22 @@ export async function getStagedDiffForFiles(repoDir, files) {
275
420
  await ensureGit();
276
421
  return git(['diff', '--cached', 'HEAD', '--', ...files], repoDir);
277
422
  }
423
+ /**
424
+ * Serializes the index-mutating untracked-binary path. The bounded per-patch
425
+ * lint pool can call {@link getDiffForFilesAgainstHead} (and thus this) for
426
+ * several patches at once; two concurrent `git add --intent-to-add` / `git
427
+ * reset` sequences would collide on `.git/index.lock` (a hard failure) or
428
+ * interleave one file's stage with another's unstage. This process-level
429
+ * promise chain runs the staging sequences one at a time. Read-only callers
430
+ * (`git diff --binary HEAD`) do not need it. Binary patches are rare, so the
431
+ * serialization cost is negligible.
432
+ */
433
+ let binaryStagingLock = Promise.resolve();
434
+ function runWithBinaryStagingLock(task) {
435
+ const result = binaryStagingLock.then(task, task);
436
+ binaryStagingLock = result.then(() => undefined, () => undefined);
437
+ return result;
438
+ }
278
439
  /**
279
440
  * Generates a GIT binary patch for a binary file.
280
441
  * For tracked files, uses `git diff --binary HEAD`.
@@ -285,7 +446,7 @@ export async function getStagedDiffForFiles(repoDir, files) {
285
446
  */
286
447
  export async function generateBinaryFilePatch(repoDir, filePath) {
287
448
  await ensureGit();
288
- // Try tracked file diff first
449
+ // Try tracked file diff first (read-only — no index lock needed)
289
450
  const result = await execGitWithAllowedExitCodes(repoDir, [
290
451
  'diff',
291
452
  '--binary',
@@ -295,20 +456,24 @@ export async function generateBinaryFilePatch(repoDir, filePath) {
295
456
  ]);
296
457
  if (result.stdout.trim())
297
458
  return result.stdout;
298
- // For untracked files, stage temporarily to produce a binary diff
299
- try {
300
- await execGitWithAllowedExitCodes(repoDir, ['add', '--intent-to-add', '--', filePath]);
301
- const diffResult = await execGitWithAllowedExitCodes(repoDir, [
302
- 'diff',
303
- '--binary',
304
- '--',
305
- filePath,
306
- ]);
307
- return diffResult.stdout;
308
- }
309
- finally {
310
- // Always unstage, even if diff fails
311
- await execGitWithAllowedExitCodes(repoDir, ['reset', 'HEAD', '--', filePath]);
312
- }
459
+ // For untracked files, stage temporarily to produce a binary diff. The
460
+ // stage/unstage pair mutates the index, so it must not interleave with
461
+ // another concurrent binary patch (see runWithBinaryStagingLock).
462
+ return runWithBinaryStagingLock(async () => {
463
+ try {
464
+ await execGitWithAllowedExitCodes(repoDir, ['add', '--intent-to-add', '--', filePath]);
465
+ const diffResult = await execGitWithAllowedExitCodes(repoDir, [
466
+ 'diff',
467
+ '--binary',
468
+ '--',
469
+ filePath,
470
+ ]);
471
+ return diffResult.stdout;
472
+ }
473
+ finally {
474
+ // Always unstage, even if diff fails
475
+ await execGitWithAllowedExitCodes(repoDir, ['reset', 'HEAD', '--', filePath]);
476
+ }
477
+ });
313
478
  }
314
479
  //# sourceMappingURL=git-diff.js.map
@@ -30,6 +30,45 @@ export declare function unstageFiles(repoDir: string, files: string[]): Promise<
30
30
  * @returns true if file exists in HEAD
31
31
  */
32
32
  export declare function fileExistsInHead(repoDir: string, filePath: string): Promise<boolean>;
33
+ /**
34
+ * Batched equivalent of {@link fileExistsInHead}: returns the subset of `files`
35
+ * that are tracked in HEAD, using a single `git ls-tree` per ARG_MAX chunk
36
+ * instead of one spawn per file. This is the cold-run hot path — a Firefox-sized
37
+ * checkout has hundreds of affected files, and the old per-file fan-out spent
38
+ * ~99s in serial `git ls-tree`/`git diff` spawns.
39
+ *
40
+ * `-r` lists nested blobs by full repo-relative path; `--name-only -z` makes the
41
+ * output a trivial NUL-split with no quoting to undo. Membership in the returned
42
+ * Set is exactly `await fileExistsInHead(repoDir, file)` for any non-directory
43
+ * `file`. Throws (via {@link git}) when HEAD itself is unresolvable, matching the
44
+ * per-file helper's failure mode.
45
+ * @param repoDir - Repository directory
46
+ * @param files - Repo-relative paths to classify
47
+ * @returns The subset of `files` present in HEAD
48
+ */
49
+ export declare function listTrackedInHead(repoDir: string, files: string[]): Promise<Set<string>>;
50
+ /**
51
+ * Batched equivalent of the per-file `git hash-object` in
52
+ * {@link import('./git-diff.js').generateNewFileDiff}: computes the git blob
53
+ * hash for every path in one spawn per ARG_MAX chunk and returns a
54
+ * `Map<fullPath, fullHash>`.
55
+ *
56
+ * Uses {@link import('../utils/process.js').exec} rather than {@link git}
57
+ * (which throws on a non-zero exit) because `git hash-object f1 f2 …` is
58
+ * all-or-nothing: it aborts at the first unreadable path and emits nothing for
59
+ * the rest. To preserve the old per-file contract — where one bad path zeroed
60
+ * only its own index line — a chunk that does not return exactly one hash per
61
+ * input falls back to hashing that chunk's paths individually. A path that is
62
+ * still unhashable is simply left out of the map; the caller applies the
63
+ * `0000000000` zero-hash fallback (and the same verbose log) for any miss, so
64
+ * the blob hash is byte-identical to `git hash-object` (filters/.gitattributes
65
+ * are applied per path) without the risk of in-process hashing, which would
66
+ * diverge under `core.autocrlf`/`text` attributes.
67
+ * @param repoDir - Repository directory
68
+ * @param fullPaths - Absolute file paths to hash
69
+ * @returns Map from each input path to its full blob hash (misses omitted)
70
+ */
71
+ export declare function hashObjectBatch(repoDir: string, fullPaths: string[]): Promise<Map<string, string>>;
33
72
  /**
34
73
  * Gets the content of a file at a specific git ref (HEAD by default).
35
74
  * @param repoDir - Repository directory
@@ -4,7 +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 { ensureGit, git } from './git-base.js';
7
+ import { chunkPathspecs, ensureGit, git } from './git-base.js';
8
8
  /**
9
9
  * Restores a tracked path from HEAD, including staged changes.
10
10
  * @param repoDir - Repository directory
@@ -87,6 +87,87 @@ export async function fileExistsInHead(repoDir, filePath) {
87
87
  await ensureGit();
88
88
  return (await git(['ls-tree', 'HEAD', '--', filePath], repoDir)).trim().length > 0;
89
89
  }
90
+ /**
91
+ * Batched equivalent of {@link fileExistsInHead}: returns the subset of `files`
92
+ * that are tracked in HEAD, using a single `git ls-tree` per ARG_MAX chunk
93
+ * instead of one spawn per file. This is the cold-run hot path — a Firefox-sized
94
+ * checkout has hundreds of affected files, and the old per-file fan-out spent
95
+ * ~99s in serial `git ls-tree`/`git diff` spawns.
96
+ *
97
+ * `-r` lists nested blobs by full repo-relative path; `--name-only -z` makes the
98
+ * output a trivial NUL-split with no quoting to undo. Membership in the returned
99
+ * Set is exactly `await fileExistsInHead(repoDir, file)` for any non-directory
100
+ * `file`. Throws (via {@link git}) when HEAD itself is unresolvable, matching the
101
+ * per-file helper's failure mode.
102
+ * @param repoDir - Repository directory
103
+ * @param files - Repo-relative paths to classify
104
+ * @returns The subset of `files` present in HEAD
105
+ */
106
+ export async function listTrackedInHead(repoDir, files) {
107
+ const tracked = new Set();
108
+ if (files.length === 0)
109
+ return tracked;
110
+ await ensureGit();
111
+ const wanted = new Set(files);
112
+ for (const chunk of chunkPathspecs(files)) {
113
+ const output = await git(['ls-tree', '-r', 'HEAD', '--name-only', '-z', '--', ...chunk], repoDir);
114
+ for (const name of output.split('\0')) {
115
+ // `ls-tree -r` can surface entries beyond the literal inputs only when an
116
+ // input is itself a directory in HEAD; intersect with `wanted` so the
117
+ // result is always a subset of the requested files, never a superset.
118
+ if (name.length > 0 && wanted.has(name))
119
+ tracked.add(name);
120
+ }
121
+ }
122
+ return tracked;
123
+ }
124
+ /**
125
+ * Batched equivalent of the per-file `git hash-object` in
126
+ * {@link import('./git-diff.js').generateNewFileDiff}: computes the git blob
127
+ * hash for every path in one spawn per ARG_MAX chunk and returns a
128
+ * `Map<fullPath, fullHash>`.
129
+ *
130
+ * Uses {@link import('../utils/process.js').exec} rather than {@link git}
131
+ * (which throws on a non-zero exit) because `git hash-object f1 f2 …` is
132
+ * all-or-nothing: it aborts at the first unreadable path and emits nothing for
133
+ * the rest. To preserve the old per-file contract — where one bad path zeroed
134
+ * only its own index line — a chunk that does not return exactly one hash per
135
+ * input falls back to hashing that chunk's paths individually. A path that is
136
+ * still unhashable is simply left out of the map; the caller applies the
137
+ * `0000000000` zero-hash fallback (and the same verbose log) for any miss, so
138
+ * the blob hash is byte-identical to `git hash-object` (filters/.gitattributes
139
+ * are applied per path) without the risk of in-process hashing, which would
140
+ * diverge under `core.autocrlf`/`text` attributes.
141
+ * @param repoDir - Repository directory
142
+ * @param fullPaths - Absolute file paths to hash
143
+ * @returns Map from each input path to its full blob hash (misses omitted)
144
+ */
145
+ export async function hashObjectBatch(repoDir, fullPaths) {
146
+ const hashes = new Map();
147
+ if (fullPaths.length === 0)
148
+ return hashes;
149
+ await ensureGit();
150
+ for (const chunk of chunkPathspecs(fullPaths)) {
151
+ const result = await exec('git', ['hash-object', '--', ...chunk], { cwd: repoDir });
152
+ const lines = result.stdout.split('\n').filter((line) => line.length > 0);
153
+ if (result.exitCode === 0 && lines.length === chunk.length) {
154
+ for (let i = 0; i < chunk.length; i++) {
155
+ hashes.set(chunk[i], lines[i]);
156
+ }
157
+ continue;
158
+ }
159
+ // Batch aborted partway (a path became unreadable between the caller's stat
160
+ // and here, or otherwise failed). Recover per file so one bad path only
161
+ // loses its own hash, exactly as the pre-batch per-file code behaved.
162
+ for (const path of chunk) {
163
+ const single = await exec('git', ['hash-object', '--', path], { cwd: repoDir });
164
+ const hash = single.stdout.trim();
165
+ if (single.exitCode === 0 && hash.length > 0)
166
+ hashes.set(path, hash);
167
+ }
168
+ }
169
+ return hashes;
170
+ }
90
171
  /**
91
172
  * Gets the content of a file at a specific git ref (HEAD by default).
92
173
  * @param repoDir - Repository directory
@@ -199,3 +199,20 @@ export declare function test(engineDir: string, testPaths?: string[], args?: str
199
199
  * `fireforge test --perf-samples` to publish the artifact-path contract.
200
200
  */
201
201
  export declare function testWithOutput(engineDir: string, testPaths?: string[], args?: string[], env?: Record<string, string>): Promise<MachCommandResult>;
202
+ /**
203
+ * Runs `mach xpcshell-test` (the suite-specific xpcshell command) while
204
+ * capturing output. Unlike the generic `mach test`, the suite-specific
205
+ * commands degrade a broken mozlog resource monitor to a warning instead of
206
+ * crashing at startup, so `fireforge test` dispatches single-suite runs here
207
+ * to stay resilient to the host psutil failure (field report E1).
208
+ *
209
+ * Signature mirrors {@link testWithOutput} so the two are interchangeable in
210
+ * the dispatch path.
211
+ */
212
+ export declare function xpcshellTestWithOutput(engineDir: string, testPaths?: string[], args?: string[], env?: Record<string, string>): Promise<MachCommandResult>;
213
+ /**
214
+ * Runs `mach mochitest` (covers browser-chrome / mochitest flavors) while
215
+ * capturing output. The suite-specific counterpart to {@link testWithOutput}
216
+ * for non-xpcshell single-suite runs — see {@link xpcshellTestWithOutput}.
217
+ */
218
+ export declare function mochitestWithOutput(engineDir: string, testPaths?: string[], args?: string[], env?: Record<string, string>): Promise<MachCommandResult>;
@@ -320,4 +320,25 @@ export async function test(engineDir, testPaths = [], args = []) {
320
320
  export async function testWithOutput(engineDir, testPaths = [], args = [], env) {
321
321
  return runMachCapture(['test', ...testPaths, ...args], engineDir, env ? { env } : {});
322
322
  }
323
+ /**
324
+ * Runs `mach xpcshell-test` (the suite-specific xpcshell command) while
325
+ * capturing output. Unlike the generic `mach test`, the suite-specific
326
+ * commands degrade a broken mozlog resource monitor to a warning instead of
327
+ * crashing at startup, so `fireforge test` dispatches single-suite runs here
328
+ * to stay resilient to the host psutil failure (field report E1).
329
+ *
330
+ * Signature mirrors {@link testWithOutput} so the two are interchangeable in
331
+ * the dispatch path.
332
+ */
333
+ export async function xpcshellTestWithOutput(engineDir, testPaths = [], args = [], env) {
334
+ return runMachCapture(['xpcshell-test', ...testPaths, ...args], engineDir, env ? { env } : {});
335
+ }
336
+ /**
337
+ * Runs `mach mochitest` (covers browser-chrome / mochitest flavors) while
338
+ * capturing output. The suite-specific counterpart to {@link testWithOutput}
339
+ * for non-xpcshell single-suite runs — see {@link xpcshellTestWithOutput}.
340
+ */
341
+ export async function mochitestWithOutput(engineDir, testPaths = [], args = [], env) {
342
+ return runMachCapture(['mochitest', ...testPaths, ...args], engineDir, env ? { env } : {});
343
+ }
323
344
  //# sourceMappingURL=mach.js.map
@@ -15,35 +15,89 @@
15
15
  * `patchLint.checkJsStrict` only tightens `strict` / `noImplicitAny`
16
16
  * and optional allowlisted `checkJsCompilerOptions`; it does not change
17
17
  * shim composition or suppressed diagnostic codes.
18
+ *
19
+ * Resolution scope vs reporting scope: the TS program is built over a
20
+ * *resolution* set (every patch-owned `.sys.mjs` the run cares about, so
21
+ * cross-patch `resource:///` imports resolve to their real sources), while
22
+ * diagnostics are emitted only for files in the *report* scope. Splitting
23
+ * the two lets per-patch lint build one queue-wide program and attribute
24
+ * findings per patch, and lets export/re-export resolve cross-patch imports
25
+ * while reporting only the patch under export.
18
26
  */
19
27
  import type { PatchLintIssue } from '../types/commands/index.js';
20
28
  import type { PatchLintCheckJsCompilerOptions, PatchLintConfig } from '../types/config.js';
29
+ /** How a checkJs run controls reporting and resolution; see module docs. */
30
+ export interface CheckJsMode {
31
+ strict: boolean;
32
+ compilerOptions?: PatchLintCheckJsCompilerOptions;
33
+ }
34
+ /**
35
+ * Result of {@link runCheckJsGrouped}: diagnostics attributed to the
36
+ * patch-owned file they originate in (`byFile`, keyed by repo-relative
37
+ * path), plus run-level errors that have no owning file (`global` — e.g.
38
+ * TypeScript missing or an unreadable extra shim).
39
+ */
40
+ export interface GroupedCheckJsResult {
41
+ byFile: Map<string, PatchLintIssue[]>;
42
+ global: PatchLintIssue[];
43
+ }
44
+ /**
45
+ * Builds the checkJs program **once** over `resolutionOwned` and returns its
46
+ * diagnostics grouped by originating file. Callers slice the result by their
47
+ * own report scope — per-patch lint attributes each file to its owning
48
+ * patch, export/re-export keeps only the patch under export. Resolution
49
+ * always spans every file in `resolutionOwned`, so cross-patch
50
+ * `resource:///`/`chrome://` imports resolve to real sources.
51
+ *
52
+ * @param repoDir - Absolute engine (repository) directory
53
+ * @param resolutionOwned - Patch-owned `.sys.mjs` paths (relative to repoDir)
54
+ * the program should see and resolve against
55
+ * @param extraShimPath - Optional project-relative extra `.d.ts` appended to
56
+ * the built-in Firefox-globals shim (from `patchLint.checkJsExtraShim`)
57
+ * @param projectRoot - Absolute project root for resolving `extraShimPath`
58
+ * @param mode - Strictness preset plus allowlisted compiler-option overrides
59
+ * @returns Diagnostics grouped per owning file plus run-level errors
60
+ */
61
+ export declare function runCheckJsGrouped(repoDir: string, resolutionOwned: Set<string>, extraShimPath?: string, projectRoot?: string, mode?: CheckJsMode): Promise<GroupedCheckJsResult>;
21
62
  /**
22
- * Runs TypeScript's checkJs pass on patch-owned `.sys.mjs` files.
63
+ * Flattens a {@link runCheckJsGrouped} run into a single issue list. When
64
+ * `reportScope` is supplied, only diagnostics from files in that set are
65
+ * returned (resolution still spans every owned file); omitting it reports
66
+ * every owned file's diagnostics — the historical whole-set behaviour.
23
67
  *
24
- * @param repoDir - Absolute path to the engine (repository) directory
25
- * @param patchOwnedFiles - Set of patch-owned `.sys.mjs` file paths (relative to repoDir)
26
- * @param extraShimPath - Optional project-relative path to an additional
27
- * `.d.ts` file whose contents are concatenated to the built-in
28
- * Firefox-globals shim. Sourced from `patchLint.checkJsExtraShim`.
29
- * Resolved against `projectRoot` (one level up from `repoDir` is the
30
- * wrong root — patches sit inside `engine/` while the shim lives at
31
- * the project root, so the caller passes both).
32
- * @param projectRoot - Absolute project root for resolving `extraShimPath`.
33
- * Defaults to `repoDir` for back-compat with callers that don't
34
- * pass an extra shim (no resolution actually happens in that case).
35
- * @param mode - When `strict` is true, enables `strict` and `noImplicitAny`
36
- * (CI-style). Optional `compilerOptions` merges allowlisted boolean
37
- * overrides after that preset (from `patchLint.checkJsCompilerOptions`).
38
- * Omitted or `{ strict: false }` preserves the historical loose preset.
68
+ * @param repoDir - Absolute engine (repository) directory
69
+ * @param patchOwnedFiles - Patch-owned `.sys.mjs` paths to resolve against
70
+ * @param extraShimPath - Optional project-relative extra `.d.ts`
71
+ * @param projectRoot - Absolute project root for resolving `extraShimPath`
72
+ * @param mode - Strictness preset plus allowlisted compiler-option overrides
73
+ * @param reportScope - When set, restrict reported diagnostics to these
74
+ * repo-relative files
39
75
  * @returns Array of lint issues from TS diagnostics
40
76
  */
41
- export declare function runCheckJs(repoDir: string, patchOwnedFiles: Set<string>, extraShimPath?: string, projectRoot?: string, mode?: {
42
- strict: boolean;
43
- compilerOptions?: PatchLintCheckJsCompilerOptions;
44
- }): Promise<PatchLintIssue[]>;
77
+ export declare function runCheckJs(repoDir: string, patchOwnedFiles: Set<string>, extraShimPath?: string, projectRoot?: string, mode?: CheckJsMode, reportScope?: ReadonlySet<string>): Promise<PatchLintIssue[]>;
45
78
  /**
46
79
  * Invokes {@link runCheckJs} for a `patchLint` block with `checkJs: true`.
47
80
  * `projectRoot` is the FireForge project root (`dirname(engine)`).
81
+ *
82
+ * @param repoDir - Absolute engine (repository) directory
83
+ * @param patchOwnedFiles - Patch-owned `.sys.mjs` paths to resolve against
84
+ * @param patchLint - The resolved `patchLint` config block
85
+ * @param projectRoot - FireForge project root for shim resolution
86
+ * @param reportScope - Optional repo-relative files to report on (export /
87
+ * re-export passes the patch under export so cross-patch resolution does
88
+ * not surface other patches' diagnostics)
89
+ */
90
+ export declare function invokePatchLintCheckJs(repoDir: string, patchOwnedFiles: Set<string>, patchLint: PatchLintConfig, projectRoot: string, reportScope?: ReadonlySet<string>): Promise<PatchLintIssue[]>;
91
+ /**
92
+ * Grouped variant of {@link invokePatchLintCheckJs}: builds one queue-wide
93
+ * checkJs program over `patchOwnedFiles` and returns its findings grouped by
94
+ * owning file. The per-patch lint orchestrator calls this **once per run**
95
+ * and attributes each file's findings to its owning patch, instead of
96
+ * rebuilding the same program for every patch in the queue.
97
+ *
98
+ * @param repoDir - Absolute engine (repository) directory
99
+ * @param patchOwnedFiles - Every patch-owned `.sys.mjs` in the queue
100
+ * @param patchLint - The resolved `patchLint` config block
101
+ * @param projectRoot - FireForge project root for shim resolution
48
102
  */
49
- export declare function invokePatchLintCheckJs(repoDir: string, patchOwnedFiles: Set<string>, patchLint: PatchLintConfig, projectRoot: string): Promise<PatchLintIssue[]>;
103
+ export declare function invokePatchLintCheckJsGrouped(repoDir: string, patchOwnedFiles: Set<string>, patchLint: PatchLintConfig, projectRoot: string): Promise<GroupedCheckJsResult>;