@hominis/fireforge 0.39.0 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (279) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +35 -5
  3. package/dist/bin/fireforge.js +25 -4
  4. package/dist/build-info.json +9 -0
  5. package/dist/src/cli.js +27 -3
  6. package/dist/src/commands/bootstrap.js +5 -1
  7. package/dist/src/commands/discard.js +2 -22
  8. package/dist/src/commands/doctor/post-rebase-audit.js +27 -4
  9. package/dist/src/commands/doctor-check-core.d.ts +20 -0
  10. package/dist/src/commands/doctor-check-core.js +28 -0
  11. package/dist/src/commands/doctor-external-toolchains.js +2 -0
  12. package/dist/src/commands/doctor-furnace-manifest-sync.js +144 -46
  13. package/dist/src/commands/doctor-furnace.js +13 -11
  14. package/dist/src/commands/doctor-orphaned-harness.js +2 -1
  15. package/dist/src/commands/doctor.js +2 -2
  16. package/dist/src/commands/export-all.js +9 -1
  17. package/dist/src/commands/export-flow.js +27 -9
  18. package/dist/src/commands/export-placement-conflicts.d.ts +17 -0
  19. package/dist/src/commands/export-placement-conflicts.js +74 -0
  20. package/dist/src/commands/export-placement-gate.js +1 -1
  21. package/dist/src/commands/export-shared.d.ts +8 -1
  22. package/dist/src/commands/export-shared.js +30 -10
  23. package/dist/src/commands/export.js +2 -2
  24. package/dist/src/commands/furnace/apply.js +4 -3
  25. package/dist/src/commands/furnace/chrome-doc-remove.js +13 -2
  26. package/dist/src/commands/furnace/chrome-doc.js +13 -2
  27. package/dist/src/commands/furnace/create-browser-test.d.ts +5 -0
  28. package/dist/src/commands/furnace/create-browser-test.js +5 -0
  29. package/dist/src/commands/furnace/create.js +2 -2
  30. package/dist/src/commands/furnace/diff.js +2 -1
  31. package/dist/src/commands/furnace/init.js +4 -6
  32. package/dist/src/commands/furnace/override.js +2 -2
  33. package/dist/src/commands/furnace/remove-mutations.d.ts +40 -0
  34. package/dist/src/commands/furnace/remove-mutations.js +171 -0
  35. package/dist/src/commands/furnace/remove.js +21 -128
  36. package/dist/src/commands/furnace/rename-browser-test.js +54 -5
  37. package/dist/src/commands/furnace/scan.js +27 -36
  38. package/dist/src/commands/furnace/status.js +30 -28
  39. package/dist/src/commands/furnace/validate.js +25 -18
  40. package/dist/src/commands/import.js +6 -20
  41. package/dist/src/commands/lint-per-patch.js +73 -51
  42. package/dist/src/commands/lint-per-run-checkjs.d.ts +64 -0
  43. package/dist/src/commands/lint-per-run-checkjs.js +102 -0
  44. package/dist/src/commands/lint-report.d.ts +21 -0
  45. package/dist/src/commands/lint-report.js +62 -0
  46. package/dist/src/commands/lint.js +26 -22
  47. package/dist/src/commands/manifest.js +3 -1
  48. package/dist/src/commands/package.js +9 -16
  49. package/dist/src/commands/patch/compact.js +5 -5
  50. package/dist/src/commands/patch/delete.js +5 -5
  51. package/dist/src/commands/patch/lint-ignore.js +3 -2
  52. package/dist/src/commands/patch/move-files-create.js +22 -4
  53. package/dist/src/commands/patch/move-files-into.js +14 -10
  54. package/dist/src/commands/patch/move-files.js +6 -5
  55. package/dist/src/commands/patch/rename.d.ts +2 -2
  56. package/dist/src/commands/patch/rename.js +152 -51
  57. package/dist/src/commands/patch/reorder.d.ts +9 -0
  58. package/dist/src/commands/patch/reorder.js +14 -10
  59. package/dist/src/commands/patch/split-plan.d.ts +13 -6
  60. package/dist/src/commands/patch/split-plan.js +22 -12
  61. package/dist/src/commands/patch/split.d.ts +1 -1
  62. package/dist/src/commands/patch/split.js +23 -9
  63. package/dist/src/commands/patch/staged-dependency-validate.d.ts +15 -0
  64. package/dist/src/commands/patch/staged-dependency-validate.js +49 -0
  65. package/dist/src/commands/patch/staged-dependency.js +112 -14
  66. package/dist/src/commands/re-export-adjacent.d.ts +34 -0
  67. package/dist/src/commands/re-export-adjacent.js +105 -0
  68. package/dist/src/commands/re-export-bulk-scan.d.ts +13 -0
  69. package/dist/src/commands/re-export-bulk-scan.js +142 -2
  70. package/dist/src/commands/re-export-drift.d.ts +74 -0
  71. package/dist/src/commands/re-export-drift.js +234 -0
  72. package/dist/src/commands/re-export-files.js +5 -3
  73. package/dist/src/commands/re-export-lint.d.ts +97 -0
  74. package/dist/src/commands/re-export-lint.js +210 -0
  75. package/dist/src/commands/re-export-options.js +11 -0
  76. package/dist/src/commands/re-export-register.d.ts +18 -0
  77. package/dist/src/commands/re-export-register.js +61 -0
  78. package/dist/src/commands/re-export-scan.js +13 -7
  79. package/dist/src/commands/re-export-single.d.ts +20 -0
  80. package/dist/src/commands/re-export-single.js +261 -0
  81. package/dist/src/commands/re-export.d.ts +0 -4
  82. package/dist/src/commands/re-export.js +123 -289
  83. package/dist/src/commands/rebase/abort.js +13 -4
  84. package/dist/src/commands/rebase/continue.js +11 -4
  85. package/dist/src/commands/rebase/index.js +19 -29
  86. package/dist/src/commands/rebase/patch-loop.js +12 -0
  87. package/dist/src/commands/reset.js +4 -23
  88. package/dist/src/commands/resolve.js +2 -9
  89. package/dist/src/commands/run.js +29 -23
  90. package/dist/src/commands/source.js +3 -9
  91. package/dist/src/commands/status-check.d.ts +48 -0
  92. package/dist/src/commands/status-check.js +106 -0
  93. package/dist/src/commands/status-json.d.ts +31 -0
  94. package/dist/src/commands/status-json.js +76 -0
  95. package/dist/src/commands/status-output.d.ts +1 -0
  96. package/dist/src/commands/status-output.js +10 -1
  97. package/dist/src/commands/status-ownership.d.ts +52 -0
  98. package/dist/src/commands/status-ownership.js +57 -0
  99. package/dist/src/commands/status.js +190 -170
  100. package/dist/src/commands/test-build-phase.d.ts +19 -0
  101. package/dist/src/commands/test-build-phase.js +168 -0
  102. package/dist/src/commands/test-diagnose.js +30 -2
  103. package/dist/src/commands/test-modes.d.ts +9 -1
  104. package/dist/src/commands/test-modes.js +46 -13
  105. package/dist/src/commands/test-register.js +59 -27
  106. package/dist/src/commands/test-run.d.ts +29 -3
  107. package/dist/src/commands/test-run.js +59 -7
  108. package/dist/src/commands/test-verdict.d.ts +32 -0
  109. package/dist/src/commands/test-verdict.js +48 -0
  110. package/dist/src/commands/test.d.ts +9 -0
  111. package/dist/src/commands/test.js +100 -123
  112. package/dist/src/commands/token-coverage.js +4 -9
  113. package/dist/src/commands/token.js +4 -1
  114. package/dist/src/commands/tree.d.ts +19 -0
  115. package/dist/src/commands/tree.js +320 -0
  116. package/dist/src/commands/verify.js +2 -2
  117. package/dist/src/commands/watch.js +22 -21
  118. package/dist/src/commands/wire.js +1 -1
  119. package/dist/src/core/ast-utils.d.ts +11 -0
  120. package/dist/src/core/ast-utils.js +11 -0
  121. package/dist/src/core/build-audit-platform.js +6 -1
  122. package/dist/src/core/build-audit-registration.js +2 -0
  123. package/dist/src/core/build-audit-resolve.d.ts +13 -0
  124. package/dist/src/core/build-audit-resolve.js +34 -13
  125. package/dist/src/core/build-audit.js +4 -20
  126. package/dist/src/core/build-baseline-types.d.ts +29 -8
  127. package/dist/src/core/build-baseline-types.js +2 -1
  128. package/dist/src/core/build-baseline.d.ts +10 -2
  129. package/dist/src/core/build-baseline.js +39 -8
  130. package/dist/src/core/build-prepare.d.ts +4 -0
  131. package/dist/src/core/build-prepare.js +7 -1
  132. package/dist/src/core/config-paths.d.ts +1 -1
  133. package/dist/src/core/config-paths.js +2 -0
  134. package/dist/src/core/config-validate-test-toolchains.js +3 -2
  135. package/dist/src/core/config-validate.js +45 -30
  136. package/dist/src/core/config.js +16 -3
  137. package/dist/src/core/coverage-extend.d.ts +89 -0
  138. package/dist/src/core/coverage-extend.js +188 -0
  139. package/dist/src/core/destructive.js +3 -1
  140. package/dist/src/core/discard-baseline.js +18 -4
  141. package/dist/src/core/engine-precondition.d.ts +35 -0
  142. package/dist/src/core/engine-precondition.js +50 -0
  143. package/dist/src/core/engine-session-lock.d.ts +38 -1
  144. package/dist/src/core/engine-session-lock.js +78 -4
  145. package/dist/src/core/file-lock.d.ts +7 -0
  146. package/dist/src/core/file-lock.js +20 -30
  147. package/dist/src/core/firefox.js +5 -2
  148. package/dist/src/core/furnace-apply-helpers.d.ts +12 -1
  149. package/dist/src/core/furnace-apply-helpers.js +12 -3
  150. package/dist/src/core/furnace-apply-output.js +9 -0
  151. package/dist/src/core/furnace-apply-overwrite-warn.d.ts +63 -0
  152. package/dist/src/core/furnace-apply-overwrite-warn.js +123 -0
  153. package/dist/src/core/furnace-apply-undeploy-actions.d.ts +11 -0
  154. package/dist/src/core/furnace-apply-undeploy-actions.js +49 -0
  155. package/dist/src/core/furnace-apply.d.ts +16 -4
  156. package/dist/src/core/furnace-apply.js +39 -43
  157. package/dist/src/core/furnace-config.js +2 -0
  158. package/dist/src/core/furnace-css-fragments.js +2 -0
  159. package/dist/src/core/furnace-jsconfig.js +14 -7
  160. package/dist/src/core/furnace-registration-ast.js +8 -8
  161. package/dist/src/core/furnace-registration-remove.js +6 -3
  162. package/dist/src/core/furnace-registration.js +9 -12
  163. package/dist/src/core/furnace-rollback.js +1 -1
  164. package/dist/src/core/furnace-scanner.js +2 -1
  165. package/dist/src/core/furnace-stale-export.js +2 -1
  166. package/dist/src/core/furnace-state-persist.d.ts +4 -6
  167. package/dist/src/core/furnace-validate-compatibility.js +4 -7
  168. package/dist/src/core/furnace-validate-helpers.js +33 -1
  169. package/dist/src/core/furnace-validate-registration.js +16 -0
  170. package/dist/src/core/furnace-validate.js +4 -2
  171. package/dist/src/core/git-diff.js +37 -2
  172. package/dist/src/core/lint-cache.d.ts +15 -5
  173. package/dist/src/core/lint-cache.js +49 -14
  174. package/dist/src/core/mach-build-artifacts.d.ts +63 -1
  175. package/dist/src/core/mach-build-artifacts.js +108 -2
  176. package/dist/src/core/mach-resource-shim.js +1 -0
  177. package/dist/src/core/marionette-port.d.ts +16 -0
  178. package/dist/src/core/marionette-port.js +93 -8
  179. package/dist/src/core/marionette-preflight.js +6 -5
  180. package/dist/src/core/patch-apply.d.ts +20 -13
  181. package/dist/src/core/patch-apply.js +32 -33
  182. package/dist/src/core/patch-export.d.ts +2 -2
  183. package/dist/src/core/patch-export.js +4 -6
  184. package/dist/src/core/patch-lint-checkjs.d.ts +42 -2
  185. package/dist/src/core/patch-lint-checkjs.js +132 -8
  186. package/dist/src/core/patch-lint-chrome-jsdoc.js +9 -0
  187. package/dist/src/core/patch-lint-creators.d.ts +30 -0
  188. package/dist/src/core/patch-lint-creators.js +45 -0
  189. package/dist/src/core/patch-lint-cross.d.ts +1 -17
  190. package/dist/src/core/patch-lint-cross.js +16 -32
  191. package/dist/src/core/patch-lint-css.js +64 -16
  192. package/dist/src/core/patch-lint-diff.d.ts +8 -0
  193. package/dist/src/core/patch-lint-diff.js +31 -0
  194. package/dist/src/core/patch-lint-jsdoc.d.ts +3 -1
  195. package/dist/src/core/patch-lint-jsdoc.js +15 -5
  196. package/dist/src/core/patch-lint-module-registration.d.ts +15 -0
  197. package/dist/src/core/patch-lint-module-registration.js +88 -0
  198. package/dist/src/core/patch-lint-mozbuild.js +99 -11
  199. package/dist/src/core/patch-lint-ownership.d.ts +21 -0
  200. package/dist/src/core/patch-lint-ownership.js +30 -0
  201. package/dist/src/core/patch-lint-reexports.d.ts +2 -1
  202. package/dist/src/core/patch-lint-reexports.js +1 -0
  203. package/dist/src/core/patch-lint.d.ts +31 -0
  204. package/dist/src/core/patch-lint.js +114 -8
  205. package/dist/src/core/patch-lock.d.ts +12 -1
  206. package/dist/src/core/patch-lock.js +40 -4
  207. package/dist/src/core/patch-manifest-consistency.js +15 -3
  208. package/dist/src/core/patch-manifest.d.ts +1 -1
  209. package/dist/src/core/patch-parse.d.ts +8 -0
  210. package/dist/src/core/patch-parse.js +10 -0
  211. package/dist/src/core/patch-policy.d.ts +7 -0
  212. package/dist/src/core/patch-policy.js +4 -1
  213. package/dist/src/core/patch-transform.d.ts +10 -0
  214. package/dist/src/core/patch-transform.js +13 -2
  215. package/dist/src/core/rebase-session.d.ts +44 -2
  216. package/dist/src/core/rebase-session.js +114 -17
  217. package/dist/src/core/register-browser-content.js +2 -1
  218. package/dist/src/core/register-shared-css.js +2 -1
  219. package/dist/src/core/smoke-patterns.d.ts +24 -16
  220. package/dist/src/core/smoke-patterns.js +26 -14
  221. package/dist/src/core/status-binary.d.ts +55 -0
  222. package/dist/src/core/status-binary.js +124 -0
  223. package/dist/src/core/status-classify.d.ts +22 -1
  224. package/dist/src/core/status-classify.js +112 -70
  225. package/dist/src/core/test-harness-crash.d.ts +32 -1
  226. package/dist/src/core/test-harness-crash.js +59 -4
  227. package/dist/src/core/test-path-scope.js +2 -0
  228. package/dist/src/core/test-stale-check.js +1 -20
  229. package/dist/src/core/token-category.d.ts +40 -0
  230. package/dist/src/core/token-category.js +264 -0
  231. package/dist/src/core/token-manager.d.ts +7 -0
  232. package/dist/src/core/token-manager.js +48 -196
  233. package/dist/src/core/toolchain-preflight.js +24 -26
  234. package/dist/src/core/tree-cow.d.ts +18 -0
  235. package/dist/src/core/tree-cow.js +71 -0
  236. package/dist/src/core/tree-guard.d.ts +35 -0
  237. package/dist/src/core/tree-guard.js +179 -0
  238. package/dist/src/core/tree-store.d.ts +129 -0
  239. package/dist/src/core/tree-store.js +452 -0
  240. package/dist/src/core/typecheck-shim.d.ts +14 -0
  241. package/dist/src/core/typecheck-shim.js +41 -2
  242. package/dist/src/core/typecheck.js +2 -1
  243. package/dist/src/core/wire-destroy.js +14 -3
  244. package/dist/src/core/wire-init.js +37 -40
  245. package/dist/src/core/wire-subscript.js +11 -3
  246. package/dist/src/core/wire-utils.js +4 -4
  247. package/dist/src/core/xpcshell-appdir.js +3 -0
  248. package/dist/src/core/xpcshell-profile-dir.d.ts +12 -0
  249. package/dist/src/core/xpcshell-profile-dir.js +45 -0
  250. package/dist/src/errors/base.d.ts +13 -0
  251. package/dist/src/errors/base.js +13 -0
  252. package/dist/src/errors/git.d.ts +8 -0
  253. package/dist/src/errors/git.js +17 -0
  254. package/dist/src/errors/rebase.d.ts +13 -0
  255. package/dist/src/errors/rebase.js +17 -0
  256. package/dist/src/index.d.ts +2 -0
  257. package/dist/src/index.js +1 -0
  258. package/dist/src/types/commands/options.d.ts +119 -3
  259. package/dist/src/types/commands/patches.d.ts +8 -0
  260. package/dist/src/types/config.d.ts +19 -0
  261. package/dist/src/types/furnace.d.ts +7 -0
  262. package/dist/src/utils/build-info.d.ts +31 -0
  263. package/dist/src/utils/build-info.js +98 -0
  264. package/dist/src/utils/concurrency.d.ts +8 -0
  265. package/dist/src/utils/concurrency.js +21 -0
  266. package/dist/src/utils/errors.d.ts +39 -0
  267. package/dist/src/utils/errors.js +55 -0
  268. package/dist/src/utils/fs.js +5 -12
  269. package/dist/src/utils/logger.d.ts +14 -2
  270. package/dist/src/utils/logger.js +43 -13
  271. package/dist/src/utils/options.d.ts +21 -4
  272. package/dist/src/utils/options.js +26 -6
  273. package/dist/src/utils/package-root.js +7 -4
  274. package/dist/src/utils/process-group.js +13 -2
  275. package/dist/src/utils/stdio-drain.d.ts +28 -0
  276. package/dist/src/utils/stdio-drain.js +54 -0
  277. package/dist/src/utils/validation.d.ts +33 -2
  278. package/dist/src/utils/validation.js +57 -2
  279. package/package.json +13 -13
@@ -0,0 +1,210 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Hoisted + cached lint context for `re-export` (FORGE J1).
4
+ *
5
+ * The per-patch loop used to rebuild the whole patch-queue context AND a
6
+ * fresh queue-wide checkJs TypeScript program for every patch — ~37 s of
7
+ * fixed setup per patch (measured on a 290-patch consumer queue), so an
8
+ * N-patch re-export cost N×37 s. This module:
9
+ *
10
+ * - builds the queue context and the {@link PerRunCheckJs} program
11
+ * controller ONCE per invocation (`buildReExportLintContext`) and slices
12
+ * per-patch findings out of it (the FORGE I3 shape `move-files`/`split`
13
+ * already use, plus the program-level hoist `lint --per-patch` has);
14
+ * - reuses the existing per-patch lint RESULT cache
15
+ * (`.fireforge/lint-cache/per-patch-v1.json`) across invocations —
16
+ * re-export lints the identical input `lint --per-patch` does (the
17
+ * fresh `git diff HEAD` of the owned files), and the key already
18
+ * hashes engine HEAD, per-file content, patch body, config, shims, and
19
+ * ownership, so a repeat single re-export is a warm hit (~4 s instead
20
+ * of ~41 s). `--no-cache` opts out; run-level checkJs globals are
21
+ * never cached (FORGE F5) and are emitted once per invocation;
22
+ * - keeps the in-memory queue context honest after each write
23
+ * (`refreshQueueCtxEntry`), so later iterations of an `--all` run lint
24
+ * against the just-refreshed body instead of the stale one.
25
+ */
26
+ import { getProjectPaths, loadConfig } from '../core/config.js';
27
+ import { buildPerPatchLintCacheKey, getCachedPerPatchLintIssues, getPerPatchLintCacheHeadSha, loadPerPatchLintCache, savePerPatchLintCache, setCachedPerPatchLintIssues, } from '../core/lint-cache.js';
28
+ import { buildPatchQueueContext, countNonBinaryDiffLines, detectNewFilesInDiff, lintExportedPatch, resolvePatchSizeTier, } from '../core/patch-lint.js';
29
+ import { invalidateNewFileCreatorsCache } from '../core/patch-lint-cross.js';
30
+ import { extractAddedLinesPerFile } from '../core/patch-lint-diff.js';
31
+ import { resolvePatchOwnedSysMjs } from '../core/patch-lint-ownership.js';
32
+ import { pathExists } from '../utils/fs.js';
33
+ import { info } from '../utils/logger.js';
34
+ import { reportPatchLintOutcome } from './export-shared.js';
35
+ import { buildPerRunCheckJs } from './lint-per-run-checkjs.js';
36
+ /** Builds the once-per-invocation lint context (FORGE J1). */
37
+ export async function buildReExportLintContext(projectRoot, paths, config, noCache) {
38
+ const patchQueueCtx = (await pathExists(paths.patches))
39
+ ? await buildPatchQueueContext(paths.patches, config)
40
+ : undefined;
41
+ const checkJs = patchQueueCtx
42
+ ? buildPerRunCheckJs(projectRoot, paths, config, patchQueueCtx)
43
+ : undefined;
44
+ const cache = noCache ? undefined : await loadPerPatchLintCache(projectRoot);
45
+ const engineHeadSha = cache ? await getPerPatchLintCacheHeadSha(paths.engine) : undefined;
46
+ return {
47
+ projectRoot,
48
+ paths,
49
+ config,
50
+ patchQueueCtx,
51
+ checkJs,
52
+ cache,
53
+ engineHeadSha,
54
+ cacheDirty: false,
55
+ reusedCacheEntries: 0,
56
+ globalsEmitted: false,
57
+ };
58
+ }
59
+ /** The checkJs-relevant files this fresh diff creates. */
60
+ function checkJsFilesInDiff(diffContent, checkJs) {
61
+ const files = new Set();
62
+ for (const f of detectNewFilesInDiff(diffContent)) {
63
+ if (checkJs.isRelevant(f))
64
+ files.add(f);
65
+ }
66
+ return files;
67
+ }
68
+ /**
69
+ * Lints one re-exported patch body using the hoisted context: cache hit →
70
+ * replay the stored issues through the shared reporter (returns `null`);
71
+ * miss → lint with the queue-wide program's pre-attributed slices (or a
72
+ * per-patch build when the fresh diff creates files the hoisted program
73
+ * never saw — the `--scan` adoption case) and return the payload for
74
+ * {@link storeReExportLintResult}. Throws exactly like `runPatchLint` on
75
+ * errors (unless `skipLint`).
76
+ *
77
+ * The cache key is built over the PROJECTED metadata (current
78
+ * filesAffected, merged lintIgnore, effective tier) so `--tier` /
79
+ * `--lint-ignore` flags change the key. The entry itself is stored AFTER
80
+ * the body write (the key hashes the on-disk `.patch` file, which this
81
+ * very run is about to rewrite — a pre-write store could never match a
82
+ * later run).
83
+ */
84
+ export async function lintReExportedPatch(args) {
85
+ const { lintCtx, patch, projectedMetadata, existingFiles, diffContent } = args;
86
+ const { paths, config, patchQueueCtx, checkJs, cache } = lintCtx;
87
+ // Tier notice, identical to runPatchLint's head — surfaced on both the
88
+ // cached and fresh paths so the governing tier is never silent.
89
+ const tierDecision = resolvePatchSizeTier(existingFiles, args.patchTier);
90
+ if (tierDecision.tier === 'branding') {
91
+ info(tierDecision.source === 'explicit'
92
+ ? 'Lint: branding threshold tier applied via patches.json `tier: "branding"` opt-in.'
93
+ : 'Lint: branding threshold tier applied (patch is all under browser/branding/ plus registration siblings).');
94
+ }
95
+ // Run-level checkJs globals surface exactly once per invocation, never
96
+ // from the cache (FORGE F5). On an all-warm run this is a cheap probe,
97
+ // not a program build.
98
+ let globalIssues = [];
99
+ if (checkJs && !lintCtx.globalsEmitted) {
100
+ lintCtx.globalsEmitted = true;
101
+ globalIssues = await checkJs.getGlobal();
102
+ }
103
+ if (cache && patchQueueCtx) {
104
+ const cacheKey = await buildPerPatchLintCacheKey({
105
+ projectRoot: lintCtx.projectRoot,
106
+ engineDir: paths.engine,
107
+ patchesDir: paths.patches,
108
+ patch: projectedMetadata,
109
+ existingFiles,
110
+ config,
111
+ queueContext: patchQueueCtx,
112
+ ...(lintCtx.engineHeadSha === undefined ? {} : { engineHeadSha: lintCtx.engineHeadSha }),
113
+ });
114
+ const cached = getCachedPerPatchLintIssues(cache, patch.filename, cacheKey);
115
+ if (cached) {
116
+ lintCtx.reusedCacheEntries++;
117
+ reportPatchLintOutcome([...globalIssues, ...cached.issues], args.skipLint);
118
+ return null;
119
+ }
120
+ }
121
+ const lintOptions = {};
122
+ const suppressedIssues = [];
123
+ lintOptions.onSuppressed = (suppressed) => suppressedIssues.push(...suppressed);
124
+ if (checkJs) {
125
+ const created = checkJsFilesInDiff(diffContent, checkJs);
126
+ const allKnown = [...created].every((f) => checkJs.resolutionSet.has(f));
127
+ if (allKnown) {
128
+ // Slice this patch's findings out of the one queue-wide program.
129
+ lintOptions.precomputedCheckJs = await checkJs.sliceFor(created);
130
+ }
131
+ else {
132
+ // Fresh `--scan` adoption created checkJs-relevant files the hoisted
133
+ // program has never seen — fall back to a per-patch build with the
134
+ // usual report scope so nothing is silently unchecked (FORGE F5).
135
+ lintOptions.checkJsReportScope = resolvePatchOwnedSysMjs(detectNewFilesInDiff(diffContent));
136
+ }
137
+ }
138
+ else if (patchQueueCtx) {
139
+ lintOptions.checkJsReportScope = resolvePatchOwnedSysMjs(detectNewFilesInDiff(diffContent));
140
+ }
141
+ const issues = await lintExportedPatch(paths.engine, existingFiles, diffContent, config, patchQueueCtx, args.ignoreChecks, args.patchTier, lintOptions);
142
+ reportPatchLintOutcome([...globalIssues, ...issues], args.skipLint);
143
+ return {
144
+ issues,
145
+ suppressed: suppressedIssues,
146
+ lineCount: countNonBinaryDiffLines(diffContent).textLines,
147
+ };
148
+ }
149
+ /**
150
+ * Stores a fresh lint result AFTER the body write, keying against the
151
+ * just-written `.patch` file and the refreshed queue context — the state
152
+ * a later identical invocation will observe (call
153
+ * {@link refreshQueueCtxEntry} first).
154
+ */
155
+ export async function storeReExportLintResult(lintCtx, patchFilename, projectedMetadata, existingFiles, result) {
156
+ const { cache, patchQueueCtx, paths, config } = lintCtx;
157
+ if (!cache || !patchQueueCtx)
158
+ return;
159
+ const cacheKey = await buildPerPatchLintCacheKey({
160
+ projectRoot: lintCtx.projectRoot,
161
+ engineDir: paths.engine,
162
+ patchesDir: paths.patches,
163
+ patch: projectedMetadata,
164
+ existingFiles,
165
+ config,
166
+ queueContext: patchQueueCtx,
167
+ ...(lintCtx.engineHeadSha === undefined ? {} : { engineHeadSha: lintCtx.engineHeadSha }),
168
+ });
169
+ setCachedPerPatchLintIssues(cache, patchFilename, cacheKey, result.issues, result.suppressed, result.lineCount);
170
+ lintCtx.cacheDirty = true;
171
+ }
172
+ /**
173
+ * Refreshes the in-memory queue entry for a just-rewritten patch so later
174
+ * iterations (and later cache keys) lint against the new body instead of
175
+ * the stale one. The on-disk write is the authority; this mirrors it.
176
+ */
177
+ export function refreshQueueCtxEntry(lintCtx, patchFilename, diffContent) {
178
+ const ctx = lintCtx.patchQueueCtx;
179
+ if (!ctx)
180
+ return;
181
+ const entry = ctx.entries.find((e) => e.filename === patchFilename);
182
+ if (!entry)
183
+ return;
184
+ entry.diff = diffContent;
185
+ const newFilePaths = detectNewFilesInDiff(diffContent);
186
+ const addedLinesByFile = extractAddedLinesPerFile(diffContent);
187
+ const newFiles = new Map();
188
+ const modifiedFileAdditions = new Map();
189
+ for (const [file, lines] of addedLinesByFile) {
190
+ if (newFilePaths.has(file)) {
191
+ // A created file's content IS its added lines.
192
+ newFiles.set(file, lines.join('\n'));
193
+ }
194
+ else {
195
+ modifiedFileAdditions.set(file, lines.join('\n'));
196
+ }
197
+ }
198
+ entry.newFiles = newFiles;
199
+ entry.modifiedFileAdditions = modifiedFileAdditions;
200
+ invalidateNewFileCreatorsCache(ctx);
201
+ }
202
+ /** Persists cache writes and reports reuse, once after the loop. */
203
+ export async function finishReExportLint(lintCtx) {
204
+ if (lintCtx.cache && lintCtx.cacheDirty) {
205
+ await savePerPatchLintCache(lintCtx.projectRoot, lintCtx.cache);
206
+ }
207
+ if (lintCtx.reusedCacheEntries > 0) {
208
+ info(`Reused lint cache for ${lintCtx.reusedCacheEntries} patch${lintCtx.reusedCacheEntries === 1 ? '' : 'es'}.`);
209
+ }
210
+ }
@@ -77,6 +77,17 @@ export function validateReExportOptionCombinations(patches, options) {
77
77
  throw new InvalidArgumentError('--scan-files selects patches from its manifest and cannot be combined with positional patches or --all.', '--scan-files');
78
78
  }
79
79
  }
80
+ if (options.refuseAdjacentUnmanaged === true && (options.scan || options.files !== undefined)) {
81
+ throw new InvalidArgumentError('--refuse-adjacent-unmanaged applies to the scan-less path only and cannot be combined with --scan or --files (those set filesAffected explicitly).', '--refuse-adjacent-unmanaged');
82
+ }
83
+ if (options.refuseForeignDrift === true && (options.scan || options.files !== undefined)) {
84
+ throw new InvalidArgumentError('--refuse-foreign-drift applies to the scan-less path only and cannot be combined with --scan or --files (those explicitly capture the current engine state).', '--refuse-foreign-drift');
85
+ }
86
+ if (options.expect !== undefined &&
87
+ options.expect.length > 0 &&
88
+ options.refuseForeignDrift !== true) {
89
+ throw new InvalidArgumentError('--expect names files whose drift is expected under --refuse-foreign-drift and has no effect without it. Pass --refuse-foreign-drift, or drop --expect.', '--expect');
90
+ }
80
91
  const usingTierFlag = options.tier !== undefined;
81
92
  const usingLintIgnoreFlag = options.lintIgnore !== undefined && options.lintIgnore.length > 0;
82
93
  if (options.all && (usingTierFlag || usingLintIgnoreFlag)) {
@@ -0,0 +1,18 @@
1
+ /**
2
+ * CLI registration for `fireforge re-export`.
3
+ *
4
+ * Split out of `re-export.ts` in 0.41.0. That file was the only one in `src/`
5
+ * carrying a file-level `eslint-disable max-lines` — it measured 526 against
6
+ * the 500 cap with the rule suppressed — and its own suppression comment named
7
+ * this block as the obvious thing to move ("splitting register plumbing is
8
+ * unrelated to this fix"). Moving it drops the file under the cap and removes
9
+ * the suppression.
10
+ *
11
+ * §7 of the quality survey forbids folding `re-export-files.ts` back INTO the
12
+ * orchestrator; this splits in the other direction, matching the existing
13
+ * `re-export-scan.ts` / `re-export-options.ts` boundary.
14
+ */
15
+ import { Command } from 'commander';
16
+ import type { CommandContext } from '../types/cli.js';
17
+ /** Registers the re-export command on the CLI program. */
18
+ export declare function registerReExport(program: Command, { getProjectRoot, withErrorHandling }: CommandContext): void;
@@ -0,0 +1,61 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * CLI registration for `fireforge re-export`.
4
+ *
5
+ * Split out of `re-export.ts` in 0.41.0. That file was the only one in `src/`
6
+ * carrying a file-level `eslint-disable max-lines` — it measured 526 against
7
+ * the 500 cap with the rule suppressed — and its own suppression comment named
8
+ * this block as the obvious thing to move ("splitting register plumbing is
9
+ * unrelated to this fix"). Moving it drops the file under the cap and removes
10
+ * the suppression.
11
+ *
12
+ * §7 of the quality survey forbids folding `re-export-files.ts` back INTO the
13
+ * orchestrator; this splits in the other direction, matching the existing
14
+ * `re-export-scan.ts` / `re-export-options.ts` boundary.
15
+ */
16
+ import { Command, Option } from 'commander';
17
+ import { withEngineSessionLock } from '../core/engine-session-lock.js';
18
+ import { addWaitLockOption, pickDefined, resolveWaitLockSeconds, stringListOption, } from '../utils/options.js';
19
+ import { reExportCommand } from './re-export.js';
20
+ /** Registers the re-export command on the CLI program. */
21
+ export function registerReExport(program, { getProjectRoot, withErrorHandling }) {
22
+ const reExport = program
23
+ .command('re-export [patches...]')
24
+ .description('Refresh existing patch bodies (and filesAffected with --scan) from the current engine ' +
25
+ 'state. Does NOT change sourceVersion/sourceProduct by default — use --stamp or run ' +
26
+ 'rebase for source metadata stamping.')
27
+ .option('-a, --all', 'Re-export all patches')
28
+ .option('-s, --scan', 'Scan directories for new/removed files and update filesAffected')
29
+ .option('--scan-file <path>', 'With --scan, add this explicit engine-relative file to one target patch without collecting adjacent files. Repeatable.', ...stringListOption())
30
+ .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.')
31
+ .option('--files <paths>', 'Restrict the re-exported filesAffected to this comma-separated list (single target patch only)', (value) => value
32
+ .split(',')
33
+ .map((v) => v.trim())
34
+ .filter((v) => v.length > 0))
35
+ .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.')
36
+ .option('--refuse-foreign-drift', "Refuse a scan-less re-export (non-zero exit, patch not written) when the refreshed body would absorb engine lines not present in the old patch body — protects multi-session checkouts from silently capturing another session's uncommitted edits. Mutually exclusive with --scan and --files.")
37
+ .option('--expect <path>', "With --refuse-foreign-drift, name an engine-relative file whose drift is this session's intended edit: drift confined to --expect files proceeds, drift anywhere else still refuses. Repeatable.", ...stringListOption())
38
+ .option('--dry-run', 'Show what would change without writing')
39
+ .option('--skip-lint', 'Skip patch lint checks (downgrade errors to warnings)')
40
+ .option('--no-cache', 'Bypass per-patch lint result cache reads and writes for this re-export')
41
+ .option('--allow-stale-furnace', 'Export the deployed engine copy even when the components/ source changed since the last furnace apply')
42
+ .option('--allow-shrink', 'Allow --files to remove paths currently owned by the patch. Required before --yes can bypass the shrink confirmation.')
43
+ .option('-y, --yes', 'Skip confirmation prompts (required for non-TTY destructive writes)')
44
+ .option('--force-unsafe', 'Bypass cross-patch lint refusal when --files shrinks a patch')
45
+ .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.")
46
+ .addOption(new Option('--tier <tier>', 'Force a tier override on the selected patch (only "branding" recognised). Mutually exclusive with --all.').choices(['branding']))
47
+ .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.', ...stringListOption());
48
+ addWaitLockOption(reExport).action(withErrorHandling(async (patches, options) => {
49
+ const { tier, lintIgnore, scanFile, scanFiles, cache, expect, ...rest } = options;
50
+ const projectRoot = getProjectRoot();
51
+ await withEngineSessionLock(projectRoot, 're-export', () => reExportCommand(projectRoot, patches, {
52
+ ...pickDefined(rest),
53
+ ...(scanFile !== undefined && scanFile.length > 0 ? { scanFiles: scanFile } : {}),
54
+ ...(scanFiles !== undefined ? { scanFilesManifest: scanFiles } : {}),
55
+ ...(tier !== undefined ? { tier: tier } : {}),
56
+ ...(lintIgnore !== undefined && lintIgnore.length > 0 ? { lintIgnore } : {}),
57
+ ...(expect !== undefined && expect.length > 0 ? { expect } : {}),
58
+ ...(cache === false ? { noCache: true } : {}),
59
+ }), { waitLockSeconds: resolveWaitLockSeconds(options.waitLock) });
60
+ }));
61
+ }
@@ -9,10 +9,13 @@ import { buildModifiedFileAdditionsFromDiff, buildPatchQueueContext, detectNewFi
9
9
  import { getClaimedFiles } from '../core/patch-manifest.js';
10
10
  import { extractNewFileContentFromDiff } from '../core/patch-transform.js';
11
11
  import { GeneralError, InvalidArgumentError } from '../errors/base.js';
12
+ import { mapWithConcurrency } from '../utils/concurrency.js';
12
13
  import { pathExists } from '../utils/fs.js';
13
14
  import { cancel, info, isCancel, warn } from '../utils/logger.js';
14
15
  import { isContainedRelativePath, normalizePathSlashes, stripEnginePrefix, } from '../utils/paths.js';
15
16
  const SCAN_ADD_COUNT_THRESHOLD = 3;
17
+ /** Concurrency bound for existence probes (matches the classify/lint pools). */
18
+ const PATH_PROBE_CONCURRENCY = 8;
16
19
  const SCAN_DIR_COUNT_THRESHOLD = 2;
17
20
  /** Normalizes repeatable `--scan-file` inputs into safe engine-relative paths. */
18
21
  export function normalizeScanFiles(scanFiles) {
@@ -70,11 +73,18 @@ async function scanPatchFilesTargeted(args) {
70
73
  const currentSet = new Set(currentFilesAffected);
71
74
  const claimedByOthers = getClaimedFiles(manifest, patchFilename);
72
75
  const added = [];
76
+ // Phase-split for bounded concurrency: the sync claimed-by-others check
77
+ // first (first offender in argument order, as before), then pooled
78
+ // existence probes with the refusal chosen by ordered iteration so the
79
+ // FIRST missing file in argument order is named deterministically.
73
80
  for (const file of scanFiles) {
74
81
  if (claimedByOthers.has(file)) {
75
82
  throw new InvalidArgumentError(`--scan-file path is already claimed by another patch: ${file}`, '--scan-file');
76
83
  }
77
- if (!(await pathExists(join(engineDir, file)))) {
84
+ }
85
+ const exists = await mapWithConcurrency(scanFiles, PATH_PROBE_CONCURRENCY, (file) => pathExists(join(engineDir, file)));
86
+ for (const [index, file] of scanFiles.entries()) {
87
+ if (exists[index] !== true) {
78
88
  throw new InvalidArgumentError(`--scan-file path not found in engine/: ${file}`, '--scan-file');
79
89
  }
80
90
  if (!currentSet.has(file))
@@ -84,12 +94,8 @@ async function scanPatchFilesTargeted(args) {
84
94
  return reportScanResult(currentFilesAffected, patchFilename, isDryRun, added.sort(), removed);
85
95
  }
86
96
  async function findRemovedFiles(files, engineDir) {
87
- const removed = [];
88
- for (const file of files) {
89
- if (!(await pathExists(join(engineDir, file))))
90
- removed.push(file);
91
- }
92
- return removed.sort();
97
+ const exists = await mapWithConcurrency(files, PATH_PROBE_CONCURRENCY, (file) => pathExists(join(engineDir, file)));
98
+ return files.filter((_, index) => exists[index] !== true).sort();
93
99
  }
94
100
  function reportScanResult(currentFilesAffected, patchFilename, isDryRun, added, removed) {
95
101
  for (const f of added)
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Single-patch refresh core for `fireforge re-export`, split out of
3
+ * `re-export.ts` (at the per-file line budget after the FORGE J1/J2
4
+ * hoisted-lint and foreign-drift wiring). The orchestrator loop calls
5
+ * {@link reExportSinglePatchWithIndexLockRetry} per selected patch.
6
+ */
7
+ import { getProjectPaths } from '../core/config.js';
8
+ import type { PatchesManifest, PatchMetadata, ReExportOptions } from '../types/commands/index.js';
9
+ import type { FireForgeConfig } from '../types/config.js';
10
+ import type { AdjacentUnmanagedContext } from './re-export-adjacent.js';
11
+ import type { ForeignDriftContext } from './re-export-drift.js';
12
+ import { type ReExportLintContext } from './re-export-lint.js';
13
+ /**
14
+ * Retries a single patch re-export exactly once when the failure is
15
+ * transient git `index.lock` contention (an external git process holding
16
+ * the engine repo's index). The retry re-enters `reExportSinglePatch`
17
+ * from the top, so it re-reads clean state; a second lock failure
18
+ * propagates to the loop's honest "N of M" accounting.
19
+ */
20
+ export declare function reExportSinglePatchWithIndexLockRetry(patch: PatchMetadata, paths: ReturnType<typeof getProjectPaths>, manifest: PatchesManifest, options: ReExportOptions, isDryRun: boolean, config: FireForgeConfig, adjacentCtx: AdjacentUnmanagedContext, driftCtx: ForeignDriftContext, lintCtx: ReExportLintContext): Promise<boolean>;
@@ -0,0 +1,261 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Single-patch refresh core for `fireforge re-export`, split out of
4
+ * `re-export.ts` (at the per-file line budget after the FORGE J1/J2
5
+ * hoisted-lint and foreign-drift wiring). The orchestrator loop calls
6
+ * {@link reExportSinglePatchWithIndexLockRetry} per selected patch.
7
+ */
8
+ import { getProjectPaths } from '../core/config.js';
9
+ import { enforceFreshFurnaceSources } from '../core/furnace-stale-export.js';
10
+ import { getDiffForFilesAgainstHead } from '../core/git-diff.js';
11
+ import { updatePatchAndMetadata } from '../core/patch-export.js';
12
+ import { buildProjectedManifest, enforcePatchPolicy } from '../core/patch-policy.js';
13
+ import { isGitIndexLockConflict } from '../errors/git.js';
14
+ import { info, success, warn } from '../utils/logger.js';
15
+ import { findMissingFiles, reportAdjacentUnmanagedFiles } from './re-export-adjacent.js';
16
+ import { reportForeignDrift } from './re-export-drift.js';
17
+ import { lintReExportedPatch, refreshQueueCtxEntry, storeReExportLintResult, } from './re-export-lint.js';
18
+ import { assertScanAdoptionsHaveNoForwardImports, assertScanFileAdditionsHaveDiffHunks, confirmBroadScanAdditions, scanPatchFilesForReExport, } from './re-export-scan.js';
19
+ const GIT_INDEX_LOCK_RETRY_MS = 300;
20
+ /**
21
+ * Retries a single patch re-export exactly once when the failure is
22
+ * transient git `index.lock` contention (an external git process holding
23
+ * the engine repo's index). The retry re-enters `reExportSinglePatch`
24
+ * from the top, so it re-reads clean state; a second lock failure
25
+ * propagates to the loop's honest "N of M" accounting.
26
+ */
27
+ export async function reExportSinglePatchWithIndexLockRetry(patch, paths, manifest, options, isDryRun, config, adjacentCtx, driftCtx, lintCtx) {
28
+ try {
29
+ return await reExportSinglePatch(patch, paths, manifest, options, isDryRun, config, adjacentCtx, driftCtx, lintCtx);
30
+ }
31
+ catch (error) {
32
+ if (!isGitIndexLockConflict(error)) {
33
+ throw error;
34
+ }
35
+ warn(`${patch.filename}: git index.lock contention detected — retrying once after ${String(GIT_INDEX_LOCK_RETRY_MS)} ms...`);
36
+ await new Promise((resolve) => setTimeout(resolve, GIT_INDEX_LOCK_RETRY_MS));
37
+ return await reExportSinglePatch(patch, paths, manifest, options, isDryRun, config, adjacentCtx, driftCtx, lintCtx);
38
+ }
39
+ }
40
+ async function reExportSinglePatch(patch, paths, manifest, options, isDryRun, config, adjacentCtx, driftCtx, lintCtx) {
41
+ let currentFilesAffected = [...patch.filesAffected];
42
+ // --- Scan for new/removed files ---
43
+ if (options.scan) {
44
+ const scanResult = await scanPatchFilesForReExport({
45
+ currentFilesAffected,
46
+ engineDir: paths.engine,
47
+ manifest,
48
+ patchFilename: patch.filename,
49
+ isDryRun,
50
+ ...(options.scanFiles !== undefined ? { scanFiles: options.scanFiles } : {}),
51
+ });
52
+ // Forward-import gate at adoption time (runs in dry-run too): refuse
53
+ // to adopt unmanaged files that import modules created by LATER
54
+ // patches — the after-the-fact lint failure this leaves behind is the
55
+ // field-reported footgun. Applies to broad --scan, --scan-file, and
56
+ // the --scan-files bulk assignments alike.
57
+ await assertScanAdoptionsHaveNoForwardImports({
58
+ patchesDir: paths.patches,
59
+ engineDir: paths.engine,
60
+ patchFilename: patch.filename,
61
+ added: scanResult.added,
62
+ });
63
+ if (options.scanFiles === undefined) {
64
+ const isInteractive = process.stdin.isTTY && process.stdout.isTTY;
65
+ const proceed = await confirmBroadScanAdditions({
66
+ patchFilename: patch.filename,
67
+ added: scanResult.added,
68
+ isDryRun,
69
+ yes: options.yes === true,
70
+ isInteractive,
71
+ });
72
+ if (!proceed) {
73
+ return false;
74
+ }
75
+ }
76
+ currentFilesAffected = scanResult.updated;
77
+ }
78
+ else if (options.files === undefined) {
79
+ // Finding #16: when neither `--scan` nor `--files` is set and some
80
+ // of the manifest's claimed files no longer exist on disk, the
81
+ // re-export silently writes a refreshed body whose filesAffected
82
+ // still names the vanished paths. That is the documented contract,
83
+ // but it is also a footgun — a later `verify` then fails on
84
+ // manifest-consistency with no obvious trigger. Emit one advisory
85
+ // warning up-front when we can detect the drift cheaply, so the
86
+ // operator has a chance to re-run with `--scan` or `--files`
87
+ // before the stale filesAffected lands in patches.json.
88
+ const args = { patch, paths, manifest, currentFilesAffected, ctx: adjacentCtx };
89
+ if (await reportAdjacentUnmanagedFiles(args))
90
+ return false;
91
+ }
92
+ // --- Explicit file-subset path ---
93
+ // When --files is given, the target filesAffected is authoritative — drop
94
+ // anything not in the list, add anything new. This is the surgical repair
95
+ // primitive that replaces hand-editing patches.json; the user has already
96
+ // acknowledged via confirmDestructive (done in the caller) that any drop
97
+ // is intentional.
98
+ if (options.files !== undefined) {
99
+ const requested = [...new Set(options.files)].sort();
100
+ currentFilesAffected = requested;
101
+ const removed = patch.filesAffected.filter((f) => !requested.includes(f));
102
+ const added = requested.filter((f) => !patch.filesAffected.includes(f));
103
+ for (const f of added)
104
+ info(` + ${f}`);
105
+ for (const f of removed)
106
+ info(` - ${f}`);
107
+ }
108
+ // Stale-furnace-source gate (0.37.0 item 4): re-export captures deployed
109
+ // engine copies, so a component source edited after the last furnace
110
+ // apply would land in the patch as its OLD deployed content. Refuse (or
111
+ // warn under --allow-stale-furnace) before diffing. Runs in dry-run too
112
+ // so the failure surfaces early.
113
+ await enforceFreshFurnaceSources(paths.root, currentFilesAffected, options.allowStaleFurnace === true, 're-export');
114
+ const missingFiles = await findMissingFiles(paths.engine, currentFilesAffected);
115
+ if (missingFiles.length === currentFilesAffected.length) {
116
+ warn(`Skipped ${patch.filename}: all affected files missing`);
117
+ warn(`Missing files: ${missingFiles.join(', ')}`);
118
+ return false;
119
+ }
120
+ if (missingFiles.length > 0) {
121
+ warn(`${patch.filename}: missing files will be skipped: ${missingFiles.join(', ')}`);
122
+ }
123
+ const missingSet = new Set(missingFiles);
124
+ const existingFiles = currentFilesAffected.filter((f) => !missingSet.has(f));
125
+ const diffContent = await getDiffForFilesAgainstHead(paths.engine, existingFiles);
126
+ assertScanFileAdditionsHaveDiffHunks({
127
+ diffContent,
128
+ patchFilename: patch.filename,
129
+ previousFilesAffected: patch.filesAffected,
130
+ scanFiles: options.scanFiles,
131
+ });
132
+ if (!diffContent.trim()) {
133
+ warn(`Skipped ${patch.filename}: no changes (files unchanged from HEAD)`);
134
+ return false;
135
+ }
136
+ // Foreign-drift preview + optional hard stop (FORGE J2): show which
137
+ // payload lines are about to enter the body that the old body did not
138
+ // carry — the case where a concurrent session's uncommitted edits in
139
+ // OWNED files would be silently absorbed. Always previews (including
140
+ // dry-run and --scan); refuses only under --refuse-foreign-drift.
141
+ if (await reportForeignDrift({
142
+ patch,
143
+ patchesDir: paths.patches,
144
+ newDiffContent: diffContent,
145
+ ctx: driftCtx,
146
+ })) {
147
+ return false;
148
+ }
149
+ const { updates, ignoreChecks, effectiveTier, existingIgnoreSet, flagIgnoreSet } = computeReExportUpdates(patch, options, currentFilesAffected);
150
+ enforcePatchPolicy({
151
+ config,
152
+ manifest: buildProjectedManifest(manifest, manifest.patches.map((entry) => entry.filename === patch.filename ? { ...entry, ...updates } : entry)),
153
+ command: 're-export',
154
+ forceUnsafe: options.forceUnsafe === true,
155
+ });
156
+ // Lint against the ONCE-per-invocation hoisted context (queue context +
157
+ // queue-wide checkJs program + per-patch result cache) instead of
158
+ // rebuilding ~37 s of setup for every patch in the loop (FORGE J1).
159
+ const projectedMetadata = { ...patch, ...updates };
160
+ const lintResult = await lintReExportedPatch({
161
+ lintCtx,
162
+ patch,
163
+ projectedMetadata,
164
+ existingFiles,
165
+ diffContent,
166
+ ...(options.skipLint !== undefined ? { skipLint: options.skipLint } : {}),
167
+ ...(ignoreChecks !== undefined ? { ignoreChecks } : {}),
168
+ ...(effectiveTier !== undefined ? { patchTier: effectiveTier } : {}),
169
+ });
170
+ if (isDryRun) {
171
+ info(`[dry-run] ${patch.filename}: ${existingFiles.length} file(s)`);
172
+ if (effectiveTier !== undefined && effectiveTier !== patch.tier) {
173
+ info(`[dry-run] ${patch.filename}: tier would become ${effectiveTier}`);
174
+ }
175
+ const addedIgnores = [...flagIgnoreSet].filter((id) => !existingIgnoreSet.has(id));
176
+ if (addedIgnores.length > 0) {
177
+ info(`[dry-run] ${patch.filename}: lintIgnore would gain ${addedIgnores.join(', ')}`);
178
+ }
179
+ }
180
+ else {
181
+ await commitRefreshedPatch({
182
+ patch,
183
+ paths,
184
+ manifest,
185
+ options,
186
+ config,
187
+ lintCtx,
188
+ diffContent,
189
+ updates,
190
+ projectedMetadata,
191
+ existingFiles,
192
+ lintResult,
193
+ });
194
+ }
195
+ return true;
196
+ }
197
+ /**
198
+ * Writes the refreshed body + manifest row atomically under the patch
199
+ * directory lock (a split write would let a concurrent queue mutation
200
+ * leave body and `filesAffected` disagreeing), then keeps the run's
201
+ * in-memory state honest: the queue context entry is refreshed and the
202
+ * fresh lint result is stored keyed against the just-written body — the
203
+ * state a repeat invocation will observe (FORGE J1) — and the in-memory
204
+ * manifest row mirrors the on-disk write for later loop iterations
205
+ * (notably `--all --scan`, where `getClaimedFiles` reads this manifest).
206
+ */
207
+ async function commitRefreshedPatch(args) {
208
+ const { patch, paths, manifest, options, config, lintCtx, diffContent, updates } = args;
209
+ await updatePatchAndMetadata(paths.patches, patch.filename, diffContent, updates, undefined, {
210
+ config,
211
+ command: 're-export',
212
+ forceUnsafe: options.forceUnsafe === true,
213
+ });
214
+ refreshQueueCtxEntry(lintCtx, patch.filename, diffContent);
215
+ if (args.lintResult !== null) {
216
+ await storeReExportLintResult(lintCtx, patch.filename, args.projectedMetadata, args.existingFiles, args.lintResult);
217
+ }
218
+ const patchIndex = manifest.patches.findIndex((pm) => pm.filename === patch.filename);
219
+ if (patchIndex !== -1) {
220
+ const existingEntry = manifest.patches[patchIndex];
221
+ if (existingEntry) {
222
+ manifest.patches[patchIndex] = {
223
+ ...existingEntry,
224
+ ...updates,
225
+ };
226
+ }
227
+ }
228
+ success(`Re-exported ${patch.filename}`);
229
+ }
230
+ /**
231
+ * Threads the patch's own `lintIgnore` list through so the per-patch
232
+ * suppression honoured by export/export-all is also honoured here — a
233
+ * re-export could otherwise not refresh an advisory-noisy but intentional
234
+ * patch without the blunt `--skip-lint`. The paired `patch.tier` threads
235
+ * the explicit branding-threshold opt-in the same way. The CLI flags
236
+ * `--tier` and `--lint-ignore` participate with append/union semantics
237
+ * (explicit removal lives on `fireforge patch lint-ignore`), computed
238
+ * before the lint pass so the new intent takes effect on this invocation.
239
+ */
240
+ function computeReExportUpdates(patch, options, currentFilesAffected) {
241
+ const existingIgnoreSet = new Set(patch.lintIgnore ?? []);
242
+ const flagIgnoreSet = new Set(options.lintIgnore ?? []);
243
+ const mergedIgnoreSet = new Set([...existingIgnoreSet, ...flagIgnoreSet]);
244
+ const effectiveLintIgnore = mergedIgnoreSet.size > 0 ? [...mergedIgnoreSet] : undefined;
245
+ const updates = {
246
+ filesAffected: currentFilesAffected,
247
+ };
248
+ if (options.tier !== undefined) {
249
+ updates.tier = options.tier;
250
+ }
251
+ if (effectiveLintIgnore !== undefined && flagIgnoreSet.size > 0) {
252
+ updates.lintIgnore = effectiveLintIgnore;
253
+ }
254
+ return {
255
+ updates,
256
+ ignoreChecks: effectiveLintIgnore ? new Set(effectiveLintIgnore) : undefined,
257
+ effectiveTier: options.tier ?? patch.tier,
258
+ existingIgnoreSet,
259
+ flagIgnoreSet,
260
+ };
261
+ }
@@ -1,5 +1,3 @@
1
- import { Command } from 'commander';
2
- import type { CommandContext } from '../types/cli.js';
3
1
  import type { ReExportOptions } from '../types/commands/index.js';
4
2
  /**
5
3
  * Runs the re-export command to regenerate existing patches from current engine state.
@@ -8,5 +6,3 @@ import type { ReExportOptions } from '../types/commands/index.js';
8
6
  * @param options - Re-export options
9
7
  */
10
8
  export declare function reExportCommand(projectRoot: string, patches: string[], options: ReExportOptions): Promise<void>;
11
- /** Registers the re-export command on the CLI program. */
12
- export declare function registerReExport(program: Command, { getProjectRoot, withErrorHandling }: CommandContext): void;