@hominis/fireforge 0.40.0 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (253) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/README.md +16 -10
  3. package/dist/bin/fireforge.js +25 -4
  4. package/dist/build-info.json +9 -0
  5. package/dist/src/cli.js +20 -3
  6. package/dist/src/commands/bootstrap.js +5 -1
  7. package/dist/src/commands/discard.js +2 -22
  8. package/dist/src/commands/doctor/post-rebase-audit.js +27 -4
  9. package/dist/src/commands/doctor-check-core.d.ts +20 -0
  10. package/dist/src/commands/doctor-check-core.js +28 -0
  11. package/dist/src/commands/doctor-external-toolchains.js +2 -0
  12. package/dist/src/commands/doctor-furnace-manifest-sync.js +144 -46
  13. package/dist/src/commands/doctor-furnace.js +13 -11
  14. package/dist/src/commands/doctor-orphaned-harness.js +2 -1
  15. package/dist/src/commands/doctor.js +2 -2
  16. package/dist/src/commands/export-all.js +9 -1
  17. package/dist/src/commands/export-flow.js +13 -3
  18. package/dist/src/commands/export-placement-conflicts.d.ts +17 -0
  19. package/dist/src/commands/export-placement-conflicts.js +74 -0
  20. package/dist/src/commands/export-placement-gate.js +1 -1
  21. package/dist/src/commands/export-shared.d.ts +8 -1
  22. package/dist/src/commands/export-shared.js +16 -7
  23. package/dist/src/commands/export.js +2 -2
  24. package/dist/src/commands/furnace/apply.js +4 -3
  25. package/dist/src/commands/furnace/chrome-doc-remove.js +13 -2
  26. package/dist/src/commands/furnace/chrome-doc.js +13 -2
  27. package/dist/src/commands/furnace/create-browser-test.d.ts +5 -0
  28. package/dist/src/commands/furnace/create-browser-test.js +5 -0
  29. package/dist/src/commands/furnace/create.js +2 -2
  30. package/dist/src/commands/furnace/diff.js +2 -1
  31. package/dist/src/commands/furnace/init.js +4 -6
  32. package/dist/src/commands/furnace/override.js +2 -2
  33. package/dist/src/commands/furnace/remove-mutations.d.ts +40 -0
  34. package/dist/src/commands/furnace/remove-mutations.js +171 -0
  35. package/dist/src/commands/furnace/remove.js +21 -128
  36. package/dist/src/commands/furnace/rename-browser-test.js +54 -5
  37. package/dist/src/commands/furnace/scan.js +27 -36
  38. package/dist/src/commands/furnace/status.js +30 -28
  39. package/dist/src/commands/furnace/validate.js +25 -18
  40. package/dist/src/commands/import.js +6 -20
  41. package/dist/src/commands/lint-per-patch.js +12 -59
  42. package/dist/src/commands/lint-per-run-checkjs.d.ts +64 -0
  43. package/dist/src/commands/lint-per-run-checkjs.js +102 -0
  44. package/dist/src/commands/lint.js +4 -4
  45. package/dist/src/commands/manifest.js +1 -1
  46. package/dist/src/commands/package.js +9 -16
  47. package/dist/src/commands/patch/compact.js +5 -5
  48. package/dist/src/commands/patch/delete.js +5 -5
  49. package/dist/src/commands/patch/lint-ignore.js +3 -2
  50. package/dist/src/commands/patch/move-files-create.js +22 -4
  51. package/dist/src/commands/patch/move-files-into.js +14 -10
  52. package/dist/src/commands/patch/move-files.js +6 -5
  53. package/dist/src/commands/patch/rename.d.ts +2 -2
  54. package/dist/src/commands/patch/rename.js +151 -50
  55. package/dist/src/commands/patch/reorder.d.ts +9 -0
  56. package/dist/src/commands/patch/reorder.js +14 -10
  57. package/dist/src/commands/patch/split-plan.d.ts +13 -6
  58. package/dist/src/commands/patch/split-plan.js +22 -12
  59. package/dist/src/commands/patch/split.d.ts +1 -1
  60. package/dist/src/commands/patch/split.js +23 -9
  61. package/dist/src/commands/patch/staged-dependency-validate.d.ts +15 -0
  62. package/dist/src/commands/patch/staged-dependency-validate.js +49 -0
  63. package/dist/src/commands/patch/staged-dependency.js +10 -0
  64. package/dist/src/commands/re-export-adjacent.js +5 -6
  65. package/dist/src/commands/re-export-bulk-scan.d.ts +13 -0
  66. package/dist/src/commands/re-export-bulk-scan.js +142 -2
  67. package/dist/src/commands/re-export-drift.d.ts +74 -0
  68. package/dist/src/commands/re-export-drift.js +234 -0
  69. package/dist/src/commands/re-export-files.js +5 -3
  70. package/dist/src/commands/re-export-lint.d.ts +97 -0
  71. package/dist/src/commands/re-export-lint.js +210 -0
  72. package/dist/src/commands/re-export-options.js +8 -0
  73. package/dist/src/commands/re-export-register.d.ts +18 -0
  74. package/dist/src/commands/re-export-register.js +61 -0
  75. package/dist/src/commands/re-export-scan.js +13 -7
  76. package/dist/src/commands/re-export-single.d.ts +20 -0
  77. package/dist/src/commands/re-export-single.js +261 -0
  78. package/dist/src/commands/re-export.d.ts +0 -4
  79. package/dist/src/commands/re-export.js +113 -268
  80. package/dist/src/commands/rebase/abort.js +13 -4
  81. package/dist/src/commands/rebase/continue.js +11 -4
  82. package/dist/src/commands/rebase/index.js +19 -29
  83. package/dist/src/commands/rebase/patch-loop.js +12 -0
  84. package/dist/src/commands/reset.js +4 -23
  85. package/dist/src/commands/resolve.js +2 -9
  86. package/dist/src/commands/run.js +11 -21
  87. package/dist/src/commands/source.js +3 -9
  88. package/dist/src/commands/status-check.d.ts +13 -0
  89. package/dist/src/commands/status-check.js +31 -13
  90. package/dist/src/commands/status-json.d.ts +31 -0
  91. package/dist/src/commands/status-json.js +76 -0
  92. package/dist/src/commands/status-output.d.ts +1 -0
  93. package/dist/src/commands/status-output.js +10 -1
  94. package/dist/src/commands/status-ownership.d.ts +52 -0
  95. package/dist/src/commands/status-ownership.js +57 -0
  96. package/dist/src/commands/status.js +184 -175
  97. package/dist/src/commands/test-build-phase.d.ts +19 -0
  98. package/dist/src/commands/test-build-phase.js +168 -0
  99. package/dist/src/commands/test-diagnose.js +30 -2
  100. package/dist/src/commands/test-modes.d.ts +9 -1
  101. package/dist/src/commands/test-modes.js +46 -13
  102. package/dist/src/commands/test-register.js +59 -27
  103. package/dist/src/commands/test-run.d.ts +29 -3
  104. package/dist/src/commands/test-run.js +41 -4
  105. package/dist/src/commands/test-verdict.d.ts +32 -0
  106. package/dist/src/commands/test-verdict.js +48 -0
  107. package/dist/src/commands/test.d.ts +9 -0
  108. package/dist/src/commands/test.js +100 -123
  109. package/dist/src/commands/token-coverage.js +4 -9
  110. package/dist/src/commands/tree.d.ts +3 -0
  111. package/dist/src/commands/tree.js +171 -25
  112. package/dist/src/commands/verify.js +2 -2
  113. package/dist/src/commands/watch.js +22 -21
  114. package/dist/src/commands/wire.js +1 -1
  115. package/dist/src/core/ast-utils.d.ts +11 -0
  116. package/dist/src/core/ast-utils.js +11 -0
  117. package/dist/src/core/build-audit-platform.js +6 -1
  118. package/dist/src/core/build-audit-registration.js +2 -0
  119. package/dist/src/core/build-audit-resolve.d.ts +13 -0
  120. package/dist/src/core/build-audit-resolve.js +34 -13
  121. package/dist/src/core/build-audit.js +4 -20
  122. package/dist/src/core/build-baseline-types.d.ts +29 -8
  123. package/dist/src/core/build-baseline-types.js +2 -1
  124. package/dist/src/core/build-baseline.d.ts +10 -2
  125. package/dist/src/core/build-baseline.js +39 -8
  126. package/dist/src/core/build-prepare.d.ts +4 -0
  127. package/dist/src/core/build-prepare.js +7 -1
  128. package/dist/src/core/config-paths.d.ts +1 -1
  129. package/dist/src/core/config-paths.js +2 -0
  130. package/dist/src/core/config-validate-test-toolchains.js +3 -2
  131. package/dist/src/core/config-validate.js +34 -36
  132. package/dist/src/core/config.js +16 -3
  133. package/dist/src/core/coverage-extend.d.ts +89 -0
  134. package/dist/src/core/coverage-extend.js +188 -0
  135. package/dist/src/core/destructive.js +3 -1
  136. package/dist/src/core/discard-baseline.js +18 -4
  137. package/dist/src/core/engine-precondition.d.ts +35 -0
  138. package/dist/src/core/engine-precondition.js +50 -0
  139. package/dist/src/core/engine-session-lock.d.ts +38 -1
  140. package/dist/src/core/engine-session-lock.js +78 -4
  141. package/dist/src/core/file-lock.d.ts +7 -0
  142. package/dist/src/core/file-lock.js +20 -30
  143. package/dist/src/core/firefox.js +5 -2
  144. package/dist/src/core/furnace-apply-helpers.d.ts +12 -1
  145. package/dist/src/core/furnace-apply-helpers.js +12 -3
  146. package/dist/src/core/furnace-apply-output.js +9 -0
  147. package/dist/src/core/furnace-apply-overwrite-warn.d.ts +63 -0
  148. package/dist/src/core/furnace-apply-overwrite-warn.js +123 -0
  149. package/dist/src/core/furnace-apply-undeploy-actions.d.ts +11 -0
  150. package/dist/src/core/furnace-apply-undeploy-actions.js +49 -0
  151. package/dist/src/core/furnace-apply.d.ts +16 -4
  152. package/dist/src/core/furnace-apply.js +39 -43
  153. package/dist/src/core/furnace-config.js +2 -0
  154. package/dist/src/core/furnace-css-fragments.js +2 -0
  155. package/dist/src/core/furnace-jsconfig.js +14 -7
  156. package/dist/src/core/furnace-registration-ast.js +8 -8
  157. package/dist/src/core/furnace-registration-remove.js +6 -3
  158. package/dist/src/core/furnace-registration.js +9 -12
  159. package/dist/src/core/furnace-rollback.js +1 -1
  160. package/dist/src/core/furnace-scanner.js +2 -1
  161. package/dist/src/core/furnace-stale-export.js +2 -1
  162. package/dist/src/core/furnace-state-persist.d.ts +4 -6
  163. package/dist/src/core/furnace-validate-compatibility.js +4 -7
  164. package/dist/src/core/furnace-validate-helpers.js +33 -1
  165. package/dist/src/core/furnace-validate-registration.js +16 -0
  166. package/dist/src/core/furnace-validate.js +4 -2
  167. package/dist/src/core/git-diff.js +37 -2
  168. package/dist/src/core/lint-cache.d.ts +1 -1
  169. package/dist/src/core/lint-cache.js +33 -10
  170. package/dist/src/core/mach-build-artifacts.d.ts +63 -1
  171. package/dist/src/core/mach-build-artifacts.js +108 -2
  172. package/dist/src/core/mach-resource-shim.js +1 -0
  173. package/dist/src/core/marionette-port.d.ts +16 -0
  174. package/dist/src/core/marionette-port.js +93 -8
  175. package/dist/src/core/marionette-preflight.js +6 -5
  176. package/dist/src/core/patch-apply.d.ts +20 -13
  177. package/dist/src/core/patch-apply.js +32 -33
  178. package/dist/src/core/patch-export.js +3 -5
  179. package/dist/src/core/patch-lint-checkjs.d.ts +25 -3
  180. package/dist/src/core/patch-lint-checkjs.js +78 -7
  181. package/dist/src/core/patch-lint-chrome-jsdoc.js +9 -0
  182. package/dist/src/core/patch-lint-creators.d.ts +30 -0
  183. package/dist/src/core/patch-lint-creators.js +45 -0
  184. package/dist/src/core/patch-lint-cross.d.ts +1 -17
  185. package/dist/src/core/patch-lint-cross.js +16 -32
  186. package/dist/src/core/patch-lint-jsdoc.d.ts +3 -1
  187. package/dist/src/core/patch-lint-jsdoc.js +15 -5
  188. package/dist/src/core/patch-lint-module-registration.d.ts +15 -0
  189. package/dist/src/core/patch-lint-module-registration.js +88 -0
  190. package/dist/src/core/patch-lint-mozbuild.js +99 -11
  191. package/dist/src/core/patch-lint-reexports.d.ts +1 -0
  192. package/dist/src/core/patch-lint-reexports.js +1 -0
  193. package/dist/src/core/patch-lint.d.ts +8 -0
  194. package/dist/src/core/patch-lint.js +84 -6
  195. package/dist/src/core/patch-lock.d.ts +12 -1
  196. package/dist/src/core/patch-lock.js +40 -4
  197. package/dist/src/core/patch-manifest-consistency.js +15 -3
  198. package/dist/src/core/patch-parse.d.ts +8 -0
  199. package/dist/src/core/patch-parse.js +10 -0
  200. package/dist/src/core/patch-policy.d.ts +7 -0
  201. package/dist/src/core/patch-policy.js +4 -1
  202. package/dist/src/core/patch-transform.d.ts +10 -0
  203. package/dist/src/core/patch-transform.js +13 -2
  204. package/dist/src/core/rebase-session.d.ts +44 -2
  205. package/dist/src/core/rebase-session.js +114 -17
  206. package/dist/src/core/register-browser-content.js +2 -1
  207. package/dist/src/core/register-shared-css.js +2 -1
  208. package/dist/src/core/smoke-patterns.d.ts +0 -8
  209. package/dist/src/core/smoke-patterns.js +3 -2
  210. package/dist/src/core/status-binary.d.ts +55 -0
  211. package/dist/src/core/status-binary.js +124 -0
  212. package/dist/src/core/status-classify.d.ts +6 -1
  213. package/dist/src/core/status-classify.js +101 -73
  214. package/dist/src/core/test-harness-crash.d.ts +32 -1
  215. package/dist/src/core/test-harness-crash.js +59 -4
  216. package/dist/src/core/test-path-scope.js +2 -0
  217. package/dist/src/core/test-stale-check.js +1 -20
  218. package/dist/src/core/toolchain-preflight.js +24 -26
  219. package/dist/src/core/tree-guard.d.ts +1 -1
  220. package/dist/src/core/tree-guard.js +67 -12
  221. package/dist/src/core/tree-store.d.ts +86 -10
  222. package/dist/src/core/tree-store.js +310 -72
  223. package/dist/src/core/typecheck.js +2 -1
  224. package/dist/src/core/wire-destroy.js +14 -3
  225. package/dist/src/core/wire-init.js +37 -40
  226. package/dist/src/core/wire-subscript.js +11 -3
  227. package/dist/src/core/wire-utils.js +4 -4
  228. package/dist/src/core/xpcshell-appdir.js +3 -0
  229. package/dist/src/errors/base.d.ts +13 -0
  230. package/dist/src/errors/base.js +13 -0
  231. package/dist/src/errors/rebase.d.ts +13 -0
  232. package/dist/src/errors/rebase.js +17 -0
  233. package/dist/src/types/commands/options.d.ts +86 -1
  234. package/dist/src/types/commands/patches.d.ts +8 -0
  235. package/dist/src/types/furnace.d.ts +7 -0
  236. package/dist/src/utils/build-info.d.ts +31 -0
  237. package/dist/src/utils/build-info.js +98 -0
  238. package/dist/src/utils/concurrency.d.ts +8 -0
  239. package/dist/src/utils/concurrency.js +21 -0
  240. package/dist/src/utils/errors.d.ts +39 -0
  241. package/dist/src/utils/errors.js +55 -0
  242. package/dist/src/utils/fs.js +5 -12
  243. package/dist/src/utils/logger.d.ts +14 -2
  244. package/dist/src/utils/logger.js +43 -13
  245. package/dist/src/utils/options.d.ts +21 -4
  246. package/dist/src/utils/options.js +26 -6
  247. package/dist/src/utils/package-root.js +7 -4
  248. package/dist/src/utils/process-group.js +13 -2
  249. package/dist/src/utils/stdio-drain.d.ts +28 -0
  250. package/dist/src/utils/stdio-drain.js +54 -0
  251. package/dist/src/utils/validation.d.ts +23 -2
  252. package/dist/src/utils/validation.js +29 -3
  253. package/package.json +9 -9
@@ -0,0 +1,234 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Foreign-drift preview + `--refuse-foreign-drift` gate for `re-export`
4
+ * (FORGE J2).
5
+ *
6
+ * A scan-less re-export rebuilds each patch body as a whole-file
7
+ * `git diff HEAD` over the owned files — so a CONCURRENT session's
8
+ * uncommitted lines inside a file the patch already owns are silently
9
+ * absorbed into the refreshed body. The adjacency advisory
10
+ * (`re-export-adjacent.ts`) warns about unmanaged NEIGHBOUR files but is
11
+ * structurally blind to drift inside owned files. This module compares
12
+ * the old body against the refreshed body per file and reports the
13
+ * payload lines that are about to ENTER the body; under
14
+ * `--refuse-foreign-drift` the patch is skipped and the run exits
15
+ * non-zero, mirroring `--refuse-adjacent-unmanaged`.
16
+ *
17
+ * "Foreign" is decided by content, not authorship: re-export's legitimate
18
+ * job is capturing intentional engine edits, so the preview always prints
19
+ * and the hard stop is an explicit opt-in for multi-session checkouts.
20
+ * The comparison is offset-insensitive — per-file MULTISETS of `+`/`-`
21
+ * payload lines — because a refresh legitimately shifts hunk offsets and
22
+ * context without changing what the patch does.
23
+ */
24
+ import { join } from 'node:path';
25
+ import { parseDiffSections } from '../core/patch-parse.js';
26
+ import { toError } from '../utils/errors.js';
27
+ import { pathExists, readText } from '../utils/fs.js';
28
+ import { info, verbose, warn } from '../utils/logger.js';
29
+ const MAX_HUNK_SUMMARIES_PER_FILE = 3;
30
+ const MAX_FILES_REPORTED = 10;
31
+ function collectPayloadByFile(body) {
32
+ const byFile = new Map();
33
+ for (const section of parseDiffSections(body)) {
34
+ const file = section.targetPath;
35
+ let payload = byFile.get(file);
36
+ if (!payload) {
37
+ payload = {
38
+ added: new Map(),
39
+ removed: new Map(),
40
+ binaryNewHash: undefined,
41
+ isBinary: false,
42
+ sections: [],
43
+ };
44
+ byFile.set(file, payload);
45
+ }
46
+ payload.sections.push(section);
47
+ if (section.isBinary) {
48
+ payload.isBinary = true;
49
+ payload.binaryNewHash = section.indexNewHash;
50
+ continue;
51
+ }
52
+ for (const hunk of section.hunks) {
53
+ for (const line of hunk.lines) {
54
+ if (line.startsWith('+')) {
55
+ const text = line.slice(1);
56
+ payload.added.set(text, (payload.added.get(text) ?? 0) + 1);
57
+ }
58
+ else if (line.startsWith('-')) {
59
+ const text = line.slice(1);
60
+ payload.removed.set(text, (payload.removed.get(text) ?? 0) + 1);
61
+ }
62
+ }
63
+ }
64
+ }
65
+ return byFile;
66
+ }
67
+ /** Multiset difference: occurrences of `next` not consumed by `previous`. */
68
+ function multisetExcess(next, previous) {
69
+ const excess = new Map();
70
+ for (const [text, count] of next) {
71
+ const remaining = count - (previous.get(text) ?? 0);
72
+ if (remaining > 0)
73
+ excess.set(text, remaining);
74
+ }
75
+ return excess;
76
+ }
77
+ function summarizeForeignHunks(payload, foreignAdded, foreignRemoved) {
78
+ const summaries = [];
79
+ const addedBudget = new Map(foreignAdded);
80
+ const removedBudget = new Map(foreignRemoved);
81
+ for (const section of payload.sections) {
82
+ for (const hunk of section.hunks) {
83
+ let added = 0;
84
+ let removed = 0;
85
+ for (const line of hunk.lines) {
86
+ if (line.startsWith('+')) {
87
+ const text = line.slice(1);
88
+ const budget = addedBudget.get(text) ?? 0;
89
+ if (budget > 0) {
90
+ addedBudget.set(text, budget - 1);
91
+ added++;
92
+ }
93
+ }
94
+ else if (line.startsWith('-')) {
95
+ const text = line.slice(1);
96
+ const budget = removedBudget.get(text) ?? 0;
97
+ if (budget > 0) {
98
+ removedBudget.set(text, budget - 1);
99
+ removed++;
100
+ }
101
+ }
102
+ }
103
+ if (added > 0 || removed > 0) {
104
+ summaries.push(`@@ -${String(hunk.oldStart)},${String(hunk.oldCount)} +${String(hunk.newStart)},${String(hunk.newCount)} @@ (+${String(added)}/-${String(removed)})`);
105
+ }
106
+ }
107
+ }
108
+ return summaries;
109
+ }
110
+ function countExcess(excess) {
111
+ let total = 0;
112
+ for (const count of excess.values())
113
+ total += count;
114
+ return total;
115
+ }
116
+ /**
117
+ * Pure comparison: per owned file, which `+`/`-` payload lines the
118
+ * refreshed body carries that the old body did not. Files NOT in
119
+ * `previousFilesAffected` are skipped — those are intentional adoptions
120
+ * (`--scan`), not drift.
121
+ */
122
+ export function computeForeignDrift(oldBody, newBody, previousFilesAffected) {
123
+ const owned = new Set(previousFilesAffected);
124
+ const oldByFile = collectPayloadByFile(oldBody);
125
+ const newByFile = collectPayloadByFile(newBody);
126
+ const drift = [];
127
+ for (const [file, next] of newByFile) {
128
+ if (!owned.has(file))
129
+ continue;
130
+ const previous = oldByFile.get(file);
131
+ if (next.isBinary || previous?.isBinary === true) {
132
+ const binaryChanged = next.binaryNewHash === undefined ||
133
+ previous?.binaryNewHash === undefined ||
134
+ !(next.binaryNewHash.startsWith(previous.binaryNewHash) ||
135
+ previous.binaryNewHash.startsWith(next.binaryNewHash));
136
+ if (binaryChanged) {
137
+ drift.push({ file, addedLines: 0, removedLines: 0, hunkSummaries: [], binaryChanged });
138
+ }
139
+ continue;
140
+ }
141
+ const foreignAdded = multisetExcess(next.added, previous?.added ?? new Map());
142
+ const foreignRemoved = multisetExcess(next.removed, previous?.removed ?? new Map());
143
+ const addedLines = countExcess(foreignAdded);
144
+ const removedLines = countExcess(foreignRemoved);
145
+ if (addedLines === 0 && removedLines === 0)
146
+ continue;
147
+ drift.push({
148
+ file,
149
+ addedLines,
150
+ removedLines,
151
+ hunkSummaries: summarizeForeignHunks(next, foreignAdded, foreignRemoved).slice(0, MAX_HUNK_SUMMARIES_PER_FILE),
152
+ binaryChanged: false,
153
+ });
154
+ }
155
+ return drift;
156
+ }
157
+ /**
158
+ * Reads the old body, prints the always-on drift preview (including under
159
+ * dry-run and `--scan`), and — when `ctx.refuseForeignDrift` is set —
160
+ * records a refusal and returns `true` so the caller skips the write.
161
+ */
162
+ export async function reportForeignDrift(args) {
163
+ const { patch, patchesDir, newDiffContent, ctx } = args;
164
+ const oldPath = join(patchesDir, patch.filename);
165
+ let oldBody;
166
+ try {
167
+ if (!(await pathExists(oldPath))) {
168
+ return refuseUnreadableBaseline(ctx, patch.filename, 'old patch body missing');
169
+ }
170
+ oldBody = await readText(oldPath);
171
+ }
172
+ catch (error) {
173
+ return refuseUnreadableBaseline(ctx, patch.filename, `old body unreadable (${toError(error).message})`);
174
+ }
175
+ const drift = computeForeignDrift(oldBody, newDiffContent, patch.filesAffected);
176
+ ctx.evaluationRuns += 1;
177
+ if (drift.length === 0)
178
+ return false;
179
+ const unexpected = drift.filter((d) => !ctx.expectedDriftFiles.has(d.file));
180
+ for (const entry of drift) {
181
+ if (ctx.expectedDriftFiles.has(entry.file))
182
+ ctx.expectedSeen.add(entry.file);
183
+ }
184
+ const totalLines = drift.reduce((sum, d) => sum + d.addedLines + d.removedLines, 0);
185
+ const lineNoun = totalLines === 1 ? 'line' : 'lines';
186
+ warn(`${patch.filename}: refreshed body absorbs ${String(totalLines)} ${lineNoun} not present in the old patch body (${String(drift.length)} file(s)):`);
187
+ for (const entry of drift.slice(0, MAX_FILES_REPORTED)) {
188
+ const expectedTag = ctx.expectedDriftFiles.has(entry.file) ? ' (expected via --expect)' : '';
189
+ if (entry.binaryChanged) {
190
+ info(` ${entry.file}: binary content changed${expectedTag}`);
191
+ continue;
192
+ }
193
+ info(` ${entry.file}: +${String(entry.addedLines)}/-${String(entry.removedLines)} newly captured line(s)${expectedTag}`);
194
+ for (const summary of entry.hunkSummaries) {
195
+ info(` ${summary}`);
196
+ }
197
+ }
198
+ if (drift.length > MAX_FILES_REPORTED) {
199
+ info(` +${String(drift.length - MAX_FILES_REPORTED)} more file(s)`);
200
+ }
201
+ if (ctx.refuseForeignDrift) {
202
+ if (unexpected.length === 0) {
203
+ info(`${patch.filename}: drift confined to --expect file(s); proceeding under --refuse-foreign-drift.`);
204
+ return false;
205
+ }
206
+ ctx.refusals.push({
207
+ patchFilename: patch.filename,
208
+ files: unexpected.map((d) => d.file),
209
+ reason: 'foreign-drift',
210
+ });
211
+ warn(`${patch.filename}: skipped (--refuse-foreign-drift).`);
212
+ return true;
213
+ }
214
+ warn(' If these are intentional engine edits, this is the normal re-export flow. ' +
215
+ 'FireForge cannot infer authorship; if another session owns them, re-run with ' +
216
+ "--refuse-foreign-drift, or commit/stash that session's edits first.");
217
+ return false;
218
+ }
219
+ /**
220
+ * Fail-closed baseline handling (FORGE L6): a missing or unreadable old
221
+ * patch body means the drift comparison CANNOT run, so under
222
+ * `--refuse-foreign-drift` the patch is refused rather than written on the
223
+ * strength of a check that never happened. Without the flag this stays the
224
+ * historical advisory skip (the preview is best-effort there).
225
+ */
226
+ function refuseUnreadableBaseline(ctx, patchFilename, detail) {
227
+ if (!ctx.refuseForeignDrift) {
228
+ verbose(`Skipping foreign-drift preview for ${patchFilename}: ${detail}`);
229
+ return false;
230
+ }
231
+ ctx.refusals.push({ patchFilename, files: [], reason: 'baseline-unreadable' });
232
+ warn(`${patchFilename}: skipped (--refuse-foreign-drift) — ${detail}; the drift comparison cannot run, refusing fail-closed.`);
233
+ return true;
234
+ }
@@ -7,7 +7,7 @@ import { getDiffForFilesAgainstHead } from '../core/git-diff.js';
7
7
  import { computeProjectedLintRegressions } from '../core/lint-projection.js';
8
8
  import { extractAffectedFiles } from '../core/patch-apply.js';
9
9
  import { updatePatchAndMetadata } from '../core/patch-export.js';
10
- import { buildModifiedFileAdditionsFromDiff, buildPatchQueueContext, detectNewFilesInDiff, lintPatchQueue, } from '../core/patch-lint.js';
10
+ import { buildModifiedFileAdditionsFromDiff, buildPatchQueueContext, detectNewFilesInDiff, formatPatchLintIssue, lintPatchQueue, } from '../core/patch-lint.js';
11
11
  import { loadPatchesManifest } from '../core/patch-manifest.js';
12
12
  import { buildProjectedManifest, enforcePatchPolicy } from '../core/patch-policy.js';
13
13
  import { extractNewFileContentFromDiff } from '../core/patch-transform.js';
@@ -73,7 +73,7 @@ async function runProjectedCrossPatchLint(patchesDir, targetFilename, projectedD
73
73
  return null;
74
74
  return {
75
75
  reason: `projected --files state introduces ${regressions.length} new cross-patch lint error(s)`,
76
- details: regressions.map((i) => `[${i.check}] ${i.file}: ${i.message}`),
76
+ details: regressions.map(formatPatchLintIssue),
77
77
  };
78
78
  }
79
79
  /**
@@ -117,7 +117,9 @@ async function confirmFilesModeProjection(args) {
117
117
  for (const line of summary) {
118
118
  info(` ${line}`);
119
119
  }
120
- throw new InvalidArgumentError(`Refusing to re-export ${target.filename} with --files because it would remove ${removed.length} existing patch-owned file${removed.length === 1 ? '' : 's'}. Run again with --allow-shrink after reviewing the dry-run output.`, '--allow-shrink');
120
+ throw new InvalidArgumentError(`Refusing to re-export ${target.filename} with --files because it would remove ${removed.length} existing patch-owned file${removed.length === 1 ? '' : 's'}. Run again with --allow-shrink after reviewing the dry-run output. ` +
121
+ 'If the intent is to move those files into their own patch instead, ' +
122
+ '"fireforge patch move-files <from> <to> --file <path> --create --order <n>" does the shrink and the new patch in one transaction.', '--allow-shrink');
121
123
  }
122
124
  return confirmDestructive({
123
125
  operation: 're-export-files',
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Hoisted + cached lint context for `re-export` (FORGE J1).
3
+ *
4
+ * The per-patch loop used to rebuild the whole patch-queue context AND a
5
+ * fresh queue-wide checkJs TypeScript program for every patch — ~37 s of
6
+ * fixed setup per patch (measured on a 290-patch consumer queue), so an
7
+ * N-patch re-export cost N×37 s. This module:
8
+ *
9
+ * - builds the queue context and the {@link PerRunCheckJs} program
10
+ * controller ONCE per invocation (`buildReExportLintContext`) and slices
11
+ * per-patch findings out of it (the FORGE I3 shape `move-files`/`split`
12
+ * already use, plus the program-level hoist `lint --per-patch` has);
13
+ * - reuses the existing per-patch lint RESULT cache
14
+ * (`.fireforge/lint-cache/per-patch-v1.json`) across invocations —
15
+ * re-export lints the identical input `lint --per-patch` does (the
16
+ * fresh `git diff HEAD` of the owned files), and the key already
17
+ * hashes engine HEAD, per-file content, patch body, config, shims, and
18
+ * ownership, so a repeat single re-export is a warm hit (~4 s instead
19
+ * of ~41 s). `--no-cache` opts out; run-level checkJs globals are
20
+ * never cached (FORGE F5) and are emitted once per invocation;
21
+ * - keeps the in-memory queue context honest after each write
22
+ * (`refreshQueueCtxEntry`), so later iterations of an `--all` run lint
23
+ * against the just-refreshed body instead of the stale one.
24
+ */
25
+ import { getProjectPaths, loadConfig } from '../core/config.js';
26
+ import { type PerPatchLintCacheFile } from '../core/lint-cache.js';
27
+ import { type PatchQueueContext } from '../core/patch-lint.js';
28
+ import type { PatchLintIssue, PatchMetadata } from '../types/commands/index.js';
29
+ import type { FireForgeConfig } from '../types/config.js';
30
+ import { type PerRunCheckJs } from './lint-per-run-checkjs.js';
31
+ /** Per-invocation lint context, built once and threaded through the loop. */
32
+ export interface ReExportLintContext {
33
+ projectRoot: string;
34
+ paths: ReturnType<typeof getProjectPaths>;
35
+ config: FireForgeConfig;
36
+ /** Whole-queue context, built WITH config so the forward-import hints
37
+ * match `lint --per-patch` (FORGE F14); undefined when no patches dir. */
38
+ patchQueueCtx: PatchQueueContext | undefined;
39
+ /** Queue-wide checkJs controller; undefined when checkJs is off. */
40
+ checkJs: PerRunCheckJs | undefined;
41
+ /** Per-patch result cache; undefined under `--no-cache`. */
42
+ cache: PerPatchLintCacheFile | undefined;
43
+ engineHeadSha: string | undefined;
44
+ cacheDirty: boolean;
45
+ reusedCacheEntries: number;
46
+ /** Run-level checkJs globals are emitted once per invocation. */
47
+ globalsEmitted: boolean;
48
+ }
49
+ /** Builds the once-per-invocation lint context (FORGE J1). */
50
+ export declare function buildReExportLintContext(projectRoot: string, paths: ReturnType<typeof getProjectPaths>, config: Awaited<ReturnType<typeof loadConfig>>, noCache: boolean): Promise<ReExportLintContext>;
51
+ /** Fresh lint payload carried to the post-write cache store. */
52
+ export interface ReExportLintResult {
53
+ issues: PatchLintIssue[];
54
+ suppressed: PatchLintIssue[];
55
+ lineCount: number;
56
+ }
57
+ /**
58
+ * Lints one re-exported patch body using the hoisted context: cache hit →
59
+ * replay the stored issues through the shared reporter (returns `null`);
60
+ * miss → lint with the queue-wide program's pre-attributed slices (or a
61
+ * per-patch build when the fresh diff creates files the hoisted program
62
+ * never saw — the `--scan` adoption case) and return the payload for
63
+ * {@link storeReExportLintResult}. Throws exactly like `runPatchLint` on
64
+ * errors (unless `skipLint`).
65
+ *
66
+ * The cache key is built over the PROJECTED metadata (current
67
+ * filesAffected, merged lintIgnore, effective tier) so `--tier` /
68
+ * `--lint-ignore` flags change the key. The entry itself is stored AFTER
69
+ * the body write (the key hashes the on-disk `.patch` file, which this
70
+ * very run is about to rewrite — a pre-write store could never match a
71
+ * later run).
72
+ */
73
+ export declare function lintReExportedPatch(args: {
74
+ lintCtx: ReExportLintContext;
75
+ patch: PatchMetadata;
76
+ projectedMetadata: PatchMetadata;
77
+ existingFiles: string[];
78
+ diffContent: string;
79
+ skipLint?: boolean;
80
+ ignoreChecks?: ReadonlySet<string>;
81
+ patchTier?: 'branding';
82
+ }): Promise<ReExportLintResult | null>;
83
+ /**
84
+ * Stores a fresh lint result AFTER the body write, keying against the
85
+ * just-written `.patch` file and the refreshed queue context — the state
86
+ * a later identical invocation will observe (call
87
+ * {@link refreshQueueCtxEntry} first).
88
+ */
89
+ export declare function storeReExportLintResult(lintCtx: ReExportLintContext, patchFilename: string, projectedMetadata: PatchMetadata, existingFiles: string[], result: ReExportLintResult): Promise<void>;
90
+ /**
91
+ * Refreshes the in-memory queue entry for a just-rewritten patch so later
92
+ * iterations (and later cache keys) lint against the new body instead of
93
+ * the stale one. The on-disk write is the authority; this mirrors it.
94
+ */
95
+ export declare function refreshQueueCtxEntry(lintCtx: ReExportLintContext, patchFilename: string, diffContent: string): void;
96
+ /** Persists cache writes and reports reuse, once after the loop. */
97
+ export declare function finishReExportLint(lintCtx: ReExportLintContext): Promise<void>;
@@ -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
+ }
@@ -80,6 +80,14 @@ export function validateReExportOptionCombinations(patches, options) {
80
80
  if (options.refuseAdjacentUnmanaged === true && (options.scan || options.files !== undefined)) {
81
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
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
+ }
83
91
  const usingTierFlag = options.tier !== undefined;
84
92
  const usingLintIgnoreFlag = options.lintIgnore !== undefined && options.lintIgnore.length > 0;
85
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
+ }