@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
@@ -15,31 +15,41 @@
15
15
  * Lives in a sibling module to keep `doctor-furnace.ts` under the
16
16
  * per-file LOC budget.
17
17
  */
18
- import { readdir, rm } from 'node:fs/promises';
18
+ import { readdir, rmdir } from 'node:fs/promises';
19
19
  import { join } from 'node:path';
20
+ import { withFileLock } from '../core/file-lock.js';
20
21
  import { getFurnacePaths, loadFurnaceConfig, writeFurnaceConfig } from '../core/furnace-config.js';
22
+ import { getFurnaceLockPath } from '../core/furnace-operation.js';
21
23
  import { toError } from '../utils/errors.js';
22
- import { pathExists, readJson } from '../utils/fs.js';
24
+ import { pathExistsStrict, readJson } from '../utils/fs.js';
23
25
  import { failure, ok, warning } from './doctor-check-core.js';
26
+ /**
27
+ * Lists component directories, distinguishing "none" from "could not look".
28
+ *
29
+ * A missing directory is genuinely empty. A `readdir` failure is not: this
30
+ * check exists to detect the concurrent-override orphan race, and swallowing
31
+ * the error into `[]` made it report clean for the one condition it is here
32
+ * to catch. The caller surfaces the failure instead.
33
+ */
24
34
  async function listComponentDirs(dir) {
25
- if (!(await pathExists(dir)))
26
- return [];
27
- try {
28
- const entries = await readdir(dir, { withFileTypes: true });
29
- return entries.filter((e) => e.isDirectory()).map((e) => e.name);
30
- }
31
- catch {
35
+ // `pathExistsStrict`, not `pathExists`: the latter answers `false` for EVERY
36
+ // access error, so an unreadable components directory reported "no orphans",
37
+ // i.e. a clean check — the same fail-open the comment above rejects for
38
+ // `readdir`. Only ENOENT means genuinely absent.
39
+ if (!(await pathExistsStrict(dir)))
32
40
  return [];
33
- }
41
+ const entries = await readdir(dir, { withFileTypes: true });
42
+ return entries.filter((e) => e.isDirectory()).map((e) => e.name);
34
43
  }
35
44
  async function recoverOverrideConfig(overrideDir, name) {
36
45
  // `furnace override` writes `override.json` alongside the copied files.
37
46
  // That sidecar is enough to reconstruct the furnace.json entry lost to
38
47
  // a concurrent-write race (eval 2: second override wrote back the
39
48
  // outer-snapshot config, dropping the sibling write's addition).
49
+ // No `pathExists` precheck: it collapses "absent" and "unreadable" into the
50
+ // same `false`, and both outcomes lead here anyway — a missing file lands in
51
+ // the catch below with the same `undefined` result.
40
52
  const sidecarPath = join(overrideDir, name, 'override.json');
41
- if (!(await pathExists(sidecarPath)))
42
- return undefined;
43
53
  try {
44
54
  const raw = await readJson(sidecarPath);
45
55
  const type = raw.type;
@@ -59,11 +69,18 @@ async function recoverOverrideConfig(overrideDir, name) {
59
69
  baseVersion,
60
70
  };
61
71
  if (typeof raw.baseCommit === 'string') {
72
+ // `baseCommit` is already declared optional on OverrideComponentConfig,
73
+ // so the intersection this replaced widened nothing.
62
74
  restored.baseCommit = raw.baseCommit;
63
75
  }
64
76
  return restored;
65
77
  }
66
78
  catch {
79
+ // Unreadable or malformed sidecar. Returning `undefined` here is the same
80
+ // value as "no sidecar present", so the caller reports the override as
81
+ // unrecoverable — which is the safe direction (it never invents config),
82
+ // but the operator sees "no valid override.json" rather than "the
83
+ // override.json is corrupt". The remediation is identical either way.
67
84
  return undefined;
68
85
  }
69
86
  }
@@ -95,61 +112,139 @@ function formatOrphanSummary(orphans) {
95
112
  const description = [overrideLabel, customLabel].filter(Boolean).join(' and ');
96
113
  return `Found orphaned component ${overrideCount + customCount === 1 ? 'directory' : 'directories'} on disk: ${description}. furnace.json does not list these — a previous mutation may have lost a concurrent write.`;
97
114
  }
98
- async function repairOrphanOverrides(projectRoot, orphans) {
99
- const restored = [];
100
- const unrecoverable = [];
101
- if (orphans.length === 0)
102
- return { restored, unrecoverable };
103
- const freshConfig = await loadFurnaceConfig(projectRoot);
115
+ function emptyOutcome() {
116
+ return {
117
+ restored: [],
118
+ unrecoverable: [],
119
+ reconciled: [],
120
+ deleted: [],
121
+ retained: [],
122
+ errors: [],
123
+ };
124
+ }
125
+ /**
126
+ * Re-registers override orphans and deletes empty custom orphan directories,
127
+ * under ONE furnace lock held across both.
128
+ *
129
+ * Two things this must not do, both of which reproduce the lost-write race the
130
+ * check exists to clean up after:
131
+ *
132
+ * 1. Write back a decision made before the lock. The orphan list is computed
133
+ * from a config snapshot loaded well before we hold anything, so by the
134
+ * time we can write, a concurrent `furnace override` may have registered
135
+ * the same name — writing our sidecar-reconstructed entry over it discards
136
+ * a live write, and a concurrent `furnace create` registering the name
137
+ * under `custom` would leave it in BOTH maps, a state nothing validates.
138
+ * Every orphan is therefore re-checked against `freshConfig` inside the
139
+ * lock and skipped when it is no longer orphaned.
140
+ * 2. Delete custom directories outside the lock. `repairCustomOrphans` used to
141
+ * run after the lock was released, so a `furnace create` that had
142
+ * registered a name and not yet populated its directory had that directory
143
+ * deleted underneath it.
144
+ */
145
+ async function repairOrphans(projectRoot, orphans) {
146
+ const outcome = emptyOutcome();
147
+ if (orphans.overrides.length === 0 && orphans.customNames.length === 0)
148
+ return outcome;
149
+ const lockPath = getFurnaceLockPath(projectRoot);
150
+ // One catch covers four distinct phases; naming the one that failed keeps
151
+ // the report honest — a lock timeout used to render as "failed while
152
+ // writing furnace.json", the phase least likely to have been reached.
153
+ // Held in an object rather than two `let`s: the lock callback mutates them
154
+ // from inside a closure, and TS's control-flow analysis does not propagate
155
+ // that through the call — it would narrow both to their initial literals in
156
+ // the catch below.
157
+ const progress = { phase: 'waiting for the furnace lock', persisted: false };
158
+ try {
159
+ await withFileLock(lockPath, async () => {
160
+ progress.phase = 'loading furnace.json under the lock';
161
+ const freshConfig = await loadFurnaceConfig(projectRoot);
162
+ repairOverridesLocked(freshConfig, orphans.overrides, outcome);
163
+ if (outcome.restored.length > 0) {
164
+ progress.phase = 'writing furnace.json';
165
+ await writeFurnaceConfig(projectRoot, freshConfig);
166
+ }
167
+ // From here the restore (if any) is on disk; a later failure must not
168
+ // report the whole repair as if it never happened.
169
+ progress.persisted = true;
170
+ progress.phase = 'cleaning custom orphan directories';
171
+ await deleteEmptyCustomOrphans(projectRoot, freshConfig, orphans.customNames, outcome);
172
+ }, {
173
+ onTimeoutMessage: `Timed out waiting for the furnace lock at ${lockPath}. ` +
174
+ 'Another fireforge furnace command may be running; retry the repair once it finishes.',
175
+ });
176
+ }
177
+ catch (err) {
178
+ if (progress.persisted) {
179
+ outcome.errors.push(`repair failed while ${progress.phase}: ${toError(err).message}`);
180
+ return outcome;
181
+ }
182
+ return {
183
+ ...emptyOutcome(),
184
+ unrecoverable: outcome.unrecoverable,
185
+ repairError: `while ${progress.phase}: ${toError(err).message}`,
186
+ };
187
+ }
188
+ return outcome;
189
+ }
190
+ /** Override half of {@link repairOrphans}; mutates `freshConfig` in place. */
191
+ function repairOverridesLocked(freshConfig, orphans, outcome) {
104
192
  for (const orphan of orphans) {
193
+ if (orphan.name in freshConfig.overrides || orphan.name in freshConfig.custom) {
194
+ // Registered while we waited for the lock — no longer an orphan, and
195
+ // writing over it would either discard that write or duplicate the name
196
+ // across `overrides` and `custom`.
197
+ outcome.reconciled.push(orphan.name);
198
+ continue;
199
+ }
105
200
  if (orphan.recoveredConfig) {
106
201
  freshConfig.overrides[orphan.name] = orphan.recoveredConfig;
107
- restored.push(orphan.name);
202
+ outcome.restored.push(orphan.name);
108
203
  }
109
204
  else {
110
- unrecoverable.push(orphan.name);
205
+ outcome.unrecoverable.push(orphan.name);
111
206
  }
112
207
  }
113
- if (restored.length > 0) {
114
- try {
115
- await writeFurnaceConfig(projectRoot, freshConfig);
116
- }
117
- catch (err) {
118
- return { restored: [], unrecoverable, writeError: toError(err).message };
119
- }
120
- }
121
- return { restored, unrecoverable };
122
208
  }
123
- async function repairCustomOrphans(projectRoot, customNames) {
124
- const deleted = [];
125
- const retained = [];
126
- const errors = [];
209
+ /** Custom half of {@link repairOrphans}; must run under the same lock. */
210
+ async function deleteEmptyCustomOrphans(projectRoot, freshConfig, customNames, outcome) {
127
211
  if (customNames.length === 0)
128
- return { deleted, retained, errors };
212
+ return;
129
213
  const furnacePaths = getFurnacePaths(projectRoot);
130
214
  for (const name of customNames) {
215
+ if (name in freshConfig.custom || name in freshConfig.overrides) {
216
+ // A name that was orphaned in BOTH trees is reconciled once, not once
217
+ // per tree — the report reads "Skipped 2 names (x, x)" otherwise.
218
+ if (!outcome.reconciled.includes(name))
219
+ outcome.reconciled.push(name);
220
+ continue;
221
+ }
131
222
  const dir = join(furnacePaths.customDir, name);
132
223
  let entries;
133
224
  try {
134
225
  entries = await readdir(dir, { withFileTypes: true });
135
226
  }
136
227
  catch (err) {
137
- errors.push(`${name}: ${toError(err).message}`);
228
+ outcome.errors.push(`${name}: ${toError(err).message}`);
138
229
  continue;
139
230
  }
140
231
  if (entries.length > 0) {
141
- retained.push(name);
232
+ outcome.retained.push(name);
142
233
  continue;
143
234
  }
144
235
  try {
145
- await rm(dir);
146
- deleted.push(name);
236
+ // `rmdir`, not `rm`: `rm` without `{ recursive: true }` throws EISDIR on
237
+ // a directory, so on a real filesystem this branch always failed and the
238
+ // "deleted N empty directories" report was unreachable. `rmdir` is also
239
+ // the exact semantics wanted here — it refuses a non-empty directory,
240
+ // which keeps the emptiness check above from being the only safeguard.
241
+ await rmdir(dir);
242
+ outcome.deleted.push(name);
147
243
  }
148
244
  catch (err) {
149
- errors.push(`${name}: ${toError(err).message}`);
245
+ outcome.errors.push(`${name}: ${toError(err).message}`);
150
246
  }
151
247
  }
152
- return { deleted, retained, errors };
153
248
  }
154
249
  export const furnaceManifestSyncCheck = {
155
250
  name: 'Furnace manifest sync',
@@ -169,12 +264,15 @@ export const furnaceManifestSyncCheck = {
169
264
  if (!ctx.options.repairFurnace) {
170
265
  return warning('Furnace manifest sync', summary, 'Run "fireforge doctor --repair-furnace" to re-register override orphans from their override.json sidecars (custom orphans are listed for manual follow-up).');
171
266
  }
172
- const repairResult = await repairOrphanOverrides(ctx.projectRoot, orphans.overrides);
173
- if (repairResult.writeError) {
174
- return failure('Furnace manifest sync', `Repair failed while writing furnace.json: ${repairResult.writeError}`, 'Fix the underlying filesystem error and retry the doctor command.');
267
+ const repairResult = await repairOrphans(ctx.projectRoot, orphans);
268
+ if (repairResult.repairError) {
269
+ return failure('Furnace manifest sync', `Repair failed ${repairResult.repairError}`, 'Fix the underlying error and retry the doctor command.');
175
270
  }
176
- const customRepair = await repairCustomOrphans(ctx.projectRoot, orphans.customNames);
177
- const { restored, unrecoverable } = repairResult;
271
+ const customRepair = repairResult;
272
+ const { restored, unrecoverable, reconciled } = repairResult;
273
+ const reconciledDetail = reconciled.length > 0
274
+ ? ` Skipped ${reconciled.length} name${reconciled.length === 1 ? '' : 's'} (${reconciled.join(', ')}) that furnace.json already listed by the time the repair held the furnace lock — a concurrent furnace command registered ${reconciled.length === 1 ? 'it' : 'them'} first.`
275
+ : '';
178
276
  const restoreDetail = restored.length > 0
179
277
  ? `Re-registered ${restored.length} override${restored.length === 1 ? '' : 's'} (${restored.join(', ')}) from their override.json sidecars.`
180
278
  : '';
@@ -190,7 +288,7 @@ export const furnaceManifestSyncCheck = {
190
288
  const customErrorDetail = customRepair.errors.length > 0
191
289
  ? ` Could not inspect or delete ${customRepair.errors.length} custom orphan ${customRepair.errors.length === 1 ? 'directory' : 'directories'} (${customRepair.errors.join('; ')}).`
192
290
  : '';
193
- return warning('Furnace manifest sync', `${restoreDetail}${unrecoverableDetail}${customDetail}${retainedCustomDetail}${customErrorDetail}`.trim() ||
291
+ return warning('Furnace manifest sync', `${restoreDetail}${unrecoverableDetail}${customDetail}${retainedCustomDetail}${customErrorDetail}${reconciledDetail}`.trim() ||
194
292
  'Nothing to repair (orphans surfaced but all were already recoverable).');
195
293
  },
196
294
  };
@@ -8,7 +8,7 @@ import { CUSTOM_ELEMENTS_JS, JAR_MN, resolveFtlDir } from '../core/furnace-const
8
8
  import { getFurnaceLockPath, runFurnaceMutation } from '../core/furnace-operation.js';
9
9
  import { hasBlockingStepErrors } from '../core/furnace-step-errors.js';
10
10
  import { validateAllComponents } from '../core/furnace-validate.js';
11
- import { toError } from '../utils/errors.js';
11
+ import { isProcessAlive, toError } from '../utils/errors.js';
12
12
  import { pathExists } from '../utils/fs.js';
13
13
  import { failure, ok, warning } from './doctor-check-core.js';
14
14
  import { furnaceStaleJarRegistrationCheck } from './doctor-furnace-jar.js';
@@ -120,6 +120,9 @@ async function collectFurnaceDrift(projectRoot, engineDir, config, ftlDir) {
120
120
  }
121
121
  }
122
122
  catch {
123
+ // A drift probe that cannot complete is reported AS drift: the doctor check
124
+ // must not claim a component is clean when it failed to look. Mirrors the
125
+ // documented twin above.
123
126
  drifted.push(name);
124
127
  }
125
128
  }
@@ -428,18 +431,11 @@ async function readFurnaceLockPid(lockPath) {
428
431
  return Number.isFinite(pid) ? pid : null;
429
432
  }
430
433
  catch {
434
+ // No PID file, or it is unreadable/unparseable. The caller falls back to the
435
+ // age-only staleness heuristic, which is why this is `null` and not a throw.
431
436
  return null;
432
437
  }
433
438
  }
434
- function isProcessStillRunning(pid) {
435
- try {
436
- process.kill(pid, 0);
437
- return true;
438
- }
439
- catch {
440
- return false;
441
- }
442
- }
443
439
  /**
444
440
  * "Furnace stale lock" check: detect and (under `--repair-furnace`)
445
441
  * remove a `.fireforge/furnace.lock` directory whose owner process is no
@@ -474,7 +470,13 @@ const furnaceStaleLockCheck = {
474
470
  // this bucket; the age gate avoids false positives on a lock
475
471
  // that was just acquired by a concurrent process that hadn't
476
472
  // written its PID yet.
477
- const ownerDead = pid !== null && !isProcessStillRunning(pid);
473
+ //
474
+ // `isProcessAlive` treats EPERM as ALIVE. The local copy it replaced
475
+ // returned false for EPERM, so a furnace lock held by a live process
476
+ // owned by another uid was reported as "owner is no longer running"
477
+ // and deleted by --repair-furnace, dropping mutual exclusion under a
478
+ // concurrent furnace operation.
479
+ const ownerDead = pid !== null && !isProcessAlive(pid);
478
480
  const pidMissingAndOld = pid === null && (ageMs ?? 0) > 60_000;
479
481
  const isStale = ownerDead || pidMissingAndOld;
480
482
  if (!isStale) {
@@ -20,6 +20,7 @@
20
20
  * doctor AND/OR `test --canary`, and adding a system-wide process scan to
21
21
  * the canary hot path buys no extra coverage over the doctor check.
22
22
  */
23
+ import { toError } from '../utils/errors.js';
23
24
  import { exec } from '../utils/process.js';
24
25
  import { ok, warning } from './doctor-check-core.js';
25
26
  /**
@@ -104,7 +105,7 @@ async function runOrphanedHarnessCheck() {
104
105
  psOutput = await listSystemProcesses();
105
106
  }
106
107
  catch (error) {
107
- return warning(CHECK_NAME, `Could not scan system processes (${error instanceof Error ? error.message : String(error)}); skipping the orphaned-worker check.`);
108
+ return warning(CHECK_NAME, `Could not scan system processes (${toError(error).message}); skipping the orphaned-worker check.`);
108
109
  }
109
110
  const orphans = findOrphanedHarnessWorkers(psOutput);
110
111
  if (orphans.length === 0) {
@@ -13,7 +13,7 @@ import { pathExists } from '../utils/fs.js';
13
13
  import { error, info, intro, outro, success, warn } from '../utils/logger.js';
14
14
  import { findExecutable } from '../utils/process.js';
15
15
  import { POST_REBASE_AUDIT_CHECK } from './doctor/post-rebase-audit.js';
16
- import { failure, ok, warning } from './doctor-check-core.js';
16
+ import { failure, ok, resolveDoctorSeverity, warning } from './doctor-check-core.js';
17
17
  import { EXTERNAL_TOOLCHAIN_DOCTOR_CHECK } from './doctor-external-toolchains.js';
18
18
  import { FURNACE_DOCTOR_CHECKS } from './doctor-furnace.js';
19
19
  import { ORPHANED_HARNESS_DOCTOR_CHECK } from './doctor-orphaned-harness.js';
@@ -406,7 +406,7 @@ export function reportDoctorResults(checks) {
406
406
  let warningCount = 0;
407
407
  let failedCount = 0;
408
408
  for (const check of checks) {
409
- const severity = check.severity ?? (check.passed ? (check.warning ? 'warning' : 'ok') : 'error');
409
+ const severity = resolveDoctorSeverity(check);
410
410
  if (severity === 'warning') {
411
411
  warn(`! ${check.name}: ${check.message}`);
412
412
  if (check.fix) {
@@ -195,6 +195,10 @@ export async function exportAllCommand(projectRoot, options = {}) {
195
195
  // post-hoc string surgery on the aggregate diff, which keeps the
196
196
  // output shape aligned with the single-file `export` command.
197
197
  let diff;
198
+ // Retain the exact scope used to build the aggregate. Interactive
199
+ // license-header repair mutates engine files and therefore has to rebuild
200
+ // the diff, but it must not silently widen an --exclude-furnace export.
201
+ let scopedExportPaths;
198
202
  if (furnaceExcluded.size > 0) {
199
203
  const rawChanged = await getWorkingTreeStatus(paths.engine);
200
204
  const allChanged = await expandUntrackedDirectoryEntries(paths.engine, rawChanged);
@@ -203,6 +207,7 @@ export async function exportAllCommand(projectRoot, options = {}) {
203
207
  .flatMap((entry) => [entry.file, entry.originalPath].filter((value) => !!value))
204
208
  .filter((file) => !furnaceExcluded.has(file))),
205
209
  ].sort();
210
+ scopedExportPaths = nonFurnacePaths;
206
211
  if (nonFurnacePaths.length === 0) {
207
212
  info(`Excluded ${furnaceExcluded.size} furnace-managed file(s) from export; no non-Furnace changes remain.`);
208
213
  outro('Nothing to export');
@@ -234,7 +239,10 @@ export async function exportAllCommand(projectRoot, options = {}) {
234
239
  // report-only under --dry-run so the preview never mutates engine/)
235
240
  const headersAdded = await autoFixLicenseHeaders(paths.engine, diff, config, isInteractive, isDryRun);
236
241
  if (headersAdded) {
237
- diff = await getAllDiff(paths.engine);
242
+ diff =
243
+ scopedExportPaths === undefined
244
+ ? await getAllDiff(paths.engine)
245
+ : await getDiffForFilesAgainstHead(paths.engine, scopedExportPaths);
238
246
  }
239
247
  const metadata = await promptExportPatchMetadata(options, isInteractive, 'export-all', config);
240
248
  if (!metadata)
@@ -20,6 +20,7 @@ import { GeneralError, InvalidArgumentError } from '../errors/base.js';
20
20
  import { toError } from '../utils/errors.js';
21
21
  import { pathExists, readText, removeFile, writeText } from '../utils/fs.js';
22
22
  import { info, warn } from '../utils/logger.js';
23
+ import { groupProjectedPlacementErrors } from './export-placement-conflicts.js';
23
24
  import { assertPlacementAvoidsReservedRanges } from './export-placement-policy.js';
24
25
  import { findPartialOwnershipOverlap } from './export-shared.js';
25
26
  function buildFilenameForPlacement(category, name, order, width) {
@@ -74,14 +75,22 @@ export function computePlacementPlan(manifestPatches, newPatchCategory, newPatch
74
75
  const sorted = [...manifestPatches].sort((a, b) => a.order - b.order);
75
76
  const renameMap = new Map();
76
77
  const prefixWidth = prefixWidthForPatches(sorted, requestedOrder);
77
- // Every existing patch at requestedOrder or later shifts up by one.
78
+ // Patches at requestedOrder or later shift up by one, but only until the
79
+ // cascade reaches a free ordinal — a gap absorbs the insert, so the tail
80
+ // beyond it (including any reserved block) keeps its numbering.
81
+ let cursor = requestedOrder;
78
82
  for (const patch of sorted) {
79
- if (patch.order >= requestedOrder) {
80
- const newOrder = patch.order + 1;
81
- const currentRest = patch.filename.replace(/^\d+-/, '');
82
- const newFilename = `${String(newOrder).padStart(prefixWidth, '0')}-${currentRest}`;
83
- renameMap.set(patch.filename, { newOrder, newFilename });
83
+ if (patch.order < requestedOrder) {
84
+ continue;
84
85
  }
86
+ if (patch.order > cursor) {
87
+ break;
88
+ }
89
+ const newOrder = cursor + 1;
90
+ const currentRest = patch.filename.replace(/^\d+-/, '');
91
+ const newFilename = `${String(newOrder).padStart(prefixWidth, '0')}-${currentRest}`;
92
+ renameMap.set(patch.filename, { newOrder, newFilename });
93
+ cursor = newOrder;
85
94
  }
86
95
  const newFilename = buildFilenameForPlacement(newPatchCategory, newPatchName, requestedOrder, prefixWidth);
87
96
  return {
@@ -199,15 +208,24 @@ export async function projectPlacementForLint(patchesDir, plan, diff, config) {
199
208
  modifiedFileAdditions: buildModifiedFileAdditionsFromDiff(diff),
200
209
  });
201
210
  projectedEntries.sort((a, b) => a.order - b.order || a.filename.localeCompare(b.filename));
211
+ const policyRest = baseCtx.patchPolicy ? { patchPolicy: baseCtx.patchPolicy } : {};
202
212
  const projectedIssues = lintPatchQueue({
203
213
  entries: projectedEntries,
204
- ...(baseCtx.patchPolicy ? { patchPolicy: baseCtx.patchPolicy } : {}),
214
+ ...policyRest,
205
215
  }).filter((i) => i.severity === 'error');
206
216
  if (projectedIssues.length === 0)
207
217
  return null;
218
+ // Baseline lint over the UN-renumbered queue: an error present here is
219
+ // pre-existing breakage, not a consequence of this placement — calling
220
+ // it one would misattribute (FORGE K9). Entries are already in memory,
221
+ // so this second pass costs no additional IO.
222
+ const baselineIssues = lintPatchQueue({
223
+ entries: baseCtx.entries,
224
+ ...policyRest,
225
+ }).filter((i) => i.severity === 'error');
208
226
  return {
209
227
  reason: `placement would introduce ${projectedIssues.length} cross-patch lint error(s)`,
210
- details: projectedIssues.map((i) => `[${i.check}] ${i.file}: ${i.message}`),
228
+ details: groupProjectedPlacementErrors(projectedIssues, baselineIssues, plan),
211
229
  };
212
230
  }
213
231
  /**
@@ -240,7 +258,7 @@ export async function commitPlacementExport(input) {
240
258
  }
241
259
  const conflicts = await projectPlacementForLint(input.patchesDir, currentPlan, input.diff, input.config);
242
260
  if (conflicts && input.unsafeOverride !== true) {
243
- throw new InvalidArgumentError(`Refusing to run export: ${conflicts.reason}. ` +
261
+ throw new InvalidArgumentError(`Refusing to run export: ${conflicts.reason}:\n ${conflicts.details.join('\n ')}\n` +
244
262
  'If the conflict names files owned by another patch (e.g. duplicate-new-file-creation), ' +
245
263
  're-run the export with an explicit file list that leaves those files with their owner — ' +
246
264
  'do NOT bypass with --force-unsafe. Pass --force-unsafe only for a reviewed placement conflict.', '--force-unsafe');
@@ -0,0 +1,17 @@
1
+ import type { PatchRenameEntry } from '../core/patch-manifest.js';
2
+ import type { PatchLintIssue } from '../types/commands/index.js';
3
+ /** Inputs {@link groupProjectedPlacementErrors} needs from the plan. */
4
+ export interface PlacementAttributionPlan {
5
+ insertionOrder: number;
6
+ newFilename: string;
7
+ renameMap: Map<string, PatchRenameEntry>;
8
+ }
9
+ /**
10
+ * Partitions the projected placement errors into three labeled groups —
11
+ * defects in the exported content, consequences of the renumbering, and
12
+ * errors the queue already had — rendered as indented detail lines for
13
+ * {@link ConflictReport.details}. Grouping travels through the plain
14
+ * `details: string[]`, so both refusal sites and confirmDestructive's
15
+ * conflict printer show it without changes.
16
+ */
17
+ export declare function groupProjectedPlacementErrors(projectedErrors: readonly PatchLintIssue[], baselineErrors: readonly PatchLintIssue[], plan: PlacementAttributionPlan): string[];
@@ -0,0 +1,74 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Attribution of projected placement lint errors (FORGE K9), split out of
4
+ * export-flow.ts for the line budget. A placement projection lints one
5
+ * merged queue — the renumbered existing patches plus a synthetic entry
6
+ * for the pending patch — so its errors used to render as one flat list
7
+ * that read as though the NEW patch caused every one of them. A
8
+ * renumbering that re-evaluates pre-existing staged relationships (455
9
+ * importing a module created by 456, legal only at their current
10
+ * ordinals) then sent the operator auditing a perfectly fine export.
11
+ *
12
+ * Consumed by export-flow.ts; no top-level registrar is exported and none
13
+ * is wanted.
14
+ */
15
+ import { formatPatchLintIssue } from '../core/patch-lint.js';
16
+ /**
17
+ * Identity for baseline comparison: check + site file + implicated
18
+ * patches, with projected filenames mapped BACK through the rename map so
19
+ * a pre-existing error keeps its identity across the renumber. Never
20
+ * parses the fingerprint — it is documented rename-sensitive.
21
+ */
22
+ function issueKey(issue, mapBackToOldName) {
23
+ const patches = (issue.patches ?? []).map(mapBackToOldName).sort((a, b) => a.localeCompare(b));
24
+ return `${issue.check}|${issue.file}|${patches.join(',')}`;
25
+ }
26
+ /**
27
+ * Partitions the projected placement errors into three labeled groups —
28
+ * defects in the exported content, consequences of the renumbering, and
29
+ * errors the queue already had — rendered as indented detail lines for
30
+ * {@link ConflictReport.details}. Grouping travels through the plain
31
+ * `details: string[]`, so both refusal sites and confirmDestructive's
32
+ * conflict printer show it without changes.
33
+ */
34
+ export function groupProjectedPlacementErrors(projectedErrors, baselineErrors, plan) {
35
+ const oldNameByNew = new Map();
36
+ for (const [oldFilename, rename] of plan.renameMap) {
37
+ oldNameByNew.set(rename.newFilename, oldFilename);
38
+ }
39
+ const mapBack = (name) => oldNameByNew.get(name) ?? name;
40
+ const baselineKeys = new Set(baselineErrors.map((issue) => issueKey(issue, (name) => name)));
41
+ const exported = [];
42
+ const renumbering = [];
43
+ const preExisting = [];
44
+ for (const issue of projectedErrors) {
45
+ // The synthetic entry's filename is unique in the projection, so
46
+ // membership in `patches` attributes exactly. Issues from older rules
47
+ // without the field fall back to a message scan, then to "the queue".
48
+ const implicatesNewPatch = issue.patches?.includes(plan.newFilename) ?? issue.message.includes(plan.newFilename);
49
+ if (implicatesNewPatch) {
50
+ exported.push(issue);
51
+ }
52
+ else if (baselineKeys.has(issueKey(issue, mapBack))) {
53
+ preExisting.push(issue);
54
+ }
55
+ else {
56
+ renumbering.push(issue);
57
+ }
58
+ }
59
+ const details = [];
60
+ const pushGroup = (header, issues) => {
61
+ if (issues.length === 0)
62
+ return;
63
+ details.push(header);
64
+ for (const issue of issues) {
65
+ details.push(` ${formatPatchLintIssue(issue)}`);
66
+ }
67
+ };
68
+ pushGroup(`errors in the exported patch content (${String(exported.length)}):`, exported);
69
+ pushGroup(`consequences of renumbering existing patches to make room at ordinal ` +
70
+ `${String(plan.insertionOrder)} (${String(renumbering.length)}) — these come from ` +
71
+ 'renumbering, NOT from the exported content:', renumbering);
72
+ pushGroup(`errors already present in the queue before this export (${String(preExisting.length)}):`, preExisting);
73
+ return details;
74
+ }
@@ -96,7 +96,7 @@ export async function gatePlacementPlan(args) {
96
96
  }
97
97
  else if (conflicts && options.forceUnsafe !== true) {
98
98
  s.stop();
99
- throw new InvalidArgumentError(`Refusing to run export: ${conflicts.reason}. ` +
99
+ throw new InvalidArgumentError(`Refusing to run export: ${conflicts.reason}:\n ${conflicts.details.join('\n ')}\n` +
100
100
  'If the conflict names files owned by another patch (e.g. duplicate-new-file-creation), ' +
101
101
  're-run the export with an explicit file list that leaves those files with their owner — ' +
102
102
  'do NOT bypass with --force-unsafe. Pass --force-unsafe only for a reviewed placement conflict.', '--force-unsafe');
@@ -1,4 +1,4 @@
1
- import type { PatchesManifest } from '../types/commands/index.js';
1
+ import type { PatchesManifest, PatchLintIssue } from '../types/commands/index.js';
2
2
  import type { ExportOptions, PatchCategory } from '../types/commands/index.js';
3
3
  import type { FireForgeConfig } from '../types/config.js';
4
4
  import type { SpinnerHandle } from '../utils/logger.js';
@@ -25,6 +25,13 @@ import type { SpinnerHandle } from '../utils/logger.js';
25
25
  * visible rather than silent.
26
26
  */
27
27
  export declare function runPatchLint(engineDir: string, filesAffected: string[], diffContent: string, config: FireForgeConfig, skipLint?: boolean, patchQueueCtx?: import('../core/patch-lint-cross.js').PatchQueueContext, ignoreChecks?: ReadonlySet<string>, patchTier?: 'branding'): Promise<void>;
28
+ /**
29
+ * Prints a patch-lint issue list with `runPatchLint`'s exact severity
30
+ * buckets and skip-lint semantics, throwing on errors unless `skipLint`.
31
+ * Factored out so `re-export`'s cached-lint path (FORGE J1) can replay
32
+ * stored issues through the identical reporter without re-linting.
33
+ */
34
+ export declare function reportPatchLintOutcome(issues: PatchLintIssue[], skipLint?: boolean): void;
28
35
  /**
29
36
  * Resolves patch metadata interactively or from flags, with shared validation.
30
37
  * @param options - Export command options