@hominis/fireforge 0.50.0 → 0.51.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 (205) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/dist/build-info.json +4 -4
  3. package/dist/src/commands/check-report.d.ts +17 -4
  4. package/dist/src/commands/check-report.js +19 -6
  5. package/dist/src/commands/check-run.d.ts +5 -0
  6. package/dist/src/commands/check-run.js +9 -1
  7. package/dist/src/commands/check-steps.d.ts +8 -0
  8. package/dist/src/commands/check-steps.js +31 -6
  9. package/dist/src/commands/check.js +1 -1
  10. package/dist/src/commands/declarations-report.d.ts +43 -0
  11. package/dist/src/commands/declarations-report.js +76 -0
  12. package/dist/src/commands/export-placement-gate.js +8 -0
  13. package/dist/src/commands/furnace/rename-registrations.js +7 -2
  14. package/dist/src/commands/furnace/validate.js +26 -1
  15. package/dist/src/commands/lint-per-patch.js +6 -2
  16. package/dist/src/commands/lint-per-run-checkjs.js +2 -2
  17. package/dist/src/commands/lint-report.js +7 -2
  18. package/dist/src/commands/manifest.js +2 -0
  19. package/dist/src/commands/park.d.ts +22 -0
  20. package/dist/src/commands/park.js +232 -0
  21. package/dist/src/commands/patch/delete.js +11 -6
  22. package/dist/src/commands/patch/index.d.ts +3 -3
  23. package/dist/src/commands/patch/index.js +8 -4
  24. package/dist/src/commands/patch/lint-ignore-from.d.ts +44 -0
  25. package/dist/src/commands/patch/lint-ignore-from.js +142 -0
  26. package/dist/src/commands/patch/lint-ignore.js +39 -29
  27. package/dist/src/commands/patch/move-files-create.js +11 -4
  28. package/dist/src/commands/patch/move-files-into.js +10 -4
  29. package/dist/src/commands/patch/move-files.js +3 -1
  30. package/dist/src/commands/patch/removal-created-files.d.ts +48 -0
  31. package/dist/src/commands/patch/removal-created-files.js +83 -0
  32. package/dist/src/commands/patch/rename.js +15 -3
  33. package/dist/src/commands/patch/reserve.d.ts +33 -0
  34. package/dist/src/commands/patch/reserve.js +152 -0
  35. package/dist/src/commands/patch/retire.js +11 -6
  36. package/dist/src/commands/patch/split-plan.d.ts +10 -1
  37. package/dist/src/commands/patch/split-plan.js +16 -1
  38. package/dist/src/commands/patch/split.js +14 -5
  39. package/dist/src/commands/patch/stage.d.ts +34 -0
  40. package/dist/src/commands/patch/stage.js +153 -0
  41. package/dist/src/commands/patch/staged-dependency-remove.d.ts +31 -0
  42. package/dist/src/commands/patch/staged-dependency-remove.js +80 -0
  43. package/dist/src/commands/patch/staged-dependency.js +40 -98
  44. package/dist/src/commands/re-export-adjacent.d.ts +5 -0
  45. package/dist/src/commands/re-export-adjacent.js +4 -3
  46. package/dist/src/commands/re-export-bulk-scan.d.ts +2 -1
  47. package/dist/src/commands/re-export-bulk-scan.js +22 -7
  48. package/dist/src/commands/re-export-hunks.d.ts +35 -0
  49. package/dist/src/commands/re-export-hunks.js +220 -0
  50. package/dist/src/commands/re-export-options.js +29 -2
  51. package/dist/src/commands/re-export-purity-scope.d.ts +18 -0
  52. package/dist/src/commands/re-export-purity-scope.js +131 -0
  53. package/dist/src/commands/re-export-register.js +15 -1
  54. package/dist/src/commands/re-export-removed.d.ts +1 -1
  55. package/dist/src/commands/re-export-removed.js +17 -2
  56. package/dist/src/commands/re-export-scan.d.ts +2 -0
  57. package/dist/src/commands/re-export-scan.js +4 -1
  58. package/dist/src/commands/re-export-single.js +102 -42
  59. package/dist/src/commands/re-export-stale-registrations.d.ts +18 -0
  60. package/dist/src/commands/re-export-stale-registrations.js +62 -0
  61. package/dist/src/commands/re-export.js +35 -34
  62. package/dist/src/commands/test-build-furnace.d.ts +3 -2
  63. package/dist/src/commands/test-build-furnace.js +8 -2
  64. package/dist/src/commands/test-build-phase.js +24 -7
  65. package/dist/src/commands/test-checkjs.js +3 -2
  66. package/dist/src/commands/test-dispatch.d.ts +39 -0
  67. package/dist/src/commands/test-dispatch.js +211 -0
  68. package/dist/src/commands/test-furnace-reach.d.ts +37 -0
  69. package/dist/src/commands/test-furnace-reach.js +132 -0
  70. package/dist/src/commands/test-orchestrate.d.ts +9 -0
  71. package/dist/src/commands/test-orchestrate.js +22 -4
  72. package/dist/src/commands/test-perf-scope.d.ts +21 -0
  73. package/dist/src/commands/test-perf-scope.js +55 -0
  74. package/dist/src/commands/test-prelock.js +100 -4
  75. package/dist/src/commands/test-register.js +18 -3
  76. package/dist/src/commands/test-results.d.ts +62 -0
  77. package/dist/src/commands/test-results.js +62 -0
  78. package/dist/src/commands/test-run.d.ts +13 -55
  79. package/dist/src/commands/test-run.js +73 -101
  80. package/dist/src/commands/test-shards.d.ts +110 -0
  81. package/dist/src/commands/test-shards.js +255 -0
  82. package/dist/src/commands/test-stale-gate.d.ts +5 -1
  83. package/dist/src/commands/test-stale-gate.js +4 -2
  84. package/dist/src/commands/test-verdict.d.ts +2 -1
  85. package/dist/src/commands/test.d.ts +1 -1
  86. package/dist/src/commands/test.js +65 -75
  87. package/dist/src/commands/token-lint.js +48 -8
  88. package/dist/src/commands/verify.js +9 -2
  89. package/dist/src/core/build-prepare.d.ts +6 -0
  90. package/dist/src/core/build-prepare.js +29 -3
  91. package/dist/src/core/config-paths.d.ts +1 -1
  92. package/dist/src/core/config-paths.js +2 -0
  93. package/dist/src/core/config-validate-check.js +83 -7
  94. package/dist/src/core/config-validate-test-toolchains.js +14 -0
  95. package/dist/src/core/config-validate-token-scales.d.ts +3 -0
  96. package/dist/src/core/config-validate-token-scales.js +47 -5
  97. package/dist/src/core/config-validate.js +6 -0
  98. package/dist/src/core/coverage-extend.js +2 -2
  99. package/dist/src/core/coverage-records.d.ts +11 -0
  100. package/dist/src/core/coverage-records.js +34 -0
  101. package/dist/src/core/engine-session-lock.d.ts +6 -0
  102. package/dist/src/core/engine-session-lock.js +10 -2
  103. package/dist/src/core/file-lock-shared.d.ts +57 -0
  104. package/dist/src/core/file-lock-shared.js +117 -0
  105. package/dist/src/core/file-lock.d.ts +9 -0
  106. package/dist/src/core/file-lock.js +58 -2
  107. package/dist/src/core/furnace-apply.js +10 -10
  108. package/dist/src/core/furnace-config-validate-exceptions.d.ts +10 -3
  109. package/dist/src/core/furnace-config-validate-exceptions.js +39 -8
  110. package/dist/src/core/furnace-config-validate.js +7 -1
  111. package/dist/src/core/furnace-registration-jar-inplace.d.ts +12 -0
  112. package/dist/src/core/furnace-registration-jar-inplace.js +80 -0
  113. package/dist/src/core/furnace-validate-accessibility.d.ts +4 -2
  114. package/dist/src/core/furnace-validate-accessibility.js +61 -27
  115. package/dist/src/core/furnace-validate-exceptions.d.ts +12 -4
  116. package/dist/src/core/furnace-validate-exceptions.js +41 -4
  117. package/dist/src/core/furnace-validate-icon-button.js +30 -1
  118. package/dist/src/core/history-log-file.js +8 -1
  119. package/dist/src/core/line-prefixer.d.ts +18 -0
  120. package/dist/src/core/line-prefixer.js +34 -0
  121. package/dist/src/core/mach.d.ts +7 -0
  122. package/dist/src/core/mach.js +24 -9
  123. package/dist/src/core/module-resolution-preflight.d.ts +19 -3
  124. package/dist/src/core/module-resolution-preflight.js +24 -6
  125. package/dist/src/core/negative-control-compiled.d.ts +27 -0
  126. package/dist/src/core/negative-control-compiled.js +73 -0
  127. package/dist/src/core/negative-control-diff.d.ts +14 -2
  128. package/dist/src/core/negative-control-diff.js +49 -6
  129. package/dist/src/core/negative-control.d.ts +12 -0
  130. package/dist/src/core/negative-control.js +48 -5
  131. package/dist/src/core/park-store.d.ts +63 -0
  132. package/dist/src/core/park-store.js +216 -0
  133. package/dist/src/core/patch-export-update.d.ts +4 -0
  134. package/dist/src/core/patch-export-update.js +12 -3
  135. package/dist/src/core/patch-export.d.ts +3 -2
  136. package/dist/src/core/patch-export.js +21 -14
  137. package/dist/src/core/patch-files.js +26 -8
  138. package/dist/src/core/patch-lint-checkjs-any-import.d.ts +6 -4
  139. package/dist/src/core/patch-lint-checkjs-any-import.js +9 -7
  140. package/dist/src/core/patch-lint-checkjs-diagnostics.d.ts +17 -0
  141. package/dist/src/core/patch-lint-checkjs-diagnostics.js +32 -0
  142. package/dist/src/core/patch-lint-checkjs.d.ts +10 -1
  143. package/dist/src/core/patch-lint-checkjs.js +13 -3
  144. package/dist/src/core/patch-lint-cross.d.ts +3 -1
  145. package/dist/src/core/patch-lint-cross.js +5 -0
  146. package/dist/src/core/patch-lint-css.js +14 -10
  147. package/dist/src/core/patch-lint-forward-registration-extended.d.ts +5 -0
  148. package/dist/src/core/patch-lint-forward-registration-extended.js +1 -1
  149. package/dist/src/core/patch-lint-ignore.d.ts +56 -12
  150. package/dist/src/core/patch-lint-ignore.js +173 -43
  151. package/dist/src/core/patch-lint-ownership.d.ts +10 -0
  152. package/dist/src/core/patch-lint-ownership.js +26 -0
  153. package/dist/src/core/patch-lint-size.js +9 -1
  154. package/dist/src/core/patch-lint-token-scale.d.ts +26 -8
  155. package/dist/src/core/patch-lint-token-scale.js +206 -29
  156. package/dist/src/core/patch-lint.js +2 -2
  157. package/dist/src/core/patch-manifest-io.js +8 -1
  158. package/dist/src/core/patch-manifest-key-order.d.ts +24 -0
  159. package/dist/src/core/patch-manifest-key-order.js +61 -0
  160. package/dist/src/core/patch-manifest-stage.d.ts +46 -0
  161. package/dist/src/core/patch-manifest-stage.js +96 -0
  162. package/dist/src/core/patch-manifest-validate.js +27 -1
  163. package/dist/src/core/patch-order-legal.d.ts +2 -1
  164. package/dist/src/core/patch-order-legal.js +7 -2
  165. package/dist/src/core/patch-policy.d.ts +6 -2
  166. package/dist/src/core/patch-policy.js +14 -7
  167. package/dist/src/core/patch-retired.d.ts +26 -4
  168. package/dist/src/core/patch-retired.js +40 -2
  169. package/dist/src/core/run-log.d.ts +6 -0
  170. package/dist/src/core/run-log.js +17 -1
  171. package/dist/src/core/test-harness-crash.d.ts +2 -1
  172. package/dist/src/core/test-harness-crash.js +5 -8
  173. package/dist/src/core/test-harness-signature.d.ts +16 -0
  174. package/dist/src/core/test-harness-signature.js +19 -1
  175. package/dist/src/core/test-raw-log.d.ts +66 -0
  176. package/dist/src/core/test-raw-log.js +122 -0
  177. package/dist/src/core/test-run-completeness.d.ts +36 -3
  178. package/dist/src/core/test-run-completeness.js +57 -17
  179. package/dist/src/core/test-xpcshell-retry.d.ts +2 -0
  180. package/dist/src/core/test-xpcshell-retry.js +2 -1
  181. package/dist/src/core/token-lint.d.ts +5 -2
  182. package/dist/src/core/token-lint.js +8 -3
  183. package/dist/src/core/token-sibling-sheets.d.ts +46 -0
  184. package/dist/src/core/token-sibling-sheets.js +119 -0
  185. package/dist/src/core/tree-guard.js +2 -0
  186. package/dist/src/core/typecheck-shim.d.ts +5 -1
  187. package/dist/src/core/typecheck-shim.js +115 -2
  188. package/dist/src/core/typecheck.js +3 -1
  189. package/dist/src/index.d.ts +9 -3
  190. package/dist/src/index.js +4 -1
  191. package/dist/src/types/commands/index.d.ts +2 -2
  192. package/dist/src/types/commands/options.d.ts +68 -7
  193. package/dist/src/types/commands/patches.d.ts +43 -0
  194. package/dist/src/types/config.d.ts +74 -2
  195. package/dist/src/types/furnace.d.ts +20 -0
  196. package/dist/src/types/index.d.ts +1 -1
  197. package/dist/src/utils/options.d.ts +3 -2
  198. package/dist/src/utils/options.js +9 -4
  199. package/docs/configuration.md +94 -9
  200. package/docs/furnace.md +43 -1
  201. package/docs/lifecycle-invariants.md +13 -11
  202. package/docs/machine-output.md +64 -18
  203. package/docs/patch-workflow.md +174 -13
  204. package/docs/testing.md +73 -4
  205. package/package.json +3 -3
@@ -14,19 +14,21 @@ import { join } from 'node:path';
14
14
  import { probeDisplaySleepState } from '../core/display-state.js';
15
15
  import { runMachTestSuite } from '../core/mach.js';
16
16
  import { parentExited } from '../core/parent-exit-watchdog.js';
17
- import { getActiveRunLogPath } from '../core/run-log.js';
17
+ import { activeRunLogLine, getActiveRunLogPath } from '../core/run-log.js';
18
18
  import { changedPrefNoiseVerdictNote } from '../core/test-changed-prefs.js';
19
- import { buildHarnessCrashMessage, classifyHarnessRun, headedDisplayAsleepVerdictNote, headedNoOutputTimeoutHint, } from '../core/test-harness-crash.js';
19
+ import { classifyHarnessRun, headedDisplayAsleepVerdictNote, } from '../core/test-harness-crash.js';
20
20
  import { reportTestHost, sampleTestHost, stampPerfHost } from '../core/test-host-state.js';
21
- import { vetoIncompletePass } from '../core/test-run-completeness.js';
21
+ import { forwardedRawLogPath, hasForwardedRawLog, readRawTestLog, } from '../core/test-raw-log.js';
22
+ import { createStartMarkerTracker, vetoIncompletePass, } from '../core/test-run-completeness.js';
22
23
  import { retryAfterXpcshellSymlinkRepair } from '../core/test-xpcshell-retry.js';
23
24
  import { withXpcshellProfileDir } from '../core/xpcshell-profile-dir.js';
24
25
  import { TestFailureError } from '../errors/build.js';
25
- import { info, note, warn } from '../utils/logger.js';
26
+ import { warn } from '../utils/logger.js';
26
27
  import { getPlatform } from '../utils/platform.js';
27
28
  import { maybeInjectAppdirArg } from './test-appdir.js';
28
29
  import { createHarnessTeardown } from './test-harness-teardown.js';
29
- import { emitHarnessVerdict, setVerdictRunAttribute } from './test-verdict.js';
30
+ import { recordTestResults } from './test-results.js';
31
+ import { setVerdictRunAttribute } from './test-verdict.js';
30
32
  /** Default bounded retry budget for recognized harness crashes. */
31
33
  export const DEFAULT_HARNESS_RETRIES = 2;
32
34
  /** Resolves the mach test command a suite dispatches to. */
@@ -42,21 +44,21 @@ function machKindForSuite(suite) {
42
44
  * crashes up to the configured budget. Every attempt goes through the
43
45
  * stale-xpcshell-symlink repair path the single-run flow already used.
44
46
  */
45
- export async function runTestsWithRetries(ctx, paths) {
47
+ export async function runTestsWithRetries(ctx, paths, label = paths.join(' ')) {
46
48
  // Pure mochitest dispatches keep their env byte-identical, since the profile
47
49
  // variable is xpcshell-harness-specific. Generic `mach
48
50
  // test` runs may include xpcshell paths, so they get the isolation too.
49
51
  if (ctx.suite === 'mochitest') {
50
- return runTestsWithRetriesInner(ctx, paths, ctx.env);
52
+ return runTestsWithRetriesInner(ctx, paths, ctx.env, label);
51
53
  }
52
54
  // Fresh profile dir per harness invocation: shards isolate from each
53
55
  // other and from concurrent `fireforge test` processes. The crash /
54
56
  // symlink-repair retries inside one invocation reuse the same directory
55
57
  // on purpose (they are the same logical run and the harness
56
58
  // re-initialises the profile contents itself).
57
- return withXpcshellProfileDir(ctx.env, (env) => runTestsWithRetriesInner(ctx, paths, env));
59
+ return withXpcshellProfileDir(ctx.env, (env) => runTestsWithRetriesInner(ctx, paths, env, label));
58
60
  }
59
- async function runTestsWithRetriesInner(ctx, paths, env) {
61
+ async function runTestsWithRetriesInner(ctx, paths, env, label) {
60
62
  const extraArgs = [...ctx.baseExtraArgs];
61
63
  const appdirInjectionAttempted = await maybeInjectAppdirArg(ctx.engineDir, paths, ctx.objDir, extraArgs);
62
64
  const kind = machKindForSuite(ctx.suite);
@@ -64,9 +66,15 @@ async function runTestsWithRetriesInner(ctx, paths, env) {
64
66
  let attempts = 0;
65
67
  let result;
66
68
  let verdict;
69
+ let rawLog;
70
+ let logLine;
67
71
  for (;;) {
68
72
  attempts += 1;
69
- result = await runTrackedMachTestSuite(ctx, kind, paths, extraArgs, env);
73
+ let tracker = createStartMarkerTracker();
74
+ const raw = rawLogDispatchArgs(ctx, extraArgs);
75
+ logLine = activeRunLogLine();
76
+ const dispatched = await runTrackedMachTestSuite(ctx, kind, paths, raw.args, env, tracker);
77
+ result = dispatched;
70
78
  // The parent that was reading this run is gone and the watchdog has
71
79
  // already killed the harness. Classifying the corpse would call it a
72
80
  // harness crash and retry it under nobody's supervision.
@@ -74,21 +82,32 @@ async function runTestsWithRetriesInner(ctx, paths, env) {
74
82
  throw new TestFailureError('The parent process exited while the harness was running; the harness tree was ' +
75
83
  'terminated and the run ended without a result.', `mach ${kind}`, undefined, getActiveRunLogPath());
76
84
  }
85
+ const retryTracker = createStartMarkerTracker();
77
86
  result = await retryAfterXpcshellSymlinkRepair({
78
87
  engineDir: ctx.engineDir,
79
88
  objDir: ctx.objDir,
80
89
  result,
81
90
  classification: ctx.classification,
82
91
  normalizedPaths: paths,
83
- extraArgs,
92
+ extraArgs: raw.args,
84
93
  env,
85
94
  kind,
86
95
  fullOutput: ctx.fullOutput,
96
+ onOutputChunk: (stream, chunk) => {
97
+ retryTracker.onChunk(stream, chunk);
98
+ },
87
99
  });
100
+ if (result !== dispatched)
101
+ tracker = retryTracker;
102
+ rawLog = raw.path === undefined ? undefined : await readRawTestLog(raw.path);
88
103
  const combined = `${result.stdout}\n${result.stderr}`;
89
104
  // An exit-0 run is still not a pass when it stopped partway or
90
- // reported unexpected results (see test-run-completeness.ts).
91
- verdict = vetoIncompletePass(classifyHarnessRun(result.exitCode, combined, paths), combined, paths);
105
+ // reported unexpected results (see test-run-completeness.ts). The
106
+ // start count reads the structured raw log when the run wrote one, and
107
+ // the streamed markers otherwise, never the 2 MB capture tail.
108
+ verdict = vetoIncompletePass(classifyHarnessRun(result.exitCode, combined, paths), combined, paths, rawLog !== undefined && rawLog.markers.started.length > 0
109
+ ? { markers: rawLog.markers, source: 'raw' }
110
+ : { markers: tracker.markers(), source: 'stream' });
92
111
  if (verdict.kind !== 'harness-crash' || attempts >= maxAttempts)
93
112
  break;
94
113
  warn(`Harness crash detected (${verdict.signature?.reason ?? 'unknown shape'}): ` +
@@ -113,14 +132,45 @@ async function runTestsWithRetriesInner(ctx, paths, env) {
113
132
  if (note !== undefined)
114
133
  verdict = { ...verdict, note };
115
134
  }
116
- return { result, verdict, attempts, appdirInjectionAttempted, displayState };
135
+ if (rawLog !== undefined) {
136
+ recordTestResults(rawLog.results, rawLog.unfinished, {
137
+ run: ctx.repetition ?? 1,
138
+ shard: label,
139
+ logLine,
140
+ });
141
+ }
142
+ return {
143
+ result,
144
+ verdict,
145
+ attempts,
146
+ appdirInjectionAttempted,
147
+ displayState,
148
+ ...(rawLog === undefined ? {} : { fileResults: rawLog.results }),
149
+ };
150
+ }
151
+ /** Sequence for raw-log file names, unique within the process. */
152
+ let rawLogSequence = 0;
153
+ /**
154
+ * The dispatch arguments with a `--log-raw` destination added, when this run
155
+ * collects per-file results. An operator's own forwarded `--log-raw <file>`
156
+ * is read instead of adding a second one.
157
+ */
158
+ function rawLogDispatchArgs(ctx, extraArgs) {
159
+ if (ctx.rawLogDir === undefined)
160
+ return { args: [...extraArgs], path: undefined };
161
+ if (hasForwardedRawLog(extraArgs)) {
162
+ return { args: [...extraArgs], path: forwardedRawLogPath(extraArgs) };
163
+ }
164
+ rawLogSequence += 1;
165
+ const path = join(ctx.rawLogDir, `dispatch-${rawLogSequence}.raw.jsonl`);
166
+ return { args: [...extraArgs, `--log-raw=${path}`], path };
117
167
  }
118
168
  /**
119
169
  * One mach dispatch with its kill path attached: helper-pid tracking and
120
170
  * post-close reaping, the process-group announcement, and the parent-exit
121
171
  * watchdog for exactly as long as mach runs.
122
172
  */
123
- async function runTrackedMachTestSuite(ctx, kind, paths, extraArgs, env) {
173
+ async function runTrackedMachTestSuite(ctx, kind, paths, extraArgs, env, tracker) {
124
174
  const teardown = createHarnessTeardown({
125
175
  objDir: ctx.objDir === undefined ? undefined : join(ctx.engineDir, ctx.objDir),
126
176
  pgidFile: ctx.pgidFile,
@@ -142,7 +192,14 @@ async function runTrackedMachTestSuite(ctx, kind, paths, extraArgs, env) {
142
192
  args: extraArgs,
143
193
  env,
144
194
  fullOutput: ctx.fullOutput,
145
- teardown: teardown.hooks,
195
+ ...(ctx.outputPrefix === undefined ? {} : { outputPrefix: ctx.outputPrefix }),
196
+ teardown: {
197
+ ...teardown.hooks,
198
+ onOutputChunk: (stream, chunk) => {
199
+ tracker.onChunk(stream, chunk);
200
+ teardown.hooks.onOutputChunk?.(stream, chunk);
201
+ },
202
+ },
146
203
  });
147
204
  }
148
205
  finally {
@@ -173,88 +230,3 @@ async function probeDisplayStateForStall(verdict, headless) {
173
230
  return undefined;
174
231
  return probeDisplaySleepState(getPlatform());
175
232
  }
176
- const SHARD_STATUS_LABEL = {
177
- 'tests-ran-ok': 'PASS',
178
- 'test-failures': 'FAIL',
179
- 'harness-crash': 'CRASH',
180
- 'no-tests': 'NO-TESTS',
181
- 'harness-arguments': 'ARGUMENTS',
182
- };
183
- /**
184
- * Derives the aggregate classification for a sharded run: all-green is
185
- * `tests-ran-ok`. Otherwise the most structural failure wins. A crashed
186
- * shard makes the whole run `harness-crash`, then `no-tests`, then plain
187
- * `test-failures`, so the aggregate `reason=` keeps the classifier-driven
188
- * contract instead of collapsing every failure to `test-failures`.
189
- */
190
- export function deriveAggregateShardVerdict(shards) {
191
- const kinds = new Set(shards.map(({ outcome }) => outcome.verdict.kind));
192
- if (kinds.has('harness-arguments'))
193
- return { kind: 'harness-arguments' };
194
- if (kinds.has('harness-crash'))
195
- return { kind: 'harness-crash' };
196
- if (kinds.has('no-tests'))
197
- return { kind: 'no-tests' };
198
- if (kinds.has('test-failures'))
199
- return { kind: 'test-failures' };
200
- return { kind: 'tests-ran-ok' };
201
- }
202
- /**
203
- * Runs each requested path argument as its own sequential harness
204
- * invocation (a directory argument's enumerated files stay together in
205
- * one invocation, see {@link ShardGroup}) and prints an aggregate
206
- * report. Per-shard failures are diagnosed via `diagnoseShardFailure`
207
- * (which receives the throwing diagnosis chain from the command layer)
208
- * but downgraded to warnings so every shard runs. The aggregate verdict
209
- * line and the aggregate error are not produced here: the caller runs
210
- * the engine-generation integrity check first and then calls
211
- * {@link finalizeShardedOutcome}, so a run invalidated by a concurrent
212
- * `engine/` mutation can never print `PASS` before the check fails.
213
- */
214
- export async function runShardedTests(ctx, groups, diagnoseShardFailure) {
215
- const shards = [];
216
- for (const [index, group] of groups.entries()) {
217
- info(`— Shard ${index + 1}/${groups.length}: ${group.label}`);
218
- const outcome = await runTestsWithRetries(ctx, group.paths);
219
- shards.push({ label: group.label, outcome });
220
- if (outcome.verdict.kind === 'harness-crash' && outcome.verdict.signature) {
221
- const base = buildHarnessCrashMessage(outcome.verdict.signature, outcome.attempts);
222
- const hint = headedNoOutputTimeoutHint(outcome.verdict.signature, {
223
- headless: ctx.headless,
224
- platform: getPlatform(),
225
- ...(outcome.displayState !== undefined ? { displayState: outcome.displayState } : {}),
226
- });
227
- warn(hint ? `${base}\n\n${hint}` : base);
228
- }
229
- else if (outcome.verdict.kind !== 'tests-ran-ok') {
230
- const diagnosis = diagnoseShardFailure(outcome, group.label);
231
- if (diagnosis)
232
- warn(diagnosis);
233
- }
234
- }
235
- const lines = shards.map(({ label, outcome }) => `${SHARD_STATUS_LABEL[outcome.verdict.kind].padEnd(8)} ${label}` +
236
- (outcome.attempts > 1 ? ` (${outcome.attempts} attempts)` : ''));
237
- const failing = shards.filter(({ outcome }) => outcome.verdict.kind !== 'tests-ran-ok');
238
- note(`${lines.join('\n')}\n\n${shards.length - failing.length}/${shards.length} shard(s) passed`, 'Sharded Test Summary');
239
- return {
240
- shards,
241
- passed: shards.length - failing.length,
242
- total: shards.length,
243
- aggregate: deriveAggregateShardVerdict(shards),
244
- };
245
- }
246
- /**
247
- * Emits the aggregate FIREFORGE-VERDICT line for a sharded run, then
248
- * throws the aggregate error when any shard did not pass, the same
249
- * emit-then-throw shape as `finalizeSingleRunOutcome`. Called only after
250
- * the engine-generation check succeeded.
251
- */
252
- export function finalizeShardedOutcome(summary) {
253
- emitHarnessVerdict(summary.aggregate, { passed: summary.passed, total: summary.total });
254
- if (summary.passed < summary.total) {
255
- const failing = summary.shards.filter(({ outcome }) => outcome.verdict.kind !== 'tests-ran-ok');
256
- throw new TestFailureError(`${failing.length} of ${summary.total} sharded test run(s) did not pass: ` +
257
- `${failing.map(({ label }) => label).join(', ')}. ` +
258
- 'See the per-shard diagnosis above. Use --no-shard to reproduce the combined single-invocation behaviour.', 'mach test', undefined, getActiveRunLogPath());
259
- }
260
- }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Sharded `fireforge test` runs: one harness invocation per shard, run in
3
+ * order on one harness instance, or on a pool of `--jobs <n>` instances.
4
+ *
5
+ * Each finished shard prints a `FIREFORGE-SHARD:` line, and the aggregate
6
+ * verdict names the shards that did not pass as `failed-shards=`. Before,
7
+ * the failing shards were named only in the prose of the aggregate error
8
+ * and the human summary box, so a wrapper that reruns failed shards had to
9
+ * parse a sentence that a reworded message would silently break.
10
+ *
11
+ * Under `--shard-size <n>` a shard is a chunk of n files, and the run is
12
+ * judged per file from each dispatch's raw log: `shards=` then counts files
13
+ * and `failed-shards=` names the files that failed.
14
+ */
15
+ import { type HarnessRunVerdict } from '../core/test-harness-crash.js';
16
+ import { type TestRunContext, type TestRunOutcome } from './test-run.js';
17
+ /**
18
+ * One harness invocation of a sharded run: a requested path argument and
19
+ * the mach paths dispatched for it. A file argument is a group of one. A
20
+ * directory argument groups its enumerated test files so the whole
21
+ * directory still runs in ONE browser instance, keeping cross-file state
22
+ * behaviour within a directory run. A `--shard-size` chunk is a group of
23
+ * up to n files.
24
+ */
25
+ export interface ShardGroup {
26
+ /** The path argument as the operator passed it (display label). */
27
+ label: string;
28
+ /** The mach dispatch paths for this argument. */
29
+ paths: string[];
30
+ }
31
+ /** Per-shard summary entry. */
32
+ export interface ShardOutcome {
33
+ label: string;
34
+ outcome: TestRunOutcome;
35
+ /** The shard's dispatch paths (per-file judging). */
36
+ paths?: string[];
37
+ }
38
+ /** Aggregate result of a sharded run, verdict emission deferred to the caller. */
39
+ export interface ShardedRunSummary {
40
+ shards: ShardOutcome[];
41
+ passed: number;
42
+ total: number;
43
+ /** What did not pass: shard labels, or file paths when judged per file. */
44
+ failed: string[];
45
+ /** Aggregate classifier verdict. See {@link deriveAggregateShardVerdict}. */
46
+ aggregate: HarnessRunVerdict;
47
+ }
48
+ /** How {@link runShardedTests} schedules and judges its shards. */
49
+ export interface ShardRunOptions {
50
+ /** Harness instances to run shards on at once (`--jobs`). Default 1. */
51
+ jobs?: number;
52
+ /**
53
+ * The context for instance `index` (0-based). Instance 0 must be the
54
+ * base context; others carry their own ports and output prefix.
55
+ */
56
+ contextFor?: (index: number) => Promise<TestRunContext> | TestRunContext;
57
+ /** Judge per file from the raw log (`--shard-size`). */
58
+ perFile?: boolean;
59
+ }
60
+ /**
61
+ * Derives the aggregate classification for a sharded run: all-green is
62
+ * `tests-ran-ok`. Otherwise the most structural failure wins. A crashed
63
+ * shard makes the whole run `harness-crash`, then `no-tests`, then plain
64
+ * `test-failures`, so the aggregate `reason=` keeps the classifier-driven
65
+ * contract instead of collapsing every failure to `test-failures`.
66
+ */
67
+ export declare function deriveAggregateShardVerdict(shards: ShardOutcome[]): HarnessRunVerdict;
68
+ /**
69
+ * The `FIREFORGE-SHARD:` line for one finished shard: the verdict it would
70
+ * have printed alone, plus `shard=<i>/<n>` and `path=<label>`. Never a
71
+ * `FIREFORGE-VERDICT:` line, so the run keeps exactly one.
72
+ */
73
+ export declare function formatShardLine(shard: ShardOutcome, index: number, total: number, failedFiles?: readonly string[]): string;
74
+ /**
75
+ * The files of one shard that did not pass, judged from its raw log. A file
76
+ * with no `test_end` failed (it never started, or never finished). Returns
77
+ * undefined when the shard wrote no raw log, so the caller judges the
78
+ * shard as a whole.
79
+ */
80
+ export declare function failedFilesOf(shard: ShardOutcome): string[] | undefined;
81
+ /**
82
+ * Runs each shard as its own harness invocation and prints an aggregate
83
+ * report. Per-shard failures are diagnosed via `diagnoseShardFailure`
84
+ * (which receives the throwing diagnosis chain from the command layer)
85
+ * but downgraded to warnings so every shard runs. The aggregate verdict
86
+ * line and the aggregate error are not produced here: the caller runs
87
+ * the engine-generation integrity check first and then calls
88
+ * {@link finalizeShardedOutcome}, so a run invalidated by a concurrent
89
+ * `engine/` mutation can never print `PASS` before the check fails.
90
+ */
91
+ export declare function runShardedTests(ctx: TestRunContext, groups: ShardGroup[], diagnoseShardFailure: (outcome: TestRunOutcome, label: string) => string | undefined, options?: ShardRunOptions): Promise<ShardedRunSummary>;
92
+ /**
93
+ * Emits the aggregate FIREFORGE-VERDICT line for a sharded run, then
94
+ * throws the aggregate error when any shard did not pass, the same
95
+ * emit-then-throw shape as `finalizeSingleRunOutcome`. Called only after
96
+ * the engine-generation check succeeded.
97
+ */
98
+ export declare function finalizeShardedOutcome(summary: ShardedRunSummary): void;
99
+ /**
100
+ * Splits the dispatch paths of every group into consecutive chunks of at
101
+ * most `size` files (`--shard-size`). Order is kept, and a chunk never
102
+ * mixes groups' order: it is the flattened request, cut every `size`
103
+ * files.
104
+ */
105
+ export declare function chunkShardGroups(groups: readonly ShardGroup[], size: number): ShardGroup[];
106
+ /**
107
+ * Splits one combined request into `parts` contiguous chunks of near-equal
108
+ * size, in order (`--jobs` with `--no-shard`).
109
+ */
110
+ export declare function splitContiguous(paths: readonly string[], parts: number): ShardGroup[];
@@ -0,0 +1,255 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Sharded `fireforge test` runs: one harness invocation per shard, run in
4
+ * order on one harness instance, or on a pool of `--jobs <n>` instances.
5
+ *
6
+ * Each finished shard prints a `FIREFORGE-SHARD:` line, and the aggregate
7
+ * verdict names the shards that did not pass as `failed-shards=`. Before,
8
+ * the failing shards were named only in the prose of the aggregate error
9
+ * and the human summary box, so a wrapper that reruns failed shards had to
10
+ * parse a sentence that a reworded message would silently break.
11
+ *
12
+ * Under `--shard-size <n>` a shard is a chunk of n files, and the run is
13
+ * judged per file from each dispatch's raw log: `shards=` then counts files
14
+ * and `failed-shards=` names the files that failed.
15
+ */
16
+ import { getActiveRunLogPath, writeToActiveRunLog } from '../core/run-log.js';
17
+ import { buildHarnessCrashMessage, formatFireforgeVerdictLine, headedNoOutputTimeoutHint, } from '../core/test-harness-crash.js';
18
+ import { TestFailureError } from '../errors/build.js';
19
+ import { info, note, warn } from '../utils/logger.js';
20
+ import { getPlatform } from '../utils/platform.js';
21
+ import { runTestsWithRetries } from './test-run.js';
22
+ import { emitHarnessVerdict } from './test-verdict.js';
23
+ const SHARD_STATUS_LABEL = {
24
+ 'tests-ran-ok': 'PASS',
25
+ 'test-failures': 'FAIL',
26
+ 'harness-crash': 'CRASH',
27
+ 'no-tests': 'NO-TESTS',
28
+ 'harness-arguments': 'ARGUMENTS',
29
+ };
30
+ /**
31
+ * Derives the aggregate classification for a sharded run: all-green is
32
+ * `tests-ran-ok`. Otherwise the most structural failure wins. A crashed
33
+ * shard makes the whole run `harness-crash`, then `no-tests`, then plain
34
+ * `test-failures`, so the aggregate `reason=` keeps the classifier-driven
35
+ * contract instead of collapsing every failure to `test-failures`.
36
+ */
37
+ export function deriveAggregateShardVerdict(shards) {
38
+ const kinds = new Set(shards.map(({ outcome }) => outcome.verdict.kind));
39
+ if (kinds.has('harness-arguments'))
40
+ return { kind: 'harness-arguments' };
41
+ if (kinds.has('harness-crash'))
42
+ return { kind: 'harness-crash' };
43
+ if (kinds.has('no-tests'))
44
+ return { kind: 'no-tests' };
45
+ if (kinds.has('test-failures'))
46
+ return { kind: 'test-failures' };
47
+ return { kind: 'tests-ran-ok' };
48
+ }
49
+ /**
50
+ * The `FIREFORGE-SHARD:` line for one finished shard: the verdict it would
51
+ * have printed alone, plus `shard=<i>/<n>` and `path=<label>`. Never a
52
+ * `FIREFORGE-VERDICT:` line, so the run keeps exactly one.
53
+ */
54
+ export function formatShardLine(shard, index, total, failedFiles) {
55
+ const body = formatFireforgeVerdictLine(shard.outcome.verdict).slice('FIREFORGE-VERDICT: '.length);
56
+ const files = failedFiles !== undefined && failedFiles.length > 0
57
+ ? ` failed-files=${failedFiles.join(',')}`
58
+ : '';
59
+ return `FIREFORGE-SHARD: ${body} shard=${index + 1}/${total} path=${shard.label.replace(/\s+/g, '_')}${files}`;
60
+ }
61
+ function writeShardLine(line) {
62
+ process.stdout.write(`${line}\n`);
63
+ writeToActiveRunLog(`${line}\n`);
64
+ }
65
+ /** True when a raw-log test id names the dispatched path. */
66
+ function namesPath(test, path) {
67
+ return test === path || test.endsWith(`/${path}`) || path.endsWith(`/${test}`);
68
+ }
69
+ /**
70
+ * The files of one shard that did not pass, judged from its raw log. A file
71
+ * with no `test_end` failed (it never started, or never finished). Returns
72
+ * undefined when the shard wrote no raw log, so the caller judges the
73
+ * shard as a whole.
74
+ */
75
+ export function failedFilesOf(shard) {
76
+ const results = shard.outcome.fileResults;
77
+ if (results === undefined || shard.paths === undefined)
78
+ return undefined;
79
+ return shard.paths.filter((path) => {
80
+ const result = results.find((entry) => namesPath(entry.test, path));
81
+ return result === undefined || result.unexpected;
82
+ });
83
+ }
84
+ function reportShardFailure(ctx, outcome, label, diagnoseShardFailure) {
85
+ if (outcome.verdict.kind === 'harness-crash' && outcome.verdict.signature) {
86
+ const base = buildHarnessCrashMessage(outcome.verdict.signature, outcome.attempts);
87
+ const hint = headedNoOutputTimeoutHint(outcome.verdict.signature, {
88
+ headless: ctx.headless,
89
+ platform: getPlatform(),
90
+ ...(outcome.displayState !== undefined ? { displayState: outcome.displayState } : {}),
91
+ });
92
+ warn(hint ? `${base}\n\n${hint}` : base);
93
+ }
94
+ else if (outcome.verdict.kind !== 'tests-ran-ok') {
95
+ const diagnosis = diagnoseShardFailure(outcome, label);
96
+ if (diagnosis)
97
+ warn(diagnosis);
98
+ }
99
+ }
100
+ /**
101
+ * Runs `count` items on up to `jobs` workers at once. Worker `w` handles
102
+ * the items it takes in order, and results keep item order.
103
+ */
104
+ async function runPool(count, jobs, worker) {
105
+ const results = new Array(count);
106
+ let next = 0;
107
+ const workers = Array.from({ length: Math.max(1, Math.min(jobs, count)) }, async (_, w) => {
108
+ while (next < count) {
109
+ const item = next;
110
+ next += 1;
111
+ results[item] = await worker(item, w);
112
+ }
113
+ });
114
+ await Promise.all(workers);
115
+ return results;
116
+ }
117
+ /**
118
+ * Runs each shard as its own harness invocation and prints an aggregate
119
+ * report. Per-shard failures are diagnosed via `diagnoseShardFailure`
120
+ * (which receives the throwing diagnosis chain from the command layer)
121
+ * but downgraded to warnings so every shard runs. The aggregate verdict
122
+ * line and the aggregate error are not produced here: the caller runs
123
+ * the engine-generation integrity check first and then calls
124
+ * {@link finalizeShardedOutcome}, so a run invalidated by a concurrent
125
+ * `engine/` mutation can never print `PASS` before the check fails.
126
+ */
127
+ export async function runShardedTests(ctx, groups, diagnoseShardFailure, options = {}) {
128
+ const jobs = options.jobs ?? 1;
129
+ const contexts = new Map([[0, ctx]]);
130
+ const contextFor = async (index) => {
131
+ const known = contexts.get(index);
132
+ if (known !== undefined)
133
+ return known;
134
+ const made = options.contextFor === undefined ? ctx : await options.contextFor(index);
135
+ contexts.set(index, made);
136
+ return made;
137
+ };
138
+ let finished = 0;
139
+ const shards = await runPool(groups.length, jobs, async (index, workerIndex) => {
140
+ const group = groups[index];
141
+ const instance = await contextFor(workerIndex);
142
+ info(`— Shard ${index + 1}/${groups.length}${jobs > 1 ? ` [job ${workerIndex + 1}]` : ''}: ${group.label}`);
143
+ const outcome = await runTestsWithRetries(instance, group.paths, group.label);
144
+ // Perf power-source changes are recorded on the instance's context.
145
+ if (instance.powerChanged === true)
146
+ ctx.powerChanged = true;
147
+ const shard = { label: group.label, outcome, paths: group.paths };
148
+ reportShardFailure(instance, outcome, group.label, diagnoseShardFailure);
149
+ finished += 1;
150
+ writeShardLine(formatShardLine(shard, index, groups.length, options.perFile === true ? failedFilesOf(shard) : undefined));
151
+ reportProgress(finished, groups.length, jobs);
152
+ return shard;
153
+ });
154
+ return summarizeShards(shards, options.perFile === true);
155
+ }
156
+ function reportProgress(finished, total, jobs) {
157
+ if (jobs > 1)
158
+ info(`${finished}/${total} shard(s) finished.`);
159
+ }
160
+ /**
161
+ * Builds the summary box and the counts. Per-file judging counts every
162
+ * dispatched file of a shard that wrote a raw log; a shard without one
163
+ * counts as a whole.
164
+ */
165
+ function summarizeShards(shards, perFile) {
166
+ const lines = shards.map(({ label, outcome }) => `${SHARD_STATUS_LABEL[outcome.verdict.kind].padEnd(8)} ${label}` +
167
+ (outcome.attempts > 1 ? ` (${outcome.attempts} attempts)` : ''));
168
+ const failingShards = shards.filter(({ outcome }) => outcome.verdict.kind !== 'tests-ran-ok');
169
+ let passed = shards.length - failingShards.length;
170
+ let total = shards.length;
171
+ let failed = failingShards.map(({ label }) => label);
172
+ if (perFile) {
173
+ passed = 0;
174
+ total = 0;
175
+ failed = [];
176
+ for (const shard of shards) {
177
+ const files = failedFilesOf(shard);
178
+ const ok = shard.outcome.verdict.kind === 'tests-ran-ok';
179
+ if (files === undefined) {
180
+ total += 1;
181
+ if (ok)
182
+ passed += 1;
183
+ else
184
+ failed.push(shard.label);
185
+ continue;
186
+ }
187
+ const dispatched = shard.paths ?? [];
188
+ total += dispatched.length;
189
+ // A red shard whose log names no failing file still failed: count
190
+ // every file of it, so a crash cannot read as all-green.
191
+ const red = files.length === 0 && !ok ? dispatched : files;
192
+ passed += dispatched.length - red.length;
193
+ failed.push(...red);
194
+ }
195
+ }
196
+ note(`${lines.join('\n')}\n\n${passed}/${total} ${perFile ? 'file(s)' : 'shard(s)'} passed`, 'Sharded Test Summary');
197
+ return { shards, passed, total, failed, aggregate: deriveAggregateShardVerdict(shards) };
198
+ }
199
+ /**
200
+ * Emits the aggregate FIREFORGE-VERDICT line for a sharded run, then
201
+ * throws the aggregate error when any shard did not pass, the same
202
+ * emit-then-throw shape as `finalizeSingleRunOutcome`. Called only after
203
+ * the engine-generation check succeeded.
204
+ */
205
+ export function finalizeShardedOutcome(summary) {
206
+ emitHarnessVerdict(summary.aggregate, {
207
+ passed: summary.passed,
208
+ total: summary.total,
209
+ failed: summary.failed.map((label) => label.replace(/\s+/g, '_')),
210
+ });
211
+ if (summary.passed < summary.total) {
212
+ throw new TestFailureError(`${summary.failed.length} of ${summary.total} sharded test run(s) did not pass: ` +
213
+ `${summary.failed.join(', ')}. ` +
214
+ 'See the per-shard diagnosis above. Use --no-shard to reproduce the combined single-invocation behaviour.', 'mach test', undefined, getActiveRunLogPath());
215
+ }
216
+ }
217
+ /**
218
+ * Splits the dispatch paths of every group into consecutive chunks of at
219
+ * most `size` files (`--shard-size`). Order is kept, and a chunk never
220
+ * mixes groups' order: it is the flattened request, cut every `size`
221
+ * files.
222
+ */
223
+ export function chunkShardGroups(groups, size) {
224
+ const paths = groups.flatMap((group) => group.paths);
225
+ const chunks = [];
226
+ for (let start = 0; start < paths.length; start += size) {
227
+ const chunk = paths.slice(start, start + size);
228
+ const first = chunk[0] ?? '';
229
+ chunks.push({
230
+ label: chunk.length === 1 ? first : `${first}+${chunk.length - 1}`,
231
+ paths: chunk,
232
+ });
233
+ }
234
+ return chunks;
235
+ }
236
+ /**
237
+ * Splits one combined request into `parts` contiguous chunks of near-equal
238
+ * size, in order (`--jobs` with `--no-shard`).
239
+ */
240
+ export function splitContiguous(paths, parts) {
241
+ const count = Math.max(1, Math.min(parts, paths.length));
242
+ const groups = [];
243
+ let start = 0;
244
+ for (let index = 0; index < count; index += 1) {
245
+ const size = Math.ceil((paths.length - start) / (count - index));
246
+ const chunk = paths.slice(start, start + size);
247
+ start += size;
248
+ const first = chunk[0] ?? '';
249
+ groups.push({
250
+ label: chunk.length === 1 ? first : `${first}+${chunk.length - 1}`,
251
+ paths: chunk,
252
+ });
253
+ }
254
+ return groups;
255
+ }
@@ -28,7 +28,11 @@ import type { TestOptions } from '../types/commands/index.js';
28
28
  * a full-suite request would refuse a probe that touches no fixtures. The
29
29
  * stale-content refusal still applies to it unchanged.
30
30
  */
31
- export declare function enforceStaleBuildGate(projectRoot: string, engineDir: string, options: TestOptions, normalizedPaths: readonly string[]): Promise<void>;
31
+ export declare function enforceStaleBuildGate(projectRoot: string, engineDir: string, options: TestOptions, normalizedPaths: readonly string[],
32
+ /** `quiet`: refuse only, without the exemption note (the pre-lock pass). */
33
+ gate?: {
34
+ quiet?: boolean;
35
+ }): Promise<void>;
32
36
  /**
33
37
  * Compiled-StaticComponents gate: refuses runs whose child process would
34
38
  * resolve a stale compiled component table. `components.conf` entries bake
@@ -33,7 +33,9 @@ import { describePerfStaleImpact } from './test-perf-scope.js';
33
33
  * a full-suite request would refuse a probe that touches no fixtures. The
34
34
  * stale-content refusal still applies to it unchanged.
35
35
  */
36
- export async function enforceStaleBuildGate(projectRoot, engineDir, options, normalizedPaths) {
36
+ export async function enforceStaleBuildGate(projectRoot, engineDir, options, normalizedPaths,
37
+ /** `quiet`: refuse only, without the exemption note (the pre-lock pass). */
38
+ gate = {}) {
37
39
  const stale = await checkStaleBuildForTest(projectRoot, engineDir);
38
40
  const dispatchesNoTests = options.doctor === true && normalizedPaths.length === 0;
39
41
  if (!dispatchesNoTests) {
@@ -41,7 +43,7 @@ export async function enforceStaleBuildGate(projectRoot, engineDir, options, nor
41
43
  }
42
44
  await enforceStaticComponentsGate(engineDir, stale.baseline, options);
43
45
  const exempt = stale.exempt ?? [];
44
- if (!stale.stale && exempt.length > 0) {
46
+ if (!stale.stale && exempt.length > 0 && gate.quiet !== true) {
45
47
  info(`Stale-build preflight: ${exempt.length} edit(s) since the last build need no rebuild: ${formatStaleExemptions(exempt)}.`);
46
48
  }
47
49
  if (!stale.stale)