@hominis/fireforge 0.49.2 → 0.51.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 (400) hide show
  1. package/CHANGELOG.md +87 -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 +41 -0
  10. package/dist/src/commands/check-report.js +91 -0
  11. package/dist/src/commands/check-run.d.ts +33 -0
  12. package/dist/src/commands/check-run.js +112 -0
  13. package/dist/src/commands/check-steps.d.ts +75 -0
  14. package/dist/src/commands/check-steps.js +200 -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/declarations-report.d.ts +43 -0
  19. package/dist/src/commands/declarations-report.js +76 -0
  20. package/dist/src/commands/doctor-engine-eslint.js +6 -1
  21. package/dist/src/commands/doctor-source-pin.js +4 -1
  22. package/dist/src/commands/export-adopt-check.d.ts +38 -0
  23. package/dist/src/commands/export-adopt-check.js +98 -0
  24. package/dist/src/commands/export-flow.js +29 -14
  25. package/dist/src/commands/export-placement-gate.d.ts +6 -2
  26. package/dist/src/commands/export-placement-gate.js +8 -0
  27. package/dist/src/commands/export-shared.d.ts +2 -1
  28. package/dist/src/commands/export-staged-registration.d.ts +46 -0
  29. package/dist/src/commands/export-staged-registration.js +91 -0
  30. package/dist/src/commands/forward-import-remedy.d.ts +32 -0
  31. package/dist/src/commands/forward-import-remedy.js +63 -0
  32. package/dist/src/commands/furnace/apply.js +3 -3
  33. package/dist/src/commands/furnace/chrome-doc-remove.d.ts +2 -1
  34. package/dist/src/commands/furnace/chrome-doc-remove.js +2 -2
  35. package/dist/src/commands/furnace/chrome-doc.d.ts +2 -1
  36. package/dist/src/commands/furnace/chrome-doc.js +2 -2
  37. package/dist/src/commands/furnace/create.js +2 -2
  38. package/dist/src/commands/furnace/deploy-named.d.ts +48 -0
  39. package/dist/src/commands/furnace/deploy-named.js +107 -0
  40. package/dist/src/commands/furnace/deploy.d.ts +3 -2
  41. package/dist/src/commands/furnace/deploy.js +50 -86
  42. package/dist/src/commands/furnace/diff.js +26 -3
  43. package/dist/src/commands/furnace/index.js +66 -41
  44. package/dist/src/commands/furnace/init.js +13 -6
  45. package/dist/src/commands/furnace/override.js +4 -2
  46. package/dist/src/commands/furnace/preview.js +4 -3
  47. package/dist/src/commands/furnace/refresh.js +2 -2
  48. package/dist/src/commands/furnace/remove.js +4 -3
  49. package/dist/src/commands/furnace/rename-dry-run.d.ts +29 -0
  50. package/dist/src/commands/furnace/rename-dry-run.js +127 -0
  51. package/dist/src/commands/furnace/rename-helpers.d.ts +7 -0
  52. package/dist/src/commands/furnace/rename-helpers.js +15 -1
  53. package/dist/src/commands/furnace/rename-registrations.d.ts +41 -0
  54. package/dist/src/commands/furnace/rename-registrations.js +98 -0
  55. package/dist/src/commands/furnace/rename.d.ts +2 -1
  56. package/dist/src/commands/furnace/rename.js +28 -66
  57. package/dist/src/commands/furnace/scan.d.ts +9 -4
  58. package/dist/src/commands/furnace/scan.js +11 -9
  59. package/dist/src/commands/furnace/sync.js +3 -2
  60. package/dist/src/commands/furnace/validate-report.d.ts +45 -0
  61. package/dist/src/commands/furnace/validate-report.js +91 -0
  62. package/dist/src/commands/furnace/validate.d.ts +1 -1
  63. package/dist/src/commands/furnace/validate.js +151 -113
  64. package/dist/src/commands/furnace/validation-output.js +14 -3
  65. package/dist/src/commands/lint-per-patch.js +36 -61
  66. package/dist/src/commands/lint-per-run-checkjs.js +2 -2
  67. package/dist/src/commands/lint-report.d.ts +8 -4
  68. package/dist/src/commands/lint-report.js +20 -5
  69. package/dist/src/commands/lint.js +15 -34
  70. package/dist/src/commands/manifest.js +4 -0
  71. package/dist/src/commands/park.d.ts +22 -0
  72. package/dist/src/commands/park.js +232 -0
  73. package/dist/src/commands/patch/delete-gap-notice.js +3 -1
  74. package/dist/src/commands/patch/delete.d.ts +1 -1
  75. package/dist/src/commands/patch/delete.js +24 -104
  76. package/dist/src/commands/patch/index.d.ts +3 -3
  77. package/dist/src/commands/patch/index.js +10 -4
  78. package/dist/src/commands/patch/lint-ignore-from.d.ts +44 -0
  79. package/dist/src/commands/patch/lint-ignore-from.js +142 -0
  80. package/dist/src/commands/patch/lint-ignore.d.ts +5 -1
  81. package/dist/src/commands/patch/lint-ignore.js +78 -31
  82. package/dist/src/commands/patch/move-files-create.js +34 -18
  83. package/dist/src/commands/patch/move-files-into.js +15 -7
  84. package/dist/src/commands/patch/move-files.js +6 -2
  85. package/dist/src/commands/patch/removal-created-files.d.ts +48 -0
  86. package/dist/src/commands/patch/removal-created-files.js +83 -0
  87. package/dist/src/commands/patch/removal-dependents.d.ts +63 -0
  88. package/dist/src/commands/patch/removal-dependents.js +133 -0
  89. package/dist/src/commands/patch/rename.js +20 -1
  90. package/dist/src/commands/patch/reserve.d.ts +33 -0
  91. package/dist/src/commands/patch/reserve.js +152 -0
  92. package/dist/src/commands/patch/retire.d.ts +34 -0
  93. package/dist/src/commands/patch/retire.js +133 -0
  94. package/dist/src/commands/patch/split-plan.d.ts +17 -8
  95. package/dist/src/commands/patch/split-plan.js +60 -28
  96. package/dist/src/commands/patch/split-staged.d.ts +108 -0
  97. package/dist/src/commands/patch/split-staged.js +202 -0
  98. package/dist/src/commands/patch/split.d.ts +5 -0
  99. package/dist/src/commands/patch/split.js +62 -17
  100. package/dist/src/commands/patch/stage.d.ts +34 -0
  101. package/dist/src/commands/patch/stage.js +153 -0
  102. package/dist/src/commands/patch/staged-dependency-batch.d.ts +48 -0
  103. package/dist/src/commands/patch/staged-dependency-batch.js +176 -0
  104. package/dist/src/commands/patch/staged-dependency-entries.d.ts +36 -0
  105. package/dist/src/commands/patch/staged-dependency-entries.js +52 -0
  106. package/dist/src/commands/patch/staged-dependency-from.d.ts +14 -0
  107. package/dist/src/commands/patch/staged-dependency-from.js +108 -0
  108. package/dist/src/commands/patch/staged-dependency-remove.d.ts +31 -0
  109. package/dist/src/commands/patch/staged-dependency-remove.js +80 -0
  110. package/dist/src/commands/patch/staged-dependency.js +54 -139
  111. package/dist/src/commands/re-export-adjacent-registrations.d.ts +20 -0
  112. package/dist/src/commands/re-export-adjacent-registrations.js +75 -0
  113. package/dist/src/commands/re-export-adjacent.d.ts +50 -2
  114. package/dist/src/commands/re-export-adjacent.js +160 -40
  115. package/dist/src/commands/re-export-bulk-scan.d.ts +2 -1
  116. package/dist/src/commands/re-export-bulk-scan.js +22 -7
  117. package/dist/src/commands/re-export-drift.d.ts +36 -8
  118. package/dist/src/commands/re-export-drift.js +73 -49
  119. package/dist/src/commands/re-export-files.js +11 -8
  120. package/dist/src/commands/re-export-hunks.d.ts +35 -0
  121. package/dist/src/commands/re-export-hunks.js +220 -0
  122. package/dist/src/commands/re-export-lint.d.ts +2 -1
  123. package/dist/src/commands/re-export-lint.js +23 -10
  124. package/dist/src/commands/re-export-options.js +74 -6
  125. package/dist/src/commands/re-export-preflight.d.ts +23 -0
  126. package/dist/src/commands/re-export-preflight.js +160 -0
  127. package/dist/src/commands/re-export-purity-scope.d.ts +18 -0
  128. package/dist/src/commands/re-export-purity-scope.js +131 -0
  129. package/dist/src/commands/re-export-register.js +56 -17
  130. package/dist/src/commands/re-export-removed.d.ts +13 -2
  131. package/dist/src/commands/re-export-removed.js +57 -12
  132. package/dist/src/commands/re-export-scan-owner.d.ts +44 -0
  133. package/dist/src/commands/re-export-scan-owner.js +116 -0
  134. package/dist/src/commands/re-export-scan.d.ts +19 -9
  135. package/dist/src/commands/re-export-scan.js +60 -24
  136. package/dist/src/commands/re-export-single.js +122 -46
  137. package/dist/src/commands/re-export-stale-registrations.d.ts +18 -0
  138. package/dist/src/commands/re-export-stale-registrations.js +62 -0
  139. package/dist/src/commands/re-export-summary.d.ts +28 -0
  140. package/dist/src/commands/re-export-summary.js +36 -0
  141. package/dist/src/commands/re-export.js +71 -57
  142. package/dist/src/commands/rebase/index.js +17 -17
  143. package/dist/src/commands/run.js +16 -2
  144. package/dist/src/commands/status-check.d.ts +55 -16
  145. package/dist/src/commands/status-check.js +92 -37
  146. package/dist/src/commands/status-json.d.ts +22 -9
  147. package/dist/src/commands/status-json.js +47 -32
  148. package/dist/src/commands/status-output.js +3 -1
  149. package/dist/src/commands/status-scope.d.ts +37 -0
  150. package/dist/src/commands/status-scope.js +54 -0
  151. package/dist/src/commands/status.js +66 -23
  152. package/dist/src/commands/test-browser-preflight.js +19 -3
  153. package/dist/src/commands/test-build-furnace.d.ts +22 -0
  154. package/dist/src/commands/test-build-furnace.js +54 -0
  155. package/dist/src/commands/test-build-phase.js +64 -41
  156. package/dist/src/commands/test-checkjs.js +10 -2
  157. package/dist/src/commands/test-diagnose.js +7 -0
  158. package/dist/src/commands/test-dispatch.d.ts +39 -0
  159. package/dist/src/commands/test-dispatch.js +211 -0
  160. package/dist/src/commands/test-furnace-reach.d.ts +37 -0
  161. package/dist/src/commands/test-furnace-reach.js +132 -0
  162. package/dist/src/commands/test-modes.d.ts +16 -1
  163. package/dist/src/commands/test-modes.js +24 -1
  164. package/dist/src/commands/test-multi-verdict.d.ts +61 -0
  165. package/dist/src/commands/test-multi-verdict.js +111 -0
  166. package/dist/src/commands/test-orchestrate.d.ts +52 -0
  167. package/dist/src/commands/test-orchestrate.js +240 -0
  168. package/dist/src/commands/test-perf-scope.d.ts +50 -0
  169. package/dist/src/commands/test-perf-scope.js +138 -0
  170. package/dist/src/commands/test-prelock.d.ts +51 -0
  171. package/dist/src/commands/test-prelock.js +273 -0
  172. package/dist/src/commands/test-register.js +69 -10
  173. package/dist/src/commands/test-results.d.ts +62 -0
  174. package/dist/src/commands/test-results.js +62 -0
  175. package/dist/src/commands/test-run.d.ts +13 -55
  176. package/dist/src/commands/test-run.js +74 -99
  177. package/dist/src/commands/test-shards.d.ts +110 -0
  178. package/dist/src/commands/test-shards.js +255 -0
  179. package/dist/src/commands/test-stale-gate.d.ts +5 -1
  180. package/dist/src/commands/test-stale-gate.js +60 -28
  181. package/dist/src/commands/test-verdict.d.ts +11 -1
  182. package/dist/src/commands/test-verdict.js +28 -1
  183. package/dist/src/commands/test.d.ts +6 -1
  184. package/dist/src/commands/test.js +120 -124
  185. package/dist/src/commands/token-lint.d.ts +14 -0
  186. package/dist/src/commands/token-lint.js +151 -0
  187. package/dist/src/commands/token.js +10 -0
  188. package/dist/src/commands/verify-scope.d.ts +28 -0
  189. package/dist/src/commands/verify-scope.js +67 -0
  190. package/dist/src/commands/verify.d.ts +10 -2
  191. package/dist/src/commands/verify.js +67 -36
  192. package/dist/src/core/build-baseline-types.d.ts +46 -0
  193. package/dist/src/core/build-baseline-validate.js +19 -0
  194. package/dist/src/core/build-baseline.d.ts +5 -1
  195. package/dist/src/core/build-baseline.js +3 -1
  196. package/dist/src/core/build-overwrite-guard.d.ts +50 -0
  197. package/dist/src/core/build-overwrite-guard.js +84 -4
  198. package/dist/src/core/build-prepare.d.ts +13 -0
  199. package/dist/src/core/build-prepare.js +42 -10
  200. package/dist/src/core/build-run-record.d.ts +74 -0
  201. package/dist/src/core/build-run-record.js +136 -0
  202. package/dist/src/core/checkjs-support-heads.d.ts +41 -0
  203. package/dist/src/core/checkjs-support-heads.js +104 -0
  204. package/dist/src/core/config-paths.d.ts +2 -2
  205. package/dist/src/core/config-paths.js +10 -0
  206. package/dist/src/core/config-validate-check.d.ts +8 -0
  207. package/dist/src/core/config-validate-check.js +219 -0
  208. package/dist/src/core/config-validate-file-size.d.ts +9 -6
  209. package/dist/src/core/config-validate-file-size.js +91 -2
  210. package/dist/src/core/config-validate-history.d.ts +11 -0
  211. package/dist/src/core/config-validate-history.js +38 -0
  212. package/dist/src/core/config-validate-patch-policy.js +100 -0
  213. package/dist/src/core/config-validate-test-toolchains.js +21 -0
  214. package/dist/src/core/config-validate-token-scales.d.ts +9 -0
  215. package/dist/src/core/config-validate-token-scales.js +92 -0
  216. package/dist/src/core/config-validate.js +20 -5
  217. package/dist/src/core/config.d.ts +5 -0
  218. package/dist/src/core/config.js +16 -0
  219. package/dist/src/core/coverage-dispatch.d.ts +24 -0
  220. package/dist/src/core/coverage-dispatch.js +44 -0
  221. package/dist/src/core/coverage-extend.d.ts +70 -45
  222. package/dist/src/core/coverage-extend.js +199 -144
  223. package/dist/src/core/coverage-records.d.ts +108 -0
  224. package/dist/src/core/coverage-records.js +384 -0
  225. package/dist/src/core/destructive.d.ts +7 -4
  226. package/dist/src/core/destructive.js +10 -10
  227. package/dist/src/core/engine-session-lock.d.ts +16 -1
  228. package/dist/src/core/engine-session-lock.js +38 -10
  229. package/dist/src/core/export-moved-code-shape.d.ts +49 -11
  230. package/dist/src/core/export-moved-code-shape.js +135 -44
  231. package/dist/src/core/file-lock-queue.d.ts +67 -0
  232. package/dist/src/core/file-lock-queue.js +221 -0
  233. package/dist/src/core/file-lock-shared.d.ts +57 -0
  234. package/dist/src/core/file-lock-shared.js +117 -0
  235. package/dist/src/core/file-lock.d.ts +13 -25
  236. package/dist/src/core/file-lock.js +163 -140
  237. package/dist/src/core/furnace-apply-output.d.ts +10 -0
  238. package/dist/src/core/furnace-apply-output.js +31 -1
  239. package/dist/src/core/furnace-apply.js +22 -12
  240. package/dist/src/core/furnace-composed-source.d.ts +76 -0
  241. package/dist/src/core/furnace-composed-source.js +167 -0
  242. package/dist/src/core/furnace-config-validate-exceptions.d.ts +16 -0
  243. package/dist/src/core/furnace-config-validate-exceptions.js +102 -0
  244. package/dist/src/core/furnace-config-validate.js +14 -0
  245. package/dist/src/core/furnace-css-fragments.d.ts +10 -0
  246. package/dist/src/core/furnace-css-fragments.js +26 -0
  247. package/dist/src/core/furnace-operation.d.ts +3 -3
  248. package/dist/src/core/furnace-registration-jar-inplace.d.ts +12 -0
  249. package/dist/src/core/furnace-registration-jar-inplace.js +80 -0
  250. package/dist/src/core/furnace-registration-remove.d.ts +22 -1
  251. package/dist/src/core/furnace-registration-remove.js +53 -17
  252. package/dist/src/core/furnace-registration-rename.d.ts +26 -0
  253. package/dist/src/core/furnace-registration-rename.js +77 -0
  254. package/dist/src/core/furnace-registration.d.ts +14 -4
  255. package/dist/src/core/furnace-registration.js +44 -10
  256. package/dist/src/core/furnace-staleness.d.ts +7 -0
  257. package/dist/src/core/furnace-staleness.js +34 -24
  258. package/dist/src/core/furnace-validate-accessibility.d.ts +8 -4
  259. package/dist/src/core/furnace-validate-accessibility.js +71 -28
  260. package/dist/src/core/furnace-validate-exceptions.d.ts +48 -0
  261. package/dist/src/core/furnace-validate-exceptions.js +80 -0
  262. package/dist/src/core/furnace-validate-icon-button.d.ts +7 -0
  263. package/dist/src/core/furnace-validate-icon-button.js +78 -0
  264. package/dist/src/core/furnace-validate.js +1 -1
  265. package/dist/src/core/harness-port-fallback.d.ts +60 -0
  266. package/dist/src/core/harness-port-fallback.js +96 -0
  267. package/dist/src/core/history-log-file.d.ts +61 -0
  268. package/dist/src/core/history-log-file.js +164 -0
  269. package/dist/src/core/history-log.d.ts +2 -1
  270. package/dist/src/core/history-log.js +2 -1
  271. package/dist/src/core/json-preserving-edit.d.ts +22 -0
  272. package/dist/src/core/json-preserving-edit.js +199 -0
  273. package/dist/src/core/line-prefixer.d.ts +18 -0
  274. package/dist/src/core/line-prefixer.js +34 -0
  275. package/dist/src/core/mach.d.ts +7 -0
  276. package/dist/src/core/mach.js +32 -10
  277. package/dist/src/core/module-resolution-preflight.d.ts +19 -3
  278. package/dist/src/core/module-resolution-preflight.js +24 -6
  279. package/dist/src/core/negative-control-compiled.d.ts +27 -0
  280. package/dist/src/core/negative-control-compiled.js +73 -0
  281. package/dist/src/core/negative-control-diff.d.ts +51 -0
  282. package/dist/src/core/negative-control-diff.js +179 -0
  283. package/dist/src/core/negative-control.d.ts +150 -0
  284. package/dist/src/core/negative-control.js +399 -0
  285. package/dist/src/core/objdir-install-manifests.d.ts +73 -0
  286. package/dist/src/core/objdir-install-manifests.js +226 -0
  287. package/dist/src/core/park-store.d.ts +63 -0
  288. package/dist/src/core/park-store.js +216 -0
  289. package/dist/src/core/patch-export-update.d.ts +6 -0
  290. package/dist/src/core/patch-export-update.js +13 -3
  291. package/dist/src/core/patch-export.d.ts +3 -2
  292. package/dist/src/core/patch-export.js +22 -16
  293. package/dist/src/core/patch-files.js +26 -8
  294. package/dist/src/core/patch-lint-checkjs-any-import.d.ts +39 -0
  295. package/dist/src/core/patch-lint-checkjs-any-import.js +63 -0
  296. package/dist/src/core/patch-lint-checkjs-diagnostics.d.ts +17 -0
  297. package/dist/src/core/patch-lint-checkjs-diagnostics.js +32 -0
  298. package/dist/src/core/patch-lint-checkjs.d.ts +10 -1
  299. package/dist/src/core/patch-lint-checkjs.js +20 -13
  300. package/dist/src/core/patch-lint-code-lines.d.ts +59 -0
  301. package/dist/src/core/patch-lint-code-lines.js +105 -0
  302. package/dist/src/core/patch-lint-cross.d.ts +3 -1
  303. package/dist/src/core/patch-lint-cross.js +17 -4
  304. package/dist/src/core/patch-lint-css.d.ts +8 -2
  305. package/dist/src/core/patch-lint-css.js +24 -14
  306. package/dist/src/core/patch-lint-file-size.d.ts +12 -5
  307. package/dist/src/core/patch-lint-file-size.js +22 -11
  308. package/dist/src/core/patch-lint-forward-registration-extended.d.ts +23 -0
  309. package/dist/src/core/patch-lint-forward-registration-extended.js +36 -11
  310. package/dist/src/core/patch-lint-forward-registration.js +10 -0
  311. package/dist/src/core/patch-lint-ignore.d.ts +196 -0
  312. package/dist/src/core/patch-lint-ignore.js +429 -0
  313. package/dist/src/core/patch-lint-ownership.d.ts +10 -0
  314. package/dist/src/core/patch-lint-ownership.js +26 -0
  315. package/dist/src/core/patch-lint-size-thresholds.d.ts +52 -0
  316. package/dist/src/core/patch-lint-size-thresholds.js +110 -0
  317. package/dist/src/core/patch-lint-size.d.ts +117 -0
  318. package/dist/src/core/patch-lint-size.js +331 -0
  319. package/dist/src/core/patch-lint-staged-registration.d.ts +18 -1
  320. package/dist/src/core/patch-lint-staged-registration.js +20 -0
  321. package/dist/src/core/patch-lint-token-scale.d.ts +57 -0
  322. package/dist/src/core/patch-lint-token-scale.js +286 -0
  323. package/dist/src/core/patch-lint.d.ts +11 -83
  324. package/dist/src/core/patch-lint.js +66 -288
  325. package/dist/src/core/patch-lock.js +7 -6
  326. package/dist/src/core/patch-manifest-consistency.js +7 -6
  327. package/dist/src/core/patch-manifest-io.d.ts +6 -3
  328. package/dist/src/core/patch-manifest-io.js +27 -9
  329. package/dist/src/core/patch-manifest-key-order.d.ts +24 -0
  330. package/dist/src/core/patch-manifest-key-order.js +61 -0
  331. package/dist/src/core/patch-manifest-stage.d.ts +46 -0
  332. package/dist/src/core/patch-manifest-stage.js +96 -0
  333. package/dist/src/core/patch-manifest-validate.js +61 -3
  334. package/dist/src/core/patch-order-legal.d.ts +26 -0
  335. package/dist/src/core/patch-order-legal.js +88 -0
  336. package/dist/src/core/patch-policy-content.d.ts +40 -0
  337. package/dist/src/core/patch-policy-content.js +216 -0
  338. package/dist/src/core/patch-policy.d.ts +22 -5
  339. package/dist/src/core/patch-policy.js +26 -12
  340. package/dist/src/core/patch-retired.d.ts +57 -0
  341. package/dist/src/core/patch-retired.js +92 -0
  342. package/dist/src/core/patch-subset.d.ts +16 -0
  343. package/dist/src/core/patch-subset.js +66 -0
  344. package/dist/src/core/preflight-wait.d.ts +20 -0
  345. package/dist/src/core/preflight-wait.js +38 -0
  346. package/dist/src/core/rebase-from-version.d.ts +46 -0
  347. package/dist/src/core/rebase-from-version.js +71 -0
  348. package/dist/src/core/run-log.d.ts +26 -0
  349. package/dist/src/core/run-log.js +106 -4
  350. package/dist/src/core/run-profile.d.ts +7 -0
  351. package/dist/src/core/run-profile.js +7 -1
  352. package/dist/src/core/smoke-patterns.d.ts +5 -0
  353. package/dist/src/core/smoke-patterns.js +13 -0
  354. package/dist/src/core/test-harness-crash.d.ts +19 -1
  355. package/dist/src/core/test-harness-crash.js +8 -9
  356. package/dist/src/core/test-harness-signature.d.ts +33 -0
  357. package/dist/src/core/test-harness-signature.js +23 -1
  358. package/dist/src/core/test-raw-log.d.ts +66 -0
  359. package/dist/src/core/test-raw-log.js +122 -0
  360. package/dist/src/core/test-run-completeness.d.ts +90 -0
  361. package/dist/src/core/test-run-completeness.js +184 -0
  362. package/dist/src/core/test-stale-check.d.ts +27 -1
  363. package/dist/src/core/test-stale-check.js +39 -49
  364. package/dist/src/core/test-stale-exempt.d.ts +40 -0
  365. package/dist/src/core/test-stale-exempt.js +70 -0
  366. package/dist/src/core/test-xpcshell-retry.d.ts +2 -0
  367. package/dist/src/core/test-xpcshell-retry.js +2 -1
  368. package/dist/src/core/token-lint.d.ts +53 -0
  369. package/dist/src/core/token-lint.js +131 -0
  370. package/dist/src/core/token-sibling-sheets.d.ts +46 -0
  371. package/dist/src/core/token-sibling-sheets.js +119 -0
  372. package/dist/src/core/tree-guard.js +5 -0
  373. package/dist/src/core/typecheck-shim.d.ts +6 -2
  374. package/dist/src/core/typecheck-shim.js +205 -4
  375. package/dist/src/core/typecheck.js +3 -1
  376. package/dist/src/index.d.ts +14 -3
  377. package/dist/src/index.js +6 -1
  378. package/dist/src/types/commands/index.d.ts +2 -2
  379. package/dist/src/types/commands/options.d.ts +212 -24
  380. package/dist/src/types/commands/patches.d.ts +118 -1
  381. package/dist/src/types/config.d.ts +264 -0
  382. package/dist/src/types/config.js +9 -0
  383. package/dist/src/types/furnace.d.ts +63 -1
  384. package/dist/src/types/furnace.js +7 -0
  385. package/dist/src/types/index.d.ts +1 -1
  386. package/dist/src/utils/options.d.ts +36 -7
  387. package/dist/src/utils/options.js +94 -8
  388. package/dist/src/utils/path-glob.d.ts +15 -0
  389. package/dist/src/utils/path-glob.js +47 -0
  390. package/dist/src/utils/regex.d.ts +15 -3
  391. package/dist/src/utils/regex.js +37 -16
  392. package/docs/configuration.md +369 -11
  393. package/docs/exit-codes.md +18 -1
  394. package/docs/furnace.md +139 -3
  395. package/docs/lifecycle-invariants.md +65 -30
  396. package/docs/machine-output.md +120 -12
  397. package/docs/patch-workflow.md +458 -22
  398. package/docs/run-logs.md +20 -1
  399. package/docs/testing.md +346 -21
  400. package/package.json +3 -3
@@ -0,0 +1,117 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Shared holds on a FireForge directory lock.
4
+ *
5
+ * Every lock used to be exclusive, so two build-less `fireforge test` runs
6
+ * on one objdir, each with its own profile and (under auto ports) its own
7
+ * ports, still waited for each other: the gate's browser tiers ran strictly
8
+ * serially on a 14-core host. A shared hold lets such runs overlap while
9
+ * anything that writes (a build, a re-export, a test run that builds)
10
+ * still holds the lock alone.
11
+ *
12
+ * The scheme reuses the exclusive lock as a turnstile, so arrival order and
13
+ * stale-holder reaping come from the machinery that already exists:
14
+ *
15
+ * - A shared holder takes the exclusive lock in the ordinary way, records
16
+ * itself under `<lock>.shared/<uuid>/` (an owner record like the lock's
17
+ * own), and releases the exclusive lock at once.
18
+ * - An exclusive holder takes the exclusive lock, then waits until no live
19
+ * shared holder is left. While it waits it already holds the turnstile,
20
+ * so no new shared holder can slip in ahead of it: a writer queued
21
+ * behind readers is served once they finish, never starved by newcomers.
22
+ *
23
+ * A shared holder whose process died is reaped by the same PID-first
24
+ * liveness check the exclusive lock uses.
25
+ */
26
+ import { randomUUID } from 'node:crypto';
27
+ import { mkdir, readdir, rm } from 'node:fs/promises';
28
+ import { join } from 'node:path';
29
+ import { getNodeErrorCode, toError } from '../utils/errors.js';
30
+ import { verbose } from '../utils/logger.js';
31
+ import { isLockOwnerAlive, readLockOwner, writeLockOwner } from './file-lock-owner.js';
32
+ /** The directory holding a lock's shared-holder records. */
33
+ export function sharedHoldersDir(lockPath) {
34
+ return `${lockPath}.shared`;
35
+ }
36
+ /** Holder directories this process registered, for the signal sweep. */
37
+ const heldShares = new Set();
38
+ /**
39
+ * Counts the live shared holders of a lock, reaping any whose process is
40
+ * gone. A missing directory means none.
41
+ *
42
+ * @param lockPath - The lock directory
43
+ * @param options - `reap: false` only counts (status reads)
44
+ */
45
+ export async function countLiveSharedHolders(lockPath, options = {}) {
46
+ const dir = sharedHoldersDir(lockPath);
47
+ let entries;
48
+ try {
49
+ entries = await readdir(dir);
50
+ }
51
+ catch (error) {
52
+ if (getNodeErrorCode(error) === 'ENOENT')
53
+ return 0;
54
+ if (options.reap === false)
55
+ return 0;
56
+ throw toError(error);
57
+ }
58
+ let live = 0;
59
+ for (const entry of entries) {
60
+ const holder = join(dir, entry);
61
+ const owner = await readLockOwner(holder);
62
+ if (owner.present && isLockOwnerAlive(owner)) {
63
+ live += 1;
64
+ continue;
65
+ }
66
+ // A record still being written has no owner yet; only an owner that
67
+ // names a dead process is reaped, so a racing registration survives.
68
+ if (owner.present && options.reap !== false) {
69
+ verbose(`Reaping shared hold ${holder}: PID ${owner.pid} is gone.`);
70
+ await rm(holder, { recursive: true, force: true });
71
+ }
72
+ }
73
+ return live;
74
+ }
75
+ /**
76
+ * Records a shared hold. Call while holding the lock's exclusive turnstile.
77
+ *
78
+ * @param lockPath - The lock directory
79
+ * @param metadata - Owner metadata lines (`command=…`)
80
+ */
81
+ export async function registerSharedHold(lockPath, metadata) {
82
+ const holder = join(sharedHoldersDir(lockPath), randomUUID());
83
+ await mkdir(holder, { recursive: true });
84
+ await writeLockOwner(holder, randomUUID(), [...(metadata ?? []), 'mode=shared']);
85
+ heldShares.add(holder);
86
+ return {
87
+ async release() {
88
+ heldShares.delete(holder);
89
+ try {
90
+ await rm(holder, { recursive: true, force: true });
91
+ }
92
+ catch (error) {
93
+ verbose(`Could not remove shared hold ${holder}: ${toError(error).message}`);
94
+ }
95
+ },
96
+ };
97
+ }
98
+ /**
99
+ * Removes every shared hold this process registered, for the signal path.
100
+ * Never throws.
101
+ *
102
+ * @returns The holder paths removed
103
+ */
104
+ export async function releaseSharedHoldsForSignal() {
105
+ const removed = [];
106
+ for (const holder of [...heldShares]) {
107
+ heldShares.delete(holder);
108
+ try {
109
+ await rm(holder, { recursive: true, force: true });
110
+ removed.push(holder);
111
+ }
112
+ catch {
113
+ // Shutdown path: nothing to do.
114
+ }
115
+ }
116
+ return removed;
117
+ }
@@ -1,5 +1,9 @@
1
1
  import { LOCK_PID_FILE } from './file-lock-owner.js';
2
+ import { type LockQueueState } from './file-lock-queue.js';
3
+ import { type LockMode } from './file-lock-shared.js';
2
4
  export { LOCK_PID_FILE };
5
+ export { formatLockQueuePosition, type LockQueueState } from './file-lock-queue.js';
6
+ export type { LockMode } from './file-lock-shared.js';
3
7
  /**
4
8
  * Snapshot of the current lock holder handed to {@link FileLockOptions.onWaitProgress}.
5
9
  * `metadata` carries the pid file's human-diagnostic lines (line 3 onward,
@@ -11,20 +15,6 @@ export interface LockHolder {
11
15
  alive: boolean;
12
16
  metadata: string[];
13
17
  }
14
- /**
15
- * Live queue state around a contended lock.
16
- *
17
- * The lock itself behaves correctly under several concurrent sessions. The
18
- * gap this closes is visibility. Waiters advertise themselves in a sibling
19
- * directory so both the wait output and `fireforge status --lock` can say
20
- * how many are queued and how many are ahead of you.
21
- */
22
- export interface LockQueueState {
23
- /** Live waiters currently queued for the lock, including this process. */
24
- depth: number;
25
- /** Live waiters that started waiting before this process. */
26
- ahead: number;
27
- }
28
18
  export interface FileLockOptions {
29
19
  timeoutMs?: number;
30
20
  pollMs?: number;
@@ -32,6 +22,8 @@ export interface FileLockOptions {
32
22
  * When set, the poll interval doubles after every contended poll (starting
33
23
  * from `pollMs`) up to this cap. When absent, polling stays at the fixed
34
24
  * `pollMs` interval, with zero behavior change for existing callers.
25
+ * Keep it far below `WAITER_STALE_MS` (30 s): a waiter refreshes its queue
26
+ * ticket between polls, and a ticket left unrefreshed that long is skipped.
35
27
  */
36
28
  pollMaxMs?: number;
37
29
  staleMs?: number;
@@ -94,20 +86,14 @@ export interface FileLockOptions {
94
86
  onStaleLockMessage?: (ageMs: number) => string | undefined;
95
87
  /** Extra owner-file lines for human diagnostics. Lock mechanics ignore them. */
96
88
  ownerMetadata?: readonly string[];
89
+ /**
90
+ * `exclusive` (default) or `shared`. Shared holders overlap each other and
91
+ * exclude exclusive ones; see `file-lock-shared.ts`.
92
+ */
93
+ mode?: LockMode | undefined;
97
94
  }
98
95
  /** Derives the sibling lock-directory path used to guard a file-based resource. */
99
96
  export declare function createSiblingLockPath(filePath: string, suffix?: string): string;
100
- /**
101
- * Reads the live waiter queue around `lockPath`, dropping entries whose
102
- * process is gone (a killed waiter must not inflate the depth forever).
103
- *
104
- * Advisory and fail-open: an unreadable registry reports an empty queue
105
- * rather than failing whatever asked.
106
- *
107
- * @param lockPath - The lock directory whose queue to inspect
108
- * @param selfStartedAt - This process's wait start, to compute `ahead`
109
- */
110
- export declare function readLockQueue(lockPath: string, selfStartedAt?: number): Promise<LockQueueState>;
111
97
  /**
112
98
  * Snapshot of a lock for `fireforge status --lock`: who holds
113
99
  * it, for how long, and how deep the queue behind it is.
@@ -118,6 +104,8 @@ export interface LockStatusSnapshot {
118
104
  /** Milliseconds since the lock directory was created, when held. */
119
105
  heldForMs?: number | undefined;
120
106
  queueDepth: number;
107
+ /** Live shared holders (build-less test runs under a shared lock). */
108
+ sharedHolders?: number | undefined;
121
109
  }
122
110
  /**
123
111
  * Inspects a lock without acquiring it. Read-only and fail-open: a missing
@@ -1,14 +1,17 @@
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';
12
+ import { countLiveSharedHolders, registerSharedHold, releaseSharedHoldsForSignal, } from './file-lock-shared.js';
11
13
  export { LOCK_PID_FILE };
14
+ export { formatLockQueuePosition } from './file-lock-queue.js';
12
15
  const DEFAULT_LOCK_TIMEOUT_MS = 30_000;
13
16
  const DEFAULT_LOCK_POLL_MS = 50;
14
17
  const DEFAULT_STALE_LOCK_MS = 5 * 60_000;
@@ -30,52 +33,14 @@ const WAIT_PROBE_INTERVAL_MS = 5_000;
30
33
  export function createSiblingLockPath(filePath, suffix = '.fireforge.lock') {
31
34
  return `${filePath}${suffix}`;
32
35
  }
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
36
  /**
74
37
  * Inspects a lock without acquiring it. Read-only and fail-open: a missing
75
38
  * lock is reported as free, never as an error.
76
39
  */
77
40
  export async function readLockStatus(lockPath) {
78
41
  const queue = await readLockQueue(lockPath);
42
+ const shared = await countLiveSharedHolders(lockPath, { reap: false });
43
+ const sharedPart = shared > 0 ? { sharedHolders: shared } : {};
79
44
  let heldForMs;
80
45
  try {
81
46
  // Clamped: filesystem mtime resolution can round a just-created lock
@@ -84,36 +49,13 @@ export async function readLockStatus(lockPath) {
84
49
  heldForMs = Math.max(0, Date.now() - (await stat(lockPath)).mtimeMs);
85
50
  }
86
51
  catch {
87
- return { held: false, queueDepth: queue.depth };
52
+ return { held: false, queueDepth: queue.depth, ...sharedPart };
88
53
  }
89
54
  const owner = await readLockOwner(lockPath);
90
55
  const holder = owner.present
91
56
  ? { pid: owner.pid, alive: isLockOwnerAlive(owner), metadata: owner.metadata }
92
57
  : undefined;
93
- return { held: true, holder, heldForMs, queueDepth: queue.depth };
94
- }
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
- };
58
+ return { held: true, holder, heldForMs, queueDepth: queue.depth, ...sharedPart };
117
59
  }
118
60
  /**
119
61
  * Reads the contended lock's owner and queue in one go. One probe per cycle
@@ -122,12 +64,12 @@ async function registerWaiter(lockPath, startedAt) {
122
64
  * degrades to `holder: undefined`, since a diagnostic must never break the
123
65
  * wait loop.
124
66
  */
125
- async function probeWaitState(lockPath, startedAt) {
67
+ async function probeWaitState(lockPath, self) {
126
68
  const owner = await readLockOwner(lockPath);
127
69
  const holder = owner.present
128
70
  ? { pid: owner.pid, alive: isLockOwnerAlive(owner), metadata: owner.metadata }
129
71
  : undefined;
130
- return { holder, queue: await readLockQueue(lockPath, startedAt) };
72
+ return { holder, queue: await readLockQueue(lockPath, self) };
131
73
  }
132
74
  /**
133
75
  * Reaps a lock directory believed to be stale, safely against concurrent
@@ -345,16 +287,24 @@ function formatQueuePositionReached(queue) {
345
287
  * caller asked for it. Split out of the wait loop so the loop stays a
346
288
  * readable state machine.
347
289
  */
348
- async function runWaitProbe(lockPath, startedAt, timeoutMs, options) {
349
- const { holder, queue } = await probeWaitState(lockPath, startedAt);
290
+ async function runWaitProbe(lockPath, self, timeoutMs, options) {
291
+ const { holder, queue } = await probeWaitState(lockPath, self);
350
292
  options.onWaitProgress?.({
351
- waitedMs: Date.now() - startedAt,
293
+ waitedMs: Date.now() - self.startedAt,
352
294
  timeoutMs,
353
295
  holder,
354
296
  queue,
355
297
  });
356
298
  return { queue };
357
299
  }
300
+ /**
301
+ * Whether it is this waiter's turn to contend for the lock: no live waiter
302
+ * arrived before it. Ties and dead or stalled tickets are settled by
303
+ * {@link readLockQueue}, so every waiter agrees on who is at the head.
304
+ */
305
+ async function isWaiterTurn(lockPath, startedAt, waiter) {
306
+ return (await readLockQueue(lockPath, { startedAt, ticket: waiter.ticket })).ahead === 0;
307
+ }
358
308
  /**
359
309
  * Lock directories this process currently holds.
360
310
  *
@@ -387,6 +337,7 @@ export async function forceReleaseHeldLocksForSignal() {
387
337
  removed.push(lockPath);
388
338
  }
389
339
  }
340
+ removed.push(...(await releaseSharedHoldsForSignal()));
390
341
  return removed;
391
342
  }
392
343
  /** Runs an async operation while holding a directory lock, with stale-lock recovery. */
@@ -404,8 +355,14 @@ export async function withFileLock(lockPath, operation, options = {}) {
404
355
  let lastProbeAt = startedAt;
405
356
  /** Best (lowest) queue position seen so far. Used to detect an advance. */
406
357
  let bestAhead;
407
- /** Set on first contention so an uncontended acquire costs nothing. */
408
- let deregisterWaiter;
358
+ /**
359
+ * This waiter's ticket in the queue. Taken on first contention, or up
360
+ * front when others are already queued, so an uncontended acquire never
361
+ * writes one.
362
+ */
363
+ let waiter;
364
+ /** The last `EEXIST`, carried as the cause of a timeout refusal. */
365
+ let contention;
409
366
  // One cadence drives both the progress line and the deadline extension.
410
367
  // Either feature alone is enough to want the probe. Neither is worth a
411
368
  // second timer.
@@ -414,83 +371,97 @@ export async function withFileLock(lockPath, operation, options = {}) {
414
371
  extend !== undefined;
415
372
  await ensureDir(dirname(lockPath));
416
373
  try {
374
+ // Arrival order: a newcomer that finds others already queued takes a
375
+ // ticket behind them instead of racing them for the next free moment.
376
+ // Without this a fresh command could take the lock in the gap between
377
+ // the holder's release and the head waiter's next poll.
378
+ if ((await readLockQueue(lockPath)).depth > 0) {
379
+ waiter = await registerWaiter(lockPath, startedAt);
380
+ }
417
381
  for (;;) {
418
- try {
419
- await mkdir(lockPath);
420
- break;
421
- }
422
- catch (error) {
423
- if (getNodeErrorCode(error) !== 'EEXIST') {
424
- throw error;
425
- }
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;
434
- }
382
+ // Only the head of the queue contends for the lock. A waiter without
383
+ // a ticket (no contention seen yet, or a registry that could not be
384
+ // written) contends as every waiter did before tickets existed.
385
+ if (waiter?.ticket === undefined || (await isWaiterTurn(lockPath, startedAt, waiter))) {
386
+ try {
387
+ await mkdir(lockPath);
388
+ break;
435
389
  }
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
- }
390
+ catch (error) {
391
+ if (getNodeErrorCode(error) !== 'EEXIST') {
392
+ throw error;
472
393
  }
394
+ contention = error;
473
395
  }
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));
396
+ }
397
+ // Take a ticket so the queue is visible to the wait output and to
398
+ // `fireforge status --lock`, and so the grant follows arrival order.
399
+ waiter ??= await registerWaiter(lockPath, startedAt);
400
+ await waiter.heartbeat();
401
+ const self = { startedAt, ticket: waiter.ticket };
402
+ if (lastStaleProbeAt === undefined || Date.now() - lastStaleProbeAt >= staleReprobeMs) {
403
+ lastStaleProbeAt = Date.now();
404
+ if (await removeIfStaleLock(lockPath, staleMs, options.onStaleLockMessage)) {
405
+ continue;
482
406
  }
483
- await sleep(currentPollMs);
484
- if (options.pollMaxMs !== undefined) {
485
- currentPollMs = Math.min(currentPollMs * 2, options.pollMaxMs);
407
+ }
408
+ // The probe runs before the deadline check, so a due progress line is
409
+ // written before the refusal that follows it, never lost to it. On a
410
+ // slow host (a cold Windows CI runner) the first iteration alone, with
411
+ // its waiter registration and stale probe, can outlast a short budget;
412
+ // checked the other way round, the wait timed out having reported
413
+ // nothing, and an advance that probe would have seen could not extend
414
+ // the deadline it was about to miss.
415
+ if (probeWanted && Date.now() - lastProbeAt >= probeIntervalMs) {
416
+ lastProbeAt = Date.now();
417
+ // Report the budget that is currently in force, not the one
418
+ // originally requested: once an extension has been granted, the
419
+ // requested figure is stale, and a progress line that keeps
420
+ // quoting it reads as a timeout that does not fire.
421
+ const probe = await runWaitProbe(lockPath, self, deadline - startedAt, options);
422
+ // An advance (someone ahead of us took their turn and finished)
423
+ // buys a fresh budget, because the wait is working. Only a queue
424
+ // that stops moving starves, which is the case the timeout is for.
425
+ //
426
+ // The first observation is a seed, never an advance. `bestAhead`
427
+ // used to start `undefined`, so the opening probe always satisfied
428
+ // this test and handed out a free full budget: a `--wait-lock 300`
429
+ // run outlived its 300 s even against a queue that never moved
430
+ // once, which is the "it does not reliably give up after N
431
+ // seconds" behaviour a downstream fork reported.
432
+ if (hardDeadline !== undefined) {
433
+ if (bestAhead === undefined) {
434
+ bestAhead = probe.queue.ahead;
435
+ }
436
+ else if (probe.queue.ahead < bestAhead) {
437
+ bestAhead = probe.queue.ahead;
438
+ deadline = Math.min(Date.now() + timeoutMs, hardDeadline);
439
+ options.onWaitExtended?.({
440
+ ahead: probe.queue.ahead,
441
+ budgetMs: deadline - startedAt,
442
+ });
443
+ }
486
444
  }
487
445
  }
446
+ if (Date.now() >= deadline) {
447
+ // The queue is read here, not carried over from the last progress
448
+ // probe: the position the wait reached is the one number that says
449
+ // whether a larger budget would have helped, and it must not depend
450
+ // on a probe having happened to land inside the budget. We are still
451
+ // registered at this point. The ticket is returned in the `finally`
452
+ // below, which is only entered once this refusal has been built.
453
+ throw await buildLockTimeoutError(lockPath, options.onTimeoutMessage, contention, await readLockQueue(lockPath, self));
454
+ }
455
+ await sleep(currentPollMs);
456
+ if (options.pollMaxMs !== undefined) {
457
+ currentPollMs = Math.min(currentPollMs * 2, options.pollMaxMs);
458
+ }
488
459
  }
489
460
  }
490
461
  finally {
491
462
  // Leaving the queue happens the moment we stop waiting, whether we
492
463
  // acquired the lock or timed out.
493
- await deregisterWaiter?.();
464
+ await waiter?.deregister();
494
465
  }
495
466
  // Stamp ownership (PID + per-acquisition token + acquisition time) into
496
467
  // the lock directory so stale-lock recovery can check liveness and release
@@ -500,8 +471,36 @@ export async function withFileLock(lockPath, operation, options = {}) {
500
471
  // `writeLockOwner` removes the just-created directory before throwing.
501
472
  const ownerToken = randomUUID();
502
473
  await writeLockOwner(lockPath, ownerToken, options.ownerMetadata);
474
+ return runWhileHeld(lockPath, ownerToken, operation, options, {
475
+ deadline: Math.max(deadline, Date.now() + pollMs),
476
+ pollMs,
477
+ });
478
+ }
479
+ /**
480
+ * Runs `operation` once the turnstile is ours: as a shared holder (turnstile
481
+ * released at once), or exclusively once every shared holder has drained.
482
+ */
483
+ async function runWhileHeld(lockPath, ownerToken, operation, options, drain) {
484
+ if (options.mode === 'shared') {
485
+ // The exclusive lock was only the turnstile: record the shared hold and
486
+ // let the next waiter in. See file-lock-shared.ts.
487
+ let hold;
488
+ try {
489
+ hold = await registerSharedHold(lockPath, options.ownerMetadata);
490
+ }
491
+ finally {
492
+ await releaseLock(lockPath, ownerToken);
493
+ }
494
+ try {
495
+ return await operation();
496
+ }
497
+ finally {
498
+ await hold.release();
499
+ }
500
+ }
503
501
  heldLocks.add(lockPath);
504
502
  try {
503
+ await waitForSharedHoldersToDrain(lockPath, drain.deadline, drain.pollMs);
505
504
  return await operation();
506
505
  }
507
506
  finally {
@@ -509,3 +508,27 @@ export async function withFileLock(lockPath, operation, options = {}) {
509
508
  await releaseLock(lockPath, ownerToken);
510
509
  }
511
510
  }
511
+ /**
512
+ * Holds an exclusive acquisition until every live shared holder has gone.
513
+ * The turnstile is already ours, so no new shared holder can join meanwhile.
514
+ *
515
+ * @throws LockContentionError when shared holders outlast the budget
516
+ */
517
+ async function waitForSharedHoldersToDrain(lockPath, deadline, pollMs) {
518
+ let announced = false;
519
+ for (;;) {
520
+ const live = await countLiveSharedHolders(lockPath);
521
+ if (live === 0)
522
+ return;
523
+ if (Date.now() >= deadline) {
524
+ throw new LockContentionError(`Timed out waiting for file lock ${lockPath}: ${live} shared holder(s) (build-less ` +
525
+ 'test runs under a shared lock) still hold it. Wait for them to finish, or retry ' +
526
+ 'with a larger --wait-lock.');
527
+ }
528
+ if (!announced) {
529
+ verbose(`Waiting for ${live} shared holder(s) of ${lockPath} to finish.`);
530
+ announced = true;
531
+ }
532
+ await sleep(Math.max(pollMs, 100));
533
+ }
534
+ }
@@ -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) {