@hominis/fireforge 0.51.0 → 0.53.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 (254) hide show
  1. package/CHANGELOG.md +46 -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.js +13 -9
  54. package/dist/src/commands/patch/tier.js +2 -2
  55. package/dist/src/commands/patch-rename-map.d.ts +14 -3
  56. package/dist/src/commands/patch-rename-map.js +21 -3
  57. package/dist/src/commands/re-export-adjacent.d.ts +4 -5
  58. package/dist/src/commands/re-export-lint.d.ts +4 -3
  59. package/dist/src/commands/re-export-lint.js +4 -3
  60. package/dist/src/commands/re-export-preflight.js +1 -2
  61. package/dist/src/commands/re-export-purity-scope.d.ts +1 -1
  62. package/dist/src/commands/re-export-purity-scope.js +13 -7
  63. package/dist/src/commands/re-export-retired-declarations.d.ts +22 -0
  64. package/dist/src/commands/re-export-retired-declarations.js +113 -0
  65. package/dist/src/commands/re-export-single.js +2 -2
  66. package/dist/src/commands/re-export-stale-registrations.d.ts +27 -6
  67. package/dist/src/commands/re-export-stale-registrations.js +65 -21
  68. package/dist/src/commands/re-export.js +4 -0
  69. package/dist/src/commands/rebase/continue.js +3 -3
  70. package/dist/src/commands/register.js +5 -4
  71. package/dist/src/commands/status-output.js +5 -1
  72. package/dist/src/commands/status.js +1 -1
  73. package/dist/src/commands/test-build-furnace.d.ts +1 -2
  74. package/dist/src/commands/test-build-furnace.js +1 -2
  75. package/dist/src/commands/test-build-phase.d.ts +10 -0
  76. package/dist/src/commands/test-build-phase.js +51 -16
  77. package/dist/src/commands/test-control-restore.d.ts +40 -0
  78. package/dist/src/commands/test-control-restore.js +86 -0
  79. package/dist/src/commands/test-dispatch.js +83 -13
  80. package/dist/src/commands/test-multi-verdict.js +1 -0
  81. package/dist/src/commands/test-orchestrate.d.ts +11 -0
  82. package/dist/src/commands/test-orchestrate.js +33 -2
  83. package/dist/src/commands/test-perf-scope.d.ts +1 -1
  84. package/dist/src/commands/test-perf-scope.js +1 -1
  85. package/dist/src/commands/test-prelock.d.ts +7 -8
  86. package/dist/src/commands/test-prelock.js +15 -12
  87. package/dist/src/commands/test-register.js +2 -2
  88. package/dist/src/commands/test-results.d.ts +10 -1
  89. package/dist/src/commands/test-results.js +20 -3
  90. package/dist/src/commands/test-run.d.ts +6 -0
  91. package/dist/src/commands/test-run.js +23 -4
  92. package/dist/src/commands/test-shards.d.ts +7 -3
  93. package/dist/src/commands/test-shards.js +24 -7
  94. package/dist/src/commands/test-stale-gate.d.ts +6 -1
  95. package/dist/src/commands/test-stale-gate.js +47 -2
  96. package/dist/src/commands/test-verdict.d.ts +5 -3
  97. package/dist/src/commands/test.js +14 -1
  98. package/dist/src/commands/token.js +2 -2
  99. package/dist/src/commands/verify.js +1 -1
  100. package/dist/src/commands/watch.js +1 -1
  101. package/dist/src/commands/wire.js +1 -1
  102. package/dist/src/core/build-audit-resolve.d.ts +4 -5
  103. package/dist/src/core/build-audit-resolve.js +4 -5
  104. package/dist/src/core/build-audit-skip.d.ts +2 -2
  105. package/dist/src/core/build-audit.d.ts +3 -4
  106. package/dist/src/core/build-baseline-types.d.ts +37 -3
  107. package/dist/src/core/build-baseline-validate.d.ts +30 -1
  108. package/dist/src/core/build-baseline-validate.js +134 -33
  109. package/dist/src/core/build-baseline.d.ts +8 -2
  110. package/dist/src/core/build-baseline.js +32 -16
  111. package/dist/src/core/build-jar-escalation.d.ts +4 -4
  112. package/dist/src/core/build-jar-escalation.js +4 -4
  113. package/dist/src/core/build-prepare.js +4 -4
  114. package/dist/src/core/config-validate.js +4 -4
  115. package/dist/src/core/coverage-extend.d.ts +5 -3
  116. package/dist/src/core/coverage-extend.js +8 -3
  117. package/dist/src/core/coverage-records.d.ts +4 -5
  118. package/dist/src/core/coverage-records.js +4 -5
  119. package/dist/src/core/destructive.d.ts +2 -2
  120. package/dist/src/core/destructive.js +2 -2
  121. package/dist/src/core/discard-baseline.d.ts +2 -2
  122. package/dist/src/core/discard-baseline.js +3 -3
  123. package/dist/src/core/engine-session-lock.d.ts +1 -1
  124. package/dist/src/core/engine-session-lock.js +1 -1
  125. package/dist/src/core/file-lock-owner.d.ts +5 -5
  126. package/dist/src/core/file-lock-owner.js +5 -5
  127. package/dist/src/core/file-lock-queue.js +6 -8
  128. package/dist/src/core/file-lock-shared.d.ts +4 -4
  129. package/dist/src/core/file-lock-shared.js +4 -4
  130. package/dist/src/core/file-lock.js +7 -10
  131. package/dist/src/core/firefox.js +2 -2
  132. package/dist/src/core/furnace-apply-overwrite-warn.d.ts +1 -1
  133. package/dist/src/core/furnace-apply-overwrite-warn.js +1 -1
  134. package/dist/src/core/furnace-apply.js +3 -3
  135. package/dist/src/core/furnace-config-validate-exceptions.d.ts +4 -3
  136. package/dist/src/core/furnace-config-validate-exceptions.js +26 -6
  137. package/dist/src/core/furnace-operation.js +5 -5
  138. package/dist/src/core/furnace-registration-jar-inplace.js +5 -6
  139. package/dist/src/core/furnace-registration-rename.d.ts +5 -5
  140. package/dist/src/core/furnace-registration-rename.js +5 -5
  141. package/dist/src/core/furnace-registration-validate.d.ts +2 -1
  142. package/dist/src/core/furnace-registration-validate.js +2 -1
  143. package/dist/src/core/furnace-registration.js +2 -2
  144. package/dist/src/core/furnace-validate-accessibility.d.ts +1 -1
  145. package/dist/src/core/furnace-validate-accessibility.js +22 -11
  146. package/dist/src/core/furnace-validate-exceptions.d.ts +2 -1
  147. package/dist/src/core/furnace-validate-exceptions.js +31 -13
  148. package/dist/src/core/git-base.js +4 -3
  149. package/dist/src/core/git-diff.js +1 -1
  150. package/dist/src/core/git.js +1 -1
  151. package/dist/src/core/harness-orphans.d.ts +6 -6
  152. package/dist/src/core/harness-orphans.js +6 -6
  153. package/dist/src/core/harness-port-reservation.d.ts +60 -0
  154. package/dist/src/core/harness-port-reservation.js +122 -0
  155. package/dist/src/core/history-log-file.d.ts +9 -9
  156. package/dist/src/core/history-log-file.js +15 -15
  157. package/dist/src/core/history-log.d.ts +3 -3
  158. package/dist/src/core/history-log.js +3 -3
  159. package/dist/src/core/launchable-build.d.ts +39 -0
  160. package/dist/src/core/launchable-build.js +85 -0
  161. package/dist/src/core/marionette-port.js +1 -3
  162. package/dist/src/core/moz-manifest-rules.js +3 -2
  163. package/dist/src/core/negative-control-compiled.d.ts +22 -3
  164. package/dist/src/core/negative-control-compiled.js +41 -5
  165. package/dist/src/core/parser-fallback.d.ts +5 -5
  166. package/dist/src/core/parser-fallback.js +5 -5
  167. package/dist/src/core/patch-identifier-suggest.d.ts +6 -0
  168. package/dist/src/core/patch-identifier-suggest.js +11 -4
  169. package/dist/src/core/patch-lint-checkjs.d.ts +1 -1
  170. package/dist/src/core/patch-lint-checkjs.js +1 -1
  171. package/dist/src/core/patch-lint-css.js +2 -2
  172. package/dist/src/core/patch-lint-file-size.d.ts +5 -7
  173. package/dist/src/core/patch-lint-file-size.js +5 -7
  174. package/dist/src/core/patch-lint-ignore.d.ts +3 -3
  175. package/dist/src/core/patch-lint-ignore.js +3 -3
  176. package/dist/src/core/patch-lint-prettier.d.ts +4 -6
  177. package/dist/src/core/patch-lint-prettier.js +4 -6
  178. package/dist/src/core/patch-lint-projection.d.ts +1 -1
  179. package/dist/src/core/patch-lint-projection.js +1 -1
  180. package/dist/src/core/patch-lint-queue-types.d.ts +2 -2
  181. package/dist/src/core/patch-lint-queue-types.js +2 -2
  182. package/dist/src/core/patch-manifest-consistency.d.ts +2 -2
  183. package/dist/src/core/patch-manifest-consistency.js +2 -2
  184. package/dist/src/core/patch-manifest-io.d.ts +5 -1
  185. package/dist/src/core/patch-manifest-io.js +12 -10
  186. package/dist/src/core/patch-manifest-key-order.d.ts +3 -4
  187. package/dist/src/core/patch-manifest-key-order.js +3 -4
  188. package/dist/src/core/patch-parse.d.ts +3 -3
  189. package/dist/src/core/patch-parse.js +3 -3
  190. package/dist/src/core/patch-policy.d.ts +1 -1
  191. package/dist/src/core/patch-policy.js +1 -1
  192. package/dist/src/core/patch-source-metadata.d.ts +1 -1
  193. package/dist/src/core/patch-source-metadata.js +1 -1
  194. package/dist/src/core/patch-subset.d.ts +5 -3
  195. package/dist/src/core/patch-subset.js +8 -7
  196. package/dist/src/core/patch-transform.d.ts +2 -2
  197. package/dist/src/core/patch-transform.js +2 -2
  198. package/dist/src/core/rebase-session.js +4 -6
  199. package/dist/src/core/register-xpcshell-test.d.ts +28 -7
  200. package/dist/src/core/register-xpcshell-test.js +76 -27
  201. package/dist/src/core/run-log.d.ts +8 -8
  202. package/dist/src/core/run-log.js +8 -8
  203. package/dist/src/core/status-classify.d.ts +1 -2
  204. package/dist/src/core/status-classify.js +1 -2
  205. package/dist/src/core/test-harness-crash.d.ts +5 -3
  206. package/dist/src/core/test-harness-crash.js +13 -14
  207. package/dist/src/core/test-harness-infra.d.ts +32 -0
  208. package/dist/src/core/test-harness-infra.js +69 -0
  209. package/dist/src/core/test-harness-signature.d.ts +6 -0
  210. package/dist/src/core/test-run-completeness.d.ts +12 -5
  211. package/dist/src/core/test-run-completeness.js +15 -8
  212. package/dist/src/core/test-stale-check.d.ts +48 -1
  213. package/dist/src/core/test-stale-check.js +97 -2
  214. package/dist/src/core/test-stall-triage.js +5 -7
  215. package/dist/src/core/token-category.js +2 -2
  216. package/dist/src/core/token-inventory.js +3 -3
  217. package/dist/src/core/token-manager.d.ts +2 -2
  218. package/dist/src/core/token-manager.js +5 -7
  219. package/dist/src/core/token-variant.d.ts +6 -6
  220. package/dist/src/core/token-variant.js +7 -7
  221. package/dist/src/core/typecheck-shim.d.ts +2 -1
  222. package/dist/src/core/typecheck-shim.js +29 -5
  223. package/dist/src/core/wire-destroy.d.ts +1 -1
  224. package/dist/src/core/wire-destroy.js +1 -1
  225. package/dist/src/core/wire-dom-fragment.js +1 -1
  226. package/dist/src/core/wire-init.d.ts +1 -1
  227. package/dist/src/core/wire-init.js +1 -1
  228. package/dist/src/core/wire-subscript.d.ts +1 -1
  229. package/dist/src/core/wire-subscript.js +1 -1
  230. package/dist/src/core/wire-utils.d.ts +2 -2
  231. package/dist/src/core/wire-utils.js +3 -3
  232. package/dist/src/errors/base.d.ts +1 -1
  233. package/dist/src/errors/base.js +1 -1
  234. package/dist/src/errors/rebase.d.ts +1 -1
  235. package/dist/src/errors/rebase.js +1 -1
  236. package/dist/src/types/commands/options.d.ts +26 -3
  237. package/dist/src/types/config.d.ts +5 -7
  238. package/dist/src/types/furnace.d.ts +9 -0
  239. package/dist/src/utils/options.js +2 -2
  240. package/dist/src/utils/process.js +2 -2
  241. package/dist/src/utils/regex.d.ts +2 -2
  242. package/dist/src/utils/regex.js +3 -3
  243. package/dist/src/utils/sleep.d.ts +5 -6
  244. package/dist/src/utils/sleep.js +5 -6
  245. package/dist/src/utils/validation.js +2 -2
  246. package/docs/configuration.md +40 -26
  247. package/docs/exit-codes.md +8 -11
  248. package/docs/furnace.md +21 -3
  249. package/docs/lifecycle-invariants.md +36 -45
  250. package/docs/machine-output.md +43 -22
  251. package/docs/patch-workflow.md +129 -24
  252. package/docs/run-logs.md +8 -9
  253. package/docs/testing.md +118 -44
  254. package/package.json +1 -1
@@ -0,0 +1,113 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * `re-export --expect-removed` drops the staged declarations whose
4
+ * `creates` path the run retires.
5
+ *
6
+ * Retiring a file would otherwise leave another patch's staged
7
+ * registration (or forward import) for it in place, unflagged until the
8
+ * per-patch lint after both re-exports reported `staged-dependency-unused`.
9
+ * The run that retires the path drops every such declaration, in any patch of the queue, in one manifest
10
+ * update, and names each one.
11
+ */
12
+ import { withPatchDirectoryLock } from '../core/patch-lock.js';
13
+ import { loadPatchesManifestForWrite, mutatePatchRowsInManifest } from '../core/patch-manifest.js';
14
+ import { info } from '../utils/logger.js';
15
+ import { recordStagedDeclarationPrune, } from './re-export-stale-registrations.js';
16
+ /**
17
+ * Finds the declarations whose `creates` path is retired and owned by no
18
+ * patch outside `retiring`.
19
+ *
20
+ * @param manifest - The queue to judge
21
+ * @param removed - The run's `--expect-removed` paths
22
+ * @param retiring - Patches whose ownership of a removed path does not
23
+ * count: the selected owners in a dry run, whose rows are not rewritten
24
+ * yet; nothing after the write, where a failed owner still owns its path
25
+ */
26
+ function planRetiredDeclarationDrops(manifest, removed, retiring) {
27
+ const orphaned = new Set(removed.filter((file) => !manifest.patches.some((patch) => !retiring.has(patch.filename) && patch.filesAffected.includes(file))));
28
+ if (orphaned.size === 0)
29
+ return [];
30
+ const drops = [];
31
+ for (const patch of manifest.patches) {
32
+ const drop = planHolderDrop(patch, orphaned);
33
+ if (drop !== undefined)
34
+ drops.push(drop);
35
+ }
36
+ return drops;
37
+ }
38
+ function planHolderDrop(patch, orphaned) {
39
+ const forwardImports = patch.stagedDependencies?.forwardImports ?? [];
40
+ const registrations = patch.stagedDependencies?.registrations ?? [];
41
+ const dropped = [
42
+ ...registrations
43
+ .filter((entry) => orphaned.has(entry.creates))
44
+ .map((entry) => ({ kind: 'registration', ...entry })),
45
+ ...forwardImports
46
+ .filter((entry) => orphaned.has(entry.creates))
47
+ .map((entry) => ({ kind: 'import', ...entry })),
48
+ ];
49
+ if (dropped.length === 0)
50
+ return undefined;
51
+ const keptImports = forwardImports.filter((entry) => !orphaned.has(entry.creates));
52
+ const keptRegistrations = registrations.filter((entry) => !orphaned.has(entry.creates));
53
+ const staged = {
54
+ ...(keptImports.length > 0 ? { forwardImports: keptImports } : {}),
55
+ ...(keptRegistrations.length > 0 ? { registrations: keptRegistrations } : {}),
56
+ };
57
+ return {
58
+ holder: patch.filename,
59
+ dropped,
60
+ stagedDependencies: Object.keys(staged).length > 0 ? staged : undefined,
61
+ };
62
+ }
63
+ function reportRetiredDeclarationDrops(drops, isDryRun) {
64
+ const verb = isDryRun ? 'would drop' : 'drops';
65
+ for (const { holder, dropped } of drops) {
66
+ for (const entry of dropped) {
67
+ const what = entry.kind === 'registration'
68
+ ? `staged registration ${entry.file} "${entry.line.trim()}"`
69
+ : `staged forward-import ${entry.file} "${entry.specifier}"`;
70
+ info(`${holder}: ${verb} ${what}: --expect-removed retires ${entry.creates}.`);
71
+ }
72
+ }
73
+ }
74
+ /**
75
+ * Drops (or, in a dry run, names) every staged declaration whose `creates`
76
+ * path this run retired. Runs after the selected patches are written, so a
77
+ * path whose owner failed to re-export is still owned and its declarations
78
+ * stay.
79
+ */
80
+ export async function dropRetiredPathDeclarations(args) {
81
+ const { manifest, isDryRun } = args;
82
+ const patchesDir = args.paths.patches;
83
+ const removed = args.options.expectRemoved ?? [];
84
+ const selected = new Set(args.selectedPatches.map((patch) => patch.filename));
85
+ if (removed.length === 0)
86
+ return;
87
+ if (isDryRun) {
88
+ reportRetiredDeclarationDrops(planRetiredDeclarationDrops(manifest, removed, selected), isDryRun);
89
+ return;
90
+ }
91
+ const drops = await withPatchDirectoryLock(patchesDir, async () => {
92
+ const current = await loadPatchesManifestForWrite(patchesDir);
93
+ if (!current)
94
+ return [];
95
+ const planned = planRetiredDeclarationDrops(current, removed, new Set());
96
+ const byHolder = new Map(planned.map((drop) => [drop.holder, drop]));
97
+ if (byHolder.size === 0)
98
+ return [];
99
+ await mutatePatchRowsInManifest(patchesDir, [...byHolder.keys()], (existing) => {
100
+ const drop = byHolder.get(existing.filename);
101
+ if (drop === undefined)
102
+ return null;
103
+ return drop.stagedDependencies === undefined
104
+ ? { unset: ['stagedDependencies'] }
105
+ : { set: { stagedDependencies: drop.stagedDependencies } };
106
+ });
107
+ return planned;
108
+ }, { command: 're-export' });
109
+ reportRetiredDeclarationDrops(drops, isDryRun);
110
+ for (const drop of drops) {
111
+ await recordStagedDeclarationPrune(patchesDir, drop.holder, drop.dropped, 'staged declarations on retired paths');
112
+ }
113
+ }
@@ -23,7 +23,7 @@ import { reportForeignDrift } from './re-export-drift.js';
23
23
  import { buildHunkSelectedDiff, hunkSelectionOf } from './re-export-hunks.js';
24
24
  import { assertRefreshedBodyHasNoNewQueueErrors, lintReExportedPatch, refreshQueueCtxEntry, storeReExportLintResult, } from './re-export-lint.js';
25
25
  import { assertScanAdoptionsHaveNoForwardImports, assertScanFileAdditionsHaveDiffHunks, confirmBroadScanAdditions, scanPatchFilesForReExport, } from './re-export-scan.js';
26
- import { planStaleRegistrationDrop, recordStaleRegistrationDrop, reportStaleRegistrationDrop, } from './re-export-stale-registrations.js';
26
+ import { findPendingRegistrationCreates, planStaleRegistrationDrop, recordStaleRegistrationDrop, reportStaleRegistrationDrop, } from './re-export-stale-registrations.js';
27
27
  const GIT_INDEX_LOCK_RETRY_MS = 300;
28
28
  /**
29
29
  * Retries a single patch re-export exactly once when the failure is
@@ -211,7 +211,7 @@ async function reExportSinglePatch(args) {
211
211
  return false;
212
212
  }
213
213
  const { updates, ignoreChecks, effectiveTier, existingIgnoreSet, flagIgnoreSet } = computeReExportUpdates(patch, options, currentFilesAffected);
214
- const stalePlan = planStaleRegistrationDrop(manifest, patch, currentFilesAffected);
214
+ const stalePlan = planStaleRegistrationDrop(manifest, patch, currentFilesAffected, await findPendingRegistrationCreates(paths.engine, manifest, patch, currentFilesAffected, adjacentCtx.expectedNew));
215
215
  if (stalePlan !== undefined)
216
216
  reportStaleRegistrationDrop(patch.filename, stalePlan, isDryRun);
217
217
  const unset = applyStaleRegistrationDrop(stalePlan, updates);
@@ -1,18 +1,39 @@
1
- import type { PatchesManifest, PatchMetadata, PatchStagedDependencies, PatchStagedRegistration } from '../types/commands/index.js';
1
+ import type { PatchesManifest, PatchMetadata, PatchStagedDependencies, PatchStagedForwardImport, PatchStagedRegistration } from '../types/commands/index.js';
2
2
  /** The holder's registrations split by whether the queue still owns `creates`. */
3
3
  export interface StaleRegistrationPlan {
4
4
  /** Registrations whose `creates` path no patch owns. */
5
5
  dropped: PatchStagedRegistration[];
6
+ /** Unowned registrations kept because their `creates` file is pending adoption. */
7
+ pending: PatchStagedRegistration[];
6
8
  /** The staged block to write, or undefined when nothing is left. */
7
9
  stagedDependencies: PatchStagedDependencies | undefined;
8
10
  }
11
+ /**
12
+ * The unowned `creates` paths of `patch`'s registrations that are pending
13
+ * adoption: the file exists in `engine/`, or is an `--expect-new` path of
14
+ * this run. Such a registration is waiting for its subject patch's scan.
15
+ */
16
+ export declare function findPendingRegistrationCreates(engineDir: string, manifest: PatchesManifest, patch: PatchMetadata, currentFilesAffected: readonly string[], expectedNew: ReadonlySet<string>): Promise<Set<string>>;
9
17
  /**
10
18
  * Finds `patch`'s registrations whose `creates` path no patch in the queue
11
- * owns, judging `patch` by the file list this re-export will write.
12
- * Returns undefined when nothing is stale.
19
+ * owns, judging `patch` by the file list this re-export will write. A
20
+ * registration whose `creates` path is in `pending` (see
21
+ * {@link findPendingRegistrationCreates}) is kept and reported instead.
22
+ * Returns undefined when nothing is stale or pending.
13
23
  */
14
- export declare function planStaleRegistrationDrop(manifest: PatchesManifest, patch: PatchMetadata, currentFilesAffected: readonly string[]): StaleRegistrationPlan | undefined;
15
- /** Names each dropped registration, in both a dry run and a write. */
24
+ export declare function planStaleRegistrationDrop(manifest: PatchesManifest, patch: PatchMetadata, currentFilesAffected: readonly string[], pendingCreates?: ReadonlySet<string>): StaleRegistrationPlan | undefined;
25
+ /** Names each dropped and each kept-pending registration, in both a dry run and a write. */
16
26
  export declare function reportStaleRegistrationDrop(patchFilename: string, plan: StaleRegistrationPlan, isDryRun: boolean): void;
17
- /** Records the dropped declarations as a delta, like `staged-dependency --remove`. */
27
+ /** One declaration a re-export pruned, as the history log records it. */
28
+ export type PrunedStagedDeclaration = ({
29
+ kind: 'registration';
30
+ } & PatchStagedRegistration) | ({
31
+ kind: 'import';
32
+ } & PatchStagedForwardImport);
33
+ /**
34
+ * Records declarations a re-export pruned from one patch as a delta, like
35
+ * `staged-dependency --remove`.
36
+ */
37
+ export declare function recordStagedDeclarationPrune(patchesDir: string, patchFilename: string, removed: readonly PrunedStagedDeclaration[], what: string): Promise<void>;
38
+ /** Records the dropped registrations as a delta, like `staged-dependency --remove`. */
18
39
  export declare function recordStaleRegistrationDrop(patchesDir: string, patchFilename: string, plan: StaleRegistrationPlan): Promise<void>;
@@ -3,23 +3,40 @@
3
3
  * Re-export prunes a staged registration the queue can no longer satisfy.
4
4
  *
5
5
  * A merge that deletes a file whose jar.mn line lives in another patch
6
- * left the staged registration on that patch: re-export kept it, lint then
7
- * warned `staged-dependency-unused`, and the stale declaration also kept
8
- * the holder measuring above a just-lowered size ceiling. When no patch in
6
+ * leaves the staged registration on that patch: lint warns
7
+ * `staged-dependency-unused`, and the stale declaration keeps the holder
8
+ * measuring above a just-lowered size ceiling. When no patch in
9
9
  * the queue owns a registration's `creates` path any more, the declaration
10
10
  * cannot be met, so the holder's own re-export drops it and says so.
11
+ *
12
+ * A registration whose `creates` file exists in `engine/`, or is an
13
+ * `--expect-new` path of the run, is kept: a manifest owner's re-export can
14
+ * run between declaring the registration and the scan that adopts the
15
+ * file, and pruning it there would break the scan that needs it next.
11
16
  */
17
+ import { join } from 'node:path';
12
18
  import { appendHistoryBestEffort } from '../core/history-log.js';
19
+ import { pathExists } from '../utils/fs.js';
13
20
  import { info } from '../utils/logger.js';
14
21
  /**
15
- * Finds `patch`'s registrations whose `creates` path no patch in the queue
16
- * owns, judging `patch` by the file list this re-export will write.
17
- * Returns undefined when nothing is stale.
22
+ * The unowned `creates` paths of `patch`'s registrations that are pending
23
+ * adoption: the file exists in `engine/`, or is an `--expect-new` path of
24
+ * this run. Such a registration is waiting for its subject patch's scan.
18
25
  */
19
- export function planStaleRegistrationDrop(manifest, patch, currentFilesAffected) {
20
- const registrations = patch.stagedDependencies?.registrations ?? [];
21
- if (registrations.length === 0)
22
- return undefined;
26
+ export async function findPendingRegistrationCreates(engineDir, manifest, patch, currentFilesAffected, expectedNew) {
27
+ const owned = ownedPaths(manifest, patch, currentFilesAffected);
28
+ const pending = new Set();
29
+ for (const registration of patch.stagedDependencies?.registrations ?? []) {
30
+ const creates = registration.creates;
31
+ if (owned.has(creates) || pending.has(creates))
32
+ continue;
33
+ if (expectedNew.has(creates) || (await pathExists(join(engineDir, creates)))) {
34
+ pending.add(creates);
35
+ }
36
+ }
37
+ return pending;
38
+ }
39
+ function ownedPaths(manifest, patch, currentFilesAffected) {
23
40
  const owned = new Set(currentFilesAffected);
24
41
  for (const entry of manifest.patches) {
25
42
  if (entry.filename === patch.filename)
@@ -27,10 +44,26 @@ export function planStaleRegistrationDrop(manifest, patch, currentFilesAffected)
27
44
  for (const file of entry.filesAffected)
28
45
  owned.add(file);
29
46
  }
30
- const dropped = registrations.filter((registration) => !owned.has(registration.creates));
31
- if (dropped.length === 0)
47
+ return owned;
48
+ }
49
+ /**
50
+ * Finds `patch`'s registrations whose `creates` path no patch in the queue
51
+ * owns, judging `patch` by the file list this re-export will write. A
52
+ * registration whose `creates` path is in `pending` (see
53
+ * {@link findPendingRegistrationCreates}) is kept and reported instead.
54
+ * Returns undefined when nothing is stale or pending.
55
+ */
56
+ export function planStaleRegistrationDrop(manifest, patch, currentFilesAffected, pendingCreates = new Set()) {
57
+ const registrations = patch.stagedDependencies?.registrations ?? [];
58
+ if (registrations.length === 0)
32
59
  return undefined;
33
- const kept = registrations.filter((registration) => owned.has(registration.creates));
60
+ const owned = ownedPaths(manifest, patch, currentFilesAffected);
61
+ const unowned = registrations.filter((registration) => !owned.has(registration.creates));
62
+ const pending = unowned.filter((registration) => pendingCreates.has(registration.creates));
63
+ const dropped = unowned.filter((registration) => !pendingCreates.has(registration.creates));
64
+ if (dropped.length === 0 && pending.length === 0)
65
+ return undefined;
66
+ const kept = registrations.filter((registration) => !dropped.includes(registration));
34
67
  const forwardImports = patch.stagedDependencies?.forwardImports ?? [];
35
68
  const staged = {
36
69
  ...(forwardImports.length > 0 ? { forwardImports } : {}),
@@ -38,25 +71,36 @@ export function planStaleRegistrationDrop(manifest, patch, currentFilesAffected)
38
71
  };
39
72
  return {
40
73
  dropped,
74
+ pending,
41
75
  stagedDependencies: Object.keys(staged).length > 0 ? staged : undefined,
42
76
  };
43
77
  }
44
- /** Names each dropped registration, in both a dry run and a write. */
78
+ /** Names each dropped and each kept-pending registration, in both a dry run and a write. */
45
79
  export function reportStaleRegistrationDrop(patchFilename, plan, isDryRun) {
46
80
  const verb = isDryRun ? 'would drop' : 'drops';
47
81
  for (const registration of plan.dropped) {
48
82
  info(`${patchFilename}: ${verb} staged registration ${registration.file} "${registration.line.trim()}": ` +
49
83
  `no patch in the queue creates ${registration.creates} any more.`);
50
84
  }
85
+ for (const registration of plan.pending) {
86
+ info(`${patchFilename}: staged registration ${registration.file} "${registration.line.trim()}" ` +
87
+ `for ${registration.creates} kept: file exists but no patch owns it yet.`);
88
+ }
51
89
  }
52
- /** Records the dropped declarations as a delta, like `staged-dependency --remove`. */
53
- export async function recordStaleRegistrationDrop(patchesDir, patchFilename, plan) {
90
+ /**
91
+ * Records declarations a re-export pruned from one patch as a delta, like
92
+ * `staged-dependency --remove`.
93
+ */
94
+ export async function recordStagedDeclarationPrune(patchesDir, patchFilename, removed, what) {
95
+ if (removed.length === 0)
96
+ return;
54
97
  await appendHistoryBestEffort(patchesDir, {
55
98
  operation: 're-export-staged-prune',
56
- args: {
57
- filename: patchFilename,
58
- removed: plan.dropped.map((registration) => ({ kind: 'registration', ...registration })),
59
- },
99
+ args: { filename: patchFilename, removed: [...removed] },
60
100
  result: 'ok',
61
- }, `re-export pruned stale staged registrations on ${patchFilename}`);
101
+ }, `re-export pruned ${what} on ${patchFilename}`);
102
+ }
103
+ /** Records the dropped registrations as a delta, like `staged-dependency --remove`. */
104
+ export async function recordStaleRegistrationDrop(patchesDir, patchFilename, plan) {
105
+ await recordStagedDeclarationPrune(patchesDir, patchFilename, plan.dropped.map((registration) => ({ kind: 'registration', ...registration })), 'stale staged registrations');
62
106
  }
@@ -17,6 +17,7 @@ import { reExportFilesInPlace } from './re-export-files.js';
17
17
  import { buildReExportLintContext, finishReExportLint } from './re-export-lint.js';
18
18
  import { applyReExportFilesPositionalFolding, validateReExportOptionCombinations, } from './re-export-options.js';
19
19
  import { validateExpectedRemovals } from './re-export-removed.js';
20
+ import { dropRetiredPathDeclarations } from './re-export-retired-declarations.js';
20
21
  import { normalizeEngineRelativeInput, normalizeScanFiles } from './re-export-scan.js';
21
22
  import { assignScanFileOwners, scanOptionsForPatch } from './re-export-scan-owner.js';
22
23
  import { reExportSinglePatchWithIndexLockRetry, } from './re-export-single.js';
@@ -190,6 +191,9 @@ export async function reExportCommand(projectRoot, patches, options) {
190
191
  progress.error('Re-export failed');
191
192
  throw new GeneralError('All selected patches failed to re-export. Check the errors above.');
192
193
  }
194
+ // Declarations on a path this run retired can never be met; drop them
195
+ // now rather than leave them for the next per-patch lint to report.
196
+ await dropRetiredPathDeclarations({ paths, manifest, options, selectedPatches, isDryRun });
193
197
  // `--stamp` only fires on a run where every selected patch refreshed
194
198
  // cleanly. A partial success would leave some patches with a stale body
195
199
  // but a new version, the opposite of the "what I tested, what the
@@ -21,9 +21,9 @@ import { runPatchLoop } from './patch-loop.js';
21
21
  */
22
22
  export async function handleContinue(projectRoot, maxFuzz, waitLockSeconds) {
23
23
  intro('FireForge Rebase — Continue');
24
- // A present-but-unreadable session is not "no session in progress". That
25
- // conflation is what made a corrupt file unrecoverable. Name the file and
26
- // point at --abort, which can now clear it.
24
+ // A present-but-unreadable session is not "no session in progress":
25
+ // treating it as one leaves a corrupt file unrecoverable. Name the file
26
+ // and point at --abort, which can clear it.
27
27
  const read = await readRebaseSession(projectRoot);
28
28
  if (!read.present)
29
29
  throw new NoRebaseSessionError();
@@ -32,9 +32,9 @@ export async function registerCommand(projectRoot, filePath, options = {}) {
32
32
  }
33
33
  // Accept either repo-root-relative (`engine/browser/...`) or
34
34
  // engine-relative (`browser/...`) inputs. Operators frequently paste
35
- // the former from the output of tab completion or `git status`, and
36
- // the mismatch used to produce a "File not found" error that named
37
- // the original path with no hint that dropping `engine/` would fix it.
35
+ // the former from the output of tab completion or `git status`, so
36
+ // both shapes resolve to the same path instead of a "File not found"
37
+ // error.
38
38
  const engineRelativePath = stripEnginePrefix(filePath);
39
39
  // Verify the file exists in engine/ (skip for dry-run)
40
40
  if (!options.dryRun) {
@@ -97,7 +97,8 @@ export function registerRegister(program, { getProjectRoot, withErrorHandling })
97
97
  .command('register <path>')
98
98
  .description('Register a file in the appropriate build manifest')
99
99
  .option('--dry-run', 'Show what would be changed without writing')
100
- .option('--after <entry>', 'Place entry after line containing this substring (instead of alphabetical)')
100
+ .option('--after <entry>', 'Place entry after line containing this substring (instead of alphabetical); for an ' +
101
+ 'xpcshell test, after the whole section of the xpcshell.toml entry it names')
101
102
  .option('--create-manifest', 'Scaffold a missing manifest (directory moz.build with EXTRA_JS_MODULES / XPCSHELL_TESTS_MANIFESTS, or xpcshell.toml) and wire the parent DIRS chain instead of failing with "Manifest not found"')
102
103
  .action(withErrorHandling(async (path, options) => {
103
104
  await registerCommand(getProjectRoot(), path, pickDefined(options));
@@ -206,7 +206,11 @@ export function renderTestCoverageStatus(baseline) {
206
206
  }
207
207
  info(`Last build: ${baseline.builtAt} (binary: ${baseline.binaryName}, ` +
208
208
  `engine HEAD: ${baseline.engineHeadSha || '(unborn)'})`);
209
- info(`Recorded by: ${baseline.recordedBy ?? 'unknown'}`);
209
+ const recorder = baseline.recorder;
210
+ info(`Recorded by: ${baseline.recordedBy ?? 'unknown'}` +
211
+ (recorder === undefined
212
+ ? ''
213
+ : ` (pid ${recorder.pid}${recorder.sessionId !== undefined ? `, session ${recorder.sessionId}` : ''})`));
210
214
  const coverage = baseline.testPackagingCoverage;
211
215
  if (coverage === undefined) {
212
216
  info('Test packaging coverage: full (implicit — baseline predates coverage recording)');
@@ -409,7 +409,7 @@ export function registerStatus(program, { getProjectRoot, withErrorHandling }) {
409
409
  .option('--fail-on <classifications>', 'Comma-separated classification list that fails --check, replacing the default set (implies --check). Valid: patch-backed, patch-owned-drift, unmanaged, branding, furnace, conflict, binary-unsupported, ownership-conflict (a path claimed by more than one patch)')
410
410
  .option('--allow <classifications>', 'Comma-separated classification list that never fails --check, removed from the fail set (implies --check; same values as --fail-on)')
411
411
  .option('--fail-on-unknown', 'With --allow: also fail on every present classification that neither --fail-on nor --allow names, so a classification added in a later FireForge fails closed (implies --check)')
412
- .option('--patches <names...>', 'Judge only the named patches: keeps the files each owns or claims and the ownership rows naming it (filename, stem, order number, manifest name, or slug; repeat or comma-separate). Unmanaged files belong to no patch and drop out. Default view, --json and --ownership only')
412
+ .option('--patches <names...>', 'Judge only the named patches: keeps the files each owns or claims and the ownership rows naming it (filename, stem, order number, manifest name, or slug; repeat, or separate with commas or whitespace). Unmanaged files belong to no patch and drop out. Default view, --json and --ownership only')
413
413
  .action(withErrorHandling(async (options) => {
414
414
  await statusCommand(getProjectRoot(), options);
415
415
  }));
@@ -3,8 +3,7 @@
3
3
  * runs `furnace apply` over the whole `components/` tree, so a
4
4
  * `fireforge test --build` meant to capture the committed behaviour could
5
5
  * silently ship component edits nobody had deployed yet (another session's
6
- * included), and nothing in the test output said so. The applied names now
7
- * ride the verdict line, and `--no-furnace-apply` packages `engine/` as it
6
+ * included). The applied names therefore ride the verdict line, and `--no-furnace-apply` packages `engine/` as it
8
7
  * stands, naming the undeployed edits it left out.
9
8
  */
10
9
  import type { BuildPreparation } from '../core/build-prepare.js';
@@ -4,8 +4,7 @@
4
4
  * runs `furnace apply` over the whole `components/` tree, so a
5
5
  * `fireforge test --build` meant to capture the committed behaviour could
6
6
  * silently ship component edits nobody had deployed yet (another session's
7
- * included), and nothing in the test output said so. The applied names now
8
- * ride the verdict line, and `--no-furnace-apply` packages `engine/` as it
7
+ * included). The applied names therefore ride the verdict line, and `--no-furnace-apply` packages `engine/` as it
9
8
  * stands, naming the undeployed edits it left out.
10
9
  */
11
10
  import { listStaleFurnaceComponents } from '../core/furnace-staleness.js';
@@ -3,6 +3,7 @@
3
3
  * the per-file line budget): the incremental protected mach build with
4
4
  * its baseline write, and the `--build-only` union-build shape.
5
5
  */
6
+ import type { ControlBuildStamp } from '../core/build-baseline-types.js';
6
7
  import type { TestOptions } from '../types/commands/index.js';
7
8
  import type { FireForgeConfig, ProjectPaths } from '../types/config.js';
8
9
  import type { HarnessClassification } from './test-modes.js';
@@ -16,4 +17,13 @@ import type { HarnessClassification } from './test-modes.js';
16
17
  export declare function runTestBuildPhase(projectRoot: string, paths: ProjectPaths, projectConfig: FireForgeConfig, harnessRetries: number, options: TestOptions, request: {
17
18
  classification: HarnessClassification;
18
19
  normalizedPaths: string[];
20
+ /** Forces a full pre-test build (a compiled negative control). */
21
+ fullBuildReason?: string;
22
+ /** Recorded on the pre-test build's baseline (a compiled negative control). */
23
+ controlBuild?: ControlBuildStamp;
24
+ /**
25
+ * Engine-relative launchable binary: anchors the stale-compiled check,
26
+ * which refuses a build-less run and escalates a `--build` one.
27
+ */
28
+ launchablePath?: string | undefined;
19
29
  }): Promise<boolean>;
@@ -7,10 +7,12 @@
7
7
  import { readBuildBaseline, writeBuildBaseline } from '../core/build-baseline.js';
8
8
  import { prepareBuildEnvironment } from '../core/build-prepare.js';
9
9
  import { applyMozconfigAnchor, formatCoverageRetentionNotice, formatExtendCoverageRefusal, planCoverageRetention, recordScopedCoverage, } from '../core/coverage-extend.js';
10
+ import { normalizeRequestPath } from '../core/coverage-records.js';
10
11
  import { hasBuildArtifacts } from '../core/mach.js';
11
12
  import { runProtectedMachBuild, withBuildLock } from '../core/mach.js';
12
13
  import { assertHealthyPartialConfig, pruneDanglingTestLinks } from '../core/objdir-maintenance.js';
13
14
  import { buildHarnessCrashMessage } from '../core/test-harness-crash.js';
15
+ import { findCompiledSourcesNewerThanBinary, formatStaleCompiledBuildNotice, } from '../core/test-stale-check.js';
14
16
  import { GeneralError, InvalidArgumentError } from '../errors/base.js';
15
17
  import { BuildError } from '../errors/build.js';
16
18
  import { toError } from '../utils/errors.js';
@@ -27,7 +29,7 @@ import { emitPassVerdict } from './test-verdict.js';
27
29
  * @param options - See {@link RunPreTestBuildOptions}
28
30
  */
29
31
  async function runPreTestBuild(options) {
30
- const { projectRoot, paths, projectConfig, harnessRetries, testPackagingCoverage, refuseUnexportedDrift, extend, furnace, } = options;
32
+ const { projectRoot, paths, projectConfig, harnessRetries, testPackagingCoverage, refuseUnexportedDrift, extend, furnace, fullBuildReason, controlBuild, } = options;
31
33
  await withBuildLock(projectRoot, async () => {
32
34
  // Pass the previous baseline exactly like `fireforge build` does, so
33
35
  // auto-configure runs under the same conditions on both paths. The
@@ -66,17 +68,16 @@ async function runPreTestBuild(options) {
66
68
  if (retentionNotice !== undefined)
67
69
  notice(retentionNotice);
68
70
  }
69
- const buildKind = preparation.fullBuildRequired ? 'full' : 'faster';
70
- if (preparation.fullBuildRequired) {
71
+ const fullBuild = preparation.fullBuildRequired || fullBuildReason !== undefined;
72
+ const buildKind = fullBuild ? 'full' : 'faster';
73
+ if (fullBuild) {
71
74
  // Warning severity rather than info: agent-facing output filters
72
75
  // keep warnings and errors only, and this is the line that explains an
73
76
  // otherwise unexplained multi-minute build.
74
- notice(preparation.fullBuildReason ??
77
+ notice((preparation.fullBuildRequired ? preparation.fullBuildReason : fullBuildReason) ??
75
78
  'A jar.mn registration changed since the last successful build; escalating this pre-test build to a full mach build so new install-manifest destinations are created.');
76
79
  }
77
- const s = spinner(preparation.fullBuildRequired
78
- ? 'Running required full build...'
79
- : 'Running incremental build...');
80
+ const s = spinner(fullBuild ? 'Running required full build...' : 'Running incremental build...');
80
81
  // The pre-test build routes through the same protected mach dispatch as
81
82
  // `fireforge build` / `build --ui`: in-venv resource-monitor guard plus
82
83
  // the uniform recognized-crash retry budget, with a fresh mach process
@@ -116,12 +117,13 @@ async function runPreTestBuild(options) {
116
117
  ...recorded,
117
118
  previousBaseline,
118
119
  recordedBy: describeBuildInvocation(extend !== undefined, testPackagingCoverage),
119
- staticComponentsHandling: preparation.fullBuildRequired
120
+ staticComponentsHandling: fullBuild
120
121
  ? 'refresh'
121
122
  : plan !== undefined && (plan.keepFull || Object.keys(plan.retained).length > 0)
122
123
  ? 'carry-forward'
123
124
  : 'auto',
124
125
  buildKind,
126
+ controlBuild,
125
127
  });
126
128
  }
127
129
  catch (baselineError) {
@@ -131,8 +133,8 @@ async function runPreTestBuild(options) {
131
133
  }
132
134
  s.error('Pre-test build failed');
133
135
  throw new BuildError(result.crashSignature
134
- ? buildHarnessCrashMessage(result.crashSignature, result.attempts, 'mach build faster')
135
- : 'Pre-test build failed', preparation.fullBuildRequired ? 'mach build' : 'mach build faster');
136
+ ? buildHarnessCrashMessage(result.crashSignature, result.attempts, fullBuild ? 'mach build' : 'mach build faster')
137
+ : 'Pre-test build failed', fullBuild ? 'mach build' : 'mach build faster');
136
138
  });
137
139
  }
138
140
  /**
@@ -201,7 +203,7 @@ function describeBuildInvocation(extending, coverage) {
201
203
  * next steps, and emits the run's single `PASS` verdict.
202
204
  */
203
205
  export async function runTestBuildPhase(projectRoot, paths, projectConfig, harnessRetries, options, request) {
204
- const { classification, normalizedPaths } = request;
206
+ const { classification, normalizedPaths, controlBuild, launchablePath } = request;
205
207
  const extending = options.extendCoverage === true;
206
208
  if (extending) {
207
209
  assertExtendCoverageUsage(options, normalizedPaths);
@@ -214,19 +216,28 @@ export async function runTestBuildPhase(projectRoot, paths, projectConfig, harne
214
216
  // rebuild also cannot regenerate the compiled StaticComponents table,
215
217
  // so it runs the components.conf gate up-front (the path-less shape
216
218
  // refreshes the anchor itself and skips it).
217
- const coverage = normalizedPaths.length === 0 ? 'full' : normalizedPaths;
218
- if (normalizedPaths.length > 0) {
219
+ // Coverage paths are recorded normalized (no trailing slash, no `./`):
220
+ // the baseline validator refuses the raw `dir/` CLI shape, which would
221
+ // block every later plain `fireforge test`. A request
222
+ // for the engine root (`.`) packages everything, so it records `'full'`.
223
+ const coveragePaths = normalizedPaths.map(normalizeRequestPath);
224
+ const coverage = coveragePaths.length === 0 || coveragePaths.includes('')
225
+ ? 'full'
226
+ : [...new Set(coveragePaths)];
227
+ if (coverage !== 'full') {
219
228
  const previousBaseline = await readBuildBaseline(projectRoot);
220
229
  await enforceStaticComponentsGate(paths.engine, previousBaseline, options);
221
230
  if (extending) {
222
231
  // Staging-input half of the plan, before the build lock: a refusal
223
232
  // here costs neither build time nor the lock.
224
- const plan = await planCoverageRetention(paths.engine, previousBaseline, normalizedPaths);
233
+ const plan = await planCoverageRetention(paths.engine, previousBaseline, coverage);
225
234
  if (plan.dropped.length > 0) {
226
235
  throw new GeneralError(formatExtendCoverageRefusal(plan.dropped));
227
236
  }
228
237
  }
229
238
  }
239
+ const fullBuildReason = request.fullBuildReason ??
240
+ (await describeCompiledEscalation(projectRoot, paths.engine, launchablePath));
230
241
  await runPreTestBuild({
231
242
  projectRoot,
232
243
  paths,
@@ -234,7 +245,9 @@ export async function runTestBuildPhase(projectRoot, paths, projectConfig, harne
234
245
  harnessRetries,
235
246
  testPackagingCoverage: coverage,
236
247
  refuseUnexportedDrift: options.refuseUnexportedDrift === true,
237
- extend: extending ? { requestedPaths: normalizedPaths } : undefined,
248
+ extend: extending && coverage !== 'full' ? { requestedPaths: coverage } : undefined,
249
+ fullBuildReason,
250
+ controlBuild,
238
251
  furnace: await planPreTestFurnace(projectRoot, paths, projectConfig, options, normalizedPaths),
239
252
  });
240
253
  info('');
@@ -249,7 +262,29 @@ export async function runTestBuildPhase(projectRoot, paths, projectConfig, harne
249
262
  }
250
263
  }
251
264
  else {
252
- await enforceStaleBuildGate(projectRoot, paths.engine, options, normalizedPaths);
265
+ await enforceStaleBuildGate(projectRoot, paths.engine, options, normalizedPaths, {
266
+ launchablePath,
267
+ });
253
268
  }
254
269
  return false;
255
270
  }
271
+ /**
272
+ * Why a `--build` run's pre-test build must be a full `mach build` although
273
+ * nothing asked for one: compiled sources changed after the binary was
274
+ * linked, or the binary still carries a negative control a killed run (or a
275
+ * failed restore build) left compiled in. `mach build faster` compiles
276
+ * nothing, so either would otherwise survive the build.
277
+ */
278
+ async function describeCompiledEscalation(projectRoot, engineDir, launchablePath) {
279
+ const baseline = await readBuildBaseline(projectRoot);
280
+ const stale = await findCompiledSourcesNewerThanBinary(engineDir, baseline, launchablePath);
281
+ if (stale !== undefined && stale.paths.length > 0)
282
+ return formatStaleCompiledBuildNotice(stale);
283
+ const stamp = baseline?.controlBuild;
284
+ if (stamp !== undefined) {
285
+ return (`The binary was built with the negative control ${stamp.diffFile} applied and not ` +
286
+ 'rebuilt since; escalating this pre-test build to a full mach build so it recompiles ' +
287
+ 'the restored source.');
288
+ }
289
+ return undefined;
290
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The restore build after a compiled negative control.
3
+ *
4
+ * A control on compiled source is applied before a full pre-test build, so
5
+ * the binary under test carries it. Restoring the source byte-exact after
6
+ * the run does not unbuild it: the objdir binary keeps the mutation until a
7
+ * build recompiles the original. Every later `fireforge test` would then
8
+ * test the control. The orchestrator therefore runs one more full
9
+ * `mach build` right after a successful restore, still under the same
10
+ * engine-session hold, and its baseline drops the `controlBuild` stamp the
11
+ * control's build recorded. When that build fails, or the run never gets
12
+ * here (killed), the stamp stays and the next `fireforge test` refuses with
13
+ * `note=control-built` until `fireforge build` replaces it.
14
+ */
15
+ import type { ControlBuildStamp } from '../core/build-baseline-types.js';
16
+ /** Outcome of {@link runControlRestoreBuild}, stamped as `control-restore-build=`. */
17
+ export type ControlRestoreBuildOutcome = 'done' | 'failed';
18
+ /**
19
+ * Whether the recorded baseline is the one this run's control build wrote:
20
+ * only then did a build compile the control in (a failed control build
21
+ * writes no baseline, and its restored source's fresh mtime trips the
22
+ * stale-compiled check instead).
23
+ *
24
+ * @param projectRoot - Project root
25
+ * @param stamp - The stamp this run's control build recorded
26
+ */
27
+ export declare function controlBuildRecorded(projectRoot: string, stamp: ControlBuildStamp): Promise<boolean>;
28
+ /**
29
+ * Rebuilds the binary after a compiled control was restored: a full
30
+ * protected `mach build` under the build lock, then a baseline without the
31
+ * stamp. The previous baseline's packaging-coverage claim is carried
32
+ * forward unchanged, since recompiling the restored source stages no tests.
33
+ * Never throws: a failure leaves the stamp in place, which is the refusal
34
+ * the next run needs.
35
+ *
36
+ * @param projectRoot - Project root
37
+ * @param stamp - The stamp this run's control build recorded
38
+ * @param harnessRetries - Recognized-crash retry budget for the build
39
+ */
40
+ export declare function runControlRestoreBuild(projectRoot: string, stamp: ControlBuildStamp, harnessRetries: number | undefined): Promise<ControlRestoreBuildOutcome>;