@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
@@ -2,12 +2,12 @@
2
2
  import { confirm } from '@clack/prompts';
3
3
  import { Command } from 'commander';
4
4
  import { getProjectPaths } from '../core/config.js';
5
+ import { assertEngineGitReady } from '../core/engine-precondition.js';
5
6
  import { clearAppliedFurnaceState } from '../core/furnace-config.js';
6
- import { getHead, hasChanges, isGitRepository, isMissingHeadError, resetChanges, } from '../core/git.js';
7
+ import { hasChanges, resetChanges } from '../core/git.js';
7
8
  import { expandUntrackedDirectoryEntries, getWorkingTreeStatus } from '../core/git-status.js';
8
9
  import { warnIfStaticComponentsStale } from '../core/test-stale-check.js';
9
- import { GeneralError, InvalidArgumentError } from '../errors/base.js';
10
- import { pathExists } from '../utils/fs.js';
10
+ import { InvalidArgumentError } from '../errors/base.js';
11
11
  import { cancel, info, intro, isCancel, outro, spinner, warn } from '../utils/logger.js';
12
12
  import { pickDefined } from '../utils/options.js';
13
13
  /**
@@ -18,26 +18,7 @@ import { pickDefined } from '../utils/options.js';
18
18
  export async function resetCommand(projectRoot, options) {
19
19
  intro('FireForge Reset');
20
20
  const paths = getProjectPaths(projectRoot);
21
- // Check if engine exists
22
- if (!(await pathExists(paths.engine))) {
23
- throw new GeneralError('Firefox source not found. Run "fireforge download" first.');
24
- }
25
- // Check if it's a git repository
26
- if (!(await isGitRepository(paths.engine))) {
27
- throw new GeneralError('Engine directory is not a git repository. Run "fireforge download" to initialize.');
28
- }
29
- // Unborn-HEAD guard (mirrors status): an interrupted download leaves a
30
- // repo with no baseline commit, where the entire ~300k-file tree reads
31
- // as untracked — a reset against that state is never what the operator
32
- // wants, and the guidance names the actual fix.
33
- try {
34
- await getHead(paths.engine);
35
- }
36
- catch (headError) {
37
- if (!isMissingHeadError(headError))
38
- throw headError;
39
- throw new GeneralError('Engine repository has no baseline commit yet — a previous "fireforge download" was interrupted before git created the initial Firefox source commit. Re-run "fireforge download --force" to recreate the baseline repository cleanly.');
40
- }
21
+ await assertEngineGitReady(paths.engine);
41
22
  // Check for changes
42
23
  const hasUncommittedChanges = await hasChanges(paths.engine);
43
24
  if (!hasUncommittedChanges) {
@@ -3,7 +3,7 @@ import { join } from 'node:path';
3
3
  import { confirm } from '@clack/prompts';
4
4
  import { Command } from 'commander';
5
5
  import { getProjectPaths, loadConfig, loadState, updateState } from '../core/config.js';
6
- import { isGitRepository } from '../core/git.js';
6
+ import { assertEngineGitReady } from '../core/engine-precondition.js';
7
7
  import { getStagedDiffForFiles } from '../core/git-diff.js';
8
8
  import { stageFiles, unstageFiles } from '../core/git-file-ops.js';
9
9
  import { extractAffectedFiles } from '../core/patch-apply.js';
@@ -30,14 +30,7 @@ export async function resolveCommand(projectRoot, options = {}) {
30
30
  }
31
31
  const { patchFilename } = state.pendingResolution;
32
32
  info(`Resolving conflict for patch: ${patchFilename}`);
33
- // Check if engine exists
34
- if (!(await pathExists(paths.engine))) {
35
- throw new GeneralError('Firefox source not found. Run "fireforge download" first.');
36
- }
37
- // Check if it's a git repository
38
- if (!(await isGitRepository(paths.engine))) {
39
- throw new GeneralError('Engine directory is not a git repository. Run "fireforge download" to initialize.');
40
- }
33
+ await assertEngineGitReady(paths.engine);
41
34
  // Non-interactive mode requires an explicit `--yes` to proceed: the
42
35
  // operator is asserting the manual merge is complete and the
43
36
  // refreshed diff is the one to record. Without `--yes`, an accidental
@@ -5,16 +5,17 @@ import { join } from 'node:path';
5
5
  import { Command } from 'commander';
6
6
  import { getProjectPaths, loadConfig } from '../core/config.js';
7
7
  import { warnIfFurnaceStale } from '../core/furnace-staleness.js';
8
- import { buildArtifactMismatchMessage, hasBuildArtifacts, hasRunnableBundle, run, runMachSmoke, } from '../core/mach.js';
9
- import { compileAllowlistFromFile, compileAllowlistFromStrings, matchesAllowlist, matchesSmokeError, } from '../core/smoke-patterns.js';
8
+ import { hasBuildArtifacts, hasRunnableBundle, run, runMachSmoke } from '../core/mach.js';
9
+ import { assertBuildArtifacts } from '../core/mach-build-artifacts.js';
10
+ import { compileAllowlistFromFile, compileAllowlistFromStrings, matchAllowlist, matchesSmokeError, } from '../core/smoke-patterns.js';
10
11
  import { GeneralError, InvalidArgumentError } from '../errors/base.js';
11
- import { AmbiguousBuildArtifactsError, BuildError } from '../errors/build.js';
12
+ import { BuildError } from '../errors/build.js';
12
13
  import { ExitCode } from '../errors/codes.js';
13
14
  import { SmokeRunError } from '../errors/run.js';
14
15
  import { toError } from '../utils/errors.js';
15
16
  import { pathExists, removeDir, removeFile } from '../utils/fs.js';
16
17
  import { info, intro, verbose, warn } from '../utils/logger.js';
17
- import { commanderArgParser, pickDefined } from '../utils/options.js';
18
+ import { commanderArgParser, pickDefined, stringListOption } from '../utils/options.js';
18
19
  /**
19
20
  * Exit code returned by smoke-run mode when the captured console stream
20
21
  * produced one or more error lines that did NOT match the operator's
@@ -81,20 +82,12 @@ export async function runCommand(projectRoot, options = {}) {
81
82
  throw new GeneralError('Firefox source not found. Run "fireforge download" first.');
82
83
  }
83
84
  const buildCheck = await hasBuildArtifacts(paths.engine);
84
- if (buildCheck.ambiguous && buildCheck.objDirs && buildCheck.objDirs.length > 0) {
85
- throw new AmbiguousBuildArtifactsError(buildCheck.objDirs);
86
- }
87
- const mismatchMessage = buildArtifactMismatchMessage(paths.engine, buildCheck, 'Run');
88
- if (mismatchMessage) {
89
- throw new GeneralError(mismatchMessage);
90
- }
91
- if (!buildCheck.exists) {
92
- const detail = buildCheck.objDir
93
- ? `Build artifacts incomplete in ${buildCheck.objDir}/`
94
- : 'No build artifacts found (obj-*/ directory missing)';
95
- throw new GeneralError(`Run requires a completed build. ${detail}\n\n` +
96
- "Run 'fireforge build' first, then rerun 'fireforge run'.");
97
- }
85
+ assertBuildArtifacts(paths.engine, buildCheck, {
86
+ label: 'Run',
87
+ requirement: 'Run requires a completed build.',
88
+ remediation: "Run 'fireforge build' first, then rerun 'fireforge run'.",
89
+ requireExisting: true,
90
+ });
98
91
  // `hasBuildArtifacts` only checks for an `obj-*/dist/` directory; a
99
92
  // build that configured but hasn't yet produced the launchable binary
100
93
  // (common in a long real Firefox compile that the operator stopped
@@ -186,6 +179,7 @@ async function runSmokeExit(engineDir, options) {
186
179
  const findings = [];
187
180
  let allowlistedErrorHits = 0;
188
181
  let allowlistedTotalHits = 0;
182
+ const allowlistHits = new Array(allowlist.length).fill(0);
189
183
  const handleLine = (stream, line) => {
190
184
  // Mirror raw output to the terminal so operators watching the smoke
191
185
  // run still see what the browser is printing. Stream selection on the
@@ -201,9 +195,11 @@ async function runSmokeExit(engineDir, options) {
201
195
  // total set of allowlisted lines (what the operator sees in the
202
196
  // console) and the subset that were error-class (what the smoke
203
197
  // exit contract cares about). The exit contract itself is unchanged.
204
- const isAllowlisted = allowlist.length > 0 && matchesAllowlist(line, allowlist);
198
+ const matchIndex = allowlist.length > 0 ? matchAllowlist(line, allowlist) : -1;
199
+ const isAllowlisted = matchIndex !== -1;
205
200
  if (isAllowlisted) {
206
201
  allowlistedTotalHits += 1;
202
+ allowlistHits[matchIndex] = (allowlistHits[matchIndex] ?? 0) + 1;
207
203
  }
208
204
  if (!matchesSmokeError(line))
209
205
  return;
@@ -249,6 +245,8 @@ async function runSmokeExit(engineDir, options) {
249
245
  timedOut: result.timedOut,
250
246
  allowlistedErrorHits,
251
247
  allowlistedTotalHits,
248
+ allowlist,
249
+ allowlistHits,
252
250
  findings,
253
251
  exitCode: result.exitCode,
254
252
  });
@@ -314,6 +312,17 @@ function reportSmokeSummary(args) {
314
312
  info(` Allowlisted error hits (suppressed): ${String(args.allowlistedErrorHits)}`);
315
313
  info(` Allowlisted lines total: ${String(args.allowlistedTotalHits)}`);
316
314
  info(` Child exit code: ${String(args.exitCode)}`);
315
+ // Per-entry attribution (FORGE G8): first-match credit per line, with
316
+ // zero-hit entries always visible — an allowlist entry whose suppressed
317
+ // shape changed upstream is only detectable as a 0× row.
318
+ if (args.allowlist.length > 0) {
319
+ info(' Allowlist attribution (first matching entry per line):');
320
+ args.allowlist.forEach((entry, index) => {
321
+ const hits = args.allowlistHits[index] ?? 0;
322
+ const zeroSuffix = hits === 0 ? ' (never matched — candidate for removal)' : '';
323
+ info(` ${String(hits)}× ${entry.origin} ${entry.source}${zeroSuffix}`);
324
+ });
325
+ }
317
326
  if (args.findings.length === 0)
318
327
  return;
319
328
  warn('');
@@ -338,10 +347,7 @@ export function registerRun(program, { getProjectRoot, withErrorHandling }) {
338
347
  }
339
348
  return parsed;
340
349
  }))
341
- .option('--console-allow <regex>', 'Allowlist regex (repeatable). Lines that match any entry do not count toward the smoke exit code.', (value, acc) => {
342
- acc.push(value);
343
- return acc;
344
- }, [])
350
+ .option('--console-allow <regex>', 'Allowlist regex (repeatable). Lines that match any entry do not count toward the smoke exit code.', ...stringListOption())
345
351
  .option('--console-allow-file <path>', 'Newline-delimited allowlist regex file. Blank lines and # comments are ignored.')
346
352
  .option('--capture-console <file>', 'Mirror captured console output to <file> for post-exit inspection.')
347
353
  .option('--headless', 'Launch the browser with --headless. Recommended for --smoke-exit on a shared desktop: input into a headed smoke window contaminates the console capture.')
@@ -4,13 +4,7 @@ import { configExists, loadRawConfigDocument, validateConfig, withConfigFileLock
4
4
  import { resolveArchive } from '../core/firefox-archive.js';
5
5
  import { GeneralError, InvalidArgumentError } from '../errors/base.js';
6
6
  import { info, intro, outro, success } from '../utils/logger.js';
7
- import { isValidFirefoxCandidate, isValidFirefoxProduct } from '../utils/validation.js';
8
- const SOURCE_PRODUCTS = [
9
- 'firefox',
10
- 'firefox-esr',
11
- 'firefox-beta',
12
- 'firefox-devedition',
13
- ];
7
+ import { FIREFOX_PRODUCTS, isValidFirefoxCandidate, isValidFirefoxProduct, } from '../utils/validation.js';
14
8
  function isRecord(value) {
15
9
  return typeof value === 'object' && value !== null && !Array.isArray(value);
16
10
  }
@@ -25,7 +19,7 @@ function parseSourceProduct(product) {
25
19
  if (isValidFirefoxProduct(product)) {
26
20
  return product;
27
21
  }
28
- throw new InvalidArgumentError(`--product must be one of: ${SOURCE_PRODUCTS.join(', ')}`, '--product');
22
+ throw new InvalidArgumentError(`--product must be one of: ${FIREFOX_PRODUCTS.join(', ')}`, '--product');
29
23
  }
30
24
  function parseSourceCandidate(candidate) {
31
25
  if (isValidFirefoxCandidate(candidate)) {
@@ -99,7 +93,7 @@ export function registerSource(program, { getProjectRoot, withErrorHandling }) {
99
93
  .description('Atomically set Firefox source version, product, and optional checksum')
100
94
  .requiredOption('--version <version>', 'Firefox version to base on')
101
95
  .addOption(new Option('--product <product>', 'Firefox product')
102
- .choices([...SOURCE_PRODUCTS])
96
+ .choices([...FIREFOX_PRODUCTS])
103
97
  .makeOptionMandatory())
104
98
  .option('--sha256 <hash>', 'Pinned SHA-256 for the resolved source archive')
105
99
  .option('--clear-sha256', 'Clear any existing pinned SHA-256')
@@ -0,0 +1,48 @@
1
+ /**
2
+ * `status --check` enforcement policy (FORGE G1). Turns the classified
3
+ * status view into a CI-enforceable non-zero exit: the default policy
4
+ * fails on any classification outside {patch-backed, branding, furnace},
5
+ * and `--fail-on <class,...>` replaces that set for finer control.
6
+ *
7
+ * Helper module consumed by status.ts (which sits at the max-lines
8
+ * budget); no top-level registrar is exported and none is wanted.
9
+ */
10
+ import type { ClassifiedFile, FileClassification } from '../core/status-classify.js';
11
+ /** Resolved `--check`/`--fail-on` policy for one status invocation. */
12
+ export interface StatusCheckPolicy {
13
+ checkEnabled: boolean;
14
+ failOn: readonly FileClassification[];
15
+ }
16
+ /**
17
+ * Resolves the enforcement policy from the raw flags: `--fail-on` implies
18
+ * `--check` and replaces the default set; both are refused alongside the
19
+ * modes that skip (or elide) classification.
20
+ */
21
+ export declare function resolveStatusCheckPolicy(options: {
22
+ check?: boolean;
23
+ failOn?: string;
24
+ raw?: boolean;
25
+ unmanaged?: boolean;
26
+ ownership?: boolean;
27
+ testCoverage?: boolean;
28
+ }): StatusCheckPolicy;
29
+ /** One fail-set classification with a non-empty file list. */
30
+ export interface StatusCheckOffender {
31
+ classification: FileClassification;
32
+ count: number;
33
+ files: string[];
34
+ }
35
+ /**
36
+ * Collects the fail-set classifications that have offending files, in
37
+ * policy order with full file lists. Shared by the human `--check`
38
+ * verdict and the `--json --summary` payload so the two views can never
39
+ * disagree about what failed.
40
+ */
41
+ export declare function collectStatusCheckOffenders(classified: readonly ClassifiedFile[], policy: StatusCheckPolicy): StatusCheckOffender[];
42
+ /**
43
+ * Throws a GeneralError (exit 1) when the policy is enabled and any of
44
+ * its classifications is non-empty, naming each offending classification
45
+ * with its first few files. A disabled policy or a fully tool-covered
46
+ * tree passes silently.
47
+ */
48
+ export declare function runStatusCheck(classified: readonly ClassifiedFile[], policy: StatusCheckPolicy): void;
@@ -0,0 +1,106 @@
1
+ import { GeneralError, InvalidArgumentError } from '../errors/base.js';
2
+ const ALL_CLASSIFICATIONS = [
3
+ 'patch-backed',
4
+ 'patch-owned-drift',
5
+ 'unmanaged',
6
+ 'branding',
7
+ 'furnace',
8
+ 'conflict',
9
+ 'binary-unsupported',
10
+ ];
11
+ /**
12
+ * Default `--check` policy: everything outside {patch-backed, branding,
13
+ * furnace, binary-unsupported} fails. `binary-unsupported` is deliberately
14
+ * excluded — an honest "cannot compare" must not keep the gate permanently
15
+ * red (FORGE J3); strict CI can opt in via `--fail-on binary-unsupported`.
16
+ */
17
+ const DEFAULT_FAIL_CLASSIFICATIONS = [
18
+ 'unmanaged',
19
+ 'patch-owned-drift',
20
+ 'conflict',
21
+ ];
22
+ /**
23
+ * Resolves the enforcement policy from the raw flags: `--fail-on` implies
24
+ * `--check` and replaces the default set; both are refused alongside the
25
+ * modes that skip (or elide) classification.
26
+ */
27
+ export function resolveStatusCheckPolicy(options) {
28
+ const failOnList = parseFailOnClassifications(options.failOn);
29
+ const checkEnabled = options.check === true || failOnList !== undefined;
30
+ if (checkEnabled &&
31
+ (options.raw === true ||
32
+ options.unmanaged === true ||
33
+ options.ownership === true ||
34
+ options.testCoverage === true)) {
35
+ throw new InvalidArgumentError('--check cannot be combined with --raw, --unmanaged, --ownership, or --test-coverage. Use the default view or --json.', '--check');
36
+ }
37
+ return { checkEnabled, failOn: failOnList ?? DEFAULT_FAIL_CLASSIFICATIONS };
38
+ }
39
+ function isClassification(value) {
40
+ return ALL_CLASSIFICATIONS.includes(value);
41
+ }
42
+ /**
43
+ * Parses a comma-separated `--fail-on` value into a classification list.
44
+ * Returns undefined when the flag was not given (the caller then applies
45
+ * the default policy only under `--check`).
46
+ */
47
+ function parseFailOnClassifications(raw) {
48
+ if (raw === undefined)
49
+ return undefined;
50
+ const parts = raw
51
+ .split(',')
52
+ .map((part) => part.trim())
53
+ .filter((part) => part.length > 0);
54
+ if (parts.length === 0) {
55
+ throw new InvalidArgumentError(`--fail-on requires at least one classification. Valid: ${ALL_CLASSIFICATIONS.join(', ')}.`, '--fail-on');
56
+ }
57
+ const classifications = [];
58
+ for (const part of parts) {
59
+ if (!isClassification(part)) {
60
+ throw new InvalidArgumentError(`Unknown --fail-on classification "${part}". Valid: ${ALL_CLASSIFICATIONS.join(', ')}.`, '--fail-on');
61
+ }
62
+ if (!classifications.includes(part))
63
+ classifications.push(part);
64
+ }
65
+ return classifications;
66
+ }
67
+ const CHECK_FILE_PREVIEW_MAX = 3;
68
+ /**
69
+ * Collects the fail-set classifications that have offending files, in
70
+ * policy order with full file lists. Shared by the human `--check`
71
+ * verdict and the `--json --summary` payload so the two views can never
72
+ * disagree about what failed.
73
+ */
74
+ export function collectStatusCheckOffenders(classified, policy) {
75
+ const offenders = [];
76
+ for (const classification of policy.failOn) {
77
+ const files = classified
78
+ .filter((entry) => entry.classification === classification)
79
+ .map((entry) => entry.file);
80
+ if (files.length === 0)
81
+ continue;
82
+ offenders.push({ classification, count: files.length, files });
83
+ }
84
+ return offenders;
85
+ }
86
+ /**
87
+ * Throws a GeneralError (exit 1) when the policy is enabled and any of
88
+ * its classifications is non-empty, naming each offending classification
89
+ * with its first few files. A disabled policy or a fully tool-covered
90
+ * tree passes silently.
91
+ */
92
+ export function runStatusCheck(classified, policy) {
93
+ if (!policy.checkEnabled)
94
+ return;
95
+ const offending = collectStatusCheckOffenders(classified, policy).map((offender) => {
96
+ const preview = offender.files.slice(0, CHECK_FILE_PREVIEW_MAX).join(', ');
97
+ const more = offender.count > CHECK_FILE_PREVIEW_MAX
98
+ ? `, +${String(offender.count - CHECK_FILE_PREVIEW_MAX)} more`
99
+ : '';
100
+ return `${String(offender.count)} ${offender.classification} (${preview}${more})`;
101
+ });
102
+ if (offending.length === 0)
103
+ return;
104
+ throw new GeneralError(`status --check failed: ${offending.join(', ')}. Export or adopt unmanaged files, ` +
105
+ `re-export drifted patches, and run "fireforge verify" for conflict details.`);
106
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * `status --json` payload rendering (schemaVersion 1). Helper module
3
+ * consumed by status.ts (which sits at the max-lines budget); no
4
+ * top-level registrar is exported and none is wanted.
5
+ *
6
+ * Two shapes share one counting pass:
7
+ * - the full payload (`renderJsonStatus`) with the per-file `files[]` list;
8
+ * - the `--summary` gate payload (`renderJsonSummaryStatus`, FORGE K8),
9
+ * which omits `files[]` entirely — engine-clean gates need the verdict,
10
+ * per-class counts, and offender names, not a payload that grows with
11
+ * the queue (175 KB+ observed on a 290-patch consumer).
12
+ */
13
+ import type { ClassifiedFile } from '../core/status-classify.js';
14
+ import { type StatusCheckPolicy } from './status-check.js';
15
+ import type { OwnershipJsonBlock } from './status-ownership.js';
16
+ /**
17
+ * Full `--json` payload: summary plus the per-file `files[]` list, and —
18
+ * under `--include-ownership` (FORGE L3) — the additive `ownership` block.
19
+ * Additive within schemaVersion 1: a consumer that does not ask for the
20
+ * block never sees the key.
21
+ */
22
+ export declare function renderJsonStatus(classified: readonly ClassifiedFile[], ownership?: OwnershipJsonBlock): void;
23
+ /**
24
+ * Summary-only `--json --summary` payload: counts always; when the
25
+ * `--check`/`--fail-on` policy is active, a `check` block with the active
26
+ * fail set, the verdict, and the offending files per fail-set
27
+ * classification (sourced from the same collector as the human `--check`
28
+ * message, so the two views can never disagree). The absent `files[]` is
29
+ * the mode signal within schemaVersion 1 — consumers opt in via the flag.
30
+ */
31
+ export declare function renderJsonSummaryStatus(classified: readonly ClassifiedFile[], policy: StatusCheckPolicy, ownership?: OwnershipJsonBlock): void;
@@ -0,0 +1,76 @@
1
+ import { collectStatusCheckOffenders, } from './status-check.js';
2
+ function countByClassification(classified) {
3
+ const byClassification = {
4
+ unmanaged: 0,
5
+ 'patch-owned-drift': 0,
6
+ 'patch-backed': 0,
7
+ branding: 0,
8
+ furnace: 0,
9
+ conflict: 0,
10
+ 'binary-unsupported': 0,
11
+ };
12
+ for (const file of classified) {
13
+ byClassification[file.classification]++;
14
+ }
15
+ return byClassification;
16
+ }
17
+ /**
18
+ * Full `--json` payload: summary plus the per-file `files[]` list, and —
19
+ * under `--include-ownership` (FORGE L3) — the additive `ownership` block.
20
+ * Additive within schemaVersion 1: a consumer that does not ask for the
21
+ * block never sees the key.
22
+ */
23
+ export function renderJsonStatus(classified, ownership) {
24
+ const outputFiles = classified.map((f) => {
25
+ const entry = {
26
+ file: f.file,
27
+ status: f.status.trim(),
28
+ classification: f.classification,
29
+ patch: f.owner ?? null,
30
+ };
31
+ if (f.classification === 'conflict' && f.claimedBy && f.claimedBy.length > 0) {
32
+ entry.claimedBy = [...f.claimedBy];
33
+ }
34
+ return entry;
35
+ });
36
+ const output = {
37
+ schemaVersion: 1,
38
+ summary: {
39
+ total: outputFiles.length,
40
+ byClassification: countByClassification(classified),
41
+ },
42
+ files: outputFiles,
43
+ ...(ownership !== undefined ? { ownership } : {}),
44
+ };
45
+ process.stdout.write(JSON.stringify(output, null, 2) + '\n');
46
+ }
47
+ /**
48
+ * Summary-only `--json --summary` payload: counts always; when the
49
+ * `--check`/`--fail-on` policy is active, a `check` block with the active
50
+ * fail set, the verdict, and the offending files per fail-set
51
+ * classification (sourced from the same collector as the human `--check`
52
+ * message, so the two views can never disagree). The absent `files[]` is
53
+ * the mode signal within schemaVersion 1 — consumers opt in via the flag.
54
+ */
55
+ export function renderJsonSummaryStatus(classified, policy, ownership) {
56
+ const output = {
57
+ schemaVersion: 1,
58
+ summary: {
59
+ total: classified.length,
60
+ byClassification: countByClassification(classified),
61
+ },
62
+ };
63
+ if (policy.checkEnabled) {
64
+ const offenders = collectStatusCheckOffenders(classified, policy);
65
+ output.check = {
66
+ enabled: true,
67
+ failOn: policy.failOn,
68
+ failed: offenders.length > 0,
69
+ offenders,
70
+ };
71
+ }
72
+ if (ownership !== undefined) {
73
+ output.ownership = ownership;
74
+ }
75
+ process.stdout.write(JSON.stringify(output, null, 2) + '\n');
76
+ }
@@ -7,6 +7,7 @@ export interface ClassifiedBuckets {
7
7
  patchOwnedDrift: ClassifiedFile[];
8
8
  branding: ClassifiedFile[];
9
9
  furnace: ClassifiedFile[];
10
+ binaryUnsupported: ClassifiedFile[];
10
11
  }
11
12
  /** Renders the unmanaged-only status view and registration hints. */
12
13
  export declare function renderUnmanagedOnly(unmanagedFiles: ClassifiedFile[], totalModified: number, projectRoot: string, binaryName: string): Promise<void>;
@@ -107,7 +107,7 @@ function renderConflictSection(conflict) {
107
107
  }
108
108
  /** Renders the default classified status buckets. */
109
109
  export async function renderDefaultStatus(totalModified, buckets, projectRoot, binaryName) {
110
- const { conflict, unmanaged, patchBacked, patchOwnedDrift, branding, furnace } = buckets;
110
+ const { conflict, unmanaged, patchBacked, patchOwnedDrift, branding, furnace, binaryUnsupported, } = buckets;
111
111
  info(`${totalModified} modified file${totalModified === 1 ? '' : 's'}:\n`);
112
112
  // Sections render in this fixed order, separated by a blank line
113
113
  // whenever an earlier section already printed (the pre-refactor code
@@ -163,6 +163,15 @@ export async function renderDefaultStatus(totalModified, buckets, projectRoot, b
163
163
  printStatusGroups(furnace);
164
164
  },
165
165
  },
166
+ {
167
+ files: binaryUnsupported,
168
+ label: 'binary — comparison unsupported',
169
+ render: () => {
170
+ warn('Binary — comparison unsupported:');
171
+ printStatusGroups(binaryUnsupported);
172
+ info('These patch-owned binary files carry no comparable blob hash in their patch body, so durability cannot be verified. Re-export the owning patch with a git binary body to make them comparable.');
173
+ },
174
+ },
166
175
  ];
167
176
  let printedAny = false;
168
177
  for (const section of sections) {
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Ownership-table assembly for `status`, split out of `status.ts` (FORGE
3
+ * L3) so the human `--ownership` mode and the additive `ownership` block on
4
+ * the `--json` payload build the SAME rows from the SAME scan.
5
+ *
6
+ * Before L3 the `--ownership` branch re-implemented the shared scan and
7
+ * inlined a second copy of the classification call, then discarded
8
+ * everything but each file's classification — three back-to-back `status`
9
+ * invocations in a gate therefore paid three full worktree scans. Both
10
+ * callers now hand this module the already-classified files.
11
+ *
12
+ * No renderer lives here: `renderOwnershipTable` (human) and the JSON
13
+ * serializer stay with their respective output layers.
14
+ */
15
+ import type { OwnershipRow } from '../core/ownership-table.js';
16
+ import type { ClassifiedFile, StatusFile } from '../core/status-classify.js';
17
+ import type { PatchMetadata } from '../types/commands/index.js';
18
+ /** Counts the human outro prints and the JSON block carries. */
19
+ export interface OwnershipSummary {
20
+ managed: number;
21
+ unmanaged: number;
22
+ conflicts: number;
23
+ }
24
+ /** The additive `ownership` block on the schemaVersion-1 JSON payload. */
25
+ export interface OwnershipJsonBlock {
26
+ rows: OwnershipRow[];
27
+ summary: OwnershipSummary;
28
+ }
29
+ /**
30
+ * Builds the flat path→owning-patch rows. Sources are the manifest's
31
+ * `filesAffected`, worktree drift, and the cross-patch
32
+ * duplicate-new-file-creation map produced by walking each patch body —
33
+ * the last being the alignment fix between `status --ownership` and
34
+ * `fireforge verify` (see `buildOwnershipTable`'s header).
35
+ * @param patchesDir - Path to the patches directory
36
+ * @param manifestPatches - Patch rows from patches.json (empty when absent)
37
+ * @param files - Scanned worktree entries, temp-filtered
38
+ * @param classified - The same entries, already classified
39
+ */
40
+ export declare function collectOwnershipRows(patchesDir: string, manifestPatches: readonly PatchMetadata[], files: StatusFile[], classified: readonly ClassifiedFile[]): Promise<OwnershipRow[]>;
41
+ /**
42
+ * Counts managed / unmanaged / conflicted rows. Conflicts are rows flagged
43
+ * `conflict`, not a separate collection.
44
+ * @param rows - Rows from {@link collectOwnershipRows}
45
+ */
46
+ export declare function summarizeOwnership(rows: readonly OwnershipRow[]): OwnershipSummary;
47
+ /**
48
+ * Assembles the JSON block. Rows are carried verbatim so the machine shape
49
+ * and the rendered table can never disagree.
50
+ * @param rows - Rows from {@link collectOwnershipRows}
51
+ */
52
+ export declare function buildOwnershipJsonBlock(rows: OwnershipRow[]): OwnershipJsonBlock;
@@ -0,0 +1,57 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Ownership-table assembly for `status`, split out of `status.ts` (FORGE
4
+ * L3) so the human `--ownership` mode and the additive `ownership` block on
5
+ * the `--json` payload build the SAME rows from the SAME scan.
6
+ *
7
+ * Before L3 the `--ownership` branch re-implemented the shared scan and
8
+ * inlined a second copy of the classification call, then discarded
9
+ * everything but each file's classification — three back-to-back `status`
10
+ * invocations in a gate therefore paid three full worktree scans. Both
11
+ * callers now hand this module the already-classified files.
12
+ *
13
+ * No renderer lives here: `renderOwnershipTable` (human) and the JSON
14
+ * serializer stay with their respective output layers.
15
+ */
16
+ import { buildOwnershipTable } from '../core/ownership-table.js';
17
+ import { buildPatchQueueContext, collectNewFileCreatorsByPath } from '../core/patch-lint.js';
18
+ import { pathExists } from '../utils/fs.js';
19
+ /**
20
+ * Builds the flat path→owning-patch rows. Sources are the manifest's
21
+ * `filesAffected`, worktree drift, and the cross-patch
22
+ * duplicate-new-file-creation map produced by walking each patch body —
23
+ * the last being the alignment fix between `status --ownership` and
24
+ * `fireforge verify` (see `buildOwnershipTable`'s header).
25
+ * @param patchesDir - Path to the patches directory
26
+ * @param manifestPatches - Patch rows from patches.json (empty when absent)
27
+ * @param files - Scanned worktree entries, temp-filtered
28
+ * @param classified - The same entries, already classified
29
+ */
30
+ export async function collectOwnershipRows(patchesDir, manifestPatches, files, classified) {
31
+ // Only walk patch bodies when the directory actually exists: a fresh
32
+ // project with no queue degrades to filesAffected-only behavior.
33
+ const newFileCreatorsByPath = (await pathExists(patchesDir))
34
+ ? collectNewFileCreatorsByPath(await buildPatchQueueContext(patchesDir))
35
+ : new Map();
36
+ return buildOwnershipTable([...manifestPatches], files, newFileCreatorsByPath, new Map(classified.map((entry) => [entry.file, entry.classification])));
37
+ }
38
+ /**
39
+ * Counts managed / unmanaged / conflicted rows. Conflicts are rows flagged
40
+ * `conflict`, not a separate collection.
41
+ * @param rows - Rows from {@link collectOwnershipRows}
42
+ */
43
+ export function summarizeOwnership(rows) {
44
+ return {
45
+ managed: rows.filter((r) => !r.unmanaged).length,
46
+ unmanaged: rows.filter((r) => r.unmanaged).length,
47
+ conflicts: rows.filter((r) => r.conflict).length,
48
+ };
49
+ }
50
+ /**
51
+ * Assembles the JSON block. Rows are carried verbatim so the machine shape
52
+ * and the rendered table can never disagree.
53
+ * @param rows - Rows from {@link collectOwnershipRows}
54
+ */
55
+ export function buildOwnershipJsonBlock(rows) {
56
+ return { rows, summary: summarizeOwnership(rows) };
57
+ }