@hominis/fireforge 0.36.0 → 0.38.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 (87) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +1 -1
  3. package/dist/src/commands/build.js +6 -6
  4. package/dist/src/commands/config.js +1 -0
  5. package/dist/src/commands/doctor-orphaned-harness.d.ts +52 -0
  6. package/dist/src/commands/doctor-orphaned-harness.js +132 -0
  7. package/dist/src/commands/doctor.js +2 -0
  8. package/dist/src/commands/download.js +5 -2
  9. package/dist/src/commands/export-flow.d.ts +5 -1
  10. package/dist/src/commands/export-flow.js +10 -7
  11. package/dist/src/commands/export-placement-gate.js +3 -5
  12. package/dist/src/commands/export-placement-policy.d.ts +11 -3
  13. package/dist/src/commands/export-placement-policy.js +32 -29
  14. package/dist/src/commands/export.js +12 -5
  15. package/dist/src/commands/furnace/index.js +23 -18
  16. package/dist/src/commands/patch/move-files-create.d.ts +19 -0
  17. package/dist/src/commands/patch/move-files-create.js +116 -0
  18. package/dist/src/commands/patch/move-files.d.ts +3 -1
  19. package/dist/src/commands/patch/move-files.js +49 -4
  20. package/dist/src/commands/patch/split.d.ts +11 -1
  21. package/dist/src/commands/patch/split.js +6 -5
  22. package/dist/src/commands/patch/staged-dependency.d.ts +1 -1
  23. package/dist/src/commands/patch/staged-dependency.js +113 -51
  24. package/dist/src/commands/re-export-files.js +4 -0
  25. package/dist/src/commands/re-export-scan.d.ts +17 -0
  26. package/dist/src/commands/re-export-scan.js +51 -0
  27. package/dist/src/commands/re-export.js +25 -6
  28. package/dist/src/commands/source.js +28 -3
  29. package/dist/src/commands/test-diagnose.js +20 -1
  30. package/dist/src/commands/test-register.js +6 -5
  31. package/dist/src/commands/test-stale-gate.d.ts +44 -0
  32. package/dist/src/commands/test-stale-gate.js +81 -0
  33. package/dist/src/commands/test.js +40 -23
  34. package/dist/src/core/build-audit.d.ts +12 -0
  35. package/dist/src/core/build-audit.js +14 -0
  36. package/dist/src/core/build-baseline-types.d.ts +58 -0
  37. package/dist/src/core/build-baseline.d.ts +10 -2
  38. package/dist/src/core/build-baseline.js +51 -7
  39. package/dist/src/core/config-paths.d.ts +1 -1
  40. package/dist/src/core/config-paths.js +1 -0
  41. package/dist/src/core/config-validate.js +6 -1
  42. package/dist/src/core/engine-session-lock.d.ts +10 -1
  43. package/dist/src/core/engine-session-lock.js +28 -2
  44. package/dist/src/core/file-lock.d.ts +33 -0
  45. package/dist/src/core/file-lock.js +32 -6
  46. package/dist/src/core/firefox-archive.d.ts +2 -1
  47. package/dist/src/core/firefox-archive.js +32 -8
  48. package/dist/src/core/firefox.d.ts +8 -3
  49. package/dist/src/core/firefox.js +11 -6
  50. package/dist/src/core/furnace-config-custom.js +10 -0
  51. package/dist/src/core/furnace-stale-export.d.ts +59 -0
  52. package/dist/src/core/furnace-stale-export.js +123 -0
  53. package/dist/src/core/furnace-validate-compatibility.js +9 -2
  54. package/dist/src/core/furnace-validate-helpers.js +28 -1
  55. package/dist/src/core/furnace-validate-structure.js +3 -2
  56. package/dist/src/core/license-headers.d.ts +12 -0
  57. package/dist/src/core/license-headers.js +76 -1
  58. package/dist/src/core/mach-known-noise-filter.d.ts +60 -0
  59. package/dist/src/core/mach-known-noise-filter.js +193 -0
  60. package/dist/src/core/mach.d.ts +8 -0
  61. package/dist/src/core/mach.js +36 -3
  62. package/dist/src/core/patch-lint-cross.js +80 -2
  63. package/dist/src/core/patch-lint.d.ts +3 -0
  64. package/dist/src/core/patch-lint.js +25 -7
  65. package/dist/src/core/patch-manifest-io.d.ts +3 -2
  66. package/dist/src/core/patch-manifest-io.js +27 -16
  67. package/dist/src/core/patch-manifest-validate.js +24 -0
  68. package/dist/src/core/status-classify.d.ts +11 -2
  69. package/dist/src/core/status-classify.js +49 -25
  70. package/dist/src/core/test-harness-crash.d.ts +16 -0
  71. package/dist/src/core/test-harness-crash.js +55 -0
  72. package/dist/src/core/test-stale-check.d.ts +69 -1
  73. package/dist/src/core/test-stale-check.js +134 -1
  74. package/dist/src/types/commands/index.d.ts +1 -1
  75. package/dist/src/types/commands/options.d.ts +84 -7
  76. package/dist/src/types/commands/patches.d.ts +29 -0
  77. package/dist/src/types/config.d.ts +7 -0
  78. package/dist/src/types/furnace.d.ts +13 -0
  79. package/dist/src/utils/options.d.ts +20 -0
  80. package/dist/src/utils/options.js +39 -0
  81. package/dist/src/utils/process-group.d.ts +33 -0
  82. package/dist/src/utils/process-group.js +161 -0
  83. package/dist/src/utils/process.d.ts +15 -0
  84. package/dist/src/utils/process.js +88 -44
  85. package/dist/src/utils/validation.d.ts +5 -0
  86. package/dist/src/utils/validation.js +7 -0
  87. package/package.json +3 -3
@@ -17,6 +17,14 @@ export interface MachOptions {
17
17
  env?: Record<string, string>;
18
18
  /** Whether to inherit stdio (show output directly) */
19
19
  inherit?: boolean;
20
+ /**
21
+ * Collapse the KNOWN mozsystemmonitor teardown traceback to one labeled
22
+ * line in the terminal ECHO (capture-only option). The captured
23
+ * stdout/stderr stay raw — the harness classifier depends on the raw
24
+ * traceback. Unrecognized tracebacks always echo verbatim. Opted into by
25
+ * the test dispatchers only.
26
+ */
27
+ annotateKnownTeardownNoise?: boolean;
20
28
  }
21
29
  /**
22
30
  * Result of running a mach command while capturing streamed output.
@@ -7,6 +7,7 @@ import { exec, execInherit, execInheritCapture, execSmokeRun, execStream, } from
7
7
  import { createSiblingLockPath, withFileLock } from './file-lock.js';
8
8
  import { ensureFirefoxIgnorefileCompatibility } from './firefox-ignorefile.js';
9
9
  import { explainMachError } from './mach-error-hints.js';
10
+ import { createKnownTeardownNoiseFilter, createTeardownNoiseContext, } from './mach-known-noise-filter.js';
10
11
  import { getPython } from './mach-python.js';
11
12
  import { installMachResourceGuard } from './mach-resource-shim.js';
12
13
  import { detectHarnessCrashSignature } from './test-harness-crash.js';
@@ -42,7 +43,12 @@ export async function runMach(args, engineDir, options = {}) {
42
43
  ...(options.env ? { env: options.env } : {}),
43
44
  };
44
45
  if (options.inherit) {
45
- return execInherit(python, [machPath, ...args], execOptions);
46
+ // Group-reap every long-lived inherited mach dispatch (bootstrap, run,
47
+ // watch): a mach dying at startup must not strand multiprocessing
48
+ // workers. Short-lived metadata queries below stay on plain exec() —
49
+ // adding groups there would touch every non-mach exec consumer's path
50
+ // for no reaping benefit.
51
+ return execInherit(python, [machPath, ...args], { ...execOptions, processGroup: true });
46
52
  }
47
53
  const result = await exec(python, [machPath, ...args], execOptions);
48
54
  return result.exitCode;
@@ -67,24 +73,47 @@ export async function runMachCapture(args, engineDir, options = {}) {
67
73
  const machPath = join(engineDir, 'mach');
68
74
  let stdout = '';
69
75
  let stderr = '';
76
+ // Echo-path filters only: the captured strings above accumulate RAW so the
77
+ // classifier still sees the full teardown traceback. When the option is
78
+ // off, the filters are absent and echo is a straight write (unchanged).
79
+ // One shutdown context per run, shared by both stream filters: the
80
+ // SUITE_END marker usually arrives on stdout while the teardown traceback
81
+ // lands on stderr, so the shutdown-seen flag must span the pair.
82
+ const noiseContext = options.annotateKnownTeardownNoise === true ? createTeardownNoiseContext() : undefined;
83
+ const stdoutFilter = noiseContext && createKnownTeardownNoiseFilter(noiseContext);
84
+ const stderrFilter = noiseContext && createKnownTeardownNoiseFilter(noiseContext);
70
85
  const exitCode = await execStream(python, [machPath, ...args], {
71
86
  cwd: engineDir,
72
87
  ...(options.env ? { env: options.env } : {}),
88
+ // Every capture dispatch (test suites, protected builds, package,
89
+ // storybook) runs as a process-group leader and is group-reaped on
90
+ // exit/abort — see ExecOptions.processGroup (0.37.0 item 9a).
91
+ processGroup: true,
73
92
  onStdout: (data) => {
74
93
  stdout += data;
75
94
  if (stdout.length > CAPTURE_TAIL_LIMIT) {
76
95
  stdout = stdout.slice(-CAPTURE_TAIL_LIMIT);
77
96
  }
78
- process.stdout.write(data);
97
+ process.stdout.write(stdoutFilter ? stdoutFilter.transform(data) : data);
79
98
  },
80
99
  onStderr: (data) => {
81
100
  stderr += data;
82
101
  if (stderr.length > CAPTURE_TAIL_LIMIT) {
83
102
  stderr = stderr.slice(-CAPTURE_TAIL_LIMIT);
84
103
  }
85
- process.stderr.write(data);
104
+ process.stderr.write(stderrFilter ? stderrFilter.transform(data) : data);
86
105
  },
87
106
  });
107
+ if (stdoutFilter) {
108
+ const residue = stdoutFilter.flush();
109
+ if (residue.length > 0)
110
+ process.stdout.write(residue);
111
+ }
112
+ if (stderrFilter) {
113
+ const residue = stderrFilter.flush();
114
+ if (residue.length > 0)
115
+ process.stderr.write(residue);
116
+ }
88
117
  return { stdout, stderr, exitCode };
89
118
  }
90
119
  /**
@@ -99,6 +128,7 @@ export async function runMachInheritCapture(args, engineDir, options = {}) {
99
128
  return execInheritCapture(python, [machPath, ...args], {
100
129
  cwd: engineDir,
101
130
  ...(options.env ? { env: options.env } : {}),
131
+ processGroup: true,
102
132
  });
103
133
  }
104
134
  /**
@@ -363,6 +393,7 @@ export async function testWithOutput(engineDir, testPaths = [], args = [], env)
363
393
  const guard = await installMachResourceGuard(engineDir);
364
394
  return runMachCapture(['test', ...testPaths, ...args], engineDir, {
365
395
  env: { ...guard.env, ...env },
396
+ annotateKnownTeardownNoise: true,
366
397
  });
367
398
  }
368
399
  /**
@@ -379,6 +410,7 @@ export async function xpcshellTestWithOutput(engineDir, testPaths = [], args = [
379
410
  const guard = await installMachResourceGuard(engineDir);
380
411
  return runMachCapture(['xpcshell-test', ...testPaths, ...args], engineDir, {
381
412
  env: { ...guard.env, ...env },
413
+ annotateKnownTeardownNoise: true,
382
414
  });
383
415
  }
384
416
  /**
@@ -390,5 +422,6 @@ export async function mochitestWithOutput(engineDir, testPaths = [], args = [],
390
422
  const guard = await installMachResourceGuard(engineDir);
391
423
  return runMachCapture(['mochitest', ...testPaths, ...args], engineDir, {
392
424
  env: { ...guard.env, ...env },
425
+ annotateKnownTeardownNoise: true,
393
426
  });
394
427
  }
@@ -343,6 +343,14 @@ function findMatchingStagedDependency(entry, sitePath, specifier, laterOwners) {
343
343
  laterOwners.some((owner) => owner.fullPath === dependency.creates &&
344
344
  (dependency.owner === undefined || dependency.owner === owner.filename)));
345
345
  }
346
+ /**
347
+ * Later-in-apply-order predicate shared by the forward-import scan and the
348
+ * registration validation: strictly higher ordinal, or same ordinal with a
349
+ * lexicographically later filename (the apply loop's tiebreak).
350
+ */
351
+ function isLaterOwner(owner, entry) {
352
+ return (owner.order > entry.order || (owner.order === entry.order && owner.filename > entry.filename));
353
+ }
346
354
  /** Builds the basename → later-creators index used by the forward-import scan. */
347
355
  function buildNewFileIndex(ctx) {
348
356
  const newFileIndex = new Map();
@@ -382,8 +390,7 @@ function eachForwardImportSite(ctx, newFileIndex, visit) {
382
390
  const owners = newFileIndex.get(leaf);
383
391
  if (!owners)
384
392
  continue;
385
- const laterOwners = owners.filter((owner) => owner.order > entry.order ||
386
- (owner.order === entry.order && owner.filename > entry.filename));
393
+ const laterOwners = owners.filter((owner) => isLaterOwner(owner, entry));
387
394
  if (laterOwners.length === 0)
388
395
  continue;
389
396
  visit(entry, sitePath, specifier, cleaned, laterOwners);
@@ -477,9 +484,80 @@ export function lintPatchQueueForwardImports(ctx) {
477
484
  severity: 'warning',
478
485
  });
479
486
  }
487
+ // Registration-kind declarations (0.37.0 item 5): a jar.mn packaging
488
+ // line, customElements registration, or actor registration referencing
489
+ // a later-created file. There is no import specifier to match — the
490
+ // declaration is "used" when the declared line (whitespace-trimmed)
491
+ // appears among the lines this patch adds to the declaring file AND
492
+ // its `creates` resolves to a file a later-ordered patch actually
493
+ // creates (with `owner`, when set, naming that patch) — mirroring
494
+ // findMatchingStagedDependency for the import kind.
495
+ for (const registration of entry.metadata?.stagedDependencies?.registrations ?? []) {
496
+ const failure = findStagedRegistrationFailure(entry, registration, newFileIndex);
497
+ if (failure === undefined)
498
+ continue;
499
+ issues.push({
500
+ file: registration.file,
501
+ check: 'staged-dependency-unused',
502
+ fingerprint: `staged-dependency-unused|${entry.filename}|${registration.file}|reg:${registration.line}|${registration.creates}|${registration.owner ?? ''}`,
503
+ message: `${entry.filename} declares a staged registration in ${registration.file} ` +
504
+ `("${registration.line}") for ${registration.creates}, but ${describeRegistrationFailure(failure)} ` +
505
+ 'Remove the stale declaration with "fireforge patch staged-dependency --remove --kind registration" or update it to match the patch and queue.',
506
+ severity: 'warning',
507
+ });
508
+ }
480
509
  }
481
510
  return issues;
482
511
  }
512
+ /**
513
+ * Validates one registration-kind declaration against the patch content
514
+ * and the queue, mirroring {@link findMatchingStagedDependency}: the
515
+ * declared line must be added by this patch, `creates` must exactly match
516
+ * a file a LATER-ordered patch creates (an earlier-only creator means the
517
+ * dependency is already satisfied and the declaration is stale), and
518
+ * `owner`, when set, must name one of those creating patches. Returns
519
+ * undefined when valid.
520
+ */
521
+ function findStagedRegistrationFailure(entry, registration, newFileIndex) {
522
+ if (!isRegistrationLinePresent(entry, registration))
523
+ return { kind: 'line-absent' };
524
+ const creators = (newFileIndex.get(basename(registration.creates)) ?? []).filter((owner) => owner.fullPath === registration.creates && isLaterOwner(owner, entry));
525
+ if (creators.length === 0)
526
+ return { kind: 'creates-unresolved' };
527
+ if (registration.owner !== undefined &&
528
+ !creators.some((owner) => owner.filename === registration.owner)) {
529
+ return { kind: 'owner-mismatch', creators };
530
+ }
531
+ return undefined;
532
+ }
533
+ /** Message tail for each {@link StagedRegistrationFailure} shape. */
534
+ function describeRegistrationFailure(failure) {
535
+ switch (failure.kind) {
536
+ case 'line-absent':
537
+ return 'the patch does not add that line.';
538
+ case 'creates-unresolved':
539
+ return ('no later-ordered patch creates that file — the declaration is stale, ' +
540
+ 'or the queue order no longer stages it.');
541
+ case 'owner-mismatch':
542
+ return `that file is created by ${failure.creators.map((o) => o.filename).join(', ')}, not the declared owner.`;
543
+ }
544
+ }
545
+ /**
546
+ * True when the declared registration/packaging line appears (trimmed)
547
+ * among the lines the patch introduces in the declaring file — either as a
548
+ * newly-created file's content or as added lines to an existing file
549
+ * (where jar.mn / customElements.js / actor-registration edits land).
550
+ */
551
+ function isRegistrationLinePresent(entry, registration) {
552
+ const declared = registration.line.trim();
553
+ if (declared.length === 0)
554
+ return false;
555
+ const introduced = [
556
+ entry.newFiles.get(registration.file),
557
+ entry.modifiedFileAdditions.get(registration.file),
558
+ ];
559
+ return introduced.some((content) => content !== undefined && content.split('\n').some((line) => line.trim() === declared));
560
+ }
483
561
  /**
484
562
  * Cross-patch lint orchestrator. Runs every cross-patch rule against the
485
563
  * provided context and returns combined issues.
@@ -8,6 +8,9 @@ export { lintPatchedCss };
8
8
  * Counts the total lines in a unified diff and the number of non-binary
9
9
  * text lines, so binary hunks do not inflate patch size checks.
10
10
  *
11
+ * Line counting agrees with `wc -l`: a trailing newline does not add a
12
+ * phantom empty line to either `total` or the binary-hunk accounting.
13
+ *
11
14
  * @param diffContent - Raw unified diff string
12
15
  * @returns Object with `total` line count and `textLines` (total minus binary hunk lines)
13
16
  */
@@ -35,15 +35,33 @@ const FILE_SIZE_THRESHOLDS = {
35
35
  general: { notice: 500, warning: 750, error: 900 },
36
36
  test: { notice: 1200, warning: 1400, error: 1600 },
37
37
  };
38
+ /**
39
+ * Counts the lines of `content` the way `wc -l` reports them: a trailing
40
+ * newline terminates the last line rather than starting an empty extra
41
+ * one. A naive `split('\n').length` over-counts by one for any content
42
+ * ending in `\n` (the common case), which made the size rules fire one
43
+ * line early against the operator's own `wc -l` measurement.
44
+ */
45
+ function countContentLines(content) {
46
+ const lines = content.split('\n');
47
+ if (lines[lines.length - 1] === '')
48
+ lines.pop();
49
+ return lines.length;
50
+ }
38
51
  /**
39
52
  * Counts the total lines in a unified diff and the number of non-binary
40
53
  * text lines, so binary hunks do not inflate patch size checks.
41
54
  *
55
+ * Line counting agrees with `wc -l`: a trailing newline does not add a
56
+ * phantom empty line to either `total` or the binary-hunk accounting.
57
+ *
42
58
  * @param diffContent - Raw unified diff string
43
59
  * @returns Object with `total` line count and `textLines` (total minus binary hunk lines)
44
60
  */
45
61
  export function countNonBinaryDiffLines(diffContent) {
46
62
  const lines = diffContent.split('\n');
63
+ if (lines[lines.length - 1] === '')
64
+ lines.pop();
47
65
  const total = lines.length;
48
66
  let binaryLines = 0;
49
67
  let inBinaryHunk = false;
@@ -329,12 +347,12 @@ export async function lintPatchedJs(repoDir, affectedFiles, newFiles, config, pa
329
347
  }
330
348
  // 2. File size check (new files only)
331
349
  if (isNew) {
332
- const lineCount = content.split('\n').length;
350
+ const lineCount = countContentLines(content);
333
351
  const isTest = isTestFile(file);
334
352
  const thresholds = isTest ? FILE_SIZE_THRESHOLDS.test : FILE_SIZE_THRESHOLDS.general;
335
353
  const label = isTest ? 'Test file' : 'New file';
336
354
  const verb = isTest ? 'splitting' : 'decomposing';
337
- if (lineCount >= thresholds.error) {
355
+ if (lineCount > thresholds.error) {
338
356
  issues.push({
339
357
  file,
340
358
  check: 'file-too-large',
@@ -342,7 +360,7 @@ export async function lintPatchedJs(repoDir, affectedFiles, newFiles, config, pa
342
360
  severity: 'error',
343
361
  });
344
362
  }
345
- else if (lineCount >= thresholds.warning) {
363
+ else if (lineCount > thresholds.warning) {
346
364
  issues.push({
347
365
  file,
348
366
  check: 'file-too-large',
@@ -350,7 +368,7 @@ export async function lintPatchedJs(repoDir, affectedFiles, newFiles, config, pa
350
368
  severity: 'warning',
351
369
  });
352
370
  }
353
- else if (lineCount >= thresholds.notice) {
371
+ else if (lineCount > thresholds.notice) {
354
372
  issues.push({
355
373
  file,
356
374
  check: 'file-too-large',
@@ -507,7 +525,7 @@ export function lintPatchSize(filesAffected, lineCount, patchTier) {
507
525
  severity: 'warning',
508
526
  });
509
527
  }
510
- if (lineCount >= lineThresholds.error) {
528
+ if (lineCount > lineThresholds.error) {
511
529
  issues.push({
512
530
  file: AGGREGATE_PATCH_FILE,
513
531
  check: 'large-patch-lines',
@@ -515,7 +533,7 @@ export function lintPatchSize(filesAffected, lineCount, patchTier) {
515
533
  severity: 'error',
516
534
  });
517
535
  }
518
- else if (lineCount >= lineThresholds.warning) {
536
+ else if (lineCount > lineThresholds.warning) {
519
537
  issues.push({
520
538
  file: AGGREGATE_PATCH_FILE,
521
539
  check: 'large-patch-lines',
@@ -523,7 +541,7 @@ export function lintPatchSize(filesAffected, lineCount, patchTier) {
523
541
  severity: 'warning',
524
542
  });
525
543
  }
526
- else if (lineCount >= lineThresholds.notice) {
544
+ else if (lineCount > lineThresholds.notice) {
527
545
  issues.push({
528
546
  file: AGGREGATE_PATCH_FILE,
529
547
  check: 'large-patch-lines',
@@ -102,8 +102,9 @@ export interface PatchRenameEntry {
102
102
  newOrder: number;
103
103
  }
104
104
  /**
105
- * Rewrites `stagedDependencies.forwardImports[].owner` references on one
106
- * patch through a rename lookup. Owners embed exact patch filenames, so any
105
+ * Rewrites `stagedDependencies.forwardImports[].owner` and
106
+ * `stagedDependencies.registrations[].owner` references on one patch
107
+ * through a rename lookup. Owners embed exact patch filenames, so any
107
108
  * renumber (compact, reorder, placement export, rename) that does not remap
108
109
  * them leaves dangling references that surface as false forward-import
109
110
  * errors on the next lint.
@@ -192,8 +192,9 @@ export async function removePatchFromManifest(patchesDir, filename) {
192
192
  return true;
193
193
  }
194
194
  /**
195
- * Rewrites `stagedDependencies.forwardImports[].owner` references on one
196
- * patch through a rename lookup. Owners embed exact patch filenames, so any
195
+ * Rewrites `stagedDependencies.forwardImports[].owner` and
196
+ * `stagedDependencies.registrations[].owner` references on one patch
197
+ * through a rename lookup. Owners embed exact patch filenames, so any
197
198
  * renumber (compact, reorder, placement export, rename) that does not remap
198
199
  * them leaves dangling references that surface as false forward-import
199
200
  * errors on the next lint.
@@ -207,25 +208,35 @@ export async function removePatchFromManifest(patchesDir, filename) {
207
208
  * @returns The same row, or a copy with remapped owners
208
209
  */
209
210
  export function rewriteStagedDependencyOwners(patch, renameLookup) {
210
- const forwardImports = patch.stagedDependencies?.forwardImports;
211
- if (!forwardImports || forwardImports.length === 0)
211
+ const staged = patch.stagedDependencies;
212
+ if (!staged)
212
213
  return patch;
213
- const rewritten = forwardImports.map((fi) => {
214
- if (!fi.owner)
215
- return fi;
216
- const newOwner = renameLookup(fi.owner);
217
- if (newOwner === undefined || newOwner === fi.owner)
218
- return fi;
219
- return { ...fi, owner: newOwner };
220
- });
221
- const changed = rewritten.some((fi, index) => fi !== forwardImports[index]);
222
- if (!changed)
214
+ const rewriteOwners = (entries) => {
215
+ if (!entries || entries.length === 0)
216
+ return { entries, changed: false };
217
+ const rewritten = entries.map((entry) => {
218
+ if (!entry.owner)
219
+ return entry;
220
+ const newOwner = renameLookup(entry.owner);
221
+ if (newOwner === undefined || newOwner === entry.owner)
222
+ return entry;
223
+ return { ...entry, owner: newOwner };
224
+ });
225
+ return {
226
+ entries: rewritten,
227
+ changed: rewritten.some((entry, index) => entry !== entries[index]),
228
+ };
229
+ };
230
+ const forwardImports = rewriteOwners(staged.forwardImports);
231
+ const registrations = rewriteOwners(staged.registrations);
232
+ if (!forwardImports.changed && !registrations.changed)
223
233
  return patch;
224
234
  return {
225
235
  ...patch,
226
236
  stagedDependencies: {
227
- ...patch.stagedDependencies,
228
- forwardImports: rewritten,
237
+ ...staged,
238
+ ...(forwardImports.entries !== undefined ? { forwardImports: forwardImports.entries } : {}),
239
+ ...(registrations.entries !== undefined ? { registrations: registrations.entries } : {}),
229
240
  },
230
241
  };
231
242
  }
@@ -24,13 +24,37 @@ function parseForwardImports(data, label) {
24
24
  return dependency;
25
25
  });
26
26
  }
27
+ function parseRegistrations(data, label) {
28
+ if (!isArray(data)) {
29
+ throw new Error(`${label} must be an array`);
30
+ }
31
+ return data.map((entry, index) => {
32
+ const rec = parseObject(entry, `${label}[${index}]`);
33
+ const dependency = {
34
+ file: rec.string('file'),
35
+ line: rec.string('line'),
36
+ creates: rec.string('creates'),
37
+ };
38
+ const owner = rec.optionalString('owner');
39
+ if (owner !== undefined)
40
+ dependency.owner = owner;
41
+ const reason = rec.optionalString('reason');
42
+ if (reason !== undefined)
43
+ dependency.reason = reason;
44
+ return dependency;
45
+ });
46
+ }
27
47
  function parseStagedDependencies(data, label) {
28
48
  const rec = parseObject(data, label);
29
49
  const rawForwardImports = rec.raw('forwardImports');
50
+ const rawRegistrations = rec.raw('registrations');
30
51
  const staged = {};
31
52
  if (rawForwardImports !== undefined) {
32
53
  staged.forwardImports = parseForwardImports(rawForwardImports, `${label}.forwardImports`);
33
54
  }
55
+ if (rawRegistrations !== undefined) {
56
+ staged.registrations = parseRegistrations(rawRegistrations, `${label}.registrations`);
57
+ }
34
58
  return staged;
35
59
  }
36
60
  /**
@@ -11,12 +11,21 @@
11
11
  * normal after `fireforge import`.
12
12
  * - `patch-owned-drift`: the file is claimed by exactly one patch, but
13
13
  * the live engine content no longer matches that patch's expected
14
- * post-apply content.
14
+ * post-apply content. This includes furnace-prefixed paths: a path
15
+ * can be both Furnace-managed and patch-claimed (export a deployed
16
+ * component, then edit the workspace source and `furnace deploy`
17
+ * again — the deployed copy now has content the patch body lacks).
18
+ * Before 0.38.0 the furnace prefix check ran first and silently
19
+ * bucketed such files as `furnace`, so `status` reported the stale
20
+ * patch as owned.
15
21
  * - `unmanaged`: edits not explained by any patch or tool — local
16
22
  * drift to export or discard.
17
23
  * - `branding`: files under tool-managed branding paths, written by
18
24
  * FireForge's branding pipeline.
19
- * - `furnace`: files under Furnace-managed component prefixes.
25
+ * - `furnace`: files under Furnace-managed component prefixes. When
26
+ * exactly one patch also claims the path, this bucket asserts the
27
+ * live content matches that patch's expected post-apply content —
28
+ * a mismatch is reported as `patch-owned-drift` instead (see above).
20
29
  * - `conflict`: the file is claimed by two or more patches in
21
30
  * `patches.json`. The human `--ownership` mode already surfaces
22
31
  * this bucket as `CONFLICT`; the classification is carried through
@@ -55,6 +55,40 @@ function isGeneratedBrandingPath(file, binaryName) {
55
55
  normalized === `${brandingRoot}/locales/en-US/brand.properties` ||
56
56
  normalized === `${brandingRoot}/locales/en-US/brand.ftl`);
57
57
  }
58
+ /**
59
+ * Compares a single-owner file's live engine content against its owning
60
+ * patch's expected post-apply content. `matchClassification` is what a
61
+ * clean match reports — `patch-backed` for ordinary patch-claimed paths,
62
+ * `furnace` for furnace-prefixed paths so healthy deployed components
63
+ * keep landing in the pinned furnace bucket. Any mismatch (including a
64
+ * deletion the patch does not expect, or a failed comparison) reports
65
+ * `patch-owned-drift`.
66
+ */
67
+ async function classifySingleOwnerFile(entry, engineDir, patchesDir, matchClassification) {
68
+ if (getPrimaryStatusCode(entry.status) === 'D') {
69
+ // Deleted file: content matches only if the patch expects deletion
70
+ const expected = await computePatchedContent(patchesDir, engineDir, entry.file);
71
+ return {
72
+ ...entry,
73
+ classification: expected === null ? matchClassification : 'patch-owned-drift',
74
+ };
75
+ }
76
+ // File exists on disk — compare actual vs expected
77
+ try {
78
+ const [expected, actual] = await Promise.all([
79
+ computePatchedContent(patchesDir, engineDir, entry.file),
80
+ readText(join(engineDir, entry.file)),
81
+ ]);
82
+ return {
83
+ ...entry,
84
+ classification: actual === expected ? matchClassification : 'patch-owned-drift',
85
+ };
86
+ }
87
+ catch (error) {
88
+ verbose(`Treating ${entry.file} as patch-owned drift because patch-backed classification failed: ${toError(error).message}`);
89
+ return { ...entry, classification: 'patch-owned-drift' };
90
+ }
91
+ }
58
92
  /**
59
93
  * Classifies files into patch-backed, unmanaged, branding, furnace, or
60
94
  * conflict buckets.
@@ -104,7 +138,20 @@ export async function classifyFiles(files, engineDir, patchesDir, binaryName, fu
104
138
  }
105
139
  }
106
140
  if (isFurnace) {
107
- results.push({ ...entry, classification: 'furnace' });
141
+ // A furnace path claimed by exactly one patch gets the same
142
+ // expected-vs-actual comparison as any other single-owner path:
143
+ // after a `furnace deploy` of an edited component the deployed
144
+ // copy has content the owning patch's body lacks, and the old
145
+ // unconditional short-circuit silently bucketed that drift as
146
+ // `furnace`. Multi-owner and unowned furnace paths keep the
147
+ // short-circuit — the ownership table independently flags
148
+ // filesAffected conflicts.
149
+ if (owners && owners.length === 1) {
150
+ results.push(await classifySingleOwnerFile(entry, engineDir, patchesDir, 'furnace'));
151
+ }
152
+ else {
153
+ results.push({ ...entry, classification: 'furnace' });
154
+ }
108
155
  continue;
109
156
  }
110
157
  }
@@ -127,30 +174,7 @@ export async function classifyFiles(files, engineDir, patchesDir, binaryName, fu
127
174
  continue;
128
175
  }
129
176
  // File is claimed by exactly one patch — compare content.
130
- if (primaryCode === 'D') {
131
- // Deleted file: patch-backed only if patch expects deletion
132
- const expected = await computePatchedContent(patchesDir, engineDir, entry.file);
133
- results.push({
134
- ...entry,
135
- classification: expected === null ? 'patch-backed' : 'patch-owned-drift',
136
- });
137
- continue;
138
- }
139
- // File exists on disk — compare actual vs expected
140
- try {
141
- const [expected, actual] = await Promise.all([
142
- computePatchedContent(patchesDir, engineDir, entry.file),
143
- readText(join(engineDir, entry.file)),
144
- ]);
145
- results.push({
146
- ...entry,
147
- classification: actual === expected ? 'patch-backed' : 'patch-owned-drift',
148
- });
149
- }
150
- catch (error) {
151
- verbose(`Treating ${entry.file} as patch-owned drift because patch-backed classification failed: ${toError(error).message}`);
152
- results.push({ ...entry, classification: 'patch-owned-drift' });
153
- }
177
+ results.push(await classifySingleOwnerFile(entry, engineDir, patchesDir, 'patch-backed'));
154
178
  }
155
179
  return results;
156
180
  }
@@ -34,6 +34,14 @@ export interface HarnessRunVerdict {
34
34
  signature?: HarnessCrashSignature;
35
35
  /** First concrete test-failure evidence line, when available. */
36
36
  realFailureLine?: string;
37
+ /**
38
+ * First N `TEST-UNEXPECTED-*` lines verbatim, each with its trailing
39
+ * assertion/diff context lines (`Got …` / `Expected …` / Assert diff /
40
+ * stack head). Lets the failure summary echo the actual assertion so a
41
+ * one-off failure that does not reproduce is still diagnosable after the
42
+ * fact. Only set on `test-failures` verdicts when such lines exist.
43
+ */
44
+ realFailureBlocks?: string[];
37
45
  /** Harness noise seen in the same output as a real test failure. */
38
46
  secondaryHarnessSignature?: HarnessCrashSignature;
39
47
  /**
@@ -104,6 +112,14 @@ export declare function analyzeTestCompleteness(output: string, requestedPaths:
104
112
  neverStarted: string[];
105
113
  neverEnded: string[];
106
114
  };
115
+ /**
116
+ * Collects the first `limit` TEST-UNEXPECTED-* lines with their trailing
117
+ * assertion/diff context, one string per block. The shutdown-reentry
118
+ * artifact is excluded (it is harness noise, not a test result). When more
119
+ * than `limit` blocks exist, a final `…(+N more …)` note is appended so the
120
+ * operator knows the echo was truncated. Pure; exported for unit tests.
121
+ */
122
+ export declare function collectUnexpectedFailureBlocks(output: string, limit?: number): string[];
107
123
  /**
108
124
  * Detects the known harness-crash shapes in captured mach output.
109
125
  * Returns undefined for anything that looks like a genuine test result.