@hominis/fireforge 0.15.7 → 0.15.9

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 (51) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +103 -12
  3. package/dist/src/commands/export-shared.d.ts +6 -1
  4. package/dist/src/commands/export-shared.js +7 -2
  5. package/dist/src/commands/furnace/create-dry-run.d.ts +7 -0
  6. package/dist/src/commands/furnace/create-dry-run.js +7 -2
  7. package/dist/src/commands/furnace/create-features.d.ts +24 -0
  8. package/dist/src/commands/furnace/create-features.js +56 -0
  9. package/dist/src/commands/furnace/create-templates.d.ts +9 -5
  10. package/dist/src/commands/furnace/create-templates.js +14 -6
  11. package/dist/src/commands/furnace/create.js +34 -39
  12. package/dist/src/commands/furnace/index.js +1 -0
  13. package/dist/src/commands/lint.d.ts +20 -0
  14. package/dist/src/commands/lint.js +157 -44
  15. package/dist/src/commands/re-export-files.js +6 -2
  16. package/dist/src/commands/re-export.js +37 -4
  17. package/dist/src/commands/run.d.ts +15 -1
  18. package/dist/src/commands/run.js +202 -7
  19. package/dist/src/commands/test.js +97 -2
  20. package/dist/src/core/furnace-apply-ftl.d.ts +5 -3
  21. package/dist/src/core/furnace-apply-ftl.js +6 -2
  22. package/dist/src/core/furnace-apply-helpers.js +14 -4
  23. package/dist/src/core/furnace-config-custom.d.ts +14 -0
  24. package/dist/src/core/furnace-config-custom.js +64 -0
  25. package/dist/src/core/furnace-config.js +2 -39
  26. package/dist/src/core/furnace-validate-accessibility.d.ts +9 -2
  27. package/dist/src/core/furnace-validate-accessibility.js +17 -3
  28. package/dist/src/core/furnace-validate-helpers.d.ts +13 -1
  29. package/dist/src/core/furnace-validate-helpers.js +19 -0
  30. package/dist/src/core/furnace-validate-structure.js +6 -2
  31. package/dist/src/core/furnace-validate.js +6 -3
  32. package/dist/src/core/mach.d.ts +26 -0
  33. package/dist/src/core/mach.js +25 -1
  34. package/dist/src/core/patch-lint.d.ts +6 -1
  35. package/dist/src/core/patch-lint.js +14 -1
  36. package/dist/src/core/shared-ftl.d.ts +28 -0
  37. package/dist/src/core/shared-ftl.js +42 -0
  38. package/dist/src/core/smoke-patterns.d.ts +45 -0
  39. package/dist/src/core/smoke-patterns.js +100 -0
  40. package/dist/src/core/xpcshell-appdir.d.ts +143 -0
  41. package/dist/src/core/xpcshell-appdir.js +273 -0
  42. package/dist/src/errors/codes.d.ts +13 -0
  43. package/dist/src/errors/codes.js +13 -0
  44. package/dist/src/errors/run.d.ts +16 -0
  45. package/dist/src/errors/run.js +22 -0
  46. package/dist/src/types/commands/options.d.ts +58 -0
  47. package/dist/src/types/commands/patches.d.ts +22 -0
  48. package/dist/src/types/furnace.d.ts +39 -0
  49. package/dist/src/utils/process.d.ts +63 -0
  50. package/dist/src/utils/process.js +122 -0
  51. 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,27 @@ 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';
14
15
  /**
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
16
+ * Resolves the diff the lint command should run against. Returns `null` when
17
+ * there is nothing to lint (e.g. no matching files, clean tree, or empty
18
+ * diff content) callers treat that as the early-exit signal and stop.
19
+ *
20
+ * Extracted from {@link lintCommand} so that function stays under the
21
+ * per-function LOC budget as the command grows; the two file-mode and
22
+ * aggregate-mode branches share no state with the post-lint reporting
23
+ * pipeline, so the split is a pure rename rather than a refactor.
19
24
  */
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;
25
+ async function resolveLintDiff(engineDir, files) {
38
26
  if (files.length > 0) {
39
- // Collect specific files/directories
40
27
  const collectedFiles = new Set();
41
28
  let fileStatuses;
42
29
  let untrackedFiles;
43
30
  for (const inputPath of files) {
44
- const fullInputPath = join(paths.engine, inputPath);
31
+ const fullInputPath = join(engineDir, inputPath);
45
32
  let isDirectory = false;
46
33
  try {
47
34
  const fileStat = await stat(fullInputPath);
@@ -52,47 +39,87 @@ export async function lintCommand(projectRoot, files, options = {}) {
52
39
  }
53
40
  if (isDirectory) {
54
41
  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);
42
+ const modifiedFiles = await getModifiedFilesInDir(engineDir, dirPath);
43
+ const dirUntrackedFiles = await getUntrackedFilesInDir(engineDir, dirPath);
57
44
  for (const f of modifiedFiles)
58
45
  collectedFiles.add(f);
59
46
  for (const f of dirUntrackedFiles)
60
47
  collectedFiles.add(f);
61
48
  }
62
49
  else {
63
- if (!fileStatuses) {
64
- fileStatuses = await getStatusWithCodes(paths.engine);
65
- }
66
- if (!untrackedFiles) {
67
- untrackedFiles = await getUntrackedFiles(paths.engine);
68
- }
50
+ if (!fileStatuses)
51
+ fileStatuses = await getStatusWithCodes(engineDir);
52
+ if (!untrackedFiles)
53
+ untrackedFiles = await getUntrackedFiles(engineDir);
69
54
  const hasStatus = fileStatuses.some((s) => s.file === inputPath) || untrackedFiles.includes(inputPath);
70
- if (hasStatus) {
55
+ if (hasStatus)
71
56
  collectedFiles.add(inputPath);
72
- }
73
57
  }
74
58
  }
75
59
  if (collectedFiles.size === 0) {
76
60
  info('No modified files found in the specified paths.');
77
61
  outro('Nothing to lint');
78
- return;
62
+ return null;
79
63
  }
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.');
64
+ const diff = await getDiffForFilesAgainstHead(engineDir, [...collectedFiles].sort());
65
+ if (!diff.trim()) {
66
+ info('No diff content to lint.');
86
67
  outro('Nothing to lint');
87
- return;
68
+ return null;
88
69
  }
89
- diff = await getAllDiff(paths.engine);
70
+ return diff;
90
71
  }
72
+ if (!(await hasChanges(engineDir))) {
73
+ info('No changes to lint.');
74
+ outro('Nothing to lint');
75
+ return null;
76
+ }
77
+ const diff = await getAllDiff(engineDir);
91
78
  if (!diff.trim()) {
92
79
  info('No diff content to lint.');
93
80
  outro('Nothing to lint');
81
+ return null;
82
+ }
83
+ return diff;
84
+ }
85
+ /**
86
+ * Runs the lint command to check engine changes against patch quality rules.
87
+ * @param projectRoot - Root directory of the project
88
+ * @param files - Optional file/directory paths to lint (relative to engine/)
89
+ * @param options - Additional lint options such as `--since` diff-scoping
90
+ */
91
+ export async function lintCommand(projectRoot, files, options = {}) {
92
+ intro('FireForge Lint');
93
+ // `--only-introduced` scopes the exit code to `--since`-tagged issues, so
94
+ // without a revision to anchor the diff there is no "introduced" subset
95
+ // to scope to — reject the combination up-front so a misconfigured CI
96
+ // invocation fails loud instead of silently treating every error as
97
+ // cumulative and passing.
98
+ if (options.onlyIntroduced && !options.since) {
99
+ throw new GeneralError('--only-introduced requires --since <git-rev> so introduced-vs-cumulative can be distinguished.');
100
+ }
101
+ // `--per-patch` rescopes the diff from "aggregate engine state" to "each
102
+ // patch's own filesAffected". Mixing in explicit file paths would produce
103
+ // an ambiguous set — is the file list an additional filter, or does it
104
+ // replace the per-patch scope? Reject up-front so the operator gets a
105
+ // clear error rather than a silently-narrowed result.
106
+ if (options.perPatch && files.length > 0) {
107
+ throw new GeneralError('--per-patch cannot be combined with explicit file paths. Pass either --per-patch or a file list, not both.');
108
+ }
109
+ const paths = getProjectPaths(projectRoot);
110
+ if (!(await pathExists(paths.engine))) {
111
+ throw new GeneralError('Firefox source not found. Run "fireforge download" first.');
112
+ }
113
+ if (!(await isGitRepository(paths.engine))) {
114
+ throw new GeneralError('Engine directory is not a git repository. Run "fireforge download" to initialize.');
115
+ }
116
+ if (options.perPatch) {
117
+ await lintPerPatch(projectRoot, paths);
94
118
  return;
95
119
  }
120
+ const diff = await resolveLintDiff(paths.engine, files);
121
+ if (diff === null)
122
+ return;
96
123
  const config = await loadConfig(projectRoot);
97
124
  const filesAffected = extractAffectedFiles(diff);
98
125
  // Build patch queue context once so it can be shared between the
@@ -110,6 +137,19 @@ export async function lintCommand(projectRoot, files, options = {}) {
110
137
  if (ctx) {
111
138
  issues.push(...lintPatchQueue(ctx));
112
139
  }
140
+ // When a queue manifest exists AND files were NOT scoped explicitly, the
141
+ // "diff" we just linted is every applied patch summed together. Patch-
142
+ // size rules (`large-patch-lines`, `large-patch-files`) then fire against
143
+ // the aggregate rather than any individual patch, producing counts like
144
+ // "Patch is 37529 lines" that read as a task-specific regression but are
145
+ // really an artefact of aggregation. Surface a one-line note pointing at
146
+ // `--per-patch` so the operator knows the per-patch scope exists before
147
+ // they read the error message as "my queue is broken".
148
+ const aggregateHintApplicable = files.length === 0 && ctx !== undefined && ctx.entries.length > 1;
149
+ if (aggregateHintApplicable &&
150
+ issues.some((i) => i.check === 'large-patch-lines' || i.check === 'large-patch-files')) {
151
+ 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.');
152
+ }
113
153
  if (issues.length === 0) {
114
154
  success('No lint issues found.');
115
155
  outro('Lint passed');
@@ -164,13 +204,83 @@ export async function lintCommand(projectRoot, files, options = {}) {
164
204
  }
165
205
  outro('Lint passed with warnings');
166
206
  }
207
+ /**
208
+ * Lints each patch in the queue as its own isolated diff, honouring
209
+ * per-patch `lintIgnore` entries. Cross-patch rules still run once over
210
+ * the whole queue so queue-level findings (duplicate creations, forward
211
+ * imports) are not lost by the rescoping.
212
+ *
213
+ * Kept separate from {@link lintCommand}'s aggregate path because the
214
+ * two scopes have genuinely different contracts — the aggregate path
215
+ * reports what `git diff HEAD` looks like right now, the per-patch
216
+ * path reports what each patch's own slice of that diff looks like.
217
+ * Sharing a loop would hide the distinction and force the caller to
218
+ * decide semantics mid-function.
219
+ */
220
+ async function lintPerPatch(projectRoot, paths) {
221
+ const manifest = await loadPatchesManifest(paths.patches);
222
+ if (!manifest || manifest.patches.length === 0) {
223
+ info('No patches in manifest — nothing to lint per-patch.');
224
+ outro('Nothing to lint');
225
+ return;
226
+ }
227
+ const config = await loadConfig(projectRoot);
228
+ const ctx = await buildPatchQueueContext(paths.patches);
229
+ const issues = [];
230
+ let linted = 0;
231
+ for (const patch of manifest.patches) {
232
+ const existing = [];
233
+ for (const f of patch.filesAffected) {
234
+ if (await pathExists(join(paths.engine, f)))
235
+ existing.push(f);
236
+ }
237
+ if (existing.length === 0)
238
+ continue;
239
+ const diff = await getDiffForFilesAgainstHead(paths.engine, existing);
240
+ if (!diff.trim())
241
+ continue;
242
+ const ignore = patch.lintIgnore?.length ? new Set(patch.lintIgnore) : undefined;
243
+ const patchIssues = await lintExportedPatch(paths.engine, existing, diff, config, ctx, ignore);
244
+ for (const issue of patchIssues) {
245
+ issues.push({ ...issue, file: `${patch.filename} :: ${issue.file}` });
246
+ }
247
+ linted++;
248
+ }
249
+ // Cross-patch rules over the whole queue — rescoping per-patch would
250
+ // lose these findings, so they run exactly once against the full
251
+ // context.
252
+ issues.push(...lintPatchQueue(ctx));
253
+ if (issues.length === 0) {
254
+ success(`No lint issues found across ${linted} patch(es).`);
255
+ outro('Lint passed');
256
+ return;
257
+ }
258
+ const errors = issues.filter((i) => i.severity === 'error');
259
+ const warnings = issues.filter((i) => i.severity === 'warning');
260
+ const notices = issues.filter((i) => i.severity === 'notice');
261
+ for (const issue of notices)
262
+ info(`NOTICE [${issue.check}] ${issue.file}: ${issue.message}`);
263
+ for (const issue of warnings)
264
+ warn(`[${issue.check}] ${issue.file}: ${issue.message}`);
265
+ for (const issue of errors)
266
+ warn(`ERROR [${issue.check}] ${issue.file}: ${issue.message}`);
267
+ info(`\nLint (per-patch over ${linted} patch(es)): ${errors.length} error(s), ${warnings.length} warning(s)`);
268
+ if (errors.length > 0) {
269
+ outro('Lint failed');
270
+ throw new GeneralError(`Patch lint found ${errors.length} error(s) across ${linted} patch(es). Fix these before exporting.`);
271
+ }
272
+ outro('Lint passed with warnings');
273
+ }
167
274
  /** Registers the lint command on the CLI program. */
168
275
  export function registerLint(program, { getProjectRoot, withErrorHandling }) {
169
276
  program
170
277
  .command('lint [paths...]')
171
- .description('Lint engine changes against patch quality rules')
278
+ .description('Lint engine changes against patch quality rules. Default: aggregate diff against HEAD ' +
279
+ '(every applied patch summed). Use --per-patch for per-patch scope, or pass explicit ' +
280
+ 'file paths to narrow to those.')
172
281
  .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
282
  .option('--only-introduced', 'Fail only on issues tagged [introduced] (requires --since). Cumulative errors still print but do not set a non-zero exit.')
283
+ .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
284
  .action(withErrorHandling(async (paths, options) => {
175
285
  const lintOptions = {};
176
286
  if (options.since !== undefined) {
@@ -179,6 +289,9 @@ export function registerLint(program, { getProjectRoot, withErrorHandling }) {
179
289
  if (options.onlyIntroduced !== undefined) {
180
290
  lintOptions.onlyIntroduced = options.onlyIntroduced;
181
291
  }
292
+ if (options.perPatch !== undefined) {
293
+ lintOptions.perPatch = options.perPatch;
294
+ }
182
295
  await lintCommand(getProjectRoot(), paths, lintOptions);
183
296
  }));
184
297
  }
@@ -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';
@@ -97,7 +97,14 @@ async function reExportSinglePatch(patch, paths, manifest, options, isDryRun, co
97
97
  warn(`Skipped ${patch.filename}: no changes (files unchanged from HEAD)`);
98
98
  return false;
99
99
  }
100
- await runPatchLint(paths.engine, existingFiles, diffContent, config, options.skipLint);
100
+ // Thread the patch's own `lintIgnore` list through so the per-patch
101
+ // suppression honoured by export/export-all is also honoured here.
102
+ // Without this, `re-export` could not refresh an advisory-noisy but
103
+ // intentional patch (a cohesive branding bundle, a localised-resource
104
+ // pack) without either `--skip-lint` (too blunt) or falling through to
105
+ // the full `rebase` flow (which internally skips the lint pipeline).
106
+ const ignoreChecks = patch.lintIgnore?.length ? new Set(patch.lintIgnore) : undefined;
107
+ await runPatchLint(paths.engine, existingFiles, diffContent, config, options.skipLint, undefined, ignoreChecks);
101
108
  if (isDryRun) {
102
109
  info(`[dry-run] ${patch.filename}: ${existingFiles.length} file(s)`);
103
110
  }
@@ -219,13 +226,16 @@ export async function reExportCommand(projectRoot, patches, options) {
219
226
  }
220
227
  const config = await loadConfig(projectRoot);
221
228
  let reExported = 0;
229
+ const reExportedFilenames = [];
222
230
  const progress = spinner('Preparing re-export...');
223
231
  for (const patch of selectedPatches) {
224
232
  progress.message(`Re-exporting ${patch.filename}...`);
225
233
  try {
226
234
  const exported = await reExportSinglePatch(patch, paths, manifest, options, isDryRun, config);
227
- if (exported)
235
+ if (exported) {
228
236
  reExported++;
237
+ reExportedFilenames.push(patch.filename);
238
+ }
229
239
  }
230
240
  catch (error) {
231
241
  warn(`Failed to re-export ${patch.filename}`);
@@ -236,14 +246,34 @@ export async function reExportCommand(projectRoot, patches, options) {
236
246
  progress.error('Re-export failed');
237
247
  throw new GeneralError('All selected patches failed to re-export. Check the errors above.');
238
248
  }
249
+ // `--stamp` only fires on a run where every selected patch refreshed
250
+ // cleanly. A partial success would leave some patches with a stale body
251
+ // but a new version — the opposite of the "what I tested, what the
252
+ // manifest says" invariant `sourceEsrVersion` exists to record. A
253
+ // non-empty `reExportedFilenames` with fewer entries than `selectedPatches`
254
+ // means a lint failure or missing-file skip landed somewhere in the loop,
255
+ // which we refuse to version-stamp through.
256
+ const shouldStamp = options.stamp === true && !isDryRun && reExported > 0 && reExported === selectedPatches.length;
257
+ if (shouldStamp) {
258
+ await stampPatchVersions(paths.patches, reExportedFilenames, config.firefox.version);
259
+ }
239
260
  if (isDryRun) {
240
261
  progress.stop('Dry run complete');
241
262
  success(`[dry-run] Would re-export ${reExported} of ${selectedPatches.length} patch(es)`);
263
+ if (options.stamp === true) {
264
+ info(`[dry-run] Would stamp sourceEsrVersion=${config.firefox.version} on ${reExported} patch(es)`);
265
+ }
242
266
  outro('Dry run complete');
243
267
  }
244
268
  else {
245
269
  progress.stop('Re-export complete');
246
270
  success(`Re-exported ${reExported} of ${selectedPatches.length} patch(es)`);
271
+ if (shouldStamp) {
272
+ success(`Stamped sourceEsrVersion=${config.firefox.version} on ${reExportedFilenames.length} patch(es)`);
273
+ }
274
+ else if (options.stamp === true && reExported !== selectedPatches.length) {
275
+ warn('--stamp was requested but some patches failed or were skipped; refusing to stamp a partial set.');
276
+ }
247
277
  outro('Re-export complete');
248
278
  }
249
279
  }
@@ -251,7 +281,9 @@ export async function reExportCommand(projectRoot, patches, options) {
251
281
  export function registerReExport(program, { getProjectRoot, withErrorHandling }) {
252
282
  program
253
283
  .command('re-export [patches...]')
254
- .description('Re-export existing patches from current engine state')
284
+ .description('Refresh existing patch bodies (and filesAffected with --scan) from the current engine ' +
285
+ 'state. Does NOT change sourceEsrVersion by default — use --stamp or run rebase for ' +
286
+ 'version stamping.')
255
287
  .option('-a, --all', 'Re-export all patches')
256
288
  .option('-s, --scan', 'Scan directories for new/removed files and update filesAffected')
257
289
  .option('--files <paths>', 'Restrict the re-exported filesAffected to this comma-separated list (single target patch only)', (value) => value
@@ -262,6 +294,7 @@ export function registerReExport(program, { getProjectRoot, withErrorHandling })
262
294
  .option('--skip-lint', 'Skip patch lint checks (downgrade errors to warnings)')
263
295
  .option('-y, --yes', 'Skip confirmation when --files shrinks a patch (required for non-TTY)')
264
296
  .option('--force-unsafe', 'Bypass cross-patch lint refusal when --files shrinks a patch')
297
+ .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
298
  .action(withErrorHandling(async (patches, options) => {
266
299
  await reExportCommand(getProjectRoot(), patches, pickDefined(options));
267
300
  }));
@@ -1,9 +1,23 @@
1
1
  import { Command } from 'commander';
2
2
  import type { CommandContext } from '../types/cli.js';
3
+ import type { RunOptions } from '../types/commands/index.js';
4
+ /**
5
+ * Exit code returned by smoke-run mode when the captured console stream
6
+ * produced one or more error lines that did NOT match the operator's
7
+ * allowlist.
8
+ */
9
+ export declare const SMOKE_EXIT_FAILURE: 12;
10
+ /**
11
+ * Exit code returned by smoke-run mode when the browser itself exited
12
+ * with a non-clean status before the smoke window elapsed — i.e. a
13
+ * launch-side failure we could NOT observe as a console error line
14
+ * (crash before console wiring, missing profile, etc.).
15
+ */
16
+ export declare const SMOKE_LAUNCH_FAILURE: 13;
3
17
  /**
4
18
  * Runs the run command to launch the built browser.
5
19
  * @param projectRoot - Root directory of the project
6
20
  */
7
- export declare function runCommand(projectRoot: string): Promise<void>;
21
+ export declare function runCommand(projectRoot: string, options?: RunOptions): Promise<void>;
8
22
  /** Registers the run command on the CLI program. */
9
23
  export declare function registerRun(program: Command, { getProjectRoot, withErrorHandling }: CommandContext): void;