@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
@@ -0,0 +1,320 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * `fireforge tree` — copy-on-write verification clones (FORGE G15).
4
+ *
5
+ * Trees enable concurrent READ-MOSTLY verification (lint, typecheck,
6
+ * status, verify, doctor, export dry-runs) beside a busy primary tree.
7
+ * Exports and every other mutation stay strictly serial in the primary —
8
+ * a tree's patches/components are snapshots with no merge model, and the
9
+ * tree guard (cli.ts preAction + core/tree-guard.ts) refuses mutating
10
+ * commands inside a tree. `create --with-objdir` additionally clones the
11
+ * primary's built obj-* directory, rewrites its mozinfo.json to the
12
+ * tree, and runs `mach configure` inside the tree so the remaining
13
+ * configure output (config.status, backend.mk, Makefile,
14
+ * config/autoconf.mk — the verified set) stops naming the primary
15
+ * (fail-closed — mach objdirs embed absolute source paths, and an
16
+ * unrelocated clone would silently operate against the primary), which
17
+ * is what admits build-less `fireforge test` inside that tree.
18
+ */
19
+ import { spawn } from 'node:child_process';
20
+ import { join } from 'node:path';
21
+ import { Command } from 'commander';
22
+ import { withEngineSessionLock } from '../core/engine-session-lock.js';
23
+ import { hasBuildArtifacts, runMach, withBuildLock } from '../core/mach.js';
24
+ import { assertBuildArtifacts, findObjdirRelocationViolation, } from '../core/mach-build-artifacts.js';
25
+ import { detectCowSupport } from '../core/tree-cow.js';
26
+ import { cloneTree, listTrees, removeTree } from '../core/tree-store.js';
27
+ import { assertValidTreeName, computePrimaryFingerprint, getTreeMarkerPath, getTreesDir, readTreeMarkerState, withTreeLifecycleLock, } from '../core/tree-store.js';
28
+ import { GeneralError } from '../errors/base.js';
29
+ import { BuildError } from '../errors/build.js';
30
+ import { pathExists } from '../utils/fs.js';
31
+ import { info, intro, outro, setMachineOutputMode, setStdoutSealed, success, warn, } from '../utils/logger.js';
32
+ import { addWaitLockOption, resolveWaitLockSeconds } from '../utils/options.js';
33
+ function assertPosix() {
34
+ if (process.platform === 'win32') {
35
+ throw new GeneralError('fireforge tree is POSIX-only; copy-on-write cloning relies on clonefile/reflink, which are not available on Windows.');
36
+ }
37
+ }
38
+ /**
39
+ * Resolves the primary objdir a `--with-objdir` clone will keep,
40
+ * refusing missing, incomplete, ambiguous, or already-mismatched builds
41
+ * before any copying starts.
42
+ */
43
+ async function resolveObjdirForClone(projectRoot) {
44
+ const engineDir = join(projectRoot, 'engine');
45
+ const buildCheck = await hasBuildArtifacts(engineDir);
46
+ assertBuildArtifacts(engineDir, buildCheck, {
47
+ label: 'tree create --with-objdir',
48
+ requirement: 'tree create --with-objdir requires a completed primary build.',
49
+ remediation: 'Run "fireforge build" first, or create the tree without --with-objdir.',
50
+ requireExisting: true,
51
+ });
52
+ if (buildCheck.objDir === undefined) {
53
+ throw new GeneralError('tree create --with-objdir could not determine the primary obj-* directory.');
54
+ }
55
+ return buildCheck.objDir;
56
+ }
57
+ /**
58
+ * Runs `mach configure` inside the tree's engine so the configure-generated
59
+ * root files (config.status, backend.mk, Makefile, config/autoconf.mk) are
60
+ * regenerated against the tree's paths — the mozinfo rewrite alone leaves
61
+ * those naming the primary, and a build-less in-tree `test` must never
62
+ * consult primary state. Exit code 0 is not trusted on its own: configure
63
+ * obeys MOZCONFIG / MOZ_OBJDIR, so the postcondition is verified afterwards
64
+ * via `findObjdirRelocationViolation` — the intended objdir was configured,
65
+ * its mozinfo points into the tree, and none of those root files still
66
+ * names the primary engine (nested Makefiles are products of the verified
67
+ * config.status; `.deps` build products are out of scope — see the
68
+ * checker's doc). A failure throws before `cloneTree` writes the marker,
69
+ * so the tree is removed and `clonedObjdir` is never recorded (fail-closed).
70
+ */
71
+ async function reconfigureClonedObjdir(treeEngineDir, objDir, primaryEngineDir) {
72
+ info(' Running mach configure in the tree so no build metadata retains primary paths...');
73
+ let exitCode;
74
+ try {
75
+ exitCode = await runMach(['configure'], treeEngineDir);
76
+ }
77
+ catch (error) {
78
+ throw new BuildError('mach configure failed in the cloned tree; the tree was removed. ' +
79
+ 'Re-run tree create, or create the tree without --with-objdir.', 'mach configure', error instanceof Error ? error : undefined);
80
+ }
81
+ if (exitCode !== 0) {
82
+ throw new BuildError(`mach configure exited non-zero (${exitCode}) in the cloned tree; the tree was removed. ` +
83
+ 'Re-run tree create, or create the tree without --with-objdir.', 'mach configure');
84
+ }
85
+ const violation = await findObjdirRelocationViolation({
86
+ engineDir: treeEngineDir,
87
+ objDir,
88
+ forbiddenDir: primaryEngineDir,
89
+ });
90
+ if (violation !== undefined) {
91
+ throw new BuildError(`mach configure exited 0 in the cloned tree but did not relocate the objdir: ${violation}. ` +
92
+ 'The tree was removed. Re-run tree create, or create the tree without --with-objdir.', 'mach configure');
93
+ }
94
+ }
95
+ /** Creates a verification tree under `.fireforge/trees/<name>`. */
96
+ export async function treeCreateCommand(projectRoot, name, options = {}) {
97
+ assertPosix();
98
+ intro('FireForge Tree Create');
99
+ assertValidTreeName(name);
100
+ // A marker we cannot parse still means "something claims this is a tree", so
101
+ // it must block nesting too — otherwise a corrupt marker is a licence to
102
+ // clone a snapshot into itself.
103
+ const markerState = await readTreeMarkerState(projectRoot);
104
+ if (markerState.kind !== 'absent') {
105
+ throw new GeneralError(markerState.kind === 'valid'
106
+ ? 'Verification trees cannot be nested: run tree create in the primary tree.'
107
+ : `${getTreeMarkerPath(projectRoot)} claims this directory is a verification tree, but ` +
108
+ `${markerState.reason}. Repair or delete the marker before creating a tree here.`);
109
+ }
110
+ const treesDir = getTreesDir(projectRoot);
111
+ const treeRoot = join(treesDir, name);
112
+ await withTreeLifecycleLock(projectRoot, async () => {
113
+ if (await pathExists(treeRoot)) {
114
+ throw new GeneralError(`A tree named "${name}" already exists. Remove it first (fireforge tree remove ${name}) — refresh is remove + create.`);
115
+ }
116
+ const { mkdir } = await import('node:fs/promises');
117
+ await mkdir(treesDir, { recursive: true });
118
+ const capability = await detectCowSupport(treesDir);
119
+ if (capability === 'none' && options.forceCopy !== true) {
120
+ throw new GeneralError('This filesystem cannot copy-on-write (APFS clonefile or btrfs/XFS reflink required), so a tree ' +
121
+ 'would be a full physical copy of the applied engine tree — potentially tens of gigabytes. ' +
122
+ 'Re-run with --force-copy to accept that explicitly.');
123
+ }
124
+ if (capability === 'none') {
125
+ warn('No copy-on-write support detected — performing a full physical copy (--force-copy).');
126
+ }
127
+ // Courtesy fast-fail: refuse obvious no-build cases before waiting on
128
+ // locks. The result is DISCARDED — a build can still start before the
129
+ // locks below are held, so only the re-resolution under the build lock
130
+ // is authoritative for what actually gets cloned.
131
+ if (options.withObjdir === true) {
132
+ await resolveObjdirForClone(projectRoot);
133
+ }
134
+ // Snapshot under the PRIMARY engine-session lock so a mid-mutation
135
+ // state (half-applied export, furnace deploy) is never captured; an
136
+ // objdir snapshot additionally holds the primary BUILD lock, under
137
+ // which the objdir is re-validated immediately before cloning so a
138
+ // concurrent `fireforge build` can neither tear the obj-* mid-write
139
+ // nor swap it after preflight. A failed clone (including the
140
+ // fail-closed mozinfo-rewrite refusal and a failed in-tree
141
+ // reconfigure) removes the partial tree so the name is immediately
142
+ // reusable.
143
+ const marker = await withEngineSessionLock(projectRoot, 'tree create', async () => {
144
+ const clone = (objDir) => cloneTree({
145
+ primaryRoot: projectRoot,
146
+ treeRoot,
147
+ name,
148
+ capability,
149
+ createdAt: new Date().toISOString(),
150
+ ...(objDir === undefined
151
+ ? {}
152
+ : {
153
+ withObjdir: {
154
+ objDir,
155
+ reconfigure: (treeEngineDir) => reconfigureClonedObjdir(treeEngineDir, objDir, join(projectRoot, 'engine')),
156
+ },
157
+ }),
158
+ });
159
+ try {
160
+ return options.withObjdir === true
161
+ ? await withBuildLock(projectRoot, async () => clone(await resolveObjdirForClone(projectRoot)))
162
+ : await clone();
163
+ }
164
+ catch (error) {
165
+ const { rm } = await import('node:fs/promises');
166
+ await rm(treeRoot, { recursive: true, force: true });
167
+ throw error;
168
+ }
169
+ }, { waitLockSeconds: options.waitLockSeconds });
170
+ success(`Created verification tree "${name}" at ${treeRoot}`);
171
+ info(` Engine HEAD: ${marker.engineHead ?? '(no engine)'}`);
172
+ info(' Read-only: status, lint, typecheck, verify, doctor, and export/re-export --dry-run run inside it; mutation commands are refused.');
173
+ if (marker.clonedObjdir !== undefined) {
174
+ info(` Build cloned: ${marker.clonedObjdir} was kept and rewritten to this tree — build-less "fireforge test" runs inside it.`);
175
+ info(' Browser tests share Marionette port 2828 with the primary; pass --marionette-port for concurrent runs.');
176
+ }
177
+ });
178
+ outro('Tree created');
179
+ }
180
+ /** Prints the tree list with staleness against the current primary state. */
181
+ export async function treeListCommand(projectRoot, options = {}) {
182
+ if (options.json === true) {
183
+ // stdout belongs exclusively to the JSON payload: engage machine mode so
184
+ // any diagnostic — including a listTrees failure rendered later by
185
+ // withErrorHandling, which also resets the mode — routes to stderr.
186
+ setMachineOutputMode(true);
187
+ const trees = await listTrees(projectRoot);
188
+ process.stdout.write(`${JSON.stringify({ schemaVersion: 1, trees }, null, 2)}\n`);
189
+ setMachineOutputMode(false);
190
+ return;
191
+ }
192
+ const trees = await listTrees(projectRoot);
193
+ intro('FireForge Trees');
194
+ if (trees.length === 0) {
195
+ info('No verification trees. Create one with: fireforge tree create <name>');
196
+ outro('Done');
197
+ return;
198
+ }
199
+ for (const tree of trees) {
200
+ info(`${tree.name.padEnd(20)} ${tree.staleness.padEnd(24)} created ${tree.createdAt}`);
201
+ }
202
+ outro(`${trees.length} tree(s)`);
203
+ }
204
+ /** Removes one tree (or all of them with `--all`). */
205
+ export async function treeRemoveCommand(projectRoot, name, options = {}) {
206
+ assertPosix();
207
+ intro('FireForge Tree Remove');
208
+ if (options.all !== true && name === undefined) {
209
+ throw new GeneralError('Pass a tree name, or --all to remove every tree.');
210
+ }
211
+ await withTreeLifecycleLock(projectRoot, async () => {
212
+ const targets = options.all === true ? (await listTrees(projectRoot)).map((t) => t.name) : [name ?? ''];
213
+ if (targets.length === 0) {
214
+ info('No verification trees to remove.');
215
+ return;
216
+ }
217
+ for (const target of targets) {
218
+ await removeTree(projectRoot, target, { force: options.force === true });
219
+ success(`Removed verification tree "${target}"`);
220
+ }
221
+ });
222
+ outro('Tree removal complete');
223
+ }
224
+ /** Spawns `fireforge <args>` with the tree as its working directory. */
225
+ async function treeExecCommand(projectRoot, name, args) {
226
+ assertPosix();
227
+ assertValidTreeName(name);
228
+ const treeRoot = join(getTreesDir(projectRoot), name);
229
+ const state = await readTreeMarkerState(treeRoot);
230
+ if (state.kind === 'corrupt') {
231
+ // Collapsing corrupt into "no such tree" misdiagnoses: the directory IS
232
+ // there, claiming to be a tree, and "no tree named X" points the operator
233
+ // at the wrong remediation.
234
+ throw new GeneralError(`Tree "${name}" exists but its marker could not be read: ${state.reason}. ` +
235
+ 'Recreate it (fireforge tree remove <name> && fireforge tree create <name>).');
236
+ }
237
+ if (state.kind === 'absent') {
238
+ throw new GeneralError(`No verification tree named "${name}". List trees with: fireforge tree list`);
239
+ }
240
+ const marker = state.marker;
241
+ const current = await computePrimaryFingerprint(projectRoot);
242
+ if (marker.engineHead !== current.engineHead) {
243
+ warn(`Tree "${name}" is stale (primary engine advanced since the snapshot).`);
244
+ }
245
+ else if (marker.engineFingerprint !== undefined &&
246
+ marker.engineFingerprint !== current.engineFingerprint) {
247
+ warn(`Tree "${name}" is stale (primary engine worktree changed since the snapshot).`);
248
+ }
249
+ else if (marker.patchesFingerprint !== current.patchesFingerprint) {
250
+ warn(`Tree "${name}" is stale (primary patches changed since the snapshot).`);
251
+ }
252
+ const cliEntry = process.argv[1];
253
+ if (cliEntry === undefined) {
254
+ throw new GeneralError('Cannot resolve the fireforge CLI entry point for tree exec.');
255
+ }
256
+ const exitCode = await new Promise((resolvePromise, rejectPromise) => {
257
+ const child = spawn(process.execPath, [cliEntry, ...args], {
258
+ cwd: treeRoot,
259
+ stdio: 'inherit',
260
+ });
261
+ child.on('error', rejectPromise);
262
+ child.on('exit', (code) => {
263
+ resolvePromise(code ?? 1);
264
+ });
265
+ }).finally(() => {
266
+ // FORGE L2: with `stdio: 'inherit'` the child owned stdout — including
267
+ // any FIREFORGE-VERDICT line it emitted as its LAST stdout write. From
268
+ // here on the parent must not write stdout again, or its own refusal
269
+ // text (the GeneralError below rendered by withErrorHandling) would
270
+ // print AFTER the child's verdict and break the "verdict is the run's
271
+ // last stdout line" contract at the tree-exec boundary. Sealing routes
272
+ // the parent's remaining output to stderr; withErrorHandling's finally
273
+ // clears the seal.
274
+ setStdoutSealed(true);
275
+ });
276
+ if (exitCode !== 0) {
277
+ throw new GeneralError(`tree exec: fireforge ${args.join(' ')} exited with code ${String(exitCode)}.`);
278
+ }
279
+ }
280
+ /** Registers the tree command group on the CLI program. */
281
+ export function registerTree(program, context) {
282
+ const { getProjectRoot, withErrorHandling } = context;
283
+ const tree = program
284
+ .command('tree')
285
+ .description('Manage copy-on-write verification clones of this project for concurrent read-mostly work (lint, typecheck, status, verify, export/re-export --dry-run; build-less test with create --with-objdir). Mutation commands are refused inside a tree.');
286
+ addWaitLockOption(tree
287
+ .command('create <name>')
288
+ .description('Clone the project (engine included, obj-* excluded) into .fireforge/trees/<name> using filesystem copy-on-write (APFS clonefile / btrfs-XFS reflink). Refuses on filesystems without CoW support unless --force-copy explicitly accepts a full physical copy.')
289
+ .option('--force-copy', 'Allow a full physical copy when the filesystem cannot copy-on-write. This can copy tens of GB; never implied.')
290
+ .option('--with-objdir', 'Also clone the primary obj-* build and rewrite its mozinfo.json to the tree (fail-closed), enabling build-less "fireforge test" inside the tree. Requires a completed, unambiguous primary build.')).action(withErrorHandling(async (name, options) => {
291
+ // The flag is a CLI-layer concern: the command takes the resolved
292
+ // wait budget, matching how furnace routes its subcommand locks.
293
+ const { waitLock, ...rest } = options;
294
+ await treeCreateCommand(getProjectRoot(), name, {
295
+ ...rest,
296
+ waitLockSeconds: resolveWaitLockSeconds(waitLock),
297
+ });
298
+ }));
299
+ tree
300
+ .command('list')
301
+ .description('List verification trees with creation time and staleness vs the primary tree (engine HEAD and patches.json fingerprint).')
302
+ .option('--json', 'Output the tree list as JSON')
303
+ .action(withErrorHandling(async (options) => {
304
+ await treeListCommand(getProjectRoot(), options);
305
+ }));
306
+ tree
307
+ .command('remove [name]')
308
+ .description("Delete a verification tree. Refuses while a live process holds the tree's build or engine-session lock, and when a lock exists whose owning process cannot be identified.")
309
+ .option('--all', 'Remove every verification tree')
310
+ .option('--force', "Delete even when a lock directory's owner cannot be identified. Only safe once you have confirmed no build or test is running against the tree.")
311
+ .action(withErrorHandling(async (name, options) => {
312
+ await treeRemoveCommand(getProjectRoot(), name, options);
313
+ }));
314
+ tree
315
+ .command('exec <name> [args...]')
316
+ .description('Run a fireforge command inside the named tree (equivalent to cd-ing into it). Example: fireforge tree exec shard-a -- lint --per-patch')
317
+ .action(withErrorHandling(async (name, args) => {
318
+ await treeExecCommand(getProjectRoot(), name, args);
319
+ }));
320
+ }
@@ -20,7 +20,7 @@ import { getProjectPaths, loadConfig } from '../core/config.js';
20
20
  import { collectFurnaceManagedPrefixes } from '../core/furnace-config.js';
21
21
  import { isGitRepository } from '../core/git.js';
22
22
  import { expandUntrackedDirectoryEntries, getWorkingTreeStatus } from '../core/git-status.js';
23
- import { buildPatchQueueContext, lintPatchQueue } from '../core/patch-lint.js';
23
+ import { buildPatchQueueContext, formatPatchLintIssue, lintPatchQueue, } from '../core/patch-lint.js';
24
24
  import { loadPatchesManifest, validatePatchesManifestConsistency } from '../core/patch-manifest.js';
25
25
  import { evaluatePatchPolicy } from '../core/patch-policy.js';
26
26
  import { collectPatchRegistrationReferences } from '../core/patch-registration-refs.js';
@@ -187,7 +187,7 @@ export async function collectPatchQueueHealth(projectRoot) {
187
187
  title: `Cross-patch lint issues (${lintIssues.length})`,
188
188
  issues: lintIssues.map((issue) => {
189
189
  const label = issue.severity === 'error' ? 'ERROR' : issue.severity === 'warning' ? 'WARN' : 'NOTICE';
190
- return `${label} [${issue.check}] ${issue.file}: ${issue.message}`;
190
+ return `${label} ${formatPatchLintIssue(issue)}`;
191
191
  }),
192
192
  errorCount: lintErrors,
193
193
  warningCount: lintWarnings,
@@ -3,9 +3,10 @@ import { delimiter, dirname } from 'node:path';
3
3
  import { Command } from 'commander';
4
4
  import { getProjectPaths, loadConfig } from '../core/config.js';
5
5
  import { warnIfFurnaceStale } from '../core/furnace-staleness.js';
6
- import { buildArtifactMismatchMessage, generateMozconfig, hasBuildArtifacts, hasRunnableBundle, watchWithOutput, } from '../core/mach.js';
6
+ import { generateMozconfig, hasBuildArtifacts, hasRunnableBundle, watchWithOutput, } from '../core/mach.js';
7
+ import { assertBuildArtifacts } from '../core/mach-build-artifacts.js';
7
8
  import { GeneralError } from '../errors/base.js';
8
- import { AmbiguousBuildArtifactsError, BuildError } from '../errors/build.js';
9
+ import { BuildError } from '../errors/build.js';
9
10
  import { toError } from '../utils/errors.js';
10
11
  import { pathExists } from '../utils/fs.js';
11
12
  import { info, intro, outro, spinner, verbose } from '../utils/logger.js';
@@ -123,24 +124,16 @@ export async function watchCommand(projectRoot) {
123
124
  // instead of an actionable preflight error.
124
125
  await probeWatchman();
125
126
  // Check for build artifacts before starting watch
127
+ // The mismatch rung rejects copied or relocated obj-* dirs whose mozinfo
128
+ // metadata (topsrcdir, topobjdir, mozconfig) still points at a different
129
+ // source tree; mach watch against stale metadata produces confusing errors.
126
130
  const buildCheck = await hasBuildArtifacts(paths.engine);
127
- if (buildCheck.ambiguous && buildCheck.objDirs && buildCheck.objDirs.length > 0) {
128
- throw new AmbiguousBuildArtifactsError(buildCheck.objDirs);
129
- }
130
- // Reject copied or relocated obj-* dirs whose mozinfo metadata (topsrcdir,
131
- // topobjdir, mozconfig) still points at a different source tree. Running mach
132
- // watch against stale metadata produces confusing build errors.
133
- const mismatchMessage = buildArtifactMismatchMessage(paths.engine, buildCheck, 'Watch mode');
134
- if (mismatchMessage) {
135
- throw new GeneralError(mismatchMessage);
136
- }
137
- if (!buildCheck.exists) {
138
- const detail = buildCheck.objDir
139
- ? `Build artifacts incomplete in ${buildCheck.objDir}/`
140
- : 'No build artifacts found (obj-*/ directory missing)';
141
- throw new GeneralError(`Watch mode requires a completed build. ${detail}\n\n` +
142
- "Run 'fireforge build' first to create the initial build, then run 'fireforge watch'.");
143
- }
131
+ assertBuildArtifacts(paths.engine, buildCheck, {
132
+ label: 'Watch mode',
133
+ requirement: 'Watch mode requires a completed build.',
134
+ remediation: "Run 'fireforge build' first to create the initial build, then run 'fireforge watch'.",
135
+ requireExisting: true,
136
+ });
144
137
  // Report bundle state alongside the "Using build artifacts..." banner
145
138
  // so an operator watching a mid-build tree can see why `fireforge run`
146
139
  // would refuse right now while watch is still going. Watch remains
@@ -182,10 +175,18 @@ export async function watchCommand(projectRoot) {
182
175
  const watchmanDir = dirname(watchmanPath);
183
176
  const existingPath = process.env['PATH'] ?? '';
184
177
  const pathSegments = existingPath.split(delimiter).filter((segment) => segment.length > 0);
178
+ // Both arms filter identically; the cast this replaced laundered
179
+ // `string | undefined` on one arm while the other built the annotated shape
180
+ // honestly.
181
+ const baseEnv = {};
182
+ for (const [key, value] of Object.entries(process.env)) {
183
+ if (value !== undefined)
184
+ baseEnv[key] = value;
185
+ }
185
186
  const watchmanEnv = pathSegments.includes(watchmanDir)
186
- ? { ...process.env }
187
+ ? baseEnv
187
188
  : {
188
- ...process.env,
189
+ ...baseEnv,
189
190
  PATH: [watchmanDir, ...pathSegments].join(delimiter),
190
191
  };
191
192
  verbose(`watch: resolved watchman at ${watchmanPath}; forwarding directory in subprocess PATH.`);
@@ -105,7 +105,7 @@ async function assertDomTargetIsWireable(projectRoot, domFilePath, domTargetPath
105
105
  await probeDomFragmentInsertionPoint(paths.engine, domFilePath, domTargetPath);
106
106
  }
107
107
  catch (probeError) {
108
- throw new InvalidArgumentError(`${probeError instanceof Error ? probeError.message : String(probeError)}\n` +
108
+ throw new InvalidArgumentError(`${toError(probeError).message}\n` +
109
109
  `The resolved chrome document ${domTargetPath} does not expose an insertion anchor ` +
110
110
  'that `fireforge wire` recognises (`#include browser-sets.inc` or `<html:body>`). ' +
111
111
  'Add one of those anchors to the chrome doc, or target a document that has them via ' +
@@ -33,6 +33,17 @@ export declare function parseModule(content: string, onComment?: acorn.Comment[]
33
33
  /**
34
34
  * Convenience cast from `acorn.Node` (or the generic ESTree union returned
35
35
  * by estree-walker callbacks) to a positioned, narrowly-typed node.
36
+ *
37
+ * **Caller obligation: you must already have discriminated on `node.type`.**
38
+ * This performs no runtime check — it exists to attach acorn's `start`/`end`
39
+ * offsets to a node the caller has *already* narrowed, typically inside a
40
+ * `if (node.type === 'CallExpression')` branch or an equivalent walker guard.
41
+ * Calling it on an undiscriminated node produces a value whose type is a lie.
42
+ *
43
+ * A runtime `type` assertion was considered and rejected: every call site sits
44
+ * in the hot loop of a lint or registration walker, and all of them are
45
+ * already post-narrowing, so the check would cost traversal time on every node
46
+ * to re-prove something the enclosing branch established.
36
47
  */
37
48
  export declare function asEstree<T extends estree.Node>(node: estree.Node): AcornESTreeNode<T>;
38
49
  /**
@@ -40,6 +40,17 @@ export function parseModule(content, onComment) {
40
40
  /**
41
41
  * Convenience cast from `acorn.Node` (or the generic ESTree union returned
42
42
  * by estree-walker callbacks) to a positioned, narrowly-typed node.
43
+ *
44
+ * **Caller obligation: you must already have discriminated on `node.type`.**
45
+ * This performs no runtime check — it exists to attach acorn's `start`/`end`
46
+ * offsets to a node the caller has *already* narrowed, typically inside a
47
+ * `if (node.type === 'CallExpression')` branch or an equivalent walker guard.
48
+ * Calling it on an undiscriminated node produces a value whose type is a lie.
49
+ *
50
+ * A runtime `type` assertion was considered and rejected: every call site sits
51
+ * in the hot loop of a lint or registration walker, and all of them are
52
+ * already post-narrowing, so the check would cost traversal time on every node
53
+ * to re-prove something the enclosing branch established.
43
54
  */
44
55
  export function asEstree(node) {
45
56
  return node;
@@ -36,6 +36,7 @@
36
36
  import { dirname, join } from 'node:path';
37
37
  import { pathExists, readText } from '../utils/fs.js';
38
38
  import { getPlatform } from '../utils/platform.js';
39
+ import { escapeRegex } from '../utils/regex.js';
39
40
  /**
40
41
  * Tokens that uniquely identify a Windows-only `if CONFIG[...]:` block.
41
42
  * `MAKENSISU` is the Windows stubinstaller compiler; `OS_TARGET ==
@@ -96,7 +97,7 @@ async function findOwningMozBuild(engineDir, sourceDir) {
96
97
  * `"stubinstaller/installing_page.css"`.
97
98
  */
98
99
  function matchesQuotedBasename(line, basename) {
99
- const escaped = basename.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
100
+ const escaped = escapeRegex(basename);
100
101
  return new RegExp(`["'](?:[^"']*\\/)?${escaped}["']`).test(line);
101
102
  }
102
103
  /**
@@ -207,6 +208,8 @@ export async function detectPlatformGate(engineDir, sourcePath) {
207
208
  host = getPlatform();
208
209
  }
209
210
  catch {
211
+ // Unrecognised host platform. The platform gates below are advisory, so an
212
+ // unknown host disables them rather than failing a warn-only audit.
210
213
  host = undefined;
211
214
  }
212
215
  const sourceDir = dirname(join(engineDir, sourcePath));
@@ -217,6 +220,8 @@ export async function detectPlatformGate(engineDir, sourcePath) {
217
220
  content = await readText(mozBuild);
218
221
  }
219
222
  catch {
223
+ // An unreadable moz.build contributes no platform gates. The audit is
224
+ // warn-only, so a missing input degrades to 'no gate found', not an error.
220
225
  content = '';
221
226
  }
222
227
  const sourceBasename = sourcePath.split('/').pop() ?? '';
@@ -105,6 +105,8 @@ export async function findRegisteredTarget(engineDir, source) {
105
105
  content = await readText(jarMn);
106
106
  }
107
107
  catch {
108
+ // An unreadable jar.mn contributes no registration hints; the resolver falls
109
+ // through to its other strategies.
108
110
  content = '';
109
111
  }
110
112
  const rel = relative(current, sourceAbs).split(sep).join('/');
@@ -24,6 +24,19 @@ export declare function isTestPath(sourcePath: string): boolean;
24
24
  * @returns Count of matching trailing segments (basename always counts as 1)
25
25
  */
26
26
  export declare function countTrailingSegmentMatches(a: string, b: string): number;
27
+ /**
28
+ * Path segments too common to identify an artifact on their own.
29
+ *
30
+ * Shared deliberately: this set governs two halves of ONE decision, and the
31
+ * halves disagreed before 0.41.0. `scoreCandidate` (selection) used an
32
+ * 11-entry copy while `isConfidentMatch` (confirmation, in `build-audit.ts`)
33
+ * used a 17-entry one, so a source path containing `test`, `tests`, `unit`,
34
+ * `common`, `xpcshell` or `mochitest` earned a +1 selection bonus for a
35
+ * segment that confirmation then rejected as meaningless — the chosen
36
+ * artifact could never be confirmed. The union is authoritative; any addition
37
+ * must apply to both halves, which is now automatic.
38
+ */
39
+ export declare const GENERIC_PATH_SEGMENTS: ReadonlySet<string>;
27
40
  /**
28
41
  * Computes a score for `candidatePath` relative to `sourcePath`. Higher
29
42
  * scores win. Score = trailing-segment match count, with a bonus when
@@ -86,6 +86,37 @@ export function countTrailingSegmentMatches(a, b) {
86
86
  }
87
87
  return matches;
88
88
  }
89
+ /**
90
+ * Path segments too common to identify an artifact on their own.
91
+ *
92
+ * Shared deliberately: this set governs two halves of ONE decision, and the
93
+ * halves disagreed before 0.41.0. `scoreCandidate` (selection) used an
94
+ * 11-entry copy while `isConfidentMatch` (confirmation, in `build-audit.ts`)
95
+ * used a 17-entry one, so a source path containing `test`, `tests`, `unit`,
96
+ * `common`, `xpcshell` or `mochitest` earned a +1 selection bonus for a
97
+ * segment that confirmation then rejected as meaningless — the chosen
98
+ * artifact could never be confirmed. The union is authoritative; any addition
99
+ * must apply to both halves, which is now automatic.
100
+ */
101
+ export const GENERIC_PATH_SEGMENTS = new Set([
102
+ 'content',
103
+ 'chrome',
104
+ 'bin',
105
+ 'browser',
106
+ 'toolkit',
107
+ 'modules',
108
+ 'base',
109
+ 'app',
110
+ 'profile',
111
+ 'shared',
112
+ 'themes',
113
+ 'test',
114
+ 'tests',
115
+ 'unit',
116
+ 'common',
117
+ 'xpcshell',
118
+ 'mochitest',
119
+ ]);
89
120
  /**
90
121
  * Computes a score for `candidatePath` relative to `sourcePath`. Higher
91
122
  * scores win. Score = trailing-segment match count, with a bonus when
@@ -112,19 +143,7 @@ export function scoreCandidate(sourcePath, candidatePath) {
112
143
  // but are not part of the trailing match. Each unique mid-path hit on
113
144
  // a meaningful (>2-char, not generic like 'content'/'chrome'/'bin')
114
145
  // segment adds 1 to the score.
115
- const generic = new Set([
116
- 'content',
117
- 'chrome',
118
- 'bin',
119
- 'browser',
120
- 'toolkit',
121
- 'modules',
122
- 'base',
123
- 'app',
124
- 'profile',
125
- 'shared',
126
- 'themes',
127
- ]);
146
+ const generic = GENERIC_PATH_SEGMENTS;
128
147
  const trailingSet = new Set(sourceSegs.slice(sourceSegs.length - trailing));
129
148
  let bonus = 0;
130
149
  for (const seg of sourceSegs) {
@@ -166,6 +185,8 @@ export async function findAllByBasename(root, name, maxDepth = MAX_SCAN_DEPTH) {
166
185
  children = await readdir(entry.dir, { withFileTypes: true });
167
186
  }
168
187
  catch {
188
+ // An unreadable subdirectory yields no candidates. Skip it and keep
189
+ // scoring the rest rather than abandoning the whole resolution.
169
190
  continue;
170
191
  }
171
192
  for (const child of children) {
@@ -39,7 +39,7 @@ import { pathExists } from '../utils/fs.js';
39
39
  import { info, verbose, warn } from '../utils/logger.js';
40
40
  import { detectPlatformGate } from './build-audit-platform.js';
41
41
  import { collectSameBasenameCandidates, findRegisteredTarget, resolveArtifactByRegistration, } from './build-audit-registration.js';
42
- import { countTrailingSegmentMatches, isTestPath, resolveBestArtifact, } from './build-audit-resolve.js';
42
+ import { countTrailingSegmentMatches, GENERIC_PATH_SEGMENTS, isTestPath, resolveBestArtifact, } from './build-audit-resolve.js';
43
43
  import { resolveArtifactByKnownTransform } from './build-audit-transforms.js';
44
44
  import { collectChangedEnginePaths } from './engine-changes.js';
45
45
  /** Path extensions that are conventionally packaged into the Firefox bundle. */
@@ -138,6 +138,7 @@ async function resolveDistRoot(engineDir) {
138
138
  entries = await readdir(engineDir);
139
139
  }
140
140
  catch {
141
+ // No readable engine directory means no objdir can be identified.
141
142
  return undefined;
142
143
  }
143
144
  const objDirs = entries.filter((e) => e.startsWith('obj-'));
@@ -161,6 +162,7 @@ async function resolveTestsRoot(engineDir) {
161
162
  entries = await readdir(engineDir);
162
163
  }
163
164
  catch {
165
+ // No readable engine directory means no objdir can be identified.
164
166
  return undefined;
165
167
  }
166
168
  const objDirs = entries.filter((e) => e.startsWith('obj-'));
@@ -236,25 +238,7 @@ function isConfidentMatch(source, candidate) {
236
238
  }
237
239
  const sourceSegs = source.split('/').filter(Boolean);
238
240
  const candSegs = candidate.split('/').filter(Boolean);
239
- const generic = new Set([
240
- 'content',
241
- 'chrome',
242
- 'bin',
243
- 'browser',
244
- 'toolkit',
245
- 'modules',
246
- 'base',
247
- 'app',
248
- 'profile',
249
- 'shared',
250
- 'themes',
251
- 'test',
252
- 'tests',
253
- 'unit',
254
- 'common',
255
- 'xpcshell',
256
- 'mochitest',
257
- ]);
241
+ const generic = GENERIC_PATH_SEGMENTS;
258
242
  // Skip the basename itself (which trail-matches by definition).
259
243
  for (let i = 0; i < sourceSegs.length - 1; i += 1) {
260
244
  const seg = sourceSegs[i];