@hominis/fireforge 0.40.0 → 0.42.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 (256) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +16 -10
  3. package/dist/bin/fireforge.js +25 -4
  4. package/dist/build-info.json +9 -0
  5. package/dist/src/cli.js +20 -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 +13 -3
  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 +16 -7
  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 +12 -59
  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.js +4 -4
  45. package/dist/src/commands/manifest.js +1 -1
  46. package/dist/src/commands/package.js +9 -16
  47. package/dist/src/commands/patch/compact.js +5 -5
  48. package/dist/src/commands/patch/delete.js +5 -5
  49. package/dist/src/commands/patch/lint-ignore.js +3 -2
  50. package/dist/src/commands/patch/move-files-create.js +22 -4
  51. package/dist/src/commands/patch/move-files-into.js +14 -10
  52. package/dist/src/commands/patch/move-files.js +6 -5
  53. package/dist/src/commands/patch/rename.d.ts +2 -2
  54. package/dist/src/commands/patch/rename.js +151 -50
  55. package/dist/src/commands/patch/reorder.d.ts +9 -0
  56. package/dist/src/commands/patch/reorder.js +14 -10
  57. package/dist/src/commands/patch/split-plan.d.ts +13 -6
  58. package/dist/src/commands/patch/split-plan.js +22 -12
  59. package/dist/src/commands/patch/split.d.ts +1 -1
  60. package/dist/src/commands/patch/split.js +23 -9
  61. package/dist/src/commands/patch/staged-dependency-validate.d.ts +15 -0
  62. package/dist/src/commands/patch/staged-dependency-validate.js +49 -0
  63. package/dist/src/commands/patch/staged-dependency.js +10 -0
  64. package/dist/src/commands/re-export-adjacent.js +5 -6
  65. package/dist/src/commands/re-export-bulk-scan.d.ts +13 -0
  66. package/dist/src/commands/re-export-bulk-scan.js +142 -2
  67. package/dist/src/commands/re-export-drift.d.ts +74 -0
  68. package/dist/src/commands/re-export-drift.js +234 -0
  69. package/dist/src/commands/re-export-files.js +5 -3
  70. package/dist/src/commands/re-export-lint.d.ts +97 -0
  71. package/dist/src/commands/re-export-lint.js +210 -0
  72. package/dist/src/commands/re-export-options.js +8 -0
  73. package/dist/src/commands/re-export-register.d.ts +18 -0
  74. package/dist/src/commands/re-export-register.js +61 -0
  75. package/dist/src/commands/re-export-scan.js +13 -7
  76. package/dist/src/commands/re-export-single.d.ts +20 -0
  77. package/dist/src/commands/re-export-single.js +261 -0
  78. package/dist/src/commands/re-export.d.ts +0 -4
  79. package/dist/src/commands/re-export.js +113 -268
  80. package/dist/src/commands/rebase/abort.js +13 -4
  81. package/dist/src/commands/rebase/continue.js +11 -4
  82. package/dist/src/commands/rebase/index.js +19 -29
  83. package/dist/src/commands/rebase/patch-loop.js +12 -0
  84. package/dist/src/commands/reset.js +4 -23
  85. package/dist/src/commands/resolve.js +2 -9
  86. package/dist/src/commands/run.js +11 -21
  87. package/dist/src/commands/source.js +3 -9
  88. package/dist/src/commands/status-check.d.ts +13 -0
  89. package/dist/src/commands/status-check.js +31 -13
  90. package/dist/src/commands/status-json.d.ts +31 -0
  91. package/dist/src/commands/status-json.js +76 -0
  92. package/dist/src/commands/status-output.d.ts +1 -0
  93. package/dist/src/commands/status-output.js +10 -1
  94. package/dist/src/commands/status-ownership.d.ts +52 -0
  95. package/dist/src/commands/status-ownership.js +57 -0
  96. package/dist/src/commands/status.js +184 -175
  97. package/dist/src/commands/test-build-phase.d.ts +19 -0
  98. package/dist/src/commands/test-build-phase.js +168 -0
  99. package/dist/src/commands/test-diagnose.js +30 -2
  100. package/dist/src/commands/test-modes.d.ts +9 -1
  101. package/dist/src/commands/test-modes.js +46 -13
  102. package/dist/src/commands/test-register.js +59 -27
  103. package/dist/src/commands/test-run.d.ts +29 -3
  104. package/dist/src/commands/test-run.js +41 -4
  105. package/dist/src/commands/test-verdict.d.ts +32 -0
  106. package/dist/src/commands/test-verdict.js +48 -0
  107. package/dist/src/commands/test.d.ts +9 -0
  108. package/dist/src/commands/test.js +100 -123
  109. package/dist/src/commands/token-coverage.js +4 -9
  110. package/dist/src/commands/tree.d.ts +3 -0
  111. package/dist/src/commands/tree.js +171 -25
  112. package/dist/src/commands/verify.js +2 -2
  113. package/dist/src/commands/watch.js +22 -21
  114. package/dist/src/commands/wire.js +1 -1
  115. package/dist/src/core/ast-utils.d.ts +11 -0
  116. package/dist/src/core/ast-utils.js +11 -0
  117. package/dist/src/core/branding.d.ts +16 -0
  118. package/dist/src/core/branding.js +18 -2
  119. package/dist/src/core/build-audit-platform.js +6 -1
  120. package/dist/src/core/build-audit-registration.js +2 -0
  121. package/dist/src/core/build-audit-resolve.d.ts +13 -0
  122. package/dist/src/core/build-audit-resolve.js +34 -13
  123. package/dist/src/core/build-audit.js +4 -20
  124. package/dist/src/core/build-baseline-types.d.ts +29 -8
  125. package/dist/src/core/build-baseline-types.js +2 -1
  126. package/dist/src/core/build-baseline.d.ts +10 -2
  127. package/dist/src/core/build-baseline.js +39 -8
  128. package/dist/src/core/build-prepare.d.ts +4 -0
  129. package/dist/src/core/build-prepare.js +7 -1
  130. package/dist/src/core/config-paths.d.ts +1 -1
  131. package/dist/src/core/config-paths.js +2 -0
  132. package/dist/src/core/config-validate-test-toolchains.js +3 -2
  133. package/dist/src/core/config-validate.js +34 -36
  134. package/dist/src/core/config.js +16 -3
  135. package/dist/src/core/coverage-extend.d.ts +89 -0
  136. package/dist/src/core/coverage-extend.js +188 -0
  137. package/dist/src/core/destructive.js +3 -1
  138. package/dist/src/core/discard-baseline.js +18 -4
  139. package/dist/src/core/engine-precondition.d.ts +35 -0
  140. package/dist/src/core/engine-precondition.js +50 -0
  141. package/dist/src/core/engine-session-lock.d.ts +38 -1
  142. package/dist/src/core/engine-session-lock.js +78 -4
  143. package/dist/src/core/file-lock.d.ts +7 -0
  144. package/dist/src/core/file-lock.js +20 -30
  145. package/dist/src/core/firefox.js +5 -2
  146. package/dist/src/core/furnace-apply-helpers.d.ts +12 -1
  147. package/dist/src/core/furnace-apply-helpers.js +12 -3
  148. package/dist/src/core/furnace-apply-output.js +9 -0
  149. package/dist/src/core/furnace-apply-overwrite-warn.d.ts +63 -0
  150. package/dist/src/core/furnace-apply-overwrite-warn.js +123 -0
  151. package/dist/src/core/furnace-apply-undeploy-actions.d.ts +11 -0
  152. package/dist/src/core/furnace-apply-undeploy-actions.js +49 -0
  153. package/dist/src/core/furnace-apply.d.ts +16 -4
  154. package/dist/src/core/furnace-apply.js +39 -43
  155. package/dist/src/core/furnace-config.js +2 -0
  156. package/dist/src/core/furnace-css-fragments.js +2 -0
  157. package/dist/src/core/furnace-jsconfig.js +14 -7
  158. package/dist/src/core/furnace-registration-ast.js +8 -8
  159. package/dist/src/core/furnace-registration-remove.js +6 -3
  160. package/dist/src/core/furnace-registration.js +9 -12
  161. package/dist/src/core/furnace-rollback.js +1 -1
  162. package/dist/src/core/furnace-scanner.js +2 -1
  163. package/dist/src/core/furnace-stale-export.js +2 -1
  164. package/dist/src/core/furnace-state-persist.d.ts +4 -6
  165. package/dist/src/core/furnace-validate-compatibility.js +4 -7
  166. package/dist/src/core/furnace-validate-helpers.js +33 -1
  167. package/dist/src/core/furnace-validate-registration.js +16 -0
  168. package/dist/src/core/furnace-validate.js +4 -2
  169. package/dist/src/core/git-diff.js +37 -2
  170. package/dist/src/core/lint-cache.d.ts +1 -1
  171. package/dist/src/core/lint-cache.js +33 -10
  172. package/dist/src/core/mach-build-artifacts.d.ts +63 -1
  173. package/dist/src/core/mach-build-artifacts.js +108 -2
  174. package/dist/src/core/mach-mozconfig.js +9 -1
  175. package/dist/src/core/mach-resource-shim.js +1 -0
  176. package/dist/src/core/marionette-port.d.ts +16 -0
  177. package/dist/src/core/marionette-port.js +93 -8
  178. package/dist/src/core/marionette-preflight.js +6 -5
  179. package/dist/src/core/patch-apply.d.ts +20 -13
  180. package/dist/src/core/patch-apply.js +32 -33
  181. package/dist/src/core/patch-export.js +3 -5
  182. package/dist/src/core/patch-lint-checkjs.d.ts +25 -3
  183. package/dist/src/core/patch-lint-checkjs.js +78 -7
  184. package/dist/src/core/patch-lint-chrome-jsdoc.js +9 -0
  185. package/dist/src/core/patch-lint-creators.d.ts +30 -0
  186. package/dist/src/core/patch-lint-creators.js +45 -0
  187. package/dist/src/core/patch-lint-cross.d.ts +1 -17
  188. package/dist/src/core/patch-lint-cross.js +16 -32
  189. package/dist/src/core/patch-lint-jsdoc.d.ts +3 -1
  190. package/dist/src/core/patch-lint-jsdoc.js +15 -5
  191. package/dist/src/core/patch-lint-module-registration.d.ts +15 -0
  192. package/dist/src/core/patch-lint-module-registration.js +88 -0
  193. package/dist/src/core/patch-lint-mozbuild.js +99 -11
  194. package/dist/src/core/patch-lint-reexports.d.ts +1 -0
  195. package/dist/src/core/patch-lint-reexports.js +1 -0
  196. package/dist/src/core/patch-lint.d.ts +8 -0
  197. package/dist/src/core/patch-lint.js +84 -6
  198. package/dist/src/core/patch-lock.d.ts +12 -1
  199. package/dist/src/core/patch-lock.js +40 -4
  200. package/dist/src/core/patch-manifest-consistency.js +15 -3
  201. package/dist/src/core/patch-parse.d.ts +8 -0
  202. package/dist/src/core/patch-parse.js +10 -0
  203. package/dist/src/core/patch-policy.d.ts +7 -0
  204. package/dist/src/core/patch-policy.js +4 -1
  205. package/dist/src/core/patch-transform.d.ts +10 -0
  206. package/dist/src/core/patch-transform.js +13 -2
  207. package/dist/src/core/rebase-session.d.ts +44 -2
  208. package/dist/src/core/rebase-session.js +114 -17
  209. package/dist/src/core/register-browser-content.js +2 -1
  210. package/dist/src/core/register-shared-css.js +2 -1
  211. package/dist/src/core/smoke-patterns.d.ts +0 -8
  212. package/dist/src/core/smoke-patterns.js +3 -2
  213. package/dist/src/core/status-binary.d.ts +55 -0
  214. package/dist/src/core/status-binary.js +124 -0
  215. package/dist/src/core/status-classify.d.ts +6 -1
  216. package/dist/src/core/status-classify.js +101 -73
  217. package/dist/src/core/test-harness-crash.d.ts +32 -1
  218. package/dist/src/core/test-harness-crash.js +59 -4
  219. package/dist/src/core/test-path-scope.js +2 -0
  220. package/dist/src/core/test-stale-check.js +1 -20
  221. package/dist/src/core/toolchain-preflight.js +24 -26
  222. package/dist/src/core/tree-guard.d.ts +1 -1
  223. package/dist/src/core/tree-guard.js +67 -12
  224. package/dist/src/core/tree-store.d.ts +86 -10
  225. package/dist/src/core/tree-store.js +310 -72
  226. package/dist/src/core/typecheck.js +2 -1
  227. package/dist/src/core/wire-destroy.js +14 -3
  228. package/dist/src/core/wire-init.js +37 -40
  229. package/dist/src/core/wire-subscript.js +11 -3
  230. package/dist/src/core/wire-utils.js +4 -4
  231. package/dist/src/core/xpcshell-appdir.js +3 -0
  232. package/dist/src/errors/base.d.ts +13 -0
  233. package/dist/src/errors/base.js +13 -0
  234. package/dist/src/errors/rebase.d.ts +13 -0
  235. package/dist/src/errors/rebase.js +17 -0
  236. package/dist/src/types/commands/options.d.ts +86 -1
  237. package/dist/src/types/commands/patches.d.ts +8 -0
  238. package/dist/src/types/furnace.d.ts +7 -0
  239. package/dist/src/utils/build-info.d.ts +31 -0
  240. package/dist/src/utils/build-info.js +98 -0
  241. package/dist/src/utils/concurrency.d.ts +8 -0
  242. package/dist/src/utils/concurrency.js +21 -0
  243. package/dist/src/utils/errors.d.ts +39 -0
  244. package/dist/src/utils/errors.js +55 -0
  245. package/dist/src/utils/fs.js +5 -12
  246. package/dist/src/utils/logger.d.ts +14 -2
  247. package/dist/src/utils/logger.js +43 -13
  248. package/dist/src/utils/options.d.ts +21 -4
  249. package/dist/src/utils/options.js +26 -6
  250. package/dist/src/utils/package-root.js +7 -4
  251. package/dist/src/utils/process-group.js +13 -2
  252. package/dist/src/utils/stdio-drain.d.ts +28 -0
  253. package/dist/src/utils/stdio-drain.js +54 -0
  254. package/dist/src/utils/validation.d.ts +23 -2
  255. package/dist/src/utils/validation.js +29 -3
  256. package/package.json +9 -9
@@ -0,0 +1,188 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * `test --build/--build-only --extend-coverage` (FORGE L1): unions the
4
+ * requested paths into the recorded `testPackagingCoverage` instead of
5
+ * replacing it, under an anchor guard.
6
+ *
7
+ * Coverage REPLACES by default for a documented reason
8
+ * (`build-baseline-types.ts`): every baseline write refreshes
9
+ * `packageableFingerprints` for ALL dirty packageable paths, so a blind
10
+ * union would whitewash an earlier scope's edited fixtures while
11
+ * `obj-*`/`_tests/` still holds that scope's stale staging. Extending is
12
+ * honest only while everything the PREVIOUS record vouched for is still
13
+ * true, which is what {@link checkExtendCoverageAnchor} and
14
+ * {@link checkExtendMozconfigAnchor} verify:
15
+ *
16
+ * 1. engine HEAD is unchanged — every committed edit is excluded;
17
+ * 2. every path the previous baseline fingerprinted still hashes to the
18
+ * recorded digest — the earlier scope's staging inputs are byte-identical.
19
+ * Files that became dirty SINCE that build are fine: the current
20
+ * whole-tree `mach build faster` repackages `dist/` and vouches for them;
21
+ * 3. the generated `engine/mozconfig` is unchanged. Engine HEAD does not
22
+ * cover this — the mozconfig is regenerated from project-side
23
+ * `configs/*.mozconfig` templates plus `fireforge.json` on every build,
24
+ * so two builds at the same engine SHA can configure differently.
25
+ *
26
+ * Every failure refuses fail-closed; the operator's remedy is always a plain
27
+ * scoped build, which resets the claim to the paths it actually vouches for.
28
+ *
29
+ * Known boundary, documented rather than closed: dirty NON-packageable
30
+ * fixtures under previously covered paths (plain test files, manifests
31
+ * `isPackageablePath` rejects) are invisible to check 2. Editing one
32
+ * uncommitted between two builds leaves the earlier scope's `_tests/`
33
+ * staging stale while extend still vouches for it. Plain REPLACE has no
34
+ * such window, because the earlier scope simply stops being covered.
35
+ */
36
+ import { createHash } from 'node:crypto';
37
+ import { readFile } from 'node:fs/promises';
38
+ import { join } from 'node:path';
39
+ import { getNodeErrorCode, toError } from '../utils/errors.js';
40
+ import { verbose } from '../utils/logger.js';
41
+ import { DELETED_FILE_FINGERPRINT, } from './build-baseline-types.js';
42
+ import { getHead, isMissingHeadError } from './git.js';
43
+ /** Filename of the generated mozconfig, relative to the engine directory. */
44
+ const MOZCONFIG_FILENAME = 'mozconfig';
45
+ /** How many diverged paths a refusal names before truncating. */
46
+ const MAX_DIVERGED_REPORTED = 5;
47
+ /**
48
+ * Verifies the parts of the anchor knowable before the build runs: a usable
49
+ * previous baseline, an unchanged engine HEAD, and byte-identical content
50
+ * for every path that baseline fingerprinted.
51
+ * @param engineDir - Path to the engine directory
52
+ * @param previousBaseline - Baseline recorded by the previous build
53
+ */
54
+ export async function checkExtendCoverageAnchor(engineDir, previousBaseline) {
55
+ if (previousBaseline === undefined) {
56
+ return { ok: false, reason: 'no-baseline', detail: [] };
57
+ }
58
+ if (previousBaseline.mozconfigHash === undefined) {
59
+ return { ok: false, reason: 'no-mozconfig-hash', detail: [] };
60
+ }
61
+ const recordedFingerprints = previousBaseline.packageableFingerprints;
62
+ if (recordedFingerprints === undefined) {
63
+ return { ok: false, reason: 'no-fingerprints', detail: [] };
64
+ }
65
+ const currentHead = await readEngineHead(engineDir);
66
+ if (currentHead === undefined || previousBaseline.engineHeadSha === '') {
67
+ // An unborn or unreadable HEAD gives nothing to anchor to.
68
+ return { ok: false, reason: 'head-moved', detail: [] };
69
+ }
70
+ if (currentHead !== previousBaseline.engineHeadSha) {
71
+ return {
72
+ ok: false,
73
+ reason: 'head-moved',
74
+ detail: [`recorded ${previousBaseline.engineHeadSha}`, `current ${currentHead}`],
75
+ };
76
+ }
77
+ const diverged = [];
78
+ for (const [relPath, recordedHash] of Object.entries(recordedFingerprints)) {
79
+ const liveHash = await hashEngineFile(engineDir, relPath);
80
+ if (liveHash !== recordedHash)
81
+ diverged.push(relPath);
82
+ }
83
+ if (diverged.length > 0) {
84
+ return { ok: false, reason: 'fingerprint-diverged', detail: diverged.sort() };
85
+ }
86
+ return { ok: true };
87
+ }
88
+ /**
89
+ * Verifies the mozconfig half of the anchor. Split from
90
+ * {@link checkExtendCoverageAnchor} because it can only run AFTER
91
+ * `prepareBuildEnvironment` regenerates `engine/mozconfig` — that file is
92
+ * the one this build will configure with — and must still run BEFORE mach,
93
+ * so a refusal costs no build time.
94
+ * @param engineDir - Path to the engine directory
95
+ * @param previousBaseline - Baseline recorded by the previous build
96
+ */
97
+ export async function checkExtendMozconfigAnchor(engineDir, previousBaseline) {
98
+ const recorded = previousBaseline?.mozconfigHash;
99
+ if (recorded === undefined) {
100
+ return { ok: false, reason: 'no-mozconfig-hash', detail: [] };
101
+ }
102
+ const live = await hashEngineFile(engineDir, MOZCONFIG_FILENAME);
103
+ if (live === undefined || live !== recorded) {
104
+ return { ok: false, reason: 'mozconfig-changed', detail: [] };
105
+ }
106
+ return { ok: true };
107
+ }
108
+ /**
109
+ * Unions the newly built paths onto the recorded claim. A `'full'` claim
110
+ * (or a pre-0.37.0 absent one, which means full historically) stays
111
+ * `'full'` — it already covers everything.
112
+ * @param previous - Coverage recorded by the previous build
113
+ * @param requested - Normalized request paths this build packaged
114
+ */
115
+ export function unionTestPackagingCoverage(previous, requested) {
116
+ if (previous === undefined || previous === 'full') {
117
+ return 'full';
118
+ }
119
+ return [...new Set([...previous, ...requested])].sort();
120
+ }
121
+ /**
122
+ * Renders the fail-closed refusal for an anchor check that did not pass.
123
+ * Kept separate from the probes so tests can assert the structured result
124
+ * without matching copy.
125
+ * @param result - Failing result from an anchor check
126
+ */
127
+ export function formatExtendCoverageRefusal(result) {
128
+ const remedy = 'Re-run without --extend-coverage: a plain scoped build REPLACES the coverage claim ' +
129
+ 'with the paths it actually vouches for.';
130
+ switch (result.reason) {
131
+ case 'no-baseline':
132
+ return `--extend-coverage has nothing to extend: no previous build baseline is recorded. ${remedy}`;
133
+ case 'no-mozconfig-hash':
134
+ return ('--extend-coverage refused: the recorded baseline predates the mozconfig anchor, so it ' +
135
+ `cannot be proven that this build configures identically. ${remedy}`);
136
+ case 'no-fingerprints':
137
+ return ('--extend-coverage refused: the recorded baseline carries no packageable fingerprints, so ' +
138
+ `the earlier scope's staging inputs cannot be verified unchanged. ${remedy}`);
139
+ case 'head-moved':
140
+ return ('--extend-coverage refused: engine HEAD moved since the recorded build ' +
141
+ `(${result.detail.join(', ') || 'no usable HEAD'}), so the earlier scope's packaging is no ` +
142
+ `longer anchored. ${remedy}`);
143
+ case 'fingerprint-diverged':
144
+ return ('--extend-coverage refused: ' +
145
+ `${String(result.detail.length)} packageable file(s) the recorded build staged have changed ` +
146
+ `since (${formatDivergedPaths(result.detail)}). Extending would vouch for the earlier scope ` +
147
+ `while obj-*/_tests/ still holds its stale staging. ${remedy}`);
148
+ case 'mozconfig-changed':
149
+ return ('--extend-coverage refused: engine/mozconfig differs from the recorded build (it is ' +
150
+ 'regenerated from configs/*.mozconfig and fireforge.json on every build, so engine HEAD ' +
151
+ `does not cover it). ${remedy}`);
152
+ }
153
+ }
154
+ function formatDivergedPaths(paths) {
155
+ const head = paths.slice(0, MAX_DIVERGED_REPORTED).join(', ');
156
+ const extra = paths.length - Math.min(paths.length, MAX_DIVERGED_REPORTED);
157
+ return extra > 0 ? `${head}, … (+${String(extra)} more)` : head;
158
+ }
159
+ /**
160
+ * Hex-encoded SHA-256 of an engine-relative file, matching the digest the
161
+ * baseline writer produces. `undefined` on any IO error, which every caller
162
+ * treats as "cannot prove unchanged" — i.e. a refusal.
163
+ * @param engineDir - Path to the engine directory
164
+ * @param relPath - Engine-relative POSIX path
165
+ */
166
+ export async function hashEngineFile(engineDir, relPath) {
167
+ try {
168
+ const buffer = await readFile(join(engineDir, relPath));
169
+ return createHash('sha256').update(buffer).digest('hex');
170
+ }
171
+ catch (error) {
172
+ if (getNodeErrorCode(error) === 'ENOENT') {
173
+ return DELETED_FILE_FINGERPRINT;
174
+ }
175
+ verbose(`Extend-coverage anchor: could not hash ${relPath} — ${toError(error).message}`);
176
+ return undefined;
177
+ }
178
+ }
179
+ async function readEngineHead(engineDir) {
180
+ try {
181
+ return await getHead(engineDir);
182
+ }
183
+ catch (error) {
184
+ if (isMissingHeadError(error))
185
+ return undefined;
186
+ throw error;
187
+ }
188
+ }
@@ -109,7 +109,9 @@ export async function confirmDestructive(input) {
109
109
  message: `${title} — proceed?`,
110
110
  initialValue: false,
111
111
  });
112
- if (isCancel(confirmed) || confirmed !== true) {
112
+ // `isCancel` narrows the clack sentinel away, leaving a plain boolean —
113
+ // the defensive `!== true` this replaced is now provably redundant.
114
+ if (isCancel(confirmed) || !confirmed) {
113
115
  return 'cancelled';
114
116
  }
115
117
  return 'proceed';
@@ -20,7 +20,7 @@ import { GeneralError } from '../errors/base.js';
20
20
  import { toError } from '../utils/errors.js';
21
21
  import { readText, removeFile, writeText } from '../utils/fs.js';
22
22
  import { discardStatusEntry, fileExistsInHead, listTrackedInHead, restoreTrackedPath, unstageFiles, } from './git-file-ops.js';
23
- import { createPatchedContentComputer } from './patch-apply.js';
23
+ import { createPatchedContentContext } from './patch-apply.js';
24
24
  import { loadPatchesManifestState } from './patch-manifest-io.js';
25
25
  import { parseDiffSections } from './patch-parse.js';
26
26
  import { buildPatchClaims } from './status-classify.js';
@@ -53,7 +53,7 @@ export async function planDiscardBaselines(patchesDir, engineDir, entries) {
53
53
  if (claimedEntries.length === 0) {
54
54
  return planUpstreamDiscards(entries);
55
55
  }
56
- const computePatched = await createPatchedContentComputer(patchesDir, engineDir);
56
+ const { computePatched } = await createPatchedContentContext(patchesDir, engineDir);
57
57
  const claimedFiles = [
58
58
  ...new Set(claimedEntries.flatMap((entry) => entry.originalPath !== undefined ? [entry.file, entry.originalPath] : [entry.file])),
59
59
  ];
@@ -84,8 +84,22 @@ export async function planDiscardBaselines(patchesDir, engineDir, entries) {
84
84
  try {
85
85
  sections = parseDiffSections(await readText(join(patchesDir, owner)));
86
86
  }
87
- catch {
88
- sections = [];
87
+ catch (error) {
88
+ // Fail closed. Swallowing this yielded no sections, which falls
89
+ // through to `return false` — "not deleted at baseline" — so a path
90
+ // the owning patch DELETES would be restored as present, i.e.
91
+ // rewritten instead of removed: silent data loss on the command
92
+ // whose header promises a refusal for exactly this class of failure.
93
+ //
94
+ // In the current flow `computeExpected` above reads the same patch
95
+ // first and refuses, so this is defence-in-depth against a TOCTOU
96
+ // (the patch file removed or made unreadable mid-plan) rather than a
97
+ // reachable branch today. It refuses with the same remediation as
98
+ // its sibling so the two cannot drift into disagreeing.
99
+ throw new GeneralError(`Cannot read the owning patch ${owner} to determine the baseline for ${path} ` +
100
+ `(${toError(error).message}). ` +
101
+ 'Fix the owning patch, or pass --to-upstream to explicitly revert to pristine ' +
102
+ 'upstream (HEAD).');
89
103
  }
90
104
  sectionCache.set(owner, sections);
91
105
  }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Shared engine precondition ladder for commands that read or mutate
3
+ * `engine/` through git.
4
+ *
5
+ * Five commands carried their own copy of this three-rung check, and **two of
6
+ * them were truncated**: `resolve.ts` and `token-coverage.ts` stopped after
7
+ * the `isGitRepository` rung and omitted the unborn-HEAD guard entirely
8
+ * (neither even imported `getHead`/`isMissingHeadError`). Both then went on to
9
+ * enumerate working-tree status, and against an unborn HEAD the whole
10
+ * ~300k-file Firefox tree reads as untracked — so an engine left half
11
+ * initialised by an interrupted `download` produced a nonsensical result
12
+ * rather than the actionable refusal the other three commands gave.
13
+ *
14
+ * The remediation paragraph also existed in three independently-editable
15
+ * copies that had already drifted. It lives here once now.
16
+ */
17
+ /** Options for {@link assertEngineGitReady}. */
18
+ export interface EngineGitReadyOptions {
19
+ /**
20
+ * Appended to the unborn-HEAD remediation, e.g. `', then retry the rebase.'`
21
+ * Preserves the one deliberate wording divergence between the copies. The
22
+ * base message deliberately ends WITHOUT punctuation so the suffix reads as
23
+ * one sentence; callers that pass nothing get a plain full stop.
24
+ */
25
+ unbornHeadSuffix?: string;
26
+ }
27
+ /**
28
+ * Asserts that `engineDir` exists, is a git repository, and has a baseline
29
+ * commit — in that order, so the operator gets the most specific applicable
30
+ * remediation rather than a downstream git error.
31
+ *
32
+ * @param engineDir - Absolute path to the engine checkout
33
+ * @param options - Optional per-command message tailoring
34
+ */
35
+ export declare function assertEngineGitReady(engineDir: string, options?: EngineGitReadyOptions): Promise<void>;
@@ -0,0 +1,50 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Shared engine precondition ladder for commands that read or mutate
4
+ * `engine/` through git.
5
+ *
6
+ * Five commands carried their own copy of this three-rung check, and **two of
7
+ * them were truncated**: `resolve.ts` and `token-coverage.ts` stopped after
8
+ * the `isGitRepository` rung and omitted the unborn-HEAD guard entirely
9
+ * (neither even imported `getHead`/`isMissingHeadError`). Both then went on to
10
+ * enumerate working-tree status, and against an unborn HEAD the whole
11
+ * ~300k-file Firefox tree reads as untracked — so an engine left half
12
+ * initialised by an interrupted `download` produced a nonsensical result
13
+ * rather than the actionable refusal the other three commands gave.
14
+ *
15
+ * The remediation paragraph also existed in three independently-editable
16
+ * copies that had already drifted. It lives here once now.
17
+ */
18
+ import { GeneralError } from '../errors/base.js';
19
+ import { pathExists } from '../utils/fs.js';
20
+ import { getHead, isGitRepository, isMissingHeadError } from './git.js';
21
+ /** Remediation for an engine whose baseline commit was never created. */
22
+ const UNBORN_HEAD_REMEDIATION = 'Engine repository has no baseline commit yet — a previous "fireforge download" was ' +
23
+ 'interrupted before git created the initial Firefox source commit. ' +
24
+ // No terminating period: the suffix below supplies its own punctuation, and
25
+ // hardcoding one here rendered as "…cleanly., then retry the rebase."
26
+ 'Re-run "fireforge download --force" to recreate the baseline repository cleanly';
27
+ /**
28
+ * Asserts that `engineDir` exists, is a git repository, and has a baseline
29
+ * commit — in that order, so the operator gets the most specific applicable
30
+ * remediation rather than a downstream git error.
31
+ *
32
+ * @param engineDir - Absolute path to the engine checkout
33
+ * @param options - Optional per-command message tailoring
34
+ */
35
+ export async function assertEngineGitReady(engineDir, options = {}) {
36
+ if (!(await pathExists(engineDir))) {
37
+ throw new GeneralError('Firefox source not found. Run "fireforge download" first.');
38
+ }
39
+ if (!(await isGitRepository(engineDir))) {
40
+ throw new GeneralError('Engine directory is not a git repository. Run "fireforge download" to initialize.');
41
+ }
42
+ try {
43
+ await getHead(engineDir);
44
+ }
45
+ catch (headError) {
46
+ if (!isMissingHeadError(headError))
47
+ throw headError;
48
+ throw new GeneralError(`${UNBORN_HEAD_REMEDIATION}${options.unbornHeadSuffix ?? '.'}`);
49
+ }
50
+ }
@@ -8,14 +8,51 @@ export interface EngineSessionLockOptions {
8
8
  waitLockSeconds?: number | undefined;
9
9
  }
10
10
  /**
11
- *
11
+ * Runs `operation` while holding the project's engine-session lock, so only
12
+ * one engine-mutating FireForge command touches `engine/` at a time.
12
13
  */
13
14
  export declare function withEngineSessionLock<T>(projectRoot: string, command: string, operation: () => Promise<T>, options?: EngineSessionLockOptions): Promise<T>;
14
15
  /**
16
+ * Captures an opaque token describing the engine's current commit and working
17
+ * tree, for comparison by {@link assertEngineGenerationUnchanged}.
15
18
  *
19
+ * A failed probe yields a failure token. Callers must branch on
20
+ * {@link isUnavailableGenerationToken} rather than comparing the token,
21
+ * because a probe failure means "nothing was measured", not "nothing changed".
16
22
  */
17
23
  export declare function snapshotEngineGeneration(engineDir: string): Promise<string>;
18
24
  /**
25
+ * True when a generation token records a failed probe — i.e. nothing was
26
+ * measured, so the token must never be compared (or hashed) as if it
27
+ * described the engine's state.
28
+ */
29
+ export declare function isUnavailableGenerationToken(token: string): boolean;
30
+ /** Strips the marker prefix off an unavailable token for display. */
31
+ export declare function unavailableGenerationReason(token: string): string;
32
+ /**
33
+ * Throws when the engine's generation token differs from `before`, i.e. the
34
+ * engine was mutated by another writer while a test run was in flight.
35
+ *
36
+ * **An unmeasurable engine warns rather than throws.** `engine/` is not always
37
+ * a git checkout — `fireforge download` extracts a source tarball — so a
38
+ * failed probe is a legitimate state and must not fail the run.
39
+ *
40
+ * What it must also not do is pass *silently*. Before 0.41.0 the failure token
41
+ * was `unavailable:<message>` and was compared for equality like any other, so
42
+ * a git failure that reproduced across both probes (unreadable `.git`, a
43
+ * permissions problem, a corrupt index) produced identical tokens, took the
44
+ * `after === before` early return, and blessed a verdict the guard had never
45
+ * verified — the precise outcome this guard exists to prevent. Two *differing*
46
+ * failure messages were worse still: they fell through to the mutation branch
47
+ * and reported a spurious "engine/ changed".
19
48
  *
49
+ * The tolerance is deliberately limited to a probe that failed *both* times:
50
+ * that is the steady state of a non-git `engine/`, and it warns. A one-sided
51
+ * transition is not that state. `engine/` that was measurable and then was not
52
+ * means the second probe measured nothing about a checkout that demonstrably
53
+ * had something to measure; the reverse means there was never a baseline to
54
+ * compare against. Neither is a run whose verdict can be trusted, so both
55
+ * throw. Compare on the prefix rather than the token, because the token embeds
56
+ * the error message and two genuine failures rarely phrase it identically.
20
57
  */
21
58
  export declare function assertEngineGenerationUnchanged(engineDir: string, before: string): Promise<void>;
@@ -1,7 +1,8 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  import { join } from 'node:path';
3
3
  import { GeneralError } from '../errors/base.js';
4
- import { info } from '../utils/logger.js';
4
+ import { toError } from '../utils/errors.js';
5
+ import { info, warn } from '../utils/logger.js';
5
6
  import { withFileLock } from './file-lock.js';
6
7
  import { git } from './git-base.js';
7
8
  const ENGINE_SESSION_LOCK_PATH = join('.fireforge', 'engine-session.lock');
@@ -20,9 +21,16 @@ function formatWaitProgressLine(waitedMs, timeoutMs, holder) {
20
21
  return `Waiting for the FireForge engine lock held by PID ${String(holder.pid)}${details} — ${progress}.`;
21
22
  }
22
23
  /**
23
- *
24
+ * Runs `operation` while holding the project's engine-session lock, so only
25
+ * one engine-mutating FireForge command touches `engine/` at a time.
24
26
  */
25
27
  export async function withEngineSessionLock(projectRoot, command, operation, options = {}) {
28
+ // The lock is bypassed under test by default, and that is deliberate: the
29
+ // suite runs many engine-mutating commands in-process, which would either
30
+ // serialise on this lock or deadlock outright.
31
+ // `FIREFORGE_ENABLE_ENGINE_SESSION_LOCK_IN_TEST=1` is what lets the lock's
32
+ // own tests opt back in. Do not delete this branch — it is the reason the
33
+ // suite completes, not an oversight.
26
34
  if (process.env['FIREFORGE_ENABLE_ENGINE_SESSION_LOCK_IN_TEST'] !== '1' &&
27
35
  (process.env['NODE_ENV'] === 'test' || process.env['VITEST'] !== undefined)) {
28
36
  return operation();
@@ -41,13 +49,26 @@ export async function withEngineSessionLock(projectRoot, command, operation, opt
41
49
  }
42
50
  : {}),
43
51
  ownerMetadata: [`command=${command}`, `started=${new Date().toISOString()}`],
52
+ // Reason first, remedy second (FORGE H5) — the no-flag default waits
53
+ // only ~1 s, so contention is the common case and `--wait-lock` is the
54
+ // genuine remedy. withFileLock appends the holder identification from
55
+ // the lock's owner metadata. The leading sentence is a message contract
56
+ // (pinned by engine-session-lock tests); extend, don't reword.
44
57
  onTimeoutMessage: `Another FireForge engine-mutating command is already running. ` +
45
- `Wait for it to finish, then retry \`${command}\`.`,
58
+ `Wait for it to finish, then retry \`${command}\` — or pass --wait-lock [seconds] ` +
59
+ `to wait for the lock (bare --wait-lock waits up to 60 seconds).`,
46
60
  onStaleLockMessage: (ageMs) => `Removed stale FireForge engine session lock (${Math.round(ageMs / 1000)}s old).`,
47
61
  });
48
62
  }
63
+ /** Prefix marking a generation token whose probe failed. */
64
+ const UNAVAILABLE_PREFIX = 'unavailable:';
49
65
  /**
66
+ * Captures an opaque token describing the engine's current commit and working
67
+ * tree, for comparison by {@link assertEngineGenerationUnchanged}.
50
68
  *
69
+ * A failed probe yields a failure token. Callers must branch on
70
+ * {@link isUnavailableGenerationToken} rather than comparing the token,
71
+ * because a probe failure means "nothing was measured", not "nothing changed".
51
72
  */
52
73
  export async function snapshotEngineGeneration(engineDir) {
53
74
  try {
@@ -56,14 +77,67 @@ export async function snapshotEngineGeneration(engineDir) {
56
77
  return `${head}\0${status}`;
57
78
  }
58
79
  catch (error) {
59
- return `unavailable:${error instanceof Error ? error.message : String(error)}`;
80
+ return `${UNAVAILABLE_PREFIX}${toError(error).message}`;
60
81
  }
61
82
  }
62
83
  /**
84
+ * True when a generation token records a failed probe — i.e. nothing was
85
+ * measured, so the token must never be compared (or hashed) as if it
86
+ * described the engine's state.
87
+ */
88
+ export function isUnavailableGenerationToken(token) {
89
+ return token.startsWith(UNAVAILABLE_PREFIX);
90
+ }
91
+ /** Strips the marker prefix off an unavailable token for display. */
92
+ export function unavailableGenerationReason(token) {
93
+ return token.slice(UNAVAILABLE_PREFIX.length);
94
+ }
95
+ /**
96
+ * Throws when the engine's generation token differs from `before`, i.e. the
97
+ * engine was mutated by another writer while a test run was in flight.
63
98
  *
99
+ * **An unmeasurable engine warns rather than throws.** `engine/` is not always
100
+ * a git checkout — `fireforge download` extracts a source tarball — so a
101
+ * failed probe is a legitimate state and must not fail the run.
102
+ *
103
+ * What it must also not do is pass *silently*. Before 0.41.0 the failure token
104
+ * was `unavailable:<message>` and was compared for equality like any other, so
105
+ * a git failure that reproduced across both probes (unreadable `.git`, a
106
+ * permissions problem, a corrupt index) produced identical tokens, took the
107
+ * `after === before` early return, and blessed a verdict the guard had never
108
+ * verified — the precise outcome this guard exists to prevent. Two *differing*
109
+ * failure messages were worse still: they fell through to the mutation branch
110
+ * and reported a spurious "engine/ changed".
111
+ *
112
+ * The tolerance is deliberately limited to a probe that failed *both* times:
113
+ * that is the steady state of a non-git `engine/`, and it warns. A one-sided
114
+ * transition is not that state. `engine/` that was measurable and then was not
115
+ * means the second probe measured nothing about a checkout that demonstrably
116
+ * had something to measure; the reverse means there was never a baseline to
117
+ * compare against. Neither is a run whose verdict can be trusted, so both
118
+ * throw. Compare on the prefix rather than the token, because the token embeds
119
+ * the error message and two genuine failures rarely phrase it identically.
64
120
  */
65
121
  export async function assertEngineGenerationUnchanged(engineDir, before) {
66
122
  const after = await snapshotEngineGeneration(engineDir);
123
+ const beforeUnavailable = isUnavailableGenerationToken(before);
124
+ const afterUnavailable = isUnavailableGenerationToken(after);
125
+ if (beforeUnavailable && afterUnavailable) {
126
+ warn('Could not verify that engine/ stayed unchanged while the tests ran ' +
127
+ `(${unavailableGenerationReason(after)}). If another writer touched engine/ during the run, ` +
128
+ 'this verdict is stale.');
129
+ return;
130
+ }
131
+ if (beforeUnavailable || afterUnavailable) {
132
+ const detail = beforeUnavailable
133
+ ? `engine/ could not be probed before the run (${unavailableGenerationReason(before)}) but could be ` +
134
+ 'probed afterwards, so there is no baseline to compare against.'
135
+ : `engine/ was probed before the run but could not be probed afterwards ` +
136
+ `(${unavailableGenerationReason(after)}), so nothing was measured to compare against.`;
137
+ throw new GeneralError(`${detail}\n\n` +
138
+ 'This test verdict is inconclusive. Restore engine/ to a readable state, then rebuild ' +
139
+ 'and rerun the affected tests.');
140
+ }
67
141
  if (after === before)
68
142
  return;
69
143
  throw new GeneralError('engine/ changed while `fireforge test` was running, so this test verdict is invalid/inconclusive.\n\n' +
@@ -48,5 +48,12 @@ export interface FileLockOptions {
48
48
  }
49
49
  /** Derives the sibling lock-directory path used to guard a file-based resource. */
50
50
  export declare function createSiblingLockPath(filePath: string, suffix?: string): string;
51
+ /**
52
+ * Filename of a lock directory's owner record, relative to the lock dir.
53
+ *
54
+ * Exported so external readers (`tree-store.ts`) share the constant instead of
55
+ * re-spelling `'pid'`; the file *format* is documented on {@link readLockOwner}.
56
+ */
57
+ export declare const LOCK_PID_FILE = "pid";
51
58
  /** Runs an async operation while holding a directory lock, with stale-lock recovery. */
52
59
  export declare function withFileLock<T>(lockPath: string, operation: () => Promise<T>, options?: FileLockOptions): Promise<T>;
@@ -2,7 +2,8 @@
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
4
4
  import { dirname, join } from 'node:path';
5
- import { toError } from '../utils/errors.js';
5
+ import { LockContentionError } from '../errors/base.js';
6
+ import { getNodeErrorCode, isProcessAlive, toError } from '../utils/errors.js';
6
7
  import { ensureDir } from '../utils/fs.js';
7
8
  import { verbose, warn } from '../utils/logger.js';
8
9
  const DEFAULT_LOCK_TIMEOUT_MS = 30_000;
@@ -22,33 +23,17 @@ function sleep(ms) {
22
23
  setTimeout(resolve, ms);
23
24
  });
24
25
  }
25
- function getNodeErrorCode(error) {
26
- if (typeof error === 'object' &&
27
- error !== null &&
28
- 'code' in error &&
29
- typeof error.code === 'string') {
30
- return error.code;
31
- }
32
- return undefined;
33
- }
34
26
  /** Derives the sibling lock-directory path used to guard a file-based resource. */
35
27
  export function createSiblingLockPath(filePath, suffix = '.fireforge.lock') {
36
28
  return `${filePath}${suffix}`;
37
29
  }
38
- const LOCK_PID_FILE = 'pid';
39
30
  /**
40
- * Checks whether a process with the given PID is still running.
41
- * Uses `kill(pid, 0)` which sends no signal but checks existence.
31
+ * Filename of a lock directory's owner record, relative to the lock dir.
32
+ *
33
+ * Exported so external readers (`tree-store.ts`) share the constant instead of
34
+ * re-spelling `'pid'`; the file *format* is documented on {@link readLockOwner}.
42
35
  */
43
- function isProcessAlive(pid) {
44
- try {
45
- process.kill(pid, 0);
46
- return true;
47
- }
48
- catch (error) {
49
- return getNodeErrorCode(error) !== 'ESRCH';
50
- }
51
- }
36
+ export const LOCK_PID_FILE = 'pid';
52
37
  /**
53
38
  * Reads the owner PID (line 1), acquisition token (line 2), and diagnostic
54
39
  * metadata (lines 3+, see {@link FileLockOptions.ownerMetadata}) from a lock
@@ -242,12 +227,7 @@ export async function withFileLock(lockPath, operation, options = {}) {
242
227
  break;
243
228
  }
244
229
  catch (error) {
245
- const isAlreadyLocked = typeof error === 'object' &&
246
- error !== null &&
247
- 'code' in error &&
248
- typeof error.code === 'string' &&
249
- error.code === 'EEXIST';
250
- if (!isAlreadyLocked) {
230
+ if (getNodeErrorCode(error) !== 'EEXIST') {
251
231
  throw error;
252
232
  }
253
233
  if (lastStaleProbeAt === undefined || Date.now() - lastStaleProbeAt >= staleReprobeMs) {
@@ -259,9 +239,19 @@ export async function withFileLock(lockPath, operation, options = {}) {
259
239
  if (Date.now() >= deadline) {
260
240
  const owner = await readLockOwner(lockPath);
261
241
  const holderHint = owner.present && isProcessAlive(owner.pid)
262
- ? ` It is held by running process ${String(owner.pid)} — wait for it to finish, or stop it and retry.`
242
+ ? ` It is held by running process ${String(owner.pid)}${owner.metadata.length > 0 ? ` (${owner.metadata.join(', ')})` : ''} — wait for it to finish, or stop it and retry.`
263
243
  : ' If no other FireForge process is running, remove the lock directory and retry.';
264
- throw new Error(options.onTimeoutMessage ?? `Timed out waiting for file lock ${lockPath}.${holderHint}`, { cause: error });
244
+ // Typed as LockContentionError (a FireForgeError) so the CLI
245
+ // boundary prints the refusal as one clean line instead of an
246
+ // "Unexpected error" stack (FORGE H5). Callers supplying
247
+ // onTimeoutMessage still get the holder identification appended —
248
+ // the reason-first copy stays theirs, the "who holds it" is ours.
249
+ const identifiedHolder = options.onTimeoutMessage !== undefined && owner.present && isProcessAlive(owner.pid)
250
+ ? ` The lock is held by PID ${String(owner.pid)}${owner.metadata.length > 0 ? ` (${owner.metadata.join(', ')})` : ''}.`
251
+ : '';
252
+ throw new LockContentionError(options.onTimeoutMessage !== undefined
253
+ ? `${options.onTimeoutMessage}${identifiedHolder}`
254
+ : `Timed out waiting for file lock ${lockPath}.${holderHint}`, error);
265
255
  }
266
256
  if (options.onWaitProgress !== undefined &&
267
257
  options.waitProgressMs !== undefined &&
@@ -8,6 +8,7 @@
8
8
  import { randomUUID } from 'node:crypto';
9
9
  import { rename } from 'node:fs/promises';
10
10
  import { join } from 'node:path';
11
+ import { getNodeErrorCode, toError } from '../utils/errors.js';
11
12
  import { ensureDir, removeDir } from '../utils/fs.js';
12
13
  import { resolveArchive } from './firefox-archive.js';
13
14
  import { ensureCachedArchive, invalidateArchiveCache } from './firefox-cache.js';
@@ -43,8 +44,8 @@ export function getTarballFilename(version, product = 'firefox', candidate) {
43
44
  * genuinely bad archive can never survive in the cache.
44
45
  */
45
46
  function isLikelyArchiveCorruptionError(error) {
46
- const message = error instanceof Error ? error.message : String(error);
47
- const code = error instanceof Error ? error.code : undefined;
47
+ const message = toError(error).message;
48
+ const code = getNodeErrorCode(error);
48
49
  if (code === 'ENOSPC' || code === 'EACCES' || code === 'EPERM' || code === 'EDQUOT') {
49
50
  return false;
50
51
  }
@@ -81,6 +82,8 @@ export async function sweepOrphanedEngineWorkDirs(destDir) {
81
82
  entries = await readdir(parent);
82
83
  }
83
84
  catch {
85
+ // An unreadable parent directory holds no sweepable work dirs. The sweep is
86
+ // best-effort housekeeping and must never fail the download it follows.
84
87
  return [];
85
88
  }
86
89
  const removed = [];