@hominis/fireforge 0.34.2 → 0.36.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 (360) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +43 -5
  3. package/dist/bin/fireforge.d.ts +4 -0
  4. package/dist/bin/fireforge.js +25 -6
  5. package/dist/src/cli.js +22 -5
  6. package/dist/src/commands/bootstrap-checks.js +0 -1
  7. package/dist/src/commands/bootstrap.d.ts +2 -1
  8. package/dist/src/commands/bootstrap.js +16 -7
  9. package/dist/src/commands/build.js +17 -3
  10. package/dist/src/commands/config.js +30 -10
  11. package/dist/src/commands/discard.js +24 -5
  12. package/dist/src/commands/doctor/post-rebase-audit.js +0 -1
  13. package/dist/src/commands/doctor-check-core.js +0 -1
  14. package/dist/src/commands/doctor-external-toolchains.d.ts +3 -0
  15. package/dist/src/commands/doctor-external-toolchains.js +54 -0
  16. package/dist/src/commands/doctor-furnace-jar.js +0 -1
  17. package/dist/src/commands/doctor-furnace-manifest-sync.js +0 -1
  18. package/dist/src/commands/doctor-furnace.js +2 -2
  19. package/dist/src/commands/doctor-working-tree.js +0 -1
  20. package/dist/src/commands/doctor.js +4 -1
  21. package/dist/src/commands/download.js +51 -15
  22. package/dist/src/commands/export-all.js +5 -3
  23. package/dist/src/commands/export-flow.js +12 -7
  24. package/dist/src/commands/export-placement-gate.js +0 -1
  25. package/dist/src/commands/export-placement-policy.js +0 -1
  26. package/dist/src/commands/export-shared.d.ts +5 -1
  27. package/dist/src/commands/export-shared.js +18 -6
  28. package/dist/src/commands/export.js +15 -10
  29. package/dist/src/commands/furnace/apply.js +22 -6
  30. package/dist/src/commands/furnace/chrome-doc-remove.js +0 -1
  31. package/dist/src/commands/furnace/chrome-doc-templates.js +0 -1
  32. package/dist/src/commands/furnace/chrome-doc-tests.js +0 -1
  33. package/dist/src/commands/furnace/chrome-doc.js +0 -1
  34. package/dist/src/commands/furnace/create-browser-test.js +0 -1
  35. package/dist/src/commands/furnace/create-dry-run.js +0 -1
  36. package/dist/src/commands/furnace/create-features.js +0 -1
  37. package/dist/src/commands/furnace/create-mochikit.js +0 -1
  38. package/dist/src/commands/furnace/create-readback.js +0 -1
  39. package/dist/src/commands/furnace/create-templates.js +0 -1
  40. package/dist/src/commands/furnace/create-validation.js +0 -1
  41. package/dist/src/commands/furnace/create-xpcshell.js +0 -1
  42. package/dist/src/commands/furnace/create.js +0 -1
  43. package/dist/src/commands/furnace/deploy.js +7 -83
  44. package/dist/src/commands/furnace/diff.js +0 -1
  45. package/dist/src/commands/furnace/index.js +59 -25
  46. package/dist/src/commands/furnace/init.js +0 -1
  47. package/dist/src/commands/furnace/list.js +0 -1
  48. package/dist/src/commands/furnace/override.js +0 -1
  49. package/dist/src/commands/furnace/preview.js +3 -3
  50. package/dist/src/commands/furnace/refresh.js +0 -1
  51. package/dist/src/commands/furnace/remove-state.d.ts +15 -0
  52. package/dist/src/commands/furnace/remove-state.js +47 -1
  53. package/dist/src/commands/furnace/remove.js +3 -9
  54. package/dist/src/commands/furnace/rename-browser-test.js +1 -5
  55. package/dist/src/commands/furnace/rename-helpers.d.ts +6 -0
  56. package/dist/src/commands/furnace/rename-helpers.js +30 -1
  57. package/dist/src/commands/furnace/rename-xpcshell.js +7 -6
  58. package/dist/src/commands/furnace/rename.js +15 -36
  59. package/dist/src/commands/furnace/scan.js +0 -1
  60. package/dist/src/commands/furnace/status.js +0 -1
  61. package/dist/src/commands/furnace/sync.js +76 -4
  62. package/dist/src/commands/furnace/validate.js +0 -1
  63. package/dist/src/commands/furnace/validation-output.js +0 -1
  64. package/dist/src/commands/import.js +35 -9
  65. package/dist/src/commands/lint-per-patch.js +19 -7
  66. package/dist/src/commands/lint.js +13 -3
  67. package/dist/src/commands/manifest.js +1 -2
  68. package/dist/src/commands/package.js +2 -1
  69. package/dist/src/commands/patch/compact.js +1 -1
  70. package/dist/src/commands/patch/delete.js +1 -1
  71. package/dist/src/commands/patch/index.js +1 -1
  72. package/dist/src/commands/patch/lint-ignore.js +21 -6
  73. package/dist/src/commands/patch/move-files.js +1 -1
  74. package/dist/src/commands/patch/patch-context.js +0 -1
  75. package/dist/src/commands/patch/rename.js +4 -4
  76. package/dist/src/commands/patch/reorder.js +3 -4
  77. package/dist/src/commands/patch/split-plan.js +2 -1
  78. package/dist/src/commands/patch/split.js +4 -4
  79. package/dist/src/commands/patch/staged-dependency.js +1 -1
  80. package/dist/src/commands/patch/tier.js +18 -5
  81. package/dist/src/commands/re-export-bulk-scan.js +0 -1
  82. package/dist/src/commands/re-export-files.js +1 -1
  83. package/dist/src/commands/re-export-options.js +0 -1
  84. package/dist/src/commands/re-export-scan.js +0 -1
  85. package/dist/src/commands/re-export.js +6 -4
  86. package/dist/src/commands/rebase/abort.js +2 -9
  87. package/dist/src/commands/rebase/confirm.js +0 -1
  88. package/dist/src/commands/rebase/conflict-summary.js +0 -1
  89. package/dist/src/commands/rebase/continue.js +0 -1
  90. package/dist/src/commands/rebase/index.d.ts +1 -1
  91. package/dist/src/commands/rebase/index.js +10 -15
  92. package/dist/src/commands/rebase/patch-loop.js +38 -7
  93. package/dist/src/commands/rebase/summary.js +2 -3
  94. package/dist/src/commands/rebase.js +0 -1
  95. package/dist/src/commands/register.js +1 -1
  96. package/dist/src/commands/reset.js +16 -9
  97. package/dist/src/commands/resolve.js +2 -2
  98. package/dist/src/commands/run.js +18 -6
  99. package/dist/src/commands/setup-support.js +0 -1
  100. package/dist/src/commands/setup.js +1 -2
  101. package/dist/src/commands/source.js +1 -2
  102. package/dist/src/commands/status-output.js +1 -12
  103. package/dist/src/commands/status.js +147 -133
  104. package/dist/src/commands/test-appdir.js +0 -1
  105. package/dist/src/commands/test-diagnose.js +18 -2
  106. package/dist/src/commands/test-modes.d.ts +15 -0
  107. package/dist/src/commands/test-modes.js +61 -0
  108. package/dist/src/commands/test-register.d.ts +4 -0
  109. package/dist/src/commands/test-register.js +64 -0
  110. package/dist/src/commands/test-run.d.ts +23 -7
  111. package/dist/src/commands/test-run.js +15 -14
  112. package/dist/src/commands/test.d.ts +0 -4
  113. package/dist/src/commands/test.js +95 -56
  114. package/dist/src/commands/token-coverage.js +0 -1
  115. package/dist/src/commands/token.js +1 -2
  116. package/dist/src/commands/typecheck.js +1 -1
  117. package/dist/src/commands/verify.js +3 -9
  118. package/dist/src/commands/watch.js +5 -1
  119. package/dist/src/commands/wire.js +1 -1
  120. package/dist/src/core/ast-utils.js +0 -1
  121. package/dist/src/core/brand-validation.js +0 -1
  122. package/dist/src/core/branding.js +0 -1
  123. package/dist/src/core/browser-wire.js +0 -1
  124. package/dist/src/core/build-audit-platform.js +0 -1
  125. package/dist/src/core/build-audit-registration.js +0 -1
  126. package/dist/src/core/build-audit-resolve.js +0 -1
  127. package/dist/src/core/build-audit-transforms.js +0 -1
  128. package/dist/src/core/build-audit.js +0 -1
  129. package/dist/src/core/build-baseline-types.js +0 -1
  130. package/dist/src/core/build-baseline.js +0 -1
  131. package/dist/src/core/build-prepare.js +21 -7
  132. package/dist/src/core/config-mutate.js +0 -1
  133. package/dist/src/core/config-paths.d.ts +2 -2
  134. package/dist/src/core/config-paths.js +6 -1
  135. package/dist/src/core/config-state.js +0 -1
  136. package/dist/src/core/config-validate-patch-policy.js +0 -1
  137. package/dist/src/core/config-validate-test-toolchains.d.ts +8 -0
  138. package/dist/src/core/config-validate-test-toolchains.js +105 -0
  139. package/dist/src/core/config-validate.js +3 -1
  140. package/dist/src/core/config.js +0 -1
  141. package/dist/src/core/destructive.js +0 -1
  142. package/dist/src/core/diff-hunks.js +13 -7
  143. package/dist/src/core/engine-changes.js +0 -1
  144. package/dist/src/core/engine-session-lock.d.ts +12 -0
  145. package/dist/src/core/engine-session-lock.js +45 -0
  146. package/dist/src/core/file-lock.d.ts +8 -0
  147. package/dist/src/core/file-lock.js +157 -48
  148. package/dist/src/core/firefox-archive.d.ts +4 -0
  149. package/dist/src/core/firefox-archive.js +6 -1
  150. package/dist/src/core/firefox-cache.js +67 -8
  151. package/dist/src/core/firefox-download.js +0 -1
  152. package/dist/src/core/firefox-extract.d.ts +21 -1
  153. package/dist/src/core/firefox-extract.js +178 -9
  154. package/dist/src/core/firefox-ignorefile.js +0 -1
  155. package/dist/src/core/firefox.d.ts +12 -0
  156. package/dist/src/core/firefox.js +64 -2
  157. package/dist/src/core/furnace-apply-dry-run.js +0 -1
  158. package/dist/src/core/furnace-apply-ftl.d.ts +7 -3
  159. package/dist/src/core/furnace-apply-ftl.js +10 -5
  160. package/dist/src/core/furnace-apply-helpers.js +0 -1
  161. package/dist/src/core/furnace-apply-output.js +2 -2
  162. package/dist/src/core/furnace-apply.d.ts +7 -1
  163. package/dist/src/core/furnace-apply.js +47 -13
  164. package/dist/src/core/furnace-checksum-utils.js +0 -1
  165. package/dist/src/core/furnace-config-array-utils.js +0 -1
  166. package/dist/src/core/furnace-config-custom.js +0 -1
  167. package/dist/src/core/furnace-config-order.js +0 -1
  168. package/dist/src/core/furnace-config-tokens.js +0 -1
  169. package/dist/src/core/furnace-config-validate.js +0 -1
  170. package/dist/src/core/furnace-config.d.ts +11 -0
  171. package/dist/src/core/furnace-config.js +37 -1
  172. package/dist/src/core/furnace-constants.js +0 -1
  173. package/dist/src/core/furnace-css-fragments.js +0 -1
  174. package/dist/src/core/furnace-graph-utils.js +0 -1
  175. package/dist/src/core/furnace-jsconfig.d.ts +2 -3
  176. package/dist/src/core/furnace-jsconfig.js +25 -18
  177. package/dist/src/core/furnace-marker.js +0 -1
  178. package/dist/src/core/furnace-operation.js +0 -1
  179. package/dist/src/core/furnace-refresh.js +0 -1
  180. package/dist/src/core/furnace-registration-ast.d.ts +14 -0
  181. package/dist/src/core/furnace-registration-ast.js +1 -2
  182. package/dist/src/core/furnace-registration-remove.js +0 -1
  183. package/dist/src/core/furnace-registration-validate.js +1 -5
  184. package/dist/src/core/furnace-registration.js +0 -1
  185. package/dist/src/core/furnace-rollback.js +0 -1
  186. package/dist/src/core/furnace-scanner.js +0 -1
  187. package/dist/src/core/furnace-staleness.js +0 -1
  188. package/dist/src/core/furnace-state-persist.d.ts +72 -0
  189. package/dist/src/core/furnace-state-persist.js +118 -0
  190. package/dist/src/core/furnace-step-errors.d.ts +23 -0
  191. package/dist/src/core/furnace-step-errors.js +25 -0
  192. package/dist/src/core/furnace-stories.js +0 -1
  193. package/dist/src/core/furnace-validate-accessibility.js +0 -1
  194. package/dist/src/core/furnace-validate-checks.js +0 -1
  195. package/dist/src/core/furnace-validate-compatibility.js +0 -1
  196. package/dist/src/core/furnace-validate-helpers.js +0 -1
  197. package/dist/src/core/furnace-validate-registration.js +7 -7
  198. package/dist/src/core/furnace-validate-structure.d.ts +8 -0
  199. package/dist/src/core/furnace-validate-structure.js +11 -2
  200. package/dist/src/core/furnace-validate.js +0 -1
  201. package/dist/src/core/furnace-version-drift.d.ts +6 -3
  202. package/dist/src/core/furnace-version-drift.js +6 -4
  203. package/dist/src/core/git-base.js +0 -1
  204. package/dist/src/core/git-diff.js +0 -1
  205. package/dist/src/core/git-file-ops.js +0 -1
  206. package/dist/src/core/git-status.d.ts +13 -0
  207. package/dist/src/core/git-status.js +32 -2
  208. package/dist/src/core/git.d.ts +10 -1
  209. package/dist/src/core/git.js +65 -8
  210. package/dist/src/core/license-headers.d.ts +16 -0
  211. package/dist/src/core/license-headers.js +19 -1
  212. package/dist/src/core/lint-cache.js +0 -1
  213. package/dist/src/core/lint-projection.js +0 -1
  214. package/dist/src/core/mach-build-artifacts.js +0 -1
  215. package/dist/src/core/mach-error-hints.js +27 -1
  216. package/dist/src/core/mach-mozconfig.js +0 -1
  217. package/dist/src/core/mach-python.js +0 -1
  218. package/dist/src/core/mach-resource-shim.js +0 -1
  219. package/dist/src/core/mach.js +0 -1
  220. package/dist/src/core/manifest-helpers.js +0 -1
  221. package/dist/src/core/manifest-register.js +0 -1
  222. package/dist/src/core/manifest-rules.js +0 -1
  223. package/dist/src/core/manifest-tokenizers.js +0 -1
  224. package/dist/src/core/marionette-port.d.ts +8 -0
  225. package/dist/src/core/marionette-port.js +34 -3
  226. package/dist/src/core/marionette-preflight.js +0 -1
  227. package/dist/src/core/markdown-table.js +0 -1
  228. package/dist/src/core/ownership-table.js +2 -9
  229. package/dist/src/core/parser-fallback.js +0 -1
  230. package/dist/src/core/patch-apply-fuzz.d.ts +27 -9
  231. package/dist/src/core/patch-apply-fuzz.js +71 -34
  232. package/dist/src/core/patch-apply.d.ts +30 -0
  233. package/dist/src/core/patch-apply.js +152 -39
  234. package/dist/src/core/patch-artifact-normalize.d.ts +18 -5
  235. package/dist/src/core/patch-artifact-normalize.js +19 -15
  236. package/dist/src/core/patch-export-coverage.js +0 -1
  237. package/dist/src/core/patch-export-metadata.js +0 -1
  238. package/dist/src/core/patch-export-update.js +2 -3
  239. package/dist/src/core/patch-export.d.ts +2 -6
  240. package/dist/src/core/patch-export.js +24 -48
  241. package/dist/src/core/patch-files.js +0 -1
  242. package/dist/src/core/patch-identifier-suggest.js +0 -1
  243. package/dist/src/core/patch-lint-checkjs.js +0 -1
  244. package/dist/src/core/patch-lint-chrome-jsdoc.js +0 -1
  245. package/dist/src/core/patch-lint-cross.js +0 -1
  246. package/dist/src/core/patch-lint-css.js +0 -1
  247. package/dist/src/core/patch-lint-diff-tag.js +0 -1
  248. package/dist/src/core/patch-lint-diff.d.ts +2 -1
  249. package/dist/src/core/patch-lint-diff.js +17 -32
  250. package/dist/src/core/patch-lint-imports.js +0 -1
  251. package/dist/src/core/patch-lint-jsdoc.js +0 -1
  252. package/dist/src/core/patch-lint-observer.js +0 -1
  253. package/dist/src/core/patch-lint-ownership.js +0 -1
  254. package/dist/src/core/patch-lint-reexports.js +0 -1
  255. package/dist/src/core/patch-lint.d.ts +37 -2
  256. package/dist/src/core/patch-lint.js +48 -24
  257. package/dist/src/core/patch-lock.js +0 -1
  258. package/dist/src/core/patch-manifest-consistency.js +9 -1
  259. package/dist/src/core/patch-manifest-io.d.ts +16 -0
  260. package/dist/src/core/patch-manifest-io.js +37 -12
  261. package/dist/src/core/patch-manifest-query.d.ts +6 -0
  262. package/dist/src/core/patch-manifest-query.js +27 -12
  263. package/dist/src/core/patch-manifest-resolve.js +0 -1
  264. package/dist/src/core/patch-manifest-validate.js +0 -1
  265. package/dist/src/core/patch-manifest.d.ts +1 -1
  266. package/dist/src/core/patch-manifest.js +1 -2
  267. package/dist/src/core/patch-parse.d.ts +59 -0
  268. package/dist/src/core/patch-parse.js +209 -92
  269. package/dist/src/core/patch-policy.js +0 -1
  270. package/dist/src/core/patch-registration-refs.js +16 -26
  271. package/dist/src/core/patch-source-metadata.js +0 -1
  272. package/dist/src/core/patch-transform.d.ts +4 -0
  273. package/dist/src/core/patch-transform.js +30 -38
  274. package/dist/src/core/rebase-session.js +0 -1
  275. package/dist/src/core/register-browser-content.js +0 -1
  276. package/dist/src/core/register-module.js +0 -1
  277. package/dist/src/core/register-result.js +0 -1
  278. package/dist/src/core/register-scaffold.js +0 -1
  279. package/dist/src/core/register-shared-css.js +0 -1
  280. package/dist/src/core/register-test-manifest.js +0 -1
  281. package/dist/src/core/register-xpcshell-test.js +0 -1
  282. package/dist/src/core/shared-ftl.js +0 -1
  283. package/dist/src/core/signal-critical.js +0 -1
  284. package/dist/src/core/smoke-patterns.js +0 -1
  285. package/dist/src/core/state-file.js +0 -1
  286. package/dist/src/core/status-classify.d.ts +16 -0
  287. package/dist/src/core/status-classify.js +24 -16
  288. package/dist/src/core/test-harness-crash.d.ts +64 -7
  289. package/dist/src/core/test-harness-crash.js +196 -15
  290. package/dist/src/core/test-harness-output.js +0 -1
  291. package/dist/src/core/test-path-scope.d.ts +62 -0
  292. package/dist/src/core/test-path-scope.js +148 -0
  293. package/dist/src/core/test-stale-check.js +0 -1
  294. package/dist/src/core/test-stale-symlink.js +0 -1
  295. package/dist/src/core/test-xpcshell-retry.js +0 -1
  296. package/dist/src/core/token-coverage.js +0 -1
  297. package/dist/src/core/token-dark-mode.js +0 -1
  298. package/dist/src/core/token-docs.js +0 -1
  299. package/dist/src/core/token-manager.js +0 -1
  300. package/dist/src/core/token-scaffold.js +0 -1
  301. package/dist/src/core/token-variant.js +0 -1
  302. package/dist/src/core/toolchain-preflight.d.ts +85 -0
  303. package/dist/src/core/toolchain-preflight.js +256 -0
  304. package/dist/src/core/typecheck-shim.d.ts +4 -1
  305. package/dist/src/core/typecheck-shim.js +61 -18
  306. package/dist/src/core/typecheck.js +0 -1
  307. package/dist/src/core/wire-destroy.js +0 -1
  308. package/dist/src/core/wire-dom-fragment.js +0 -1
  309. package/dist/src/core/wire-init.js +0 -1
  310. package/dist/src/core/wire-subscript.js +0 -1
  311. package/dist/src/core/wire-targets.js +0 -1
  312. package/dist/src/core/wire-utils.js +0 -1
  313. package/dist/src/core/xpcshell-appdir.js +1 -4
  314. package/dist/src/errors/base.d.ts +29 -0
  315. package/dist/src/errors/base.js +35 -1
  316. package/dist/src/errors/build.js +0 -1
  317. package/dist/src/errors/codes.d.ts +6 -0
  318. package/dist/src/errors/codes.js +6 -1
  319. package/dist/src/errors/config.js +0 -1
  320. package/dist/src/errors/download.js +2 -1
  321. package/dist/src/errors/furnace.d.ts +11 -1
  322. package/dist/src/errors/furnace.js +6 -5
  323. package/dist/src/errors/git.js +0 -1
  324. package/dist/src/errors/patch.d.ts +17 -0
  325. package/dist/src/errors/patch.js +29 -1
  326. package/dist/src/errors/rebase.js +0 -1
  327. package/dist/src/errors/run.js +1 -1
  328. package/dist/src/index.js +0 -1
  329. package/dist/src/types/cli.js +0 -1
  330. package/dist/src/types/commands/index.js +0 -1
  331. package/dist/src/types/commands/options.d.ts +20 -5
  332. package/dist/src/types/commands/options.js +0 -1
  333. package/dist/src/types/commands/patches.d.ts +8 -0
  334. package/dist/src/types/commands/patches.js +0 -1
  335. package/dist/src/types/commands/project.d.ts +11 -2
  336. package/dist/src/types/commands/project.js +0 -1
  337. package/dist/src/types/config.d.ts +32 -0
  338. package/dist/src/types/config.js +0 -1
  339. package/dist/src/types/furnace.d.ts +10 -0
  340. package/dist/src/types/furnace.js +0 -1
  341. package/dist/src/types/index.js +0 -1
  342. package/dist/src/types/typecheck.js +0 -1
  343. package/dist/src/utils/elapsed.js +0 -1
  344. package/dist/src/utils/errors.js +0 -1
  345. package/dist/src/utils/fs.js +37 -5
  346. package/dist/src/utils/logger.d.ts +7 -0
  347. package/dist/src/utils/logger.js +85 -3
  348. package/dist/src/utils/options.d.ts +15 -0
  349. package/dist/src/utils/options.js +25 -1
  350. package/dist/src/utils/package-root.js +11 -4
  351. package/dist/src/utils/parse.js +0 -1
  352. package/dist/src/utils/paths.js +0 -1
  353. package/dist/src/utils/platform.js +0 -1
  354. package/dist/src/utils/process.d.ts +33 -4
  355. package/dist/src/utils/process.js +183 -28
  356. package/dist/src/utils/regex.d.ts +13 -3
  357. package/dist/src/utils/regex.js +53 -4
  358. package/dist/src/utils/validation.d.ts +0 -12
  359. package/dist/src/utils/validation.js +13 -26
  360. package/package.json +8 -7
@@ -48,6 +48,14 @@ export declare function probeMarionettePort(port?: number): Promise<MarionettePo
48
48
  export declare function assertMarionettePortAvailable(port?: number, options?: {
49
49
  binaryName?: string;
50
50
  }): Promise<void>;
51
+ /**
52
+ * Ensures the Marionette port is usable, optionally terminating a stale
53
+ * Firefox-family browser holder first. Unrelated listeners still fail.
54
+ */
55
+ export declare function ensureMarionettePortAvailable(port?: number, options?: {
56
+ binaryName?: string;
57
+ killStaleBrowser?: boolean;
58
+ }): Promise<void>;
51
59
  /**
52
60
  * Extracts a `--marionette-port=N` (or `--marionette-port N`) argument from
53
61
  * a list of forwarded mach args, if present. Used so an operator passing the
@@ -56,7 +56,7 @@ const BROWSER_BASENAMES = new Set([
56
56
  * so a fork that ships under a custom name (e.g.
57
57
  * `mybrowser-nightly`) is still recognised as a browser.
58
58
  */
59
- function isBrowserHolder(holder, binaryName) {
59
+ function isBrowserMarionettePortHolder(holder, binaryName) {
60
60
  if (/\s-marionette(?:\s|$)/.test(holder.commandLine)) {
61
61
  return true;
62
62
  }
@@ -195,7 +195,7 @@ export async function assertMarionettePortAvailable(port = DEFAULT_MARIONETTE_PO
195
195
  if (!probe.inUse || !probe.holder)
196
196
  return;
197
197
  const holder = probe.holder;
198
- if (isBrowserHolder(holder, options.binaryName)) {
198
+ if (isBrowserMarionettePortHolder(holder, options.binaryName)) {
199
199
  const killHint = process.platform === 'win32'
200
200
  ? `Stop-Process -Id ${holder.pid} -Force`
201
201
  : `kill ${holder.pid} # or "kill -9 ${holder.pid}" if it doesn't exit`;
@@ -212,6 +212,38 @@ export async function assertMarionettePortAvailable(port = DEFAULT_MARIONETTE_PO
212
212
  throw new GeneralError(`Marionette port ${port} is already in use by ${holder.command} (PID ${holder.pid}). ` +
213
213
  `This is not a FireForge-launched browser; stop the holder process or free the port before rerunning.`);
214
214
  }
215
+ /**
216
+ * Ensures the Marionette port is usable, optionally terminating a stale
217
+ * Firefox-family browser holder first. Unrelated listeners still fail.
218
+ */
219
+ export async function ensureMarionettePortAvailable(port = DEFAULT_MARIONETTE_PORT, options = {}) {
220
+ const probe = await probeMarionettePort(port);
221
+ if (!probe.inUse || !probe.holder)
222
+ return;
223
+ const holder = probe.holder;
224
+ const isBrowser = isBrowserMarionettePortHolder(holder, options.binaryName);
225
+ if (!options.killStaleBrowser || !isBrowser) {
226
+ await assertMarionettePortAvailable(port, options);
227
+ return;
228
+ }
229
+ try {
230
+ if (process.platform === 'win32') {
231
+ await exec('powershell.exe', [
232
+ '-NoProfile',
233
+ '-NonInteractive',
234
+ '-Command',
235
+ `Stop-Process -Id ${holder.pid} -Force`,
236
+ ]);
237
+ }
238
+ else {
239
+ process.kill(holder.pid, 'SIGTERM');
240
+ }
241
+ }
242
+ catch (error) {
243
+ throw new GeneralError(`Marionette port ${port} is held by stale browser ${holder.command} (PID ${holder.pid}), ` +
244
+ `but FireForge could not terminate it: ${error instanceof Error ? error.message : String(error)}`);
245
+ }
246
+ }
215
247
  /**
216
248
  * Extracts a `--marionette-port=N` (or `--marionette-port N`) argument from
217
249
  * a list of forwarded mach args, if present. Used so an operator passing the
@@ -350,4 +382,3 @@ export function isMarionetteFlavor(testPaths, machArgs) {
350
382
  }
351
383
  return false;
352
384
  }
353
- //# sourceMappingURL=marionette-port.js.map
@@ -307,4 +307,3 @@ export function formatMarionettePreflightLine(result) {
307
307
  const status = result.ok ? 'PASS' : 'FAIL';
308
308
  return `Marionette preflight: ${status} (${result.durationMs}ms) — ${result.detail}`;
309
309
  }
310
- //# sourceMappingURL=marionette-preflight.js.map
@@ -263,4 +263,3 @@ export function updateCellByKey(table, keyColumn, keyValue, targetColumn, newVal
263
263
  }
264
264
  return false;
265
265
  }
266
- //# sourceMappingURL=markdown-table.js.map
@@ -1,4 +1,5 @@
1
1
  import { info } from '../utils/logger.js';
2
+ import { buildPatchClaims } from './status-classify.js';
2
3
  /**
3
4
  * Builds the flat ownership table from the manifest, worktree status, and
4
5
  * the duplicate-new-file-creation map produced by cross-patch lint.
@@ -29,14 +30,7 @@ import { info } from '../utils/logger.js';
29
30
  * paths with a `.length > 1` owner list become duplicate-create conflicts
30
31
  */
31
32
  export function buildOwnershipTable(manifestPatches, worktreeFiles, newFileCreatorsByPath, classifications = new Map()) {
32
- const ownersByPath = new Map();
33
- for (const patch of manifestPatches) {
34
- for (const file of patch.filesAffected) {
35
- const existing = ownersByPath.get(file) ?? [];
36
- existing.push(patch.filename);
37
- ownersByPath.set(file, existing);
38
- }
39
- }
33
+ const ownersByPath = buildPatchClaims(manifestPatches);
40
34
  // Merge duplicate-new-file-creation findings. The structured helper
41
35
  // returns all new-file paths, so we filter to the ones with more
42
36
  // than one creator. Paths are added to the table even when no patch
@@ -165,4 +159,3 @@ export function renderOwnershipTable(rows) {
165
159
  info(`| ${pad(row.path, pathWidth)} | ${pad(ownerCell, ownerWidth)} | ${pad(stateCell, stateWidth)} |`);
166
160
  }
167
161
  }
168
- //# sourceMappingURL=ownership-table.js.map
@@ -40,4 +40,3 @@ export function withParserFallback(primary, fallback, context, rethrowIf) {
40
40
  return { value: fallback(), usedFallback: true, fallbackReason: reason };
41
41
  }
42
42
  }
43
- //# sourceMappingURL=parser-fallback.js.map
@@ -1,14 +1,27 @@
1
1
  /**
2
- * Fuzzy patch application with escalating fuzz factors.
2
+ * Fuzz-like patch application with escalating context reduction.
3
3
  *
4
- * Tries an exact `git apply` first, then retries with increasing `--fuzz=N`
5
- * values. If all fuzz levels fail, falls through to `git apply --reject`
6
- * so the user gets `.rej` files for manual resolution.
4
+ * Tries an exact `git apply` first, then retries with git's real
5
+ * drift-tolerance knob: `-C<n>` (reduced context matching). `git apply`
6
+ * has NO `--fuzz` option that flag belongs to GNU `patch(1)`. The
7
+ * original implementation passed `--fuzz=N`, which git rejects with a
8
+ * usage error (exit 129) that the escalation loop swallowed, so every
9
+ * "fuzzy" attempt silently failed and patches that would have applied
10
+ * with context drift were reported as hard conflicts (2026-07-05 review,
11
+ * finding H1; verified against git 2.50). If all context-reduction levels
12
+ * fail, falls through to `git apply --reject` so the user gets `.rej`
13
+ * files for manual resolution.
14
+ *
15
+ * Semantics note: GNU fuzz *fuzzily matches* context; `-C<n>` *ignores*
16
+ * outer context lines instead. Step k maps to `-C(3-k)` (git's default
17
+ * context width is 3), so the final step `-C0` matches on line positions
18
+ * and -/+ content alone — the rough analogue of GNU `--fuzz=3` on a
19
+ * 3-context patch.
7
20
  */
8
21
  export interface FuzzyApplyResult {
9
22
  /** Whether the patch was applied successfully. */
10
23
  success: boolean;
11
- /** Fuzz factor that succeeded (0 = exact match). */
24
+ /** Context-reduction steps taken (0 = exact match, k = applied with `-C(3-k)`). */
12
25
  fuzzFactor: number;
13
26
  /** Error description when `success` is false. */
14
27
  error?: string;
@@ -16,14 +29,19 @@ export interface FuzzyApplyResult {
16
29
  rejectFiles?: string[];
17
30
  }
18
31
  /**
19
- * Attempts to apply a patch with escalating fuzz factors.
32
+ * Attempts to apply a patch with escalating context reduction.
20
33
  *
21
- * 1. `git apply --check` at fuzz 0 maxFuzz
22
- * 2. `git apply --fuzz=N` at the first passing level
34
+ * 1. `git apply --check` exact, then with `-C2`, `-C1`, `-C0`
35
+ * 2. `git apply` (same flags) at the first passing level
23
36
  * 3. Fall through to `git apply --reject` if nothing succeeds
24
37
  *
25
38
  * @param patchPath - Absolute path to the `.patch` file
26
39
  * @param engineDir - Working directory (engine/)
27
- * @param maxFuzz - Maximum fuzz factor to try (default 3)
40
+ * @param maxFuzz - Maximum context-reduction steps to try (default 3,
41
+ * 0 = exact only). Values above 3 are capped: git cannot reduce below
42
+ * `-C0`.
43
+ * @throws InvalidArgumentError when `maxFuzz` is not a non-negative integer —
44
+ * a NaN or negative value would silently skip every apply attempt
45
+ * (including the exact-match one) and fall straight through to `--reject`
28
46
  */
29
47
  export declare function applyPatchWithFuzz(patchPath: string, engineDir: string, maxFuzz?: number): Promise<FuzzyApplyResult>;
@@ -1,70 +1,107 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  /**
3
- * Fuzzy patch application with escalating fuzz factors.
3
+ * Fuzz-like patch application with escalating context reduction.
4
4
  *
5
- * Tries an exact `git apply` first, then retries with increasing `--fuzz=N`
6
- * values. If all fuzz levels fail, falls through to `git apply --reject`
7
- * so the user gets `.rej` files for manual resolution.
5
+ * Tries an exact `git apply` first, then retries with git's real
6
+ * drift-tolerance knob: `-C<n>` (reduced context matching). `git apply`
7
+ * has NO `--fuzz` option that flag belongs to GNU `patch(1)`. The
8
+ * original implementation passed `--fuzz=N`, which git rejects with a
9
+ * usage error (exit 129) that the escalation loop swallowed, so every
10
+ * "fuzzy" attempt silently failed and patches that would have applied
11
+ * with context drift were reported as hard conflicts (2026-07-05 review,
12
+ * finding H1; verified against git 2.50). If all context-reduction levels
13
+ * fail, falls through to `git apply --reject` so the user gets `.rej`
14
+ * files for manual resolution.
15
+ *
16
+ * Semantics note: GNU fuzz *fuzzily matches* context; `-C<n>` *ignores*
17
+ * outer context lines instead. Step k maps to `-C(3-k)` (git's default
18
+ * context width is 3), so the final step `-C0` matches on line positions
19
+ * and -/+ content alone — the rough analogue of GNU `--fuzz=3` on a
20
+ * 3-context patch.
8
21
  */
22
+ import { InvalidArgumentError } from '../errors/base.js';
9
23
  import { verbose } from '../utils/logger.js';
10
24
  import { exec } from '../utils/process.js';
11
25
  import { ensureGit } from './git-base.js';
26
+ /** Git's default diff context width — the ceiling for reduction steps. */
27
+ const GIT_DEFAULT_CONTEXT = 3;
12
28
  // ── Implementation ──
13
29
  /**
14
- * Attempts to apply a patch with escalating fuzz factors.
30
+ * Attempts to apply a patch with escalating context reduction.
15
31
  *
16
- * 1. `git apply --check` at fuzz 0 maxFuzz
17
- * 2. `git apply --fuzz=N` at the first passing level
32
+ * 1. `git apply --check` exact, then with `-C2`, `-C1`, `-C0`
33
+ * 2. `git apply` (same flags) at the first passing level
18
34
  * 3. Fall through to `git apply --reject` if nothing succeeds
19
35
  *
20
36
  * @param patchPath - Absolute path to the `.patch` file
21
37
  * @param engineDir - Working directory (engine/)
22
- * @param maxFuzz - Maximum fuzz factor to try (default 3)
38
+ * @param maxFuzz - Maximum context-reduction steps to try (default 3,
39
+ * 0 = exact only). Values above 3 are capped: git cannot reduce below
40
+ * `-C0`.
41
+ * @throws InvalidArgumentError when `maxFuzz` is not a non-negative integer —
42
+ * a NaN or negative value would silently skip every apply attempt
43
+ * (including the exact-match one) and fall straight through to `--reject`
23
44
  */
24
45
  export async function applyPatchWithFuzz(patchPath, engineDir, maxFuzz = 3) {
46
+ if (!Number.isInteger(maxFuzz) || maxFuzz < 0) {
47
+ throw new InvalidArgumentError(`maxFuzz must be a non-negative integer, got ${String(maxFuzz)}.`, 'maxFuzz');
48
+ }
25
49
  await ensureGit();
26
- // Try exact match first, then escalate
27
- for (let fuzz = 0; fuzz <= maxFuzz; fuzz++) {
28
- const fuzzArgs = fuzz > 0 ? [`--fuzz=${fuzz}`] : [];
29
- const check = await exec('git', ['apply', '--check', ...fuzzArgs, '--', patchPath], {
50
+ // Try exact match first, then escalate context reduction.
51
+ const maxSteps = Math.min(maxFuzz, GIT_DEFAULT_CONTEXT);
52
+ for (let step = 0; step <= maxSteps; step++) {
53
+ const contextArgs = step > 0 ? [`-C${String(GIT_DEFAULT_CONTEXT - step)}`] : [];
54
+ const check = await exec('git', ['apply', '--check', ...contextArgs, '--', patchPath], {
30
55
  cwd: engineDir,
31
56
  });
32
57
  if (check.exitCode === 0) {
33
58
  // --check passed: apply for real
34
- const apply = await exec('git', ['apply', ...fuzzArgs, '--', patchPath], {
59
+ const apply = await exec('git', ['apply', ...contextArgs, '--', patchPath], {
35
60
  cwd: engineDir,
36
61
  });
37
62
  if (apply.exitCode === 0) {
38
- if (fuzz > 0) {
39
- verbose(`Patch applied with fuzz=${fuzz}: ${patchPath}`);
63
+ if (step > 0) {
64
+ verbose(`Patch applied with reduced context (-C${String(GIT_DEFAULT_CONTEXT - step)}): ${patchPath}`);
40
65
  }
41
- return { success: true, fuzzFactor: fuzz };
66
+ return { success: true, fuzzFactor: step };
42
67
  }
43
- // Unlikely: --check passed but apply failed; fall through to next fuzz
44
- verbose(`git apply fuzz=${fuzz} --check passed but apply failed: ${apply.stderr.trim()}`);
68
+ // Unlikely: --check passed but apply failed; fall through to next step
69
+ verbose(`git apply -C${String(GIT_DEFAULT_CONTEXT - step)} --check passed but apply failed: ${apply.stderr.trim()}`);
45
70
  }
46
71
  }
47
- // All fuzz levels failed → generate .rej files for manual resolution
72
+ // All context-reduction levels failed → generate .rej files for manual resolution
48
73
  const rejectResult = await exec('git', ['apply', '--reject', '--', patchPath], {
49
74
  cwd: engineDir,
50
75
  });
51
- const errorMessage = rejectResult.stderr.trim() || 'All fuzz levels failed';
52
- // Extract .rej file paths from stderr
53
- const rejectFiles = [];
54
- for (const line of rejectResult.stderr.split('\n')) {
55
- const match = line.match(/Applying patch .* with (\d+) reject/);
56
- if (match)
57
- continue;
58
- const rejMatch = line.match(/Rejected hunk.*to (.+\.rej)/);
59
- if (rejMatch?.[1]) {
60
- rejectFiles.push(rejMatch[1]);
61
- }
62
- }
76
+ const errorMessage = rejectResult.stderr.trim() || 'All context-reduction levels failed';
63
77
  return {
64
78
  success: false,
65
- fuzzFactor: maxFuzz,
79
+ fuzzFactor: maxSteps,
66
80
  error: errorMessage,
67
- rejectFiles,
81
+ rejectFiles: extractRejectFiles(rejectResult.stderr),
68
82
  };
69
83
  }
70
- //# sourceMappingURL=patch-apply-fuzz.js.map
84
+ /**
85
+ * Extracts `.rej` file paths from `git apply --reject` stderr.
86
+ *
87
+ * Git's actual output shape (verified against git 2.50):
88
+ *
89
+ * Applying patch f.txt with 1 reject...
90
+ * Rejected hunk #1.
91
+ *
92
+ * The reject file is `<file>.rej` next to the target. The previous regex
93
+ * (`Rejected hunk.*to (.+\.rej)`) targeted GNU `patch(1)`'s "saving
94
+ * rejects to file X.rej" phrasing, which git never prints — so
95
+ * `rejectFiles` was always empty and the conflict-summary ".rej files
96
+ * created" hint never fired.
97
+ */
98
+ function extractRejectFiles(stderr) {
99
+ const rejectFiles = [];
100
+ for (const line of stderr.split('\n')) {
101
+ const match = /^Applying patch (.+) with \d+ rejects?\.\.\./.exec(line);
102
+ if (match?.[1]) {
103
+ rejectFiles.push(`${match[1]}.rej`);
104
+ }
105
+ }
106
+ return rejectFiles;
107
+ }
@@ -40,6 +40,28 @@ export interface ApplyPatchesOptions {
40
40
  */
41
41
  untilFilename?: string | undefined;
42
42
  }
43
+ /**
44
+ * Decides whether a patch filename matches an `--until` identifier.
45
+ *
46
+ * The identifier is one of three shapes and the three shapes must stay
47
+ * disjoint so the operator can reason about which patch they picked:
48
+ *
49
+ * 1. **Exact filename** — `005-foo.patch`. Matches only that filename.
50
+ * 2. **Filename without extension** — `005-foo`. Matches `005-foo.patch`.
51
+ * 3. **Bare numeric ordinal** — `5` or `005`. Matches the patch whose
52
+ * order prefix parses to the same integer (so `5` and `005` both
53
+ * match `005-foo.patch`, and `05` matches too because parseInt
54
+ * normalizes leading zeros).
55
+ *
56
+ * A purely-numeric identifier is treated **only** as an ordinal: it does
57
+ * not also match a filename that happens to literally equal the digits.
58
+ * That would require a patch literally named `5` or `005` — which would
59
+ * collide with the filename prefix anyway — and we prefer the explicit
60
+ * ordinal interpretation. The earlier form's `patchFilename === needle`
61
+ * short-circuit was kept behind the numeric gate so the match stays
62
+ * single-meaning per identifier.
63
+ */
64
+ export declare function matchesUntilFilename(patchFilename: string, needle: string): boolean;
43
65
  /**
44
66
  * Enhanced patch application with continue mode.
45
67
  * When continueOnFailure is false, rolls back all previously applied patches
@@ -58,3 +80,11 @@ export declare function applyPatchesWithContinue(patchesDir: string, engineDir:
58
80
  * @returns Content after all patches applied, or null if file doesn't exist
59
81
  */
60
82
  export declare function computePatchedContent(patchesDir: string, engineDir: string, filePath: string): Promise<string | null>;
83
+ /**
84
+ * Builds a batched variant of {@link computePatchedContent} that loads the
85
+ * manifest and discovers patch files ONCE for many lookups. The per-call
86
+ * function re-runs `loadPatchesManifest` + `discoverPatches` for every
87
+ * file — O(dirtyFiles × patches) redundant IO when classifying a broad
88
+ * engine edit session during `import`.
89
+ */
90
+ export declare function createPatchedContentComputer(patchesDir: string, engineDir: string): Promise<(filePath: string) => Promise<string | null>>;
@@ -3,8 +3,8 @@
3
3
  * Patch orchestration — coordinates patch discovery, application, and validation.
4
4
  * Pure parsing, content transformation, and lock management are in separate modules.
5
5
  */
6
- import { lstat } from 'node:fs/promises';
7
- import { join, resolve } from 'node:path';
6
+ import { lstat, readlink, realpath } from 'node:fs/promises';
7
+ import { basename, dirname, join, resolve, sep } from 'node:path';
8
8
  import { PatchError } from '../errors/patch.js';
9
9
  import { toError } from '../utils/errors.js';
10
10
  import { pathExists, readText, writeText } from '../utils/fs.js';
@@ -14,7 +14,7 @@ import { exec } from '../utils/process.js';
14
14
  import { applyPatchIdempotent, reversePatch } from './git.js';
15
15
  import { getFileContentFromHead } from './git-file-ops.js';
16
16
  import { discoverPatches } from './patch-files.js';
17
- import { findPatchesAffectingFile } from './patch-manifest.js';
17
+ import { findPatchesAffectingFile, loadPatchesManifest } from './patch-manifest.js';
18
18
  import { extractAffectedFiles, extractConflictingFiles, isNewFileInPatch } from './patch-parse.js';
19
19
  import { applyPatchToContent, extractNewFileContent } from './patch-transform.js';
20
20
  // Re-export from split modules so existing import sites continue working
@@ -27,16 +27,23 @@ export { applyPatchToContent, extractNewFileContent } from './patch-transform.js
27
27
  * Applies a single patch.
28
28
  * @param patch - Patch info
29
29
  * @param engineDir - Path to the engine directory
30
+ * @param protectedFiles - Files touched by patches already applied in the
31
+ * current run; the idempotent-recovery reset must not wipe them (see
32
+ * applyPatchIdempotent). Overlapping queues (`--allow-overlap`) share
33
+ * files between patches, and a later patch's recovery used to reset the
34
+ * shared file to HEAD — silently discarding the earlier patch's changes.
30
35
  * @returns Patch result
31
36
  */
32
- async function applySinglePatch(patch, engineDir) {
37
+ async function applySinglePatch(patch, engineDir, protectedFiles) {
33
38
  let patchContent = '';
34
39
  let affectedFiles = [];
35
40
  try {
36
41
  patchContent = await readText(patch.path);
37
42
  affectedFiles = extractAffectedFiles(patchContent);
38
43
  await validatePatchTargets(patch, affectedFiles, engineDir);
39
- await applyPatchIdempotent(patch.path, engineDir);
44
+ await applyPatchIdempotent(patch.path, engineDir, {
45
+ ...(protectedFiles ? { protectedFiles } : {}),
46
+ });
40
47
  return { patch, success: true };
41
48
  }
42
49
  catch (error) {
@@ -48,21 +55,39 @@ async function applySinglePatch(patch, engineDir) {
48
55
  let resolvedNewFiles = false;
49
56
  // Save original content for files we might overwrite, so we can restore on failure
50
57
  const savedContents = new Map();
51
- for (const file of affectedFiles) {
52
- if (isNewFileInPatch(patchContent, file)) {
53
- const targetPath = join(engineDir, file);
54
- if (await pathExists(targetPath)) {
55
- savedContents.set(file, await readText(targetPath));
56
- const content = await extractNewFileContent(patch.path, file);
57
- await writeText(targetPath, content);
58
- resolvedNewFiles = true;
58
+ try {
59
+ for (const file of affectedFiles) {
60
+ if (isNewFileInPatch(patchContent, file)) {
61
+ const targetPath = join(engineDir, file);
62
+ if (await pathExists(targetPath)) {
63
+ savedContents.set(file, await readText(targetPath));
64
+ const content = await extractNewFileContent(patch.path, file);
65
+ await writeText(targetPath, content);
66
+ resolvedNewFiles = true;
67
+ }
59
68
  }
60
69
  }
61
70
  }
71
+ catch (extractError) {
72
+ // extractNewFileContent threw for a LATER target (e.g. a binary
73
+ // section) after earlier targets were already overwritten — restore
74
+ // them before reporting failure, instead of letting the exception
75
+ // skip the restore loop entirely and crash the whole import.
76
+ for (const [file, originalContent] of savedContents) {
77
+ await writeText(join(engineDir, file), originalContent);
78
+ }
79
+ return { patch, success: false, error: toError(extractError).message };
80
+ }
62
81
  if (resolvedNewFiles) {
63
82
  try {
64
- await applyPatchIdempotent(patch.path, engineDir);
65
- return { patch, success: true, autoResolved: true };
83
+ await applyPatchIdempotent(patch.path, engineDir, {
84
+ ...(protectedFiles ? { protectedFiles } : {}),
85
+ });
86
+ // Keep the originals: rollbackPatches needs them if a LATER patch
87
+ // fails, since reverse-applying this new-file patch deletes the
88
+ // target and would otherwise permanently discard the pre-existing
89
+ // content (recoverable from git only if it was tracked at HEAD).
90
+ return { patch, success: true, autoResolved: true, autoResolvedOriginals: savedContents };
66
91
  }
67
92
  catch (retryError) {
68
93
  verbose(`Auto-resolved new-file retry failed for ${patch.filename}: ${toError(retryError).message}`);
@@ -76,7 +101,10 @@ async function applySinglePatch(patch, engineDir) {
76
101
  let errorMessage = applyError.message;
77
102
  try {
78
103
  // Use --reject to apply what we can and create .rej files for what we can't
79
- await applyPatchIdempotent(patch.path, engineDir, { reject: true });
104
+ await applyPatchIdempotent(patch.path, engineDir, {
105
+ reject: true,
106
+ ...(protectedFiles ? { protectedFiles } : {}),
107
+ });
80
108
  // If this somehow succeeds with --reject but failed without, it still shouldn't
81
109
  // happen because applyPatch first runs --check which would fail.
82
110
  // But if it did succeed, we should still return failure because manual fix is needed
@@ -105,6 +133,19 @@ async function rollbackPatches(results, engineDir) {
105
133
  catch (rollbackError) {
106
134
  verbose(`Failed to roll back ${result.patch.filename}: ${toError(rollbackError).message}`);
107
135
  }
136
+ // Reversing an auto-resolved new-file patch deletes the target; put
137
+ // back the pre-existing content the auto-resolve overwrote.
138
+ if (result.autoResolvedOriginals) {
139
+ for (const [file, originalContent] of result.autoResolvedOriginals) {
140
+ try {
141
+ await writeText(join(engineDir, file), originalContent);
142
+ verbose(`Restored pre-existing content of ${file} after rollback`);
143
+ }
144
+ catch (restoreError) {
145
+ verbose(`Could not restore ${file}: ${toError(restoreError).message}`);
146
+ }
147
+ }
148
+ }
108
149
  }
109
150
  }
110
151
  /**
@@ -117,9 +158,15 @@ async function rollbackPatches(results, engineDir) {
117
158
  export async function applyPatches(patchesDir, engineDir) {
118
159
  const patches = await discoverPatches(patchesDir);
119
160
  const results = [];
161
+ const appliedFiles = new Set();
120
162
  for (const patch of patches) {
121
- const result = await applySinglePatch(patch, engineDir);
163
+ const result = await applySinglePatch(patch, engineDir, appliedFiles);
122
164
  results.push(result);
165
+ if (result.success) {
166
+ for (const file of extractAffectedFiles(await readText(patch.path))) {
167
+ appliedFiles.add(file);
168
+ }
169
+ }
123
170
  // Stop on first failure and roll back all previously applied patches
124
171
  if (!result.success) {
125
172
  const succeeded = results.filter((r) => r.success);
@@ -159,33 +206,63 @@ export async function validatePatches(patchesDir, engineDir) {
159
206
  return { valid: errors.length === 0, errors };
160
207
  }
161
208
  async function validatePatchTargets(patch, affectedFiles, engineDir) {
209
+ // realpath the engine root once so containment is checked against the
210
+ // physical tree (the root itself may sit behind a symlink, e.g. /tmp on
211
+ // macOS). An unresolvable engine dir skips the symlink checks — a missing
212
+ // engine surfaces through `git apply --check` with a better message.
213
+ const engineRoot = engineDir ? await realpath(engineDir).catch(() => null) : null;
162
214
  for (const file of affectedFiles) {
163
215
  if (!isContainedRelativePath(file)) {
164
216
  throw new PatchError(`Patch targets a path outside engine/: ${file}`, patch.filename);
165
217
  }
166
- // When the engine directory is known, verify that existing target paths
167
- // are not symlinks pointing outside the engine tree. A crafted patch
168
- // could otherwise write through a symlink to an arbitrary location.
169
- if (engineDir) {
170
- const targetPath = join(engineDir, file);
171
- try {
172
- const stats = await lstat(targetPath);
173
- if (stats.isSymbolicLink()) {
174
- const realPath = resolve(engineDir, file);
175
- const resolvedEngine = resolve(engineDir);
176
- if (!realPath.startsWith(resolvedEngine + '/') && realPath !== resolvedEngine) {
177
- throw new PatchError(`Patch targets a symlink that resolves outside engine/: ${file}`, patch.filename);
178
- }
179
- }
180
- }
181
- catch (error) {
182
- // File doesn't exist yet (new file) or stat fails — skip check
183
- if (error instanceof PatchError)
184
- throw error;
218
+ // Verify that a write to the target would physically land inside the
219
+ // engine tree. A crafted patch could otherwise write through a symlink
220
+ // (the target itself, a dangling link, or a symlinked parent directory)
221
+ // to an arbitrary location.
222
+ if (engineDir && engineRoot) {
223
+ const destination = await resolvePatchWriteDestination(join(engineDir, file));
224
+ if (destination !== engineRoot && !destination.startsWith(engineRoot + sep)) {
225
+ throw new PatchError(`Patch targets a path that resolves outside engine/ (symlink escape): ${file}`, patch.filename);
185
226
  }
186
227
  }
187
228
  }
188
229
  }
230
+ /**
231
+ * Resolves where a write to `targetPath` would physically land, following
232
+ * symlinks on every path component that already exists.
233
+ *
234
+ * - Existing target: `realpath` resolves it fully.
235
+ * - Dangling symlink: `realpath` rejects it, but a write through the link
236
+ * would still be created at the link target, so the target is resolved
237
+ * against the link's (real) parent directory. The link target is taken
238
+ * textually — a loop or nested dangling link fails at apply time anyway.
239
+ * - Not-yet-existing file: resolved against the nearest existing ancestor's
240
+ * real path. Components below that ancestor do not exist, so they cannot
241
+ * be symlinks and are appended verbatim.
242
+ */
243
+ async function resolvePatchWriteDestination(targetPath) {
244
+ try {
245
+ return await realpath(targetPath);
246
+ }
247
+ catch {
248
+ // Fall through: the path (or its symlink target) does not exist.
249
+ }
250
+ try {
251
+ const stats = await lstat(targetPath);
252
+ if (stats.isSymbolicLink()) {
253
+ const linkTarget = await readlink(targetPath);
254
+ return resolve(await resolvePatchWriteDestination(dirname(targetPath)), linkTarget);
255
+ }
256
+ }
257
+ catch {
258
+ // The path component itself does not exist.
259
+ }
260
+ const parent = dirname(targetPath);
261
+ if (parent === targetPath) {
262
+ return targetPath;
263
+ }
264
+ return join(await resolvePatchWriteDestination(parent), basename(targetPath));
265
+ }
189
266
  /**
190
267
  * Decides whether a patch filename matches an `--until` identifier.
191
268
  *
@@ -207,7 +284,7 @@ async function validatePatchTargets(patch, affectedFiles, engineDir) {
207
284
  * short-circuit was kept behind the numeric gate so the match stays
208
285
  * single-meaning per identifier.
209
286
  */
210
- function matchesUntilFilename(patchFilename, needle) {
287
+ export function matchesUntilFilename(patchFilename, needle) {
211
288
  const isNumeric = /^\d+$/.test(needle);
212
289
  if (isNumeric) {
213
290
  const order = parseInt(needle, 10);
@@ -268,6 +345,7 @@ export async function applyPatchesWithContinue(patchesDir, engineDir, optionsOrC
268
345
  const succeeded = [];
269
346
  const failed = [];
270
347
  const skipped = [];
348
+ const appliedFiles = new Set();
271
349
  for (let i = 0; i < patches.length; i++) {
272
350
  const patch = patches[i];
273
351
  if (!patch)
@@ -279,9 +357,12 @@ export async function applyPatchesWithContinue(patchesDir, engineDir, optionsOrC
279
357
  skipped.push(patch);
280
358
  continue;
281
359
  }
282
- const result = await applySinglePatch(patch, engineDir);
360
+ const result = await applySinglePatch(patch, engineDir, appliedFiles);
283
361
  if (result.success) {
284
362
  succeeded.push(result);
363
+ for (const file of extractAffectedFiles(await readText(patch.path))) {
364
+ appliedFiles.add(file);
365
+ }
285
366
  }
286
367
  else {
287
368
  // Try to extract conflicting files from error message
@@ -332,4 +413,36 @@ export async function computePatchedContent(patchesDir, engineDir, filePath) {
332
413
  }
333
414
  return content;
334
415
  }
335
- //# sourceMappingURL=patch-apply.js.map
416
+ /**
417
+ * Builds a batched variant of {@link computePatchedContent} that loads the
418
+ * manifest and discovers patch files ONCE for many lookups. The per-call
419
+ * function re-runs `loadPatchesManifest` + `discoverPatches` for every
420
+ * file — O(dirtyFiles × patches) redundant IO when classifying a broad
421
+ * engine edit session during `import`.
422
+ */
423
+ export async function createPatchedContentComputer(patchesDir, engineDir) {
424
+ const manifest = await loadPatchesManifest(patchesDir);
425
+ const patches = await discoverPatches(patchesDir);
426
+ const patchByFilename = new Map(patches.map((p) => [p.filename, p]));
427
+ const affectingByFile = new Map();
428
+ for (const metadata of manifest?.patches ?? []) {
429
+ const patch = patchByFilename.get(metadata.filename);
430
+ if (!patch)
431
+ continue;
432
+ for (const file of metadata.filesAffected) {
433
+ const list = affectingByFile.get(file) ?? [];
434
+ list.push(patch);
435
+ affectingByFile.set(file, list);
436
+ }
437
+ }
438
+ for (const list of affectingByFile.values()) {
439
+ list.sort((a, b) => a.order - b.order);
440
+ }
441
+ return async (filePath) => {
442
+ let content = await getFileContentFromHead(engineDir, filePath);
443
+ for (const patch of affectingByFile.get(filePath) ?? []) {
444
+ content = await applyPatchToContent(content, patch.path, filePath);
445
+ }
446
+ return content;
447
+ };
448
+ }