@hominis/fireforge 0.52.0 → 0.54.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 (248) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/dist/build-info.json +4 -4
  3. package/dist/src/commands/bootstrap.js +2 -3
  4. package/dist/src/commands/build-prelock.d.ts +3 -3
  5. package/dist/src/commands/build-prelock.js +3 -3
  6. package/dist/src/commands/build.js +4 -0
  7. package/dist/src/commands/declarations-report.d.ts +9 -3
  8. package/dist/src/commands/declarations-report.js +18 -11
  9. package/dist/src/commands/discard.js +3 -3
  10. package/dist/src/commands/doctor-furnace-config-sync.js +4 -4
  11. package/dist/src/commands/doctor-furnace.js +5 -5
  12. package/dist/src/commands/export-adopt-check.d.ts +5 -6
  13. package/dist/src/commands/export-adopt-check.js +5 -6
  14. package/dist/src/commands/export-flow.js +6 -9
  15. package/dist/src/commands/export-staged-registration.d.ts +4 -4
  16. package/dist/src/commands/export-staged-registration.js +4 -4
  17. package/dist/src/commands/forward-import-remedy.d.ts +2 -3
  18. package/dist/src/commands/forward-import-remedy.js +2 -3
  19. package/dist/src/commands/furnace/create-dry-run.d.ts +2 -2
  20. package/dist/src/commands/furnace/deploy-fragment-includers.d.ts +2 -2
  21. package/dist/src/commands/furnace/deploy-fragment-includers.js +2 -2
  22. package/dist/src/commands/furnace/index.js +2 -2
  23. package/dist/src/commands/lint.js +4 -4
  24. package/dist/src/commands/patch/compact.js +1 -1
  25. package/dist/src/commands/patch/lint-ignore-from.d.ts +3 -4
  26. package/dist/src/commands/patch/lint-ignore-from.js +3 -4
  27. package/dist/src/commands/patch/move-files-adopt.d.ts +33 -0
  28. package/dist/src/commands/patch/move-files-adopt.js +65 -0
  29. package/dist/src/commands/patch/move-files-create.js +41 -20
  30. package/dist/src/commands/patch/move-files-into.d.ts +18 -0
  31. package/dist/src/commands/patch/move-files-into.js +125 -60
  32. package/dist/src/commands/patch/move-files-retire.d.ts +39 -0
  33. package/dist/src/commands/patch/move-files-retire.js +91 -0
  34. package/dist/src/commands/patch/move-files.d.ts +3 -0
  35. package/dist/src/commands/patch/move-files.js +42 -12
  36. package/dist/src/commands/patch/move-size-tier.d.ts +27 -0
  37. package/dist/src/commands/patch/move-size-tier.js +66 -0
  38. package/dist/src/commands/patch/rename.js +1 -1
  39. package/dist/src/commands/patch/reorder-projection.d.ts +17 -0
  40. package/dist/src/commands/patch/reorder-projection.js +49 -0
  41. package/dist/src/commands/patch/reorder-shift.d.ts +30 -0
  42. package/dist/src/commands/patch/reorder-shift.js +307 -0
  43. package/dist/src/commands/patch/reorder.d.ts +9 -15
  44. package/dist/src/commands/patch/reorder.js +52 -50
  45. package/dist/src/commands/patch/split-plan.d.ts +31 -3
  46. package/dist/src/commands/patch/split-plan.js +36 -12
  47. package/dist/src/commands/patch/split-staged.d.ts +2 -2
  48. package/dist/src/commands/patch/split-staged.js +2 -2
  49. package/dist/src/commands/patch/split.js +18 -5
  50. package/dist/src/commands/patch/staged-dependency-entries.d.ts +9 -1
  51. package/dist/src/commands/patch/staged-dependency-entries.js +32 -4
  52. package/dist/src/commands/patch/staged-dependency-from.js +15 -9
  53. package/dist/src/commands/patch/staged-dependency-remove.d.ts +26 -4
  54. package/dist/src/commands/patch/staged-dependency-remove.js +60 -23
  55. package/dist/src/commands/patch/staged-dependency.js +35 -24
  56. package/dist/src/commands/patch/tier.js +2 -2
  57. package/dist/src/commands/patch-rename-map.d.ts +14 -3
  58. package/dist/src/commands/patch-rename-map.js +21 -3
  59. package/dist/src/commands/re-export-adjacent.d.ts +4 -5
  60. package/dist/src/commands/re-export-lint.d.ts +4 -3
  61. package/dist/src/commands/re-export-lint.js +4 -3
  62. package/dist/src/commands/re-export-options.js +8 -2
  63. package/dist/src/commands/re-export-preflight.js +1 -2
  64. package/dist/src/commands/re-export-purity-scope.d.ts +1 -1
  65. package/dist/src/commands/re-export-purity-scope.js +13 -7
  66. package/dist/src/commands/re-export-retired-declarations.d.ts +22 -0
  67. package/dist/src/commands/re-export-retired-declarations.js +113 -0
  68. package/dist/src/commands/re-export-single.js +2 -2
  69. package/dist/src/commands/re-export-stale-registrations.d.ts +27 -6
  70. package/dist/src/commands/re-export-stale-registrations.js +65 -21
  71. package/dist/src/commands/re-export.js +4 -0
  72. package/dist/src/commands/rebase/continue.js +3 -3
  73. package/dist/src/commands/register.js +5 -4
  74. package/dist/src/commands/run.js +11 -4
  75. package/dist/src/commands/status.js +1 -1
  76. package/dist/src/commands/test-build-furnace.d.ts +1 -2
  77. package/dist/src/commands/test-build-furnace.js +1 -2
  78. package/dist/src/commands/test-build-phase.d.ts +8 -0
  79. package/dist/src/commands/test-build-phase.js +42 -7
  80. package/dist/src/commands/test-control-restore.d.ts +40 -0
  81. package/dist/src/commands/test-control-restore.js +86 -0
  82. package/dist/src/commands/test-dispatch.js +68 -7
  83. package/dist/src/commands/test-multi-verdict.js +1 -0
  84. package/dist/src/commands/test-orchestrate.d.ts +6 -0
  85. package/dist/src/commands/test-orchestrate.js +32 -1
  86. package/dist/src/commands/test-perf-scope.d.ts +1 -1
  87. package/dist/src/commands/test-perf-scope.js +1 -1
  88. package/dist/src/commands/test-prelock.d.ts +18 -8
  89. package/dist/src/commands/test-prelock.js +42 -14
  90. package/dist/src/commands/test-register.js +4 -3
  91. package/dist/src/commands/test-results.d.ts +10 -1
  92. package/dist/src/commands/test-results.js +20 -3
  93. package/dist/src/commands/test-run.d.ts +6 -0
  94. package/dist/src/commands/test-run.js +23 -4
  95. package/dist/src/commands/test-shards.d.ts +7 -3
  96. package/dist/src/commands/test-shards.js +24 -7
  97. package/dist/src/commands/test-stale-gate.d.ts +6 -1
  98. package/dist/src/commands/test-stale-gate.js +40 -2
  99. package/dist/src/commands/test-verdict.d.ts +5 -3
  100. package/dist/src/commands/test.js +14 -2
  101. package/dist/src/commands/token.js +2 -2
  102. package/dist/src/commands/verify.js +1 -1
  103. package/dist/src/commands/watch.js +1 -1
  104. package/dist/src/commands/wire.js +1 -1
  105. package/dist/src/core/build-audit-resolve.d.ts +4 -5
  106. package/dist/src/core/build-audit-resolve.js +4 -5
  107. package/dist/src/core/build-audit-skip.d.ts +2 -2
  108. package/dist/src/core/build-audit.d.ts +3 -4
  109. package/dist/src/core/build-baseline-types.d.ts +24 -3
  110. package/dist/src/core/build-baseline-validate.d.ts +30 -1
  111. package/dist/src/core/build-baseline-validate.js +127 -35
  112. package/dist/src/core/build-baseline.d.ts +8 -2
  113. package/dist/src/core/build-baseline.js +25 -16
  114. package/dist/src/core/build-jar-escalation.d.ts +4 -4
  115. package/dist/src/core/build-jar-escalation.js +4 -4
  116. package/dist/src/core/build-prepare.js +4 -4
  117. package/dist/src/core/config-validate.js +4 -4
  118. package/dist/src/core/coverage-extend.d.ts +5 -3
  119. package/dist/src/core/coverage-extend.js +8 -3
  120. package/dist/src/core/coverage-records.d.ts +4 -5
  121. package/dist/src/core/coverage-records.js +4 -5
  122. package/dist/src/core/destructive.d.ts +2 -2
  123. package/dist/src/core/destructive.js +2 -2
  124. package/dist/src/core/discard-baseline.d.ts +2 -2
  125. package/dist/src/core/discard-baseline.js +3 -3
  126. package/dist/src/core/engine-session-lock.d.ts +1 -1
  127. package/dist/src/core/engine-session-lock.js +1 -1
  128. package/dist/src/core/file-lock-owner.d.ts +5 -5
  129. package/dist/src/core/file-lock-owner.js +5 -5
  130. package/dist/src/core/file-lock-queue.js +6 -8
  131. package/dist/src/core/file-lock-shared.d.ts +4 -4
  132. package/dist/src/core/file-lock-shared.js +4 -4
  133. package/dist/src/core/file-lock.js +7 -10
  134. package/dist/src/core/firefox.js +2 -2
  135. package/dist/src/core/furnace-apply-overwrite-warn.d.ts +1 -1
  136. package/dist/src/core/furnace-apply-overwrite-warn.js +1 -1
  137. package/dist/src/core/furnace-apply.js +3 -3
  138. package/dist/src/core/furnace-operation.js +5 -5
  139. package/dist/src/core/furnace-registration-jar-inplace.js +5 -6
  140. package/dist/src/core/furnace-registration-rename.d.ts +5 -5
  141. package/dist/src/core/furnace-registration-rename.js +5 -5
  142. package/dist/src/core/furnace-registration-validate.d.ts +2 -1
  143. package/dist/src/core/furnace-registration-validate.js +2 -1
  144. package/dist/src/core/furnace-registration.js +2 -2
  145. package/dist/src/core/git-base.js +4 -3
  146. package/dist/src/core/git-diff.js +1 -1
  147. package/dist/src/core/git.js +1 -1
  148. package/dist/src/core/harness-orphans.d.ts +6 -6
  149. package/dist/src/core/harness-orphans.js +6 -6
  150. package/dist/src/core/harness-port-reservation.d.ts +60 -0
  151. package/dist/src/core/harness-port-reservation.js +122 -0
  152. package/dist/src/core/history-log-file.d.ts +9 -9
  153. package/dist/src/core/history-log-file.js +15 -15
  154. package/dist/src/core/history-log.d.ts +3 -3
  155. package/dist/src/core/history-log.js +3 -3
  156. package/dist/src/core/launchable-build.d.ts +39 -0
  157. package/dist/src/core/launchable-build.js +85 -0
  158. package/dist/src/core/marionette-port.js +1 -3
  159. package/dist/src/core/moz-manifest-rules.js +3 -2
  160. package/dist/src/core/negative-control-compiled.d.ts +13 -3
  161. package/dist/src/core/negative-control-compiled.js +27 -5
  162. package/dist/src/core/negative-control-diff.js +31 -6
  163. package/dist/src/core/parser-fallback.d.ts +5 -5
  164. package/dist/src/core/parser-fallback.js +5 -5
  165. package/dist/src/core/patch-identifier-suggest.d.ts +6 -0
  166. package/dist/src/core/patch-identifier-suggest.js +11 -4
  167. package/dist/src/core/patch-lint-checkjs.d.ts +1 -1
  168. package/dist/src/core/patch-lint-checkjs.js +1 -1
  169. package/dist/src/core/patch-lint-css.js +2 -2
  170. package/dist/src/core/patch-lint-file-size.d.ts +5 -7
  171. package/dist/src/core/patch-lint-file-size.js +5 -7
  172. package/dist/src/core/patch-lint-ignore.d.ts +3 -3
  173. package/dist/src/core/patch-lint-ignore.js +3 -3
  174. package/dist/src/core/patch-lint-prettier.d.ts +4 -6
  175. package/dist/src/core/patch-lint-prettier.js +4 -6
  176. package/dist/src/core/patch-lint-projection.d.ts +1 -1
  177. package/dist/src/core/patch-lint-projection.js +1 -1
  178. package/dist/src/core/patch-lint-queue-types.d.ts +2 -2
  179. package/dist/src/core/patch-lint-queue-types.js +2 -2
  180. package/dist/src/core/patch-manifest-consistency.d.ts +2 -2
  181. package/dist/src/core/patch-manifest-consistency.js +2 -2
  182. package/dist/src/core/patch-manifest-io.d.ts +5 -1
  183. package/dist/src/core/patch-manifest-io.js +12 -10
  184. package/dist/src/core/patch-manifest-key-order.d.ts +3 -4
  185. package/dist/src/core/patch-manifest-key-order.js +3 -4
  186. package/dist/src/core/patch-parse.d.ts +3 -3
  187. package/dist/src/core/patch-parse.js +3 -3
  188. package/dist/src/core/patch-policy.d.ts +1 -1
  189. package/dist/src/core/patch-policy.js +1 -1
  190. package/dist/src/core/patch-source-metadata.d.ts +1 -1
  191. package/dist/src/core/patch-source-metadata.js +1 -1
  192. package/dist/src/core/patch-subset.d.ts +5 -3
  193. package/dist/src/core/patch-subset.js +8 -7
  194. package/dist/src/core/patch-transform.d.ts +2 -2
  195. package/dist/src/core/patch-transform.js +2 -2
  196. package/dist/src/core/rebase-session.js +4 -6
  197. package/dist/src/core/register-xpcshell-test.d.ts +28 -7
  198. package/dist/src/core/register-xpcshell-test.js +76 -27
  199. package/dist/src/core/run-log.d.ts +8 -8
  200. package/dist/src/core/run-log.js +8 -8
  201. package/dist/src/core/status-classify.d.ts +1 -2
  202. package/dist/src/core/status-classify.js +1 -2
  203. package/dist/src/core/test-harness-crash.d.ts +5 -3
  204. package/dist/src/core/test-harness-crash.js +13 -14
  205. package/dist/src/core/test-harness-infra.d.ts +32 -0
  206. package/dist/src/core/test-harness-infra.js +69 -0
  207. package/dist/src/core/test-harness-signature.d.ts +6 -0
  208. package/dist/src/core/test-stale-check.d.ts +38 -0
  209. package/dist/src/core/test-stale-check.js +82 -1
  210. package/dist/src/core/test-stall-triage.js +5 -7
  211. package/dist/src/core/token-category.js +2 -2
  212. package/dist/src/core/token-inventory.js +3 -3
  213. package/dist/src/core/token-manager.d.ts +2 -2
  214. package/dist/src/core/token-manager.js +5 -7
  215. package/dist/src/core/token-variant.d.ts +6 -6
  216. package/dist/src/core/token-variant.js +7 -7
  217. package/dist/src/core/typecheck-shim.js +1 -1
  218. package/dist/src/core/wire-destroy.d.ts +1 -1
  219. package/dist/src/core/wire-destroy.js +1 -1
  220. package/dist/src/core/wire-dom-fragment.js +1 -1
  221. package/dist/src/core/wire-init.d.ts +1 -1
  222. package/dist/src/core/wire-init.js +1 -1
  223. package/dist/src/core/wire-subscript.d.ts +1 -1
  224. package/dist/src/core/wire-subscript.js +1 -1
  225. package/dist/src/core/wire-utils.d.ts +2 -2
  226. package/dist/src/core/wire-utils.js +3 -3
  227. package/dist/src/errors/base.d.ts +1 -1
  228. package/dist/src/errors/base.js +1 -1
  229. package/dist/src/errors/rebase.d.ts +1 -1
  230. package/dist/src/errors/rebase.js +1 -1
  231. package/dist/src/types/commands/options.d.ts +38 -3
  232. package/dist/src/types/config.d.ts +5 -7
  233. package/dist/src/utils/options.js +2 -2
  234. package/dist/src/utils/process.js +2 -2
  235. package/dist/src/utils/regex.d.ts +2 -2
  236. package/dist/src/utils/regex.js +3 -3
  237. package/dist/src/utils/sleep.d.ts +5 -6
  238. package/dist/src/utils/sleep.js +5 -6
  239. package/dist/src/utils/validation.js +2 -2
  240. package/docs/configuration.md +28 -23
  241. package/docs/exit-codes.md +8 -11
  242. package/docs/furnace.md +3 -3
  243. package/docs/lifecycle-invariants.md +36 -45
  244. package/docs/machine-output.md +41 -24
  245. package/docs/patch-workflow.md +140 -29
  246. package/docs/run-logs.md +8 -9
  247. package/docs/testing.md +114 -48
  248. package/package.json +1 -1
@@ -39,6 +39,7 @@ const REASON_RANK = [
39
39
  'inconclusive',
40
40
  'preflight',
41
41
  'crash',
42
+ 'server-startup',
42
43
  'harness-arguments',
43
44
  'no-tests',
44
45
  'test-failures',
@@ -14,6 +14,7 @@
14
14
  * `FIREFORGE-VERDICT:` line. A request that needs none of this runs the
15
15
  * body exactly as before.
16
16
  */
17
+ import type { ControlBuildStamp } from '../core/build-baseline-types.js';
17
18
  import type { TestOptions } from '../types/commands/index.js';
18
19
  /** What the orchestrator asks of one sub-run beyond its paths and options. */
19
20
  export interface TestRunHooks {
@@ -35,6 +36,11 @@ export interface TestRunHooks {
35
36
  * compiles nothing, so a compiled control's binary needs the full one.
36
37
  */
37
38
  fullBuildReason?: string;
39
+ /**
40
+ * Stamp the pre-test build records on its baseline because it compiles a
41
+ * negative control in (see `BuildBaseline.controlBuild`).
42
+ */
43
+ controlBuild?: ControlBuildStamp;
38
44
  /** `--repeat` run this sub-run is (1-based), for `--results`. */
39
45
  repetition?: number;
40
46
  /** Where dispatches write their mozlog raw logs (`--results`, `--shard-size`). */
@@ -24,8 +24,10 @@ import { TestFailureError } from '../errors/build.js';
24
24
  import { toError } from '../utils/errors.js';
25
25
  import { info, notice, success } from '../utils/logger.js';
26
26
  import { stripEnginePrefix } from '../utils/paths.js';
27
+ import { controlBuildRecorded, runControlRestoreBuild } from './test-control-restore.js';
27
28
  import { classifyTestHarnesses } from './test-modes.js';
28
29
  import { deriveControlOutcome, describeControlOutcome, formatAggregateBody, formatRunLine, parseCapturedVerdict, worstRun, } from './test-multi-verdict.js';
30
+ import { rearmSpentWaits } from './test-prelock.js';
29
31
  import { emitAggregateVerdict, emitFailVerdict, setVerdictCapture, setVerdictRunAttribute, verdictEmitted, } from './test-verdict.js';
30
32
  function assertMultiRunUsage(testPaths, options) {
31
33
  const flag = options.negativeControl !== undefined ? '--negative-control' : '--repeat';
@@ -57,7 +59,9 @@ async function splitHarnesses(engineDir, testPaths, options) {
57
59
  }
58
60
  /** Options for every run after the first: the build and doctor run once. */
59
61
  function laterRunOptions(options) {
60
- const later = { ...options };
62
+ // A later run follows the earlier runs' work under the same lock hold, so
63
+ // its browser and port checks wait out the budgets again.
64
+ const later = { ...rearmSpentWaits(options) };
61
65
  delete later.build;
62
66
  delete later.extendCoverage;
63
67
  delete later.refuseUnexportedDrift;
@@ -105,6 +109,26 @@ function controlHook(paths, plan, slot) {
105
109
  'it stays in place until this run has finished.');
106
110
  };
107
111
  }
112
+ /** The stamp a compiled control's pre-test build records on its baseline. */
113
+ function compiledControlStamp(options, compiled) {
114
+ if (compiled === undefined || options.negativeControl === undefined)
115
+ return undefined;
116
+ return {
117
+ diffFile: options.negativeControl,
118
+ targets: [...compiled],
119
+ appliedAt: new Date().toISOString(),
120
+ };
121
+ }
122
+ /**
123
+ * After a compiled control was restored, rebuilds the binary that still
124
+ * carries it, when this run's control build recorded its stamp, and
125
+ * stamps `control-restore-build=done|failed` on the verdict.
126
+ */
127
+ async function rebuildAfterCompiledControl(projectRoot, stamp, options) {
128
+ if (stamp === undefined || !(await controlBuildRecorded(projectRoot, stamp)))
129
+ return;
130
+ setVerdictRunAttribute('control-restore-build', await runControlRestoreBuild(projectRoot, stamp, options.harnessRetries));
131
+ }
108
132
  /**
109
133
  * Throws the invocation's error after the aggregate verdict, or returns
110
134
  * when the invocation succeeded (all runs green, or a control caught).
@@ -172,6 +196,7 @@ export async function orchestrateTestRuns(projectRoot, testPaths, options, body,
172
196
  }
173
197
  setVerdictRunAttribute('control-build', 'compiled');
174
198
  }
199
+ const controlStamp = compiledControlStamp(options, compiled);
175
200
  const results = [];
176
201
  let restoreError;
177
202
  for (const [index, run] of runs.entries()) {
@@ -190,6 +215,7 @@ export async function orchestrateTestRuns(projectRoot, testPaths, options, body,
190
215
  ? {
191
216
  beforeBuild: controlHook(controlPaths, plan, slot),
192
217
  fullBuildReason: formatCompiledControlBuildNotice(compiled),
218
+ ...(controlStamp === undefined ? {} : { controlBuild: controlStamp }),
193
219
  }
194
220
  : { beforeDispatch: controlHook(controlPaths, plan, slot) }),
195
221
  });
@@ -234,6 +260,11 @@ export async function orchestrateTestRuns(projectRoot, testPaths, options, body,
234
260
  emitFailVerdict('inconclusive', 'negative-control-restore');
235
261
  throw toError(restoreError);
236
262
  }
263
+ // The source is back byte-exact, but the binary still carries the
264
+ // compiled control: rebuild it under this same hold (see
265
+ // test-control-restore.ts). Only after every run, since a later
266
+ // `--repeat` run tests the same control-built binary.
267
+ await rebuildAfterCompiledControl(projectRoot, controlStamp, options);
237
268
  const repetitions = options.repeat ?? 1;
238
269
  const control = plan === undefined
239
270
  ? undefined
@@ -32,7 +32,7 @@ export declare function perfGenerationTolerance(engineDir: string): (before: str
32
32
  * (with a warning, and `drift-tolerated=<n>` on the verdict) when HEAD did
33
33
  * not move and every changed path is one the running browser cannot see.
34
34
  *
35
- * A peer deleting an unrelated chrome script used to make a whole run
35
+ * A peer deleting an unrelated chrome script must not make a whole run
36
36
  * inconclusive. What a running browser can see mid-run is narrower than
37
37
  * "anything under engine/": a file the objdir links to its source (`live`),
38
38
  * one the harness stages at dispatch (`at-dispatch`), and the run's own
@@ -86,7 +86,7 @@ export function perfGenerationTolerance(engineDir) {
86
86
  * (with a warning, and `drift-tolerated=<n>` on the verdict) when HEAD did
87
87
  * not move and every changed path is one the running browser cannot see.
88
88
  *
89
- * A peer deleting an unrelated chrome script used to make a whole run
89
+ * A peer deleting an unrelated chrome script must not make a whole run
90
90
  * inconclusive. What a running browser can see mid-run is narrower than
91
91
  * "anything under engine/": a file the objdir links to its source (`live`),
92
92
  * one the harness stages at dispatch (`at-dispatch`), and the run's own
@@ -2,15 +2,14 @@
2
2
  * The `fireforge test` checks that run BEFORE the engine session lock.
3
3
  *
4
4
  * Everything here reads, never writes, so none of it needs the lock, and
5
- * each check used to cost a full place in the lock queue before it could
6
- * refuse:
5
+ * running it first keeps each check from costing a full place in the lock
6
+ * queue before it can refuse:
7
7
  *
8
- * - a missing launchable binary (a peer's interrupted build) refused only
9
- * after the wait, in every session, with nothing naming the cause;
10
- * - the named-file checkJs pass refused an `Element | null` after an
11
- * eighteen-minute queue;
12
- * - `--wait-browser` and `--wait-port` spent their budget holding the
13
- * lock, so every peer queued behind a run that was doing nothing.
8
+ * - a missing launchable binary (a peer's interrupted build) refuses
9
+ * before the wait, naming the cause;
10
+ * - the named-file checkJs pass refuses a type error without a queue;
11
+ * - `--wait-browser` and `--wait-port` spend their budget without holding
12
+ * the lock, so no peer queues behind a run that is doing nothing.
14
13
  *
15
14
  * The in-lock ladder in `test.ts` still runs in full afterwards. That is
16
15
  * the "re-check once under the lock": the waits this pass spent are
@@ -49,3 +48,14 @@ export declare const TEST_BUILD_PREFLIGHT: {
49
48
  * @param options - Parsed test options
50
49
  */
51
50
  export declare function runTestPreLockPreflight(projectRoot: string, testPaths: string[], options: TestOptions): Promise<TestOptions>;
51
+ /**
52
+ * `options` with the wait budgets the pre-lock pass spent put back, for a
53
+ * browser or port check that follows work this invocation already did
54
+ * under the lock: its pre-test build, or an earlier run of a sequence. A
55
+ * busy browser there is a peer that started after the lock was taken, and
56
+ * refusing would throw the build away, so it is waited out like the first
57
+ * check's.
58
+ *
59
+ * @param options - In-lock run options
60
+ */
61
+ export declare function rearmSpentWaits(options: TestOptions): TestOptions;
@@ -3,15 +3,14 @@
3
3
  * The `fireforge test` checks that run BEFORE the engine session lock.
4
4
  *
5
5
  * Everything here reads, never writes, so none of it needs the lock, and
6
- * each check used to cost a full place in the lock queue before it could
7
- * refuse:
6
+ * running it first keeps each check from costing a full place in the lock
7
+ * queue before it can refuse:
8
8
  *
9
- * - a missing launchable binary (a peer's interrupted build) refused only
10
- * after the wait, in every session, with nothing naming the cause;
11
- * - the named-file checkJs pass refused an `Element | null` after an
12
- * eighteen-minute queue;
13
- * - `--wait-browser` and `--wait-port` spent their budget holding the
14
- * lock, so every peer queued behind a run that was doing nothing.
9
+ * - a missing launchable binary (a peer's interrupted build) refuses
10
+ * before the wait, naming the cause;
11
+ * - the named-file checkJs pass refuses a type error without a queue;
12
+ * - `--wait-browser` and `--wait-port` spend their budget without holding
13
+ * the lock, so no peer queues behind a run that is doing nothing.
15
14
  *
16
15
  * The in-lock ladder in `test.ts` still runs in full afterwards. That is
17
16
  * the "re-check once under the lock": the waits this pass spent are
@@ -122,7 +121,10 @@ export async function runTestPreLockPreflight(projectRoot, testPaths, options) {
122
121
  }
123
122
  else {
124
123
  await refuseCompiledControlBeforeLock(projectRoot, paths.engine, options);
125
- await refuseStalePackagingBeforeLock(projectRoot, paths.engine, options, normalizedPaths);
124
+ await refuseStalePackagingBeforeLock(projectRoot, paths.engine, options, {
125
+ normalizedPaths,
126
+ launchablePath,
127
+ });
126
128
  }
127
129
  return waitForResourcesBeforeLock(paths.engine, config, options, {
128
130
  launchablePath,
@@ -195,16 +197,17 @@ async function refuseCompiledControlBeforeLock(projectRoot, engineDir, options)
195
197
  * under `--allow-stale-build`/`--allow-stale-components`, whose warnings
196
198
  * belong to the in-lock pass alone. A `--negative-control` refusal says to
197
199
  * add `--build`: without it the control has nothing current to run on, and
198
- * the invocation used to end `control=inconclusive` after a full queue.
200
+ * the invocation would end `control=inconclusive` after a full queue.
199
201
  */
200
- async function refuseStalePackagingBeforeLock(projectRoot, engineDir, options, normalizedPaths) {
202
+ async function refuseStalePackagingBeforeLock(projectRoot, engineDir, options, request) {
201
203
  if (options.allowStaleBuild === true || options.allowStaleComponents === true)
202
204
  return;
203
205
  if ((await readBuildRunState(projectRoot)).kind === 'running')
204
206
  return;
205
207
  try {
206
- await enforceStaleBuildGate(projectRoot, engineDir, options, normalizedPaths, {
208
+ await enforceStaleBuildGate(projectRoot, engineDir, options, request.normalizedPaths, {
207
209
  quiet: true,
210
+ launchablePath: request.launchablePath,
208
211
  });
209
212
  }
210
213
  catch (error) {
@@ -216,15 +219,40 @@ async function refuseStalePackagingBeforeLock(projectRoot, engineDir, options, n
216
219
  'against a fresh build of the requested paths.', error.note);
217
220
  }
218
221
  }
219
- /** `options` without one wait budget, which this pass already spent. */
222
+ /**
223
+ * `options` without one wait budget, which this pass already spent. The
224
+ * budget moves to `spentWaits`, for {@link rearmSpentWaits}.
225
+ */
220
226
  function withoutOption(options, key) {
221
- const rest = { ...options };
227
+ const rest = { ...options, spentWaits: { ...options.spentWaits, [key]: options[key] } };
222
228
  if (key === 'waitBrowser')
223
229
  delete rest.waitBrowser;
224
230
  else
225
231
  delete rest.waitPort;
226
232
  return rest;
227
233
  }
234
+ /**
235
+ * `options` with the wait budgets the pre-lock pass spent put back, for a
236
+ * browser or port check that follows work this invocation already did
237
+ * under the lock: its pre-test build, or an earlier run of a sequence. A
238
+ * busy browser there is a peer that started after the lock was taken, and
239
+ * refusing would throw the build away, so it is waited out like the first
240
+ * check's.
241
+ *
242
+ * @param options - In-lock run options
243
+ */
244
+ export function rearmSpentWaits(options) {
245
+ const spent = options.spentWaits;
246
+ if (spent === undefined)
247
+ return options;
248
+ const rearmed = { ...options };
249
+ if (spent.waitBrowser !== undefined)
250
+ rearmed.waitBrowser = spent.waitBrowser;
251
+ if (spent.waitPort !== undefined)
252
+ rearmed.waitPort = spent.waitPort;
253
+ delete rearmed.spentWaits;
254
+ return rearmed;
255
+ }
228
256
  /**
229
257
  * Spends `--wait-browser` and `--wait-port` before the lock, mirroring
230
258
  * which in-lock checks would apply to this run, and strips each budget it
@@ -44,8 +44,8 @@ const TEST_HELP_TEXT = [
44
44
  '',
45
45
  'Machine-readable verdict: every run (single, sharded, --canary,',
46
46
  '--doctor) ends with exactly one raw stdout line',
47
- ' FIREFORGE-VERDICT: PASS|FAIL [reason=crash|harness-arguments|test-failures|',
48
- ' no-tests|preflight|inconclusive|lock-timeout|killed] [checks=<n>]',
47
+ ' FIREFORGE-VERDICT: PASS|FAIL [reason=crash|server-startup|harness-arguments|',
48
+ ' test-failures|no-tests|preflight|inconclusive|lock-timeout|killed] [checks=<n>]',
49
49
  ' [unexpected=<n>] [started=<n>/<m> first-unstarted=<path>]',
50
50
  ' [shards=<p>/<t>] [(<note>)] [shuffle=<seed>]',
51
51
  ' [runs=<p>/<n>] [control=caught|missed|inconclusive]',
@@ -86,7 +86,8 @@ const TEST_HELP_TEXT = [
86
86
  'launchable binary (with the build that left it missing, when one is',
87
87
  'recorded), the named-file checkJs pass of --build, and the',
88
88
  '--wait-browser/--wait-port budgets. Each is re-checked once under the',
89
- 'lock.',
89
+ 'lock; the checks after a pre-test build or an earlier run of the',
90
+ 'sequence wait the budgets out again.',
90
91
  ].join('\n');
91
92
  function registerTestMultiRunOptions(test) {
92
93
  return test
@@ -18,6 +18,13 @@ export interface TestFileResult extends RawTestResult {
18
18
  shard: string;
19
19
  /** Line of the run log where this file's dispatch began (1-based). */
20
20
  logLine: number;
21
+ /**
22
+ * The shard's dispatched files, on a harness pseudo-record only (see
23
+ * {@link HARNESS_PSEUDO_TEST}).
24
+ */
25
+ shardFiles?: string[];
26
+ /** The shard's one file, on a harness pseudo-record of a one-file shard. */
27
+ file?: string;
21
28
  }
22
29
  /** The document `--results` writes. */
23
30
  export interface TestResultsDocument {
@@ -38,12 +45,14 @@ export declare function resetTestResults(): void;
38
45
  *
39
46
  * @param results - The dispatch's finished files
40
47
  * @param unfinished - Its started-but-unfinished files
41
- * @param context - Run, shard and run-log line of the dispatch
48
+ * @param context - Run, shard and run-log line of the dispatch, and the
49
+ * shard's dispatched files for harness pseudo-records
42
50
  */
43
51
  export declare function recordTestResults(results: readonly RawTestResult[], unfinished: readonly string[], context: {
44
52
  run: number;
45
53
  shard: string;
46
54
  logLine: number;
55
+ shardFiles?: readonly string[];
47
56
  }): void;
48
57
  /** The results collected so far (for tests and the shard summary). */
49
58
  export declare function collectedTestResults(): TestResultsDocument;
@@ -14,6 +14,13 @@ import { mkdtemp, rm } from 'node:fs/promises';
14
14
  import { tmpdir } from 'node:os';
15
15
  import { join } from 'node:path';
16
16
  import { writeJson } from '../utils/fs.js';
17
+ /**
18
+ * The `test` id mozlog gives a result the harness itself reports, such as
19
+ * the no-output TIMEOUT of a hung browser: a record that names no test
20
+ * file. It keeps that id, and gains the shard's files so a consumer can
21
+ * attribute it without matching `shard` labels back to paths.
22
+ */
23
+ const HARNESS_PSEUDO_TEST = 'automation.py';
17
24
  let collected = { schemaVersion: 1, files: [], unfinished: [] };
18
25
  /** Clears the collector at the start of an invocation. */
19
26
  export function resetTestResults() {
@@ -24,15 +31,25 @@ export function resetTestResults() {
24
31
  *
25
32
  * @param results - The dispatch's finished files
26
33
  * @param unfinished - Its started-but-unfinished files
27
- * @param context - Run, shard and run-log line of the dispatch
34
+ * @param context - Run, shard and run-log line of the dispatch, and the
35
+ * shard's dispatched files for harness pseudo-records
28
36
  */
29
37
  export function recordTestResults(results, unfinished, context) {
30
- for (const result of results)
31
- collected.files.push({ ...result, ...context });
38
+ const { shardFiles, ...dispatch } = context;
39
+ for (const result of results) {
40
+ collected.files.push({ ...result, ...dispatch, ...harnessRecordFiles(result, shardFiles) });
41
+ }
32
42
  for (const test of unfinished) {
33
43
  collected.unfinished.push({ test, run: context.run, shard: context.shard });
34
44
  }
35
45
  }
46
+ /** The `shardFiles`/`file` keys a harness pseudo-record gains. */
47
+ function harnessRecordFiles(result, shardFiles) {
48
+ if (result.test !== HARNESS_PSEUDO_TEST || shardFiles === undefined)
49
+ return {};
50
+ const only = shardFiles.length === 1 ? shardFiles[0] : undefined;
51
+ return { shardFiles: [...shardFiles], ...(only === undefined ? {} : { file: only }) };
52
+ }
36
53
  /** The results collected so far (for tests and the shard summary). */
37
54
  export function collectedTestResults() {
38
55
  return collected;
@@ -10,6 +10,7 @@
10
10
  * stays available via `--no-shard`.
11
11
  */
12
12
  import { type DisplaySleepState } from '../core/display-state.js';
13
+ import { type HarnessServerPorts } from '../core/harness-port-reservation.js';
13
14
  import { type MachCommandResult } from '../core/mach.js';
14
15
  import { type HarnessRunVerdict } from '../core/test-harness-crash.js';
15
16
  import { type RawTestResult } from '../core/test-raw-log.js';
@@ -74,6 +75,11 @@ export interface TestRunOutcome {
74
75
  displayState?: DisplaySleepState | undefined;
75
76
  /** Per-file results from the raw log, when the run wrote one. */
76
77
  fileResults?: RawTestResult[] | undefined;
78
+ /**
79
+ * The mochitest server ports the last attempt reported (its "falling
80
+ * back to port" lines), for the shard line's `ports=` key.
81
+ */
82
+ serverPorts?: HarnessServerPorts | undefined;
77
83
  }
78
84
  /**
79
85
  * Runs one mach test invocation for `paths`, retrying recognized harness
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import { join } from 'node:path';
14
14
  import { probeDisplaySleepState } from '../core/display-state.js';
15
+ import { createHarnessPortTracker, } from '../core/harness-port-reservation.js';
15
16
  import { runMachTestSuite } from '../core/mach.js';
16
17
  import { parentExited } from '../core/parent-exit-watchdog.js';
17
18
  import { activeRunLogLine, getActiveRunLogPath } from '../core/run-log.js';
@@ -68,12 +69,17 @@ async function runTestsWithRetriesInner(ctx, paths, env, label) {
68
69
  let verdict;
69
70
  let rawLog;
70
71
  let logLine;
72
+ let portTracker;
71
73
  for (;;) {
72
74
  attempts += 1;
73
75
  let tracker = createStartMarkerTracker();
76
+ portTracker = createHarnessPortTracker();
74
77
  const raw = rawLogDispatchArgs(ctx, extraArgs);
75
78
  logLine = activeRunLogLine();
76
- const dispatched = await runTrackedMachTestSuite(ctx, kind, paths, raw.args, env, tracker);
79
+ const dispatched = await runTrackedMachTestSuite(ctx, kind, paths, raw.args, env, [
80
+ tracker,
81
+ portTracker,
82
+ ]);
77
83
  result = dispatched;
78
84
  // The parent that was reading this run is gone and the watchdog has
79
85
  // already killed the harness. Classifying the corpse would call it a
@@ -108,9 +114,14 @@ async function runTestsWithRetriesInner(ctx, paths, env, label) {
108
114
  verdict = vetoIncompletePass(classifyHarnessRun(result.exitCode, combined, paths), combined, paths, rawLog !== undefined && rawLog.markers.started.length > 0
109
115
  ? { markers: rawLog.markers, source: 'raw' }
110
116
  : { markers: tracker.markers(), source: 'stream' });
117
+ // A mochitest server that never came up is retried like a crash: no
118
+ // test ran, and a parallel instance's port race is not a result.
111
119
  if (verdict.kind !== 'harness-crash' || attempts >= maxAttempts)
112
120
  break;
113
- warn(`Harness crash detected (${verdict.signature?.reason ?? 'unknown shape'}): ` +
121
+ const shape = verdict.signature?.verdictReason === 'server-startup'
122
+ ? 'Harness server startup failed'
123
+ : 'Harness crash detected';
124
+ warn(`${shape} (${verdict.signature?.reason ?? 'unknown shape'}): ` +
114
125
  `${verdict.signature?.line ?? ''}\n` +
115
126
  `Retrying (attempt ${attempts + 1} of ${maxAttempts})...`);
116
127
  }
@@ -137,6 +148,7 @@ async function runTestsWithRetriesInner(ctx, paths, env, label) {
137
148
  run: ctx.repetition ?? 1,
138
149
  shard: label,
139
150
  logLine,
151
+ shardFiles: paths,
140
152
  });
141
153
  }
142
154
  return {
@@ -146,8 +158,14 @@ async function runTestsWithRetriesInner(ctx, paths, env, label) {
146
158
  appdirInjectionAttempted,
147
159
  displayState,
148
160
  ...(rawLog === undefined ? {} : { fileResults: rawLog.results }),
161
+ ...serverPortsOf(portTracker),
149
162
  };
150
163
  }
164
+ /** The `serverPorts` field of an outcome, present only when a port was seen. */
165
+ function serverPortsOf(tracker) {
166
+ const ports = tracker.ports();
167
+ return Object.keys(ports).length > 0 ? { serverPorts: ports } : {};
168
+ }
151
169
  /** Sequence for raw-log file names, unique within the process. */
152
170
  let rawLogSequence = 0;
153
171
  /**
@@ -170,7 +188,7 @@ function rawLogDispatchArgs(ctx, extraArgs) {
170
188
  * post-close reaping, the process-group announcement, and the parent-exit
171
189
  * watchdog for exactly as long as mach runs.
172
190
  */
173
- async function runTrackedMachTestSuite(ctx, kind, paths, extraArgs, env, tracker) {
191
+ async function runTrackedMachTestSuite(ctx, kind, paths, extraArgs, env, trackers) {
174
192
  const teardown = createHarnessTeardown({
175
193
  objDir: ctx.objDir === undefined ? undefined : join(ctx.engineDir, ctx.objDir),
176
194
  pgidFile: ctx.pgidFile,
@@ -196,7 +214,8 @@ async function runTrackedMachTestSuite(ctx, kind, paths, extraArgs, env, tracker
196
214
  teardown: {
197
215
  ...teardown.hooks,
198
216
  onOutputChunk: (stream, chunk) => {
199
- tracker.onChunk(stream, chunk);
217
+ for (const tracker of trackers)
218
+ tracker.onChunk(stream, chunk);
200
219
  teardown.hooks.onOutputChunk?.(stream, chunk);
201
220
  },
202
221
  },
@@ -62,13 +62,17 @@ export interface ShardRunOptions {
62
62
  * `tests-ran-ok`. Otherwise the most structural failure wins. A crashed
63
63
  * shard makes the whole run `harness-crash`, then `no-tests`, then plain
64
64
  * `test-failures`, so the aggregate `reason=` keeps the classifier-driven
65
- * contract instead of collapsing every failure to `test-failures`.
65
+ * contract instead of collapsing every failure to `test-failures`. When
66
+ * every crashed shard is a server that never started, the aggregate keeps
67
+ * that signature, so its line says `reason=server-startup` too.
66
68
  */
67
69
  export declare function deriveAggregateShardVerdict(shards: ShardOutcome[]): HarnessRunVerdict;
68
70
  /**
69
71
  * 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
+ * have printed alone, plus `shard=<i>/<n>` and `path=<label>`, and
73
+ * `ports=http:N,ssl:N,ws:N` when the harness reported the mochitest server
74
+ * ports it took. Never a `FIREFORGE-VERDICT:` line, so the run keeps
75
+ * exactly one.
72
76
  */
73
77
  export declare function formatShardLine(shard: ShardOutcome, index: number, total: number, failedFiles?: readonly string[]): string;
74
78
  /**
@@ -13,6 +13,7 @@
13
13
  * judged per file from each dispatch's raw log: `shards=` then counts files
14
14
  * and `failed-shards=` names the files that failed.
15
15
  */
16
+ import { formatHarnessServerPorts } from '../core/harness-port-reservation.js';
16
17
  import { getActiveRunLogPath, writeToActiveRunLog } from '../core/run-log.js';
17
18
  import { buildHarnessCrashMessage, formatFireforgeVerdictLine, headedNoOutputTimeoutHint, } from '../core/test-harness-crash.js';
18
19
  import { TestFailureError } from '../errors/build.js';
@@ -27,19 +28,32 @@ const SHARD_STATUS_LABEL = {
27
28
  'no-tests': 'NO-TESTS',
28
29
  'harness-arguments': 'ARGUMENTS',
29
30
  };
31
+ /** The summary box's status word; a server that never started is not a crash. */
32
+ function shardStatusLabel(verdict) {
33
+ return verdict.signature?.verdictReason === 'server-startup'
34
+ ? 'SERVER'
35
+ : SHARD_STATUS_LABEL[verdict.kind];
36
+ }
30
37
  /**
31
38
  * Derives the aggregate classification for a sharded run: all-green is
32
39
  * `tests-ran-ok`. Otherwise the most structural failure wins. A crashed
33
40
  * shard makes the whole run `harness-crash`, then `no-tests`, then plain
34
41
  * `test-failures`, so the aggregate `reason=` keeps the classifier-driven
35
- * contract instead of collapsing every failure to `test-failures`.
42
+ * contract instead of collapsing every failure to `test-failures`. When
43
+ * every crashed shard is a server that never started, the aggregate keeps
44
+ * that signature, so its line says `reason=server-startup` too.
36
45
  */
37
46
  export function deriveAggregateShardVerdict(shards) {
38
47
  const kinds = new Set(shards.map(({ outcome }) => outcome.verdict.kind));
39
48
  if (kinds.has('harness-arguments'))
40
49
  return { kind: 'harness-arguments' };
41
- if (kinds.has('harness-crash'))
42
- return { kind: 'harness-crash' };
50
+ if (kinds.has('harness-crash')) {
51
+ const crashes = shards.filter(({ outcome }) => outcome.verdict.kind === 'harness-crash');
52
+ const signature = crashes[0]?.outcome.verdict.signature;
53
+ return crashes.every(({ outcome }) => outcome.verdict.signature?.verdictReason === 'server-startup') && signature !== undefined
54
+ ? { kind: 'harness-crash', signature }
55
+ : { kind: 'harness-crash' };
56
+ }
43
57
  if (kinds.has('no-tests'))
44
58
  return { kind: 'no-tests' };
45
59
  if (kinds.has('test-failures'))
@@ -48,15 +62,18 @@ export function deriveAggregateShardVerdict(shards) {
48
62
  }
49
63
  /**
50
64
  * 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.
65
+ * have printed alone, plus `shard=<i>/<n>` and `path=<label>`, and
66
+ * `ports=http:N,ssl:N,ws:N` when the harness reported the mochitest server
67
+ * ports it took. Never a `FIREFORGE-VERDICT:` line, so the run keeps
68
+ * exactly one.
53
69
  */
54
70
  export function formatShardLine(shard, index, total, failedFiles) {
55
71
  const body = formatFireforgeVerdictLine(shard.outcome.verdict).slice('FIREFORGE-VERDICT: '.length);
56
72
  const files = failedFiles !== undefined && failedFiles.length > 0
57
73
  ? ` failed-files=${failedFiles.join(',')}`
58
74
  : '';
59
- return `FIREFORGE-SHARD: ${body} shard=${index + 1}/${total} path=${shard.label.replace(/\s+/g, '_')}${files}`;
75
+ const ports = formatHarnessServerPorts(shard.outcome.serverPorts);
76
+ return `FIREFORGE-SHARD: ${body} shard=${index + 1}/${total} path=${shard.label.replace(/\s+/g, '_')}${files}${ports === undefined ? '' : ` ports=${ports}`}`;
60
77
  }
61
78
  function writeShardLine(line) {
62
79
  process.stdout.write(`${line}\n`);
@@ -163,7 +180,7 @@ function reportProgress(finished, total, jobs) {
163
180
  * counts as a whole.
164
181
  */
165
182
  function summarizeShards(shards, perFile) {
166
- const lines = shards.map(({ label, outcome }) => `${SHARD_STATUS_LABEL[outcome.verdict.kind].padEnd(8)} ${label}` +
183
+ const lines = shards.map(({ label, outcome }) => `${shardStatusLabel(outcome.verdict).padEnd(8)} ${label}` +
167
184
  (outcome.attempts > 1 ? ` (${outcome.attempts} attempts)` : ''));
168
185
  const failingShards = shards.filter(({ outcome }) => outcome.verdict.kind !== 'tests-ran-ok');
169
186
  let passed = shards.length - failingShards.length;
@@ -29,9 +29,14 @@ import type { TestOptions } from '../types/commands/index.js';
29
29
  * stale-content refusal still applies to it unchanged.
30
30
  */
31
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). */
32
+ /**
33
+ * `quiet`: refuse only, without the exemption note (the pre-lock pass).
34
+ * `launchablePath`: the engine-relative binary the run launches, which
35
+ * anchors the stale-compiled check (skipped without one).
36
+ */
33
37
  gate?: {
34
38
  quiet?: boolean;
39
+ launchablePath?: string | undefined;
35
40
  }): Promise<void>;
36
41
  /**
37
42
  * Compiled-StaticComponents gate: refuses runs whose child process would
@@ -8,7 +8,7 @@
8
8
  * `src/core/test-stale-check.ts`.
9
9
  */
10
10
  import { filterByCarriedFullCoverage } from '../core/coverage-dispatch.js';
11
- import { checkStaleBuildForTest, checkStaticComponentsStale, findChangedTestManifestsForPaths, findUncoveredRequestPaths, formatStaleBuildWarning, formatStaticComponentsRefusal, formatTestCoverageRefusal, FULL_SUITE_REQUEST, } from '../core/test-stale-check.js';
11
+ import { checkStaleBuildForTest, checkStaticComponentsStale, findChangedTestManifestsForPaths, findCompiledSourcesNewerThanBinary, findUncoveredRequestPaths, formatStaleBuildWarning, formatStaleCompiledRefusal, formatStaticComponentsRefusal, formatTestCoverageRefusal, FULL_SUITE_REQUEST, } from '../core/test-stale-check.js';
12
12
  import { formatStaleExemptions } from '../core/test-stale-exempt.js';
13
13
  import { PreflightRefusalError } from '../errors/base.js';
14
14
  import { isProcessAlive } from '../utils/errors.js';
@@ -35,14 +35,20 @@ import { describePerfStaleImpact } from './test-perf-scope.js';
35
35
  * stale-content refusal still applies to it unchanged.
36
36
  */
37
37
  export async function enforceStaleBuildGate(projectRoot, engineDir, options, normalizedPaths,
38
- /** `quiet`: refuse only, without the exemption note (the pre-lock pass). */
38
+ /**
39
+ * `quiet`: refuse only, without the exemption note (the pre-lock pass).
40
+ * `launchablePath`: the engine-relative binary the run launches, which
41
+ * anchors the stale-compiled check (skipped without one).
42
+ */
39
43
  gate = {}) {
40
44
  const stale = await checkStaleBuildForTest(projectRoot, engineDir);
45
+ enforceControlBuiltGate(stale.baseline);
41
46
  const dispatchesNoTests = options.doctor === true && normalizedPaths.length === 0;
42
47
  if (!dispatchesNoTests) {
43
48
  await enforceCoverageGate(engineDir, stale.baseline, normalizedPaths);
44
49
  }
45
50
  await enforceStaticComponentsGate(engineDir, stale.baseline, options);
51
+ await enforceStaleCompiledGate(engineDir, stale.baseline, options, gate.launchablePath);
46
52
  const exempt = stale.exempt ?? [];
47
53
  if (!stale.stale && exempt.length > 0 && gate.quiet !== true) {
48
54
  info(`Stale-build preflight: ${exempt.length} edit(s) since the last build need no rebuild: ${formatStaleExemptions(exempt)}.`);
@@ -73,6 +79,38 @@ gate = {}) {
73
79
  throw new PreflightRefusalError(staleMessage, 'stale-build');
74
80
  }
75
81
  }
82
+ /**
83
+ * Control-built refusal: the recorded build compiled a negative control
84
+ * into the binary, and no build since has recompiled the restored source
85
+ * (the run was killed, or its restore build failed). Every test against
86
+ * that binary tests the mutation. Not bypassable: `--allow-stale-build`
87
+ * accepts old content, not a deliberately broken binary.
88
+ */
89
+ function enforceControlBuiltGate(baseline) {
90
+ const stamp = baseline?.controlBuild;
91
+ if (stamp === undefined)
92
+ return;
93
+ throw new PreflightRefusalError(`The binary under test was built with the negative control ${stamp.diffFile} applied ` +
94
+ `(${stamp.targets.map((target) => `engine/${target}`).join(', ')}, applied ${stamp.appliedAt}), ` +
95
+ 'and no build has recompiled the restored source since. Run "fireforge build" to ' +
96
+ 'rebuild the binary without the control, then rerun.', 'control-built');
97
+ }
98
+ /**
99
+ * Stale-compiled refusal: compiled sources modified after the binary under
100
+ * test was linked. `--allow-stale-build` downgrades it to a warning, as it
101
+ * does the stale-build refusal.
102
+ */
103
+ async function enforceStaleCompiledGate(engineDir, baseline, options, launchablePath) {
104
+ const result = await findCompiledSourcesNewerThanBinary(engineDir, baseline, launchablePath);
105
+ if (result === undefined || result.paths.length === 0)
106
+ return;
107
+ const message = formatStaleCompiledRefusal(result);
108
+ if (options.allowStaleBuild === true) {
109
+ warn(message);
110
+ return;
111
+ }
112
+ throw new PreflightRefusalError(message, 'stale-compiled');
113
+ }
76
114
  /**
77
115
  * Packaging-coverage refusal. A path outside the recorded scoped list is
78
116
  * still covered while its own staging inputs are unchanged against a