@hominis/fireforge 0.49.2 → 0.50.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 (334) hide show
  1. package/CHANGELOG.md +40 -1
  2. package/README.md +1 -1
  3. package/dist/bin/fireforge.js +6 -0
  4. package/dist/build-info.json +4 -4
  5. package/dist/src/commands/build-prelock.d.ts +20 -0
  6. package/dist/src/commands/build-prelock.js +50 -0
  7. package/dist/src/commands/build.d.ts +3 -1
  8. package/dist/src/commands/build.js +13 -6
  9. package/dist/src/commands/check-report.d.ts +28 -0
  10. package/dist/src/commands/check-report.js +78 -0
  11. package/dist/src/commands/check-run.d.ts +28 -0
  12. package/dist/src/commands/check-run.js +104 -0
  13. package/dist/src/commands/check-steps.d.ts +67 -0
  14. package/dist/src/commands/check-steps.js +175 -0
  15. package/dist/src/commands/check.d.ts +32 -0
  16. package/dist/src/commands/check.js +160 -0
  17. package/dist/src/commands/config.js +5 -6
  18. package/dist/src/commands/doctor-engine-eslint.js +6 -1
  19. package/dist/src/commands/doctor-source-pin.js +4 -1
  20. package/dist/src/commands/export-adopt-check.d.ts +38 -0
  21. package/dist/src/commands/export-adopt-check.js +98 -0
  22. package/dist/src/commands/export-flow.js +29 -14
  23. package/dist/src/commands/export-placement-gate.d.ts +6 -2
  24. package/dist/src/commands/export-shared.d.ts +2 -1
  25. package/dist/src/commands/export-staged-registration.d.ts +46 -0
  26. package/dist/src/commands/export-staged-registration.js +91 -0
  27. package/dist/src/commands/forward-import-remedy.d.ts +32 -0
  28. package/dist/src/commands/forward-import-remedy.js +63 -0
  29. package/dist/src/commands/furnace/apply.js +3 -3
  30. package/dist/src/commands/furnace/chrome-doc-remove.d.ts +2 -1
  31. package/dist/src/commands/furnace/chrome-doc-remove.js +2 -2
  32. package/dist/src/commands/furnace/chrome-doc.d.ts +2 -1
  33. package/dist/src/commands/furnace/chrome-doc.js +2 -2
  34. package/dist/src/commands/furnace/create.js +2 -2
  35. package/dist/src/commands/furnace/deploy-named.d.ts +48 -0
  36. package/dist/src/commands/furnace/deploy-named.js +107 -0
  37. package/dist/src/commands/furnace/deploy.d.ts +3 -2
  38. package/dist/src/commands/furnace/deploy.js +50 -86
  39. package/dist/src/commands/furnace/diff.js +26 -3
  40. package/dist/src/commands/furnace/index.js +66 -41
  41. package/dist/src/commands/furnace/init.js +13 -6
  42. package/dist/src/commands/furnace/override.js +4 -2
  43. package/dist/src/commands/furnace/preview.js +4 -3
  44. package/dist/src/commands/furnace/refresh.js +2 -2
  45. package/dist/src/commands/furnace/remove.js +4 -3
  46. package/dist/src/commands/furnace/rename-dry-run.d.ts +29 -0
  47. package/dist/src/commands/furnace/rename-dry-run.js +127 -0
  48. package/dist/src/commands/furnace/rename-helpers.d.ts +7 -0
  49. package/dist/src/commands/furnace/rename-helpers.js +15 -1
  50. package/dist/src/commands/furnace/rename-registrations.d.ts +41 -0
  51. package/dist/src/commands/furnace/rename-registrations.js +93 -0
  52. package/dist/src/commands/furnace/rename.d.ts +2 -1
  53. package/dist/src/commands/furnace/rename.js +28 -66
  54. package/dist/src/commands/furnace/scan.d.ts +9 -4
  55. package/dist/src/commands/furnace/scan.js +11 -9
  56. package/dist/src/commands/furnace/sync.js +3 -2
  57. package/dist/src/commands/furnace/validate-report.d.ts +45 -0
  58. package/dist/src/commands/furnace/validate-report.js +91 -0
  59. package/dist/src/commands/furnace/validate.d.ts +1 -1
  60. package/dist/src/commands/furnace/validate.js +127 -114
  61. package/dist/src/commands/furnace/validation-output.js +14 -3
  62. package/dist/src/commands/lint-per-patch.js +31 -60
  63. package/dist/src/commands/lint-report.d.ts +8 -4
  64. package/dist/src/commands/lint-report.js +15 -5
  65. package/dist/src/commands/lint.js +15 -34
  66. package/dist/src/commands/manifest.js +2 -0
  67. package/dist/src/commands/patch/delete-gap-notice.js +3 -1
  68. package/dist/src/commands/patch/delete.d.ts +1 -1
  69. package/dist/src/commands/patch/delete.js +13 -98
  70. package/dist/src/commands/patch/index.d.ts +1 -1
  71. package/dist/src/commands/patch/index.js +4 -2
  72. package/dist/src/commands/patch/lint-ignore.d.ts +5 -1
  73. package/dist/src/commands/patch/lint-ignore.js +65 -28
  74. package/dist/src/commands/patch/move-files-create.js +26 -17
  75. package/dist/src/commands/patch/move-files-into.js +5 -3
  76. package/dist/src/commands/patch/move-files.js +3 -1
  77. package/dist/src/commands/patch/removal-dependents.d.ts +63 -0
  78. package/dist/src/commands/patch/removal-dependents.js +133 -0
  79. package/dist/src/commands/patch/rename.js +7 -0
  80. package/dist/src/commands/patch/retire.d.ts +34 -0
  81. package/dist/src/commands/patch/retire.js +128 -0
  82. package/dist/src/commands/patch/split-plan.d.ts +7 -7
  83. package/dist/src/commands/patch/split-plan.js +45 -28
  84. package/dist/src/commands/patch/split-staged.d.ts +108 -0
  85. package/dist/src/commands/patch/split-staged.js +202 -0
  86. package/dist/src/commands/patch/split.d.ts +5 -0
  87. package/dist/src/commands/patch/split.js +51 -15
  88. package/dist/src/commands/patch/staged-dependency-batch.d.ts +48 -0
  89. package/dist/src/commands/patch/staged-dependency-batch.js +176 -0
  90. package/dist/src/commands/patch/staged-dependency-entries.d.ts +36 -0
  91. package/dist/src/commands/patch/staged-dependency-entries.js +52 -0
  92. package/dist/src/commands/patch/staged-dependency-from.d.ts +14 -0
  93. package/dist/src/commands/patch/staged-dependency-from.js +108 -0
  94. package/dist/src/commands/patch/staged-dependency.js +14 -41
  95. package/dist/src/commands/re-export-adjacent-registrations.d.ts +20 -0
  96. package/dist/src/commands/re-export-adjacent-registrations.js +75 -0
  97. package/dist/src/commands/re-export-adjacent.d.ts +45 -2
  98. package/dist/src/commands/re-export-adjacent.js +156 -37
  99. package/dist/src/commands/re-export-drift.d.ts +36 -8
  100. package/dist/src/commands/re-export-drift.js +73 -49
  101. package/dist/src/commands/re-export-files.js +11 -8
  102. package/dist/src/commands/re-export-lint.d.ts +2 -1
  103. package/dist/src/commands/re-export-lint.js +23 -10
  104. package/dist/src/commands/re-export-options.js +45 -4
  105. package/dist/src/commands/re-export-preflight.d.ts +23 -0
  106. package/dist/src/commands/re-export-preflight.js +160 -0
  107. package/dist/src/commands/re-export-register.js +42 -17
  108. package/dist/src/commands/re-export-removed.d.ts +12 -1
  109. package/dist/src/commands/re-export-removed.js +41 -11
  110. package/dist/src/commands/re-export-scan-owner.d.ts +44 -0
  111. package/dist/src/commands/re-export-scan-owner.js +116 -0
  112. package/dist/src/commands/re-export-scan.d.ts +17 -9
  113. package/dist/src/commands/re-export-scan.js +56 -23
  114. package/dist/src/commands/re-export-single.js +22 -6
  115. package/dist/src/commands/re-export-summary.d.ts +28 -0
  116. package/dist/src/commands/re-export-summary.js +36 -0
  117. package/dist/src/commands/re-export.js +37 -24
  118. package/dist/src/commands/rebase/index.js +17 -17
  119. package/dist/src/commands/run.js +16 -2
  120. package/dist/src/commands/status-check.d.ts +55 -16
  121. package/dist/src/commands/status-check.js +92 -37
  122. package/dist/src/commands/status-json.d.ts +22 -9
  123. package/dist/src/commands/status-json.js +47 -32
  124. package/dist/src/commands/status-output.js +3 -1
  125. package/dist/src/commands/status-scope.d.ts +37 -0
  126. package/dist/src/commands/status-scope.js +54 -0
  127. package/dist/src/commands/status.js +66 -23
  128. package/dist/src/commands/test-browser-preflight.js +19 -3
  129. package/dist/src/commands/test-build-furnace.d.ts +21 -0
  130. package/dist/src/commands/test-build-furnace.js +48 -0
  131. package/dist/src/commands/test-build-phase.js +47 -41
  132. package/dist/src/commands/test-checkjs.js +7 -0
  133. package/dist/src/commands/test-diagnose.js +7 -0
  134. package/dist/src/commands/test-modes.d.ts +16 -1
  135. package/dist/src/commands/test-modes.js +24 -1
  136. package/dist/src/commands/test-multi-verdict.d.ts +61 -0
  137. package/dist/src/commands/test-multi-verdict.js +111 -0
  138. package/dist/src/commands/test-orchestrate.d.ts +43 -0
  139. package/dist/src/commands/test-orchestrate.js +222 -0
  140. package/dist/src/commands/test-perf-scope.d.ts +29 -0
  141. package/dist/src/commands/test-perf-scope.js +83 -0
  142. package/dist/src/commands/test-prelock.d.ts +51 -0
  143. package/dist/src/commands/test-prelock.js +177 -0
  144. package/dist/src/commands/test-register.js +54 -10
  145. package/dist/src/commands/test-run.js +4 -1
  146. package/dist/src/commands/test-stale-gate.js +57 -27
  147. package/dist/src/commands/test-verdict.d.ts +10 -1
  148. package/dist/src/commands/test-verdict.js +28 -1
  149. package/dist/src/commands/test.d.ts +6 -1
  150. package/dist/src/commands/test.js +66 -60
  151. package/dist/src/commands/token-lint.d.ts +14 -0
  152. package/dist/src/commands/token-lint.js +111 -0
  153. package/dist/src/commands/token.js +10 -0
  154. package/dist/src/commands/verify-scope.d.ts +28 -0
  155. package/dist/src/commands/verify-scope.js +67 -0
  156. package/dist/src/commands/verify.d.ts +10 -2
  157. package/dist/src/commands/verify.js +59 -35
  158. package/dist/src/core/build-baseline-types.d.ts +46 -0
  159. package/dist/src/core/build-baseline-validate.js +19 -0
  160. package/dist/src/core/build-baseline.d.ts +5 -1
  161. package/dist/src/core/build-baseline.js +3 -1
  162. package/dist/src/core/build-overwrite-guard.d.ts +50 -0
  163. package/dist/src/core/build-overwrite-guard.js +84 -4
  164. package/dist/src/core/build-prepare.d.ts +7 -0
  165. package/dist/src/core/build-prepare.js +14 -8
  166. package/dist/src/core/build-run-record.d.ts +74 -0
  167. package/dist/src/core/build-run-record.js +136 -0
  168. package/dist/src/core/checkjs-support-heads.d.ts +41 -0
  169. package/dist/src/core/checkjs-support-heads.js +104 -0
  170. package/dist/src/core/config-paths.d.ts +2 -2
  171. package/dist/src/core/config-paths.js +8 -0
  172. package/dist/src/core/config-validate-check.d.ts +8 -0
  173. package/dist/src/core/config-validate-check.js +143 -0
  174. package/dist/src/core/config-validate-file-size.d.ts +9 -6
  175. package/dist/src/core/config-validate-file-size.js +91 -2
  176. package/dist/src/core/config-validate-history.d.ts +11 -0
  177. package/dist/src/core/config-validate-history.js +38 -0
  178. package/dist/src/core/config-validate-patch-policy.js +100 -0
  179. package/dist/src/core/config-validate-test-toolchains.js +7 -0
  180. package/dist/src/core/config-validate-token-scales.d.ts +6 -0
  181. package/dist/src/core/config-validate-token-scales.js +50 -0
  182. package/dist/src/core/config-validate.js +14 -5
  183. package/dist/src/core/config.d.ts +5 -0
  184. package/dist/src/core/config.js +16 -0
  185. package/dist/src/core/coverage-dispatch.d.ts +24 -0
  186. package/dist/src/core/coverage-dispatch.js +44 -0
  187. package/dist/src/core/coverage-extend.d.ts +70 -45
  188. package/dist/src/core/coverage-extend.js +199 -144
  189. package/dist/src/core/coverage-records.d.ts +97 -0
  190. package/dist/src/core/coverage-records.js +350 -0
  191. package/dist/src/core/destructive.d.ts +7 -4
  192. package/dist/src/core/destructive.js +10 -10
  193. package/dist/src/core/engine-session-lock.d.ts +10 -1
  194. package/dist/src/core/engine-session-lock.js +28 -8
  195. package/dist/src/core/export-moved-code-shape.d.ts +49 -11
  196. package/dist/src/core/export-moved-code-shape.js +135 -44
  197. package/dist/src/core/file-lock-queue.d.ts +67 -0
  198. package/dist/src/core/file-lock-queue.js +221 -0
  199. package/dist/src/core/file-lock.d.ts +4 -25
  200. package/dist/src/core/file-lock.js +105 -138
  201. package/dist/src/core/furnace-apply-output.d.ts +10 -0
  202. package/dist/src/core/furnace-apply-output.js +31 -1
  203. package/dist/src/core/furnace-apply.js +12 -2
  204. package/dist/src/core/furnace-composed-source.d.ts +76 -0
  205. package/dist/src/core/furnace-composed-source.js +167 -0
  206. package/dist/src/core/furnace-config-validate-exceptions.d.ts +9 -0
  207. package/dist/src/core/furnace-config-validate-exceptions.js +71 -0
  208. package/dist/src/core/furnace-config-validate.js +8 -0
  209. package/dist/src/core/furnace-css-fragments.d.ts +10 -0
  210. package/dist/src/core/furnace-css-fragments.js +26 -0
  211. package/dist/src/core/furnace-operation.d.ts +3 -3
  212. package/dist/src/core/furnace-registration-remove.d.ts +22 -1
  213. package/dist/src/core/furnace-registration-remove.js +53 -17
  214. package/dist/src/core/furnace-registration-rename.d.ts +26 -0
  215. package/dist/src/core/furnace-registration-rename.js +77 -0
  216. package/dist/src/core/furnace-registration.d.ts +14 -4
  217. package/dist/src/core/furnace-registration.js +44 -10
  218. package/dist/src/core/furnace-staleness.d.ts +7 -0
  219. package/dist/src/core/furnace-staleness.js +34 -24
  220. package/dist/src/core/furnace-validate-accessibility.d.ts +4 -2
  221. package/dist/src/core/furnace-validate-accessibility.js +10 -1
  222. package/dist/src/core/furnace-validate-exceptions.d.ts +40 -0
  223. package/dist/src/core/furnace-validate-exceptions.js +43 -0
  224. package/dist/src/core/furnace-validate-icon-button.d.ts +7 -0
  225. package/dist/src/core/furnace-validate-icon-button.js +49 -0
  226. package/dist/src/core/furnace-validate.js +1 -1
  227. package/dist/src/core/harness-port-fallback.d.ts +60 -0
  228. package/dist/src/core/harness-port-fallback.js +96 -0
  229. package/dist/src/core/history-log-file.d.ts +61 -0
  230. package/dist/src/core/history-log-file.js +157 -0
  231. package/dist/src/core/history-log.d.ts +2 -1
  232. package/dist/src/core/history-log.js +2 -1
  233. package/dist/src/core/json-preserving-edit.d.ts +22 -0
  234. package/dist/src/core/json-preserving-edit.js +199 -0
  235. package/dist/src/core/mach.js +8 -1
  236. package/dist/src/core/negative-control-diff.d.ts +39 -0
  237. package/dist/src/core/negative-control-diff.js +136 -0
  238. package/dist/src/core/negative-control.d.ts +138 -0
  239. package/dist/src/core/negative-control.js +356 -0
  240. package/dist/src/core/objdir-install-manifests.d.ts +73 -0
  241. package/dist/src/core/objdir-install-manifests.js +226 -0
  242. package/dist/src/core/patch-export-update.d.ts +2 -0
  243. package/dist/src/core/patch-export-update.js +1 -0
  244. package/dist/src/core/patch-export.js +3 -4
  245. package/dist/src/core/patch-lint-checkjs-any-import.d.ts +37 -0
  246. package/dist/src/core/patch-lint-checkjs-any-import.js +61 -0
  247. package/dist/src/core/patch-lint-checkjs.js +8 -11
  248. package/dist/src/core/patch-lint-code-lines.d.ts +59 -0
  249. package/dist/src/core/patch-lint-code-lines.js +105 -0
  250. package/dist/src/core/patch-lint-cross.js +12 -4
  251. package/dist/src/core/patch-lint-css.d.ts +8 -2
  252. package/dist/src/core/patch-lint-css.js +10 -4
  253. package/dist/src/core/patch-lint-file-size.d.ts +12 -5
  254. package/dist/src/core/patch-lint-file-size.js +22 -11
  255. package/dist/src/core/patch-lint-forward-registration-extended.d.ts +18 -0
  256. package/dist/src/core/patch-lint-forward-registration-extended.js +35 -10
  257. package/dist/src/core/patch-lint-forward-registration.js +10 -0
  258. package/dist/src/core/patch-lint-ignore.d.ts +152 -0
  259. package/dist/src/core/patch-lint-ignore.js +299 -0
  260. package/dist/src/core/patch-lint-size-thresholds.d.ts +52 -0
  261. package/dist/src/core/patch-lint-size-thresholds.js +110 -0
  262. package/dist/src/core/patch-lint-size.d.ts +117 -0
  263. package/dist/src/core/patch-lint-size.js +323 -0
  264. package/dist/src/core/patch-lint-staged-registration.d.ts +18 -1
  265. package/dist/src/core/patch-lint-staged-registration.js +20 -0
  266. package/dist/src/core/patch-lint-token-scale.d.ts +39 -0
  267. package/dist/src/core/patch-lint-token-scale.js +109 -0
  268. package/dist/src/core/patch-lint.d.ts +11 -83
  269. package/dist/src/core/patch-lint.js +64 -286
  270. package/dist/src/core/patch-lock.js +7 -6
  271. package/dist/src/core/patch-manifest-consistency.js +7 -6
  272. package/dist/src/core/patch-manifest-io.d.ts +6 -3
  273. package/dist/src/core/patch-manifest-io.js +19 -8
  274. package/dist/src/core/patch-manifest-validate.js +34 -2
  275. package/dist/src/core/patch-order-legal.d.ts +25 -0
  276. package/dist/src/core/patch-order-legal.js +83 -0
  277. package/dist/src/core/patch-policy-content.d.ts +40 -0
  278. package/dist/src/core/patch-policy-content.js +216 -0
  279. package/dist/src/core/patch-policy.d.ts +16 -3
  280. package/dist/src/core/patch-policy.js +16 -9
  281. package/dist/src/core/patch-retired.d.ts +35 -0
  282. package/dist/src/core/patch-retired.js +54 -0
  283. package/dist/src/core/patch-subset.d.ts +16 -0
  284. package/dist/src/core/patch-subset.js +66 -0
  285. package/dist/src/core/preflight-wait.d.ts +20 -0
  286. package/dist/src/core/preflight-wait.js +38 -0
  287. package/dist/src/core/rebase-from-version.d.ts +46 -0
  288. package/dist/src/core/rebase-from-version.js +71 -0
  289. package/dist/src/core/run-log.d.ts +20 -0
  290. package/dist/src/core/run-log.js +89 -3
  291. package/dist/src/core/run-profile.d.ts +7 -0
  292. package/dist/src/core/run-profile.js +7 -1
  293. package/dist/src/core/smoke-patterns.d.ts +5 -0
  294. package/dist/src/core/smoke-patterns.js +13 -0
  295. package/dist/src/core/test-harness-crash.d.ts +18 -1
  296. package/dist/src/core/test-harness-crash.js +6 -4
  297. package/dist/src/core/test-harness-signature.d.ts +17 -0
  298. package/dist/src/core/test-harness-signature.js +4 -0
  299. package/dist/src/core/test-run-completeness.d.ts +57 -0
  300. package/dist/src/core/test-run-completeness.js +144 -0
  301. package/dist/src/core/test-stale-check.d.ts +27 -1
  302. package/dist/src/core/test-stale-check.js +39 -49
  303. package/dist/src/core/test-stale-exempt.d.ts +40 -0
  304. package/dist/src/core/test-stale-exempt.js +70 -0
  305. package/dist/src/core/token-lint.d.ts +50 -0
  306. package/dist/src/core/token-lint.js +126 -0
  307. package/dist/src/core/tree-guard.js +3 -0
  308. package/dist/src/core/typecheck-shim.d.ts +1 -1
  309. package/dist/src/core/typecheck-shim.js +90 -2
  310. package/dist/src/index.d.ts +8 -3
  311. package/dist/src/index.js +3 -1
  312. package/dist/src/types/commands/index.d.ts +2 -2
  313. package/dist/src/types/commands/options.d.ts +148 -21
  314. package/dist/src/types/commands/patches.d.ts +75 -1
  315. package/dist/src/types/config.d.ts +192 -0
  316. package/dist/src/types/config.js +9 -0
  317. package/dist/src/types/furnace.d.ts +43 -1
  318. package/dist/src/types/furnace.js +7 -0
  319. package/dist/src/types/index.d.ts +1 -1
  320. package/dist/src/utils/options.d.ts +34 -6
  321. package/dist/src/utils/options.js +86 -5
  322. package/dist/src/utils/path-glob.d.ts +15 -0
  323. package/dist/src/utils/path-glob.js +47 -0
  324. package/dist/src/utils/regex.d.ts +15 -3
  325. package/dist/src/utils/regex.js +37 -16
  326. package/docs/configuration.md +282 -9
  327. package/docs/exit-codes.md +18 -1
  328. package/docs/furnace.md +97 -3
  329. package/docs/lifecycle-invariants.md +52 -19
  330. package/docs/machine-output.md +74 -12
  331. package/docs/patch-workflow.md +297 -22
  332. package/docs/run-logs.md +20 -1
  333. package/docs/testing.md +276 -20
  334. package/package.json +1 -1
@@ -1,14 +1,16 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  import { randomUUID } from 'node:crypto';
3
- import { mkdir, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
4
- import { dirname, join } from 'node:path';
3
+ import { mkdir, rename, rm, stat } from 'node:fs/promises';
4
+ import { dirname } from 'node:path';
5
5
  import { LockContentionError } from '../errors/base.js';
6
- import { getNodeErrorCode, isProcessAlive, toError } from '../utils/errors.js';
6
+ import { getNodeErrorCode, toError } from '../utils/errors.js';
7
7
  import { ensureDir } from '../utils/fs.js';
8
8
  import { verbose, warn } from '../utils/logger.js';
9
9
  import { sleep } from '../utils/sleep.js';
10
10
  import { isLockOwnerAlive, LOCK_PID_FILE, readLockOwner, writeLockOwner, } from './file-lock-owner.js';
11
+ import { readLockQueue, registerWaiter, } from './file-lock-queue.js';
11
12
  export { LOCK_PID_FILE };
13
+ export { formatLockQueuePosition } from './file-lock-queue.js';
12
14
  const DEFAULT_LOCK_TIMEOUT_MS = 30_000;
13
15
  const DEFAULT_LOCK_POLL_MS = 50;
14
16
  const DEFAULT_STALE_LOCK_MS = 5 * 60_000;
@@ -30,46 +32,6 @@ const WAIT_PROBE_INTERVAL_MS = 5_000;
30
32
  export function createSiblingLockPath(filePath, suffix = '.fireforge.lock') {
31
33
  return `${filePath}${suffix}`;
32
34
  }
33
- /** Sibling directory in which waiters advertise themselves. */
34
- function lockWaitersDir(lockPath) {
35
- return `${lockPath}.waiters`;
36
- }
37
- /** Parsed waiter-file name: `<startedAtMs>-<pid>-<uuid>`. */
38
- function parseWaiterFileName(name) {
39
- const match = /^(\d+)-(\d+)-/.exec(name);
40
- const startedAt = match?.[1];
41
- const pid = match?.[2];
42
- if (startedAt === undefined || pid === undefined)
43
- return undefined;
44
- return { startedAt: Number.parseInt(startedAt, 10), pid: Number.parseInt(pid, 10) };
45
- }
46
- /**
47
- * Reads the live waiter queue around `lockPath`, dropping entries whose
48
- * process is gone (a killed waiter must not inflate the depth forever).
49
- *
50
- * Advisory and fail-open: an unreadable registry reports an empty queue
51
- * rather than failing whatever asked.
52
- *
53
- * @param lockPath - The lock directory whose queue to inspect
54
- * @param selfStartedAt - This process's wait start, to compute `ahead`
55
- */
56
- export async function readLockQueue(lockPath, selfStartedAt) {
57
- let names;
58
- try {
59
- names = await readdir(lockWaitersDir(lockPath));
60
- }
61
- catch {
62
- return { depth: 0, ahead: 0 };
63
- }
64
- const live = names
65
- .map(parseWaiterFileName)
66
- .filter((entry) => entry !== undefined)
67
- .filter((entry) => isProcessAlive(entry.pid));
68
- const ahead = selfStartedAt === undefined
69
- ? 0
70
- : live.filter((entry) => entry.startedAt < selfStartedAt).length;
71
- return { depth: live.length, ahead };
72
- }
73
35
  /**
74
36
  * Inspects a lock without acquiring it. Read-only and fail-open: a missing
75
37
  * lock is reported as free, never as an error.
@@ -92,29 +54,6 @@ export async function readLockStatus(lockPath) {
92
54
  : undefined;
93
55
  return { held: true, holder, heldForMs, queueDepth: queue.depth };
94
56
  }
95
- /**
96
- * Registers this process in the lock's waiter queue. Returns a
97
- * deregistration function that is always safe to call (including when
98
- * registration itself failed).
99
- */
100
- async function registerWaiter(lockPath, startedAt) {
101
- const dir = lockWaitersDir(lockPath);
102
- const file = join(dir, `${startedAt}-${process.pid}-${randomUUID()}`);
103
- try {
104
- await ensureDir(dir);
105
- await writeFile(file, `${process.pid}\n`, 'utf-8');
106
- }
107
- catch (error) {
108
- // Advisory only. Never fail a lock acquisition over the queue display.
109
- verbose(`Could not register lock waiter for ${lockPath}: ${toError(error).message}`);
110
- return async () => {
111
- /* nothing to remove */
112
- };
113
- }
114
- return async () => {
115
- await rm(file, { force: true });
116
- };
117
- }
118
57
  /**
119
58
  * Reads the contended lock's owner and queue in one go. One probe per cycle
120
59
  * serves both the progress line and the deadline extension, so the two can
@@ -122,12 +61,12 @@ async function registerWaiter(lockPath, startedAt) {
122
61
  * degrades to `holder: undefined`, since a diagnostic must never break the
123
62
  * wait loop.
124
63
  */
125
- async function probeWaitState(lockPath, startedAt) {
64
+ async function probeWaitState(lockPath, self) {
126
65
  const owner = await readLockOwner(lockPath);
127
66
  const holder = owner.present
128
67
  ? { pid: owner.pid, alive: isLockOwnerAlive(owner), metadata: owner.metadata }
129
68
  : undefined;
130
- return { holder, queue: await readLockQueue(lockPath, startedAt) };
69
+ return { holder, queue: await readLockQueue(lockPath, self) };
131
70
  }
132
71
  /**
133
72
  * Reaps a lock directory believed to be stale, safely against concurrent
@@ -345,16 +284,24 @@ function formatQueuePositionReached(queue) {
345
284
  * caller asked for it. Split out of the wait loop so the loop stays a
346
285
  * readable state machine.
347
286
  */
348
- async function runWaitProbe(lockPath, startedAt, timeoutMs, options) {
349
- const { holder, queue } = await probeWaitState(lockPath, startedAt);
287
+ async function runWaitProbe(lockPath, self, timeoutMs, options) {
288
+ const { holder, queue } = await probeWaitState(lockPath, self);
350
289
  options.onWaitProgress?.({
351
- waitedMs: Date.now() - startedAt,
290
+ waitedMs: Date.now() - self.startedAt,
352
291
  timeoutMs,
353
292
  holder,
354
293
  queue,
355
294
  });
356
295
  return { queue };
357
296
  }
297
+ /**
298
+ * Whether it is this waiter's turn to contend for the lock: no live waiter
299
+ * arrived before it. Ties and dead or stalled tickets are settled by
300
+ * {@link readLockQueue}, so every waiter agrees on who is at the head.
301
+ */
302
+ async function isWaiterTurn(lockPath, startedAt, waiter) {
303
+ return (await readLockQueue(lockPath, { startedAt, ticket: waiter.ticket })).ahead === 0;
304
+ }
358
305
  /**
359
306
  * Lock directories this process currently holds.
360
307
  *
@@ -404,8 +351,14 @@ export async function withFileLock(lockPath, operation, options = {}) {
404
351
  let lastProbeAt = startedAt;
405
352
  /** Best (lowest) queue position seen so far. Used to detect an advance. */
406
353
  let bestAhead;
407
- /** Set on first contention so an uncontended acquire costs nothing. */
408
- let deregisterWaiter;
354
+ /**
355
+ * This waiter's ticket in the queue. Taken on first contention, or up
356
+ * front when others are already queued, so an uncontended acquire never
357
+ * writes one.
358
+ */
359
+ let waiter;
360
+ /** The last `EEXIST`, carried as the cause of a timeout refusal. */
361
+ let contention;
409
362
  // One cadence drives both the progress line and the deadline extension.
410
363
  // Either feature alone is enough to want the probe. Neither is worth a
411
364
  // second timer.
@@ -414,83 +367,97 @@ export async function withFileLock(lockPath, operation, options = {}) {
414
367
  extend !== undefined;
415
368
  await ensureDir(dirname(lockPath));
416
369
  try {
370
+ // Arrival order: a newcomer that finds others already queued takes a
371
+ // ticket behind them instead of racing them for the next free moment.
372
+ // Without this a fresh command could take the lock in the gap between
373
+ // the holder's release and the head waiter's next poll.
374
+ if ((await readLockQueue(lockPath)).depth > 0) {
375
+ waiter = await registerWaiter(lockPath, startedAt);
376
+ }
417
377
  for (;;) {
418
- try {
419
- await mkdir(lockPath);
420
- break;
421
- }
422
- catch (error) {
423
- if (getNodeErrorCode(error) !== 'EEXIST') {
424
- throw error;
378
+ // Only the head of the queue contends for the lock. A waiter without
379
+ // a ticket (no contention seen yet, or a registry that could not be
380
+ // written) contends as every waiter did before tickets existed.
381
+ if (waiter?.ticket === undefined || (await isWaiterTurn(lockPath, startedAt, waiter))) {
382
+ try {
383
+ await mkdir(lockPath);
384
+ break;
425
385
  }
426
- // Advertise this waiter so the queue is visible to the wait output
427
- // and to `fireforge status --lock`. Registered lazily:
428
- // an uncontended acquisition never touches the registry.
429
- deregisterWaiter ??= await registerWaiter(lockPath, startedAt);
430
- if (lastStaleProbeAt === undefined || Date.now() - lastStaleProbeAt >= staleReprobeMs) {
431
- lastStaleProbeAt = Date.now();
432
- if (await removeIfStaleLock(lockPath, staleMs, options.onStaleLockMessage)) {
433
- continue;
386
+ catch (error) {
387
+ if (getNodeErrorCode(error) !== 'EEXIST') {
388
+ throw error;
434
389
  }
390
+ contention = error;
435
391
  }
436
- // The probe runs before the deadline check, so a due progress line is
437
- // written before the refusal that follows it, never lost to it. On a
438
- // slow host (a cold Windows CI runner) the first iteration alone, with
439
- // its waiter registration and stale probe, can outlast a short budget;
440
- // checked the other way round, the wait timed out having reported
441
- // nothing, and an advance that probe would have seen could not extend
442
- // the deadline it was about to miss.
443
- if (probeWanted && Date.now() - lastProbeAt >= probeIntervalMs) {
444
- lastProbeAt = Date.now();
445
- // Report the budget that is currently in force, not the one
446
- // originally requested: once an extension has been granted, the
447
- // requested figure is stale, and a progress line that keeps
448
- // quoting it reads as a timeout that does not fire.
449
- const probe = await runWaitProbe(lockPath, startedAt, deadline - startedAt, options);
450
- // An advance (someone ahead of us took their turn and finished)
451
- // buys a fresh budget, because the wait is working. Only a queue
452
- // that stops moving starves, which is the case the timeout is for.
453
- //
454
- // The first observation is a seed, never an advance. `bestAhead`
455
- // used to start `undefined`, so the opening probe always satisfied
456
- // this test and handed out a free full budget: a `--wait-lock 300`
457
- // run outlived its 300 s even against a queue that never moved
458
- // once, which is the "it does not reliably give up after N
459
- // seconds" behaviour a downstream fork reported.
460
- if (hardDeadline !== undefined) {
461
- if (bestAhead === undefined) {
462
- bestAhead = probe.queue.ahead;
463
- }
464
- else if (probe.queue.ahead < bestAhead) {
465
- bestAhead = probe.queue.ahead;
466
- deadline = Math.min(Date.now() + timeoutMs, hardDeadline);
467
- options.onWaitExtended?.({
468
- ahead: probe.queue.ahead,
469
- budgetMs: deadline - startedAt,
470
- });
471
- }
472
- }
473
- }
474
- if (Date.now() >= deadline) {
475
- // The queue is read here, not carried over from the last progress
476
- // probe: the position the wait reached is the one number that says
477
- // whether a larger budget would have helped, and it must not depend
478
- // on a probe having happened to land inside the budget. We are still
479
- // registered at this point. `deregisterWaiter` runs in the `finally`
480
- // below, which is only entered once this refusal has been built.
481
- throw await buildLockTimeoutError(lockPath, options.onTimeoutMessage, error, await readLockQueue(lockPath, startedAt));
392
+ }
393
+ // Take a ticket so the queue is visible to the wait output and to
394
+ // `fireforge status --lock`, and so the grant follows arrival order.
395
+ waiter ??= await registerWaiter(lockPath, startedAt);
396
+ await waiter.heartbeat();
397
+ const self = { startedAt, ticket: waiter.ticket };
398
+ if (lastStaleProbeAt === undefined || Date.now() - lastStaleProbeAt >= staleReprobeMs) {
399
+ lastStaleProbeAt = Date.now();
400
+ if (await removeIfStaleLock(lockPath, staleMs, options.onStaleLockMessage)) {
401
+ continue;
482
402
  }
483
- await sleep(currentPollMs);
484
- if (options.pollMaxMs !== undefined) {
485
- currentPollMs = Math.min(currentPollMs * 2, options.pollMaxMs);
403
+ }
404
+ // The probe runs before the deadline check, so a due progress line is
405
+ // written before the refusal that follows it, never lost to it. On a
406
+ // slow host (a cold Windows CI runner) the first iteration alone, with
407
+ // its waiter registration and stale probe, can outlast a short budget;
408
+ // checked the other way round, the wait timed out having reported
409
+ // nothing, and an advance that probe would have seen could not extend
410
+ // the deadline it was about to miss.
411
+ if (probeWanted && Date.now() - lastProbeAt >= probeIntervalMs) {
412
+ lastProbeAt = Date.now();
413
+ // Report the budget that is currently in force, not the one
414
+ // originally requested: once an extension has been granted, the
415
+ // requested figure is stale, and a progress line that keeps
416
+ // quoting it reads as a timeout that does not fire.
417
+ const probe = await runWaitProbe(lockPath, self, deadline - startedAt, options);
418
+ // An advance (someone ahead of us took their turn and finished)
419
+ // buys a fresh budget, because the wait is working. Only a queue
420
+ // that stops moving starves, which is the case the timeout is for.
421
+ //
422
+ // The first observation is a seed, never an advance. `bestAhead`
423
+ // used to start `undefined`, so the opening probe always satisfied
424
+ // this test and handed out a free full budget: a `--wait-lock 300`
425
+ // run outlived its 300 s even against a queue that never moved
426
+ // once, which is the "it does not reliably give up after N
427
+ // seconds" behaviour a downstream fork reported.
428
+ if (hardDeadline !== undefined) {
429
+ if (bestAhead === undefined) {
430
+ bestAhead = probe.queue.ahead;
431
+ }
432
+ else if (probe.queue.ahead < bestAhead) {
433
+ bestAhead = probe.queue.ahead;
434
+ deadline = Math.min(Date.now() + timeoutMs, hardDeadline);
435
+ options.onWaitExtended?.({
436
+ ahead: probe.queue.ahead,
437
+ budgetMs: deadline - startedAt,
438
+ });
439
+ }
486
440
  }
487
441
  }
442
+ if (Date.now() >= deadline) {
443
+ // The queue is read here, not carried over from the last progress
444
+ // probe: the position the wait reached is the one number that says
445
+ // whether a larger budget would have helped, and it must not depend
446
+ // on a probe having happened to land inside the budget. We are still
447
+ // registered at this point. The ticket is returned in the `finally`
448
+ // below, which is only entered once this refusal has been built.
449
+ throw await buildLockTimeoutError(lockPath, options.onTimeoutMessage, contention, await readLockQueue(lockPath, self));
450
+ }
451
+ await sleep(currentPollMs);
452
+ if (options.pollMaxMs !== undefined) {
453
+ currentPollMs = Math.min(currentPollMs * 2, options.pollMaxMs);
454
+ }
488
455
  }
489
456
  }
490
457
  finally {
491
458
  // Leaving the queue happens the moment we stop waiting, whether we
492
459
  // acquired the lock or timed out.
493
- await deregisterWaiter?.();
460
+ await waiter?.deregister();
494
461
  }
495
462
  // Stamp ownership (PID + per-acquisition token + acquisition time) into
496
463
  // the lock directory so stale-lock recovery can check liveness and release
@@ -2,6 +2,16 @@ import type { ApplyResult, DryRunAction } from '../types/furnace.js';
2
2
  type ApplyResultWithActions = ApplyResult & {
3
3
  actions?: DryRunAction[];
4
4
  };
5
+ /**
6
+ * The reason breakdown appended to a "skipped N" summary, e.g.
7
+ * ` (2 already current)`, so `Applied 0, skipped 1` says whether the
8
+ * component was already current or skipped for another reason without
9
+ * diffing the deployed files by hand. Empty when nothing was skipped.
10
+ *
11
+ * @param skipped - `result.skipped` from applyAllComponents
12
+ * @returns The parenthesised breakdown with a leading space, or ''
13
+ */
14
+ export declare function formatSkipBreakdown(skipped: ApplyResult['skipped']): string;
5
15
  /**
6
16
  * Prints a standard summary of an apply result for both normal and dry-run flows.
7
17
  *
@@ -1,4 +1,34 @@
1
1
  import { error, info, success, warn } from '../utils/logger.js';
2
+ /** Summary label and per-component explanation for each skip kind. */
3
+ const SKIP_KIND_TEXT = {
4
+ 'already-current': {
5
+ label: 'already current',
6
+ detail: 'already current: source unchanged since the last recorded apply (from any session) and the engine copy matches it',
7
+ },
8
+ };
9
+ /** The per-component line text for one skip. */
10
+ function describeSkip(skipped) {
11
+ return skipped.kind !== undefined ? SKIP_KIND_TEXT[skipped.kind].detail : skipped.reason;
12
+ }
13
+ /**
14
+ * The reason breakdown appended to a "skipped N" summary, e.g.
15
+ * ` (2 already current)`, so `Applied 0, skipped 1` says whether the
16
+ * component was already current or skipped for another reason without
17
+ * diffing the deployed files by hand. Empty when nothing was skipped.
18
+ *
19
+ * @param skipped - `result.skipped` from applyAllComponents
20
+ * @returns The parenthesised breakdown with a leading space, or ''
21
+ */
22
+ export function formatSkipBreakdown(skipped) {
23
+ if (skipped.length === 0)
24
+ return '';
25
+ const counts = new Map();
26
+ for (const entry of skipped) {
27
+ const label = entry.kind !== undefined ? SKIP_KIND_TEXT[entry.kind].label : entry.reason;
28
+ counts.set(label, (counts.get(label) ?? 0) + 1);
29
+ }
30
+ return ` (${[...counts].map(([label, count]) => `${count} ${label}`).join(', ')})`;
31
+ }
2
32
  /**
3
33
  * Prints a standard summary of an apply result for both normal and dry-run flows.
4
34
  *
@@ -40,7 +70,7 @@ export function logApplyResult(result, isDryRun) {
40
70
  success(`${applied.name} (${applied.type}) → ${applied.filesAffected.length} files`);
41
71
  }
42
72
  for (const skipped of result.skipped) {
43
- info(`${skipped.name} — ${skipped.reason}`);
73
+ info(`${skipped.name} — ${describeSkip(skipped)}`);
44
74
  }
45
75
  for (const applied of result.applied) {
46
76
  if (applied.stepErrors && applied.stepErrors.length > 0) {
@@ -39,6 +39,16 @@ function addMissingComponentError(result, name, directoryPath) {
39
39
  error: `Component directory not found: ${directoryPath}`,
40
40
  });
41
41
  }
42
+ /**
43
+ * The skip both batch loops record when a component's source is unchanged
44
+ * since the last recorded apply and its engine copy still matches. `reason`
45
+ * keeps its long-standing text for API consumers; `kind` lets summaries
46
+ * name it.
47
+ */
48
+ const ALREADY_CURRENT_SKIP = {
49
+ reason: 'No changes since last apply',
50
+ kind: 'already-current',
51
+ };
42
52
  async function applyOverrideBatch(ctx) {
43
53
  const { config, furnacePaths, state, engineDir, ftlDir, dryRun } = ctx;
44
54
  const { result, allActions, newChecksums, rollbackJournal, componentName } = ctx;
@@ -66,7 +76,7 @@ async function applyOverrideBatch(ctx) {
66
76
  const cachedEngine = extractComponentChecksums(state.engineChecksums, 'override', name);
67
77
  const drifted = await hasOverrideEngineDrift({ engineDir, componentDir, ftlDir }, overrideConfig, cachedEngine);
68
78
  if (!drifted) {
69
- result.skipped.push({ name, reason: 'No changes since last apply' });
79
+ result.skipped.push({ name, ...ALREADY_CURRENT_SKIP });
70
80
  recordComponentChecksums(newChecksums, previous, 'override', name);
71
81
  continue;
72
82
  }
@@ -226,7 +236,7 @@ async function applyCustomBatch(ctx, root, markerComment) {
226
236
  // re-apply even though the workspace is unchanged.
227
237
  const drifted = await hasCustomEngineDrift({ root, name, componentDir, ftlDir }, customConfig);
228
238
  if (!drifted) {
229
- result.skipped.push({ name, reason: 'No changes since last apply' });
239
+ result.skipped.push({ name, ...ALREADY_CURRENT_SKIP });
230
240
  recordComponentChecksums(newChecksums, previous, 'custom', name);
231
241
  continue;
232
242
  }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * The Furnace-composed form of a component source, and the questions that
3
+ * need it.
4
+ *
5
+ * `furnace apply` does not copy every source verbatim: a stylesheet with
6
+ * `@fireforge-include` directives is deployed with each fragment body from
7
+ * `components/shared/` inlined between the directive and its
8
+ * `@fireforge-end-include` marker. Anything that compares a deployed engine
9
+ * file with its source has to compare against that composed form, or a
10
+ * freshly deployed widget reads as a large difference it is not:
11
+ *
12
+ * - the unexported-drift belt (`build`/`test --refuse-unexported-drift`),
13
+ * which must not refuse to overwrite a deployed file with the very bytes
14
+ * it already holds;
15
+ * - `furnace diff`, which must show what the next apply would change.
16
+ */
17
+ import type { FurnaceConfig } from '../types/furnace.js';
18
+ /**
19
+ * Composes one component source into the bytes apply deploys: a stylesheet
20
+ * carrying include directives is fragment-expanded, anything else is
21
+ * returned as is.
22
+ *
23
+ * @param content - Workspace source content
24
+ * @param fileName - Source file name (only `.css` is ever expanded)
25
+ * @param sharedDir - Absolute path of `components/shared/`
26
+ * @returns The composed content
27
+ * @throws FurnaceError when an included fragment is missing or nested
28
+ */
29
+ export declare function composeComponentSource(content: string, fileName: string, sharedDir: string): Promise<string>;
30
+ /** The workspace source apply deploys to one engine path. */
31
+ export interface FurnaceDeployedSource {
32
+ type: 'custom' | 'override';
33
+ component: string;
34
+ /** Source file name inside the component directory. */
35
+ fileName: string;
36
+ /** Absolute path of the workspace source. */
37
+ sourcePath: string;
38
+ }
39
+ /**
40
+ * Maps every engine-relative path `furnace apply` writes to the workspace
41
+ * source it writes there: custom `.mjs`/`.css` into `targetPath`, a custom
42
+ * component's own `.ftl` into the Fluent tree (never for `sharedFtl`), and
43
+ * each override copy candidate onto its engine target.
44
+ *
45
+ * @param root - Project root
46
+ * @param config - Loaded furnace.json
47
+ * @returns Engine-relative path (POSIX) → deployed source
48
+ */
49
+ export declare function mapDeployedComponentSources(root: string, config: FurnaceConfig): Promise<Map<string, FurnaceDeployedSource>>;
50
+ /**
51
+ * Why a deployed engine file is safe to overwrite from its component
52
+ * source even though no patch body or baseline explains it:
53
+ *
54
+ * - `composed-source`: it already equals the Furnace-composed current
55
+ * source, so the overwrite writes the same bytes;
56
+ * - `fragment-refresh`: it differs from the composed source only inside
57
+ * Furnace-generated fragment expansions (a fragment changed since it was
58
+ * deployed), so the overwrite only refreshes generated text;
59
+ * - `previous-deploy`: it is exactly the content Furnace's last recorded
60
+ * apply deployed (the source has moved on since), so the overwrite loses
61
+ * nothing that was not already a component source.
62
+ */
63
+ export type DeployedSourceMatch = 'composed-source' | 'fragment-refresh' | 'previous-deploy';
64
+ /**
65
+ * For each engine-relative path, decides whether its current engine content
66
+ * is a Furnace deployment of its component source (see
67
+ * {@link DeployedSourceMatch}). Paths with no component source, an
68
+ * unreadable engine copy, or content explained by none of the matches are
69
+ * absent from the result.
70
+ *
71
+ * @param root - Project root
72
+ * @param engineDir - Absolute engine directory
73
+ * @param files - Engine-relative paths to classify
74
+ * @returns Engine-relative path → match, for the matching paths only
75
+ */
76
+ export declare function matchDeployedComponentFiles(root: string, engineDir: string, files: readonly string[]): Promise<Map<string, DeployedSourceMatch>>;
@@ -0,0 +1,167 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * The Furnace-composed form of a component source, and the questions that
4
+ * need it.
5
+ *
6
+ * `furnace apply` does not copy every source verbatim: a stylesheet with
7
+ * `@fireforge-include` directives is deployed with each fragment body from
8
+ * `components/shared/` inlined between the directive and its
9
+ * `@fireforge-end-include` marker. Anything that compares a deployed engine
10
+ * file with its source has to compare against that composed form, or a
11
+ * freshly deployed widget reads as a large difference it is not:
12
+ *
13
+ * - the unexported-drift belt (`build`/`test --refuse-unexported-drift`),
14
+ * which must not refuse to overwrite a deployed file with the very bytes
15
+ * it already holds;
16
+ * - `furnace diff`, which must show what the next apply would change.
17
+ */
18
+ import { readdir } from 'node:fs/promises';
19
+ import { join } from 'node:path';
20
+ import { pathExists, readText } from '../utils/fs.js';
21
+ import { sha256Hex } from '../utils/hash.js';
22
+ import { normalizePathSlashes } from '../utils/paths.js';
23
+ import { isOverrideCopyCandidate } from './furnace-apply-helpers.js';
24
+ import { extractComponentChecksums, normalizeForChecksum } from './furnace-checksum-utils.js';
25
+ import { furnaceConfigExists, getFurnacePaths, loadFurnaceConfig, loadFurnaceState, } from './furnace-config.js';
26
+ import { resolveFtlDir } from './furnace-constants.js';
27
+ import { expandCssFragments, listFragmentIncludes, stripExpandedFragments, } from './furnace-css-fragments.js';
28
+ import { isRegularFile } from './furnace-dir-entry.js';
29
+ /**
30
+ * Composes one component source into the bytes apply deploys: a stylesheet
31
+ * carrying include directives is fragment-expanded, anything else is
32
+ * returned as is.
33
+ *
34
+ * @param content - Workspace source content
35
+ * @param fileName - Source file name (only `.css` is ever expanded)
36
+ * @param sharedDir - Absolute path of `components/shared/`
37
+ * @returns The composed content
38
+ * @throws FurnaceError when an included fragment is missing or nested
39
+ */
40
+ export async function composeComponentSource(content, fileName, sharedDir) {
41
+ if (!fileName.endsWith('.css') || listFragmentIncludes(content).length === 0)
42
+ return content;
43
+ return (await expandCssFragments(content, sharedDir)).expanded;
44
+ }
45
+ /**
46
+ * Maps every engine-relative path `furnace apply` writes to the workspace
47
+ * source it writes there: custom `.mjs`/`.css` into `targetPath`, a custom
48
+ * component's own `.ftl` into the Fluent tree (never for `sharedFtl`), and
49
+ * each override copy candidate onto its engine target.
50
+ *
51
+ * @param root - Project root
52
+ * @param config - Loaded furnace.json
53
+ * @returns Engine-relative path (POSIX) → deployed source
54
+ */
55
+ export async function mapDeployedComponentSources(root, config) {
56
+ const furnacePaths = getFurnacePaths(root);
57
+ const ftlDir = resolveFtlDir(config.ftlBasePath);
58
+ const sources = new Map();
59
+ for (const [component, custom] of Object.entries(config.custom)) {
60
+ const componentDir = join(furnacePaths.customDir, component);
61
+ for (const fileName of await listSourceFiles(componentDir)) {
62
+ let engineRel;
63
+ if (fileName.endsWith('.mjs') || fileName.endsWith('.css')) {
64
+ engineRel = normalizePathSlashes(join(custom.targetPath, fileName));
65
+ }
66
+ else if (fileName === `${component}.ftl` && custom.localized && !custom.sharedFtl) {
67
+ engineRel = normalizePathSlashes(join(ftlDir, fileName));
68
+ }
69
+ if (engineRel === undefined)
70
+ continue;
71
+ sources.set(engineRel, {
72
+ type: 'custom',
73
+ component,
74
+ fileName,
75
+ sourcePath: join(componentDir, fileName),
76
+ });
77
+ }
78
+ }
79
+ for (const [component, override] of Object.entries(config.overrides)) {
80
+ const componentDir = join(furnacePaths.overridesDir, component);
81
+ for (const fileName of await listSourceFiles(componentDir)) {
82
+ if (!isOverrideCopyCandidate(fileName, override.type))
83
+ continue;
84
+ // Same target rule as `getOverrideEngineTargetPath`, engine-relative.
85
+ const base = fileName.endsWith('.ftl') ? ftlDir : override.basePath;
86
+ sources.set(normalizePathSlashes(join(base, fileName)), {
87
+ type: 'override',
88
+ component,
89
+ fileName,
90
+ sourcePath: join(componentDir, fileName),
91
+ });
92
+ }
93
+ }
94
+ return sources;
95
+ }
96
+ async function listSourceFiles(componentDir) {
97
+ if (!(await pathExists(componentDir)))
98
+ return [];
99
+ const entries = await readdir(componentDir, { withFileTypes: true, encoding: 'utf8' });
100
+ return entries.filter((entry) => isRegularFile(entry)).map((entry) => entry.name);
101
+ }
102
+ /**
103
+ * Classifies one deployed engine file against its component source.
104
+ *
105
+ * @returns The match, or undefined when the engine copy is explained by
106
+ * none of them (a real, unrecorded engine edit)
107
+ */
108
+ async function matchDeployedSource(engineContent, source, sharedDir, appliedChecksums) {
109
+ const sourceContent = await readText(source.sourcePath);
110
+ const engine = normalizeForChecksum(engineContent);
111
+ let composed;
112
+ try {
113
+ composed = await composeComponentSource(sourceContent, source.fileName, sharedDir);
114
+ }
115
+ catch {
116
+ // A missing fragment: no composed form exists. The other two
117
+ // comparisons below still apply.
118
+ }
119
+ if (composed !== undefined && normalizeForChecksum(composed) === engine) {
120
+ return 'composed-source';
121
+ }
122
+ const strippedEngine = stripExpandedFragments(engine);
123
+ if (listFragmentIncludes(sourceContent).length > 0 &&
124
+ strippedEngine === stripExpandedFragments(normalizeForChecksum(sourceContent))) {
125
+ return 'fragment-refresh';
126
+ }
127
+ const recorded = extractComponentChecksums(appliedChecksums, source.type, source.component)[source.fileName];
128
+ // Apply records the checksum of the (unexpanded) source it deployed, so
129
+ // an engine copy with its expansions collapsed hashes to that value
130
+ // exactly when it is that deployment.
131
+ if (recorded !== undefined && sha256Hex(strippedEngine) === recorded) {
132
+ return 'previous-deploy';
133
+ }
134
+ return undefined;
135
+ }
136
+ /**
137
+ * For each engine-relative path, decides whether its current engine content
138
+ * is a Furnace deployment of its component source (see
139
+ * {@link DeployedSourceMatch}). Paths with no component source, an
140
+ * unreadable engine copy, or content explained by none of the matches are
141
+ * absent from the result.
142
+ *
143
+ * @param root - Project root
144
+ * @param engineDir - Absolute engine directory
145
+ * @param files - Engine-relative paths to classify
146
+ * @returns Engine-relative path → match, for the matching paths only
147
+ */
148
+ export async function matchDeployedComponentFiles(root, engineDir, files) {
149
+ const matches = new Map();
150
+ if (files.length === 0 || !(await furnaceConfigExists(root)))
151
+ return matches;
152
+ const sources = await mapDeployedComponentSources(root, await loadFurnaceConfig(root));
153
+ const { sharedDir } = getFurnacePaths(root);
154
+ const { appliedChecksums } = await loadFurnaceState(root);
155
+ for (const file of files) {
156
+ const source = sources.get(file);
157
+ if (source === undefined)
158
+ continue;
159
+ const enginePath = join(engineDir, file);
160
+ if (!(await pathExists(enginePath)))
161
+ continue;
162
+ const match = await matchDeployedSource(await readText(enginePath), source, sharedDir, appliedChecksums);
163
+ if (match !== undefined)
164
+ matches.set(file, match);
165
+ }
166
+ return matches;
167
+ }