@hominis/fireforge 0.15.8 → 0.16.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 (44) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +16 -3
  3. package/dist/src/cli.d.ts +4 -1
  4. package/dist/src/cli.js +6 -3
  5. package/dist/src/commands/download.js +9 -0
  6. package/dist/src/commands/export-all.js +46 -0
  7. package/dist/src/commands/export-shared.d.ts +6 -1
  8. package/dist/src/commands/export-shared.js +7 -2
  9. package/dist/src/commands/export.js +10 -1
  10. package/dist/src/commands/furnace/diff.js +22 -2
  11. package/dist/src/commands/furnace/override.js +35 -12
  12. package/dist/src/commands/furnace/preview.js +33 -1
  13. package/dist/src/commands/furnace/rename.js +14 -3
  14. package/dist/src/commands/lint.d.ts +20 -0
  15. package/dist/src/commands/lint.js +167 -45
  16. package/dist/src/commands/package.js +16 -5
  17. package/dist/src/commands/re-export-files.js +6 -2
  18. package/dist/src/commands/re-export.js +62 -4
  19. package/dist/src/commands/register.js +2 -18
  20. package/dist/src/commands/run.js +23 -2
  21. package/dist/src/commands/status.js +25 -3
  22. package/dist/src/commands/test.js +6 -24
  23. package/dist/src/commands/token.js +14 -1
  24. package/dist/src/commands/watch.js +14 -2
  25. package/dist/src/core/branding.d.ts +23 -0
  26. package/dist/src/core/branding.js +39 -0
  27. package/dist/src/core/browser-wire.js +68 -23
  28. package/dist/src/core/mach-build-artifacts.d.ts +41 -0
  29. package/dist/src/core/mach-build-artifacts.js +70 -0
  30. package/dist/src/core/mach-error-hints.js +15 -0
  31. package/dist/src/core/mach-mozconfig.d.ts +25 -0
  32. package/dist/src/core/mach-mozconfig.js +66 -0
  33. package/dist/src/core/mach.d.ts +12 -1
  34. package/dist/src/core/mach.js +14 -1
  35. package/dist/src/core/manifest-rules.js +22 -1
  36. package/dist/src/core/patch-lint.d.ts +6 -1
  37. package/dist/src/core/patch-lint.js +14 -1
  38. package/dist/src/types/commands/options.d.ts +10 -0
  39. package/dist/src/types/commands/patches.d.ts +22 -0
  40. package/dist/src/utils/fs.d.ts +12 -0
  41. package/dist/src/utils/fs.js +12 -0
  42. package/dist/src/utils/paths.d.ts +19 -0
  43. package/dist/src/utils/paths.js +33 -0
  44. package/package.json +1 -1
@@ -25,6 +25,26 @@ export interface LintCommandOptions {
25
25
  * rejected up-front rather than silently ignored.
26
26
  */
27
27
  onlyIntroduced?: boolean;
28
+ /**
29
+ * Lint each patch in the queue as its own isolated diff, rather than
30
+ * the aggregate `git diff HEAD` across all applied patches.
31
+ *
32
+ * Motivating case: running `fireforge lint` (no args) on a repo where
33
+ * `fireforge import` or `fireforge rebase` has just applied the full
34
+ * patch queue produces an aggregate diff (every patch's changes
35
+ * summed). The patch-size advisory rules (`large-patch-lines`,
36
+ * `large-patch-files`) then fire against the sum — e.g. "Patch is
37
+ * 37529 lines" on a queue of 22 individually-fine patches — which
38
+ * reads as a task-specific regression when it is really an artefact
39
+ * of the aggregation. `--per-patch` rescopes the diff to each patch's
40
+ * own `filesAffected`, honours the patch's own `lintIgnore`, and runs
41
+ * the cross-patch rules once over the whole queue so queue-level
42
+ * findings (duplicate creations, forward imports) still surface.
43
+ *
44
+ * Mutually exclusive with passing explicit file paths — the two
45
+ * scope contracts are different.
46
+ */
47
+ perPatch?: boolean;
28
48
  }
29
49
  /**
30
50
  * Runs the lint command to check engine changes against patch quality rules.
@@ -8,40 +8,36 @@ import { getModifiedFilesInDir, getUntrackedFiles, getUntrackedFilesInDir, } fro
8
8
  import { extractAffectedFiles } from '../core/patch-apply.js';
9
9
  import { buildPatchQueueContext, lintExportedPatch, lintPatchQueue } from '../core/patch-lint.js';
10
10
  import { collectDiffFilePaths, tagLintIssues } from '../core/patch-lint-diff-tag.js';
11
+ import { loadPatchesManifest } from '../core/patch-manifest.js';
11
12
  import { GeneralError } from '../errors/base.js';
12
13
  import { pathExists } from '../utils/fs.js';
13
14
  import { info, intro, outro, success, warn } from '../utils/logger.js';
15
+ import { stripEnginePrefix } from '../utils/paths.js';
14
16
  /**
15
- * Runs the lint command to check engine changes against patch quality rules.
16
- * @param projectRoot - Root directory of the project
17
- * @param files - Optional file/directory paths to lint (relative to engine/)
18
- * @param options - Additional lint options such as `--since` diff-scoping
17
+ * Resolves the diff the lint command should run against. Returns `null` when
18
+ * there is nothing to lint (e.g. no matching files, clean tree, or empty
19
+ * diff content) callers treat that as the early-exit signal and stop.
20
+ *
21
+ * Extracted from {@link lintCommand} so that function stays under the
22
+ * per-function LOC budget as the command grows; the two file-mode and
23
+ * aggregate-mode branches share no state with the post-lint reporting
24
+ * pipeline, so the split is a pure rename rather than a refactor.
19
25
  */
20
- export async function lintCommand(projectRoot, files, options = {}) {
21
- intro('FireForge Lint');
22
- // `--only-introduced` scopes the exit code to `--since`-tagged issues, so
23
- // without a revision to anchor the diff there is no "introduced" subset
24
- // to scope to — reject the combination up-front so a misconfigured CI
25
- // invocation fails loud instead of silently treating every error as
26
- // cumulative and passing.
27
- if (options.onlyIntroduced && !options.since) {
28
- throw new GeneralError('--only-introduced requires --since <git-rev> so introduced-vs-cumulative can be distinguished.');
29
- }
30
- const paths = getProjectPaths(projectRoot);
31
- if (!(await pathExists(paths.engine))) {
32
- throw new GeneralError('Firefox source not found. Run "fireforge download" first.');
33
- }
34
- if (!(await isGitRepository(paths.engine))) {
35
- throw new GeneralError('Engine directory is not a git repository. Run "fireforge download" to initialize.');
36
- }
37
- let diff;
26
+ async function resolveLintDiff(engineDir, files) {
38
27
  if (files.length > 0) {
39
- // Collect specific files/directories
40
28
  const collectedFiles = new Set();
41
29
  let fileStatuses;
42
30
  let untrackedFiles;
43
- for (const inputPath of files) {
44
- const fullInputPath = join(paths.engine, inputPath);
31
+ // Strip a leading `engine/` segment up-front so the rest of the lookup
32
+ // pipeline (directory stat, modified-files-in-dir, status probe) all
33
+ // see the engine-relative form. Without this, passing
34
+ // `engine/browser/base/content/foo.js` fell through to "No modified
35
+ // files found in the specified paths." because git sees every path
36
+ // relative to engine/. The same normalization runs in `register`,
37
+ // `test`, and `export` via `stripEnginePrefix`.
38
+ const normalizedFiles = files.map((inputPath) => stripEnginePrefix(inputPath));
39
+ for (const inputPath of normalizedFiles) {
40
+ const fullInputPath = join(engineDir, inputPath);
45
41
  let isDirectory = false;
46
42
  try {
47
43
  const fileStat = await stat(fullInputPath);
@@ -52,47 +48,87 @@ export async function lintCommand(projectRoot, files, options = {}) {
52
48
  }
53
49
  if (isDirectory) {
54
50
  const dirPath = inputPath.endsWith('/') ? inputPath.slice(0, -1) : inputPath;
55
- const modifiedFiles = await getModifiedFilesInDir(paths.engine, dirPath);
56
- const dirUntrackedFiles = await getUntrackedFilesInDir(paths.engine, dirPath);
51
+ const modifiedFiles = await getModifiedFilesInDir(engineDir, dirPath);
52
+ const dirUntrackedFiles = await getUntrackedFilesInDir(engineDir, dirPath);
57
53
  for (const f of modifiedFiles)
58
54
  collectedFiles.add(f);
59
55
  for (const f of dirUntrackedFiles)
60
56
  collectedFiles.add(f);
61
57
  }
62
58
  else {
63
- if (!fileStatuses) {
64
- fileStatuses = await getStatusWithCodes(paths.engine);
65
- }
66
- if (!untrackedFiles) {
67
- untrackedFiles = await getUntrackedFiles(paths.engine);
68
- }
59
+ if (!fileStatuses)
60
+ fileStatuses = await getStatusWithCodes(engineDir);
61
+ if (!untrackedFiles)
62
+ untrackedFiles = await getUntrackedFiles(engineDir);
69
63
  const hasStatus = fileStatuses.some((s) => s.file === inputPath) || untrackedFiles.includes(inputPath);
70
- if (hasStatus) {
64
+ if (hasStatus)
71
65
  collectedFiles.add(inputPath);
72
- }
73
66
  }
74
67
  }
75
68
  if (collectedFiles.size === 0) {
76
69
  info('No modified files found in the specified paths.');
77
70
  outro('Nothing to lint');
78
- return;
71
+ return null;
79
72
  }
80
- diff = await getDiffForFilesAgainstHead(paths.engine, [...collectedFiles].sort());
81
- }
82
- else {
83
- // Lint all changes
84
- if (!(await hasChanges(paths.engine))) {
85
- info('No changes to lint.');
73
+ const diff = await getDiffForFilesAgainstHead(engineDir, [...collectedFiles].sort());
74
+ if (!diff.trim()) {
75
+ info('No diff content to lint.');
86
76
  outro('Nothing to lint');
87
- return;
77
+ return null;
88
78
  }
89
- diff = await getAllDiff(paths.engine);
79
+ return diff;
90
80
  }
81
+ if (!(await hasChanges(engineDir))) {
82
+ info('No changes to lint.');
83
+ outro('Nothing to lint');
84
+ return null;
85
+ }
86
+ const diff = await getAllDiff(engineDir);
91
87
  if (!diff.trim()) {
92
88
  info('No diff content to lint.');
93
89
  outro('Nothing to lint');
90
+ return null;
91
+ }
92
+ return diff;
93
+ }
94
+ /**
95
+ * Runs the lint command to check engine changes against patch quality rules.
96
+ * @param projectRoot - Root directory of the project
97
+ * @param files - Optional file/directory paths to lint (relative to engine/)
98
+ * @param options - Additional lint options such as `--since` diff-scoping
99
+ */
100
+ export async function lintCommand(projectRoot, files, options = {}) {
101
+ intro('FireForge Lint');
102
+ // `--only-introduced` scopes the exit code to `--since`-tagged issues, so
103
+ // without a revision to anchor the diff there is no "introduced" subset
104
+ // to scope to — reject the combination up-front so a misconfigured CI
105
+ // invocation fails loud instead of silently treating every error as
106
+ // cumulative and passing.
107
+ if (options.onlyIntroduced && !options.since) {
108
+ throw new GeneralError('--only-introduced requires --since <git-rev> so introduced-vs-cumulative can be distinguished.');
109
+ }
110
+ // `--per-patch` rescopes the diff from "aggregate engine state" to "each
111
+ // patch's own filesAffected". Mixing in explicit file paths would produce
112
+ // an ambiguous set — is the file list an additional filter, or does it
113
+ // replace the per-patch scope? Reject up-front so the operator gets a
114
+ // clear error rather than a silently-narrowed result.
115
+ if (options.perPatch && files.length > 0) {
116
+ throw new GeneralError('--per-patch cannot be combined with explicit file paths. Pass either --per-patch or a file list, not both.');
117
+ }
118
+ const paths = getProjectPaths(projectRoot);
119
+ if (!(await pathExists(paths.engine))) {
120
+ throw new GeneralError('Firefox source not found. Run "fireforge download" first.');
121
+ }
122
+ if (!(await isGitRepository(paths.engine))) {
123
+ throw new GeneralError('Engine directory is not a git repository. Run "fireforge download" to initialize.');
124
+ }
125
+ if (options.perPatch) {
126
+ await lintPerPatch(projectRoot, paths);
94
127
  return;
95
128
  }
129
+ const diff = await resolveLintDiff(paths.engine, files);
130
+ if (diff === null)
131
+ return;
96
132
  const config = await loadConfig(projectRoot);
97
133
  const filesAffected = extractAffectedFiles(diff);
98
134
  // Build patch queue context once so it can be shared between the
@@ -110,6 +146,19 @@ export async function lintCommand(projectRoot, files, options = {}) {
110
146
  if (ctx) {
111
147
  issues.push(...lintPatchQueue(ctx));
112
148
  }
149
+ // When a queue manifest exists AND files were NOT scoped explicitly, the
150
+ // "diff" we just linted is every applied patch summed together. Patch-
151
+ // size rules (`large-patch-lines`, `large-patch-files`) then fire against
152
+ // the aggregate rather than any individual patch, producing counts like
153
+ // "Patch is 37529 lines" that read as a task-specific regression but are
154
+ // really an artefact of aggregation. Surface a one-line note pointing at
155
+ // `--per-patch` so the operator knows the per-patch scope exists before
156
+ // they read the error message as "my queue is broken".
157
+ const aggregateHintApplicable = files.length === 0 && ctx !== undefined && ctx.entries.length > 1;
158
+ if (aggregateHintApplicable &&
159
+ issues.some((i) => i.check === 'large-patch-lines' || i.check === 'large-patch-files')) {
160
+ info('NOTE: aggregate diff across all applied patches. Use `fireforge lint --per-patch` to lint each patch individually; patch-size rules fire against the sum in aggregate mode.');
161
+ }
113
162
  if (issues.length === 0) {
114
163
  success('No lint issues found.');
115
164
  outro('Lint passed');
@@ -164,13 +213,83 @@ export async function lintCommand(projectRoot, files, options = {}) {
164
213
  }
165
214
  outro('Lint passed with warnings');
166
215
  }
216
+ /**
217
+ * Lints each patch in the queue as its own isolated diff, honouring
218
+ * per-patch `lintIgnore` entries. Cross-patch rules still run once over
219
+ * the whole queue so queue-level findings (duplicate creations, forward
220
+ * imports) are not lost by the rescoping.
221
+ *
222
+ * Kept separate from {@link lintCommand}'s aggregate path because the
223
+ * two scopes have genuinely different contracts — the aggregate path
224
+ * reports what `git diff HEAD` looks like right now, the per-patch
225
+ * path reports what each patch's own slice of that diff looks like.
226
+ * Sharing a loop would hide the distinction and force the caller to
227
+ * decide semantics mid-function.
228
+ */
229
+ async function lintPerPatch(projectRoot, paths) {
230
+ const manifest = await loadPatchesManifest(paths.patches);
231
+ if (!manifest || manifest.patches.length === 0) {
232
+ info('No patches in manifest — nothing to lint per-patch.');
233
+ outro('Nothing to lint');
234
+ return;
235
+ }
236
+ const config = await loadConfig(projectRoot);
237
+ const ctx = await buildPatchQueueContext(paths.patches);
238
+ const issues = [];
239
+ let linted = 0;
240
+ for (const patch of manifest.patches) {
241
+ const existing = [];
242
+ for (const f of patch.filesAffected) {
243
+ if (await pathExists(join(paths.engine, f)))
244
+ existing.push(f);
245
+ }
246
+ if (existing.length === 0)
247
+ continue;
248
+ const diff = await getDiffForFilesAgainstHead(paths.engine, existing);
249
+ if (!diff.trim())
250
+ continue;
251
+ const ignore = patch.lintIgnore?.length ? new Set(patch.lintIgnore) : undefined;
252
+ const patchIssues = await lintExportedPatch(paths.engine, existing, diff, config, ctx, ignore);
253
+ for (const issue of patchIssues) {
254
+ issues.push({ ...issue, file: `${patch.filename} :: ${issue.file}` });
255
+ }
256
+ linted++;
257
+ }
258
+ // Cross-patch rules over the whole queue — rescoping per-patch would
259
+ // lose these findings, so they run exactly once against the full
260
+ // context.
261
+ issues.push(...lintPatchQueue(ctx));
262
+ if (issues.length === 0) {
263
+ success(`No lint issues found across ${linted} patch(es).`);
264
+ outro('Lint passed');
265
+ return;
266
+ }
267
+ const errors = issues.filter((i) => i.severity === 'error');
268
+ const warnings = issues.filter((i) => i.severity === 'warning');
269
+ const notices = issues.filter((i) => i.severity === 'notice');
270
+ for (const issue of notices)
271
+ info(`NOTICE [${issue.check}] ${issue.file}: ${issue.message}`);
272
+ for (const issue of warnings)
273
+ warn(`[${issue.check}] ${issue.file}: ${issue.message}`);
274
+ for (const issue of errors)
275
+ warn(`ERROR [${issue.check}] ${issue.file}: ${issue.message}`);
276
+ info(`\nLint (per-patch over ${linted} patch(es)): ${errors.length} error(s), ${warnings.length} warning(s)`);
277
+ if (errors.length > 0) {
278
+ outro('Lint failed');
279
+ throw new GeneralError(`Patch lint found ${errors.length} error(s) across ${linted} patch(es). Fix these before exporting.`);
280
+ }
281
+ outro('Lint passed with warnings');
282
+ }
167
283
  /** Registers the lint command on the CLI program. */
168
284
  export function registerLint(program, { getProjectRoot, withErrorHandling }) {
169
285
  program
170
286
  .command('lint [paths...]')
171
- .description('Lint engine changes against patch quality rules')
287
+ .description('Lint engine changes against patch quality rules. Default: aggregate diff against HEAD ' +
288
+ '(every applied patch summed). Use --per-patch for per-patch scope, or pass explicit ' +
289
+ 'file paths to narrow to those.')
172
290
  .option('--since <git-rev>', 'Tag issues as [introduced] or [cumulative] based on whether the file changed since <git-rev> (e.g. HEAD, a branch, a SHA)')
173
291
  .option('--only-introduced', 'Fail only on issues tagged [introduced] (requires --since). Cumulative errors still print but do not set a non-zero exit.')
292
+ .option('--per-patch', "Lint each patch in the queue as its own isolated diff. Rescopes patch-size rules so they fire against individual patches rather than the aggregate. Honours each patch's `lintIgnore` entries.")
174
293
  .action(withErrorHandling(async (paths, options) => {
175
294
  const lintOptions = {};
176
295
  if (options.since !== undefined) {
@@ -179,6 +298,9 @@ export function registerLint(program, { getProjectRoot, withErrorHandling }) {
179
298
  if (options.onlyIntroduced !== undefined) {
180
299
  lintOptions.onlyIntroduced = options.onlyIntroduced;
181
300
  }
301
+ if (options.perPatch !== undefined) {
302
+ lintOptions.perPatch = options.perPatch;
303
+ }
182
304
  await lintCommand(getProjectRoot(), paths, lintOptions);
183
305
  }));
184
306
  }
@@ -1,7 +1,8 @@
1
1
  import { validateBrandOverride } from '../core/brand-validation.js';
2
2
  import { prepareBuildEnvironment } from '../core/build-prepare.js';
3
3
  import { getProjectPaths, loadConfig } from '../core/config.js';
4
- import { buildArtifactMismatchMessage, hasBuildArtifacts, machPackage } from '../core/mach.js';
4
+ import { buildArtifactMismatchMessage, hasBuildArtifacts, machPackageCapture, } from '../core/mach.js';
5
+ import { explainMachError } from '../core/mach-error-hints.js';
5
6
  import { GeneralError } from '../errors/base.js';
6
7
  import { AmbiguousBuildArtifactsError, BuildError } from '../errors/build.js';
7
8
  import { pathExists } from '../utils/fs.js';
@@ -49,9 +50,16 @@ export async function packageCommand(projectRoot, options) {
49
50
  info('Creating distribution package...');
50
51
  info('This may take a while.\n');
51
52
  const startTime = Date.now();
52
- let exitCode;
53
+ let result;
53
54
  try {
54
- exitCode = await machPackage(paths.engine);
55
+ // `machPackageCapture` streams output live AND captures the tail for
56
+ // post-run diagnostics. Previously `machPackage` inherited stdio
57
+ // only, so a targeted hint translator could not see the failure text.
58
+ // The captured stderr is fed through `explainMachError` below so
59
+ // recognised failure modes (notably the `packager.py` NoneType trip
60
+ // the evaluator hit on `hominis/`) get an actionable hint prepended
61
+ // to the raw mach output the operator already saw.
62
+ result = await machPackageCapture(paths.engine);
55
63
  }
56
64
  catch (error) {
57
65
  throw new BuildError('Package process failed to start', 'mach package', error instanceof Error ? error : undefined);
@@ -60,9 +68,12 @@ export async function packageCommand(projectRoot, options) {
60
68
  const minutes = Math.floor(duration / 60000);
61
69
  const seconds = Math.floor((duration % 60000) / 1000);
62
70
  const timeStr = minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
63
- if (exitCode !== 0) {
71
+ if (result.exitCode !== 0) {
64
72
  error(`Packaging failed after ${timeStr}`);
65
- throw new BuildError(`Packaging failed with exit code ${exitCode}`, 'mach package');
73
+ const combinedOutput = `${result.stdout}\n${result.stderr}`;
74
+ const hints = explainMachError(combinedOutput);
75
+ const hintBlock = hints.length > 0 ? `\n\nHint:\n${hints.map((h) => ` ${h}`).join('\n')}` : '';
76
+ throw new BuildError(`Packaging failed with exit code ${result.exitCode}.${hintBlock}`, 'mach package');
66
77
  }
67
78
  info(`\nPackage created in obj-*/dist/`);
68
79
  outro(`Packaging completed in ${timeStr}!`);
@@ -71,8 +71,12 @@ export async function reExportFilesInPlace(paths, selectedPatches, options, conf
71
71
  'Remove those paths from --files or modify them before retrying.', '--files');
72
72
  }
73
73
  // Run the per-patch lint against the projected diff. This mirrors what
74
- // runPatchLint does in the standard re-export path.
75
- await runPatchLint(paths.engine, actualProjectedFiles, projectedDiff, config, options.skipLint);
74
+ // runPatchLint does in the standard re-export path. The target patch's
75
+ // `lintIgnore` threads through so a shrink of an advisory-noisy-but-
76
+ // intentional patch (branding bundle, localised-resource pack) does not
77
+ // have to choose between `--skip-lint` (blunt) and the full rebase path.
78
+ const ignoreChecks = target.lintIgnore?.length ? new Set(target.lintIgnore) : undefined;
79
+ await runPatchLint(paths.engine, actualProjectedFiles, projectedDiff, config, options.skipLint, undefined, ignoreChecks);
76
80
  // Project the cross-patch context: replace the target entry with its
77
81
  // would-be shrunken self (new diff + new newFiles + new
78
82
  // modifiedFileAdditions). The projected entry must repopulate both
@@ -6,7 +6,7 @@ import { isGitRepository } from '../core/git.js';
6
6
  import { getDiffForFilesAgainstHead } from '../core/git-diff.js';
7
7
  import { getModifiedFilesInDir, getUntrackedFilesInDir } from '../core/git-status.js';
8
8
  import { updatePatchAndMetadata } from '../core/patch-export.js';
9
- import { getClaimedFiles, loadPatchesManifest, resolvePatchIdentifier, } from '../core/patch-manifest.js';
9
+ import { getClaimedFiles, loadPatchesManifest, resolvePatchIdentifier, stampPatchVersions, } from '../core/patch-manifest.js';
10
10
  import { GeneralError, InvalidArgumentError } from '../errors/base.js';
11
11
  import { toError } from '../utils/errors.js';
12
12
  import { pathExists } from '../utils/fs.js';
@@ -59,6 +59,31 @@ async function reExportSinglePatch(patch, paths, manifest, options, isDryRun, co
59
59
  if (options.scan) {
60
60
  currentFilesAffected = await scanPatchFiles(currentFilesAffected, paths.engine, manifest, patch.filename, isDryRun);
61
61
  }
62
+ else if (options.files === undefined) {
63
+ // Finding #16: when neither `--scan` nor `--files` is set and some
64
+ // of the manifest's claimed files no longer exist on disk, the
65
+ // re-export silently writes a refreshed body whose filesAffected
66
+ // still names the vanished paths. That is the documented contract,
67
+ // but it is also a footgun — a later `verify` then fails on
68
+ // manifest-consistency with no obvious trigger. Emit one advisory
69
+ // warning up-front when we can detect the drift cheaply, so the
70
+ // operator has a chance to re-run with `--scan` or `--files`
71
+ // before the stale filesAffected lands in patches.json.
72
+ const missingFiles = [];
73
+ for (const file of currentFilesAffected) {
74
+ if (!(await pathExists(join(paths.engine, file)))) {
75
+ missingFiles.push(file);
76
+ }
77
+ }
78
+ if (missingFiles.length > 0) {
79
+ warn(`${patch.filename}: some files in patches.json no longer exist on disk ` +
80
+ `(${missingFiles.join(', ')}). Without --scan, re-export keeps the manifest's ` +
81
+ `filesAffected unchanged and the missing entries will be preserved — ` +
82
+ `\`fireforge verify\` may flag manifest inconsistency after this run.\n` +
83
+ ` Re-run with --scan to reconcile filesAffected with the current worktree, ` +
84
+ `or pass --files <paths> to set the list explicitly.`);
85
+ }
86
+ }
62
87
  // --- Explicit file-subset path ---
63
88
  // When --files is given, the target filesAffected is authoritative — drop
64
89
  // anything not in the list, add anything new. This is the surgical repair
@@ -97,7 +122,14 @@ async function reExportSinglePatch(patch, paths, manifest, options, isDryRun, co
97
122
  warn(`Skipped ${patch.filename}: no changes (files unchanged from HEAD)`);
98
123
  return false;
99
124
  }
100
- await runPatchLint(paths.engine, existingFiles, diffContent, config, options.skipLint);
125
+ // Thread the patch's own `lintIgnore` list through so the per-patch
126
+ // suppression honoured by export/export-all is also honoured here.
127
+ // Without this, `re-export` could not refresh an advisory-noisy but
128
+ // intentional patch (a cohesive branding bundle, a localised-resource
129
+ // pack) without either `--skip-lint` (too blunt) or falling through to
130
+ // the full `rebase` flow (which internally skips the lint pipeline).
131
+ const ignoreChecks = patch.lintIgnore?.length ? new Set(patch.lintIgnore) : undefined;
132
+ await runPatchLint(paths.engine, existingFiles, diffContent, config, options.skipLint, undefined, ignoreChecks);
101
133
  if (isDryRun) {
102
134
  info(`[dry-run] ${patch.filename}: ${existingFiles.length} file(s)`);
103
135
  }
@@ -219,13 +251,16 @@ export async function reExportCommand(projectRoot, patches, options) {
219
251
  }
220
252
  const config = await loadConfig(projectRoot);
221
253
  let reExported = 0;
254
+ const reExportedFilenames = [];
222
255
  const progress = spinner('Preparing re-export...');
223
256
  for (const patch of selectedPatches) {
224
257
  progress.message(`Re-exporting ${patch.filename}...`);
225
258
  try {
226
259
  const exported = await reExportSinglePatch(patch, paths, manifest, options, isDryRun, config);
227
- if (exported)
260
+ if (exported) {
228
261
  reExported++;
262
+ reExportedFilenames.push(patch.filename);
263
+ }
229
264
  }
230
265
  catch (error) {
231
266
  warn(`Failed to re-export ${patch.filename}`);
@@ -236,14 +271,34 @@ export async function reExportCommand(projectRoot, patches, options) {
236
271
  progress.error('Re-export failed');
237
272
  throw new GeneralError('All selected patches failed to re-export. Check the errors above.');
238
273
  }
274
+ // `--stamp` only fires on a run where every selected patch refreshed
275
+ // cleanly. A partial success would leave some patches with a stale body
276
+ // but a new version — the opposite of the "what I tested, what the
277
+ // manifest says" invariant `sourceEsrVersion` exists to record. A
278
+ // non-empty `reExportedFilenames` with fewer entries than `selectedPatches`
279
+ // means a lint failure or missing-file skip landed somewhere in the loop,
280
+ // which we refuse to version-stamp through.
281
+ const shouldStamp = options.stamp === true && !isDryRun && reExported > 0 && reExported === selectedPatches.length;
282
+ if (shouldStamp) {
283
+ await stampPatchVersions(paths.patches, reExportedFilenames, config.firefox.version);
284
+ }
239
285
  if (isDryRun) {
240
286
  progress.stop('Dry run complete');
241
287
  success(`[dry-run] Would re-export ${reExported} of ${selectedPatches.length} patch(es)`);
288
+ if (options.stamp === true) {
289
+ info(`[dry-run] Would stamp sourceEsrVersion=${config.firefox.version} on ${reExported} patch(es)`);
290
+ }
242
291
  outro('Dry run complete');
243
292
  }
244
293
  else {
245
294
  progress.stop('Re-export complete');
246
295
  success(`Re-exported ${reExported} of ${selectedPatches.length} patch(es)`);
296
+ if (shouldStamp) {
297
+ success(`Stamped sourceEsrVersion=${config.firefox.version} on ${reExportedFilenames.length} patch(es)`);
298
+ }
299
+ else if (options.stamp === true && reExported !== selectedPatches.length) {
300
+ warn('--stamp was requested but some patches failed or were skipped; refusing to stamp a partial set.');
301
+ }
247
302
  outro('Re-export complete');
248
303
  }
249
304
  }
@@ -251,7 +306,9 @@ export async function reExportCommand(projectRoot, patches, options) {
251
306
  export function registerReExport(program, { getProjectRoot, withErrorHandling }) {
252
307
  program
253
308
  .command('re-export [patches...]')
254
- .description('Re-export existing patches from current engine state')
309
+ .description('Refresh existing patch bodies (and filesAffected with --scan) from the current engine ' +
310
+ 'state. Does NOT change sourceEsrVersion by default — use --stamp or run rebase for ' +
311
+ 'version stamping.')
255
312
  .option('-a, --all', 'Re-export all patches')
256
313
  .option('-s, --scan', 'Scan directories for new/removed files and update filesAffected')
257
314
  .option('--files <paths>', 'Restrict the re-exported filesAffected to this comma-separated list (single target patch only)', (value) => value
@@ -262,6 +319,7 @@ export function registerReExport(program, { getProjectRoot, withErrorHandling })
262
319
  .option('--skip-lint', 'Skip patch lint checks (downgrade errors to warnings)')
263
320
  .option('-y, --yes', 'Skip confirmation when --files shrinks a patch (required for non-TTY)')
264
321
  .option('--force-unsafe', 'Bypass cross-patch lint refusal when --files shrinks a patch')
322
+ .option('--stamp', "After every selected patch refreshes cleanly, stamp each re-exported patch's sourceEsrVersion in patches.json to firefox.version from fireforge.json. No effect on a partial run.")
265
323
  .action(withErrorHandling(async (patches, options) => {
266
324
  await reExportCommand(getProjectRoot(), patches, pickDefined(options));
267
325
  }));
@@ -6,23 +6,7 @@ import { InvalidArgumentError } from '../errors/base.js';
6
6
  import { pathExists } from '../utils/fs.js';
7
7
  import { info, intro, outro, success, warn } from '../utils/logger.js';
8
8
  import { pickDefined } from '../utils/options.js';
9
- /**
10
- * Strips a leading `engine/` segment (either separator flavour) from a
11
- * user-supplied path so operators can pass either a repo-root-relative
12
- * path (`engine/browser/base/content/foo.xhtml`) or an engine-relative
13
- * path (`browser/base/content/foo.xhtml`). The engine-relative form is
14
- * what the manifest writers expect; without this normalisation, the
15
- * former failed with a misleading "File not found in engine" pointing
16
- * at a doubled path like `engine/engine/browser/...` that operators
17
- * had no way to spot from the error message alone.
18
- */
19
- function normalizeEngineRelativePath(filePath) {
20
- if (filePath.startsWith('engine/'))
21
- return filePath.slice('engine/'.length);
22
- if (filePath.startsWith('engine\\'))
23
- return filePath.slice('engine\\'.length);
24
- return filePath;
25
- }
9
+ import { stripEnginePrefix } from '../utils/paths.js';
26
10
  /**
27
11
  * Registers a file in the appropriate build manifest.
28
12
  *
@@ -50,7 +34,7 @@ export async function registerCommand(projectRoot, filePath, options = {}) {
50
34
  // the former from the output of tab completion or `git status`, and
51
35
  // the mismatch used to produce a "File not found" error that named
52
36
  // the original path with no hint that dropping `engine/` would fix it.
53
- const engineRelativePath = normalizeEngineRelativePath(filePath);
37
+ const engineRelativePath = stripEnginePrefix(filePath);
54
38
  // Verify the file exists in engine/ (skip for dry-run)
55
39
  if (!options.dryRun) {
56
40
  const paths = getProjectPaths(projectRoot);
@@ -2,9 +2,9 @@
2
2
  import { createWriteStream } from 'node:fs';
3
3
  import { readdir, readFile } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
- import { getProjectPaths } from '../core/config.js';
5
+ import { getProjectPaths, loadConfig } from '../core/config.js';
6
6
  import { warnIfFurnaceStale } from '../core/furnace-staleness.js';
7
- import { buildArtifactMismatchMessage, hasBuildArtifacts, run, runMachSmoke, } from '../core/mach.js';
7
+ import { buildArtifactMismatchMessage, hasBuildArtifacts, hasRunnableBundle, run, runMachSmoke, } from '../core/mach.js';
8
8
  import { compileAllowlistFromFile, compileAllowlistFromStrings, matchesAllowlist, matchesSmokeError, } from '../core/smoke-patterns.js';
9
9
  import { GeneralError, InvalidArgumentError } from '../errors/base.js';
10
10
  import { AmbiguousBuildArtifactsError, BuildError } from '../errors/build.js';
@@ -94,6 +94,27 @@ export async function runCommand(projectRoot, options = {}) {
94
94
  throw new GeneralError(`Run requires a completed build. ${detail}\n\n` +
95
95
  "Run 'fireforge build' first, then rerun 'fireforge run'.");
96
96
  }
97
+ // `hasBuildArtifacts` only checks for an `obj-*/dist/` directory; a
98
+ // build that configured but hasn't yet produced the launchable binary
99
+ // (common in a long real Firefox compile that the operator stopped
100
+ // and restarted) passes that check, and `mach run` then fails on the
101
+ // missing binary path. `hasRunnableBundle` narrows the probe to the
102
+ // actual executable so `fireforge run` refuses with a targeted
103
+ // message before handing control to mach. `fireforge watch` stays
104
+ // permissive and instead surfaces the same information as a banner
105
+ // suffix; watch is supposed to drive rebuilds of partially-built
106
+ // trees, so blocking there would defeat the feature.
107
+ if (buildCheck.objDir) {
108
+ const config = await loadConfig(projectRoot);
109
+ const bundleCheck = await hasRunnableBundle(paths.engine, config.binaryName, buildCheck.objDir);
110
+ if (!bundleCheck.runnable) {
111
+ const expected = bundleCheck.expectedPath ?? `dist/bin/${config.binaryName}`;
112
+ throw new GeneralError(`Run requires a completed build that produced the launchable bundle. ` +
113
+ `Build artifacts exist in ${buildCheck.objDir}/ but the expected binary at ${expected} is missing — ` +
114
+ `the build may have aborted or is still in progress.\n\n` +
115
+ "Run 'fireforge build' and wait for it to finish before retrying 'fireforge run'.");
116
+ }
117
+ }
97
118
  // Warn if Furnace components changed since the last apply
98
119
  await warnIfFurnaceStale(projectRoot);
99
120
  // Clean stale profile state to prevent silent startup failures
@@ -12,7 +12,7 @@ import { buildPatchQueueContext, collectNewFileCreatorsByPath } from '../core/pa
12
12
  import { loadPatchesManifest } from '../core/patch-manifest.js';
13
13
  import { GeneralError } from '../errors/base.js';
14
14
  import { toError } from '../utils/errors.js';
15
- import { pathExists, readText } from '../utils/fs.js';
15
+ import { FIREFORGE_TMP_PATH_PATTERN, pathExists, readText } from '../utils/fs.js';
16
16
  import { info, intro, outro, verbose, warn } from '../utils/logger.js';
17
17
  /**
18
18
  * Status code descriptions for git status.
@@ -164,6 +164,21 @@ async function expandDirectoryEntries(files, engineDir) {
164
164
  }
165
165
  return { entries: expanded, truncations };
166
166
  }
167
+ /**
168
+ * Strips entries whose path matches the atomic-temp-file shape
169
+ * FireForge's own `writeText` produces (see
170
+ * {@link import('../utils/fs.js').FIREFORGE_TMP_PATH_PATTERN}). Those
171
+ * files only exist for the duration of a write + rename and should
172
+ * never appear in `status` output; filtering them here keeps every
173
+ * status mode (default, raw, unmanaged, ownership, json) symmetric so
174
+ * the operator never sees a `.mozconfig.fireforge-tmp-<pid>-<uuid>`
175
+ * entry mid-write. Files named for unrelated reasons (e.g. a user's
176
+ * `.bashrc.fireforge-tmp-backup` without the PID+UUID tail) do not
177
+ * match the pattern and pass through unfiltered.
178
+ */
179
+ function filterFireForgeTempFiles(files) {
180
+ return files.filter((entry) => !FIREFORGE_TMP_PATH_PATTERN.test(entry.file));
181
+ }
167
182
  /**
168
183
  * Classifies files into patch-backed, unmanaged, or branding buckets.
169
184
  */
@@ -277,7 +292,11 @@ export async function statusCommand(projectRoot, options = {}) {
277
292
  const ownershipExpansion = (await isGitRepository(paths.engine))
278
293
  ? await expandDirectoryEntries(await getStatusWithCodes(paths.engine), paths.engine)
279
294
  : { entries: [], truncations: [] };
280
- const rawFilesOwnership = ownershipExpansion.entries;
295
+ // Filter atomic-write temp files (Finding #18) so a mid-flight
296
+ // `.fireforge-tmp-<pid>-<uuid>` artefact never shows up in any
297
+ // status mode. The pattern is tight enough to let legitimately
298
+ // similar names through.
299
+ const rawFilesOwnership = filterFireForgeTempFiles(ownershipExpansion.entries);
281
300
  renderTruncationBanner(ownershipExpansion.truncations);
282
301
  // Only walk the patch bodies when the directory actually exists.
283
302
  // Fresh projects with no patch queue yet pass through with an empty
@@ -313,7 +332,10 @@ export async function statusCommand(projectRoot, options = {}) {
313
332
  throw new GeneralError('Engine directory is not a git repository. Run "fireforge download" to initialize.');
314
333
  }
315
334
  const rawFiles = await getStatusWithCodes(paths.engine);
316
- const { entries: files, truncations } = await expandDirectoryEntries(rawFiles, paths.engine);
335
+ const { entries: expanded, truncations } = await expandDirectoryEntries(rawFiles, paths.engine);
336
+ // Strip atomic-write temp files (Finding #18) before every mode
337
+ // branch so raw / unmanaged / default / json all agree.
338
+ const files = filterFireForgeTempFiles(expanded);
317
339
  renderTruncationBanner(truncations);
318
340
  if (files.length === 0) {
319
341
  info('No modified files');