@hominis/fireforge 0.40.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 (253) hide show
  1. package/CHANGELOG.md +79 -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/build-audit-platform.js +6 -1
  118. package/dist/src/core/build-audit-registration.js +2 -0
  119. package/dist/src/core/build-audit-resolve.d.ts +13 -0
  120. package/dist/src/core/build-audit-resolve.js +34 -13
  121. package/dist/src/core/build-audit.js +4 -20
  122. package/dist/src/core/build-baseline-types.d.ts +29 -8
  123. package/dist/src/core/build-baseline-types.js +2 -1
  124. package/dist/src/core/build-baseline.d.ts +10 -2
  125. package/dist/src/core/build-baseline.js +39 -8
  126. package/dist/src/core/build-prepare.d.ts +4 -0
  127. package/dist/src/core/build-prepare.js +7 -1
  128. package/dist/src/core/config-paths.d.ts +1 -1
  129. package/dist/src/core/config-paths.js +2 -0
  130. package/dist/src/core/config-validate-test-toolchains.js +3 -2
  131. package/dist/src/core/config-validate.js +34 -36
  132. package/dist/src/core/config.js +16 -3
  133. package/dist/src/core/coverage-extend.d.ts +89 -0
  134. package/dist/src/core/coverage-extend.js +188 -0
  135. package/dist/src/core/destructive.js +3 -1
  136. package/dist/src/core/discard-baseline.js +18 -4
  137. package/dist/src/core/engine-precondition.d.ts +35 -0
  138. package/dist/src/core/engine-precondition.js +50 -0
  139. package/dist/src/core/engine-session-lock.d.ts +38 -1
  140. package/dist/src/core/engine-session-lock.js +78 -4
  141. package/dist/src/core/file-lock.d.ts +7 -0
  142. package/dist/src/core/file-lock.js +20 -30
  143. package/dist/src/core/firefox.js +5 -2
  144. package/dist/src/core/furnace-apply-helpers.d.ts +12 -1
  145. package/dist/src/core/furnace-apply-helpers.js +12 -3
  146. package/dist/src/core/furnace-apply-output.js +9 -0
  147. package/dist/src/core/furnace-apply-overwrite-warn.d.ts +63 -0
  148. package/dist/src/core/furnace-apply-overwrite-warn.js +123 -0
  149. package/dist/src/core/furnace-apply-undeploy-actions.d.ts +11 -0
  150. package/dist/src/core/furnace-apply-undeploy-actions.js +49 -0
  151. package/dist/src/core/furnace-apply.d.ts +16 -4
  152. package/dist/src/core/furnace-apply.js +39 -43
  153. package/dist/src/core/furnace-config.js +2 -0
  154. package/dist/src/core/furnace-css-fragments.js +2 -0
  155. package/dist/src/core/furnace-jsconfig.js +14 -7
  156. package/dist/src/core/furnace-registration-ast.js +8 -8
  157. package/dist/src/core/furnace-registration-remove.js +6 -3
  158. package/dist/src/core/furnace-registration.js +9 -12
  159. package/dist/src/core/furnace-rollback.js +1 -1
  160. package/dist/src/core/furnace-scanner.js +2 -1
  161. package/dist/src/core/furnace-stale-export.js +2 -1
  162. package/dist/src/core/furnace-state-persist.d.ts +4 -6
  163. package/dist/src/core/furnace-validate-compatibility.js +4 -7
  164. package/dist/src/core/furnace-validate-helpers.js +33 -1
  165. package/dist/src/core/furnace-validate-registration.js +16 -0
  166. package/dist/src/core/furnace-validate.js +4 -2
  167. package/dist/src/core/git-diff.js +37 -2
  168. package/dist/src/core/lint-cache.d.ts +1 -1
  169. package/dist/src/core/lint-cache.js +33 -10
  170. package/dist/src/core/mach-build-artifacts.d.ts +63 -1
  171. package/dist/src/core/mach-build-artifacts.js +108 -2
  172. package/dist/src/core/mach-resource-shim.js +1 -0
  173. package/dist/src/core/marionette-port.d.ts +16 -0
  174. package/dist/src/core/marionette-port.js +93 -8
  175. package/dist/src/core/marionette-preflight.js +6 -5
  176. package/dist/src/core/patch-apply.d.ts +20 -13
  177. package/dist/src/core/patch-apply.js +32 -33
  178. package/dist/src/core/patch-export.js +3 -5
  179. package/dist/src/core/patch-lint-checkjs.d.ts +25 -3
  180. package/dist/src/core/patch-lint-checkjs.js +78 -7
  181. package/dist/src/core/patch-lint-chrome-jsdoc.js +9 -0
  182. package/dist/src/core/patch-lint-creators.d.ts +30 -0
  183. package/dist/src/core/patch-lint-creators.js +45 -0
  184. package/dist/src/core/patch-lint-cross.d.ts +1 -17
  185. package/dist/src/core/patch-lint-cross.js +16 -32
  186. package/dist/src/core/patch-lint-jsdoc.d.ts +3 -1
  187. package/dist/src/core/patch-lint-jsdoc.js +15 -5
  188. package/dist/src/core/patch-lint-module-registration.d.ts +15 -0
  189. package/dist/src/core/patch-lint-module-registration.js +88 -0
  190. package/dist/src/core/patch-lint-mozbuild.js +99 -11
  191. package/dist/src/core/patch-lint-reexports.d.ts +1 -0
  192. package/dist/src/core/patch-lint-reexports.js +1 -0
  193. package/dist/src/core/patch-lint.d.ts +8 -0
  194. package/dist/src/core/patch-lint.js +84 -6
  195. package/dist/src/core/patch-lock.d.ts +12 -1
  196. package/dist/src/core/patch-lock.js +40 -4
  197. package/dist/src/core/patch-manifest-consistency.js +15 -3
  198. package/dist/src/core/patch-parse.d.ts +8 -0
  199. package/dist/src/core/patch-parse.js +10 -0
  200. package/dist/src/core/patch-policy.d.ts +7 -0
  201. package/dist/src/core/patch-policy.js +4 -1
  202. package/dist/src/core/patch-transform.d.ts +10 -0
  203. package/dist/src/core/patch-transform.js +13 -2
  204. package/dist/src/core/rebase-session.d.ts +44 -2
  205. package/dist/src/core/rebase-session.js +114 -17
  206. package/dist/src/core/register-browser-content.js +2 -1
  207. package/dist/src/core/register-shared-css.js +2 -1
  208. package/dist/src/core/smoke-patterns.d.ts +0 -8
  209. package/dist/src/core/smoke-patterns.js +3 -2
  210. package/dist/src/core/status-binary.d.ts +55 -0
  211. package/dist/src/core/status-binary.js +124 -0
  212. package/dist/src/core/status-classify.d.ts +6 -1
  213. package/dist/src/core/status-classify.js +101 -73
  214. package/dist/src/core/test-harness-crash.d.ts +32 -1
  215. package/dist/src/core/test-harness-crash.js +59 -4
  216. package/dist/src/core/test-path-scope.js +2 -0
  217. package/dist/src/core/test-stale-check.js +1 -20
  218. package/dist/src/core/toolchain-preflight.js +24 -26
  219. package/dist/src/core/tree-guard.d.ts +1 -1
  220. package/dist/src/core/tree-guard.js +67 -12
  221. package/dist/src/core/tree-store.d.ts +86 -10
  222. package/dist/src/core/tree-store.js +310 -72
  223. package/dist/src/core/typecheck.js +2 -1
  224. package/dist/src/core/wire-destroy.js +14 -3
  225. package/dist/src/core/wire-init.js +37 -40
  226. package/dist/src/core/wire-subscript.js +11 -3
  227. package/dist/src/core/wire-utils.js +4 -4
  228. package/dist/src/core/xpcshell-appdir.js +3 -0
  229. package/dist/src/errors/base.d.ts +13 -0
  230. package/dist/src/errors/base.js +13 -0
  231. package/dist/src/errors/rebase.d.ts +13 -0
  232. package/dist/src/errors/rebase.js +17 -0
  233. package/dist/src/types/commands/options.d.ts +86 -1
  234. package/dist/src/types/commands/patches.d.ts +8 -0
  235. package/dist/src/types/furnace.d.ts +7 -0
  236. package/dist/src/utils/build-info.d.ts +31 -0
  237. package/dist/src/utils/build-info.js +98 -0
  238. package/dist/src/utils/concurrency.d.ts +8 -0
  239. package/dist/src/utils/concurrency.js +21 -0
  240. package/dist/src/utils/errors.d.ts +39 -0
  241. package/dist/src/utils/errors.js +55 -0
  242. package/dist/src/utils/fs.js +5 -12
  243. package/dist/src/utils/logger.d.ts +14 -2
  244. package/dist/src/utils/logger.js +43 -13
  245. package/dist/src/utils/options.d.ts +21 -4
  246. package/dist/src/utils/options.js +26 -6
  247. package/dist/src/utils/package-root.js +7 -4
  248. package/dist/src/utils/process-group.js +13 -2
  249. package/dist/src/utils/stdio-drain.d.ts +28 -0
  250. package/dist/src/utils/stdio-drain.js +54 -0
  251. package/dist/src/utils/validation.d.ts +23 -2
  252. package/dist/src/utils/validation.js +29 -3
  253. package/package.json +9 -9
@@ -1,230 +1,20 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
- /* eslint-disable max-lines -- Re-export is at the guardrail; splitting register plumbing is unrelated to this fix. */
3
2
  import { multiselect } from '@clack/prompts';
4
- import { Command, Option } from 'commander';
5
3
  import { getProjectPaths, loadConfig } from '../core/config.js';
6
- import { withEngineSessionLock } from '../core/engine-session-lock.js';
7
4
  import { collectFurnaceManagedPrefixes } from '../core/furnace-config.js';
8
- import { enforceFreshFurnaceSources } from '../core/furnace-stale-export.js';
9
5
  import { isGitRepository } from '../core/git.js';
10
- import { getDiffForFilesAgainstHead } from '../core/git-diff.js';
11
- import { updatePatchAndMetadata } from '../core/patch-export.js';
12
- import { buildPatchQueueContext } from '../core/patch-lint.js';
13
6
  import { loadPatchesManifest, resolvePatchIdentifier, stampPatchVersions, } from '../core/patch-manifest.js';
14
- import { buildProjectedManifest, enforcePatchPolicy } from '../core/patch-policy.js';
15
7
  import { GeneralError, InvalidArgumentError } from '../errors/base.js';
16
- import { isGitIndexLockConflict } from '../errors/git.js';
17
8
  import { elapsedSince } from '../utils/elapsed.js';
18
9
  import { toError } from '../utils/errors.js';
19
10
  import { pathExists } from '../utils/fs.js';
20
11
  import { cancel, info, intro, isCancel, outro, spinner, success, warn } from '../utils/logger.js';
21
- import { addWaitLockOption, pickDefined, resolveWaitLockSeconds } from '../utils/options.js';
22
- import { runPatchLint } from './export-shared.js';
23
- import { findMissingFiles, reportAdjacentUnmanagedFiles } from './re-export-adjacent.js';
24
- import { loadScanFilesAssignments, withDryRunReExportLock } from './re-export-bulk-scan.js';
12
+ import { loadScanFilesAssignments, withDryRunPurityGuard, withDryRunReExportLock, } from './re-export-bulk-scan.js';
25
13
  import { reExportFilesInPlace } from './re-export-files.js';
14
+ import { buildReExportLintContext, finishReExportLint } from './re-export-lint.js';
26
15
  import { applyReExportFilesPositionalFolding, validateReExportOptionCombinations, } from './re-export-options.js';
27
- import { assertScanAdoptionsHaveNoForwardImports, assertScanFileAdditionsHaveDiffHunks, confirmBroadScanAdditions, normalizeScanFiles, scanPatchFilesForReExport, } from './re-export-scan.js';
28
- const GIT_INDEX_LOCK_RETRY_MS = 300;
29
- /**
30
- * Retries a single patch re-export exactly once when the failure is
31
- * transient git `index.lock` contention (an external git process holding
32
- * the engine repo's index). The retry re-enters `reExportSinglePatch`
33
- * from the top, so it re-reads clean state; a second lock failure
34
- * propagates to the loop's honest "N of M" accounting.
35
- */
36
- async function reExportSinglePatchWithIndexLockRetry(patch, paths, manifest, options, isDryRun, config, adjacentCtx) {
37
- try {
38
- return await reExportSinglePatch(patch, paths, manifest, options, isDryRun, config, adjacentCtx);
39
- }
40
- catch (error) {
41
- if (!isGitIndexLockConflict(error)) {
42
- throw error;
43
- }
44
- warn(`${patch.filename}: git index.lock contention detected — retrying once after ${String(GIT_INDEX_LOCK_RETRY_MS)} ms...`);
45
- await new Promise((resolve) => setTimeout(resolve, GIT_INDEX_LOCK_RETRY_MS));
46
- return await reExportSinglePatch(patch, paths, manifest, options, isDryRun, config, adjacentCtx);
47
- }
48
- }
49
- async function reExportSinglePatch(patch, paths, manifest, options, isDryRun, config, adjacentCtx) {
50
- let currentFilesAffected = [...patch.filesAffected];
51
- // --- Scan for new/removed files ---
52
- if (options.scan) {
53
- const scanResult = await scanPatchFilesForReExport({
54
- currentFilesAffected,
55
- engineDir: paths.engine,
56
- manifest,
57
- patchFilename: patch.filename,
58
- isDryRun,
59
- ...(options.scanFiles !== undefined ? { scanFiles: options.scanFiles } : {}),
60
- });
61
- // Forward-import gate at adoption time (runs in dry-run too): refuse
62
- // to adopt unmanaged files that import modules created by LATER
63
- // patches — the after-the-fact lint failure this leaves behind is the
64
- // field-reported footgun. Applies to broad --scan, --scan-file, and
65
- // the --scan-files bulk assignments alike.
66
- await assertScanAdoptionsHaveNoForwardImports({
67
- patchesDir: paths.patches,
68
- engineDir: paths.engine,
69
- patchFilename: patch.filename,
70
- added: scanResult.added,
71
- });
72
- if (options.scanFiles === undefined) {
73
- const isInteractive = process.stdin.isTTY && process.stdout.isTTY;
74
- const proceed = await confirmBroadScanAdditions({
75
- patchFilename: patch.filename,
76
- added: scanResult.added,
77
- isDryRun,
78
- yes: options.yes === true,
79
- isInteractive,
80
- });
81
- if (!proceed) {
82
- return false;
83
- }
84
- }
85
- currentFilesAffected = scanResult.updated;
86
- }
87
- else if (options.files === undefined) {
88
- // Finding #16: when neither `--scan` nor `--files` is set and some
89
- // of the manifest's claimed files no longer exist on disk, the
90
- // re-export silently writes a refreshed body whose filesAffected
91
- // still names the vanished paths. That is the documented contract,
92
- // but it is also a footgun — a later `verify` then fails on
93
- // manifest-consistency with no obvious trigger. Emit one advisory
94
- // warning up-front when we can detect the drift cheaply, so the
95
- // operator has a chance to re-run with `--scan` or `--files`
96
- // before the stale filesAffected lands in patches.json.
97
- const args = { patch, paths, manifest, currentFilesAffected, ctx: adjacentCtx };
98
- if (await reportAdjacentUnmanagedFiles(args))
99
- return false;
100
- }
101
- // --- Explicit file-subset path ---
102
- // When --files is given, the target filesAffected is authoritative — drop
103
- // anything not in the list, add anything new. This is the surgical repair
104
- // primitive that replaces hand-editing patches.json; the user has already
105
- // acknowledged via confirmDestructive (done in the caller) that any drop
106
- // is intentional.
107
- if (options.files !== undefined) {
108
- const requested = [...new Set(options.files)].sort();
109
- currentFilesAffected = requested;
110
- const removed = patch.filesAffected.filter((f) => !requested.includes(f));
111
- const added = requested.filter((f) => !patch.filesAffected.includes(f));
112
- for (const f of added)
113
- info(` + ${f}`);
114
- for (const f of removed)
115
- info(` - ${f}`);
116
- }
117
- // Stale-furnace-source gate (0.37.0 item 4): re-export captures deployed
118
- // engine copies, so a component source edited after the last furnace
119
- // apply would land in the patch as its OLD deployed content. Refuse (or
120
- // warn under --allow-stale-furnace) before diffing. Runs in dry-run too
121
- // so the failure surfaces early.
122
- await enforceFreshFurnaceSources(paths.root, currentFilesAffected, options.allowStaleFurnace === true, 're-export');
123
- const missingFiles = await findMissingFiles(paths.engine, currentFilesAffected);
124
- if (missingFiles.length === currentFilesAffected.length) {
125
- warn(`Skipped ${patch.filename}: all affected files missing`);
126
- warn(`Missing files: ${missingFiles.join(', ')}`);
127
- return false;
128
- }
129
- if (missingFiles.length > 0) {
130
- warn(`${patch.filename}: missing files will be skipped: ${missingFiles.join(', ')}`);
131
- }
132
- const missingSet = new Set(missingFiles);
133
- const existingFiles = currentFilesAffected.filter((f) => !missingSet.has(f));
134
- const diffContent = await getDiffForFilesAgainstHead(paths.engine, existingFiles);
135
- assertScanFileAdditionsHaveDiffHunks({
136
- diffContent,
137
- patchFilename: patch.filename,
138
- previousFilesAffected: patch.filesAffected,
139
- scanFiles: options.scanFiles,
140
- });
141
- if (!diffContent.trim()) {
142
- warn(`Skipped ${patch.filename}: no changes (files unchanged from HEAD)`);
143
- return false;
144
- }
145
- // Thread the patch's own `lintIgnore` list through so the per-patch
146
- // suppression honoured by export/export-all is also honoured here.
147
- // Without this, `re-export` could not refresh an advisory-noisy but
148
- // intentional patch (a cohesive branding bundle, a localised-resource
149
- // pack) without either `--skip-lint` (too blunt) or falling through to
150
- // the full `rebase` flow (which internally skips the lint pipeline).
151
- // The paired `patch.tier` threads the explicit branding-threshold
152
- // opt-in the same way, for the branding patch that also touches a
153
- // non-allowlisted registration sibling.
154
- //
155
- // The CLI flags `--tier` and `--lint-ignore` participate too, with
156
- // append/union semantics on the lint-ignore list (the operator's
157
- // intuition for "I want this patch to also suppress X" — explicit
158
- // removal lives on the `fireforge patch lint-ignore` subcommand).
159
- // Computed before the lint pass so the new intent takes effect on
160
- // this invocation, not the next one.
161
- const existingIgnoreSet = new Set(patch.lintIgnore ?? []);
162
- const flagIgnoreSet = new Set(options.lintIgnore ?? []);
163
- const mergedIgnoreSet = new Set([...existingIgnoreSet, ...flagIgnoreSet]);
164
- const effectiveLintIgnore = mergedIgnoreSet.size > 0 ? [...mergedIgnoreSet] : undefined;
165
- const ignoreChecks = effectiveLintIgnore ? new Set(effectiveLintIgnore) : undefined;
166
- const effectiveTier = options.tier ?? patch.tier;
167
- const updates = {
168
- filesAffected: currentFilesAffected,
169
- };
170
- if (options.tier !== undefined) {
171
- updates.tier = options.tier;
172
- }
173
- if (effectiveLintIgnore !== undefined && flagIgnoreSet.size > 0) {
174
- updates.lintIgnore = effectiveLintIgnore;
175
- }
176
- enforcePatchPolicy({
177
- config,
178
- manifest: buildProjectedManifest(manifest, manifest.patches.map((entry) => entry.filename === patch.filename ? { ...entry, ...updates } : entry)),
179
- command: 're-export',
180
- forceUnsafe: options.forceUnsafe === true,
181
- });
182
- // Pass the whole-queue context so checkJs resolves cross-patch
183
- // `resource:///` imports against the real owning sources (report scope
184
- // stays this patch — see runPatchLint).
185
- const patchQueueCtx = (await pathExists(paths.patches))
186
- ? await buildPatchQueueContext(paths.patches)
187
- : undefined;
188
- await runPatchLint(paths.engine, existingFiles, diffContent, config, options.skipLint, patchQueueCtx, ignoreChecks, effectiveTier);
189
- if (isDryRun) {
190
- info(`[dry-run] ${patch.filename}: ${existingFiles.length} file(s)`);
191
- if (effectiveTier !== undefined && effectiveTier !== patch.tier) {
192
- info(`[dry-run] ${patch.filename}: tier would become ${effectiveTier}`);
193
- }
194
- const addedIgnores = [...flagIgnoreSet].filter((id) => !existingIgnoreSet.has(id));
195
- if (addedIgnores.length > 0) {
196
- info(`[dry-run] ${patch.filename}: lintIgnore would gain ${addedIgnores.join(', ')}`);
197
- }
198
- }
199
- else {
200
- // Atomic body + manifest update under a single patch-directory lock.
201
- // A split `updatePatch` (lock-free) + `updatePatchMetadata` (lock-guarded)
202
- // sequence allows a concurrent `resolve` / `rebase --continue` / `patch
203
- // compact` / `patch reorder` to rewrite the manifest between the two
204
- // writes and leave patch body and `filesAffected` disagreeing.
205
- await updatePatchAndMetadata(paths.patches, patch.filename, diffContent, updates, undefined, {
206
- config,
207
- command: 're-export',
208
- forceUnsafe: options.forceUnsafe === true,
209
- });
210
- // Keep the in-memory manifest in sync so subsequent iterations (notably
211
- // `--all --scan`, where `getClaimedFiles` reads from this manifest) see
212
- // the just-written `filesAffected`. The on-disk write above is the
213
- // authority; this is a cache update.
214
- const patchIndex = manifest.patches.findIndex((pm) => pm.filename === patch.filename);
215
- if (patchIndex !== -1) {
216
- const existingEntry = manifest.patches[patchIndex];
217
- if (existingEntry) {
218
- manifest.patches[patchIndex] = {
219
- ...existingEntry,
220
- ...updates,
221
- };
222
- }
223
- }
224
- success(`Re-exported ${patch.filename}`);
225
- }
226
- return true;
227
- }
16
+ import { normalizeEngineRelativeInput, normalizeScanFiles } from './re-export-scan.js';
17
+ import { reExportSinglePatchWithIndexLockRetry } from './re-export-single.js';
228
18
  async function resolveSelectedPatches(patches, options, manifest) {
229
19
  if (options.all) {
230
20
  return [...manifest.patches];
@@ -328,7 +118,7 @@ export async function reExportCommand(projectRoot, patches, options) {
328
118
  // we write anything.
329
119
  if (options.files !== undefined) {
330
120
  const filesConfig = await loadConfig(projectRoot);
331
- await withDryRunReExportLock(paths.fireforgeDir, isDryRun, () => reExportFilesInPlace(paths, selectedPatches, options, filesConfig));
121
+ await withDryRunReExportLock(paths.fireforgeDir, isDryRun, () => withDryRunPurityGuard(paths.engine, paths.patches, isDryRun, () => reExportFilesInPlace(paths, selectedPatches, options, filesConfig)));
332
122
  return;
333
123
  }
334
124
  const config = await loadConfig(projectRoot);
@@ -340,35 +130,37 @@ export async function reExportCommand(projectRoot, patches, options) {
340
130
  refuseAdjacentUnmanaged: options.refuseAdjacentUnmanaged === true,
341
131
  refusals: [],
342
132
  };
133
+ const driftCtx = buildForeignDriftContext(options);
134
+ // Hoisted lint context, one per run (FORGE J1): queue context + checkJs
135
+ // program + per-patch result cache, shared across every loop iteration.
136
+ const lintCtx = await buildReExportLintContext(projectRoot, paths, config, options.noCache === true);
343
137
  let reExported = 0;
344
138
  const reExportedFilenames = [];
139
+ const failedFilenames = [];
345
140
  const progress = spinner('Preparing re-export...');
346
141
  const startedAt = Date.now();
347
- await withDryRunReExportLock(paths.fireforgeDir, isDryRun, async () => {
142
+ await withDryRunReExportLock(paths.fireforgeDir, isDryRun, async () => withDryRunPurityGuard(paths.engine, paths.patches, isDryRun, async () => {
348
143
  for (const [index, patch] of selectedPatches.entries()) {
349
144
  const assignedScanFiles = scanFilesByPatch?.get(patch.filename);
350
145
  const patchOptions = assignedScanFiles !== undefined ? { ...options, scanFiles: assignedScanFiles } : options;
351
146
  progress.message(`Re-exporting ${index + 1}/${selectedPatches.length}: ${patch.filename} (${patch.filesAffected.length} file(s), ${elapsedSince(startedAt)} elapsed)...`);
352
147
  try {
353
- const exported = await reExportSinglePatchWithIndexLockRetry(patch, paths, manifest, patchOptions, isDryRun, config, adjacentCtx);
148
+ const exported = await reExportSinglePatchWithIndexLockRetry(patch, paths, manifest, patchOptions, isDryRun, config, adjacentCtx, driftCtx, lintCtx);
354
149
  if (exported) {
355
150
  reExported++;
356
151
  reExportedFilenames.push(patch.filename);
357
152
  }
358
153
  }
359
154
  catch (error) {
155
+ failedFilenames.push(patch.filename);
360
156
  warn(`Failed to re-export ${patch.filename}`);
361
157
  warn(toError(error).message);
362
158
  }
363
159
  }
364
- });
365
- if (adjacentCtx.refusals.length > 0) {
366
- progress.error('Re-export refused');
367
- const names = adjacentCtx.refusals.map((r) => r.patchFilename).join(', ');
368
- throw new GeneralError(`Refused ${String(adjacentCtx.refusals.length)} patch(es) with adjacent unmanaged files ` +
369
- `(--refuse-adjacent-unmanaged): ${names}. Adopt reviewed files with --scan --scan-file, ` +
370
- `or export them as a new patch.`);
371
- }
160
+ }));
161
+ await finishReExportLint(lintCtx);
162
+ warnUnseenExpectedDrift(driftCtx);
163
+ throwRunLevelRefusals(adjacentCtx, driftCtx, progress);
372
164
  if (reExported === 0 && selectedPatches.length > 0) {
373
165
  progress.error('Re-export failed');
374
166
  throw new GeneralError('All selected patches failed to re-export. Check the errors above.');
@@ -384,60 +176,113 @@ export async function reExportCommand(projectRoot, patches, options) {
384
176
  if (shouldStamp) {
385
177
  await stampPatchVersions(paths.patches, reExportedFilenames, config.firefox.version, config.firefox.product);
386
178
  }
179
+ reportReExportOutcome({
180
+ isDryRun,
181
+ reExported,
182
+ selectedCount: selectedPatches.length,
183
+ reExportedFilenames,
184
+ failedFilenames,
185
+ shouldStamp,
186
+ stampRequested: options.stamp === true,
187
+ config,
188
+ progress,
189
+ });
190
+ }
191
+ /**
192
+ * Foreign-drift guard context, one per run (FORGE J2). `--expect` paths
193
+ * (FORGE L6) are normalized once here so the per-patch comparison matches
194
+ * them against engine-relative diff-section paths.
195
+ */
196
+ function buildForeignDriftContext(options) {
197
+ return {
198
+ refuseForeignDrift: options.refuseForeignDrift === true,
199
+ expectedDriftFiles: new Set((options.expect ?? []).map((file) => normalizeEngineRelativeInput(file, '--expect'))),
200
+ expectedSeen: new Set(),
201
+ evaluationRuns: 0,
202
+ refusals: [],
203
+ };
204
+ }
205
+ /**
206
+ * Names `--expect` paths that never drifted this run (FORGE L6). A typo'd
207
+ * `--expect` path silently degrades the flag back to refusing the slice it
208
+ * was meant to admit, so surface the mismatch — but only as a warning: an
209
+ * expected file legitimately shows no drift when the slice was already
210
+ * captured by an earlier export.
211
+ */
212
+ function warnUnseenExpectedDrift(driftCtx) {
213
+ const unseen = [...driftCtx.expectedDriftFiles].filter((file) => !driftCtx.expectedSeen.has(file));
214
+ if (unseen.length === 0)
215
+ return;
216
+ if (driftCtx.evaluationRuns === 0) {
217
+ warn(`--expect path(s) were not evaluated because no selected patch reached the drift check (for example, an earlier refusal): ${unseen.join(', ')}`);
218
+ return;
219
+ }
220
+ warn(`--expect path(s) showed no drift this run (typo, or already captured?): ${unseen.join(', ')}`);
221
+ }
222
+ /**
223
+ * Turns the run's collected `--refuse-adjacent-unmanaged` (FORGE G2) and
224
+ * `--refuse-foreign-drift` (FORGE J2) refusals into the non-zero exit,
225
+ * naming every refused patch and the remedy.
226
+ */
227
+ function throwRunLevelRefusals(adjacentCtx, driftCtx, progress) {
228
+ if (adjacentCtx.refusals.length > 0) {
229
+ progress.error('Re-export refused');
230
+ const names = adjacentCtx.refusals.map((r) => r.patchFilename).join(', ');
231
+ throw new GeneralError(`Refused ${String(adjacentCtx.refusals.length)} patch(es) with adjacent unmanaged files ` +
232
+ `(--refuse-adjacent-unmanaged): ${names}. Adopt reviewed files with --scan --scan-file, ` +
233
+ `or export them as a new patch.`);
234
+ }
235
+ if (driftCtx.refusals.length > 0) {
236
+ progress.error('Re-export refused');
237
+ const names = driftCtx.refusals.map((r) => r.patchFilename).join(', ');
238
+ const unreadable = driftCtx.refusals.filter((r) => r.reason === 'baseline-unreadable');
239
+ const unreadablePart = unreadable.length > 0
240
+ ? ` ${String(unreadable.length)} of these refused because the old patch body was missing or unreadable, so the drift comparison could not run (fail-closed).`
241
+ : '';
242
+ throw new GeneralError(`Refused ${String(driftCtx.refusals.length)} patch(es) whose refreshed body would absorb ` +
243
+ `engine edits not present in the old patch body (--refuse-foreign-drift): ${names}.${unreadablePart} ` +
244
+ `Commit or stash the foreign edits (another session may own them), name the slice's ` +
245
+ `intended files with --expect <path>, or re-run without the flag to capture them intentionally.`);
246
+ }
247
+ }
248
+ /**
249
+ * Prints the end-of-run summary and enforces the FORGE H8 exit contract:
250
+ * a partial run exits non-zero BY DEFAULT (adjudicated for 0.41.0, per the
251
+ * follow-up 0.40.0's G7 recorded). "Re-exported 2 of 3" used to print and
252
+ * exit 0, letting a partial refresh ride an `&&` chain as success. The
253
+ * summary still prints first so the honest "N of M" accounting stays
254
+ * visible; the throw then carries the failed/skipped breakdown. Dry-run
255
+ * included: a preview reporting that the real run would partially fail
256
+ * must not read as a passing preview in scripts.
257
+ */
258
+ function reportReExportOutcome(args) {
259
+ const { isDryRun, reExported, selectedCount, config, progress } = args;
387
260
  if (isDryRun) {
388
261
  progress.stop('Dry run complete');
389
- success(`[dry-run] Would re-export ${reExported} of ${selectedPatches.length} patch(es)`);
390
- if (options.stamp === true) {
262
+ success(`[dry-run] Would re-export ${reExported} of ${selectedCount} patch(es)`);
263
+ if (args.stampRequested) {
391
264
  info(`[dry-run] Would stamp sourceVersion=${config.firefox.version} (${config.firefox.product}) on ${reExported} patch(es)`);
392
265
  }
393
266
  outro('Dry run complete');
394
267
  }
395
268
  else {
396
269
  progress.stop('Re-export complete');
397
- success(`Re-exported ${reExported} of ${selectedPatches.length} patch(es)`);
398
- if (shouldStamp) {
399
- success(`Stamped sourceVersion=${config.firefox.version} (${config.firefox.product}) on ${reExportedFilenames.length} patch(es)`);
270
+ success(`Re-exported ${reExported} of ${selectedCount} patch(es)`);
271
+ if (args.shouldStamp) {
272
+ success(`Stamped sourceVersion=${config.firefox.version} (${config.firefox.product}) on ${args.reExportedFilenames.length} patch(es)`);
400
273
  }
401
- else if (options.stamp === true && reExported !== selectedPatches.length) {
274
+ else if (args.stampRequested && reExported !== selectedCount) {
402
275
  warn('--stamp was requested but some patches failed or were skipped; refusing to stamp a partial set.');
403
276
  }
404
277
  outro('Re-export complete');
405
278
  }
406
- }
407
- /** Registers the re-export command on the CLI program. */
408
- export function registerReExport(program, { getProjectRoot, withErrorHandling }) {
409
- const reExport = program
410
- .command('re-export [patches...]')
411
- .description('Refresh existing patch bodies (and filesAffected with --scan) from the current engine ' +
412
- 'state. Does NOT change sourceVersion/sourceProduct by default — use --stamp or run ' +
413
- 'rebase for source metadata stamping.')
414
- .option('-a, --all', 'Re-export all patches')
415
- .option('-s, --scan', 'Scan directories for new/removed files and update filesAffected')
416
- .option('--scan-file <path>', 'With --scan, add this explicit engine-relative file to one target patch without collecting adjacent files. Repeatable.', (value, prev) => [...prev, value], [])
417
- .option('--scan-files <manifest>', 'With --scan, bulk-assign generated files from a JSON manifest: {"assignments":[{"patch":"002-name.patch","files":["path"]}]}. Selects patches from the manifest.')
418
- .option('--files <paths>', 'Restrict the re-exported filesAffected to this comma-separated list (single target patch only)', (value) => value
419
- .split(',')
420
- .map((v) => v.trim())
421
- .filter((v) => v.length > 0))
422
- .option('--refuse-adjacent-unmanaged', 'Refuse a scan-less re-export (non-zero exit, patch not written) when unmanaged files exist adjacent to the patch ownership, instead of warning. Mutually exclusive with --scan and --files.')
423
- .option('--dry-run', 'Show what would change without writing')
424
- .option('--skip-lint', 'Skip patch lint checks (downgrade errors to warnings)')
425
- .option('--allow-stale-furnace', 'Export the deployed engine copy even when the components/ source changed since the last furnace apply')
426
- .option('--allow-shrink', 'Allow --files to remove paths currently owned by the patch. Required before --yes can bypass the shrink confirmation.')
427
- .option('-y, --yes', 'Skip confirmation prompts (required for non-TTY destructive writes)')
428
- .option('--force-unsafe', 'Bypass cross-patch lint refusal when --files shrinks a patch')
429
- .option('--stamp', "After every selected patch refreshes cleanly, stamp each re-exported patch's sourceVersion/sourceProduct in patches.json to firefox.version/firefox.product from fireforge.json. No effect on a partial run.")
430
- .addOption(new Option('--tier <tier>', 'Force a tier override on the selected patch (only "branding" recognised). Mutually exclusive with --all.').choices(['branding']))
431
- .option('--lint-ignore <check-id>', 'Append a lint check ID to the patch\'s PatchMetadata.lintIgnore (union, de-duped, repeatable). Mutually exclusive with --all. Use "fireforge patch lint-ignore" for --remove / --clear.', (value, prev) => [...prev, value], []);
432
- addWaitLockOption(reExport).action(withErrorHandling(async (patches, options) => {
433
- const { tier, lintIgnore, scanFile, scanFiles, ...rest } = options;
434
- const projectRoot = getProjectRoot();
435
- await withEngineSessionLock(projectRoot, 're-export', () => reExportCommand(projectRoot, patches, {
436
- ...pickDefined(rest),
437
- ...(scanFile !== undefined && scanFile.length > 0 ? { scanFiles: scanFile } : {}),
438
- ...(scanFiles !== undefined ? { scanFilesManifest: scanFiles } : {}),
439
- ...(tier !== undefined ? { tier: tier } : {}),
440
- ...(lintIgnore !== undefined && lintIgnore.length > 0 ? { lintIgnore } : {}),
441
- }), { waitLockSeconds: resolveWaitLockSeconds(options.waitLock) });
442
- }));
279
+ if (reExported < selectedCount) {
280
+ const skippedCount = selectedCount - reExported - args.failedFilenames.length;
281
+ const failedPart = args.failedFilenames.length > 0 ? ` Failed: ${args.failedFilenames.join(', ')}.` : '';
282
+ const skippedPart = skippedCount > 0 ? ` Skipped: ${String(skippedCount)} patch(es) (see warnings above).` : '';
283
+ throw new GeneralError(`${isDryRun ? '[dry-run] Would re-export' : 'Re-exported'} only ${String(reExported)} of ` +
284
+ `${String(selectedCount)} selected patch(es).${failedPart}${skippedPart} ` +
285
+ 'Partial re-export exits non-zero so it cannot pass as success in scripts; ' +
286
+ 'the per-patch errors are listed above.');
287
+ }
443
288
  }
@@ -5,18 +5,27 @@
5
5
  import { getProjectPaths, updateState } from '../../core/config.js';
6
6
  import { clearAppliedFurnaceState } from '../../core/furnace-config.js';
7
7
  import { resetChanges } from '../../core/git.js';
8
- import { clearRebaseSession, loadRebaseSession } from '../../core/rebase-session.js';
8
+ import { clearRebaseSession, getRebaseSessionPath, readRebaseSession, } from '../../core/rebase-session.js';
9
9
  import { NoRebaseSessionError } from '../../errors/rebase.js';
10
- import { intro, outro, spinner, success } from '../../utils/logger.js';
10
+ import { intro, outro, spinner, success, warn } from '../../utils/logger.js';
11
11
  import { confirmDirtyEngineReset } from './confirm.js';
12
12
  /**
13
13
  * Handles `fireforge rebase --abort`.
14
14
  */
15
15
  export async function handleAbort(projectRoot, yes) {
16
16
  intro('FireForge Rebase — Abort');
17
- const session = await loadRebaseSession(projectRoot);
18
- if (!session)
17
+ // Abort is the escape hatch, so it deliberately does NOT require a *valid*
18
+ // session — only that one is present. Nothing below reads the session
19
+ // object (the restore works off `paths.engine` and `resetChanges`), and the
20
+ // pre-0.41.0 `if (!session) throw` refused to run against a corrupt file,
21
+ // which was the only command that could have cleared it.
22
+ const read = await readRebaseSession(projectRoot);
23
+ if (!read.present)
19
24
  throw new NoRebaseSessionError();
25
+ if (!read.valid) {
26
+ warn(`The rebase session at ${getRebaseSessionPath(projectRoot)} is unreadable ` +
27
+ `(${read.reason}). Aborting anyway: the engine will be restored and the session cleared.`);
28
+ }
20
29
  const paths = getProjectPaths(projectRoot);
21
30
  if (!(await confirmDirtyEngineReset({
22
31
  engineDir: paths.engine,
@@ -8,10 +8,10 @@ import { getStagedDiffForFiles } from '../../core/git-diff.js';
8
8
  import { stageFiles, unstageFiles } from '../../core/git-file-ops.js';
9
9
  import { updatePatchAndMetadata } from '../../core/patch-export.js';
10
10
  import { loadPatchesManifest } from '../../core/patch-manifest.js';
11
- import { loadRebaseSession, saveRebaseSession } from '../../core/rebase-session.js';
11
+ import { getRebaseSessionPath, readRebaseSession, saveRebaseSession, } from '../../core/rebase-session.js';
12
12
  import { runInSignalCriticalSection } from '../../core/signal-critical.js';
13
13
  import { GeneralError } from '../../errors/base.js';
14
- import { NoRebaseSessionError, RebaseError } from '../../errors/rebase.js';
14
+ import { CorruptRebaseSessionError, NoRebaseSessionError, RebaseError, } from '../../errors/rebase.js';
15
15
  import { pathExists } from '../../utils/fs.js';
16
16
  import { info, intro, success, warn } from '../../utils/logger.js';
17
17
  import { runPatchLoop } from './patch-loop.js';
@@ -20,9 +20,16 @@ import { runPatchLoop } from './patch-loop.js';
20
20
  */
21
21
  export async function handleContinue(projectRoot, maxFuzz) {
22
22
  intro('FireForge Rebase — Continue');
23
- const session = await loadRebaseSession(projectRoot);
24
- if (!session)
23
+ // A present-but-unreadable session is NOT "no session in progress" — that
24
+ // conflation is what made a corrupt file unrecoverable. Name the file and
25
+ // point at --abort, which can now clear it.
26
+ const read = await readRebaseSession(projectRoot);
27
+ if (!read.present)
25
28
  throw new NoRebaseSessionError();
29
+ if (!read.valid) {
30
+ throw new CorruptRebaseSessionError(getRebaseSessionPath(projectRoot), read.reason);
31
+ }
32
+ const session = read.session;
26
33
  const paths = getProjectPaths(projectRoot);
27
34
  // Special case: every patch has already applied but a previous run failed
28
35
  // somewhere in the post-apply work (re-export, version stamping). In that
@@ -12,15 +12,15 @@
12
12
  */
13
13
  import { Command } from 'commander';
14
14
  import { getProjectPaths, loadConfig } from '../../core/config.js';
15
+ import { assertEngineGitReady } from '../../core/engine-precondition.js';
15
16
  import { clearAppliedFurnaceState } from '../../core/furnace-config.js';
16
- import { getHead, isGitRepository, isMissingHeadError, resetChanges } from '../../core/git.js';
17
+ import { getHead, resetChanges } from '../../core/git.js';
17
18
  import { discoverPatches } from '../../core/patch-files.js';
18
19
  import { loadPatchesManifest } from '../../core/patch-manifest.js';
19
20
  import { getPatchSourceProduct, getPatchSourceVersion } from '../../core/patch-source-metadata.js';
20
- import { hasActiveRebaseSession, saveRebaseSession } from '../../core/rebase-session.js';
21
+ import { getRebaseSessionPath, readRebaseSession, saveRebaseSession, } from '../../core/rebase-session.js';
21
22
  import { GeneralError } from '../../errors/base.js';
22
- import { RebaseSessionExistsError } from '../../errors/rebase.js';
23
- import { pathExists } from '../../utils/fs.js';
23
+ import { CorruptRebaseSessionError, RebaseSessionExistsError } from '../../errors/rebase.js';
24
24
  import { info, intro, outro, spinner } from '../../utils/logger.js';
25
25
  import { commanderArgParser, pickDefined } from '../../utils/options.js';
26
26
  import { parsePositiveIntegerFlag } from '../../utils/validation.js';
@@ -33,34 +33,24 @@ async function handleFreshStart(projectRoot, options) {
33
33
  const isDryRun = options.dryRun === true;
34
34
  const maxFuzz = options.maxFuzz ?? 3;
35
35
  intro(isDryRun ? 'FireForge Rebase (dry run)' : 'FireForge Rebase');
36
- if (await hasActiveRebaseSession(projectRoot)) {
36
+ // One read decides liveness AND validity: a pathExists pre-probe here left
37
+ // a window where the file vanished between probe and read, reporting
38
+ // "already in progress" with no session on disk.
39
+ const existing = await readRebaseSession(projectRoot);
40
+ if (existing.present) {
41
+ // A corrupt session must not be reported as "already in progress": that
42
+ // message tells the operator to run --continue or --abort, and before
43
+ // 0.41.0 both of those then reported "no rebase session in progress".
44
+ if (!existing.valid) {
45
+ throw new CorruptRebaseSessionError(getRebaseSessionPath(projectRoot), existing.reason);
46
+ }
37
47
  throw new RebaseSessionExistsError();
38
48
  }
39
49
  const paths = getProjectPaths(projectRoot);
40
- if (!(await pathExists(paths.engine))) {
41
- throw new GeneralError('Firefox source not found. Run "fireforge download" first.');
42
- }
43
- if (!(await isGitRepository(paths.engine))) {
44
- throw new GeneralError('Engine directory is not a git repository. Run "fireforge download" to initialize.');
45
- }
46
- // 2026-04-24 eval Finding 11: `rebase --dry-run` used to print
47
- // "Dry run complete" without validating that the engine had a valid
48
- // HEAD. A previous `download --force` abort could leave `.git/`
49
- // initialized but unborn (no baseline commit); the real rebase then
50
- // failed immediately with `fatal: ambiguous argument 'HEAD'` on the
51
- // first `git rev-parse HEAD` call. Replicate the same baseline check
52
- // here so dry-run mirrors the real-run preconditions and operators
53
- // cannot mistake a broken baseline for a ready-to-rebase tree.
54
- try {
55
- await getHead(paths.engine);
56
- }
57
- catch (err) {
58
- if (isMissingHeadError(err)) {
59
- throw new GeneralError('Engine repository has no baseline commit yet — a previous "fireforge download" was interrupted before git created the initial Firefox source commit. ' +
60
- 'Re-run "fireforge download --force" to recreate the baseline repository cleanly, then retry the rebase.');
61
- }
62
- throw err;
63
- }
50
+ // Keeps the rebase-specific tail on the unborn-HEAD remediation (2026-04-24
51
+ // eval Finding 11: --dry-run used to skip this check entirely, so the real
52
+ // run then failed on `git rev-parse HEAD`).
53
+ await assertEngineGitReady(paths.engine, { unbornHeadSuffix: ', then retry the rebase.' });
64
54
  const config = await loadConfig(projectRoot);
65
55
  const currentVersion = config.firefox.version;
66
56
  const manifest = await loadPatchesManifest(paths.patches);
@@ -19,6 +19,7 @@ import { elapsedSince } from '../../utils/elapsed.js';
19
19
  import { toError } from '../../utils/errors.js';
20
20
  import { pathExists } from '../../utils/fs.js';
21
21
  import { error, info, outro, spinner, success, warn } from '../../utils/logger.js';
22
+ import { isValidFirefoxVersion } from '../../utils/validation.js';
22
23
  import { buildRebaseConflictSummary } from './conflict-summary.js';
23
24
  import { printSummary } from './summary.js';
24
25
  /**
@@ -164,6 +165,17 @@ export async function runPatchLoop(projectRoot, session, paths, maxFuzz) {
164
165
  // succeeded on the patch side, so the operator is committing to the
165
166
  // new source baseline; per-component health checking stays with
166
167
  // `fireforge furnace validate` / `doctor --repair-furnace`.
168
+ //
169
+ // "Unconditional" means unconditional on component health, not on the value:
170
+ // `stampFurnaceOverrideBaseVersions` assigns and persists whatever string it
171
+ // is handed, so a session whose `toVersion` is not a real Firefox version
172
+ // would rewrite every override's baseline to that value. `isValidSession`
173
+ // now rejects such a session at read time; this refuses to persist it even
174
+ // if a future caller reaches here another way.
175
+ if (!isValidFirefoxVersion(session.toVersion)) {
176
+ throw new RebaseError(`Refusing to stamp Furnace override baseVersion(s): the rebase session's target version ` +
177
+ `("${session.toVersion}") is not a valid Firefox version.`);
178
+ }
167
179
  try {
168
180
  const overridesStamped = await stampFurnaceOverrideBaseVersions(projectRoot, session.toVersion);
169
181
  if (overridesStamped > 0) {