@hominis/fireforge 0.39.0 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (279) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +35 -5
  3. package/dist/bin/fireforge.js +25 -4
  4. package/dist/build-info.json +9 -0
  5. package/dist/src/cli.js +27 -3
  6. package/dist/src/commands/bootstrap.js +5 -1
  7. package/dist/src/commands/discard.js +2 -22
  8. package/dist/src/commands/doctor/post-rebase-audit.js +27 -4
  9. package/dist/src/commands/doctor-check-core.d.ts +20 -0
  10. package/dist/src/commands/doctor-check-core.js +28 -0
  11. package/dist/src/commands/doctor-external-toolchains.js +2 -0
  12. package/dist/src/commands/doctor-furnace-manifest-sync.js +144 -46
  13. package/dist/src/commands/doctor-furnace.js +13 -11
  14. package/dist/src/commands/doctor-orphaned-harness.js +2 -1
  15. package/dist/src/commands/doctor.js +2 -2
  16. package/dist/src/commands/export-all.js +9 -1
  17. package/dist/src/commands/export-flow.js +27 -9
  18. package/dist/src/commands/export-placement-conflicts.d.ts +17 -0
  19. package/dist/src/commands/export-placement-conflicts.js +74 -0
  20. package/dist/src/commands/export-placement-gate.js +1 -1
  21. package/dist/src/commands/export-shared.d.ts +8 -1
  22. package/dist/src/commands/export-shared.js +30 -10
  23. package/dist/src/commands/export.js +2 -2
  24. package/dist/src/commands/furnace/apply.js +4 -3
  25. package/dist/src/commands/furnace/chrome-doc-remove.js +13 -2
  26. package/dist/src/commands/furnace/chrome-doc.js +13 -2
  27. package/dist/src/commands/furnace/create-browser-test.d.ts +5 -0
  28. package/dist/src/commands/furnace/create-browser-test.js +5 -0
  29. package/dist/src/commands/furnace/create.js +2 -2
  30. package/dist/src/commands/furnace/diff.js +2 -1
  31. package/dist/src/commands/furnace/init.js +4 -6
  32. package/dist/src/commands/furnace/override.js +2 -2
  33. package/dist/src/commands/furnace/remove-mutations.d.ts +40 -0
  34. package/dist/src/commands/furnace/remove-mutations.js +171 -0
  35. package/dist/src/commands/furnace/remove.js +21 -128
  36. package/dist/src/commands/furnace/rename-browser-test.js +54 -5
  37. package/dist/src/commands/furnace/scan.js +27 -36
  38. package/dist/src/commands/furnace/status.js +30 -28
  39. package/dist/src/commands/furnace/validate.js +25 -18
  40. package/dist/src/commands/import.js +6 -20
  41. package/dist/src/commands/lint-per-patch.js +73 -51
  42. package/dist/src/commands/lint-per-run-checkjs.d.ts +64 -0
  43. package/dist/src/commands/lint-per-run-checkjs.js +102 -0
  44. package/dist/src/commands/lint-report.d.ts +21 -0
  45. package/dist/src/commands/lint-report.js +62 -0
  46. package/dist/src/commands/lint.js +26 -22
  47. package/dist/src/commands/manifest.js +3 -1
  48. package/dist/src/commands/package.js +9 -16
  49. package/dist/src/commands/patch/compact.js +5 -5
  50. package/dist/src/commands/patch/delete.js +5 -5
  51. package/dist/src/commands/patch/lint-ignore.js +3 -2
  52. package/dist/src/commands/patch/move-files-create.js +22 -4
  53. package/dist/src/commands/patch/move-files-into.js +14 -10
  54. package/dist/src/commands/patch/move-files.js +6 -5
  55. package/dist/src/commands/patch/rename.d.ts +2 -2
  56. package/dist/src/commands/patch/rename.js +152 -51
  57. package/dist/src/commands/patch/reorder.d.ts +9 -0
  58. package/dist/src/commands/patch/reorder.js +14 -10
  59. package/dist/src/commands/patch/split-plan.d.ts +13 -6
  60. package/dist/src/commands/patch/split-plan.js +22 -12
  61. package/dist/src/commands/patch/split.d.ts +1 -1
  62. package/dist/src/commands/patch/split.js +23 -9
  63. package/dist/src/commands/patch/staged-dependency-validate.d.ts +15 -0
  64. package/dist/src/commands/patch/staged-dependency-validate.js +49 -0
  65. package/dist/src/commands/patch/staged-dependency.js +112 -14
  66. package/dist/src/commands/re-export-adjacent.d.ts +34 -0
  67. package/dist/src/commands/re-export-adjacent.js +105 -0
  68. package/dist/src/commands/re-export-bulk-scan.d.ts +13 -0
  69. package/dist/src/commands/re-export-bulk-scan.js +142 -2
  70. package/dist/src/commands/re-export-drift.d.ts +74 -0
  71. package/dist/src/commands/re-export-drift.js +234 -0
  72. package/dist/src/commands/re-export-files.js +5 -3
  73. package/dist/src/commands/re-export-lint.d.ts +97 -0
  74. package/dist/src/commands/re-export-lint.js +210 -0
  75. package/dist/src/commands/re-export-options.js +11 -0
  76. package/dist/src/commands/re-export-register.d.ts +18 -0
  77. package/dist/src/commands/re-export-register.js +61 -0
  78. package/dist/src/commands/re-export-scan.js +13 -7
  79. package/dist/src/commands/re-export-single.d.ts +20 -0
  80. package/dist/src/commands/re-export-single.js +261 -0
  81. package/dist/src/commands/re-export.d.ts +0 -4
  82. package/dist/src/commands/re-export.js +123 -289
  83. package/dist/src/commands/rebase/abort.js +13 -4
  84. package/dist/src/commands/rebase/continue.js +11 -4
  85. package/dist/src/commands/rebase/index.js +19 -29
  86. package/dist/src/commands/rebase/patch-loop.js +12 -0
  87. package/dist/src/commands/reset.js +4 -23
  88. package/dist/src/commands/resolve.js +2 -9
  89. package/dist/src/commands/run.js +29 -23
  90. package/dist/src/commands/source.js +3 -9
  91. package/dist/src/commands/status-check.d.ts +48 -0
  92. package/dist/src/commands/status-check.js +106 -0
  93. package/dist/src/commands/status-json.d.ts +31 -0
  94. package/dist/src/commands/status-json.js +76 -0
  95. package/dist/src/commands/status-output.d.ts +1 -0
  96. package/dist/src/commands/status-output.js +10 -1
  97. package/dist/src/commands/status-ownership.d.ts +52 -0
  98. package/dist/src/commands/status-ownership.js +57 -0
  99. package/dist/src/commands/status.js +190 -170
  100. package/dist/src/commands/test-build-phase.d.ts +19 -0
  101. package/dist/src/commands/test-build-phase.js +168 -0
  102. package/dist/src/commands/test-diagnose.js +30 -2
  103. package/dist/src/commands/test-modes.d.ts +9 -1
  104. package/dist/src/commands/test-modes.js +46 -13
  105. package/dist/src/commands/test-register.js +59 -27
  106. package/dist/src/commands/test-run.d.ts +29 -3
  107. package/dist/src/commands/test-run.js +59 -7
  108. package/dist/src/commands/test-verdict.d.ts +32 -0
  109. package/dist/src/commands/test-verdict.js +48 -0
  110. package/dist/src/commands/test.d.ts +9 -0
  111. package/dist/src/commands/test.js +100 -123
  112. package/dist/src/commands/token-coverage.js +4 -9
  113. package/dist/src/commands/token.js +4 -1
  114. package/dist/src/commands/tree.d.ts +19 -0
  115. package/dist/src/commands/tree.js +320 -0
  116. package/dist/src/commands/verify.js +2 -2
  117. package/dist/src/commands/watch.js +22 -21
  118. package/dist/src/commands/wire.js +1 -1
  119. package/dist/src/core/ast-utils.d.ts +11 -0
  120. package/dist/src/core/ast-utils.js +11 -0
  121. package/dist/src/core/build-audit-platform.js +6 -1
  122. package/dist/src/core/build-audit-registration.js +2 -0
  123. package/dist/src/core/build-audit-resolve.d.ts +13 -0
  124. package/dist/src/core/build-audit-resolve.js +34 -13
  125. package/dist/src/core/build-audit.js +4 -20
  126. package/dist/src/core/build-baseline-types.d.ts +29 -8
  127. package/dist/src/core/build-baseline-types.js +2 -1
  128. package/dist/src/core/build-baseline.d.ts +10 -2
  129. package/dist/src/core/build-baseline.js +39 -8
  130. package/dist/src/core/build-prepare.d.ts +4 -0
  131. package/dist/src/core/build-prepare.js +7 -1
  132. package/dist/src/core/config-paths.d.ts +1 -1
  133. package/dist/src/core/config-paths.js +2 -0
  134. package/dist/src/core/config-validate-test-toolchains.js +3 -2
  135. package/dist/src/core/config-validate.js +45 -30
  136. package/dist/src/core/config.js +16 -3
  137. package/dist/src/core/coverage-extend.d.ts +89 -0
  138. package/dist/src/core/coverage-extend.js +188 -0
  139. package/dist/src/core/destructive.js +3 -1
  140. package/dist/src/core/discard-baseline.js +18 -4
  141. package/dist/src/core/engine-precondition.d.ts +35 -0
  142. package/dist/src/core/engine-precondition.js +50 -0
  143. package/dist/src/core/engine-session-lock.d.ts +38 -1
  144. package/dist/src/core/engine-session-lock.js +78 -4
  145. package/dist/src/core/file-lock.d.ts +7 -0
  146. package/dist/src/core/file-lock.js +20 -30
  147. package/dist/src/core/firefox.js +5 -2
  148. package/dist/src/core/furnace-apply-helpers.d.ts +12 -1
  149. package/dist/src/core/furnace-apply-helpers.js +12 -3
  150. package/dist/src/core/furnace-apply-output.js +9 -0
  151. package/dist/src/core/furnace-apply-overwrite-warn.d.ts +63 -0
  152. package/dist/src/core/furnace-apply-overwrite-warn.js +123 -0
  153. package/dist/src/core/furnace-apply-undeploy-actions.d.ts +11 -0
  154. package/dist/src/core/furnace-apply-undeploy-actions.js +49 -0
  155. package/dist/src/core/furnace-apply.d.ts +16 -4
  156. package/dist/src/core/furnace-apply.js +39 -43
  157. package/dist/src/core/furnace-config.js +2 -0
  158. package/dist/src/core/furnace-css-fragments.js +2 -0
  159. package/dist/src/core/furnace-jsconfig.js +14 -7
  160. package/dist/src/core/furnace-registration-ast.js +8 -8
  161. package/dist/src/core/furnace-registration-remove.js +6 -3
  162. package/dist/src/core/furnace-registration.js +9 -12
  163. package/dist/src/core/furnace-rollback.js +1 -1
  164. package/dist/src/core/furnace-scanner.js +2 -1
  165. package/dist/src/core/furnace-stale-export.js +2 -1
  166. package/dist/src/core/furnace-state-persist.d.ts +4 -6
  167. package/dist/src/core/furnace-validate-compatibility.js +4 -7
  168. package/dist/src/core/furnace-validate-helpers.js +33 -1
  169. package/dist/src/core/furnace-validate-registration.js +16 -0
  170. package/dist/src/core/furnace-validate.js +4 -2
  171. package/dist/src/core/git-diff.js +37 -2
  172. package/dist/src/core/lint-cache.d.ts +15 -5
  173. package/dist/src/core/lint-cache.js +49 -14
  174. package/dist/src/core/mach-build-artifacts.d.ts +63 -1
  175. package/dist/src/core/mach-build-artifacts.js +108 -2
  176. package/dist/src/core/mach-resource-shim.js +1 -0
  177. package/dist/src/core/marionette-port.d.ts +16 -0
  178. package/dist/src/core/marionette-port.js +93 -8
  179. package/dist/src/core/marionette-preflight.js +6 -5
  180. package/dist/src/core/patch-apply.d.ts +20 -13
  181. package/dist/src/core/patch-apply.js +32 -33
  182. package/dist/src/core/patch-export.d.ts +2 -2
  183. package/dist/src/core/patch-export.js +4 -6
  184. package/dist/src/core/patch-lint-checkjs.d.ts +42 -2
  185. package/dist/src/core/patch-lint-checkjs.js +132 -8
  186. package/dist/src/core/patch-lint-chrome-jsdoc.js +9 -0
  187. package/dist/src/core/patch-lint-creators.d.ts +30 -0
  188. package/dist/src/core/patch-lint-creators.js +45 -0
  189. package/dist/src/core/patch-lint-cross.d.ts +1 -17
  190. package/dist/src/core/patch-lint-cross.js +16 -32
  191. package/dist/src/core/patch-lint-css.js +64 -16
  192. package/dist/src/core/patch-lint-diff.d.ts +8 -0
  193. package/dist/src/core/patch-lint-diff.js +31 -0
  194. package/dist/src/core/patch-lint-jsdoc.d.ts +3 -1
  195. package/dist/src/core/patch-lint-jsdoc.js +15 -5
  196. package/dist/src/core/patch-lint-module-registration.d.ts +15 -0
  197. package/dist/src/core/patch-lint-module-registration.js +88 -0
  198. package/dist/src/core/patch-lint-mozbuild.js +99 -11
  199. package/dist/src/core/patch-lint-ownership.d.ts +21 -0
  200. package/dist/src/core/patch-lint-ownership.js +30 -0
  201. package/dist/src/core/patch-lint-reexports.d.ts +2 -1
  202. package/dist/src/core/patch-lint-reexports.js +1 -0
  203. package/dist/src/core/patch-lint.d.ts +31 -0
  204. package/dist/src/core/patch-lint.js +114 -8
  205. package/dist/src/core/patch-lock.d.ts +12 -1
  206. package/dist/src/core/patch-lock.js +40 -4
  207. package/dist/src/core/patch-manifest-consistency.js +15 -3
  208. package/dist/src/core/patch-manifest.d.ts +1 -1
  209. package/dist/src/core/patch-parse.d.ts +8 -0
  210. package/dist/src/core/patch-parse.js +10 -0
  211. package/dist/src/core/patch-policy.d.ts +7 -0
  212. package/dist/src/core/patch-policy.js +4 -1
  213. package/dist/src/core/patch-transform.d.ts +10 -0
  214. package/dist/src/core/patch-transform.js +13 -2
  215. package/dist/src/core/rebase-session.d.ts +44 -2
  216. package/dist/src/core/rebase-session.js +114 -17
  217. package/dist/src/core/register-browser-content.js +2 -1
  218. package/dist/src/core/register-shared-css.js +2 -1
  219. package/dist/src/core/smoke-patterns.d.ts +24 -16
  220. package/dist/src/core/smoke-patterns.js +26 -14
  221. package/dist/src/core/status-binary.d.ts +55 -0
  222. package/dist/src/core/status-binary.js +124 -0
  223. package/dist/src/core/status-classify.d.ts +22 -1
  224. package/dist/src/core/status-classify.js +112 -70
  225. package/dist/src/core/test-harness-crash.d.ts +32 -1
  226. package/dist/src/core/test-harness-crash.js +59 -4
  227. package/dist/src/core/test-path-scope.js +2 -0
  228. package/dist/src/core/test-stale-check.js +1 -20
  229. package/dist/src/core/token-category.d.ts +40 -0
  230. package/dist/src/core/token-category.js +264 -0
  231. package/dist/src/core/token-manager.d.ts +7 -0
  232. package/dist/src/core/token-manager.js +48 -196
  233. package/dist/src/core/toolchain-preflight.js +24 -26
  234. package/dist/src/core/tree-cow.d.ts +18 -0
  235. package/dist/src/core/tree-cow.js +71 -0
  236. package/dist/src/core/tree-guard.d.ts +35 -0
  237. package/dist/src/core/tree-guard.js +179 -0
  238. package/dist/src/core/tree-store.d.ts +129 -0
  239. package/dist/src/core/tree-store.js +452 -0
  240. package/dist/src/core/typecheck-shim.d.ts +14 -0
  241. package/dist/src/core/typecheck-shim.js +41 -2
  242. package/dist/src/core/typecheck.js +2 -1
  243. package/dist/src/core/wire-destroy.js +14 -3
  244. package/dist/src/core/wire-init.js +37 -40
  245. package/dist/src/core/wire-subscript.js +11 -3
  246. package/dist/src/core/wire-utils.js +4 -4
  247. package/dist/src/core/xpcshell-appdir.js +3 -0
  248. package/dist/src/core/xpcshell-profile-dir.d.ts +12 -0
  249. package/dist/src/core/xpcshell-profile-dir.js +45 -0
  250. package/dist/src/errors/base.d.ts +13 -0
  251. package/dist/src/errors/base.js +13 -0
  252. package/dist/src/errors/git.d.ts +8 -0
  253. package/dist/src/errors/git.js +17 -0
  254. package/dist/src/errors/rebase.d.ts +13 -0
  255. package/dist/src/errors/rebase.js +17 -0
  256. package/dist/src/index.d.ts +2 -0
  257. package/dist/src/index.js +1 -0
  258. package/dist/src/types/commands/options.d.ts +119 -3
  259. package/dist/src/types/commands/patches.d.ts +8 -0
  260. package/dist/src/types/config.d.ts +19 -0
  261. package/dist/src/types/furnace.d.ts +7 -0
  262. package/dist/src/utils/build-info.d.ts +31 -0
  263. package/dist/src/utils/build-info.js +98 -0
  264. package/dist/src/utils/concurrency.d.ts +8 -0
  265. package/dist/src/utils/concurrency.js +21 -0
  266. package/dist/src/utils/errors.d.ts +39 -0
  267. package/dist/src/utils/errors.js +55 -0
  268. package/dist/src/utils/fs.js +5 -12
  269. package/dist/src/utils/logger.d.ts +14 -2
  270. package/dist/src/utils/logger.js +43 -13
  271. package/dist/src/utils/options.d.ts +21 -4
  272. package/dist/src/utils/options.js +26 -6
  273. package/dist/src/utils/package-root.js +7 -4
  274. package/dist/src/utils/process-group.js +13 -2
  275. package/dist/src/utils/stdio-drain.d.ts +28 -0
  276. package/dist/src/utils/stdio-drain.js +54 -0
  277. package/dist/src/utils/validation.d.ts +33 -2
  278. package/dist/src/utils/validation.js +57 -2
  279. package/package.json +13 -13
@@ -1,257 +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
- import { dirname, join } from 'node:path';
4
2
  import { multiselect } from '@clack/prompts';
5
- import { Command, Option } from 'commander';
6
3
  import { getProjectPaths, loadConfig } from '../core/config.js';
7
- import { withEngineSessionLock } from '../core/engine-session-lock.js';
8
- import { enforceFreshFurnaceSources } from '../core/furnace-stale-export.js';
4
+ import { collectFurnaceManagedPrefixes } from '../core/furnace-config.js';
9
5
  import { isGitRepository } from '../core/git.js';
10
- import { getDiffForFilesAgainstHead } from '../core/git-diff.js';
11
- import { getModifiedFilesInDir, getUntrackedFilesInDir } from '../core/git-status.js';
12
- import { updatePatchAndMetadata } from '../core/patch-export.js';
13
- import { buildPatchQueueContext } from '../core/patch-lint.js';
14
- import { getClaimedFiles, loadPatchesManifest, resolvePatchIdentifier, stampPatchVersions, } from '../core/patch-manifest.js';
15
- import { buildProjectedManifest, enforcePatchPolicy } from '../core/patch-policy.js';
6
+ import { loadPatchesManifest, resolvePatchIdentifier, stampPatchVersions, } from '../core/patch-manifest.js';
16
7
  import { GeneralError, InvalidArgumentError } from '../errors/base.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 { loadScanFilesAssignments, withDryRunReExportLock } from './re-export-bulk-scan.js';
12
+ import { loadScanFilesAssignments, withDryRunPurityGuard, withDryRunReExportLock, } from './re-export-bulk-scan.js';
24
13
  import { reExportFilesInPlace } from './re-export-files.js';
14
+ import { buildReExportLintContext, finishReExportLint } from './re-export-lint.js';
25
15
  import { applyReExportFilesPositionalFolding, validateReExportOptionCombinations, } from './re-export-options.js';
26
- import { assertScanAdoptionsHaveNoForwardImports, assertScanFileAdditionsHaveDiffHunks, confirmBroadScanAdditions, normalizeScanFiles, scanPatchFilesForReExport, } from './re-export-scan.js';
27
- async function findMissingFiles(engineDir, files) {
28
- const missingFiles = [];
29
- for (const file of files) {
30
- if (!(await pathExists(join(engineDir, file))))
31
- missingFiles.push(file);
32
- }
33
- return missingFiles;
34
- }
35
- async function findLikelyNewSiblingFiles(args) {
36
- const { currentFilesAffected, engineDir, manifest, patchFilename } = args;
37
- const parentDirs = [...new Set(currentFilesAffected.map((file) => dirname(file)))];
38
- const currentSet = new Set(currentFilesAffected);
39
- const claimedByOthers = getClaimedFiles(manifest, patchFilename);
40
- const candidates = new Set();
41
- for (const dir of parentDirs) {
42
- const [modifiedFiles, untrackedFiles] = await Promise.all([
43
- getModifiedFilesInDir(engineDir, dir),
44
- getUntrackedFilesInDir(engineDir, dir),
45
- ]);
46
- for (const file of [...modifiedFiles, ...untrackedFiles]) {
47
- if (currentSet.has(file) || claimedByOthers.has(file))
48
- continue;
49
- candidates.add(file);
50
- }
51
- }
52
- return [...candidates].sort();
53
- }
54
- async function warnPlainReExportFileDrift(args) {
55
- const { patch, paths, manifest, currentFilesAffected } = args;
56
- const missingFiles = await findMissingFiles(paths.engine, currentFilesAffected);
57
- if (missingFiles.length > 0) {
58
- warn(`${patch.filename}: some files in patches.json no longer exist on disk ` +
59
- `(${missingFiles.join(', ')}). Without --scan, re-export keeps the manifest's ` +
60
- `filesAffected unchanged and the missing entries will be preserved — ` +
61
- `\`fireforge verify\` may flag manifest inconsistency after this run.\n` +
62
- ` Re-run with --scan to reconcile filesAffected with the current worktree, ` +
63
- `or pass --files <paths> to set the list explicitly.`);
64
- }
65
- const likelyNewFiles = await findLikelyNewSiblingFiles({
66
- currentFilesAffected,
67
- engineDir: paths.engine,
68
- manifest,
69
- patchFilename: patch.filename,
70
- });
71
- if (likelyNewFiles.length === 0)
72
- return;
73
- warn(`${patch.filename}: found ${likelyNewFiles.length} unowned changed sibling file${likelyNewFiles.length === 1 ? '' : 's'} near this patch. Plain re-export keeps filesAffected unchanged; add reviewed files explicitly with --scan-file.`);
74
- for (const file of likelyNewFiles) {
75
- info(` ${file} — fireforge re-export ${patch.filename} --scan --scan-file ${file}`);
76
- }
77
- }
78
- async function reExportSinglePatch(patch, paths, manifest, options, isDryRun, config) {
79
- let currentFilesAffected = [...patch.filesAffected];
80
- // --- Scan for new/removed files ---
81
- if (options.scan) {
82
- const scanResult = await scanPatchFilesForReExport({
83
- currentFilesAffected,
84
- engineDir: paths.engine,
85
- manifest,
86
- patchFilename: patch.filename,
87
- isDryRun,
88
- ...(options.scanFiles !== undefined ? { scanFiles: options.scanFiles } : {}),
89
- });
90
- // Forward-import gate at adoption time (runs in dry-run too): refuse
91
- // to adopt unmanaged files that import modules created by LATER
92
- // patches — the after-the-fact lint failure this leaves behind is the
93
- // field-reported footgun. Applies to broad --scan, --scan-file, and
94
- // the --scan-files bulk assignments alike.
95
- await assertScanAdoptionsHaveNoForwardImports({
96
- patchesDir: paths.patches,
97
- engineDir: paths.engine,
98
- patchFilename: patch.filename,
99
- added: scanResult.added,
100
- });
101
- if (options.scanFiles === undefined) {
102
- const isInteractive = process.stdin.isTTY && process.stdout.isTTY;
103
- const proceed = await confirmBroadScanAdditions({
104
- patchFilename: patch.filename,
105
- added: scanResult.added,
106
- isDryRun,
107
- yes: options.yes === true,
108
- isInteractive,
109
- });
110
- if (!proceed) {
111
- return false;
112
- }
113
- }
114
- currentFilesAffected = scanResult.updated;
115
- }
116
- else if (options.files === undefined) {
117
- // Finding #16: when neither `--scan` nor `--files` is set and some
118
- // of the manifest's claimed files no longer exist on disk, the
119
- // re-export silently writes a refreshed body whose filesAffected
120
- // still names the vanished paths. That is the documented contract,
121
- // but it is also a footgun — a later `verify` then fails on
122
- // manifest-consistency with no obvious trigger. Emit one advisory
123
- // warning up-front when we can detect the drift cheaply, so the
124
- // operator has a chance to re-run with `--scan` or `--files`
125
- // before the stale filesAffected lands in patches.json.
126
- await warnPlainReExportFileDrift({ patch, paths, manifest, currentFilesAffected });
127
- }
128
- // --- Explicit file-subset path ---
129
- // When --files is given, the target filesAffected is authoritative — drop
130
- // anything not in the list, add anything new. This is the surgical repair
131
- // primitive that replaces hand-editing patches.json; the user has already
132
- // acknowledged via confirmDestructive (done in the caller) that any drop
133
- // is intentional.
134
- if (options.files !== undefined) {
135
- const requested = [...new Set(options.files)].sort();
136
- currentFilesAffected = requested;
137
- const removed = patch.filesAffected.filter((f) => !requested.includes(f));
138
- const added = requested.filter((f) => !patch.filesAffected.includes(f));
139
- for (const f of added)
140
- info(` + ${f}`);
141
- for (const f of removed)
142
- info(` - ${f}`);
143
- }
144
- // Stale-furnace-source gate (0.37.0 item 4): re-export captures deployed
145
- // engine copies, so a component source edited after the last furnace
146
- // apply would land in the patch as its OLD deployed content. Refuse (or
147
- // warn under --allow-stale-furnace) before diffing. Runs in dry-run too
148
- // so the failure surfaces early.
149
- await enforceFreshFurnaceSources(paths.root, currentFilesAffected, options.allowStaleFurnace === true, 're-export');
150
- const missingFiles = await findMissingFiles(paths.engine, currentFilesAffected);
151
- if (missingFiles.length === currentFilesAffected.length) {
152
- warn(`Skipped ${patch.filename}: all affected files missing`);
153
- warn(`Missing files: ${missingFiles.join(', ')}`);
154
- return false;
155
- }
156
- if (missingFiles.length > 0) {
157
- warn(`${patch.filename}: missing files will be skipped: ${missingFiles.join(', ')}`);
158
- }
159
- const missingSet = new Set(missingFiles);
160
- const existingFiles = currentFilesAffected.filter((f) => !missingSet.has(f));
161
- const diffContent = await getDiffForFilesAgainstHead(paths.engine, existingFiles);
162
- assertScanFileAdditionsHaveDiffHunks({
163
- diffContent,
164
- patchFilename: patch.filename,
165
- previousFilesAffected: patch.filesAffected,
166
- scanFiles: options.scanFiles,
167
- });
168
- if (!diffContent.trim()) {
169
- warn(`Skipped ${patch.filename}: no changes (files unchanged from HEAD)`);
170
- return false;
171
- }
172
- // Thread the patch's own `lintIgnore` list through so the per-patch
173
- // suppression honoured by export/export-all is also honoured here.
174
- // Without this, `re-export` could not refresh an advisory-noisy but
175
- // intentional patch (a cohesive branding bundle, a localised-resource
176
- // pack) without either `--skip-lint` (too blunt) or falling through to
177
- // the full `rebase` flow (which internally skips the lint pipeline).
178
- // The paired `patch.tier` threads the explicit branding-threshold
179
- // opt-in the same way, for the branding patch that also touches a
180
- // non-allowlisted registration sibling.
181
- //
182
- // The CLI flags `--tier` and `--lint-ignore` participate too, with
183
- // append/union semantics on the lint-ignore list (the operator's
184
- // intuition for "I want this patch to also suppress X" — explicit
185
- // removal lives on the `fireforge patch lint-ignore` subcommand).
186
- // Computed before the lint pass so the new intent takes effect on
187
- // this invocation, not the next one.
188
- const existingIgnoreSet = new Set(patch.lintIgnore ?? []);
189
- const flagIgnoreSet = new Set(options.lintIgnore ?? []);
190
- const mergedIgnoreSet = new Set([...existingIgnoreSet, ...flagIgnoreSet]);
191
- const effectiveLintIgnore = mergedIgnoreSet.size > 0 ? [...mergedIgnoreSet] : undefined;
192
- const ignoreChecks = effectiveLintIgnore ? new Set(effectiveLintIgnore) : undefined;
193
- const effectiveTier = options.tier ?? patch.tier;
194
- const updates = {
195
- filesAffected: currentFilesAffected,
196
- };
197
- if (options.tier !== undefined) {
198
- updates.tier = options.tier;
199
- }
200
- if (effectiveLintIgnore !== undefined && flagIgnoreSet.size > 0) {
201
- updates.lintIgnore = effectiveLintIgnore;
202
- }
203
- enforcePatchPolicy({
204
- config,
205
- manifest: buildProjectedManifest(manifest, manifest.patches.map((entry) => entry.filename === patch.filename ? { ...entry, ...updates } : entry)),
206
- command: 're-export',
207
- forceUnsafe: options.forceUnsafe === true,
208
- });
209
- // Pass the whole-queue context so checkJs resolves cross-patch
210
- // `resource:///` imports against the real owning sources (report scope
211
- // stays this patch — see runPatchLint).
212
- const patchQueueCtx = (await pathExists(paths.patches))
213
- ? await buildPatchQueueContext(paths.patches)
214
- : undefined;
215
- await runPatchLint(paths.engine, existingFiles, diffContent, config, options.skipLint, patchQueueCtx, ignoreChecks, effectiveTier);
216
- if (isDryRun) {
217
- info(`[dry-run] ${patch.filename}: ${existingFiles.length} file(s)`);
218
- if (effectiveTier !== undefined && effectiveTier !== patch.tier) {
219
- info(`[dry-run] ${patch.filename}: tier would become ${effectiveTier}`);
220
- }
221
- const addedIgnores = [...flagIgnoreSet].filter((id) => !existingIgnoreSet.has(id));
222
- if (addedIgnores.length > 0) {
223
- info(`[dry-run] ${patch.filename}: lintIgnore would gain ${addedIgnores.join(', ')}`);
224
- }
225
- }
226
- else {
227
- // Atomic body + manifest update under a single patch-directory lock.
228
- // A split `updatePatch` (lock-free) + `updatePatchMetadata` (lock-guarded)
229
- // sequence allows a concurrent `resolve` / `rebase --continue` / `patch
230
- // compact` / `patch reorder` to rewrite the manifest between the two
231
- // writes and leave patch body and `filesAffected` disagreeing.
232
- await updatePatchAndMetadata(paths.patches, patch.filename, diffContent, updates, undefined, {
233
- config,
234
- command: 're-export',
235
- forceUnsafe: options.forceUnsafe === true,
236
- });
237
- // Keep the in-memory manifest in sync so subsequent iterations (notably
238
- // `--all --scan`, where `getClaimedFiles` reads from this manifest) see
239
- // the just-written `filesAffected`. The on-disk write above is the
240
- // authority; this is a cache update.
241
- const patchIndex = manifest.patches.findIndex((pm) => pm.filename === patch.filename);
242
- if (patchIndex !== -1) {
243
- const existingEntry = manifest.patches[patchIndex];
244
- if (existingEntry) {
245
- manifest.patches[patchIndex] = {
246
- ...existingEntry,
247
- ...updates,
248
- };
249
- }
250
- }
251
- success(`Re-exported ${patch.filename}`);
252
- }
253
- return true;
254
- }
16
+ import { normalizeEngineRelativeInput, normalizeScanFiles } from './re-export-scan.js';
17
+ import { reExportSinglePatchWithIndexLockRetry } from './re-export-single.js';
255
18
  async function resolveSelectedPatches(patches, options, manifest) {
256
19
  if (options.all) {
257
20
  return [...manifest.patches];
@@ -355,32 +118,49 @@ export async function reExportCommand(projectRoot, patches, options) {
355
118
  // we write anything.
356
119
  if (options.files !== undefined) {
357
120
  const filesConfig = await loadConfig(projectRoot);
358
- 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)));
359
122
  return;
360
123
  }
361
124
  const config = await loadConfig(projectRoot);
125
+ // Classification inputs for the scan-less adjacency advisory, computed
126
+ // once for the whole run (FORGE G2).
127
+ const adjacentCtx = {
128
+ binaryName: config.binaryName,
129
+ furnacePrefixes: await collectFurnaceManagedPrefixes(paths.root),
130
+ refuseAdjacentUnmanaged: options.refuseAdjacentUnmanaged === true,
131
+ refusals: [],
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);
362
137
  let reExported = 0;
363
138
  const reExportedFilenames = [];
139
+ const failedFilenames = [];
364
140
  const progress = spinner('Preparing re-export...');
365
141
  const startedAt = Date.now();
366
- await withDryRunReExportLock(paths.fireforgeDir, isDryRun, async () => {
142
+ await withDryRunReExportLock(paths.fireforgeDir, isDryRun, async () => withDryRunPurityGuard(paths.engine, paths.patches, isDryRun, async () => {
367
143
  for (const [index, patch] of selectedPatches.entries()) {
368
144
  const assignedScanFiles = scanFilesByPatch?.get(patch.filename);
369
145
  const patchOptions = assignedScanFiles !== undefined ? { ...options, scanFiles: assignedScanFiles } : options;
370
146
  progress.message(`Re-exporting ${index + 1}/${selectedPatches.length}: ${patch.filename} (${patch.filesAffected.length} file(s), ${elapsedSince(startedAt)} elapsed)...`);
371
147
  try {
372
- const exported = await reExportSinglePatch(patch, paths, manifest, patchOptions, isDryRun, config);
148
+ const exported = await reExportSinglePatchWithIndexLockRetry(patch, paths, manifest, patchOptions, isDryRun, config, adjacentCtx, driftCtx, lintCtx);
373
149
  if (exported) {
374
150
  reExported++;
375
151
  reExportedFilenames.push(patch.filename);
376
152
  }
377
153
  }
378
154
  catch (error) {
155
+ failedFilenames.push(patch.filename);
379
156
  warn(`Failed to re-export ${patch.filename}`);
380
157
  warn(toError(error).message);
381
158
  }
382
159
  }
383
- });
160
+ }));
161
+ await finishReExportLint(lintCtx);
162
+ warnUnseenExpectedDrift(driftCtx);
163
+ throwRunLevelRefusals(adjacentCtx, driftCtx, progress);
384
164
  if (reExported === 0 && selectedPatches.length > 0) {
385
165
  progress.error('Re-export failed');
386
166
  throw new GeneralError('All selected patches failed to re-export. Check the errors above.');
@@ -396,59 +176,113 @@ export async function reExportCommand(projectRoot, patches, options) {
396
176
  if (shouldStamp) {
397
177
  await stampPatchVersions(paths.patches, reExportedFilenames, config.firefox.version, config.firefox.product);
398
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;
399
260
  if (isDryRun) {
400
261
  progress.stop('Dry run complete');
401
- success(`[dry-run] Would re-export ${reExported} of ${selectedPatches.length} patch(es)`);
402
- if (options.stamp === true) {
262
+ success(`[dry-run] Would re-export ${reExported} of ${selectedCount} patch(es)`);
263
+ if (args.stampRequested) {
403
264
  info(`[dry-run] Would stamp sourceVersion=${config.firefox.version} (${config.firefox.product}) on ${reExported} patch(es)`);
404
265
  }
405
266
  outro('Dry run complete');
406
267
  }
407
268
  else {
408
269
  progress.stop('Re-export complete');
409
- success(`Re-exported ${reExported} of ${selectedPatches.length} patch(es)`);
410
- if (shouldStamp) {
411
- 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)`);
412
273
  }
413
- else if (options.stamp === true && reExported !== selectedPatches.length) {
274
+ else if (args.stampRequested && reExported !== selectedCount) {
414
275
  warn('--stamp was requested but some patches failed or were skipped; refusing to stamp a partial set.');
415
276
  }
416
277
  outro('Re-export complete');
417
278
  }
418
- }
419
- /** Registers the re-export command on the CLI program. */
420
- export function registerReExport(program, { getProjectRoot, withErrorHandling }) {
421
- const reExport = program
422
- .command('re-export [patches...]')
423
- .description('Refresh existing patch bodies (and filesAffected with --scan) from the current engine ' +
424
- 'state. Does NOT change sourceVersion/sourceProduct by default — use --stamp or run ' +
425
- 'rebase for source metadata stamping.')
426
- .option('-a, --all', 'Re-export all patches')
427
- .option('-s, --scan', 'Scan directories for new/removed files and update filesAffected')
428
- .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], [])
429
- .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.')
430
- .option('--files <paths>', 'Restrict the re-exported filesAffected to this comma-separated list (single target patch only)', (value) => value
431
- .split(',')
432
- .map((v) => v.trim())
433
- .filter((v) => v.length > 0))
434
- .option('--dry-run', 'Show what would change without writing')
435
- .option('--skip-lint', 'Skip patch lint checks (downgrade errors to warnings)')
436
- .option('--allow-stale-furnace', 'Export the deployed engine copy even when the components/ source changed since the last furnace apply')
437
- .option('--allow-shrink', 'Allow --files to remove paths currently owned by the patch. Required before --yes can bypass the shrink confirmation.')
438
- .option('-y, --yes', 'Skip confirmation prompts (required for non-TTY destructive writes)')
439
- .option('--force-unsafe', 'Bypass cross-patch lint refusal when --files shrinks a patch')
440
- .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.")
441
- .addOption(new Option('--tier <tier>', 'Force a tier override on the selected patch (only "branding" recognised). Mutually exclusive with --all.').choices(['branding']))
442
- .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], []);
443
- addWaitLockOption(reExport).action(withErrorHandling(async (patches, options) => {
444
- const { tier, lintIgnore, scanFile, scanFiles, ...rest } = options;
445
- const projectRoot = getProjectRoot();
446
- await withEngineSessionLock(projectRoot, 're-export', () => reExportCommand(projectRoot, patches, {
447
- ...pickDefined(rest),
448
- ...(scanFile !== undefined && scanFile.length > 0 ? { scanFiles: scanFile } : {}),
449
- ...(scanFiles !== undefined ? { scanFilesManifest: scanFiles } : {}),
450
- ...(tier !== undefined ? { tier: tier } : {}),
451
- ...(lintIgnore !== undefined && lintIgnore.length > 0 ? { lintIgnore } : {}),
452
- }), { waitLockSeconds: resolveWaitLockSeconds(options.waitLock) });
453
- }));
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
+ }
454
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) {