@hominis/fireforge 0.39.0 → 0.41.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 (279) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +35 -5
  3. package/dist/bin/fireforge.js +25 -4
  4. package/dist/build-info.json +9 -0
  5. package/dist/src/cli.js +27 -3
  6. package/dist/src/commands/bootstrap.js +5 -1
  7. package/dist/src/commands/discard.js +2 -22
  8. package/dist/src/commands/doctor/post-rebase-audit.js +27 -4
  9. package/dist/src/commands/doctor-check-core.d.ts +20 -0
  10. package/dist/src/commands/doctor-check-core.js +28 -0
  11. package/dist/src/commands/doctor-external-toolchains.js +2 -0
  12. package/dist/src/commands/doctor-furnace-manifest-sync.js +144 -46
  13. package/dist/src/commands/doctor-furnace.js +13 -11
  14. package/dist/src/commands/doctor-orphaned-harness.js +2 -1
  15. package/dist/src/commands/doctor.js +2 -2
  16. package/dist/src/commands/export-all.js +9 -1
  17. package/dist/src/commands/export-flow.js +27 -9
  18. package/dist/src/commands/export-placement-conflicts.d.ts +17 -0
  19. package/dist/src/commands/export-placement-conflicts.js +74 -0
  20. package/dist/src/commands/export-placement-gate.js +1 -1
  21. package/dist/src/commands/export-shared.d.ts +8 -1
  22. package/dist/src/commands/export-shared.js +30 -10
  23. package/dist/src/commands/export.js +2 -2
  24. package/dist/src/commands/furnace/apply.js +4 -3
  25. package/dist/src/commands/furnace/chrome-doc-remove.js +13 -2
  26. package/dist/src/commands/furnace/chrome-doc.js +13 -2
  27. package/dist/src/commands/furnace/create-browser-test.d.ts +5 -0
  28. package/dist/src/commands/furnace/create-browser-test.js +5 -0
  29. package/dist/src/commands/furnace/create.js +2 -2
  30. package/dist/src/commands/furnace/diff.js +2 -1
  31. package/dist/src/commands/furnace/init.js +4 -6
  32. package/dist/src/commands/furnace/override.js +2 -2
  33. package/dist/src/commands/furnace/remove-mutations.d.ts +40 -0
  34. package/dist/src/commands/furnace/remove-mutations.js +171 -0
  35. package/dist/src/commands/furnace/remove.js +21 -128
  36. package/dist/src/commands/furnace/rename-browser-test.js +54 -5
  37. package/dist/src/commands/furnace/scan.js +27 -36
  38. package/dist/src/commands/furnace/status.js +30 -28
  39. package/dist/src/commands/furnace/validate.js +25 -18
  40. package/dist/src/commands/import.js +6 -20
  41. package/dist/src/commands/lint-per-patch.js +73 -51
  42. package/dist/src/commands/lint-per-run-checkjs.d.ts +64 -0
  43. package/dist/src/commands/lint-per-run-checkjs.js +102 -0
  44. package/dist/src/commands/lint-report.d.ts +21 -0
  45. package/dist/src/commands/lint-report.js +62 -0
  46. package/dist/src/commands/lint.js +26 -22
  47. package/dist/src/commands/manifest.js +3 -1
  48. package/dist/src/commands/package.js +9 -16
  49. package/dist/src/commands/patch/compact.js +5 -5
  50. package/dist/src/commands/patch/delete.js +5 -5
  51. package/dist/src/commands/patch/lint-ignore.js +3 -2
  52. package/dist/src/commands/patch/move-files-create.js +22 -4
  53. package/dist/src/commands/patch/move-files-into.js +14 -10
  54. package/dist/src/commands/patch/move-files.js +6 -5
  55. package/dist/src/commands/patch/rename.d.ts +2 -2
  56. package/dist/src/commands/patch/rename.js +152 -51
  57. package/dist/src/commands/patch/reorder.d.ts +9 -0
  58. package/dist/src/commands/patch/reorder.js +14 -10
  59. package/dist/src/commands/patch/split-plan.d.ts +13 -6
  60. package/dist/src/commands/patch/split-plan.js +22 -12
  61. package/dist/src/commands/patch/split.d.ts +1 -1
  62. package/dist/src/commands/patch/split.js +23 -9
  63. package/dist/src/commands/patch/staged-dependency-validate.d.ts +15 -0
  64. package/dist/src/commands/patch/staged-dependency-validate.js +49 -0
  65. package/dist/src/commands/patch/staged-dependency.js +112 -14
  66. package/dist/src/commands/re-export-adjacent.d.ts +34 -0
  67. package/dist/src/commands/re-export-adjacent.js +105 -0
  68. package/dist/src/commands/re-export-bulk-scan.d.ts +13 -0
  69. package/dist/src/commands/re-export-bulk-scan.js +142 -2
  70. package/dist/src/commands/re-export-drift.d.ts +74 -0
  71. package/dist/src/commands/re-export-drift.js +234 -0
  72. package/dist/src/commands/re-export-files.js +5 -3
  73. package/dist/src/commands/re-export-lint.d.ts +97 -0
  74. package/dist/src/commands/re-export-lint.js +210 -0
  75. package/dist/src/commands/re-export-options.js +11 -0
  76. package/dist/src/commands/re-export-register.d.ts +18 -0
  77. package/dist/src/commands/re-export-register.js +61 -0
  78. package/dist/src/commands/re-export-scan.js +13 -7
  79. package/dist/src/commands/re-export-single.d.ts +20 -0
  80. package/dist/src/commands/re-export-single.js +261 -0
  81. package/dist/src/commands/re-export.d.ts +0 -4
  82. package/dist/src/commands/re-export.js +123 -289
  83. package/dist/src/commands/rebase/abort.js +13 -4
  84. package/dist/src/commands/rebase/continue.js +11 -4
  85. package/dist/src/commands/rebase/index.js +19 -29
  86. package/dist/src/commands/rebase/patch-loop.js +12 -0
  87. package/dist/src/commands/reset.js +4 -23
  88. package/dist/src/commands/resolve.js +2 -9
  89. package/dist/src/commands/run.js +29 -23
  90. package/dist/src/commands/source.js +3 -9
  91. package/dist/src/commands/status-check.d.ts +48 -0
  92. package/dist/src/commands/status-check.js +106 -0
  93. package/dist/src/commands/status-json.d.ts +31 -0
  94. package/dist/src/commands/status-json.js +76 -0
  95. package/dist/src/commands/status-output.d.ts +1 -0
  96. package/dist/src/commands/status-output.js +10 -1
  97. package/dist/src/commands/status-ownership.d.ts +52 -0
  98. package/dist/src/commands/status-ownership.js +57 -0
  99. package/dist/src/commands/status.js +190 -170
  100. package/dist/src/commands/test-build-phase.d.ts +19 -0
  101. package/dist/src/commands/test-build-phase.js +168 -0
  102. package/dist/src/commands/test-diagnose.js +30 -2
  103. package/dist/src/commands/test-modes.d.ts +9 -1
  104. package/dist/src/commands/test-modes.js +46 -13
  105. package/dist/src/commands/test-register.js +59 -27
  106. package/dist/src/commands/test-run.d.ts +29 -3
  107. package/dist/src/commands/test-run.js +59 -7
  108. package/dist/src/commands/test-verdict.d.ts +32 -0
  109. package/dist/src/commands/test-verdict.js +48 -0
  110. package/dist/src/commands/test.d.ts +9 -0
  111. package/dist/src/commands/test.js +100 -123
  112. package/dist/src/commands/token-coverage.js +4 -9
  113. package/dist/src/commands/token.js +4 -1
  114. package/dist/src/commands/tree.d.ts +19 -0
  115. package/dist/src/commands/tree.js +320 -0
  116. package/dist/src/commands/verify.js +2 -2
  117. package/dist/src/commands/watch.js +22 -21
  118. package/dist/src/commands/wire.js +1 -1
  119. package/dist/src/core/ast-utils.d.ts +11 -0
  120. package/dist/src/core/ast-utils.js +11 -0
  121. package/dist/src/core/build-audit-platform.js +6 -1
  122. package/dist/src/core/build-audit-registration.js +2 -0
  123. package/dist/src/core/build-audit-resolve.d.ts +13 -0
  124. package/dist/src/core/build-audit-resolve.js +34 -13
  125. package/dist/src/core/build-audit.js +4 -20
  126. package/dist/src/core/build-baseline-types.d.ts +29 -8
  127. package/dist/src/core/build-baseline-types.js +2 -1
  128. package/dist/src/core/build-baseline.d.ts +10 -2
  129. package/dist/src/core/build-baseline.js +39 -8
  130. package/dist/src/core/build-prepare.d.ts +4 -0
  131. package/dist/src/core/build-prepare.js +7 -1
  132. package/dist/src/core/config-paths.d.ts +1 -1
  133. package/dist/src/core/config-paths.js +2 -0
  134. package/dist/src/core/config-validate-test-toolchains.js +3 -2
  135. package/dist/src/core/config-validate.js +45 -30
  136. package/dist/src/core/config.js +16 -3
  137. package/dist/src/core/coverage-extend.d.ts +89 -0
  138. package/dist/src/core/coverage-extend.js +188 -0
  139. package/dist/src/core/destructive.js +3 -1
  140. package/dist/src/core/discard-baseline.js +18 -4
  141. package/dist/src/core/engine-precondition.d.ts +35 -0
  142. package/dist/src/core/engine-precondition.js +50 -0
  143. package/dist/src/core/engine-session-lock.d.ts +38 -1
  144. package/dist/src/core/engine-session-lock.js +78 -4
  145. package/dist/src/core/file-lock.d.ts +7 -0
  146. package/dist/src/core/file-lock.js +20 -30
  147. package/dist/src/core/firefox.js +5 -2
  148. package/dist/src/core/furnace-apply-helpers.d.ts +12 -1
  149. package/dist/src/core/furnace-apply-helpers.js +12 -3
  150. package/dist/src/core/furnace-apply-output.js +9 -0
  151. package/dist/src/core/furnace-apply-overwrite-warn.d.ts +63 -0
  152. package/dist/src/core/furnace-apply-overwrite-warn.js +123 -0
  153. package/dist/src/core/furnace-apply-undeploy-actions.d.ts +11 -0
  154. package/dist/src/core/furnace-apply-undeploy-actions.js +49 -0
  155. package/dist/src/core/furnace-apply.d.ts +16 -4
  156. package/dist/src/core/furnace-apply.js +39 -43
  157. package/dist/src/core/furnace-config.js +2 -0
  158. package/dist/src/core/furnace-css-fragments.js +2 -0
  159. package/dist/src/core/furnace-jsconfig.js +14 -7
  160. package/dist/src/core/furnace-registration-ast.js +8 -8
  161. package/dist/src/core/furnace-registration-remove.js +6 -3
  162. package/dist/src/core/furnace-registration.js +9 -12
  163. package/dist/src/core/furnace-rollback.js +1 -1
  164. package/dist/src/core/furnace-scanner.js +2 -1
  165. package/dist/src/core/furnace-stale-export.js +2 -1
  166. package/dist/src/core/furnace-state-persist.d.ts +4 -6
  167. package/dist/src/core/furnace-validate-compatibility.js +4 -7
  168. package/dist/src/core/furnace-validate-helpers.js +33 -1
  169. package/dist/src/core/furnace-validate-registration.js +16 -0
  170. package/dist/src/core/furnace-validate.js +4 -2
  171. package/dist/src/core/git-diff.js +37 -2
  172. package/dist/src/core/lint-cache.d.ts +15 -5
  173. package/dist/src/core/lint-cache.js +49 -14
  174. package/dist/src/core/mach-build-artifacts.d.ts +63 -1
  175. package/dist/src/core/mach-build-artifacts.js +108 -2
  176. package/dist/src/core/mach-resource-shim.js +1 -0
  177. package/dist/src/core/marionette-port.d.ts +16 -0
  178. package/dist/src/core/marionette-port.js +93 -8
  179. package/dist/src/core/marionette-preflight.js +6 -5
  180. package/dist/src/core/patch-apply.d.ts +20 -13
  181. package/dist/src/core/patch-apply.js +32 -33
  182. package/dist/src/core/patch-export.d.ts +2 -2
  183. package/dist/src/core/patch-export.js +4 -6
  184. package/dist/src/core/patch-lint-checkjs.d.ts +42 -2
  185. package/dist/src/core/patch-lint-checkjs.js +132 -8
  186. package/dist/src/core/patch-lint-chrome-jsdoc.js +9 -0
  187. package/dist/src/core/patch-lint-creators.d.ts +30 -0
  188. package/dist/src/core/patch-lint-creators.js +45 -0
  189. package/dist/src/core/patch-lint-cross.d.ts +1 -17
  190. package/dist/src/core/patch-lint-cross.js +16 -32
  191. package/dist/src/core/patch-lint-css.js +64 -16
  192. package/dist/src/core/patch-lint-diff.d.ts +8 -0
  193. package/dist/src/core/patch-lint-diff.js +31 -0
  194. package/dist/src/core/patch-lint-jsdoc.d.ts +3 -1
  195. package/dist/src/core/patch-lint-jsdoc.js +15 -5
  196. package/dist/src/core/patch-lint-module-registration.d.ts +15 -0
  197. package/dist/src/core/patch-lint-module-registration.js +88 -0
  198. package/dist/src/core/patch-lint-mozbuild.js +99 -11
  199. package/dist/src/core/patch-lint-ownership.d.ts +21 -0
  200. package/dist/src/core/patch-lint-ownership.js +30 -0
  201. package/dist/src/core/patch-lint-reexports.d.ts +2 -1
  202. package/dist/src/core/patch-lint-reexports.js +1 -0
  203. package/dist/src/core/patch-lint.d.ts +31 -0
  204. package/dist/src/core/patch-lint.js +114 -8
  205. package/dist/src/core/patch-lock.d.ts +12 -1
  206. package/dist/src/core/patch-lock.js +40 -4
  207. package/dist/src/core/patch-manifest-consistency.js +15 -3
  208. package/dist/src/core/patch-manifest.d.ts +1 -1
  209. package/dist/src/core/patch-parse.d.ts +8 -0
  210. package/dist/src/core/patch-parse.js +10 -0
  211. package/dist/src/core/patch-policy.d.ts +7 -0
  212. package/dist/src/core/patch-policy.js +4 -1
  213. package/dist/src/core/patch-transform.d.ts +10 -0
  214. package/dist/src/core/patch-transform.js +13 -2
  215. package/dist/src/core/rebase-session.d.ts +44 -2
  216. package/dist/src/core/rebase-session.js +114 -17
  217. package/dist/src/core/register-browser-content.js +2 -1
  218. package/dist/src/core/register-shared-css.js +2 -1
  219. package/dist/src/core/smoke-patterns.d.ts +24 -16
  220. package/dist/src/core/smoke-patterns.js +26 -14
  221. package/dist/src/core/status-binary.d.ts +55 -0
  222. package/dist/src/core/status-binary.js +124 -0
  223. package/dist/src/core/status-classify.d.ts +22 -1
  224. package/dist/src/core/status-classify.js +112 -70
  225. package/dist/src/core/test-harness-crash.d.ts +32 -1
  226. package/dist/src/core/test-harness-crash.js +59 -4
  227. package/dist/src/core/test-path-scope.js +2 -0
  228. package/dist/src/core/test-stale-check.js +1 -20
  229. package/dist/src/core/token-category.d.ts +40 -0
  230. package/dist/src/core/token-category.js +264 -0
  231. package/dist/src/core/token-manager.d.ts +7 -0
  232. package/dist/src/core/token-manager.js +48 -196
  233. package/dist/src/core/toolchain-preflight.js +24 -26
  234. package/dist/src/core/tree-cow.d.ts +18 -0
  235. package/dist/src/core/tree-cow.js +71 -0
  236. package/dist/src/core/tree-guard.d.ts +35 -0
  237. package/dist/src/core/tree-guard.js +179 -0
  238. package/dist/src/core/tree-store.d.ts +129 -0
  239. package/dist/src/core/tree-store.js +452 -0
  240. package/dist/src/core/typecheck-shim.d.ts +14 -0
  241. package/dist/src/core/typecheck-shim.js +41 -2
  242. package/dist/src/core/typecheck.js +2 -1
  243. package/dist/src/core/wire-destroy.js +14 -3
  244. package/dist/src/core/wire-init.js +37 -40
  245. package/dist/src/core/wire-subscript.js +11 -3
  246. package/dist/src/core/wire-utils.js +4 -4
  247. package/dist/src/core/xpcshell-appdir.js +3 -0
  248. package/dist/src/core/xpcshell-profile-dir.d.ts +12 -0
  249. package/dist/src/core/xpcshell-profile-dir.js +45 -0
  250. package/dist/src/errors/base.d.ts +13 -0
  251. package/dist/src/errors/base.js +13 -0
  252. package/dist/src/errors/git.d.ts +8 -0
  253. package/dist/src/errors/git.js +17 -0
  254. package/dist/src/errors/rebase.d.ts +13 -0
  255. package/dist/src/errors/rebase.js +17 -0
  256. package/dist/src/index.d.ts +2 -0
  257. package/dist/src/index.js +1 -0
  258. package/dist/src/types/commands/options.d.ts +119 -3
  259. package/dist/src/types/commands/patches.d.ts +8 -0
  260. package/dist/src/types/config.d.ts +19 -0
  261. package/dist/src/types/furnace.d.ts +7 -0
  262. package/dist/src/utils/build-info.d.ts +31 -0
  263. package/dist/src/utils/build-info.js +98 -0
  264. package/dist/src/utils/concurrency.d.ts +8 -0
  265. package/dist/src/utils/concurrency.js +21 -0
  266. package/dist/src/utils/errors.d.ts +39 -0
  267. package/dist/src/utils/errors.js +55 -0
  268. package/dist/src/utils/fs.js +5 -12
  269. package/dist/src/utils/logger.d.ts +14 -2
  270. package/dist/src/utils/logger.js +43 -13
  271. package/dist/src/utils/options.d.ts +21 -4
  272. package/dist/src/utils/options.js +26 -6
  273. package/dist/src/utils/package-root.js +7 -4
  274. package/dist/src/utils/process-group.js +13 -2
  275. package/dist/src/utils/stdio-drain.d.ts +28 -0
  276. package/dist/src/utils/stdio-drain.js +54 -0
  277. package/dist/src/utils/validation.d.ts +33 -2
  278. package/dist/src/utils/validation.js +57 -2
  279. package/package.json +13 -13
@@ -5,15 +5,17 @@ import { getProjectPaths, loadConfig } from '../core/config.js';
5
5
  import { collectFurnaceManagedPrefixes } from '../core/furnace-config.js';
6
6
  import { getHead, getStatusWithCodes, isGitRepository, isMissingHeadError } from '../core/git.js';
7
7
  import { getUntrackedFilesInDir, resolveMaxUntrackedFilesPerDir } from '../core/git-status.js';
8
- import { buildOwnershipTable, renderOwnershipTable } from '../core/ownership-table.js';
9
- import { buildPatchQueueContext, collectNewFileCreatorsByPath } from '../core/patch-lint.js';
8
+ import { renderOwnershipTable } from '../core/ownership-table.js';
10
9
  import { loadPatchesManifest } from '../core/patch-manifest.js';
11
- import { classifyFiles, } from '../core/status-classify.js';
12
- import { CommandError, GeneralError } from '../errors/base.js';
10
+ import { classifyFiles } from '../core/status-classify.js';
11
+ import { CommandError, GeneralError, InvalidArgumentError } from '../errors/base.js';
13
12
  import { ExitCode } from '../errors/codes.js';
14
13
  import { FIREFORGE_TMP_PATH_PATTERN, pathExists } from '../utils/fs.js';
15
14
  import { info, intro, isMachineOutputMode, outro, setMachineOutputMode, warn, } from '../utils/logger.js';
15
+ import { resolveStatusCheckPolicy, runStatusCheck } from './status-check.js';
16
+ import { renderJsonStatus, renderJsonSummaryStatus } from './status-json.js';
16
17
  import { renderDefaultStatus, renderTestCoverageStatus, renderUnmanagedOnly, } from './status-output.js';
18
+ import { buildOwnershipJsonBlock, collectOwnershipRows, summarizeOwnership, } from './status-ownership.js';
17
19
  /**
18
20
  * Renders raw worktree status as machine-parseable porcelain-style output.
19
21
  * Each line is: STATUS<tab>FILE
@@ -86,40 +88,52 @@ function filterFireForgeTempFiles(files) {
86
88
  /**
87
89
  * Renders classified file status as machine-readable JSON to stdout.
88
90
  */
89
- async function renderJsonStatus(files, paths, projectRoot, binaryName) {
91
+ async function classifyStatusFiles(files, paths, projectRoot, binaryName) {
90
92
  const furnacePrefixes = await collectFurnaceManagedPrefixes(projectRoot);
91
- const classified = await classifyFiles(files, paths.engine, paths.patches, binaryName, furnacePrefixes);
92
- const outputFiles = classified.map((f) => {
93
- const entry = {
94
- file: f.file,
95
- status: f.status.trim(),
96
- classification: f.classification,
97
- };
98
- if (f.classification === 'conflict' && f.claimedBy && f.claimedBy.length > 0) {
99
- entry.claimedBy = [...f.claimedBy];
100
- }
101
- return entry;
102
- });
103
- const byClassification = {
104
- unmanaged: 0,
105
- 'patch-owned-drift': 0,
106
- 'patch-backed': 0,
107
- branding: 0,
108
- furnace: 0,
109
- conflict: 0,
110
- };
111
- for (const file of outputFiles) {
112
- byClassification[file.classification]++;
93
+ return classifyFiles(files, paths.engine, paths.patches, binaryName, furnacePrefixes);
94
+ }
95
+ /**
96
+ * The one worktree scan every classifying mode shares: porcelain status,
97
+ * collapsed-directory expansion, atomic-temp-file filtering, and the
98
+ * truncation banner. `--ownership` used to carry its own copy of this
99
+ * block (FORGE L3); a non-git engine degrades to an empty list there,
100
+ * which is why the git probe is a parameter rather than a hard guard.
101
+ * @param engineDir - Path to the engine directory
102
+ * @param requireGitRepository - When false, a non-git engine yields `[]`
103
+ * instead of being probed (the historical `--ownership` behavior)
104
+ */
105
+ async function scanEngineStatusFiles(engineDir, requireGitRepository) {
106
+ if (!requireGitRepository && !(await isGitRepository(engineDir))) {
107
+ return [];
113
108
  }
114
- const output = {
115
- schemaVersion: 1,
116
- summary: {
117
- total: outputFiles.length,
118
- byClassification,
119
- },
120
- files: outputFiles,
121
- };
122
- process.stdout.write(JSON.stringify(output, null, 2) + '\n');
109
+ const { entries, truncations } = await expandDirectoryEntries(await getStatusWithCodes(engineDir), engineDir);
110
+ renderTruncationBanner(truncations);
111
+ return filterFireForgeTempFiles(entries);
112
+ }
113
+ /**
114
+ * Emits the `--json` payload (full or `--summary`) and applies the
115
+ * `--check` policy, which stays the SOLE exit driver on this path.
116
+ *
117
+ * `--include-ownership` (FORGE L3) is a MODIFIER, not a mode: it appends an
118
+ * ownership block without touching exit semantics, so an ownership conflict
119
+ * still fails only the human `--ownership` mode.
120
+ * @param classified - Classified worktree entries
121
+ * @param files - The same entries pre-classification, for ownership rows
122
+ * @param paths - Resolved project paths
123
+ * @param options - Status display options
124
+ * @param checkPolicy - Resolved `--check`/`--fail-on` policy
125
+ */
126
+ async function renderJsonMode(classified, files, paths, options, checkPolicy) {
127
+ const ownership = options.includeOwnership === true
128
+ ? buildOwnershipJsonBlock(await collectOwnershipRows(paths.patches, (await loadPatchesManifest(paths.patches))?.patches ?? [], files, classified))
129
+ : undefined;
130
+ if (options.summary === true) {
131
+ renderJsonSummaryStatus(classified, checkPolicy, ownership);
132
+ }
133
+ else {
134
+ renderJsonStatus(classified, ownership);
135
+ }
136
+ runStatusCheck(classified, checkPolicy);
123
137
  }
124
138
  /**
125
139
  * Detects the "unborn HEAD" aftermath of an interrupted `fireforge download`
@@ -170,149 +184,151 @@ export async function statusCommand(projectRoot, options = {}) {
170
184
  if (shouldUseMachineOutput) {
171
185
  setMachineOutputMode(true);
172
186
  }
173
- try {
174
- const modeCount = [
175
- options.raw,
176
- options.unmanaged,
177
- options.ownership,
178
- options.testCoverage,
179
- options.json,
180
- ].filter((v) => v === true).length;
181
- if (modeCount > 1) {
182
- throw new GeneralError('Cannot use --raw, --unmanaged, --ownership, --test-coverage, and --json together. Pick at most one.');
183
- }
184
- if (!options.raw && !options.json) {
185
- intro('FireForge Status');
186
- }
187
- // Test-coverage mode needs only the project root (the baseline lives
188
- // in .fireforge/), so it short-circuits before any engine/git guards.
189
- if (options.testCoverage) {
190
- renderTestCoverageStatus(await readBuildBaseline(projectRoot));
191
- return;
192
- }
193
- const paths = getProjectPaths(projectRoot);
194
- const config = await loadConfig(projectRoot);
195
- const emitJsonError = (code, message) => {
196
- process.stdout.write(JSON.stringify({ schemaVersion: 1, error: message, code }) + '\n');
197
- throw new CommandError(ExitCode.GENERAL_ERROR);
198
- };
199
- // Ownership mode is a flat file→patch table; sources are the manifest's
200
- // filesAffected, any worktree drift, and the cross-patch
201
- // duplicate-new-file-creation map produced by walking each patch
202
- // body. The latter is the alignment fix between `status --ownership`
203
- // and `fireforge verify` — see buildOwnershipTable's header comment.
204
- // Runs before the default classify path so we can short-circuit
205
- // without computing patch-backed state.
206
- if (options.ownership) {
207
- if (!(await pathExists(paths.engine))) {
208
- throw new GeneralError('Firefox source not found. Run "fireforge download" first.');
209
- }
210
- const manifest = await loadPatchesManifest(paths.patches);
211
- const ownershipExpansion = (await isGitRepository(paths.engine))
212
- ? await expandDirectoryEntries(await getStatusWithCodes(paths.engine), paths.engine)
213
- : { entries: [], truncations: [] };
214
- // Filter atomic-write temp files (Finding #18) so a mid-flight
215
- // `.fireforge-tmp-<pid>-<uuid>` artefact never shows up in any
216
- // status mode. The pattern is tight enough to let legitimately
217
- // similar names through.
218
- const rawFilesOwnership = filterFireForgeTempFiles(ownershipExpansion.entries);
219
- renderTruncationBanner(ownershipExpansion.truncations);
220
- // Only walk the patch bodies when the directory actually exists.
221
- // Fresh projects with no patch queue yet pass through with an empty
222
- // creators map, which degrades to the old filesAffected-only
223
- // behavior for the empty case.
224
- const newFileCreatorsByPath = (await pathExists(paths.patches))
225
- ? collectNewFileCreatorsByPath(await buildPatchQueueContext(paths.patches))
226
- : new Map();
227
- const rows = buildOwnershipTable(manifest?.patches ?? [], rawFilesOwnership, newFileCreatorsByPath, new Map((await classifyFiles(rawFilesOwnership, paths.engine, paths.patches, config.binaryName, await collectFurnaceManagedPrefixes(projectRoot))).map((entry) => [entry.file, entry.classification])));
228
- renderOwnershipTable(rows);
229
- const conflictCount = rows.filter((r) => r.conflict).length;
230
- const unmanagedCount = rows.filter((r) => r.unmanaged).length;
231
- const managedCount = rows.filter((r) => !r.unmanaged).length;
232
- const parts = [`${managedCount} managed`];
233
- if (conflictCount > 0)
234
- parts.push(`${conflictCount} conflict${conflictCount === 1 ? '' : 's'}`);
235
- if (unmanagedCount > 0)
236
- parts.push(`${unmanagedCount} unmanaged`);
237
- outro(parts.join(', '));
238
- if (conflictCount > 0) {
239
- throw new GeneralError(`${conflictCount} path(s) are claimed by more than one patch. ` +
240
- 'Run "fireforge verify" for full details, then use "re-export --files" or ' +
241
- '"patch delete" to resolve.');
242
- }
243
- return;
244
- }
245
- // Check if engine exists
187
+ // On a throw, machine mode deliberately stays ENGAGED: withErrorHandling
188
+ // routes the styled error to stderr while the mode is on and resets it
189
+ // centrally (a mid-throw restore here put the refusal on stdout after the
190
+ // JSON payload, breaking the 0.40.0 stderr promise).
191
+ await runStatusCommandBody(projectRoot, options);
192
+ if (isMachineOutputMode() !== previousMachineOutputMode) {
193
+ setMachineOutputMode(previousMachineOutputMode);
194
+ }
195
+ }
196
+ /** The status body proper; the machine-mode lifecycle lives in {@link statusCommand}. */
197
+ async function runStatusCommandBody(projectRoot, options) {
198
+ const modeCount = [
199
+ options.raw,
200
+ options.unmanaged,
201
+ options.ownership,
202
+ options.testCoverage,
203
+ options.json,
204
+ ].filter((v) => v === true).length;
205
+ if (modeCount > 1) {
206
+ throw new GeneralError('Cannot use --raw, --unmanaged, --ownership, --test-coverage, and --json together. Pick at most one.');
207
+ }
208
+ // --summary elides the files[] payload for gate consumers (FORGE K8);
209
+ // it only makes sense on the JSON shape.
210
+ if (options.summary === true && options.json !== true) {
211
+ throw new InvalidArgumentError('--summary requires --json.', '--summary');
212
+ }
213
+ // --include-ownership adds a block to the JSON payload; it is meaningless
214
+ // on the human views, where --ownership is the mode that renders the
215
+ // table (FORGE L3).
216
+ if (options.includeOwnership === true && options.json !== true) {
217
+ throw new InvalidArgumentError('--include-ownership requires --json. Use --ownership for the human table.', '--include-ownership');
218
+ }
219
+ // --check / --fail-on enforcement policy (FORGE G1). Applies only
220
+ // where classification runs (the default view and --json).
221
+ const checkPolicy = resolveStatusCheckPolicy(options);
222
+ if (!options.raw && !options.json) {
223
+ intro('FireForge Status');
224
+ }
225
+ // Test-coverage mode needs only the project root (the baseline lives
226
+ // in .fireforge/), so it short-circuits before any engine/git guards.
227
+ if (options.testCoverage) {
228
+ renderTestCoverageStatus(await readBuildBaseline(projectRoot));
229
+ return;
230
+ }
231
+ const paths = getProjectPaths(projectRoot);
232
+ const config = await loadConfig(projectRoot);
233
+ const emitJsonError = (code, message) => {
234
+ process.stdout.write(JSON.stringify({ schemaVersion: 1, error: message, code }) + '\n');
235
+ throw new CommandError(ExitCode.GENERAL_ERROR);
236
+ };
237
+ // Ownership mode is a flat file→patch table; sources are the manifest's
238
+ // filesAffected, any worktree drift, and the cross-patch
239
+ // duplicate-new-file-creation map produced by walking each patch
240
+ // body. The latter is the alignment fix between `status --ownership`
241
+ // and `fireforge verify` — see buildOwnershipTable's header comment.
242
+ // Runs before the default classify path so we can short-circuit
243
+ // without computing patch-backed state.
244
+ if (options.ownership) {
246
245
  if (!(await pathExists(paths.engine))) {
247
- if (options.json) {
248
- emitJsonError('engine-missing', 'Firefox source not found. Run "fireforge download" first.');
249
- }
250
246
  throw new GeneralError('Firefox source not found. Run "fireforge download" first.');
251
247
  }
252
- // Check if it's a git repository
253
- if (!(await isGitRepository(paths.engine))) {
254
- if (options.json) {
255
- emitJsonError('engine-not-git', 'Engine directory is not a git repository. Run "fireforge download" to initialize.');
256
- }
257
- throw new GeneralError('Engine directory is not a git repository. Run "fireforge download" to initialize.');
248
+ const manifest = await loadPatchesManifest(paths.patches);
249
+ // Same scan and the SAME classification pass as every other mode
250
+ // (FORGE L3). This branch keeps its historical guard shape on purpose:
251
+ // no baseline-commit assertion, and a non-git engine degrades to an
252
+ // empty table rather than the recovery banner.
253
+ const rawFilesOwnership = await scanEngineStatusFiles(paths.engine, false);
254
+ const rows = await collectOwnershipRows(paths.patches, manifest?.patches ?? [], rawFilesOwnership, await classifyStatusFiles(rawFilesOwnership, paths, projectRoot, config.binaryName));
255
+ renderOwnershipTable(rows);
256
+ const { managed, unmanaged, conflicts } = summarizeOwnership(rows);
257
+ const parts = [`${managed} managed`];
258
+ if (conflicts > 0)
259
+ parts.push(`${conflicts} conflict${conflicts === 1 ? '' : 's'}`);
260
+ if (unmanaged > 0)
261
+ parts.push(`${unmanaged} unmanaged`);
262
+ outro(parts.join(', '));
263
+ if (conflicts > 0) {
264
+ throw new GeneralError(`${conflicts} path(s) are claimed by more than one patch. ` +
265
+ 'Run "fireforge verify" for full details, then use "re-export --files" or ' +
266
+ '"patch delete" to resolve.');
258
267
  }
259
- await assertEngineHasBaselineCommit(paths.engine, options);
260
- const rawFiles = await getStatusWithCodes(paths.engine);
261
- const { entries: expanded, truncations } = await expandDirectoryEntries(rawFiles, paths.engine);
262
- // Strip atomic-write temp files (Finding #18) before every mode
263
- // branch so raw / unmanaged / default / json all agree.
264
- const files = filterFireForgeTempFiles(expanded);
265
- renderTruncationBanner(truncations);
266
- // `--json` callers expect machine-parseable output on every invocation,
267
- // including the clean-tree case. Before this ordering fix a clean tree
268
- // printed "No modified files" / "Working tree clean" via the human
269
- // branch below and `--json` was silently ignored, so scripts that piped
270
- // the output through a JSON parser broke precisely when there was
271
- // nothing to report. Emit `[]` here and return before the human fallback.
268
+ return;
269
+ }
270
+ // Check if engine exists
271
+ if (!(await pathExists(paths.engine))) {
272
272
  if (options.json) {
273
- await renderJsonStatus(files, paths, projectRoot, config.binaryName);
274
- return;
275
- }
276
- // `--raw` consumers parse the native `git status --porcelain` output
277
- // directly. On a clean tree the raw mode should produce nothing on
278
- // stdout — the human "Working tree clean" banner would contaminate the
279
- // pipe. Short-circuit before the human clean-tree branch below.
280
- if (options.raw && files.length === 0) {
281
- return;
282
- }
283
- if (files.length === 0) {
284
- info('No modified files');
285
- outro('Working tree clean');
286
- return;
287
- }
288
- // Raw mode: existing behavior
289
- if (options.raw) {
290
- renderRawStatus(files);
291
- return;
292
- }
293
- // Patch-aware classification
294
- const furnacePrefixes = await collectFurnaceManagedPrefixes(projectRoot);
295
- const classified = await classifyFiles(files, paths.engine, paths.patches, config.binaryName, furnacePrefixes);
296
- const buckets = {
297
- conflict: classified.filter((f) => f.classification === 'conflict'),
298
- unmanaged: classified.filter((f) => f.classification === 'unmanaged'),
299
- patchOwnedDrift: classified.filter((f) => f.classification === 'patch-owned-drift'),
300
- patchBacked: classified.filter((f) => f.classification === 'patch-backed'),
301
- branding: classified.filter((f) => f.classification === 'branding'),
302
- furnace: classified.filter((f) => f.classification === 'furnace'),
303
- };
304
- // --unmanaged mode: only show unmanaged
305
- if (options.unmanaged) {
306
- await renderUnmanagedOnly(buckets.unmanaged, files.length, projectRoot, config.binaryName);
307
- return;
273
+ emitJsonError('engine-missing', 'Firefox source not found. Run "fireforge download" first.');
308
274
  }
309
- await renderDefaultStatus(files.length, buckets, projectRoot, config.binaryName);
275
+ throw new GeneralError('Firefox source not found. Run "fireforge download" first.');
310
276
  }
311
- finally {
312
- if (isMachineOutputMode() !== previousMachineOutputMode) {
313
- setMachineOutputMode(previousMachineOutputMode);
277
+ // Check if it's a git repository
278
+ if (!(await isGitRepository(paths.engine))) {
279
+ if (options.json) {
280
+ emitJsonError('engine-not-git', 'Engine directory is not a git repository. Run "fireforge download" to initialize.');
314
281
  }
282
+ throw new GeneralError('Engine directory is not a git repository. Run "fireforge download" to initialize.');
283
+ }
284
+ await assertEngineHasBaselineCommit(paths.engine, options);
285
+ const files = await scanEngineStatusFiles(paths.engine, true);
286
+ // `--json` callers expect machine-parseable output on every invocation,
287
+ // including the clean-tree case. Before this ordering fix a clean tree
288
+ // printed "No modified files" / "Working tree clean" via the human
289
+ // branch below and `--json` was silently ignored, so scripts that piped
290
+ // the output through a JSON parser broke precisely when there was
291
+ // nothing to report. Emit `[]` here and return before the human fallback.
292
+ if (options.json) {
293
+ const classified = await classifyStatusFiles(files, paths, projectRoot, config.binaryName);
294
+ await renderJsonMode(classified, files, paths, options, checkPolicy);
295
+ return;
296
+ }
297
+ // `--raw` consumers parse the native `git status --porcelain` output
298
+ // directly. On a clean tree the raw mode should produce nothing on
299
+ // stdout — the human "Working tree clean" banner would contaminate the
300
+ // pipe. Short-circuit before the human clean-tree branch below.
301
+ if (options.raw && files.length === 0) {
302
+ return;
303
+ }
304
+ if (files.length === 0) {
305
+ info('No modified files');
306
+ outro('Working tree clean');
307
+ return;
308
+ }
309
+ // Raw mode: existing behavior
310
+ if (options.raw) {
311
+ renderRawStatus(files);
312
+ return;
313
+ }
314
+ // Patch-aware classification
315
+ const classified = await classifyStatusFiles(files, paths, projectRoot, config.binaryName);
316
+ const buckets = {
317
+ conflict: classified.filter((f) => f.classification === 'conflict'),
318
+ unmanaged: classified.filter((f) => f.classification === 'unmanaged'),
319
+ patchOwnedDrift: classified.filter((f) => f.classification === 'patch-owned-drift'),
320
+ patchBacked: classified.filter((f) => f.classification === 'patch-backed'),
321
+ branding: classified.filter((f) => f.classification === 'branding'),
322
+ furnace: classified.filter((f) => f.classification === 'furnace'),
323
+ binaryUnsupported: classified.filter((f) => f.classification === 'binary-unsupported'),
324
+ };
325
+ // --unmanaged mode: only show unmanaged
326
+ if (options.unmanaged) {
327
+ await renderUnmanagedOnly(buckets.unmanaged, files.length, projectRoot, config.binaryName);
328
+ return;
315
329
  }
330
+ await renderDefaultStatus(files.length, buckets, projectRoot, config.binaryName);
331
+ runStatusCheck(classified, checkPolicy);
316
332
  }
317
333
  /** Registers the status command on the CLI program. */
318
334
  export function registerStatus(program, { getProjectRoot, withErrorHandling }) {
@@ -324,6 +340,10 @@ export function registerStatus(program, { getProjectRoot, withErrorHandling }) {
324
340
  .option('--ownership', 'Show a flat path → owning patch table (flags files claimed by multiple patches)')
325
341
  .option('--test-coverage', 'Show what the last recorded build covers for test packaging (full or scoped paths)')
326
342
  .option('--json', 'Output classified file status as JSON')
343
+ .option('--summary', 'With --json: emit only summary counts (plus offending files when --check/--fail-on is active), omitting the per-file files[] payload')
344
+ .option('--include-ownership', 'With --json (composes with --summary): append an ownership block (path->owning-patch rows plus managed/unmanaged/conflict counts) to the payload, so one scan serves the classification, ownership, and check views. Does not change exit semantics.')
345
+ .option('--check', 'Exit non-zero when any unmanaged, patch-owned-drift, or conflict file exists (composes with --json; combine with --fail-on for finer policy)')
346
+ .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')
327
347
  .action(withErrorHandling(async (options) => {
328
348
  await statusCommand(getProjectRoot(), options);
329
349
  }));
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Pre-test build phase for `fireforge test`, split out of `test.ts` (at
3
+ * the per-file line budget): the incremental protected mach build with
4
+ * its baseline write, and the FORGE J9 `--build-only` union-build shape.
5
+ */
6
+ import type { getProjectPaths, loadConfig } from '../core/config.js';
7
+ import type { TestOptions } from '../types/commands/index.js';
8
+ import type { HarnessClassification } from './test-modes.js';
9
+ /**
10
+ * Runs the pre-test build (or the stale-build gate when no build was
11
+ * requested). Returns `true` when the run is COMPLETE — the `--build-only`
12
+ * union-build shape (FORGE J9), which packages every requested path
13
+ * (mixed harnesses legal, nothing dispatches), prints the per-harness
14
+ * next steps, and emits the run's single `PASS` verdict.
15
+ */
16
+ export declare function runTestBuildPhase(projectRoot: string, paths: ReturnType<typeof getProjectPaths>, projectConfig: Awaited<ReturnType<typeof loadConfig>>, harnessRetries: number, options: TestOptions, request: {
17
+ classification: HarnessClassification;
18
+ normalizedPaths: string[];
19
+ }): Promise<boolean>;
@@ -0,0 +1,168 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Pre-test build phase for `fireforge test`, split out of `test.ts` (at
4
+ * the per-file line budget): the incremental protected mach build with
5
+ * its baseline write, and the FORGE J9 `--build-only` union-build shape.
6
+ */
7
+ import { readBuildBaseline, writeBuildBaseline } from '../core/build-baseline.js';
8
+ import { prepareBuildEnvironment } from '../core/build-prepare.js';
9
+ import { checkExtendCoverageAnchor, checkExtendMozconfigAnchor, formatExtendCoverageRefusal, unionTestPackagingCoverage, } from '../core/coverage-extend.js';
10
+ import { runProtectedMachBuild, withBuildLock } from '../core/mach.js';
11
+ import { buildHarnessCrashMessage } from '../core/test-harness-crash.js';
12
+ import { GeneralError, InvalidArgumentError } from '../errors/base.js';
13
+ import { BuildError } from '../errors/build.js';
14
+ import { toError } from '../utils/errors.js';
15
+ import { info, spinner, verbose } from '../utils/logger.js';
16
+ import { reportBuildOnlyCompletion } from './test-modes.js';
17
+ import { enforceStaleBuildGate, enforceStaticComponentsGate } from './test-stale-gate.js';
18
+ import { emitPassVerdict } from './test-verdict.js';
19
+ async function runPreTestBuild(projectRoot, paths, projectConfig, harnessRetries, testPackagingCoverage, extend) {
20
+ await withBuildLock(projectRoot, async () => {
21
+ // Pass the previous baseline exactly like `fireforge build` does, so
22
+ // auto-configure runs under the same conditions on both paths. The
23
+ // pre-test build must never invalidate more of the objdir than a plain
24
+ // `mach build faster` would (field incident: a failed pre-test build
25
+ // was followed by a ~64-minute full rebuild where an incremental one
26
+ // should have sufficed).
27
+ const previousBaseline = await readBuildBaseline(projectRoot);
28
+ const preparation = await prepareBuildEnvironment(projectRoot, paths, projectConfig, {
29
+ previousBaseline,
30
+ });
31
+ // The mozconfig half of the --extend-coverage anchor (FORGE L1) can
32
+ // only be checked once prepareBuildEnvironment has regenerated
33
+ // engine/mozconfig — that is the file this build will configure with —
34
+ // and must still be checked before mach, so a refusal costs no build.
35
+ if (extend !== undefined) {
36
+ const mozconfigAnchor = await checkExtendMozconfigAnchor(paths.engine, previousBaseline);
37
+ if (!mozconfigAnchor.ok) {
38
+ throw new GeneralError(formatExtendCoverageRefusal(mozconfigAnchor));
39
+ }
40
+ }
41
+ const buildKind = preparation.fullBuildRequired ? 'full' : 'faster';
42
+ if (preparation.fullBuildRequired) {
43
+ info('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.');
44
+ }
45
+ const s = spinner(preparation.fullBuildRequired
46
+ ? 'Running required full build...'
47
+ : 'Running incremental build...');
48
+ // The pre-test build routes through the same protected mach dispatch as
49
+ // `fireforge build` / `build --ui`: in-venv resource-monitor guard plus
50
+ // the uniform recognized-crash retry budget, with a fresh mach process
51
+ // (and fresh guard install) per attempt. prepareBuildEnvironment runs
52
+ // ONCE, outside the retry loop — retries never re-run mach configure.
53
+ const result = await runProtectedMachBuild(buildKind, paths.engine, {
54
+ retries: harnessRetries,
55
+ onRetry: (signature, nextAttempt, maxAttempts) => {
56
+ s.message(`Pre-test build hit a harness crash (${signature.reason}); ` +
57
+ `retrying (attempt ${nextAttempt} of ${maxAttempts})...`);
58
+ },
59
+ });
60
+ if (result.exitCode === 0) {
61
+ s.stop('Build complete');
62
+ // Same record, same failure tolerance as `fireforge build` /
63
+ // `build --ui` (build.ts): a green pre-test build refreshes the
64
+ // stale-build baseline so a later plain `fireforge test` over the
65
+ // same files — in any invocation shape — is not refused. A failed
66
+ // write never fails the run. The coverage claim is scoped to the
67
+ // requested test paths, since a file-scoped `test --build` only
68
+ // guarantees packaging for those manifests. The previous baseline is
69
+ // passed through so a scoped write carries the static-components
70
+ // anchor forward (a `mach build faster` does not rebake
71
+ // components.conf into the compiled table).
72
+ try {
73
+ // Under --extend-coverage the claim is the UNION of the previous
74
+ // record and this build's paths, and the static-components anchor
75
+ // is always carried forward: that union can evaluate to 'full'
76
+ // while the build behind it was a scoped `mach build faster` that
77
+ // did not rebake the compiled table.
78
+ const recordedCoverage = extend !== undefined
79
+ ? unionTestPackagingCoverage(previousBaseline?.testPackagingCoverage, extend.requestedPaths)
80
+ : testPackagingCoverage;
81
+ await writeBuildBaseline(projectRoot, paths.engine, projectConfig.binaryName, recordedCoverage, previousBaseline, describeBuildInvocation(extend !== undefined, testPackagingCoverage), preparation.fullBuildRequired
82
+ ? 'refresh'
83
+ : extend !== undefined
84
+ ? 'carry-forward'
85
+ : 'auto');
86
+ }
87
+ catch (baselineError) {
88
+ verbose(`Could not persist build baseline: ${toError(baselineError).message}`);
89
+ }
90
+ return;
91
+ }
92
+ s.error('Pre-test build failed');
93
+ throw new BuildError(result.crashSignature
94
+ ? buildHarnessCrashMessage(result.crashSignature, result.attempts, 'mach build faster')
95
+ : 'Pre-test build failed', preparation.fullBuildRequired ? 'mach build' : 'mach build faster');
96
+ });
97
+ }
98
+ /**
99
+ * `--extend-coverage` only means something for a SCOPED build: it unions
100
+ * this build's paths onto the recorded claim. Without `--build`/
101
+ * `--build-only` there is no build to extend from, and a path-less build
102
+ * records `'full'`, which already covers everything — refusing both is more
103
+ * honest than silently ignoring the flag.
104
+ */
105
+ function assertExtendCoverageUsage(options, normalizedPaths) {
106
+ if (options.build !== true && options.buildOnly !== true) {
107
+ throw new InvalidArgumentError('--extend-coverage requires --build or --build-only: it unions the paths a build packages onto the recorded coverage claim.', '--extend-coverage');
108
+ }
109
+ if (normalizedPaths.length === 0) {
110
+ throw new InvalidArgumentError('--extend-coverage requires explicit test paths: a path-less build records full coverage, which already covers every path.', '--extend-coverage');
111
+ }
112
+ }
113
+ /** Renders the `recordedBy` invocation string surfaced by `status --test-coverage`. */
114
+ function describeBuildInvocation(extending, coverage) {
115
+ const extendFlag = extending ? ' --extend-coverage' : '';
116
+ return coverage === 'full'
117
+ ? `fireforge test --build${extendFlag}`
118
+ : `fireforge test --build${extendFlag} ${coverage.join(' ')}`;
119
+ }
120
+ /**
121
+ * Runs the pre-test build (or the stale-build gate when no build was
122
+ * requested). Returns `true` when the run is COMPLETE — the `--build-only`
123
+ * union-build shape (FORGE J9), which packages every requested path
124
+ * (mixed harnesses legal, nothing dispatches), prints the per-harness
125
+ * next steps, and emits the run's single `PASS` verdict.
126
+ */
127
+ export async function runTestBuildPhase(projectRoot, paths, projectConfig, harnessRetries, options, request) {
128
+ const { classification, normalizedPaths } = request;
129
+ const extending = options.extendCoverage === true;
130
+ if (extending) {
131
+ assertExtendCoverageUsage(options, normalizedPaths);
132
+ }
133
+ if (options.build || options.buildOnly) {
134
+ // A path-less `test --build` runs (and packages for) the full suite;
135
+ // a scoped invocation only vouches for the requested paths. A SCOPED
136
+ // rebuild also cannot regenerate the compiled StaticComponents table,
137
+ // so it runs the components.conf gate up-front (the path-less shape
138
+ // refreshes the anchor itself and skips it).
139
+ const coverage = normalizedPaths.length === 0 ? 'full' : normalizedPaths;
140
+ if (normalizedPaths.length > 0) {
141
+ const previousBaseline = await readBuildBaseline(projectRoot);
142
+ await enforceStaticComponentsGate(paths.engine, previousBaseline, options);
143
+ if (extending) {
144
+ // Head/fingerprint half of the anchor, before the build lock: a
145
+ // refusal here costs neither build time nor the lock.
146
+ const anchor = await checkExtendCoverageAnchor(paths.engine, previousBaseline);
147
+ if (!anchor.ok) {
148
+ throw new GeneralError(formatExtendCoverageRefusal(anchor));
149
+ }
150
+ }
151
+ }
152
+ await runPreTestBuild(projectRoot, paths, projectConfig, harnessRetries, coverage, extending ? { requestedPaths: normalizedPaths } : undefined);
153
+ info('');
154
+ if (options.buildOnly) {
155
+ // Union build for mixed harnesses (FORGE J9): the coverage claim
156
+ // above lists every requested path, so each harness half can run
157
+ // build-less against this packaging. Nothing dispatches here; the
158
+ // run still ends with exactly one verdict line.
159
+ reportBuildOnlyCompletion(classification, normalizedPaths);
160
+ emitPassVerdict();
161
+ return true;
162
+ }
163
+ }
164
+ else {
165
+ await enforceStaleBuildGate(projectRoot, paths.engine, options, normalizedPaths);
166
+ }
167
+ return false;
168
+ }