@hominis/fireforge 0.44.0 → 0.44.2

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 (71) hide show
  1. package/CHANGELOG.md +65 -2
  2. package/README.md +20 -121
  3. package/dist/build-info.json +4 -4
  4. package/dist/src/commands/build.js +17 -0
  5. package/dist/src/commands/doctor-check-core.d.ts +9 -0
  6. package/dist/src/commands/doctor-patch-manifest.d.ts +8 -0
  7. package/dist/src/commands/doctor-patch-manifest.js +155 -0
  8. package/dist/src/commands/doctor.d.ts +1 -1
  9. package/dist/src/commands/doctor.js +48 -62
  10. package/dist/src/commands/furnace/apply.js +2 -2
  11. package/dist/src/commands/furnace/deploy.js +2 -2
  12. package/dist/src/commands/furnace/diff.js +6 -1
  13. package/dist/src/commands/furnace/index.js +12 -6
  14. package/dist/src/commands/furnace/init.js +5 -2
  15. package/dist/src/commands/furnace/remove-mutations.js +5 -1
  16. package/dist/src/commands/furnace/sync.js +2 -2
  17. package/dist/src/commands/import.js +6 -2
  18. package/dist/src/commands/re-export-files.js +63 -23
  19. package/dist/src/commands/re-export-scan.js +18 -2
  20. package/dist/src/commands/re-export-single.js +19 -7
  21. package/dist/src/commands/test-register.js +5 -1
  22. package/dist/src/commands/test-run.d.ts +5 -0
  23. package/dist/src/commands/test-run.js +11 -4
  24. package/dist/src/commands/test-verdict.js +10 -1
  25. package/dist/src/commands/test.js +9 -0
  26. package/dist/src/commands/token.js +33 -11
  27. package/dist/src/core/build-audit-resolve.js +12 -3
  28. package/dist/src/core/build-audit.js +6 -2
  29. package/dist/src/core/config-mutate.js +20 -19
  30. package/dist/src/core/furnace-apply-ftl.js +4 -1
  31. package/dist/src/core/furnace-apply-helpers.js +11 -5
  32. package/dist/src/core/furnace-jsconfig.js +5 -3
  33. package/dist/src/core/furnace-operation.d.ts +25 -2
  34. package/dist/src/core/furnace-operation.js +27 -2
  35. package/dist/src/core/git-base.d.ts +14 -0
  36. package/dist/src/core/git-base.js +17 -0
  37. package/dist/src/core/git.js +3 -1
  38. package/dist/src/core/mach-known-noise-filter.d.ts +17 -0
  39. package/dist/src/core/mach-known-noise-filter.js +19 -0
  40. package/dist/src/core/mach.d.ts +8 -3
  41. package/dist/src/core/mach.js +47 -3
  42. package/dist/src/core/patch-lint-checkjs.js +25 -4
  43. package/dist/src/core/patch-manifest-consistency.d.ts +56 -6
  44. package/dist/src/core/patch-manifest-consistency.js +96 -23
  45. package/dist/src/core/patch-manifest-files-affected.d.ts +43 -0
  46. package/dist/src/core/patch-manifest-files-affected.js +77 -0
  47. package/dist/src/core/patch-manifest.d.ts +2 -1
  48. package/dist/src/core/patch-manifest.js +2 -1
  49. package/dist/src/core/run-log.d.ts +60 -0
  50. package/dist/src/core/run-log.js +183 -0
  51. package/dist/src/core/test-harness-output.d.ts +24 -1
  52. package/dist/src/core/test-harness-output.js +46 -15
  53. package/dist/src/core/test-xpcshell-retry.d.ts +2 -2
  54. package/dist/src/core/test-xpcshell-retry.js +6 -1
  55. package/dist/src/core/token-category.js +61 -7
  56. package/dist/src/core/token-dark-mode.js +20 -2
  57. package/dist/src/core/token-docs.js +1 -1
  58. package/dist/src/core/token-manager.d.ts +7 -2
  59. package/dist/src/core/token-manager.js +74 -20
  60. package/dist/src/core/token-variant.d.ts +34 -0
  61. package/dist/src/core/token-variant.js +98 -8
  62. package/dist/src/core/tree-store.js +5 -1
  63. package/dist/src/core/typecheck.js +7 -1
  64. package/dist/src/types/commands/options.d.ts +53 -1
  65. package/dist/src/utils/child-env.d.ts +30 -0
  66. package/dist/src/utils/child-env.js +30 -0
  67. package/dist/src/utils/options.d.ts +28 -0
  68. package/dist/src/utils/options.js +32 -1
  69. package/dist/src/utils/process.d.ts +22 -0
  70. package/dist/src/utils/process.js +8 -7
  71. package/package.json +2 -2
@@ -6,17 +6,19 @@ import { getCurrentBranch, getHead, isGitRepository, isMissingHeadError } from '
6
6
  import { ensureGit } from '../core/git-base.js';
7
7
  import { ensureMach, ensurePython } from '../core/mach.js';
8
8
  import { countPatches } from '../core/patch-apply.js';
9
- import { rebuildPatchesManifest, validatePatchesManifestConsistency, validatePatchIntegrity, } from '../core/patch-manifest.js';
9
+ import { validatePatchIntegrity } from '../core/patch-manifest.js';
10
+ import { InvalidArgumentError } from '../errors/base.js';
10
11
  import { ExitCode } from '../errors/codes.js';
11
12
  import { toError } from '../utils/errors.js';
12
13
  import { pathExists } from '../utils/fs.js';
13
14
  import { error, info, intro, outro, success, warn } from '../utils/logger.js';
14
- import { addWaitLockOption, resolveWaitLockSeconds } from '../utils/options.js';
15
+ import { addWaitLockOption } from '../utils/options.js';
15
16
  import { findExecutable } from '../utils/process.js';
16
17
  import { failure, ok, resolveDoctorSeverity, warning } from './doctor-check-core.js';
17
18
  import { EXTERNAL_TOOLCHAIN_DOCTOR_CHECK } from './doctor-external-toolchains.js';
18
19
  import { FURNACE_DOCTOR_CHECKS } from './doctor-furnace.js';
19
20
  import { ORPHANED_HARNESS_DOCTOR_CHECK } from './doctor-orphaned-harness.js';
21
+ import { PATCH_MANIFEST_CONSISTENCY_CHECK } from './doctor-patch-manifest.js';
20
22
  import { POST_REBASE_AUDIT_CHECK } from './doctor-post-rebase-audit.js';
21
23
  import { inspectEngineWorkingTree } from './doctor-working-tree.js';
22
24
  import { collectPatchQueueHealth } from './verify.js';
@@ -274,63 +276,7 @@ const DOCTOR_CHECKS = [
274
276
  return ok('Patches found', `${patchCount} patch${patchCount === 1 ? '' : 'es'} found`);
275
277
  },
276
278
  },
277
- {
278
- name: 'Patch manifest consistency',
279
- dependsOn: ['fireforge.json is valid'],
280
- run: async (ctx) => {
281
- if (!(await pathExists(ctx.paths.patches))) {
282
- return [];
283
- }
284
- const manifestConsistencyIssues = await validatePatchesManifestConsistency(ctx.paths.patches);
285
- if (manifestConsistencyIssues.length === 0) {
286
- return ok('Patch manifest consistency');
287
- }
288
- if (!ctx.options.repairPatchesManifest) {
289
- return failure('Patch manifest consistency', manifestConsistencyIssues.map((issue) => issue.message).join(' '), 'Run "fireforge doctor --repair-patches-manifest" to rebuild patches.json from patch files.');
290
- }
291
- // Repair stamps sourceEsrVersion into every recovered entry. If the
292
- // earlier "fireforge.json is valid" check failed, ctx.config is
293
- // undefined and we must refuse rather than fabricate a fallback —
294
- // persisting 'unknown' into manifest metadata is hard to reverse
295
- // and would mislead every later command that reads it.
296
- if (!ctx.config) {
297
- return failure('Patch manifest consistency', 'Cannot repair patches.json: fireforge.json could not be loaded, so the Firefox version to stamp into recovered manifest entries is unknown.', 'Fix the fireforge.json errors reported above and re-run "fireforge doctor --repair-patches-manifest".');
298
- }
299
- try {
300
- const repaired = await rebuildPatchesManifest(ctx.paths.patches, ctx.config.firefox.version, {
301
- waitLockSeconds: resolveWaitLockSeconds(ctx.options.waitLock),
302
- command: 'doctor --repair-patches-manifest',
303
- });
304
- // The repair path must not silently overwrite human-written
305
- // descriptions on recovered entries, which leaves the queue less
306
- // trustworthy as an audit trail. The rebuilder returns the list of
307
- // filenames whose metadata was entirely invented, named explicitly
308
- // here so the operator knows which patches to review. Entries that
309
- // WERE preserved (only `filesAffected` / ordering drifted) are not
310
- // flagged.
311
- if (repaired.recoveredFilenames.length > 0) {
312
- for (const filename of repaired.recoveredFilenames) {
313
- // Telling the operator to hand-edit patches.json contradicts the
314
- // README and downstream docs, which treat the manifest as
315
- // FireForge-owned. Point at the existing `re-export` / `export`
316
- // workflow so the fix stays inside the tool: re-exporting the
317
- // same files with an explicit `--description` overwrites the
318
- // recovered entry with operator-supplied metadata and supersedes
319
- // the mtime-based createdAt stamp.
320
- warn(`Recovered manifest entry for ${filename} with generic description and mtime-based createdAt. ` +
321
- 'Re-export the affected files with `fireforge re-export <filename> --description "<your description>"` ' +
322
- '(or `fireforge export <paths...> --name <name> --category <category> --description "<your description>"`) ' +
323
- 'to overwrite the reconstructed metadata, or accept the generic description if the original text is not recoverable. ' +
324
- 'Avoid hand-editing patches.json — FireForge owns that file and will regenerate it on the next manifest consistency pass.');
325
- }
326
- }
327
- return warning('Patch manifest consistency', `Rebuilt patches.json from ${repaired.manifest.patches.length} patch${repaired.manifest.patches.length === 1 ? '' : 'es'}${repaired.recoveredFilenames.length > 0 ? ` (${repaired.recoveredFilenames.length} with reconstructed metadata — see warnings above)` : ''}. Review recovered metadata before release.`);
328
- }
329
- catch (err) {
330
- return failure('Patch manifest consistency', toError(err).message, 'Repair failed. Fix the underlying patch metadata issue and retry the doctor command.');
331
- }
332
- },
333
- },
279
+ PATCH_MANIFEST_CONSISTENCY_CHECK,
334
280
  {
335
281
  name: 'Patch integrity',
336
282
  skipIf: (ctx) => !ctx.engineExists,
@@ -382,7 +328,7 @@ export const DOCTOR_CHECK_ORDER = DOCTOR_CHECKS.map((check) => check.name);
382
328
  * @param checks - The check results to display
383
329
  * @returns The exit code reflecting the overall result
384
330
  */
385
- export function reportDoctorResults(checks) {
331
+ export function reportDoctorResults(checks, mutations = []) {
386
332
  info('');
387
333
  let passedCount = 0;
388
334
  let warningCount = 0;
@@ -409,6 +355,16 @@ export function reportDoctorResults(checks) {
409
355
  }
410
356
  }
411
357
  info('');
358
+ // Printed in every branch, including the failing one: a repair that wrote
359
+ // and then met an unrelated failing check must not be reported as a run
360
+ // where nothing happened.
361
+ if (mutations.length > 0) {
362
+ warn('Repairs applied this run:');
363
+ for (const mutation of mutations) {
364
+ warn(` ${mutation}`);
365
+ }
366
+ info('');
367
+ }
412
368
  if (failedCount === 0 && warningCount === 0) {
413
369
  outro(`All ${passedCount} checks passed!`);
414
370
  }
@@ -421,12 +377,38 @@ export function reportDoctorResults(checks) {
421
377
  }
422
378
  return ExitCode.SUCCESS;
423
379
  }
380
+ /**
381
+ * Rejects repair-flag combinations that cannot mean what they say.
382
+ *
383
+ * The two manifest repairs are different operations on the same file, and
384
+ * `--dry-run` with nothing to project is a flag that silently does nothing —
385
+ * the shape most likely to be mistaken for a preview that reported "no
386
+ * changes".
387
+ */
388
+ function assertRepairOptions(options) {
389
+ if (options.repairPatchesManifest === true && options.repairFilesAffected === true) {
390
+ throw new InvalidArgumentError('--repair-patches-manifest and --repair-files-affected are mutually exclusive. ' +
391
+ 'Use --repair-files-affected for filesAffected drift; use --repair-patches-manifest ' +
392
+ 'when rows are missing, untracked or duplicated.', '--repair-files-affected');
393
+ }
394
+ if (options.allowMetadataLoss === true && options.repairPatchesManifest !== true) {
395
+ throw new InvalidArgumentError('--allow-metadata-loss only applies to --repair-patches-manifest.', '--allow-metadata-loss');
396
+ }
397
+ if (options.dryRun === true &&
398
+ options.repairPatchesManifest !== true &&
399
+ options.repairFilesAffected !== true) {
400
+ throw new InvalidArgumentError('--dry-run needs a manifest repair to project. Pass it with ' +
401
+ '--repair-patches-manifest or --repair-files-affected. ' +
402
+ '(--repair-furnace has no dry-run: it reconciles engine state, not a manifest.)', '--dry-run');
403
+ }
404
+ }
424
405
  /**
425
406
  * Runs the doctor command to diagnose issues.
426
407
  * @param projectRoot - Root directory of the project
427
408
  */
428
409
  export async function doctorCommand(projectRoot, options = {}) {
429
410
  intro('FireForge Doctor');
411
+ assertRepairOptions(options);
430
412
  const paths = getProjectPaths(projectRoot);
431
413
  const state = await loadState(projectRoot);
432
414
  const engineExists = await pathExists(paths.engine);
@@ -440,12 +422,13 @@ export async function doctorCommand(projectRoot, options = {}) {
440
422
  config: undefined,
441
423
  furnaceConfigExists: furnaceConfigExistsFlag,
442
424
  furnaceConfig: undefined,
425
+ mutations: [],
443
426
  };
444
427
  const checks = [];
445
428
  for (const definition of DOCTOR_CHECKS) {
446
429
  checks.push(...(await executeCheck(definition, ctx)));
447
430
  }
448
- const exitCode = reportDoctorResults(checks);
431
+ const exitCode = reportDoctorResults(checks, ctx.mutations);
449
432
  return { checks, exitCode };
450
433
  }
451
434
  /** Registers the doctor command on the CLI program. */
@@ -453,7 +436,10 @@ export function registerDoctor(program, { getProjectRoot, withErrorHandling }) {
453
436
  const command = program
454
437
  .command('doctor')
455
438
  .description('Diagnose project issues')
456
- .option('--repair-patches-manifest', 'Rebuild patches/patches.json from the current patch files before reporting results')
439
+ .option('--repair-patches-manifest', 'Rebuild patches/patches.json from the current patch files before reporting results. Only filesAffected and order are recomputed; every other field on an existing entry is preserved')
440
+ .option('--repair-files-affected', 'Repair only the filesAffected lists that disagree with their patch body, leaving every other manifest field untouched')
441
+ .option('--allow-metadata-loss', 'With --repair-patches-manifest, rebuild even when patches.json cannot be parsed — every descriptive field on every entry is then reinvented')
442
+ .option('--dry-run', 'Show what a requested repair would change without writing anything')
457
443
  .option('--repair-furnace', 'Reconcile furnace state: clear stale furnace-state.json entries, re-run furnace apply to fix engine drift, and clear the pending-repair marker set by a failed preview teardown')
458
444
  .option('--clear-resolution', 'Clear stale pendingResolution state after the patch queue health check reports no errors')
459
445
  .option('--post-rebase-audit', 'Check common registration surfaces after a Firefox source rebase');
@@ -5,7 +5,7 @@ import { applyAllComponents, computeComponentChecksums } from '../../core/furnac
5
5
  import { logApplyResult } from '../../core/furnace-apply-output.js';
6
6
  import { getFurnacePaths } from '../../core/furnace-config.js';
7
7
  import { isComponentSourceFile } from '../../core/furnace-constants.js';
8
- import { runFurnaceMutation } from '../../core/furnace-operation.js';
8
+ import { runFurnaceMutation, waitLockMutationOptions } from '../../core/furnace-operation.js';
9
9
  import { assertFurnaceReady } from '../../core/furnace-precondition.js';
10
10
  import { getPersistableAppliedEntry, persistSingleComponentState, shouldPersistSingleComponentState, } from '../../core/furnace-state-persist.js';
11
11
  import { countEntriesWithBlockingStepErrors } from '../../core/furnace-step-errors.js';
@@ -294,7 +294,7 @@ export async function furnaceApplyCommand(projectRoot, name, options = {}) {
294
294
  await persistSingleComponentState(projectRoot, getPersistableAppliedEntry('Apply', name, applyResult.applied[0]), getFurnacePaths(projectRoot));
295
295
  }
296
296
  return applyResult;
297
- }, { dryRun });
297
+ }, { dryRun, ...waitLockMutationOptions(options.waitLockSeconds) });
298
298
  if (applySpinner) {
299
299
  applySpinner.stop('Components applied');
300
300
  }
@@ -4,7 +4,7 @@ import { applyAllComponents } from '../../core/furnace-apply.js';
4
4
  import { logApplyResult } from '../../core/furnace-apply-output.js';
5
5
  import { getFurnacePaths, loadFurnaceConfig } from '../../core/furnace-config.js';
6
6
  import { reportJsconfigPathsSync } from '../../core/furnace-jsconfig.js';
7
- import { runFurnaceMutation } from '../../core/furnace-operation.js';
7
+ import { runFurnaceMutation, waitLockMutationOptions, } from '../../core/furnace-operation.js';
8
8
  import { assertFurnaceReady } from '../../core/furnace-precondition.js';
9
9
  import { getPersistableAppliedEntry, getStepFailureCount, persistSingleComponentState, shouldPersistSingleComponentState, } from '../../core/furnace-state-persist.js';
10
10
  import { hasBlockingStepErrors } from '../../core/furnace-step-errors.js';
@@ -177,7 +177,7 @@ export async function furnaceDeployCommand(projectRoot, name, options = {}) {
177
177
  operationContext: ctx,
178
178
  });
179
179
  return { kind: 'result', result: allResult };
180
- }, { dryRun: isDryRun });
180
+ }, { dryRun: isDryRun, ...waitLockMutationOptions(options.waitLockSeconds) });
181
181
  if (applyOutcome.kind === 'stock') {
182
182
  applySpinner.stop('Apply skipped');
183
183
  warn(`"${name}" is a stock component. Stock components are not applied locally.`);
@@ -11,6 +11,7 @@ import { FurnaceError } from '../../errors/furnace.js';
11
11
  import { toError } from '../../utils/errors.js';
12
12
  import { pathExists, readText } from '../../utils/fs.js';
13
13
  import { formatErrorText, formatSuccessText, info, intro, outro } from '../../utils/logger.js';
14
+ import { normalizePathSlashes } from '../../utils/paths.js';
14
15
  /**
15
16
  * Renders a multi-hunk unified diff between the two strings and returns a
16
17
  * flat list of display-ready lines. Each line has already had its marker
@@ -64,7 +65,11 @@ async function diffOverride(name, projectRoot, config) {
64
65
  if (!isComponentSourceFile(entry.name))
65
66
  continue;
66
67
  // git show takes a repo-relative path. paths.engine IS the repo root.
67
- const enginePath = getOverrideEngineTargetPath(paths.engine, overrideConfig, entry.name, ftlDir).slice(paths.engine.length + 1);
68
+ // The slice leaves the host's separators behind, and `git show <ref>:<path>`
69
+ // only understands forward slashes — on Windows the unnormalized form makes
70
+ // every baseline read miss, so every overridden file is reported as a new
71
+ // file and `furnace diff` shows nothing.
72
+ const enginePath = normalizePathSlashes(getOverrideEngineTargetPath(paths.engine, overrideConfig, entry.name, ftlDir).slice(paths.engine.length + 1));
68
73
  const modifiedPath = join(overrideDir, entry.name);
69
74
  const baselineDisplayPath = enginePath;
70
75
  let originalContent;
@@ -35,13 +35,17 @@ function registerFurnaceApplyCommand(furnace, context) {
35
35
  .option('-w, --watch', 'Watch component directories and re-apply on changes');
36
36
  addWaitLockOption(apply).action(withErrorHandling(async (name, options) => {
37
37
  const { waitLock, ...rest } = options ?? {};
38
- const parsed = pickDefined(rest);
38
+ // Resolved ONCE and used twice: the engine session lock below, and
39
+ // the furnace lock the mutation takes inside it. Resolving twice
40
+ // would be the same value today and a drift risk tomorrow.
41
+ const waitLockSeconds = resolveWaitLockSeconds(waitLock);
42
+ const parsed = pickDefined({ ...rest, waitLockSeconds });
39
43
  const run = (projectRoot) => furnaceApplyCommand(projectRoot, name, parsed);
40
44
  if (parsed.dryRun === true) {
41
45
  await run(getProjectRoot());
42
46
  return;
43
47
  }
44
- await runEngineLockedFurnaceCommand(context, 'furnace apply', run, resolveWaitLockSeconds(waitLock));
48
+ await runEngineLockedFurnaceCommand(context, 'furnace apply', run, waitLockSeconds);
45
49
  }));
46
50
  }
47
51
  function registerFurnaceDeployCommand(furnace, context) {
@@ -54,13 +58,14 @@ function registerFurnaceDeployCommand(furnace, context) {
54
58
  .option('--skip-validate', 'Skip the validation step (apply only)');
55
59
  addWaitLockOption(deploy).action(withErrorHandling(async (name, options) => {
56
60
  const { waitLock, ...rest } = options ?? {};
57
- const parsed = pickDefined(rest);
61
+ const waitLockSeconds = resolveWaitLockSeconds(waitLock);
62
+ const parsed = pickDefined({ ...rest, waitLockSeconds });
58
63
  const run = (projectRoot) => furnaceDeployCommand(projectRoot, name, parsed);
59
64
  if (parsed.dryRun === true) {
60
65
  await run(getProjectRoot());
61
66
  return;
62
67
  }
63
- await runEngineLockedFurnaceCommand(context, 'furnace deploy', run, resolveWaitLockSeconds(waitLock));
68
+ await runEngineLockedFurnaceCommand(context, 'furnace deploy', run, waitLockSeconds);
64
69
  }));
65
70
  }
66
71
  function registerFurnaceSyncCommand(furnace, context) {
@@ -72,13 +77,14 @@ function registerFurnaceSyncCommand(furnace, context) {
72
77
  .addOption(new Option('-s, --strategy <strategy>', 'Auto-resolve merge conflicts (ours = keep local, theirs = accept upstream)').choices(['ours', 'theirs']));
73
78
  addWaitLockOption(sync).action(withErrorHandling(async (options) => {
74
79
  const { waitLock, ...rest } = options;
75
- const parsed = pickDefined(rest);
80
+ const waitLockSeconds = resolveWaitLockSeconds(waitLock);
81
+ const parsed = pickDefined({ ...rest, waitLockSeconds });
76
82
  const run = (projectRoot) => furnaceSyncCommand(projectRoot, parsed);
77
83
  if (parsed.dryRun === true) {
78
84
  await run(getProjectRoot());
79
85
  return;
80
86
  }
81
- await runEngineLockedFurnaceCommand(context, 'furnace sync', run, resolveWaitLockSeconds(waitLock));
87
+ await runEngineLockedFurnaceCommand(context, 'furnace sync', run, waitLockSeconds);
82
88
  }));
83
89
  }
84
90
  /**
@@ -1,6 +1,6 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  import { stat } from 'node:fs/promises';
3
- import { basename, dirname, isAbsolute, join, normalize } from 'node:path';
3
+ import { basename, dirname, isAbsolute, join, posix } from 'node:path';
4
4
  import { text } from '@clack/prompts';
5
5
  import { getProjectPaths, loadConfig, mutateConfig, writeConfig } from '../../core/config.js';
6
6
  import { stdioIsInteractive } from '../../core/destructive.js';
@@ -61,7 +61,10 @@ async function validateFtlBasePath(value, engineDir) {
61
61
  if (isAbsolute(value) || /^[a-zA-Z]:[\\/]/.test(value)) {
62
62
  throw new FurnaceError(`ftlBasePath "${value}" must be a relative path inside the engine checkout, not absolute.`);
63
63
  }
64
- const normalized = normalize(value.replace(/\\/g, '/'));
64
+ // Normalize with the POSIX rules regardless of host: `normalize` on Windows
65
+ // emits backslashes, so a `../`-prefix test against the platform `normalize`
66
+ // silently passes every traversal attempt there.
67
+ const normalized = posix.normalize(value.replace(/\\/g, '/'));
65
68
  if (normalized === '..' || normalized.startsWith('../')) {
66
69
  throw new FurnaceError(`ftlBasePath "${value}" must not escape the engine checkout via parent-directory segments.`);
67
70
  }
@@ -23,6 +23,7 @@ import { isGitRepository } from '../../core/git.js';
23
23
  import { FurnaceError } from '../../errors/furnace.js';
24
24
  import { pathExists, removeDir, removeFile } from '../../utils/fs.js';
25
25
  import { info } from '../../utils/logger.js';
26
+ import { normalizePathSlashes } from '../../utils/paths.js';
26
27
  import { removeDeployedCustomFiles } from './remove-state.js';
27
28
  /**
28
29
  * Restores every override-deployed file in `engine/` to its pristine HEAD
@@ -153,7 +154,10 @@ export async function performCustomRemovalMutations(args) {
153
154
  if (await pathExists(ftlPath)) {
154
155
  await snapshotFile(journal, ftlPath);
155
156
  await removeFile(ftlPath);
156
- info(`Deleted localized file engine/${ftlRel}`);
157
+ // `ftlRel` is a native join, so on Windows an interpolated
158
+ // `engine/${ftlRel}` renders as `engine/toolkit\locales\...` — one
159
+ // message with both separators. Print the engine-relative form.
160
+ info(`Deleted localized file engine/${normalizePathSlashes(ftlRel)}`);
157
161
  }
158
162
  // Drop the locale jar.mn chrome registration that `applyCustomFtlFile`
159
163
  // wrote during deploy — otherwise the engine is left with a
@@ -6,7 +6,7 @@ import { applyAllComponents } from '../../core/furnace-apply.js';
6
6
  import { logApplyResult } from '../../core/furnace-apply-output.js';
7
7
  import { getFurnacePaths, loadFurnaceConfig } from '../../core/furnace-config.js';
8
8
  import { reportJsconfigPathsSync } from '../../core/furnace-jsconfig.js';
9
- import { runFurnaceMutation } from '../../core/furnace-operation.js';
9
+ import { runFurnaceMutation, waitLockMutationOptions } from '../../core/furnace-operation.js';
10
10
  import { assertFurnaceReady } from '../../core/furnace-precondition.js';
11
11
  import { countEntriesWithBlockingStepErrors } from '../../core/furnace-step-errors.js';
12
12
  import { containsMergeConflictMarkers } from '../../core/furnace-validate-structure.js';
@@ -127,7 +127,7 @@ export async function furnaceSyncCommand(projectRoot, options = {}) {
127
127
  if (!options.dryRun) {
128
128
  info('\nApplying all components to engine...');
129
129
  const applySpinner = spinner('Applying components...');
130
- const result = await runFurnaceMutation(projectRoot, 'apply-rollback', (ctx) => applyAllComponents(projectRoot, false, { operationContext: ctx }), { dryRun: false });
130
+ const result = await runFurnaceMutation(projectRoot, 'apply-rollback', (ctx) => applyAllComponents(projectRoot, false, { operationContext: ctx }), { dryRun: false, ...waitLockMutationOptions(options.waitLockSeconds) });
131
131
  applySpinner.stop('Components applied');
132
132
  logApplyResult(result, false);
133
133
  const appliedWithStepErrorsCount = countEntriesWithBlockingStepErrors(result.applied);
@@ -8,7 +8,7 @@ import { assertEngineExists } from '../core/engine-precondition.js';
8
8
  import { getHead } from '../core/git.js';
9
9
  import { getDirtyFiles } from '../core/git-status.js';
10
10
  import { applyPatchesWithContinue, countPatches, createPatchedContentContext, discoverPatches, extractAffectedFiles, matchesUntilFilename, PatchError, } from '../core/patch-apply.js';
11
- import { checkVersionCompatibility, loadPatchesManifest, validatePatchesManifestConsistency, validatePatchIntegrity, } from '../core/patch-manifest.js';
11
+ import { checkVersionCompatibility, loadPatchesManifest, recommendManifestRepair, validatePatchesManifestConsistency, validatePatchIntegrity, } from '../core/patch-manifest.js';
12
12
  import { getPatchSourceVersion } from '../core/patch-source-metadata.js';
13
13
  import { warnIfStaticComponentsStale } from '../core/test-stale-check.js';
14
14
  import { GeneralError } from '../errors/base.js';
@@ -239,7 +239,11 @@ async function assertScopedManifestConsistency(patchesDir, untilFilenameSet, unt
239
239
  const issueSummary = scopedManifestIssues.map((issue) => issue.message).join('\n ');
240
240
  throw new GeneralError('Patch manifest consistency check failed. Repair patches/patches.json before importing.\n' +
241
241
  ` ${issueSummary}\n\n` +
242
- 'Run "fireforge doctor --repair-patches-manifest" to rebuild the manifest from on-disk patch files.');
242
+ // Naming the whole-manifest rebuild for drift that is only in
243
+ // `filesAffected` puts the operator one keystroke from rewriting every
244
+ // row to correct one derived list. The narrow repair is the remedy the
245
+ // failure actually calls for.
246
+ recommendManifestRepair(scopedManifestIssues));
243
247
  }
244
248
  }
245
249
  /**
@@ -4,6 +4,7 @@ import { getProjectPaths } from '../core/config.js';
4
4
  import { appendHistory, confirmDestructive, } from '../core/destructive.js';
5
5
  import { enforceFreshFurnaceSources } from '../core/furnace-stale-export.js';
6
6
  import { getDiffForFilesAgainstHead } from '../core/git-diff.js';
7
+ import { listTrackedInHead } from '../core/git-file-ops.js';
7
8
  import { extractAffectedFiles } from '../core/patch-apply.js';
8
9
  import { updatePatchAndMetadata } from '../core/patch-export.js';
9
10
  import { buildModifiedFileAdditionsFromDiff, buildPatchQueueContext, detectNewFilesInDiff, formatPatchLintIssue, lintPatchQueue, } from '../core/patch-lint.js';
@@ -88,7 +89,7 @@ function buildFilesModeMetadataUpdates(actualProjectedFiles, options, effectiveL
88
89
  return updates;
89
90
  }
90
91
  async function confirmFilesModeProjection(args) {
91
- const { target, retained, removed, added, actualProjectedFiles, missingFiles, options, conflicts, } = args;
92
+ const { target, retained, removed, added, actualProjectedFiles, deletedFiles, options, conflicts, } = args;
92
93
  const isDryRun = options.dryRun === true;
93
94
  const summary = [
94
95
  `re-export ${target.filename} with --files scope`,
@@ -102,8 +103,8 @@ async function confirmFilesModeProjection(args) {
102
103
  if (added.length > 0) {
103
104
  summary.push(`newly included files (${added.length}): ${added.join(', ')}`);
104
105
  }
105
- if (missingFiles.length > 0) {
106
- summary.push(`missing on disk (will be dropped): ${missingFiles.join(', ')}`);
106
+ if (deletedFiles.length > 0) {
107
+ summary.push(`deleted on disk (captured as deletions the patch will replay): ${deletedFiles.join(', ')}`);
107
108
  }
108
109
  if (!isDryRun && removed.length > 0 && options.allowShrink !== true) {
109
110
  warn(`Refusing to shrink ${target.filename} without --allow-shrink.`);
@@ -118,12 +119,60 @@ async function confirmFilesModeProjection(args) {
118
119
  operation: 're-export-files',
119
120
  title: `Re-export ${target.filename} with --files`,
120
121
  summary,
121
- yes: removed.length === 0 && missingFiles.length === 0 ? true : options.yes === true,
122
+ // A captured deletion still needs the confirmation: the patch will now
123
+ // REMOVE those files wherever it is applied, which is the same class of
124
+ // consequence as shrinking the scope.
125
+ yes: removed.length === 0 && deletedFiles.length === 0 ? true : options.yes === true,
122
126
  dryRun: isDryRun,
123
127
  unsafeOverride: options.forceUnsafe === true,
124
128
  conflicts,
125
129
  });
126
130
  }
131
+ /**
132
+ * Splits the requested paths into what can be diffed and what is a deletion.
133
+ *
134
+ * A path absent from disk is one of two very different things, and
135
+ * collapsing them is what made a DELETION impossible to capture.
136
+ *
137
+ * Tracked in HEAD: a deletion. `git diff HEAD -- <path>` renders it as a
138
+ * proper `deleted file mode` section, so it STAYS in the diff scope. (The
139
+ * comment this replaces claimed the diff "would fail" on any missing path —
140
+ * true only for the never-tracked case.) Dropping these produced the
141
+ * dangerous shape: a re-export that reported success while writing a patch
142
+ * whose own file list claimed a path it did not restore, so a fresh clone
143
+ * would apply it and resurrect exactly what the change meant to remove.
144
+ *
145
+ * Never tracked in HEAD: nothing on disk and nothing to diff against, so
146
+ * there is no hunk to be had. Refused rather than warned about, because the
147
+ * patch cannot express it either way.
148
+ *
149
+ * @param requested - The `--files` scope
150
+ * @param engineDir - Absolute path to the engine checkout
151
+ * @param patchFilename - Target patch, for the refusal message
152
+ * @returns The diffable scope and the subset captured as deletions
153
+ */
154
+ async function classifyRequestedPaths(requested, engineDir, patchFilename) {
155
+ const absentFiles = [];
156
+ for (const file of requested) {
157
+ if (!(await pathExists(join(engineDir, file)))) {
158
+ absentFiles.push(file);
159
+ }
160
+ }
161
+ const trackedAbsent = await listTrackedInHead(engineDir, absentFiles);
162
+ const untrackedAbsent = absentFiles.filter((f) => !trackedAbsent.has(f));
163
+ const deletedFiles = absentFiles.filter((f) => trackedAbsent.has(f));
164
+ if (untrackedAbsent.length > 0) {
165
+ throw new InvalidArgumentError(`Refusing to re-export ${patchFilename} with --files because ${untrackedAbsent.length} requested path${untrackedAbsent.length === 1 ? ' is' : 's are'} ` +
166
+ `neither on disk nor tracked in engine HEAD (${untrackedAbsent.join(', ')}). ` +
167
+ 'There is no content to diff and no deletion to record, so the patch could not ' +
168
+ 'honour the file list it would claim. Remove those paths from --files.', '--files');
169
+ }
170
+ for (const file of deletedFiles) {
171
+ info(`${patchFilename}: capturing deletion of ${file}`);
172
+ }
173
+ const untrackedSet = new Set(untrackedAbsent);
174
+ return { diffableFiles: requested.filter((f) => !untrackedSet.has(f)), deletedFiles };
175
+ }
127
176
  /**
128
177
  * Handles `re-export --files` end-to-end: computes the projected diff, runs
129
178
  * the per-patch and cross-patch lint against a context in which the target
@@ -152,22 +201,7 @@ export async function reExportFilesInPlace(paths, selectedPatches, options, conf
152
201
  const removed = target.filesAffected.filter((f) => !requested.includes(f));
153
202
  const added = requested.filter((f) => !target.filesAffected.includes(f));
154
203
  const retained = target.filesAffected.filter((f) => requested.includes(f));
155
- // Filter out paths that no longer exist on disk; we cannot include
156
- // them in the new diff because getDiffForFilesAgainstHead would fail.
157
- // Missing files are still dropped from the manifest so the resulting
158
- // filesAffected reflects reality.
159
- const missingFiles = [];
160
- for (const file of requested) {
161
- const filePath = join(paths.engine, file);
162
- if (!(await pathExists(filePath))) {
163
- missingFiles.push(file);
164
- }
165
- }
166
- const missingSet = new Set(missingFiles);
167
- const diffableFiles = requested.filter((f) => !missingSet.has(f));
168
- for (const file of missingFiles) {
169
- warn(`${target.filename}: requested file is missing on disk and will be dropped: ${file}`);
170
- }
204
+ const { diffableFiles, deletedFiles } = await classifyRequestedPaths(requested, paths.engine, target.filename);
171
205
  // Compute the projected diff up front. This is the same diff the real
172
206
  // write would produce, so we get an exact preview through the lint
173
207
  // gate and avoid computing it twice.
@@ -179,7 +213,13 @@ export async function reExportFilesInPlace(paths, selectedPatches, options, conf
179
213
  }
180
214
  const actualProjectedFiles = extractAffectedFiles(projectedDiff);
181
215
  const actualProjectedSet = new Set(actualProjectedFiles);
182
- const noDiffFiles = diffableFiles.filter((file) => !actualProjectedSet.has(file));
216
+ // Domain is the FULL requested list, not the diffable subset. When the
217
+ // subset was used, any path filtered out upstream was invisible to this
218
+ // check by construction — which is exactly how a dropped deletion slipped
219
+ // through while the run reported success. A patch that does not do what
220
+ // its own file list claims is the one property a manifest must never get
221
+ // wrong, so an explicitly named path that produced no hunk is a refusal.
222
+ const noDiffFiles = requested.filter((file) => !actualProjectedSet.has(file));
183
223
  if (noDiffFiles.length > 0) {
184
224
  throw new InvalidArgumentError(`Refusing to re-export ${target.filename} with --files because ${noDiffFiles.length} requested path${noDiffFiles.length === 1 ? '' : 's'} produced no diff hunks (${noDiffFiles.join(', ')}). ` +
185
225
  'Keeping them in filesAffected would desync patches.json from the patch body. ' +
@@ -216,7 +256,7 @@ export async function reExportFilesInPlace(paths, selectedPatches, options, conf
216
256
  removed,
217
257
  added,
218
258
  actualProjectedFiles,
219
- missingFiles,
259
+ deletedFiles,
220
260
  options,
221
261
  conflicts,
222
262
  });
@@ -243,7 +283,7 @@ export async function reExportFilesInPlace(paths, selectedPatches, options, conf
243
283
  filename: target.filename,
244
284
  files: actualProjectedFiles,
245
285
  previousFiles: target.filesAffected,
246
- missingFilesDropped: missingFiles,
286
+ deletionsCaptured: deletedFiles,
247
287
  },
248
288
  ...(options.yes === true ? { yes: true } : {}),
249
289
  ...(options.forceUnsafe === true ? { unsafeOverride: true } : {}),
@@ -2,6 +2,7 @@
2
2
  import { dirname, join } from 'node:path';
3
3
  import { confirm } from '@clack/prompts';
4
4
  import { getDiffForFilesAgainstHead } from '../core/git-diff.js';
5
+ import { listTrackedInHead } from '../core/git-file-ops.js';
5
6
  import { getModifiedFilesInDir, getUntrackedFilesInDir } from '../core/git-status.js';
6
7
  import { extractAffectedFiles } from '../core/patch-apply.js';
7
8
  import { buildModifiedFileAdditionsFromDiff, buildPatchQueueContext, detectNewFilesInDiff, lintPatchQueue, } from '../core/patch-lint.js';
@@ -85,7 +86,10 @@ async function scanPatchFilesTargeted(args) {
85
86
  const exists = await mapWithConcurrency(scanFiles, PATH_PROBE_CONCURRENCY, (file) => pathExists(join(engineDir, file)));
86
87
  for (const [index, file] of scanFiles.entries()) {
87
88
  if (exists[index] !== true) {
88
- throw new InvalidArgumentError(`--scan-file path not found in engine/: ${file}`, '--scan-file');
89
+ throw new InvalidArgumentError(`--scan-file path not found in engine/: ${file}. ` +
90
+ '--scan-file brings a path INTO patch ownership, which needs content to diff. ' +
91
+ 'If the path was deleted and the patch already owns it, a plain re-export now ' +
92
+ 'captures the deletion — no scan flag is needed.', '--scan-file');
89
93
  }
90
94
  if (!currentSet.has(file))
91
95
  added.push(file);
@@ -93,9 +97,21 @@ async function scanPatchFilesTargeted(args) {
93
97
  const removed = await findRemovedFiles(currentFilesAffected, engineDir);
94
98
  return reportScanResult(currentFilesAffected, patchFilename, isDryRun, added.sort(), removed);
95
99
  }
100
+ /**
101
+ * Paths that have genuinely left the patch's ownership: absent from disk AND
102
+ * untracked in engine HEAD.
103
+ *
104
+ * An absent path that IS tracked in HEAD is a DELETION, not a de-ownership.
105
+ * The diff carries it as a `deleted file mode` section, so it must stay in
106
+ * `filesAffected` — pruning it desynchronises the manifest from the patch
107
+ * body in the direction that hurts: the body says "delete this file", the
108
+ * file list says the patch has nothing to do with it.
109
+ */
96
110
  async function findRemovedFiles(files, engineDir) {
97
111
  const exists = await mapWithConcurrency(files, PATH_PROBE_CONCURRENCY, (file) => pathExists(join(engineDir, file)));
98
- return files.filter((_, index) => exists[index] !== true).sort();
112
+ const absent = files.filter((_, index) => exists[index] !== true);
113
+ const trackedAbsent = await listTrackedInHead(engineDir, absent);
114
+ return absent.filter((file) => !trackedAbsent.has(file)).sort();
99
115
  }
100
116
  function reportScanResult(currentFilesAffected, patchFilename, isDryRun, added, removed) {
101
117
  for (const f of added)
@@ -9,6 +9,7 @@ import { getProjectPaths } from '../core/config.js';
9
9
  import { stdioIsInteractive } from '../core/destructive.js';
10
10
  import { enforceFreshFurnaceSources } from '../core/furnace-stale-export.js';
11
11
  import { getDiffForFilesAgainstHead } from '../core/git-diff.js';
12
+ import { listTrackedInHead } from '../core/git-file-ops.js';
12
13
  import { updatePatchAndMetadata } from '../core/patch-export.js';
13
14
  import { buildProjectedManifest, enforcePatchPolicy } from '../core/patch-policy.js';
14
15
  import { isGitIndexLockConflict } from '../errors/git.js';
@@ -111,17 +112,28 @@ async function reExportSinglePatch(patch, paths, manifest, options, isDryRun, co
111
112
  // --allow-stale-furnace) before diffing. Runs in dry-run too so the failure
112
113
  // surfaces early.
113
114
  await enforceFreshFurnaceSources(paths.root, currentFilesAffected, options.allowStaleFurnace === true, 're-export');
114
- const missingFiles = await findMissingFiles(paths.engine, currentFilesAffected);
115
- if (missingFiles.length === currentFilesAffected.length) {
115
+ const absentFiles = await findMissingFiles(paths.engine, currentFilesAffected);
116
+ // A path absent from disk but tracked in engine HEAD is a DELETION, and
117
+ // `git diff HEAD` renders it as a real `deleted file mode` section. Only
118
+ // a never-tracked path has nothing to diff. Skipping both alike meant a
119
+ // retired file quietly left the patch body while staying in its file
120
+ // list — the patch then claimed a file it could not restore.
121
+ const trackedAbsent = await listTrackedInHead(paths.engine, absentFiles);
122
+ const deletedFiles = absentFiles.filter((f) => trackedAbsent.has(f));
123
+ const untrackedAbsent = absentFiles.filter((f) => !trackedAbsent.has(f));
124
+ if (untrackedAbsent.length === currentFilesAffected.length) {
116
125
  warn(`Skipped ${patch.filename}: all affected files missing`);
117
- warn(`Missing files: ${missingFiles.join(', ')}`);
126
+ warn(`Missing files: ${untrackedAbsent.join(', ')}`);
118
127
  return false;
119
128
  }
120
- if (missingFiles.length > 0) {
121
- warn(`${patch.filename}: missing files will be skipped: ${missingFiles.join(', ')}`);
129
+ if (untrackedAbsent.length > 0) {
130
+ warn(`${patch.filename}: missing files will be skipped: ${untrackedAbsent.join(', ')}`);
122
131
  }
123
- const missingSet = new Set(missingFiles);
124
- const existingFiles = currentFilesAffected.filter((f) => !missingSet.has(f));
132
+ if (deletedFiles.length > 0) {
133
+ info(`${patch.filename}: capturing deletion of ${deletedFiles.join(', ')}`);
134
+ }
135
+ const untrackedSet = new Set(untrackedAbsent);
136
+ const existingFiles = currentFilesAffected.filter((f) => !untrackedSet.has(f));
125
137
  const diffContent = await getDiffForFilesAgainstHead(paths.engine, existingFiles);
126
138
  assertScanFileAdditionsHaveDiffHunks({
127
139
  diffContent,
@@ -29,7 +29,10 @@ const TEST_HELP_TEXT = [
29
29
  '--doctor) ends with exactly one raw stdout line',
30
30
  ' FIREFORGE-VERDICT: PASS|FAIL [reason=crash|test-failures|',
31
31
  ' no-tests|preflight|inconclusive|lock-timeout] [checks=<n>]',
32
- ' [unexpected=<n>] [shards=<p>/<t>] [(<note>)]',
32
+ ' [unexpected=<n>] [shards=<p>/<t>] [(<note>)] [log=<path>]',
33
+ "log=<path> names this run's complete output, written to",
34
+ '.fireforge/logs/ as the run streams, so a piped or truncated run',
35
+ 'still leaves a re-readable artifact.',
33
36
  'The status follows the harness classifier, not the raw exit code:',
34
37
  'a crash-classified run says FAIL reason=crash even at exit 0, and',
35
38
  'a green-summary-override pass says PASS despite a non-zero mach',
@@ -55,6 +58,7 @@ export function registerTest(program, { getProjectRoot, withErrorHandling }) {
55
58
  .command('test [paths...]')
56
59
  .description('Run tests via mach test')
57
60
  .option('--headless', 'Run tests in headless mode')
61
+ .option('--full-output', "Pass mozbuild's full verbosity through for the TEST phase. mozbuild quiets terminal output to warnings and errors when it detects a coding agent, which removes TEST_START and console INFO — the lines a hang or stall diagnosis needs. This unsets that detection for test dispatches only; the build phase stays quiet.")
58
62
  .option('--build', 'Run incremental UI build before testing')
59
63
  .option('--build-only', 'Run the scoped pre-test build (packaging exactly [paths...], mixed harnesses allowed) and exit without dispatching tests — one build covers both harness halves, then run each half without --build')
60
64
  .option('--extend-coverage', 'With --build/--build-only and explicit paths, union those paths into the recorded test-packaging coverage instead of replacing it, so an earlier scoped build stays covered. Refused when the build anchor moved (engine HEAD, mozconfig, or a previously fingerprinted packageable file changed).')