@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
@@ -1,7 +1,9 @@
1
1
  import type { HarnessRunVerdict } from '../core/test-harness-crash.js';
2
2
  /**
3
3
  * Reason codes the emission layer can put on a `FIREFORGE-VERDICT: FAIL`
4
- * line. The first three mirror the harness classifier. `preflight` covers
4
+ * line. The first three mirror the harness classifier, as does
5
+ * `server-startup`: a harness-crash-class infrastructure failure where a
6
+ * mochitest server never came up, so no test ran. `preflight` covers
5
7
  * every failure before harness classification (missing/stale builds,
6
8
  * invalid paths, port conflicts, config errors, spawn failures),
7
9
  * `inconclusive` marks a run whose harness result exists but cannot be
@@ -11,9 +13,9 @@ import type { HarnessRunVerdict } from '../core/test-harness-crash.js';
11
13
  * and `killed` marks a run terminated by a signal before it could reach any
12
14
  * of the others.
13
15
  */
14
- export type FireforgeVerdictReason = 'crash' | 'harness-arguments' | 'no-tests' | 'test-failures' | 'preflight' | 'inconclusive' | 'lock-timeout' | 'killed';
16
+ export type FireforgeVerdictReason = 'crash' | 'harness-arguments' | 'no-tests' | 'test-failures' | 'server-startup' | 'preflight' | 'inconclusive' | 'lock-timeout' | 'killed';
15
17
  /** Additive keys a run can stamp on its verdict line. */
16
- export type VerdictRunAttributeKey = 'shuffle' | 'orphans-reaped' | 'host-load' | 'host-cpu-warning' | 'power-source' | 'power-changed' | 'furnace-applied' | 'furnace-apply' | 'furnace-skipped' | 'control-build' | 'drift-tolerated' | 'lock' | 'jobs' | 'results';
18
+ export type VerdictRunAttributeKey = 'shuffle' | 'orphans-reaped' | 'host-load' | 'host-cpu-warning' | 'power-source' | 'power-changed' | 'furnace-applied' | 'furnace-apply' | 'furnace-skipped' | 'control-build' | 'control-restore-build' | 'build-id' | 'drift-tolerated' | 'lock' | 'jobs' | 'upload-dirs' | 'results';
17
19
  /** Records an additive attribute for this run's verdict line. */
18
20
  export declare function setVerdictRunAttribute(key: VerdictRunAttributeKey, value: string): void;
19
21
  /**
@@ -2,6 +2,7 @@
2
2
  import { join, resolve } from 'node:path';
3
3
  import { getProjectPaths, loadConfig } from '../core/config.js';
4
4
  import { assertEngineExists } from '../core/engine-precondition.js';
5
+ import { readBuildId } from '../core/launchable-build.js';
5
6
  import { hasBuildArtifacts } from '../core/mach.js';
6
7
  import { assertBuildArtifacts } from '../core/mach-build-artifacts.js';
7
8
  import { ensureLaunchableBrowserNotRunning } from '../core/marionette-port.js';
@@ -24,7 +25,7 @@ import { buildPerfSampleEnv, mergeHarnessEnv, resolveShuffleSeed } from './test-
24
25
  import { removePgidFile, setActivePgidFile } from './test-harness-teardown.js';
25
26
  import { assertPathlessTestMode, assertTestModeCombinations, canaryTimeoutSeconds, classifyBeforeDispatch, resolveCanaryPath, restartBetweenTestsArgs, } from './test-modes.js';
26
27
  import { orchestrateTestRuns } from './test-orchestrate.js';
27
- import { resolveLaunchablePathForTests, runTestPreLockPreflight, TEST_BUILD_PREFLIGHT, } from './test-prelock.js';
28
+ import { rearmSpentWaits, resolveLaunchablePathForTests, runTestPreLockPreflight, TEST_BUILD_PREFLIGHT, } from './test-prelock.js';
28
29
  import { resetTestResults, withRawLogDir, writeTestResults } from './test-results.js';
29
30
  import { DEFAULT_HARNESS_RETRIES } from './test-run.js';
30
31
  import { emitFailVerdict, resetVerdictEmission, setVerdictRunAttribute, verdictEmitted, } from './test-verdict.js';
@@ -349,10 +350,21 @@ async function runTestCommandBody(projectRoot, testPaths, options = {}, profileE
349
350
  classification,
350
351
  normalizedPaths: hooks.coveragePaths ?? normalizedPaths,
351
352
  ...(hooks.fullBuildReason === undefined ? {} : { fullBuildReason: hooks.fullBuildReason }),
353
+ ...(hooks.controlBuild === undefined ? {} : { controlBuild: hooks.controlBuild }),
354
+ launchablePath,
352
355
  })) {
353
356
  return;
354
357
  }
355
- const { forwardedPort, effectivePort } = await ensureTestBrowserEnvironment(paths.engine, launchablePath, xpcshellOnly, projectConfig, options, buildCheck.objDir);
358
+ // Which binary this run exercises, read after the pre-test build so it
359
+ // names the build the tests actually ran against (`moz:buildID`).
360
+ if (launchablePath !== undefined) {
361
+ const buildId = await readBuildId(paths.engine, launchablePath);
362
+ if (buildId !== undefined)
363
+ setVerdictRunAttribute('build-id', buildId);
364
+ }
365
+ // After a pre-test build, a busy browser or port is a peer that started
366
+ // during it; waiting it out keeps the build this run just paid for.
367
+ const { forwardedPort, effectivePort } = await ensureTestBrowserEnvironment(paths.engine, launchablePath, xpcshellOnly, projectConfig, options.build === true ? rearmSpentWaits(options) : options, buildCheck.objDir);
356
368
  if (options.doctor) {
357
369
  const doctorOutcome = await runDoctorPreflight({
358
370
  engineDir: paths.engine,
@@ -42,8 +42,8 @@ async function normalizeTokenNameForProject(projectRoot, rawTokenName) {
42
42
  *
43
43
  * A variant skip has no category to name, because the declaration lives in
44
44
  * a `:root<selector>` block, so the location reads as that block. The
45
- * variant path used to report no location at all, so a re-run meant to
46
- * change a value exited 0 having silently changed nothing.
45
+ * location matters: without it a re-run meant to change a value exits 0
46
+ * having silently changed nothing.
47
47
  *
48
48
  * @param tokenName - Full token name including prefix
49
49
  * @param result - Result carrying the skip location, when known
@@ -328,7 +328,7 @@ export function registerVerify(program, { getProjectRoot, withErrorHandling }) {
328
328
  program
329
329
  .command('verify')
330
330
  .description('Read-only fsck for the patch queue (manifest + cross-patch lint)')
331
- .option('--patches <names...>', 'Judge only findings that name these patches (filename, stem, order number, manifest name, or slug; repeat or comma-separate). Queue-wide findings stay; unowned worktree changes drop out')
331
+ .option('--patches <names...>', 'Judge only findings that name these patches (filename, stem, order number, manifest name, or slug; repeat, or separate with commas or whitespace). Queue-wide findings stay; unowned worktree changes drop out')
332
332
  .action(withErrorHandling(async (options) => {
333
333
  await verifyCommand(getProjectRoot(), options);
334
334
  }));
@@ -195,7 +195,7 @@ export async function watchCommand(projectRoot) {
195
195
  catch (error) {
196
196
  throw new BuildError('Watch process failed to start', 'mach watch', error instanceof Error ? error : undefined);
197
197
  }
198
- // On Ctrl+C the bin signal handler now waits for the mach child to shut
198
+ // On Ctrl+C the bin signal handler waits for the mach child to shut
199
199
  // down (waitForActiveChildShutdown) before exiting, so this continuation
200
200
  // can run and whitelist the SIGINT exit code, though the bin may still
201
201
  // win the race and terminate first. Both outcomes are acceptable.
@@ -208,7 +208,7 @@ function buildWireSubscriptOptions(options, domFilePath, domTargetPath, subscrip
208
208
  };
209
209
  }
210
210
  /**
211
- * Surfaces any legacy parser fallbacks the wiring run consumed, so the
211
+ * Surfaces any regex parser fallbacks the wiring run consumed, so the
212
212
  * operator knows which files were mutated by the regex path rather than
213
213
  * the AST path.
214
214
  */
@@ -29,11 +29,10 @@ export declare function countTrailingSegmentMatches(a: string, b: string): numbe
29
29
  *
30
30
  * Shared on purpose: this set governs two halves of one decision,
31
31
  * `scoreCandidate` (selection) and `isConfidentMatch` (confirmation, in
32
- * `build-audit.ts`). When the halves kept separate copies they disagreed,
33
- * so a source path could earn a selection bonus for a segment that
34
- * confirmation then rejected as meaningless, leaving the chosen artifact
35
- * unconfirmable. Any addition must apply to both halves, which is now
36
- * automatic.
32
+ * `build-audit.ts`). Separate copies would disagree, so a source path
33
+ * could earn a selection bonus for a segment that confirmation then
34
+ * rejected as meaningless, leaving the chosen artifact unconfirmable. One
35
+ * set makes every addition apply to both halves.
37
36
  */
38
37
  export declare const GENERIC_PATH_SEGMENTS: ReadonlySet<string>;
39
38
  /**
@@ -96,11 +96,10 @@ export function countTrailingSegmentMatches(a, b) {
96
96
  *
97
97
  * Shared on purpose: this set governs two halves of one decision,
98
98
  * `scoreCandidate` (selection) and `isConfidentMatch` (confirmation, in
99
- * `build-audit.ts`). When the halves kept separate copies they disagreed,
100
- * so a source path could earn a selection bonus for a segment that
101
- * confirmation then rejected as meaningless, leaving the chosen artifact
102
- * unconfirmable. Any addition must apply to both halves, which is now
103
- * automatic.
99
+ * `build-audit.ts`). Separate copies would disagree, so a source path
100
+ * could earn a selection bonus for a segment that confirmation then
101
+ * rejected as meaningless, leaving the chosen artifact unconfirmable. One
102
+ * set makes every addition apply to both halves.
104
103
  */
105
104
  export const GENERIC_PATH_SEGMENTS = new Set([
106
105
  'content',
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Why a changed engine path was not audited.
3
3
  *
4
- * The first four name skips that already existed as unstructured
5
- * `verbose()` strings. The last four are the 0.46.0 classifiers.
4
+ * Each reason is structured so summaries and machine output can count
5
+ * skips by cause instead of reading verbose log strings.
6
6
  */
7
7
  export type AuditSkipReason = 'not-packageable' | 'platform-gated' | 'platform-gated-ancestor' | 'tests-not-packaged' | 'source-unreadable' | 'branding-not-selected' | 'storybook-story' | 'declared-unpackaged';
8
8
  /** Render order for the `Packaged:` summary's per-class breakdown. */
@@ -40,10 +40,9 @@ export interface AuditEntry {
40
40
  status: 'updated' | 'stale' | 'missing' | 'skipped';
41
41
  /**
42
42
  * Why a `skipped` entry was skipped. Present only for `skipped`. The
43
- * reasons used to exist only as verbose log strings, so the summary line
44
- * could report `4 missing` on a run with zero real misses and nothing in
45
- * the counts distinguished a structural non-input from an unregistered
46
- * file an operator must act on.
43
+ * reason lets the counts distinguish a structural non-input from an
44
+ * unregistered file an operator must act on, so the summary line never
45
+ * reports skips as misses.
47
46
  */
48
47
  skipReason?: AuditSkipReason;
49
48
  }
@@ -93,10 +93,11 @@ export interface BuildBaseline {
93
93
  * mozconfig and complete testInputFingerprints outside the rebuilt scope
94
94
  * remain anchored. Explicitly rebuilt paths may change. A failed anchor
95
95
  * replaces coverage with a dropped-path notice, or refuses when the caller
96
- * required --extend-coverage. Legacy packageable-only anchors cannot retain
97
- * coverage because they cannot prove test scripts and fixtures unchanged.
96
+ * required --extend-coverage. Anchors that fingerprint only packageable
97
+ * inputs cannot retain coverage because they cannot prove test scripts and
98
+ * fixtures unchanged.
98
99
  *
99
- * Since 0.50.0 retention is decided per staged path: see
100
+ * Retention is decided per staged path: see
100
101
  * {@link BuildBaseline.testCoverageRecords} and
101
102
  * {@link BuildBaseline.fullCoverageAnchor}.
102
103
  */
@@ -151,6 +152,26 @@ export interface BuildBaseline {
151
152
  * session's scoped build replaced can tell whose build that was.
152
153
  */
153
154
  recorder?: BaselineRecorder;
155
+ /**
156
+ * Set when this build compiled a negative control into the binary
157
+ * (`test --negative-control --build` on compiled source). The source is
158
+ * restored byte-exact after the run, but the objdir binary keeps the
159
+ * mutation until a build recompiles it. FireForge runs that restore build
160
+ * itself, inside the same lock hold, and its baseline drops the stamp.
161
+ * A stamp still present (the run was killed, or the restore build
162
+ * failed) makes the next `fireforge test` refuse with `control-built`
163
+ * until `fireforge build` replaces the baseline.
164
+ */
165
+ controlBuild?: ControlBuildStamp;
166
+ }
167
+ /** See {@link BuildBaseline.controlBuild}. */
168
+ export interface ControlBuildStamp {
169
+ /** The `--negative-control` diff file, as given. */
170
+ diffFile: string;
171
+ /** Engine-relative compiled targets the control edited. */
172
+ targets: string[];
173
+ /** ISO-8601 time the control was applied. */
174
+ appliedAt: string;
154
175
  }
155
176
  /** See {@link BuildBaseline.recorder}. */
156
177
  export interface BaselineRecorder {
@@ -1,3 +1,32 @@
1
1
  import { type BuildBaseline } from './build-baseline-types.js';
2
- /** Validates persisted fields without converting malformed coverage into a full claim. */
2
+ /**
3
+ * The first field of a persisted baseline that fails validation, and the
4
+ * offending value: what a refusal names so the operator can tell a scoped
5
+ * coverage claim from a corrupt marker.
6
+ */
7
+ export interface BuildBaselineProblem {
8
+ /** Field path, e.g. `testPackagingCoverage[2]` or `testCoverageRecords["a/"]`. */
9
+ field: string;
10
+ /** The offending value, as found (may be any JSON value). */
11
+ value: unknown;
12
+ }
13
+ /** True when the problem sits in a packaging-coverage field. */
14
+ export declare function isCoverageProblem(problem: BuildBaselineProblem): boolean;
15
+ /** Renders a problem for a refusal: the field and a bounded JSON rendering of its value. */
16
+ export declare function describeBuildBaselineProblem(problem: BuildBaselineProblem): string;
17
+ /**
18
+ * Validates persisted fields without converting malformed coverage into a
19
+ * full claim, and returns the first failing field with its value, or
20
+ * undefined when the baseline is valid.
21
+ *
22
+ * @param value - The parsed `last-build.json`
23
+ */
24
+ export declare function findBuildBaselineProblem(value: unknown): BuildBaselineProblem | undefined;
25
+ /**
26
+ * Validates persisted fields without converting malformed coverage into a
27
+ * full claim. The boolean form of {@link findBuildBaselineProblem}.
28
+ *
29
+ * @internal Exported only so tests can reach it. Production reads go
30
+ * through `findBuildBaselineProblem`, which names the failing field.
31
+ */
3
32
  export declare function isBuildBaseline(value: unknown): value is BuildBaseline;
@@ -24,13 +24,14 @@ function isCoverageAnchor(value) {
24
24
  isFingerprint(value['mozconfigHash']) &&
25
25
  isFingerprintMap(value['fingerprints']));
26
26
  }
27
- function isCoverageRecordMap(value) {
27
+ /** A `controlBuild` stamp: the control's diff file, its targets and when it went in. */
28
+ function isControlBuildStamp(value) {
28
29
  return (isObject(value) &&
29
- Object.entries(value).every(([path, record]) => isBaselinePath(path) &&
30
- isCoverageAnchor(record) &&
31
- isObject(record) &&
32
- Array.isArray(record['inputs']) &&
33
- record['inputs'].every(isBaselinePath)));
30
+ typeof value['diffFile'] === 'string' &&
31
+ Array.isArray(value['targets']) &&
32
+ value['targets'].every(isBaselinePath) &&
33
+ typeof value['appliedAt'] === 'string' &&
34
+ Number.isFinite(Date.parse(value['appliedAt'])));
34
35
  }
35
36
  /** A `recorder` stamp: an integer pid, an invocation id and an optional session id. */
36
37
  function isBaselineRecorder(value) {
@@ -39,43 +40,134 @@ function isBaselineRecorder(value) {
39
40
  typeof value['invocationId'] === 'string' &&
40
41
  (value['sessionId'] === undefined || typeof value['sessionId'] === 'string'));
41
42
  }
42
- /** Validates persisted fields without converting malformed coverage into a full claim. */
43
- export function isBuildBaseline(value) {
43
+ /** Fields whose problem a scoped `test --build` can leave and a fresh one replaces. */
44
+ const COVERAGE_FIELDS = ['testPackagingCoverage', 'testCoverageRecords', 'fullCoverageAnchor'];
45
+ /** True when the problem sits in a packaging-coverage field. */
46
+ export function isCoverageProblem(problem) {
47
+ return COVERAGE_FIELDS.some((field) => problem.field === field ||
48
+ problem.field.startsWith(`${field}[`) ||
49
+ problem.field.startsWith(`${field}.`));
50
+ }
51
+ /** Renders a problem for a refusal: the field and a bounded JSON rendering of its value. */
52
+ export function describeBuildBaselineProblem(problem) {
53
+ let rendered;
54
+ try {
55
+ // JSON.stringify(undefined) returns undefined at runtime despite its type.
56
+ const json = JSON.stringify(problem.value);
57
+ rendered = json ?? 'undefined';
58
+ }
59
+ catch {
60
+ rendered = String(problem.value);
61
+ }
62
+ if (rendered.length > 120)
63
+ rendered = `${rendered.slice(0, 117)}...`;
64
+ return `${problem.field} = ${rendered}`;
65
+ }
66
+ function findFingerprintMapProblem(field, value) {
67
+ if (!isObject(value))
68
+ return { field, value };
69
+ for (const [path, fingerprint] of Object.entries(value)) {
70
+ if (!isBaselinePath(path))
71
+ return { field: `${field}[${JSON.stringify(path)}]`, value: path };
72
+ if (!isFingerprint(fingerprint)) {
73
+ return { field: `${field}[${JSON.stringify(path)}]`, value: fingerprint };
74
+ }
75
+ }
76
+ return undefined;
77
+ }
78
+ function findCoverageRecordProblem(value) {
79
+ const field = 'testCoverageRecords';
80
+ if (!isObject(value))
81
+ return { field, value };
82
+ for (const [path, record] of Object.entries(value)) {
83
+ const entry = `${field}[${JSON.stringify(path)}]`;
84
+ if (!isBaselinePath(path))
85
+ return { field: entry, value: path };
86
+ if (!isCoverageAnchor(record) || !isObject(record) || !Array.isArray(record['inputs'])) {
87
+ return { field: entry, value: record };
88
+ }
89
+ const inputs = record['inputs'];
90
+ const input = inputs.find((item) => !isBaselinePath(item));
91
+ if (input !== undefined)
92
+ return { field: `${entry}.inputs`, value: input };
93
+ }
94
+ return undefined;
95
+ }
96
+ /**
97
+ * Validates persisted fields without converting malformed coverage into a
98
+ * full claim, and returns the first failing field with its value, or
99
+ * undefined when the baseline is valid.
100
+ *
101
+ * @param value - The parsed `last-build.json`
102
+ */
103
+ export function findBuildBaselineProblem(value) {
44
104
  if (!isObject(value))
45
- return false;
46
- if (typeof value['engineHeadSha'] !== 'string' ||
47
- typeof value['builtAt'] !== 'string' ||
48
- !Number.isFinite(Date.parse(value['builtAt'])) ||
49
- typeof value['binaryName'] !== 'string' ||
50
- !/^[a-z][a-z0-9-]*$/.test(value['binaryName']))
51
- return false;
105
+ return { field: '(root)', value };
106
+ if (typeof value['engineHeadSha'] !== 'string') {
107
+ return { field: 'engineHeadSha', value: value['engineHeadSha'] };
108
+ }
109
+ if (typeof value['builtAt'] !== 'string' || !Number.isFinite(Date.parse(value['builtAt']))) {
110
+ return { field: 'builtAt', value: value['builtAt'] };
111
+ }
112
+ if (typeof value['binaryName'] !== 'string' || !/^[a-z][a-z0-9-]*$/.test(value['binaryName'])) {
113
+ return { field: 'binaryName', value: value['binaryName'] };
114
+ }
52
115
  for (const field of [
53
116
  'packageableFingerprints',
54
117
  'testInputFingerprints',
55
118
  'buildInputFingerprints',
56
119
  ]) {
57
- if (value[field] !== undefined && !isFingerprintMap(value[field]))
58
- return false;
120
+ if (value[field] === undefined)
121
+ continue;
122
+ const problem = findFingerprintMapProblem(field, value[field]);
123
+ if (problem !== undefined)
124
+ return problem;
59
125
  }
60
126
  const coverage = value['testPackagingCoverage'];
61
- if (coverage !== undefined &&
62
- coverage !== 'full' &&
63
- !(Array.isArray(coverage) && coverage.every(isBaselinePath)))
64
- return false;
65
- if (value['mozconfigHash'] !== undefined && !isFingerprint(value['mozconfigHash']))
66
- return false;
67
- if (value['recordedBy'] !== undefined && typeof value['recordedBy'] !== 'string')
68
- return false;
69
- if (value['recorder'] !== undefined && !isBaselineRecorder(value['recorder']))
70
- return false;
71
- if (value['testCoverageRecords'] !== undefined &&
72
- !isCoverageRecordMap(value['testCoverageRecords']))
73
- return false;
74
- if (value['fullCoverageAnchor'] !== undefined && !isCoverageAnchor(value['fullCoverageAnchor']))
75
- return false;
127
+ if (coverage !== undefined && coverage !== 'full') {
128
+ if (!Array.isArray(coverage))
129
+ return { field: 'testPackagingCoverage', value: coverage };
130
+ const index = coverage.findIndex((path) => !isBaselinePath(path));
131
+ if (index >= 0) {
132
+ return { field: `testPackagingCoverage[${index}]`, value: coverage[index] };
133
+ }
134
+ }
135
+ if (value['mozconfigHash'] !== undefined && !isFingerprint(value['mozconfigHash'])) {
136
+ return { field: 'mozconfigHash', value: value['mozconfigHash'] };
137
+ }
138
+ if (value['recordedBy'] !== undefined && typeof value['recordedBy'] !== 'string') {
139
+ return { field: 'recordedBy', value: value['recordedBy'] };
140
+ }
141
+ if (value['recorder'] !== undefined && !isBaselineRecorder(value['recorder'])) {
142
+ return { field: 'recorder', value: value['recorder'] };
143
+ }
144
+ if (value['testCoverageRecords'] !== undefined) {
145
+ const problem = findCoverageRecordProblem(value['testCoverageRecords']);
146
+ if (problem !== undefined)
147
+ return problem;
148
+ }
149
+ if (value['fullCoverageAnchor'] !== undefined && !isCoverageAnchor(value['fullCoverageAnchor'])) {
150
+ return { field: 'fullCoverageAnchor', value: value['fullCoverageAnchor'] };
151
+ }
76
152
  const components = value['staticComponentsBaseline'];
77
- return (components === undefined ||
78
- (isObject(components) &&
153
+ if (components !== undefined &&
154
+ !(isObject(components) &&
79
155
  typeof components['engineHeadSha'] === 'string' &&
80
- isFingerprintMap(components['fingerprints'])));
156
+ isFingerprintMap(components['fingerprints']))) {
157
+ return { field: 'staticComponentsBaseline', value: components };
158
+ }
159
+ if (value['controlBuild'] !== undefined && !isControlBuildStamp(value['controlBuild'])) {
160
+ return { field: 'controlBuild', value: value['controlBuild'] };
161
+ }
162
+ return undefined;
163
+ }
164
+ /**
165
+ * Validates persisted fields without converting malformed coverage into a
166
+ * full claim. The boolean form of {@link findBuildBaselineProblem}.
167
+ *
168
+ * @internal Exported only so tests can reach it. Production reads go
169
+ * through `findBuildBaselineProblem`, which names the failing field.
170
+ */
171
+ export function isBuildBaseline(value) {
172
+ return findBuildBaselineProblem(value) === undefined;
81
173
  }
@@ -15,7 +15,7 @@
15
15
  * on successful build completion. A failed build does not update it, so a
16
16
  * subsequent run still audits against the last known-good tree.
17
17
  */
18
- import { type BuildBaseline, type CoverageAnchor, type CoverageRecord, type TestPackagingCoverage } from './build-baseline-types.js';
18
+ import { type BuildBaseline, type ControlBuildStamp, type CoverageAnchor, type CoverageRecord, type TestPackagingCoverage } from './build-baseline-types.js';
19
19
  /**
20
20
  * Which mach build produced the baseline being written. Decides whether
21
21
  * the `jar.mn` half of {@link BuildBaseline.buildInputFingerprints} is
@@ -35,7 +35,7 @@ export declare function getBuildBaselinePath(projectRoot: string): string;
35
35
  * Reads the last-build baseline if present. Returns undefined when no
36
36
  * previous successful build has been recorded. Builds can replace invalid
37
37
  * markers; dispatch preflights pass `refuse` so invalid coverage never
38
- * becomes the legacy implicit full claim.
38
+ * becomes the implicit full claim of a baseline without coverage.
39
39
  * @param projectRoot - Root directory of the project
40
40
  */
41
41
  export declare function readBuildBaseline(projectRoot: string, invalid?: 'ignore' | 'refuse'): Promise<BuildBaseline | undefined>;
@@ -85,6 +85,12 @@ export interface WriteBuildBaselineOptions {
85
85
  * escalation no full build has honoured.
86
86
  */
87
87
  buildKind?: BaselineBuildKind | undefined;
88
+ /**
89
+ * Stamp for a build that compiled a negative control into the binary.
90
+ * Omitted → field left off, which is how every other build clears it.
91
+ * See {@link BuildBaseline.controlBuild}.
92
+ */
93
+ controlBuild?: ControlBuildStamp | undefined;
88
94
  }
89
95
  /**
90
96
  * Records a successful build by writing a fresh baseline marker. Captures
@@ -26,12 +26,11 @@ import { sha256Hex } from '../utils/hash.js';
26
26
  import { verbose } from '../utils/logger.js';
27
27
  import { isBuildInputPath, isJarManifestPath, isPackageablePath, isXpcomManifestPath, } from './build-audit.js';
28
28
  import { DELETED_FILE_FINGERPRINT, } from './build-baseline-types.js';
29
- import { isBuildBaseline } from './build-baseline-validate.js';
29
+ import { describeBuildBaselineProblem, findBuildBaselineProblem, isCoverageProblem, } from './build-baseline-validate.js';
30
30
  import { FIREFORGE_DIR } from './config-paths.js';
31
31
  import { hashEngineFile } from './coverage-extend.js';
32
32
  import { getHead, hasChanges, isMissingHeadError } from './git.js';
33
33
  import { git } from './git-base.js';
34
- import { getUntrackedFiles } from './git-status.js';
35
34
  import { historyRecordIdentity } from './history-log-file.js';
36
35
  /*
37
36
  * Keep the IO fan-out bounded: a patched Firefox checkout can carry hundreds
@@ -52,22 +51,31 @@ export function getBuildBaselinePath(projectRoot) {
52
51
  * Reads the last-build baseline if present. Returns undefined when no
53
52
  * previous successful build has been recorded. Builds can replace invalid
54
53
  * markers; dispatch preflights pass `refuse` so invalid coverage never
55
- * becomes the legacy implicit full claim.
54
+ * becomes the implicit full claim of a baseline without coverage.
56
55
  * @param projectRoot - Root directory of the project
57
56
  */
58
57
  export async function readBuildBaseline(projectRoot, invalid = 'ignore') {
59
58
  const path = getBuildBaselinePath(projectRoot);
59
+ let problem;
60
60
  try {
61
61
  const value = await readJson(path);
62
- if (!isBuildBaseline(value))
63
- throw new Error('invalid baseline fields');
62
+ problem = findBuildBaselineProblem(value);
63
+ if (problem !== undefined) {
64
+ throw new Error(`invalid baseline field ${describeBuildBaselineProblem(problem)}`);
65
+ }
64
66
  return value;
65
67
  }
66
68
  catch (error) {
67
69
  if (getNodeErrorCode(error) === 'ENOENT')
68
70
  return undefined;
69
71
  if (invalid === 'refuse') {
70
- throw new PreflightRefusalError(`Build baseline ${path} is invalid: ${toError(error).message}. Run "fireforge build" to record a fresh baseline before dispatching tests.`, 'invalid-build-baseline');
72
+ // A coverage field is what a scoped `test --build` writes, so a fresh
73
+ // scoped build over the requested paths replaces it as well as a full
74
+ // one does.
75
+ const remedy = problem !== undefined && isCoverageProblem(problem)
76
+ ? 'The field is part of the packaging-coverage claim a scoped "fireforge test --build" records. Rerun this test with --build to record a fresh claim for its paths, or run "fireforge build" to record a full baseline.'
77
+ : 'Run "fireforge build" to record a fresh baseline before dispatching tests.';
78
+ throw new PreflightRefusalError(`Build baseline ${path} is invalid: ${toError(error).message}. ${remedy}`, 'invalid-build-baseline');
71
79
  }
72
80
  verbose(`Ignoring invalid build baseline ${path}: ${toError(error).message}`);
73
81
  return undefined;
@@ -87,7 +95,7 @@ function baselineRecorder() {
87
95
  * @param options - See {@link WriteBuildBaselineOptions}
88
96
  */
89
97
  export async function writeBuildBaseline(options) {
90
- const { projectRoot, engineDir, binaryName, testPackagingCoverage, testCoverageRecords, fullCoverageAnchor, previousBaseline, recordedBy, staticComponentsHandling = 'auto', buildKind = 'full', } = options;
98
+ const { projectRoot, engineDir, binaryName, testPackagingCoverage, testCoverageRecords, fullCoverageAnchor, previousBaseline, recordedBy, staticComponentsHandling = 'auto', buildKind = 'full', controlBuild, } = options;
91
99
  let engineHeadSha = '';
92
100
  try {
93
101
  engineHeadSha = await getHead(engineDir);
@@ -104,7 +112,7 @@ export async function writeBuildBaseline(options) {
104
112
  const packageableFingerprints = await collectPackageableFingerprints(engineDir);
105
113
  const testInputFingerprints = await collectDirtyFingerprints(engineDir, () => true, 'test staging fingerprint');
106
114
  const buildInputFingerprints = await collectBuildInputFingerprints(engineDir, previousBaseline, buildKind);
107
- // A full-coverage write (and the legacy no-claim `fireforge build` shape)
115
+ // A full-coverage write (and a no-claim `fireforge build` record)
108
116
  // just recompiled the StaticComponents table, so it anchors the table to
109
117
  // the current engine state. A scoped write did not, so carry the previous
110
118
  // anchor forward verbatim.
@@ -128,6 +136,7 @@ export async function writeBuildBaseline(options) {
128
136
  ...(staticComponentsBaseline !== undefined ? { staticComponentsBaseline } : {}),
129
137
  ...(recordedBy !== undefined ? { recordedBy } : {}),
130
138
  recorder: baselineRecorder(),
139
+ ...(controlBuild !== undefined ? { controlBuild } : {}),
131
140
  };
132
141
  await writeJson(getBuildBaselinePath(projectRoot), baseline);
133
142
  }
@@ -202,14 +211,14 @@ async function collectDirtyFingerprints(engineDir, includePath, contextLabel) {
202
211
  try {
203
212
  const dirtyPaths = new Set();
204
213
  if (await hasChanges(engineDir)) {
205
- const worktreeDiff = await git(['diff', '--name-only', 'HEAD'], engineDir);
206
- for (const line of worktreeDiff.split('\n')) {
207
- const trimmed = line.trim();
208
- if (trimmed)
209
- dirtyPaths.add(trimmed);
210
- }
211
- for (const untracked of await getUntrackedFiles(engineDir)) {
212
- dirtyPaths.add(untracked);
214
+ // NUL-separated and unquoted: git's default output backslash-quotes a
215
+ // non-ASCII path (`"a/\303\251.js"`), which is neither the file on
216
+ // disk nor a key the baseline validator accepts.
217
+ const worktreeDiff = await git(['-c', 'core.quotePath=false', 'diff', '--name-only', '-z', 'HEAD'], engineDir);
218
+ const untracked = await git(['-c', 'core.quotePath=false', 'ls-files', '--others', '--exclude-standard', '-z'], engineDir);
219
+ for (const path of `${worktreeDiff}\0${untracked}`.split('\0')) {
220
+ if (path.length > 0)
221
+ dirtyPaths.add(path);
213
222
  }
214
223
  }
215
224
  const included = [...dirtyPaths].filter(includePath);
@@ -2,10 +2,10 @@
2
2
  * Decides whether a changed `jar.mn` really forces the pre-test build to
3
3
  * escalate from `mach build faster` to a full `mach build`.
4
4
  *
5
- * The 0.41.0 rule was `any changed jar.mn ⇒ full build`, which costs ~10
6
- * minutes against ~20 s for a chrome-only slice and is paid twice per
7
- * green/no-change pair on a Furnace-touching slice. A downstream experiment
8
- * (two clean runs, 2026-08-29, against 0.44.4) showed the rule is over-broad
5
+ * Escalating on every changed jar.mn (`any changed jar.mn ⇒ full build`)
6
+ * costs ~10 minutes against ~20 s for a chrome-only slice and is paid twice
7
+ * per green/no-change pair on a Furnace-touching slice. A downstream
8
+ * experiment (two clean runs, 2026-08-29) showed that rule is over-broad
9
9
  * for the common case: a jar-only registration of a new content file added
10
10
  * to an existing `dist/bin` manifest was installed by a plain
11
11
  * `fireforge build --ui` both times: `faster/install_dist_bin_browser` named
@@ -3,10 +3,10 @@
3
3
  * Decides whether a changed `jar.mn` really forces the pre-test build to
4
4
  * escalate from `mach build faster` to a full `mach build`.
5
5
  *
6
- * The 0.41.0 rule was `any changed jar.mn ⇒ full build`, which costs ~10
7
- * minutes against ~20 s for a chrome-only slice and is paid twice per
8
- * green/no-change pair on a Furnace-touching slice. A downstream experiment
9
- * (two clean runs, 2026-08-29, against 0.44.4) showed the rule is over-broad
6
+ * Escalating on every changed jar.mn (`any changed jar.mn ⇒ full build`)
7
+ * costs ~10 minutes against ~20 s for a chrome-only slice and is paid twice
8
+ * per green/no-change pair on a Furnace-touching slice. A downstream
9
+ * experiment (two clean runs, 2026-08-29) showed that rule is over-broad
10
10
  * for the common case: a jar-only registration of a new content file added
11
11
  * to an existing `dist/bin` manifest was installed by a plain
12
12
  * `fireforge build --ui` both times: `faster/install_dist_bin_browser` named
@@ -122,7 +122,7 @@ async function decideJarEscalation(engineDir, changed) {
122
122
  // build. Path-shape only: `evaluateJarManifestEscalation` decides whether a
123
123
  // given changed manifest actually forces one (a new manifest, or one that
124
124
  // redirects its install base directory), so an entry added to an existing
125
- // `dist/bin` manifest no longer costs a ~10-minute build.
125
+ // `dist/bin` manifest does not cost a ~10-minute build.
126
126
  const decision = await evaluateJarManifestEscalation(engineDir, changed.filter(isJarManifestPath));
127
127
  if (decision.cleared.length > 0) {
128
128
  verbose(`jar escalation: ${decision.cleared.length} changed jar.mn add entries to an existing dist/bin manifest; not escalating for ${decision.cleared.join(', ')}.`);
@@ -241,9 +241,9 @@ async function applyFurnaceBeforeBuild(projectRoot, only) {
241
241
  }
242
242
  // The apply computed these (patch-owned engine files whose content
243
243
  // differed from components/ and were replaced). `furnace apply`
244
- // prints them; this path used to drop them, so a negative control
245
- // that edited a deployed Furnace file ran against the shipped code
246
- // with a green result and only a checksum could tell.
244
+ // prints them, and so does this path: dropped silently, a negative
245
+ // control that edited a deployed Furnace file would run against the
246
+ // shipped code with a green result and only a checksum could tell.
247
247
  const overwriteCount = logApplyWarnings(result.warnings);
248
248
  if (overwriteCount > 0) {
249
249
  notice(`Furnace: ${overwriteCount} Furnace-managed engine file${overwriteCount === 1 ? '' : 's'} differed from components/ and ${overwriteCount === 1 ? 'was' : 'were'} REPLACED before this build. An engine-side edit to a Furnace-managed file (for example a negative control) did not run; edit the component source under components/ instead, or restore the edit after the build.`);