@hominis/fireforge 0.44.4 → 0.45.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 (66) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/dist/build-info.json +4 -4
  3. package/dist/src/commands/doctor-source-pin.d.ts +29 -0
  4. package/dist/src/commands/doctor-source-pin.js +81 -0
  5. package/dist/src/commands/doctor.js +2 -0
  6. package/dist/src/commands/export-flow.js +3 -18
  7. package/dist/src/commands/patch/move-files-into.js +3 -6
  8. package/dist/src/commands/patch/split-plan.js +3 -6
  9. package/dist/src/commands/re-export-adjacent.d.ts +8 -0
  10. package/dist/src/commands/re-export-adjacent.js +26 -1
  11. package/dist/src/commands/re-export-drift.d.ts +8 -0
  12. package/dist/src/commands/re-export-drift.js +61 -0
  13. package/dist/src/commands/re-export-files.js +3 -6
  14. package/dist/src/commands/re-export-options.js +19 -5
  15. package/dist/src/commands/re-export-register.js +5 -1
  16. package/dist/src/commands/re-export-scan.js +3 -6
  17. package/dist/src/commands/re-export.js +16 -16
  18. package/dist/src/commands/test-build-phase.js +2 -1
  19. package/dist/src/commands/test-diagnose.js +14 -5
  20. package/dist/src/commands/test-register.js +1 -0
  21. package/dist/src/commands/test-run.js +4 -3
  22. package/dist/src/commands/test-stale-gate.js +5 -2
  23. package/dist/src/commands/test.js +9 -2
  24. package/dist/src/core/build-audit.js +44 -2
  25. package/dist/src/core/build-jar-escalation.d.ts +64 -0
  26. package/dist/src/core/build-jar-escalation.js +127 -0
  27. package/dist/src/core/build-prepare.d.ts +12 -1
  28. package/dist/src/core/build-prepare.js +36 -3
  29. package/dist/src/core/config-validate-file-size.d.ts +12 -0
  30. package/dist/src/core/config-validate-file-size.js +65 -0
  31. package/dist/src/core/config-validate.js +6 -0
  32. package/dist/src/core/harness-orphans.d.ts +73 -0
  33. package/dist/src/core/harness-orphans.js +186 -0
  34. package/dist/src/core/mach-known-noise-filter.d.ts +21 -0
  35. package/dist/src/core/mach-known-noise-filter.js +24 -1
  36. package/dist/src/core/mach.d.ts +7 -0
  37. package/dist/src/core/mach.js +18 -2
  38. package/dist/src/core/patch-apply.js +49 -20
  39. package/dist/src/core/patch-lint-checkjs.js +6 -1
  40. package/dist/src/core/patch-lint-file-size.d.ts +52 -0
  41. package/dist/src/core/patch-lint-file-size.js +53 -0
  42. package/dist/src/core/patch-lint-prettier.d.ts +36 -0
  43. package/dist/src/core/patch-lint-prettier.js +126 -0
  44. package/dist/src/core/patch-lint-unmanaged-companion.d.ts +106 -0
  45. package/dist/src/core/patch-lint-unmanaged-companion.js +214 -0
  46. package/dist/src/core/patch-lint.js +23 -24
  47. package/dist/src/core/patch-transform.d.ts +21 -0
  48. package/dist/src/core/patch-transform.js +38 -0
  49. package/dist/src/core/smoke-patterns.js +12 -0
  50. package/dist/src/core/test-harness-crash.js +61 -48
  51. package/dist/src/core/test-harness-verdict-notes.d.ts +55 -0
  52. package/dist/src/core/test-harness-verdict-notes.js +48 -0
  53. package/dist/src/core/test-stale-check.d.ts +23 -6
  54. package/dist/src/core/test-stale-check.js +75 -2
  55. package/dist/src/core/test-stall-triage.js +37 -19
  56. package/dist/src/errors/build.d.ts +28 -0
  57. package/dist/src/errors/build.js +49 -0
  58. package/dist/src/types/commands/options.d.ts +19 -0
  59. package/dist/src/types/commands/patches.d.ts +17 -6
  60. package/dist/src/types/config.d.ts +41 -0
  61. package/dist/src/utils/fs.d.ts +4 -2
  62. package/dist/src/utils/fs.js +3 -1
  63. package/docs/configuration.md +47 -0
  64. package/docs/exit-codes.md +4 -2
  65. package/docs/testing.md +62 -10
  66. package/package.json +1 -1
@@ -8,11 +8,13 @@ import { lintChromeScriptJsDocForFile } from './patch-lint-chrome-jsdoc.js';
8
8
  import { lintPatchedCss } from './patch-lint-css.js';
9
9
  import { detectNewFilesInDiff, extractAddedLinesPerFile } from './patch-lint-diff.js';
10
10
  import { AGGREGATE_PATCH_FILE } from './patch-lint-diff-tag.js';
11
+ import { formatFileTooLargeMessage, resolveFileSizeThresholds } from './patch-lint-file-size.js';
11
12
  import { hasRelativeImport } from './patch-lint-imports.js';
12
13
  import { validateExportJsDoc } from './patch-lint-jsdoc.js';
13
14
  import { lintMozBuildSortedLists } from './patch-lint-mozbuild.js';
14
15
  import { lintObserverTopics } from './patch-lint-observer.js';
15
16
  import { resolvePatchOwnedChromeScripts, resolvePatchOwnedSysMjs, resolvePatchOwnedTestScripts, } from './patch-lint-ownership.js';
17
+ import { invokePatchLintPrettier } from './patch-lint-prettier.js';
16
18
  // ---------------------------------------------------------------------------
17
19
  // Cross-patch lint re-exports
18
20
  // ---------------------------------------------------------------------------
@@ -31,10 +33,6 @@ export { lintPatchedCss };
31
33
  // Helpers
32
34
  // ---------------------------------------------------------------------------
33
35
  const JS_EXTENSIONS = ['.js', '.mjs', '.jsm'];
34
- const FILE_SIZE_THRESHOLDS = {
35
- general: { notice: 500, warning: 750, error: 900 },
36
- test: { notice: 1200, warning: 1400, error: 1600 },
37
- };
38
36
  /**
39
37
  * Counts the lines of `content` the way `wc -l` reports them: a trailing
40
38
  * newline terminates the last line rather than starting an empty extra one.
@@ -407,31 +405,22 @@ export async function lintPatchedJs(repoDir, affectedFiles, newFiles, config, pa
407
405
  if (isNew) {
408
406
  const lineCount = countContentLines(content);
409
407
  const isTest = isTestFile(file);
410
- const thresholds = isTest ? FILE_SIZE_THRESHOLDS.test : FILE_SIZE_THRESHOLDS.general;
408
+ const thresholds = resolveFileSizeThresholds(config.patchLint?.fileSizeThresholds, isTest);
411
409
  const label = isTest ? 'Test file' : 'New file';
412
410
  const verb = isTest ? 'splitting' : 'decomposing';
413
- if (lineCount > thresholds.error) {
414
- issues.push({
415
- file,
416
- check: 'file-too-large',
417
- message: `${label} has ${lineCount} lines (hard limit: ${thresholds.error}). Consider ${verb}.`,
418
- severity: 'error',
419
- });
420
- }
421
- else if (lineCount > thresholds.warning) {
411
+ const band = lineCount > thresholds.error
412
+ ? 'error'
413
+ : lineCount > thresholds.warning
414
+ ? 'warning'
415
+ : lineCount > thresholds.notice
416
+ ? 'notice'
417
+ : undefined;
418
+ if (band !== undefined) {
422
419
  issues.push({
423
420
  file,
424
421
  check: 'file-too-large',
425
- message: `${label} has ${lineCount} lines (soft limit: ${thresholds.warning}, hard limit: ${thresholds.error}). Consider ${verb}.`,
426
- severity: 'warning',
427
- });
428
- }
429
- else if (lineCount > thresholds.notice) {
430
- issues.push({
431
- file,
432
- check: 'file-too-large',
433
- message: `${label} has ${lineCount} lines (soft limit: ${thresholds.warning}, hard limit: ${thresholds.error}). Consider ${verb}.`,
434
- severity: 'notice',
422
+ message: formatFileTooLargeMessage({ label, lineCount, thresholds, band, verb }),
423
+ severity: band,
435
424
  });
436
425
  }
437
426
  }
@@ -715,6 +704,16 @@ export async function lintExportedPatch(repoDir, affectedFiles, diffContent, con
715
704
  }
716
705
  }
717
706
  }
707
+ // Prettier over patch-owned .sys.mjs. Deliberately NOT part of the
708
+ // precomputedCheckJs short-circuit: it is a separate tool with its own
709
+ // gate, and a queue-wide program has no equivalent to share. Scoped to
710
+ // the files THIS patch owns, run from engine/ so the engine's own config
711
+ // and ignore file decide (see `patch-lint-prettier.ts`).
712
+ const prettierGate = config.patchLint?.prettier ?? 'off';
713
+ if (prettierGate !== 'off' && patchOwnedFiles.size > 0) {
714
+ const scoped = [...patchOwnedFiles].filter((file) => !options?.checkJsReportScope || options.checkJsReportScope.has(file));
715
+ issues.push(...(await invokePatchLintPrettier(repoDir, dirname(repoDir), scoped.sort(), prettierGate)));
716
+ }
718
717
  // Filter out ignored checks last so every rule still runs (keeps the
719
718
  // implementation uniform) but suppressed rules do not surface. We do not
720
719
  // reclassify severities — an ignored error simply drops, mirroring how
@@ -18,6 +18,27 @@
18
18
  * binary new-file conflicts as unresolvable rather than auto-resolving.
19
19
  */
20
20
  export declare function extractNewFileContentFromDiff(diff: string, targetFile?: string): string;
21
+ /**
22
+ * Projects every TEXT new file a diff creates into a path → content map, for
23
+ * the cross-patch lint projections that build a synthetic patch-queue entry.
24
+ *
25
+ * Binary new files are SKIPPED rather than refused. The map feeds only the
26
+ * forward-import rule, which scans content for import statements — a binary
27
+ * blob authors none, so omitting it contributes exactly what including it
28
+ * would. Refusing instead (which is what
29
+ * {@link extractNewFileContentFromDiff} correctly does for a caller that
30
+ * named ONE file) made vendoring a new binary impossible: every projection
31
+ * site fed it every detected new file, so `export --order`, `re-export
32
+ * --scan --scan-file`, `patch move-files-into` and `patch split` all died on
33
+ * a file the export half had just written a valid `GIT binary patch` for.
34
+ * Nothing is lost by skipping: binary bodies are covered independently by the
35
+ * `binary-body-not-reconstructable` queue lint, and
36
+ * `buildPatchQueueContext` already tolerates them for real queue entries.
37
+ *
38
+ * One parse for the whole diff, rather than one per new file as the five
39
+ * call sites previously did.
40
+ */
41
+ export declare function buildNewFileTextProjection(diff: string): Map<string, string>;
21
42
  /**
22
43
  * Extracts the complete file content from a "new file" patch.
23
44
  * When targetFile is provided, only extracts content for that file
@@ -27,6 +27,15 @@ export function extractNewFileContentFromDiff(diff, targetFile) {
27
27
  if (binary) {
28
28
  throw new PatchError(`Cannot extract text content from binary patch section for ${binary.targetPath}`, binary.targetPath);
29
29
  }
30
+ return contentFromSections(sections);
31
+ }
32
+ /**
33
+ * Walks the added (`+`) lines of already-parsed sections into file content.
34
+ * Shared by {@link extractNewFileContentFromDiff} and
35
+ * {@link buildNewFileTextProjection} so the two cannot drift on the empty-file
36
+ * and no-trailing-newline cases below.
37
+ */
38
+ function contentFromSections(sections) {
30
39
  // An empty new file is a legitimate git diff: `new file mode` with no
31
40
  // hunks at all. It must extract as '' — the historical line-walker
32
41
  // returned '\n' for it, creating a one-byte file that failed checksum
@@ -51,6 +60,35 @@ export function extractNewFileContentFromDiff(diff, targetFile) {
51
60
  const result = contentLines.join('\n');
52
61
  return hasNoNewlineMarker ? result : result + '\n';
53
62
  }
63
+ /**
64
+ * Projects every TEXT new file a diff creates into a path → content map, for
65
+ * the cross-patch lint projections that build a synthetic patch-queue entry.
66
+ *
67
+ * Binary new files are SKIPPED rather than refused. The map feeds only the
68
+ * forward-import rule, which scans content for import statements — a binary
69
+ * blob authors none, so omitting it contributes exactly what including it
70
+ * would. Refusing instead (which is what
71
+ * {@link extractNewFileContentFromDiff} correctly does for a caller that
72
+ * named ONE file) made vendoring a new binary impossible: every projection
73
+ * site fed it every detected new file, so `export --order`, `re-export
74
+ * --scan --scan-file`, `patch move-files-into` and `patch split` all died on
75
+ * a file the export half had just written a valid `GIT binary patch` for.
76
+ * Nothing is lost by skipping: binary bodies are covered independently by the
77
+ * `binary-body-not-reconstructable` queue lint, and
78
+ * `buildPatchQueueContext` already tolerates them for real queue entries.
79
+ *
80
+ * One parse for the whole diff, rather than one per new file as the five
81
+ * call sites previously did.
82
+ */
83
+ export function buildNewFileTextProjection(diff) {
84
+ const newFiles = new Map();
85
+ for (const section of parseDiffSections(diff)) {
86
+ if (!section.isNewFile || section.isBinary)
87
+ continue;
88
+ newFiles.set(section.targetPath, contentFromSections([section]));
89
+ }
90
+ return newFiles;
91
+ }
54
92
  /**
55
93
  * Extracts the complete file content from a "new file" patch.
56
94
  * When targetFile is provided, only extracts content for that file
@@ -29,6 +29,18 @@ export const SMOKE_ERROR_PATTERNS = [
29
29
  /^\s*\[JavaScript (Error|Warning)\]/i,
30
30
  // IPC-layer fatal assertions — Firefox prints `###!!! [Parent] Error: …` on content-process crashes.
31
31
  /^\s*###!!! \[Parent\]/,
32
+ // A chrome:// or resource:// URL that resolves to nothing. Outside
33
+ // automation Gecko only PRINTS this (printf_stderr in
34
+ // `CheckForBrokenChromeURL`, netwerk/base/nsNetUtil.cpp); under
35
+ // `xpc::IsInAutomation()` the same condition is a
36
+ // `MOZ_CRASH_UNSAFE_PRINTF` in whichever process opened the channel. So
37
+ // the smoke probe — which runs OUTSIDE automation — sees the printed
38
+ // line for the same defect that hard-crashes every harness run, and a
39
+ // probe that exits 0 on it reports "startup is healthy" for a build that
40
+ // hangs at "Waiting for browser…". Both spellings are matched: the
41
+ // non-automation singular `URL:` and the automation plural `URLs:`.
42
+ // Allowlistable through `--console-allow` like any other pattern.
43
+ /^\s*Missing chrome or resource URLs?:/,
32
44
  ];
33
45
  /**
34
46
  * Returns `true` when `line` matches any pattern in
@@ -23,6 +23,7 @@
23
23
  * reporting phantom test failures (or phantom passes).
24
24
  */
25
25
  import { hasKnownTeardownNoise } from './mach-known-noise-filter.js';
26
+ import { truncateEvidence, unmarkedFailureEvidenceNote, } from './test-harness-verdict-notes.js';
26
27
  // The no-output-stall triage text lives in `test-stall-triage.ts` (it is
27
28
  // operator advice, not classification) and is re-exported here so the
28
29
  // command layer keeps importing its harness diagnostics from one module.
@@ -487,12 +488,22 @@ export function classifyHarnessRun(exitCode, output, requestedPaths) {
487
488
  // shutdown-re-entry shape, or a requested file that never started keeps
488
489
  // the run failing. Only the missing shutdown marker is forgiven, and only
489
490
  // when the recognized teardown traceback explains it.
490
- if (isGreenSuiteEndedByKnownTeardownNoise(output, counts, realFailures, requestedPaths)) {
491
+ const teardownOverride = evaluateGreenTeardownOverride(output, counts, realFailures, requestedPaths);
492
+ if (teardownOverride.accepted) {
491
493
  return { kind: 'tests-ran-ok', note: 'harness teardown noise ignored', ...counts };
492
494
  }
495
+ // A run that carried the recognized teardown noise and still failed says
496
+ // WHICH condition rejected the override, on the one greppable line.
497
+ const teardownNote = teardownOverride.rejectedBy !== undefined
498
+ ? `green-teardown override rejected: ${teardownOverride.rejectedBy}`
499
+ : undefined;
500
+ const withNotes = (verdict, extra) => {
501
+ const notes = [teardownNote, extra].filter((n) => n !== undefined);
502
+ return notes.length > 0 ? { ...verdict, note: notes.join('; ') } : verdict;
503
+ };
493
504
  const signature = detectHarnessCrashSignature(output);
494
505
  if (signature) {
495
- return { kind: 'harness-crash', signature, ...counts };
506
+ return withNotes({ kind: 'harness-crash', signature, ...counts });
496
507
  }
497
508
  const ranTests = hasExecutionSignal(output);
498
509
  if (!ranTests && requestedPaths.length > 0) {
@@ -518,7 +529,7 @@ export function classifyHarnessRun(exitCode, output, requestedPaths) {
518
529
  if (crashLine !== undefined ||
519
530
  completeness.neverStarted.length > 0 ||
520
531
  completeness.neverEnded.length > 0) {
521
- return {
532
+ return withNotes({
522
533
  kind: 'test-failures',
523
534
  ...counts,
524
535
  ...(firstRealFailure !== undefined ? { realFailureLine: firstRealFailure } : {}),
@@ -529,43 +540,18 @@ export function classifyHarnessRun(exitCode, output, requestedPaths) {
529
540
  neverStarted: completeness.neverStarted,
530
541
  neverEnded: completeness.neverEnded,
531
542
  },
532
- };
543
+ });
533
544
  }
534
545
  return { kind: 'tests-ran-ok', greenSummaryOverride: true, ...counts };
535
546
  }
536
547
  const wordMatchNote = unmarkedFailureEvidenceNote(counts, realFailures);
537
- return {
548
+ return withNotes({
538
549
  kind: 'test-failures',
539
550
  ...counts,
540
551
  ...(firstRealFailure !== undefined ? { realFailureLine: firstRealFailure } : {}),
541
552
  ...(failureBlocks.length > 0 ? { realFailureBlocks: failureBlocks } : {}),
542
553
  ...(secondaryHarnessSignature !== undefined ? { secondaryHarnessSignature } : {}),
543
- ...(wordMatchNote !== undefined ? { note: wordMatchNote } : {}),
544
- };
545
- }
546
- /**
547
- * Belt for the residual case the tightened {@link FAIL_LINE_PATTERN} /
548
- * {@link ASSERTION_LINE_PATTERN} cannot reach: the summary reported
549
- * `Unexpected results: 0`, yet the run is still classified `test-failures`
550
- * on evidence lines that carry NO `TEST-UNEXPECTED-*` marker.
551
- *
552
- * `unexpected=0` printed beside `reason=test-failures` is the tell that the
553
- * verdict rests on pattern matching rather than on a harness result, and a
554
- * reader who cannot see that spends hours attributing a red run to the
555
- * change under test. The note says so on the verdict line itself.
556
- *
557
- * Deliberately a NOTE and not a reclassification: a non-zero exit with no
558
- * green-shaped summary is still a failing run, and inventing a new `reason`
559
- * would break the `FIREFORGE-VERDICT:` contract its consumers parse.
560
- */
561
- function unmarkedFailureEvidenceNote(counts, realFailures) {
562
- if (counts.unexpected !== 0)
563
- return undefined;
564
- if (realFailures.length === 0)
565
- return undefined;
566
- if (realFailures.some((line) => UNEXPECTED_LINE_SINGLE.test(line)))
567
- return undefined;
568
- return 'summary reported 0 unexpected; no TEST-UNEXPECTED marker in the matched evidence';
554
+ }, wordMatchNote);
569
555
  }
570
556
  /**
571
557
  * True when the capture shows a substantively GREEN suite whose only
@@ -577,29 +563,56 @@ function unmarkedFailureEvidenceNote(counts, realFailures) {
577
563
  * unexpected count anywhere; no real `TEST-UNEXPECTED-*`/assertion line;
578
564
  * no crash marker; every requested test file both started and ended; and
579
565
  * the recognized teardown traceback is present. Anything short of that
580
- * falls through to the normal (failing) chain.
566
+ * falls through to the normal (failing) chain — with the failing condition
567
+ * NAMED, so the next occurrence is a filed bug rather than another
568
+ * undiagnosable re-run.
569
+ *
570
+ * The conditions are evaluated in a fixed order and the FIRST failing one
571
+ * is reported; a run can violate several, and reporting one deterministic
572
+ * condition beats an unordered list of everything that happened to be true.
581
573
  */
582
- function isGreenSuiteEndedByKnownTeardownNoise(output, counts, realFailures, requestedPaths) {
574
+ function evaluateGreenTeardownOverride(output, counts, realFailures, requestedPaths) {
583
575
  if (!hasKnownTeardownNoise(output))
584
- return false;
576
+ return { accepted: false };
577
+ const reject = (rejectedBy) => ({ accepted: false, rejectedBy });
585
578
  if (!hasExecutionSignal(output))
586
- return false;
587
- if (counts.checks === undefined || counts.unexpected !== 0)
588
- return false;
589
- if (!GREEN_UNEXPECTED_SUMMARY_PATTERN.test(output))
590
- return false;
591
- if (NONZERO_UNEXPECTED_SUMMARY_PATTERN.test(output))
592
- return false;
593
- if (realFailures.length > 0)
594
- return false;
595
- if (findCrashMarkerLine(output) !== undefined)
596
- return false;
579
+ return reject('no execution signal (no TEST-START/TEST_START)');
580
+ if (counts.checks === undefined)
581
+ return reject('summary printed no "Ran N checks" line');
582
+ // `counts.unexpected === 0` IS the explicit-`Unexpected results: 0` test
583
+ // the previous revision spelled a second time with
584
+ // GREEN_UNEXPECTED_SUMMARY_PATTERN: the count is parsed from the same
585
+ // line and an absent line leaves it undefined. The two are kept apart in
586
+ // the message because "printed no such line" and "printed a non-zero one"
587
+ // are different findings for the reader.
588
+ if (counts.unexpected === undefined) {
589
+ return reject('summary printed no "Unexpected results:" line');
590
+ }
591
+ if (counts.unexpected !== 0) {
592
+ return reject(`summary reported unexpected=${String(counts.unexpected)}`);
593
+ }
594
+ if (NONZERO_UNEXPECTED_SUMMARY_PATTERN.test(output)) {
595
+ return reject('a non-zero "Unexpected results:" line is present somewhere in the output');
596
+ }
597
+ if (realFailures.length > 0) {
598
+ return reject(`${String(realFailures.length)} matched failure line(s), first: ${truncateEvidence(realFailures[0] ?? '')}`);
599
+ }
600
+ const crashLine = findCrashMarkerLine(output);
601
+ if (crashLine !== undefined)
602
+ return reject(`crash marker present: ${truncateEvidence(crashLine)}`);
597
603
  // The post-green shutdown re-entry shape is deliberately a crash verdict
598
604
  // on an otherwise-green log; it must not be swept up here.
599
- if (SHUTDOWN_REENTRY_PATTERN.test(output))
600
- return false;
605
+ if (SHUTDOWN_REENTRY_PATTERN.test(output)) {
606
+ return reject('post-green shutdown re-entry shape present');
607
+ }
601
608
  const completeness = analyzeTestCompleteness(output, requestedPaths);
602
- return completeness.neverStarted.length === 0 && completeness.neverEnded.length === 0;
609
+ if (completeness.neverStarted.length > 0) {
610
+ return reject(`requested file(s) never started: ${completeness.neverStarted.join(', ')}`);
611
+ }
612
+ if (completeness.neverEnded.length > 0) {
613
+ return reject(`requested file(s) never ended: ${completeness.neverEnded.join(', ')}`);
614
+ }
615
+ return { accepted: true };
603
616
  }
604
617
  /**
605
618
  * Parses the numeric counts from the harness's embedded result summary
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Verdict-note helpers for the harness classifier.
3
+ *
4
+ * Split out of `test-harness-crash.ts` (at its per-file line budget). These
5
+ * decide the ADVISORY parenthetical on the `FIREFORGE-VERDICT:` line; none
6
+ * of them changes a status or a reason key, which the verdict contract's
7
+ * consumers parse.
8
+ */
9
+ /**
10
+ * The only part of the classifier's summary counts these notes read.
11
+ * Structural rather than an import of `HarnessSummaryCounts`: a type edge
12
+ * back into `test-harness-crash.ts` is still an edge, and the cycle gate
13
+ * rejects it.
14
+ */
15
+ interface SummaryCountsView {
16
+ unexpected?: number;
17
+ }
18
+ /**
19
+ * Belt for the residual case the tightened {@link FAIL_LINE_PATTERN} /
20
+ * {@link ASSERTION_LINE_PATTERN} cannot reach: the summary reported
21
+ * `Unexpected results: 0`, yet the run is still classified `test-failures`
22
+ * on evidence lines that carry NO `TEST-UNEXPECTED-*` marker.
23
+ *
24
+ * `unexpected=0` printed beside `reason=test-failures` is the tell that the
25
+ * verdict rests on pattern matching rather than on a harness result, and a
26
+ * reader who cannot see that spends hours attributing a red run to the
27
+ * change under test. The note says so on the verdict line itself.
28
+ *
29
+ * Deliberately a NOTE and not a reclassification: a non-zero exit with no
30
+ * green-shaped summary is still a failing run, and inventing a new `reason`
31
+ * would break the `FIREFORGE-VERDICT:` contract its consumers parse.
32
+ */
33
+ export declare function unmarkedFailureEvidenceNote(counts: SummaryCountsView, realFailures: readonly string[]): string | undefined;
34
+ /** Caps an evidence line so the one-line verdict note stays one line. */
35
+ export declare function truncateEvidence(line: string): string;
36
+ /**
37
+ * Outcome of the green-teardown belt.
38
+ *
39
+ * `accepted` is the pass. A rejection carries `rejectedBy` ONLY when the
40
+ * recognized teardown noise was present — i.e. when the belt was the thing
41
+ * that could have applied and did not. That distinction is the whole point
42
+ * of the field: a run with no teardown traceback was never a candidate and
43
+ * naming a "rejecting condition" for it would be noise, whereas a run that
44
+ * DOES carry the traceback and still fails is the shape an operator
45
+ * otherwise cannot diagnose from outside — every condition is
46
+ * all-or-nothing, so the failing verdict looks identical whichever one
47
+ * rejected it, and a downstream report spent a re-run and a filed-bug's
48
+ * worth of effort guessing between them.
49
+ */
50
+ export interface GreenTeardownOverride {
51
+ accepted: boolean;
52
+ /** Which single condition rejected, when the teardown noise was present. */
53
+ rejectedBy?: string;
54
+ }
55
+ export {};
@@ -0,0 +1,48 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Verdict-note helpers for the harness classifier.
4
+ *
5
+ * Split out of `test-harness-crash.ts` (at its per-file line budget). These
6
+ * decide the ADVISORY parenthetical on the `FIREFORGE-VERDICT:` line; none
7
+ * of them changes a status or a reason key, which the verdict contract's
8
+ * consumers parse.
9
+ */
10
+ /**
11
+ * A single `TEST-UNEXPECTED-*` marker. Local copy of the classifier's line
12
+ * pattern: importing the value would draw a runtime edge back into
13
+ * `test-harness-crash.ts` and make the split circular.
14
+ */
15
+ const UNEXPECTED_MARKER = /TEST-UNEXPECTED-\w+/;
16
+ /**
17
+ * Belt for the residual case the tightened {@link FAIL_LINE_PATTERN} /
18
+ * {@link ASSERTION_LINE_PATTERN} cannot reach: the summary reported
19
+ * `Unexpected results: 0`, yet the run is still classified `test-failures`
20
+ * on evidence lines that carry NO `TEST-UNEXPECTED-*` marker.
21
+ *
22
+ * `unexpected=0` printed beside `reason=test-failures` is the tell that the
23
+ * verdict rests on pattern matching rather than on a harness result, and a
24
+ * reader who cannot see that spends hours attributing a red run to the
25
+ * change under test. The note says so on the verdict line itself.
26
+ *
27
+ * Deliberately a NOTE and not a reclassification: a non-zero exit with no
28
+ * green-shaped summary is still a failing run, and inventing a new `reason`
29
+ * would break the `FIREFORGE-VERDICT:` contract its consumers parse.
30
+ */
31
+ export function unmarkedFailureEvidenceNote(counts, realFailures) {
32
+ if (counts.unexpected !== 0)
33
+ return undefined;
34
+ if (realFailures.length === 0)
35
+ return undefined;
36
+ if (realFailures.some((line) => UNEXPECTED_MARKER.test(line)))
37
+ return undefined;
38
+ return 'summary reported 0 unexpected; no TEST-UNEXPECTED marker in the matched evidence';
39
+ }
40
+ /** Caps an evidence line so the one-line verdict note stays one line. */
41
+ export function truncateEvidence(line) {
42
+ const flat = line.trim().replace(/\s+/g, ' ');
43
+ return flat.length <= GREEN_TEARDOWN_EVIDENCE_LIMIT
44
+ ? flat
45
+ : `${flat.slice(0, GREEN_TEARDOWN_EVIDENCE_LIMIT)}…`;
46
+ }
47
+ /** Character budget for an evidence excerpt inside the verdict note. */
48
+ const GREEN_TEARDOWN_EVIDENCE_LIMIT = 80;
@@ -66,13 +66,30 @@ export declare const FULL_SUITE_REQUEST = "(entire suite)";
66
66
  */
67
67
  export declare function findUncoveredRequestPaths(coverage: TestPackagingCoverage | undefined, requestedPaths: readonly string[]): string[];
68
68
  /**
69
- * Formats the refusal shown when a non-`--build` run requests paths the
70
- * packaged runtime's scoped `test --build` coverage does not include —
71
- * enforced on every such run, with or without `--allow-stale-build`. Kept
72
- * separate from the matcher so tests can pin structure and copy
73
- * independently (same split as {@link formatStaleBuildWarning}).
69
+ * Test manifests changed since the recorded build that sit in (or above)
70
+ * the directory of an uncovered request path — i.e. the manifests that
71
+ * EXPLAIN why this run needs coverage the build never claimed.
72
+ *
73
+ * Best-effort: a probe failure returns an empty list, so the refusal
74
+ * degrades to exactly its previous text rather than failing differently.
75
+ *
76
+ * @param engineDir - Absolute engine directory
77
+ * @param baseline - Last successful build's baseline
78
+ * @param uncovered - Uncovered request paths from
79
+ * {@link findUncoveredRequestPaths}
80
+ * @returns Engine-relative manifest paths, capped at
81
+ * {@link STALE_PATHS_LIMIT}
82
+ */
83
+ export declare function findChangedTestManifestsForPaths(engineDir: string, baseline: BuildBaseline | undefined, uncovered: readonly string[]): Promise<string[]>;
84
+ /**
85
+ * @param uncovered - Request paths the recorded packaging does not cover
86
+ * @param coverage - The recorded scoped coverage claim
87
+ * @param changedManifests - Test manifests changed since the recorded
88
+ * build that explain an uncovered path (from
89
+ * {@link findChangedTestManifestsForPaths}); omitted or empty renders
90
+ * nothing
74
91
  */
75
- export declare function formatTestCoverageRefusal(uncovered: string[], coverage: string[]): string;
92
+ export declare function formatTestCoverageRefusal(uncovered: string[], coverage: string[], changedManifests?: readonly string[]): string;
76
93
  /** Result of the compiled-StaticComponents staleness probe. */
77
94
  export interface StaticComponentsStaleResult {
78
95
  /** True when at least one `components.conf` genuinely diverged from the anchor. */
@@ -147,7 +147,74 @@ function normalizeCoveragePath(path) {
147
147
  * separate from the matcher so tests can pin structure and copy
148
148
  * independently (same split as {@link formatStaleBuildWarning}).
149
149
  */
150
- export function formatTestCoverageRefusal(uncovered, coverage) {
150
+ /**
151
+ * Basenames of the test manifests whose entries decide what a packaged test
152
+ * runtime stages. A manifest that gained an entry is the single most common
153
+ * reason a run needs coverage the last build never claimed — and it is a
154
+ * fact FireForge already holds but never said, leaving the operator to
155
+ * rediscover it after a correct refusal.
156
+ */
157
+ const TEST_MANIFEST_BASENAMES = new Set([
158
+ 'xpcshell.toml',
159
+ 'xpcshell.ini',
160
+ 'browser.toml',
161
+ 'browser.ini',
162
+ 'mochitest.toml',
163
+ 'mochitest.ini',
164
+ 'chrome.toml',
165
+ 'chrome.ini',
166
+ 'a11y.toml',
167
+ 'a11y.ini',
168
+ ]);
169
+ /** True for a test manifest path (by basename). */
170
+ function isTestManifestPath(path) {
171
+ const base = path.slice(path.lastIndexOf('/') + 1);
172
+ return TEST_MANIFEST_BASENAMES.has(base);
173
+ }
174
+ /**
175
+ * Test manifests changed since the recorded build that sit in (or above)
176
+ * the directory of an uncovered request path — i.e. the manifests that
177
+ * EXPLAIN why this run needs coverage the build never claimed.
178
+ *
179
+ * Best-effort: a probe failure returns an empty list, so the refusal
180
+ * degrades to exactly its previous text rather than failing differently.
181
+ *
182
+ * @param engineDir - Absolute engine directory
183
+ * @param baseline - Last successful build's baseline
184
+ * @param uncovered - Uncovered request paths from
185
+ * {@link findUncoveredRequestPaths}
186
+ * @returns Engine-relative manifest paths, capped at
187
+ * {@link STALE_PATHS_LIMIT}
188
+ */
189
+ export async function findChangedTestManifestsForPaths(engineDir, baseline, uncovered) {
190
+ let changed;
191
+ try {
192
+ changed = await collectChangedEnginePaths(engineDir, baseline, 'Coverage refusal');
193
+ }
194
+ catch (error) {
195
+ verbose(`Coverage refusal: manifest probe failed — ${toError(error).message}`);
196
+ return [];
197
+ }
198
+ const requestDirs = uncovered
199
+ .filter((p) => p !== FULL_SUITE_REQUEST)
200
+ .map((p) => toManifestGranule(normalizeCoveragePath(p)));
201
+ const hits = changed.filter((path) => {
202
+ if (!isTestManifestPath(path))
203
+ return false;
204
+ const dir = path.slice(0, Math.max(0, path.lastIndexOf('/')));
205
+ return requestDirs.some((req) => req === dir || req.startsWith(`${dir}/`));
206
+ });
207
+ return hits.slice(0, STALE_PATHS_LIMIT);
208
+ }
209
+ /**
210
+ * @param uncovered - Request paths the recorded packaging does not cover
211
+ * @param coverage - The recorded scoped coverage claim
212
+ * @param changedManifests - Test manifests changed since the recorded
213
+ * build that explain an uncovered path (from
214
+ * {@link findChangedTestManifestsForPaths}); omitted or empty renders
215
+ * nothing
216
+ */
217
+ export function formatTestCoverageRefusal(uncovered, coverage, changedManifests = []) {
151
218
  const cap = (paths) => {
152
219
  const head = paths.slice(0, STALE_PATHS_LIMIT);
153
220
  const truncated = paths.length - head.length;
@@ -166,10 +233,16 @@ export function formatTestCoverageRefusal(uncovered, coverage) {
166
233
  const rebuildHint = rebuildTargets.length > 0
167
234
  ? `Rerun "fireforge test --build ${rebuildTargets.slice(0, STALE_PATHS_LIMIT).join(' ')}" to package them, or run "fireforge build" for full coverage.${extendHint}`
168
235
  : 'Run "fireforge build" (or a path-less "fireforge test --build") for full coverage first.';
236
+ // Why THIS run needs new coverage, when a changed manifest explains it: a
237
+ // manifest that gained an entry is not covered by a build claim recorded
238
+ // for other paths, and a browser-chrome claim never covers xpcshell.
239
+ const manifestLine = changedManifests.length > 0
240
+ ? ` This run needs coverage the recorded build never claimed because a test manifest changed since it: ${changedManifests.map((m) => `engine/${m}`).join(', ')} — a manifest that gained an entry stages new support fixtures, and a claim recorded for other paths (or another harness) does not cover them.`
241
+ : '';
169
242
  return (`The packaged test runtime was produced by a scoped "fireforge test --build" covering only: ${cap(coverage)}.\n` +
170
243
  `The requested run needs ${cap(uncovered)}, which that packaging does not cover — support ` +
171
244
  'fixtures for those manifests may be missing from obj-*/_tests/, and the run can hang ' +
172
- `rather than fail. ${rebuildHint} ` +
245
+ `rather than fail.${manifestLine} ${rebuildHint} ` +
173
246
  '(--allow-stale-build does not bypass this check — it accepts stale content, not missing coverage.)');
174
247
  }
175
248
  /**