@hominis/fireforge 0.27.1 → 0.27.3

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 (47) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +5 -5
  3. package/dist/src/cli.js +5 -1
  4. package/dist/src/commands/build.js +61 -1
  5. package/dist/src/commands/doctor-working-tree.js +5 -1
  6. package/dist/src/commands/download.js +178 -112
  7. package/dist/src/commands/export-all.js +3 -2
  8. package/dist/src/commands/export-flow.d.ts +2 -0
  9. package/dist/src/commands/export-flow.js +2 -0
  10. package/dist/src/commands/export.js +5 -4
  11. package/dist/src/commands/import.js +2 -1
  12. package/dist/src/commands/re-export.js +6 -6
  13. package/dist/src/commands/rebase/continue.js +2 -0
  14. package/dist/src/commands/rebase/index.d.ts +2 -2
  15. package/dist/src/commands/rebase/index.js +9 -4
  16. package/dist/src/commands/rebase/patch-loop.js +5 -5
  17. package/dist/src/commands/rebase/summary.js +7 -2
  18. package/dist/src/commands/resolve.js +2 -1
  19. package/dist/src/commands/status-output.d.ts +13 -0
  20. package/dist/src/commands/status-output.js +186 -0
  21. package/dist/src/commands/status.js +4 -247
  22. package/dist/src/commands/verify.js +32 -16
  23. package/dist/src/core/build-prepare.js +12 -4
  24. package/dist/src/core/firefox-archive.js +7 -3
  25. package/dist/src/core/firefox-cache.d.ts +1 -1
  26. package/dist/src/core/firefox-cache.js +12 -5
  27. package/dist/src/core/firefox.js +1 -1
  28. package/dist/src/core/git.js +7 -2
  29. package/dist/src/core/ownership-table.d.ts +3 -1
  30. package/dist/src/core/ownership-table.js +31 -7
  31. package/dist/src/core/patch-export.d.ts +4 -0
  32. package/dist/src/core/patch-export.js +4 -0
  33. package/dist/src/core/patch-manifest-consistency.d.ts +1 -1
  34. package/dist/src/core/patch-manifest-consistency.js +4 -2
  35. package/dist/src/core/patch-manifest-query.d.ts +4 -3
  36. package/dist/src/core/patch-manifest-query.js +12 -4
  37. package/dist/src/core/patch-manifest-validate.js +22 -4
  38. package/dist/src/core/patch-source-metadata.d.ts +8 -0
  39. package/dist/src/core/patch-source-metadata.js +17 -0
  40. package/dist/src/core/rebase-session.d.ts +8 -3
  41. package/dist/src/core/rebase-session.js +1 -1
  42. package/dist/src/core/status-classify.d.ts +4 -1
  43. package/dist/src/core/status-classify.js +4 -5
  44. package/dist/src/errors/download.d.ts +11 -0
  45. package/dist/src/errors/download.js +33 -1
  46. package/dist/src/types/commands/patches.d.ts +9 -1
  47. package/package.json +1 -1
@@ -2,7 +2,6 @@ import { getProjectPaths, loadConfig } from '../core/config.js';
2
2
  import { collectFurnaceManagedPrefixes } from '../core/furnace-config.js';
3
3
  import { getHead, getStatusWithCodes, isGitRepository, isMissingHeadError } from '../core/git.js';
4
4
  import { getUntrackedFilesInDir } from '../core/git-status.js';
5
- import { isFileRegistered, matchesRegistrablePattern } from '../core/manifest-rules.js';
6
5
  import { buildOwnershipTable, renderOwnershipTable } from '../core/ownership-table.js';
7
6
  import { buildPatchQueueContext, collectNewFileCreatorsByPath } from '../core/patch-lint.js';
8
7
  import { loadPatchesManifest } from '../core/patch-manifest.js';
@@ -11,118 +10,7 @@ import { CommandError, GeneralError } from '../errors/base.js';
11
10
  import { ExitCode } from '../errors/codes.js';
12
11
  import { FIREFORGE_TMP_PATH_PATTERN, pathExists } from '../utils/fs.js';
13
12
  import { info, intro, outro, warn } from '../utils/logger.js';
14
- /**
15
- * Status code descriptions for git status.
16
- */
17
- const STATUS_DESCRIPTIONS = {
18
- M: 'modified',
19
- A: 'added',
20
- D: 'deleted',
21
- R: 'renamed',
22
- C: 'copied',
23
- U: 'unmerged',
24
- '?': 'untracked',
25
- '!': 'ignored',
26
- };
27
- /**
28
- * Gets a human-readable description for a git status code.
29
- */
30
- function getStatusDescription(code) {
31
- return STATUS_DESCRIPTIONS[code] ?? 'changed';
32
- }
33
- function getPrimaryStatusCode(status) {
34
- if (status.includes('?'))
35
- return '?';
36
- if (status.includes('!'))
37
- return '!';
38
- for (const code of status) {
39
- if (code !== ' ') {
40
- return code;
41
- }
42
- }
43
- return status;
44
- }
45
- function isNewFileStatus(status) {
46
- const code = getPrimaryStatusCode(status);
47
- return code === '?' || code === 'A';
48
- }
49
- function groupFilesByStatus(files) {
50
- const grouped = new Map();
51
- for (const { status, file } of files) {
52
- const code = getPrimaryStatusCode(status);
53
- const existing = grouped.get(code) ?? [];
54
- existing.push(file);
55
- grouped.set(code, existing);
56
- }
57
- return grouped;
58
- }
59
- function printStatusGroups(files) {
60
- const grouped = groupFilesByStatus(files);
61
- for (const [status, fileList] of grouped) {
62
- const description = getStatusDescription(status);
63
- warn(`${description}:`);
64
- for (const file of fileList) {
65
- info(` ${file}`);
66
- }
67
- }
68
- }
69
- async function printUnregisteredWarnings(files, projectRoot, binaryName) {
70
- const newFiles = files.filter((f) => isNewFileStatus(f.status));
71
- if (newFiles.length === 0)
72
- return;
73
- const registrableFiles = newFiles.filter((f) => matchesRegistrablePattern(f.file, binaryName));
74
- // `isFileRegistered` throws `GeneralError("Manifest not found: ...")` when a
75
- // rule sees a file whose parent manifest does not yet exist on disk — e.g.
76
- // a brand-new `browser/modules/<binary>/` directory with no `moz.build`.
77
- // `status` is a read-only reporter; before 0.18.1 the rejected promise
78
- // bubbled through `Promise.all` and exited status with code 1, breaking the
79
- // "use status --unmanaged to discover new files before running register"
80
- // workflow. We now bucket missing-manifest cases into a distinct warning
81
- // list while still surfacing the same actionable signal. Other error
82
- // shapes continue to propagate (permission denied, corrupt file, etc.) so
83
- // we do not silently hide anything surprising.
84
- const registrationChecks = await Promise.all(registrableFiles.map(async (f) => {
85
- try {
86
- return {
87
- file: f.file,
88
- registered: await isFileRegistered(projectRoot, f.file),
89
- manifestMissing: false,
90
- manifestMissingMessage: undefined,
91
- };
92
- }
93
- catch (err) {
94
- if (err instanceof GeneralError && /^Manifest not found:/i.test(err.message)) {
95
- return {
96
- file: f.file,
97
- registered: false,
98
- manifestMissing: true,
99
- manifestMissingMessage: err.message,
100
- };
101
- }
102
- throw err;
103
- }
104
- }));
105
- const unregistered = registrationChecks.filter((f) => !f.registered && !f.manifestMissing);
106
- const manifestMissing = registrationChecks.filter((f) => f.manifestMissing);
107
- if (unregistered.length > 0) {
108
- info('');
109
- warn('Potentially unregistered files:');
110
- for (const f of unregistered) {
111
- info(` ${f.file} — run 'fireforge register ${f.file}'`);
112
- }
113
- }
114
- if (manifestMissing.length > 0) {
115
- info('');
116
- warn('Files whose registration manifest does not exist yet:');
117
- for (const f of manifestMissing) {
118
- // `manifestMissingMessage` is always the specific
119
- // "Manifest not found: <path>" string when manifestMissing is
120
- // true (see the catch branch above that sets them together).
121
- info(` ${f.file} — ${f.manifestMissingMessage}`);
122
- info(` Create the parent manifest, then run 'fireforge register ${f.file}'.`);
123
- }
124
- }
125
- }
13
+ import { renderDefaultStatus, renderUnmanagedOnly, } from './status-output.js';
126
14
  /**
127
15
  * Renders raw worktree status as machine-parseable porcelain-style output.
128
16
  * Each line is: STATUS<tab>FILE
@@ -132,19 +20,6 @@ function renderRawStatus(files) {
132
20
  process.stdout.write(`${status.trim()}\t${file}\n`);
133
21
  }
134
22
  }
135
- /**
136
- * Default maximum number of files we will materialise from a single
137
- * untracked directory. Pathological inputs (an accidental dump of build
138
- * output, a symlink that resolves into a huge unrelated tree, etc.)
139
- * should not be able to balloon `status` into multi-gigabyte memory or
140
- * hang the CLI. Going over this cap surfaces a warning so the user knows
141
- * the listing has been truncated, and it bounds the JSON / default
142
- * rendering paths.
143
- *
144
- * Override via the `FIREFORGE_MAX_UNTRACKED_FILES` environment variable
145
- * for monorepos or fixture-heavy projects with legitimately large
146
- * untracked directories.
147
- */
148
23
  const DEFAULT_MAX_UNTRACKED_FILES_PER_DIR = 5000;
149
24
  function resolveMaxUntrackedFilesPerDir() {
150
25
  const raw = process.env['FIREFORGE_MAX_UNTRACKED_FILES'];
@@ -158,14 +33,6 @@ function resolveMaxUntrackedFilesPerDir() {
158
33
  return parsed;
159
34
  }
160
35
  const MAX_UNTRACKED_FILES_PER_DIR = resolveMaxUntrackedFilesPerDir();
161
- /**
162
- * Emits a prominent top-of-output warning when one or more untracked
163
- * directories were truncated during expansion. Individual per-dir warnings
164
- * already fired inside expandDirectoryEntries but are easily lost in
165
- * scrollback for large status outputs; this banner summarises the total
166
- * hidden count so the user doesn't miss that an export based on this
167
- * status would be incomplete.
168
- */
169
36
  function renderTruncationBanner(truncations) {
170
37
  if (truncations.length === 0)
171
38
  return;
@@ -227,10 +94,6 @@ async function renderJsonStatus(files, paths, projectRoot, binaryName) {
227
94
  status: f.status.trim(),
228
95
  classification: f.classification,
229
96
  };
230
- // `claimedBy` is an optional field present only on conflict
231
- // entries, so non-conflict output stays byte-identical to the
232
- // pre-0.16.0 shape (no unconditional schema change for the
233
- // 99% of entries that are not cross-patch conflicts).
234
97
  if (f.classification === 'conflict' && f.claimedBy && f.claimedBy.length > 0) {
235
98
  entry.claimedBy = [...f.claimedBy];
236
99
  }
@@ -238,6 +101,7 @@ async function renderJsonStatus(files, paths, projectRoot, binaryName) {
238
101
  });
239
102
  const byClassification = {
240
103
  unmanaged: 0,
104
+ 'patch-owned-drift': 0,
241
105
  'patch-backed': 0,
242
106
  branding: 0,
243
107
  furnace: 0,
@@ -306,25 +170,6 @@ export async function statusCommand(projectRoot, options = {}) {
306
170
  }
307
171
  const paths = getProjectPaths(projectRoot);
308
172
  const config = await loadConfig(projectRoot);
309
- // `--json` mode contracts to machine-parseable output on every code path,
310
- // including failure modes. Before this guard, errors raised below
311
- // ("Firefox source not found", "engine is not a git repository") flowed
312
- // through the normal styled error renderer in `withErrorHandling`, so
313
- // scripts piping `status --json | jq` broke precisely when the engine was
314
- // missing. Surface a structured `{ "error": ..., "code": ... }` payload
315
- // and exit non-zero via GeneralError so the exit code still reflects the
316
- // failure but stdout remains valid JSON. The same guard runs for
317
- // ownership mode below because that path also throws on missing engine.
318
- // 2026-04-26 eval Finding 1: throw `CommandError` rather than
319
- // `GeneralError` after the JSON line lands on stdout. `GeneralError`
320
- // is a `FireForgeError`, so the `withErrorHandling` wrapper in cli.ts
321
- // calls `logError(error.userMessage)` on it, which routes the styled
322
- // human banner through clack to stdout — `status --json` therefore
323
- // emitted both the JSON object AND the `■ Firefox source not found …`
324
- // line on stdout, breaking every script that pipes the command into
325
- // a JSON parser. `CommandError` is the bin-only sentinel that
326
- // `withErrorHandling` does not log: bin/fireforge.ts catches it,
327
- // exits with the carried code, and stdout stays a single JSON line.
328
173
  const emitJsonError = (code, message) => {
329
174
  process.stdout.write(JSON.stringify({ schemaVersion: 1, error: message, code }) + '\n');
330
175
  throw new CommandError(ExitCode.GENERAL_ERROR);
@@ -357,7 +202,7 @@ export async function statusCommand(projectRoot, options = {}) {
357
202
  const newFileCreatorsByPath = (await pathExists(paths.patches))
358
203
  ? collectNewFileCreatorsByPath(await buildPatchQueueContext(paths.patches))
359
204
  : new Map();
360
- const rows = buildOwnershipTable(manifest?.patches ?? [], rawFilesOwnership, newFileCreatorsByPath);
205
+ const rows = buildOwnershipTable(manifest?.patches ?? [], rawFilesOwnership, newFileCreatorsByPath, new Map((await classifyFiles(rawFilesOwnership, paths.engine, paths.patches, config.binaryName, await collectFurnaceManagedPrefixes(projectRoot))).map((entry) => [entry.file, entry.classification])));
361
206
  renderOwnershipTable(rows);
362
207
  const conflictCount = rows.filter((r) => r.conflict).length;
363
208
  const unmanagedCount = rows.filter((r) => r.unmanaged).length;
@@ -429,6 +274,7 @@ export async function statusCommand(projectRoot, options = {}) {
429
274
  const buckets = {
430
275
  conflict: classified.filter((f) => f.classification === 'conflict'),
431
276
  unmanaged: classified.filter((f) => f.classification === 'unmanaged'),
277
+ patchOwnedDrift: classified.filter((f) => f.classification === 'patch-owned-drift'),
432
278
  patchBacked: classified.filter((f) => f.classification === 'patch-backed'),
433
279
  branding: classified.filter((f) => f.classification === 'branding'),
434
280
  furnace: classified.filter((f) => f.classification === 'furnace'),
@@ -440,95 +286,6 @@ export async function statusCommand(projectRoot, options = {}) {
440
286
  }
441
287
  await renderDefaultStatus(files.length, buckets, projectRoot, config.binaryName);
442
288
  }
443
- async function renderUnmanagedOnly(unmanagedFiles, totalModified, projectRoot, binaryName) {
444
- info(`${unmanagedFiles.length} unmanaged file${unmanagedFiles.length === 1 ? '' : 's'} (${totalModified} total modified):\n`);
445
- if (unmanagedFiles.length > 0) {
446
- printStatusGroups(unmanagedFiles);
447
- await printUnregisteredWarnings(unmanagedFiles, projectRoot, binaryName);
448
- }
449
- else {
450
- info('No unmanaged changes');
451
- }
452
- outro(unmanagedFiles.length === 0
453
- ? 'No unmanaged changes'
454
- : `${unmanagedFiles.length} unmanaged change${unmanagedFiles.length === 1 ? '' : 's'}`);
455
- }
456
- /**
457
- * Renders the default five-bucket status display: conflicts first
458
- * (they block export/import/rebase), then unmanaged, patch-backed,
459
- * branding, and furnace-managed sections. Cross-bucket separators
460
- * ensure the sections are visually distinct without trailing empty
461
- * groups. Empty buckets are omitted — the very-empty case surfaces a
462
- * single `No changes` line.
463
- */
464
- async function renderDefaultStatus(totalModified, buckets, projectRoot, binaryName) {
465
- const { conflict, unmanaged, patchBacked, branding, furnace } = buckets;
466
- info(`${totalModified} modified file${totalModified === 1 ? '' : 's'}:\n`);
467
- if (conflict.length > 0) {
468
- // Surface cross-patch ownership conflicts at the top of the default
469
- // output — they block export/import/rebase and want immediate
470
- // attention. The `--ownership` view already renders the full table;
471
- // here we just name the files and point the operator at the
472
- // canonical recovery path.
473
- warn('Cross-patch ownership conflicts (same file claimed by multiple patches):');
474
- printStatusGroups(conflict);
475
- for (const entry of conflict) {
476
- if (entry.claimedBy && entry.claimedBy.length > 0) {
477
- info(` ${entry.file} — claimed by ${entry.claimedBy.join(', ')}`);
478
- }
479
- }
480
- info('Run "fireforge status --ownership" for the full conflict table, then repartition with "fireforge re-export --files <paths> <patch>".');
481
- }
482
- if (unmanaged.length > 0) {
483
- if (conflict.length > 0)
484
- info('');
485
- warn('Unmanaged changes:');
486
- printStatusGroups(unmanaged);
487
- await printUnregisteredWarnings(unmanaged, projectRoot, binaryName);
488
- }
489
- if (patchBacked.length > 0) {
490
- if (conflict.length > 0 || unmanaged.length > 0)
491
- info('');
492
- warn('Patch-backed materialized changes:');
493
- printStatusGroups(patchBacked);
494
- }
495
- if (branding.length > 0) {
496
- if (conflict.length > 0 || unmanaged.length > 0 || patchBacked.length > 0) {
497
- info('');
498
- }
499
- warn('Tool-managed branding changes:');
500
- printStatusGroups(branding);
501
- }
502
- if (furnace.length > 0) {
503
- if (conflict.length > 0 ||
504
- unmanaged.length > 0 ||
505
- patchBacked.length > 0 ||
506
- branding.length > 0) {
507
- info('');
508
- }
509
- warn('Furnace-managed component changes:');
510
- printStatusGroups(furnace);
511
- }
512
- if (conflict.length === 0 &&
513
- unmanaged.length === 0 &&
514
- patchBacked.length === 0 &&
515
- branding.length === 0 &&
516
- furnace.length === 0) {
517
- info('No changes');
518
- }
519
- const parts = [];
520
- if (conflict.length > 0)
521
- parts.push(`${conflict.length} conflict`);
522
- if (unmanaged.length > 0)
523
- parts.push(`${unmanaged.length} unmanaged`);
524
- if (patchBacked.length > 0)
525
- parts.push(`${patchBacked.length} patch-backed`);
526
- if (branding.length > 0)
527
- parts.push(`${branding.length} branding`);
528
- if (furnace.length > 0)
529
- parts.push(`${furnace.length} furnace`);
530
- outro(parts.join(', '));
531
- }
532
289
  /** Registers the status command on the CLI program. */
533
290
  export function registerStatus(program, { getProjectRoot, withErrorHandling }) {
534
291
  program
@@ -16,12 +16,14 @@
16
16
  */
17
17
  import { join } from 'node:path';
18
18
  import { getProjectPaths, loadConfig } from '../core/config.js';
19
+ import { collectFurnaceManagedPrefixes } from '../core/furnace-config.js';
19
20
  import { isGitRepository } from '../core/git.js';
20
21
  import { expandUntrackedDirectoryEntries, getWorkingTreeStatus } from '../core/git-status.js';
21
22
  import { buildPatchQueueContext, lintPatchQueue } from '../core/patch-lint.js';
22
23
  import { loadPatchesManifest, validatePatchesManifestConsistency } from '../core/patch-manifest.js';
23
24
  import { evaluatePatchPolicy } from '../core/patch-policy.js';
24
25
  import { collectPatchRegistrationReferences } from '../core/patch-registration-refs.js';
26
+ import { classifyFiles } from '../core/status-classify.js';
25
27
  import { GeneralError } from '../errors/base.js';
26
28
  import { pathExists, readText } from '../utils/fs.js';
27
29
  import { info, intro, outro, success, warn } from '../utils/logger.js';
@@ -102,14 +104,25 @@ function detectCrossPatchFileClaims(manifestPatches) {
102
104
  }
103
105
  return results;
104
106
  }
105
- async function detectUnownedWorktreeChanges(engineDir, claimedFiles) {
107
+ async function detectWorktreeOwnershipDrift(projectRoot, engineDir, patchesDir, binaryName) {
106
108
  if (!(await pathExists(engineDir)) || !(await isGitRepository(engineDir))) {
107
- return [];
109
+ return { unowned: [], patchOwnedDrift: [] };
108
110
  }
109
111
  const entries = await expandUntrackedDirectoryEntries(engineDir, await getWorkingTreeStatus(engineDir));
110
- return [
111
- ...new Set(entries.map((entry) => entry.file).filter((file) => !claimedFiles.has(file))),
112
- ].sort();
112
+ const furnacePrefixes = await collectFurnaceManagedPrefixes(projectRoot);
113
+ const classified = await classifyFiles(entries, engineDir, patchesDir, binaryName, furnacePrefixes);
114
+ return {
115
+ unowned: [
116
+ ...new Set(classified
117
+ .filter((entry) => entry.classification === 'unmanaged')
118
+ .map((entry) => entry.file)),
119
+ ].sort(),
120
+ patchOwnedDrift: [
121
+ ...new Set(classified
122
+ .filter((entry) => entry.classification === 'patch-owned-drift')
123
+ .map((entry) => entry.file)),
124
+ ].sort(),
125
+ };
113
126
  }
114
127
  /**
115
128
  * Collects the same queue-health findings reported by `fireforge verify`
@@ -188,21 +201,24 @@ export async function collectPatchQueueHealth(projectRoot) {
188
201
  warningCount += lintWarnings;
189
202
  }
190
203
  if (manifest) {
191
- const claimedFiles = new Set();
192
- for (const patch of manifest.patches) {
193
- for (const file of patch.filesAffected) {
194
- claimedFiles.add(file);
195
- }
204
+ const worktreeDrift = await detectWorktreeOwnershipDrift(projectRoot, paths.engine, paths.patches, config.binaryName);
205
+ if (worktreeDrift.unowned.length > 0) {
206
+ groups.push({
207
+ title: `Unowned worktree changes (${worktreeDrift.unowned.length})`,
208
+ issues: worktreeDrift.unowned.map((file) => `${file} is changed in engine/ but is not listed in any patch filesAffected entry`),
209
+ errorCount: 0,
210
+ warningCount: worktreeDrift.unowned.length,
211
+ });
212
+ warningCount += worktreeDrift.unowned.length;
196
213
  }
197
- const unownedWorktreeChanges = await detectUnownedWorktreeChanges(paths.engine, claimedFiles);
198
- if (unownedWorktreeChanges.length > 0) {
214
+ if (worktreeDrift.patchOwnedDrift.length > 0) {
199
215
  groups.push({
200
- title: `Unowned worktree changes (${unownedWorktreeChanges.length})`,
201
- issues: unownedWorktreeChanges.map((file) => `${file} is changed in engine/ but is not listed in any patch filesAffected entry`),
216
+ title: `Patch-owned worktree drift (${worktreeDrift.patchOwnedDrift.length})`,
217
+ issues: worktreeDrift.patchOwnedDrift.map((file) => `${file} is claimed by exactly one patch, but engine/ no longer matches that patch output`),
202
218
  errorCount: 0,
203
- warningCount: unownedWorktreeChanges.length,
219
+ warningCount: worktreeDrift.patchOwnedDrift.length,
204
220
  });
205
- warningCount += unownedWorktreeChanges.length;
221
+ warningCount += worktreeDrift.patchOwnedDrift.length;
206
222
  }
207
223
  const registrationIssues = await detectDanglingRegistrations(paths.patches, paths.engine, manifest.patches);
208
224
  if (registrationIssues.length > 0) {
@@ -3,6 +3,7 @@
3
3
  * Shared pre-flight logic for build and package commands:
4
4
  * story cleanup, branding setup, Furnace component application, and mozconfig generation.
5
5
  */
6
+ import { BuildError } from '../errors/build.js';
6
7
  import { FurnaceError } from '../errors/furnace.js';
7
8
  import { toError } from '../utils/errors.js';
8
9
  import { pathExists } from '../utils/fs.js';
@@ -103,21 +104,28 @@ export async function prepareBuildEnvironment(projectRoot, paths, config, option
103
104
  const changed = await collectBackendRelevantChanges(paths.engine, options.previousBaseline);
104
105
  const invalidating = changed.filter(isBackendInvalidatingFile);
105
106
  if (invalidating.length > 0) {
106
- info(`Backend config changed; running mach configure first... (${invalidating.length} file${invalidating.length === 1 ? '' : 's'} touched)`);
107
+ info(`Backend config changed; running backend regeneration first (${invalidating.length} file${invalidating.length === 1 ? '' : 's'} touched).`);
108
+ info(`Backend command: mach configure`);
107
109
  const configureSpinner = spinner('Running mach configure...');
108
110
  try {
109
111
  const exitCode = await runMach(['configure'], paths.engine);
110
112
  if (exitCode !== 0) {
111
- configureSpinner.error('mach configure exited non-zero; continuing with build anyway');
113
+ configureSpinner.error(`mach configure failed with exit code ${exitCode}`);
114
+ throw new BuildError(`Backend regeneration failed: mach configure exited with code ${exitCode}. Build stopped because continuing would hide the real configure failure.`, 'mach configure');
112
115
  }
113
116
  else {
114
- configureSpinner.stop('Backend regenerated');
117
+ configureSpinner.stop('Backend regenerated successfully (mach configure exit code 0)');
118
+ info('Backend regeneration succeeded; continuing with build.');
115
119
  reconfigured = true;
116
120
  }
117
121
  }
118
122
  catch (error) {
119
- configureSpinner.error('mach configure failed; continuing with build anyway');
123
+ if (error instanceof BuildError) {
124
+ throw error;
125
+ }
126
+ configureSpinner.error('mach configure failed');
120
127
  verbose(`Auto-configure error: ${toError(error).message}`);
128
+ throw new BuildError(`Backend regeneration failed while running mach configure: ${toError(error).message}. Build stopped because continuing would hide the real configure failure.`, 'mach configure', error instanceof Error ? error : undefined);
121
129
  }
122
130
  }
123
131
  }
@@ -6,9 +6,13 @@ import { ConfigError } from '../errors/config.js';
6
6
  import { parseObject } from '../utils/parse.js';
7
7
  import { isValidFirefoxProduct } from '../utils/validation.js';
8
8
  /**
9
- * Base URL for Firefox releases on archive.mozilla.org.
9
+ * Base URLs for Firefox source archives on archive.mozilla.org.
10
10
  */
11
- const ARCHIVE_BASE_URL = 'https://archive.mozilla.org/pub/firefox/releases';
11
+ const FIREFOX_ARCHIVE_BASE_URL = 'https://archive.mozilla.org/pub/firefox/releases';
12
+ const DEVEDITION_ARCHIVE_BASE_URL = 'https://archive.mozilla.org/pub/devedition/releases';
13
+ function getArchiveBaseUrl(product) {
14
+ return product === 'firefox-devedition' ? DEVEDITION_ARCHIVE_BASE_URL : FIREFOX_ARCHIVE_BASE_URL;
15
+ }
12
16
  /**
13
17
  * Validates raw JSON data as ArchiveMetadata.
14
18
  * @param data - Unknown data to validate
@@ -55,7 +59,7 @@ export function resolveArchive(version, product = 'firefox') {
55
59
  requestedVersion: version,
56
60
  product,
57
61
  archiveVersion,
58
- url: `${ARCHIVE_BASE_URL}/${archiveVersion}/source/firefox-${archiveVersion}.source.tar.xz`,
62
+ url: `${getArchiveBaseUrl(product)}/${archiveVersion}/source/firefox-${archiveVersion}.source.tar.xz`,
59
63
  filename: `firefox-${safeProduct}-${archiveVersion}.source.tar.xz`,
60
64
  metadataFilename: `firefox-${safeProduct}-${archiveVersion}.source.tar.xz.json`,
61
65
  };
@@ -14,7 +14,7 @@ export declare function sha256File(filePath: string): Promise<string>;
14
14
  * @param cacheDir - Cache directory
15
15
  * @param onProgress - Optional progress callback
16
16
  */
17
- export declare function ensureCachedArchive(archive: ResolvedArchive, cacheDir: string, onProgress?: ProgressCallback, expectedSha256?: string): Promise<void>;
17
+ export declare function ensureCachedArchive(archive: ResolvedArchive, cacheDir: string, onProgress?: ProgressCallback, expectedSha256?: string, onCacheProgress?: (message: string) => void): Promise<void>;
18
18
  /**
19
19
  * Removes cached tarball, metadata, and partial download files for an archive.
20
20
  * @param archive - Resolved archive descriptor
@@ -7,7 +7,7 @@ import { createReadStream } from 'node:fs';
7
7
  import { rename } from 'node:fs/promises';
8
8
  import { join } from 'node:path';
9
9
  import { pipeline } from 'node:stream/promises';
10
- import { DownloadError } from '../errors/download.js';
10
+ import { ChecksumMismatchError } from '../errors/download.js';
11
11
  import { toError } from '../utils/errors.js';
12
12
  import { pathExists, readJson, removeFile, writeJson } from '../utils/fs.js';
13
13
  import { verbose } from '../utils/logger.js';
@@ -30,19 +30,22 @@ export async function sha256File(filePath) {
30
30
  * @param cacheDir - Cache directory
31
31
  * @param onProgress - Optional progress callback
32
32
  */
33
- export async function ensureCachedArchive(archive, cacheDir, onProgress, expectedSha256) {
33
+ export async function ensureCachedArchive(archive, cacheDir, onProgress, expectedSha256, onCacheProgress) {
34
34
  const lockPath = createSiblingLockPath(join(cacheDir, archive.filename), '.fireforge-cache.lock');
35
35
  await withFileLock(lockPath, async () => {
36
+ onCacheProgress?.(`Validating source archive cache metadata for ${archive.filename}...`);
36
37
  if (await validateCachedArchive(archive, cacheDir, expectedSha256)) {
38
+ onCacheProgress?.(`Using validated cached source archive ${archive.filename}`);
37
39
  return;
38
40
  }
39
41
  if (await cacheEntryExists(archive, cacheDir)) {
42
+ onCacheProgress?.(`Invalid cached source archive metadata; refreshing ${archive.filename}`);
40
43
  await invalidateArchiveCache(archive, cacheDir);
41
44
  }
42
45
  else {
43
46
  await removeArchivePartFiles(archive, cacheDir);
44
47
  }
45
- await downloadToCache(archive, cacheDir, onProgress, expectedSha256);
48
+ await downloadToCache(archive, cacheDir, onProgress, expectedSha256, onCacheProgress);
46
49
  });
47
50
  }
48
51
  async function cacheEntryExists(archive, cacheDir) {
@@ -97,7 +100,7 @@ async function validateCachedArchive(archive, cacheDir, expectedSha256) {
97
100
  * @param cacheDir - Cache directory
98
101
  * @param onProgress - Optional progress callback
99
102
  */
100
- async function downloadToCache(archive, cacheDir, onProgress, expectedSha256) {
103
+ async function downloadToCache(archive, cacheDir, onProgress, expectedSha256, onCacheProgress) {
101
104
  const tarballPath = join(cacheDir, archive.filename);
102
105
  // Use a unique .part path so concurrent downloads for the same archive
103
106
  // do not clobber each other's partial files.
@@ -105,13 +108,16 @@ async function downloadToCache(archive, cacheDir, onProgress, expectedSha256) {
105
108
  const metadataPath = join(cacheDir, archive.metadataFilename);
106
109
  let promotedTarball = false;
107
110
  try {
111
+ onCacheProgress?.(`Downloading source archive to cache: ${archive.filename}`);
108
112
  const contentLength = await downloadFile(archive.url, partPath, onProgress);
109
113
  await rename(partPath, tarballPath);
110
114
  promotedTarball = true;
115
+ onCacheProgress?.(`Calculating source archive SHA-256 for ${archive.filename}...`);
111
116
  const sha256 = await sha256File(tarballPath);
112
117
  if (expectedSha256 && sha256 !== expectedSha256) {
113
- throw new DownloadError(`Downloaded archive SHA-256 mismatch: expected ${expectedSha256}, got ${sha256}`, archive.url);
118
+ throw new ChecksumMismatchError(archive.product, expectedSha256, sha256, archive.url);
114
119
  }
120
+ onCacheProgress?.(`Writing source archive cache metadata for ${archive.metadataFilename}...`);
115
121
  await writeJson(metadataPath, {
116
122
  requestedVersion: archive.requestedVersion,
117
123
  product: archive.product,
@@ -121,6 +127,7 @@ async function downloadToCache(archive, cacheDir, onProgress, expectedSha256) {
121
127
  sha256,
122
128
  downloadedAt: new Date().toISOString(),
123
129
  });
130
+ onCacheProgress?.(`Source archive cache metadata written: ${archive.metadataFilename}`);
124
131
  }
125
132
  catch (error) {
126
133
  await removeFile(partPath);
@@ -50,7 +50,7 @@ export async function downloadFirefoxSource(version, product, destDir, cacheDir,
50
50
  // Ensure cache directory exists
51
51
  await ensureDir(cacheDir);
52
52
  onPhase?.('download');
53
- await ensureCachedArchive(archive, cacheDir, onProgress, expectedSha256);
53
+ await ensureCachedArchive(archive, cacheDir, onProgress, expectedSha256, onPhaseProgress);
54
54
  // Extract to a unique temporary directory so concurrent downloads for
55
55
  // the same destination do not clobber each other.
56
56
  onPhase?.('extract');
@@ -206,9 +206,10 @@ export async function stageAllFiles(dir, options = {}) {
206
206
  heartbeatTimer?.unref();
207
207
  try {
208
208
  try {
209
+ reportProgress?.('Git phase: starting git add -A source indexing.');
209
210
  reportProgress?.(`Starting monolithic git add -A for ${scan.directories.length} director${scan.directories.length === 1 ? 'y' : 'ies'} and ${scan.topLevelFiles.length} top-level file${scan.topLevelFiles.length === 1 ? '' : 's'}...`);
210
211
  await git(['add', '-A'], dir, { timeout, env: GIT_ADD_ENV });
211
- reportProgress?.('Monolithic git add -A completed.');
212
+ reportProgress?.('Git phase complete: git add -A source indexing finished.');
212
213
  return;
213
214
  }
214
215
  catch (error) {
@@ -233,6 +234,7 @@ export async function stageAllFiles(dir, options = {}) {
233
234
  phaseStartedAt = Date.now();
234
235
  try {
235
236
  await stageAllFilesChunked(dir, scan, options);
237
+ reportProgress?.('Git phase complete: chunked source indexing finished.');
236
238
  }
237
239
  catch (error) {
238
240
  if (error instanceof GitIndexingTimeoutError)
@@ -247,6 +249,7 @@ export async function stageAllFiles(dir, options = {}) {
247
249
  }
248
250
  async function createInitialSourceCommit(dir, reportProgress) {
249
251
  const startedAt = Date.now();
252
+ reportProgress('Git phase: creating initial source commit.');
250
253
  reportProgress(`Creating initial Firefox source commit (${elapsedSince(startedAt)} elapsed)...`);
251
254
  const heartbeat = setInterval(() => {
252
255
  reportProgress(`Creating initial Firefox source commit (${elapsedSince(startedAt)} elapsed)...`);
@@ -258,7 +261,7 @@ async function createInitialSourceCommit(dir, reportProgress) {
258
261
  finally {
259
262
  clearInterval(heartbeat);
260
263
  }
261
- reportProgress(`Initial Firefox source commit created (${elapsedSince(startedAt)} elapsed).`);
264
+ reportProgress(`Git phase complete: initial source commit created (${elapsedSince(startedAt)} elapsed).`);
262
265
  }
263
266
  /**
264
267
  * Initializes a new git repository with an orphan branch.
@@ -269,6 +272,7 @@ export async function initRepository(dir, branchName = 'main', options = {}) {
269
272
  await ensureGit();
270
273
  const reportProgress = options.onProgress ?? (() => { });
271
274
  // Initialize repository
275
+ reportProgress('Git phase: initializing source git repository.');
272
276
  reportProgress('Creating git repository...');
273
277
  await git(['init'], dir);
274
278
  // Create orphan branch
@@ -286,6 +290,7 @@ export async function initRepository(dir, branchName = 'main', options = {}) {
286
290
  // fail. Nothing is ever fetched from or pushed to this remote.
287
291
  reportProgress('Configuring origin remote for build compatibility...');
288
292
  await git(['remote', 'add', 'origin', 'https://github.com/mozilla-firefox/firefox'], dir);
293
+ reportProgress('Git phase complete: source git repository metadata initialized.');
289
294
  // Add all files
290
295
  reportProgress('Indexing Firefox source with git add -A (this can take several minutes on large trees)...');
291
296
  await assertNoGitIndexLock(dir);
@@ -1,4 +1,5 @@
1
1
  import type { PatchMetadata } from '../types/commands/index.js';
2
+ import type { FileClassification } from './status-classify.js';
2
3
  /**
3
4
  * A row in the flat path → owning-patch ownership table.
4
5
  */
@@ -8,6 +9,7 @@ export interface OwnershipRow {
8
9
  conflict: boolean;
9
10
  conflictReason: 'files-affected' | 'duplicate-create' | null;
10
11
  unmanaged: boolean;
12
+ state: 'owned' | 'patch-backed' | 'patch-owned-drift' | 'unmanaged' | 'conflict';
11
13
  }
12
14
  interface StatusFile {
13
15
  status: string;
@@ -42,7 +44,7 @@ interface StatusFile {
42
44
  * {@link import('../core/patch-lint.js').collectNewFileCreatorsByPath};
43
45
  * paths with a `.length > 1` owner list become duplicate-create conflicts
44
46
  */
45
- export declare function buildOwnershipTable(manifestPatches: PatchMetadata[], worktreeFiles: StatusFile[], newFileCreatorsByPath: Map<string, string[]>): OwnershipRow[];
47
+ export declare function buildOwnershipTable(manifestPatches: PatchMetadata[], worktreeFiles: StatusFile[], newFileCreatorsByPath: Map<string, string[]>, classifications?: Map<string, FileClassification>): OwnershipRow[];
46
48
  /**
47
49
  * Renders the ownership table as a GitHub-flavored Markdown pipe table.
48
50
  * Using markdown-table's own serializer would require a seed document to