@hominis/fireforge 0.49.1 → 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 (336) hide show
  1. package/CHANGELOG.md +41 -0
  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-templates.d.ts +8 -3
  35. package/dist/src/commands/furnace/create-templates.js +8 -23
  36. package/dist/src/commands/furnace/create.js +2 -2
  37. package/dist/src/commands/furnace/deploy-named.d.ts +48 -0
  38. package/dist/src/commands/furnace/deploy-named.js +107 -0
  39. package/dist/src/commands/furnace/deploy.d.ts +3 -2
  40. package/dist/src/commands/furnace/deploy.js +50 -86
  41. package/dist/src/commands/furnace/diff.js +26 -3
  42. package/dist/src/commands/furnace/index.js +66 -41
  43. package/dist/src/commands/furnace/init.js +13 -6
  44. package/dist/src/commands/furnace/override.js +4 -2
  45. package/dist/src/commands/furnace/preview.js +4 -3
  46. package/dist/src/commands/furnace/refresh.js +2 -2
  47. package/dist/src/commands/furnace/remove.js +4 -3
  48. package/dist/src/commands/furnace/rename-dry-run.d.ts +29 -0
  49. package/dist/src/commands/furnace/rename-dry-run.js +127 -0
  50. package/dist/src/commands/furnace/rename-helpers.d.ts +7 -0
  51. package/dist/src/commands/furnace/rename-helpers.js +15 -1
  52. package/dist/src/commands/furnace/rename-registrations.d.ts +41 -0
  53. package/dist/src/commands/furnace/rename-registrations.js +93 -0
  54. package/dist/src/commands/furnace/rename.d.ts +2 -1
  55. package/dist/src/commands/furnace/rename.js +28 -66
  56. package/dist/src/commands/furnace/scan.d.ts +9 -4
  57. package/dist/src/commands/furnace/scan.js +11 -9
  58. package/dist/src/commands/furnace/sync.js +3 -2
  59. package/dist/src/commands/furnace/validate-report.d.ts +45 -0
  60. package/dist/src/commands/furnace/validate-report.js +91 -0
  61. package/dist/src/commands/furnace/validate.d.ts +1 -1
  62. package/dist/src/commands/furnace/validate.js +127 -114
  63. package/dist/src/commands/furnace/validation-output.js +14 -3
  64. package/dist/src/commands/lint-per-patch.js +31 -60
  65. package/dist/src/commands/lint-report.d.ts +8 -4
  66. package/dist/src/commands/lint-report.js +15 -5
  67. package/dist/src/commands/lint.js +15 -34
  68. package/dist/src/commands/manifest.js +2 -0
  69. package/dist/src/commands/patch/delete-gap-notice.js +3 -1
  70. package/dist/src/commands/patch/delete.d.ts +1 -1
  71. package/dist/src/commands/patch/delete.js +13 -98
  72. package/dist/src/commands/patch/index.d.ts +1 -1
  73. package/dist/src/commands/patch/index.js +4 -2
  74. package/dist/src/commands/patch/lint-ignore.d.ts +5 -1
  75. package/dist/src/commands/patch/lint-ignore.js +65 -28
  76. package/dist/src/commands/patch/move-files-create.js +26 -17
  77. package/dist/src/commands/patch/move-files-into.js +5 -3
  78. package/dist/src/commands/patch/move-files.js +3 -1
  79. package/dist/src/commands/patch/removal-dependents.d.ts +63 -0
  80. package/dist/src/commands/patch/removal-dependents.js +133 -0
  81. package/dist/src/commands/patch/rename.js +7 -0
  82. package/dist/src/commands/patch/retire.d.ts +34 -0
  83. package/dist/src/commands/patch/retire.js +128 -0
  84. package/dist/src/commands/patch/split-plan.d.ts +7 -7
  85. package/dist/src/commands/patch/split-plan.js +45 -28
  86. package/dist/src/commands/patch/split-staged.d.ts +108 -0
  87. package/dist/src/commands/patch/split-staged.js +202 -0
  88. package/dist/src/commands/patch/split.d.ts +5 -0
  89. package/dist/src/commands/patch/split.js +51 -15
  90. package/dist/src/commands/patch/staged-dependency-batch.d.ts +48 -0
  91. package/dist/src/commands/patch/staged-dependency-batch.js +176 -0
  92. package/dist/src/commands/patch/staged-dependency-entries.d.ts +36 -0
  93. package/dist/src/commands/patch/staged-dependency-entries.js +52 -0
  94. package/dist/src/commands/patch/staged-dependency-from.d.ts +14 -0
  95. package/dist/src/commands/patch/staged-dependency-from.js +108 -0
  96. package/dist/src/commands/patch/staged-dependency.js +14 -41
  97. package/dist/src/commands/re-export-adjacent-registrations.d.ts +20 -0
  98. package/dist/src/commands/re-export-adjacent-registrations.js +75 -0
  99. package/dist/src/commands/re-export-adjacent.d.ts +45 -2
  100. package/dist/src/commands/re-export-adjacent.js +156 -37
  101. package/dist/src/commands/re-export-drift.d.ts +36 -8
  102. package/dist/src/commands/re-export-drift.js +73 -49
  103. package/dist/src/commands/re-export-files.js +11 -8
  104. package/dist/src/commands/re-export-lint.d.ts +2 -1
  105. package/dist/src/commands/re-export-lint.js +23 -10
  106. package/dist/src/commands/re-export-options.js +45 -4
  107. package/dist/src/commands/re-export-preflight.d.ts +23 -0
  108. package/dist/src/commands/re-export-preflight.js +160 -0
  109. package/dist/src/commands/re-export-register.js +42 -17
  110. package/dist/src/commands/re-export-removed.d.ts +12 -1
  111. package/dist/src/commands/re-export-removed.js +41 -11
  112. package/dist/src/commands/re-export-scan-owner.d.ts +44 -0
  113. package/dist/src/commands/re-export-scan-owner.js +116 -0
  114. package/dist/src/commands/re-export-scan.d.ts +17 -9
  115. package/dist/src/commands/re-export-scan.js +56 -23
  116. package/dist/src/commands/re-export-single.js +22 -6
  117. package/dist/src/commands/re-export-summary.d.ts +28 -0
  118. package/dist/src/commands/re-export-summary.js +36 -0
  119. package/dist/src/commands/re-export.js +37 -24
  120. package/dist/src/commands/rebase/index.js +17 -17
  121. package/dist/src/commands/run.js +16 -2
  122. package/dist/src/commands/status-check.d.ts +55 -16
  123. package/dist/src/commands/status-check.js +92 -37
  124. package/dist/src/commands/status-json.d.ts +22 -9
  125. package/dist/src/commands/status-json.js +47 -32
  126. package/dist/src/commands/status-output.js +3 -1
  127. package/dist/src/commands/status-scope.d.ts +37 -0
  128. package/dist/src/commands/status-scope.js +54 -0
  129. package/dist/src/commands/status.js +66 -23
  130. package/dist/src/commands/test-browser-preflight.js +19 -3
  131. package/dist/src/commands/test-build-furnace.d.ts +21 -0
  132. package/dist/src/commands/test-build-furnace.js +48 -0
  133. package/dist/src/commands/test-build-phase.js +47 -41
  134. package/dist/src/commands/test-checkjs.js +7 -0
  135. package/dist/src/commands/test-diagnose.js +7 -0
  136. package/dist/src/commands/test-modes.d.ts +16 -1
  137. package/dist/src/commands/test-modes.js +24 -1
  138. package/dist/src/commands/test-multi-verdict.d.ts +61 -0
  139. package/dist/src/commands/test-multi-verdict.js +111 -0
  140. package/dist/src/commands/test-orchestrate.d.ts +43 -0
  141. package/dist/src/commands/test-orchestrate.js +222 -0
  142. package/dist/src/commands/test-perf-scope.d.ts +29 -0
  143. package/dist/src/commands/test-perf-scope.js +83 -0
  144. package/dist/src/commands/test-prelock.d.ts +51 -0
  145. package/dist/src/commands/test-prelock.js +177 -0
  146. package/dist/src/commands/test-register.js +54 -10
  147. package/dist/src/commands/test-run.js +4 -1
  148. package/dist/src/commands/test-stale-gate.js +57 -27
  149. package/dist/src/commands/test-verdict.d.ts +10 -1
  150. package/dist/src/commands/test-verdict.js +28 -1
  151. package/dist/src/commands/test.d.ts +6 -1
  152. package/dist/src/commands/test.js +66 -60
  153. package/dist/src/commands/token-lint.d.ts +14 -0
  154. package/dist/src/commands/token-lint.js +111 -0
  155. package/dist/src/commands/token.js +10 -0
  156. package/dist/src/commands/verify-scope.d.ts +28 -0
  157. package/dist/src/commands/verify-scope.js +67 -0
  158. package/dist/src/commands/verify.d.ts +10 -2
  159. package/dist/src/commands/verify.js +59 -35
  160. package/dist/src/core/build-baseline-types.d.ts +46 -0
  161. package/dist/src/core/build-baseline-validate.js +19 -0
  162. package/dist/src/core/build-baseline.d.ts +5 -1
  163. package/dist/src/core/build-baseline.js +3 -1
  164. package/dist/src/core/build-overwrite-guard.d.ts +50 -0
  165. package/dist/src/core/build-overwrite-guard.js +84 -4
  166. package/dist/src/core/build-prepare.d.ts +7 -0
  167. package/dist/src/core/build-prepare.js +14 -8
  168. package/dist/src/core/build-run-record.d.ts +74 -0
  169. package/dist/src/core/build-run-record.js +136 -0
  170. package/dist/src/core/checkjs-support-heads.d.ts +41 -0
  171. package/dist/src/core/checkjs-support-heads.js +104 -0
  172. package/dist/src/core/config-paths.d.ts +2 -2
  173. package/dist/src/core/config-paths.js +8 -0
  174. package/dist/src/core/config-validate-check.d.ts +8 -0
  175. package/dist/src/core/config-validate-check.js +143 -0
  176. package/dist/src/core/config-validate-file-size.d.ts +9 -6
  177. package/dist/src/core/config-validate-file-size.js +91 -2
  178. package/dist/src/core/config-validate-history.d.ts +11 -0
  179. package/dist/src/core/config-validate-history.js +38 -0
  180. package/dist/src/core/config-validate-patch-policy.js +100 -0
  181. package/dist/src/core/config-validate-test-toolchains.js +7 -0
  182. package/dist/src/core/config-validate-token-scales.d.ts +6 -0
  183. package/dist/src/core/config-validate-token-scales.js +50 -0
  184. package/dist/src/core/config-validate.js +14 -5
  185. package/dist/src/core/config.d.ts +5 -0
  186. package/dist/src/core/config.js +16 -0
  187. package/dist/src/core/coverage-dispatch.d.ts +24 -0
  188. package/dist/src/core/coverage-dispatch.js +44 -0
  189. package/dist/src/core/coverage-extend.d.ts +70 -45
  190. package/dist/src/core/coverage-extend.js +199 -144
  191. package/dist/src/core/coverage-records.d.ts +97 -0
  192. package/dist/src/core/coverage-records.js +350 -0
  193. package/dist/src/core/destructive.d.ts +7 -4
  194. package/dist/src/core/destructive.js +10 -10
  195. package/dist/src/core/engine-session-lock.d.ts +10 -1
  196. package/dist/src/core/engine-session-lock.js +28 -8
  197. package/dist/src/core/export-moved-code-shape.d.ts +49 -11
  198. package/dist/src/core/export-moved-code-shape.js +135 -44
  199. package/dist/src/core/file-lock-queue.d.ts +67 -0
  200. package/dist/src/core/file-lock-queue.js +221 -0
  201. package/dist/src/core/file-lock.d.ts +4 -25
  202. package/dist/src/core/file-lock.js +105 -138
  203. package/dist/src/core/furnace-apply-output.d.ts +10 -0
  204. package/dist/src/core/furnace-apply-output.js +31 -1
  205. package/dist/src/core/furnace-apply.js +12 -2
  206. package/dist/src/core/furnace-composed-source.d.ts +76 -0
  207. package/dist/src/core/furnace-composed-source.js +167 -0
  208. package/dist/src/core/furnace-config-validate-exceptions.d.ts +9 -0
  209. package/dist/src/core/furnace-config-validate-exceptions.js +71 -0
  210. package/dist/src/core/furnace-config-validate.js +8 -0
  211. package/dist/src/core/furnace-css-fragments.d.ts +10 -0
  212. package/dist/src/core/furnace-css-fragments.js +26 -0
  213. package/dist/src/core/furnace-operation.d.ts +3 -3
  214. package/dist/src/core/furnace-registration-remove.d.ts +22 -1
  215. package/dist/src/core/furnace-registration-remove.js +53 -17
  216. package/dist/src/core/furnace-registration-rename.d.ts +26 -0
  217. package/dist/src/core/furnace-registration-rename.js +77 -0
  218. package/dist/src/core/furnace-registration.d.ts +14 -4
  219. package/dist/src/core/furnace-registration.js +44 -10
  220. package/dist/src/core/furnace-staleness.d.ts +7 -0
  221. package/dist/src/core/furnace-staleness.js +34 -24
  222. package/dist/src/core/furnace-validate-accessibility.d.ts +4 -2
  223. package/dist/src/core/furnace-validate-accessibility.js +10 -1
  224. package/dist/src/core/furnace-validate-exceptions.d.ts +40 -0
  225. package/dist/src/core/furnace-validate-exceptions.js +43 -0
  226. package/dist/src/core/furnace-validate-icon-button.d.ts +7 -0
  227. package/dist/src/core/furnace-validate-icon-button.js +49 -0
  228. package/dist/src/core/furnace-validate.js +1 -1
  229. package/dist/src/core/harness-port-fallback.d.ts +60 -0
  230. package/dist/src/core/harness-port-fallback.js +96 -0
  231. package/dist/src/core/history-log-file.d.ts +61 -0
  232. package/dist/src/core/history-log-file.js +157 -0
  233. package/dist/src/core/history-log.d.ts +2 -1
  234. package/dist/src/core/history-log.js +2 -1
  235. package/dist/src/core/json-preserving-edit.d.ts +22 -0
  236. package/dist/src/core/json-preserving-edit.js +199 -0
  237. package/dist/src/core/mach.js +8 -1
  238. package/dist/src/core/negative-control-diff.d.ts +39 -0
  239. package/dist/src/core/negative-control-diff.js +136 -0
  240. package/dist/src/core/negative-control.d.ts +138 -0
  241. package/dist/src/core/negative-control.js +356 -0
  242. package/dist/src/core/objdir-install-manifests.d.ts +73 -0
  243. package/dist/src/core/objdir-install-manifests.js +226 -0
  244. package/dist/src/core/patch-export-update.d.ts +2 -0
  245. package/dist/src/core/patch-export-update.js +1 -0
  246. package/dist/src/core/patch-export.js +3 -4
  247. package/dist/src/core/patch-lint-checkjs-any-import.d.ts +37 -0
  248. package/dist/src/core/patch-lint-checkjs-any-import.js +61 -0
  249. package/dist/src/core/patch-lint-checkjs.js +8 -11
  250. package/dist/src/core/patch-lint-code-lines.d.ts +59 -0
  251. package/dist/src/core/patch-lint-code-lines.js +105 -0
  252. package/dist/src/core/patch-lint-cross.js +12 -4
  253. package/dist/src/core/patch-lint-css.d.ts +8 -2
  254. package/dist/src/core/patch-lint-css.js +10 -4
  255. package/dist/src/core/patch-lint-file-size.d.ts +12 -5
  256. package/dist/src/core/patch-lint-file-size.js +22 -11
  257. package/dist/src/core/patch-lint-forward-registration-extended.d.ts +18 -0
  258. package/dist/src/core/patch-lint-forward-registration-extended.js +35 -10
  259. package/dist/src/core/patch-lint-forward-registration.js +10 -0
  260. package/dist/src/core/patch-lint-ignore.d.ts +152 -0
  261. package/dist/src/core/patch-lint-ignore.js +299 -0
  262. package/dist/src/core/patch-lint-size-thresholds.d.ts +52 -0
  263. package/dist/src/core/patch-lint-size-thresholds.js +110 -0
  264. package/dist/src/core/patch-lint-size.d.ts +117 -0
  265. package/dist/src/core/patch-lint-size.js +323 -0
  266. package/dist/src/core/patch-lint-staged-registration.d.ts +18 -1
  267. package/dist/src/core/patch-lint-staged-registration.js +20 -0
  268. package/dist/src/core/patch-lint-token-scale.d.ts +39 -0
  269. package/dist/src/core/patch-lint-token-scale.js +109 -0
  270. package/dist/src/core/patch-lint.d.ts +11 -83
  271. package/dist/src/core/patch-lint.js +64 -286
  272. package/dist/src/core/patch-lock.js +7 -6
  273. package/dist/src/core/patch-manifest-consistency.js +7 -6
  274. package/dist/src/core/patch-manifest-io.d.ts +6 -3
  275. package/dist/src/core/patch-manifest-io.js +19 -8
  276. package/dist/src/core/patch-manifest-validate.js +34 -2
  277. package/dist/src/core/patch-order-legal.d.ts +25 -0
  278. package/dist/src/core/patch-order-legal.js +83 -0
  279. package/dist/src/core/patch-policy-content.d.ts +40 -0
  280. package/dist/src/core/patch-policy-content.js +216 -0
  281. package/dist/src/core/patch-policy.d.ts +16 -3
  282. package/dist/src/core/patch-policy.js +16 -9
  283. package/dist/src/core/patch-retired.d.ts +35 -0
  284. package/dist/src/core/patch-retired.js +54 -0
  285. package/dist/src/core/patch-subset.d.ts +16 -0
  286. package/dist/src/core/patch-subset.js +66 -0
  287. package/dist/src/core/preflight-wait.d.ts +20 -0
  288. package/dist/src/core/preflight-wait.js +38 -0
  289. package/dist/src/core/rebase-from-version.d.ts +46 -0
  290. package/dist/src/core/rebase-from-version.js +71 -0
  291. package/dist/src/core/run-log.d.ts +20 -0
  292. package/dist/src/core/run-log.js +89 -3
  293. package/dist/src/core/run-profile.d.ts +7 -0
  294. package/dist/src/core/run-profile.js +7 -1
  295. package/dist/src/core/smoke-patterns.d.ts +5 -0
  296. package/dist/src/core/smoke-patterns.js +13 -0
  297. package/dist/src/core/test-harness-crash.d.ts +18 -1
  298. package/dist/src/core/test-harness-crash.js +6 -4
  299. package/dist/src/core/test-harness-signature.d.ts +17 -0
  300. package/dist/src/core/test-harness-signature.js +4 -0
  301. package/dist/src/core/test-run-completeness.d.ts +57 -0
  302. package/dist/src/core/test-run-completeness.js +144 -0
  303. package/dist/src/core/test-stale-check.d.ts +27 -1
  304. package/dist/src/core/test-stale-check.js +39 -49
  305. package/dist/src/core/test-stale-exempt.d.ts +40 -0
  306. package/dist/src/core/test-stale-exempt.js +70 -0
  307. package/dist/src/core/token-lint.d.ts +50 -0
  308. package/dist/src/core/token-lint.js +126 -0
  309. package/dist/src/core/tree-guard.js +3 -0
  310. package/dist/src/core/typecheck-shim.d.ts +1 -1
  311. package/dist/src/core/typecheck-shim.js +90 -2
  312. package/dist/src/index.d.ts +8 -3
  313. package/dist/src/index.js +3 -1
  314. package/dist/src/types/commands/index.d.ts +2 -2
  315. package/dist/src/types/commands/options.d.ts +148 -21
  316. package/dist/src/types/commands/patches.d.ts +75 -1
  317. package/dist/src/types/config.d.ts +192 -0
  318. package/dist/src/types/config.js +9 -0
  319. package/dist/src/types/furnace.d.ts +43 -1
  320. package/dist/src/types/furnace.js +7 -0
  321. package/dist/src/types/index.d.ts +1 -1
  322. package/dist/src/utils/options.d.ts +34 -6
  323. package/dist/src/utils/options.js +86 -5
  324. package/dist/src/utils/path-glob.d.ts +15 -0
  325. package/dist/src/utils/path-glob.js +47 -0
  326. package/dist/src/utils/regex.d.ts +15 -3
  327. package/dist/src/utils/regex.js +37 -16
  328. package/docs/configuration.md +282 -9
  329. package/docs/exit-codes.md +18 -1
  330. package/docs/furnace.md +97 -3
  331. package/docs/lifecycle-invariants.md +52 -19
  332. package/docs/machine-output.md +74 -12
  333. package/docs/patch-workflow.md +297 -22
  334. package/docs/run-logs.md +20 -1
  335. package/docs/testing.md +276 -20
  336. package/package.json +2 -2
@@ -0,0 +1,9 @@
1
+ import { type FurnaceOptInCheck, type FurnaceValidationException } from '../types/furnace.js';
2
+ /**
3
+ * Parses `validationExceptions[]`: `{ component, check, reason }` objects,
4
+ * no other keys, every field a non-empty string, and no two entries for the
5
+ * same component and check.
6
+ */
7
+ export declare function parseValidationExceptions(raw: unknown): FurnaceValidationException[];
8
+ /** Parses `optInChecks[]`: names from {@link FURNACE_OPT_IN_CHECKS}. */
9
+ export declare function parseOptInChecks(raw: unknown): FurnaceOptInCheck[];
@@ -0,0 +1,71 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Validators for the two `furnace validate` policy fields of furnace.json:
4
+ * `validationExceptions[]` and `optInChecks[]`.
5
+ *
6
+ * Strict on purpose, like `buildAudit.unpackaged`: every exception is a
7
+ * standing suppression of a finding, so a malformed one must fail at load
8
+ * rather than quietly suppress nothing (or everything).
9
+ */
10
+ import { FurnaceError } from '../errors/furnace.js';
11
+ import { FURNACE_OPT_IN_CHECKS, } from '../types/furnace.js';
12
+ import { isArray, isObject, isString } from '../utils/validation.js';
13
+ const EXCEPTION_KEYS = new Set(['component', 'check', 'reason']);
14
+ function requireNonEmptyString(entry, key, field) {
15
+ const value = entry[key];
16
+ if (!isString(value) || value.trim().length === 0) {
17
+ throw new FurnaceError(`Furnace config: "${field}.${key}" must be a non-empty string`);
18
+ }
19
+ return value;
20
+ }
21
+ /**
22
+ * Parses `validationExceptions[]`: `{ component, check, reason }` objects,
23
+ * no other keys, every field a non-empty string, and no two entries for the
24
+ * same component and check.
25
+ */
26
+ export function parseValidationExceptions(raw) {
27
+ if (!isArray(raw)) {
28
+ throw new FurnaceError('Furnace config: "validationExceptions" must be an array');
29
+ }
30
+ const seen = new Set();
31
+ return raw.map((entry, index) => {
32
+ const field = `validationExceptions[${String(index)}]`;
33
+ if (!isObject(entry)) {
34
+ throw new FurnaceError(`Furnace config: "${field}" must be an object with "component", "check" and "reason"`);
35
+ }
36
+ for (const key of Object.keys(entry)) {
37
+ if (!EXCEPTION_KEYS.has(key)) {
38
+ throw new FurnaceError(`Furnace config: "${field}" has unknown key "${key}"`);
39
+ }
40
+ }
41
+ const exception = {
42
+ component: requireNonEmptyString(entry, 'component', field),
43
+ check: requireNonEmptyString(entry, 'check', field),
44
+ reason: requireNonEmptyString(entry, 'reason', field),
45
+ };
46
+ const identity = `${exception.component}\u0000${exception.check}`;
47
+ if (seen.has(identity)) {
48
+ throw new FurnaceError(`Furnace config: "${field}" repeats the exception for ${exception.component} [${exception.check}]`);
49
+ }
50
+ seen.add(identity);
51
+ return exception;
52
+ });
53
+ }
54
+ function isOptInCheck(value) {
55
+ return FURNACE_OPT_IN_CHECKS.includes(value);
56
+ }
57
+ /** Parses `optInChecks[]`: names from {@link FURNACE_OPT_IN_CHECKS}. */
58
+ export function parseOptInChecks(raw) {
59
+ if (!isArray(raw)) {
60
+ throw new FurnaceError('Furnace config: "optInChecks" must be an array');
61
+ }
62
+ const checks = [];
63
+ for (const value of raw) {
64
+ if (!isString(value) || !isOptInCheck(value)) {
65
+ throw new FurnaceError(`Furnace config: "optInChecks" entry ${JSON.stringify(value)} is not an opt-in check. Valid: ${FURNACE_OPT_IN_CHECKS.join(', ')}`);
66
+ }
67
+ if (!checks.includes(value))
68
+ checks.push(value);
69
+ }
70
+ return checks;
71
+ }
@@ -8,6 +8,7 @@
8
8
  import { FurnaceError } from '../errors/furnace.js';
9
9
  import { isJsonObject, isString } from '../utils/validation.js';
10
10
  import { parseStringArray } from './furnace-config-array-utils.js';
11
+ import { parseOptInChecks, parseValidationExceptions, } from './furnace-config-validate-exceptions.js';
11
12
  /**
12
13
  * Validates an override component config object.
13
14
  * @param data - Raw data to validate
@@ -129,4 +130,11 @@ export function applyOptionalFurnaceFields(migrated, config) {
129
130
  }
130
131
  config.scanPaths = paths;
131
132
  }
133
+ // `furnace validate` policy: reviewed exceptions and opt-in checks.
134
+ if (migrated['validationExceptions'] !== undefined) {
135
+ config.validationExceptions = parseValidationExceptions(migrated['validationExceptions']);
136
+ }
137
+ if (migrated['optInChecks'] !== undefined) {
138
+ config.optInChecks = parseOptInChecks(migrated['optInChecks']);
139
+ }
132
140
  }
@@ -35,6 +35,16 @@ export declare function listFragmentIncludes(css: string): string[];
35
35
  * against its workspace source and to re-expand idempotently.
36
36
  */
37
37
  export declare function stripExpandedFragments(css: string): string;
38
+ /**
39
+ * Marks the lines of a stylesheet that belong to a fenced fragment
40
+ * expansion: the directive, the inlined body and the end marker. Index `i`
41
+ * is line `i + 1` of `css`. Fences are found exactly as
42
+ * {@link stripExpandedFragments} finds them, so a bare directive or an
43
+ * unterminated fence marks nothing. The patch-size metric reads this
44
+ * off a file's post-image, because a hunk can start inside a span and
45
+ * carry no directive of its own.
46
+ */
47
+ export declare function expandedFragmentLineMask(css: string): boolean[];
38
48
  /**
39
49
  * Expands every `@fireforge-include` directive in `css` with the current
40
50
  * content of its fragment file from `sharedDir`, fencing each expansion
@@ -78,6 +78,32 @@ export function stripExpandedFragments(css) {
78
78
  }
79
79
  return out.join('\n');
80
80
  }
81
+ /**
82
+ * Marks the lines of a stylesheet that belong to a fenced fragment
83
+ * expansion: the directive, the inlined body and the end marker. Index `i`
84
+ * is line `i + 1` of `css`. Fences are found exactly as
85
+ * {@link stripExpandedFragments} finds them, so a bare directive or an
86
+ * unterminated fence marks nothing. The patch-size metric reads this
87
+ * off a file's post-image, because a hunk can start inside a span and
88
+ * carry no directive of its own.
89
+ */
90
+ export function expandedFragmentLineMask(css) {
91
+ const lines = css.split('\n');
92
+ const mask = lines.map(() => false);
93
+ for (let i = 0; i < lines.length; i++) {
94
+ const inc = INCLUDE_PATTERN.exec(lines[i] ?? '');
95
+ if (!inc?.[1])
96
+ continue;
97
+ for (let j = i + 1; j < lines.length; j++) {
98
+ if (END_INCLUDE_PATTERN.exec(lines[j] ?? '')?.[1] !== inc[1])
99
+ continue;
100
+ mask.fill(true, i, j + 1);
101
+ i = j;
102
+ break;
103
+ }
104
+ }
105
+ return mask;
106
+ }
81
107
  /**
82
108
  * Expands every `@fireforge-include` directive in `css` with the current
83
109
  * content of its fragment file from `sharedDir`, fencing each expansion
@@ -63,9 +63,9 @@ export interface RunFurnaceMutationOptions {
63
63
  * never passed: `furnace deploy --wait-lock 1800` waited 1800 s for the
64
64
  * engine session lock and then met `.fireforge/furnace.lock` with a fixed
65
65
  * 30 s, which is the worst shape a budget can have: it pays the whole
66
- * wait and still refuses. When absent, {@link sessionWaitLockMs} supplies
67
- * the session-wide budget so the twelve furnace mutators that declare no
68
- * flag are covered too.
66
+ * wait and still refuses. Every furnace leaf that takes this lock threads
67
+ * its `--wait-lock` here. When absent, {@link sessionWaitLockMs} supplies
68
+ * the session-wide budget (`FIREFORGE_WAIT_LOCK`).
69
69
  */
70
70
  lockTimeoutMs?: number;
71
71
  /**
@@ -15,11 +15,32 @@
15
15
  * - Two registration shapes are recognised:
16
16
  * (A) Standalone statement:
17
17
  * customElements.setElementCreationCallback("tag", ...);
18
- * (B) Entry inside a `for (... of [ ... ])` registration array:
18
+ * (B) Entry inside a `for (... of [ ... ])` registration array, or an
19
+ * array declared by name and iterated with `for (... of NAME)`:
19
20
  * ["tag", "chrome://..."]
20
21
  * Both are deleted together with any trailing comma and newline so the
21
22
  * resulting file is still valid JavaScript.
22
23
  */
24
+ import type * as estree from 'estree';
25
+ import { type AcornESTreeNode } from './ast-utils.js';
26
+ /** A `[start, end)` source range in customElements.js. */
27
+ export interface RemovalRange {
28
+ start: number;
29
+ end: number;
30
+ }
31
+ /**
32
+ * Walks the AST and returns the source range of every registration of
33
+ * `tagName`: each `[tag, module]` entry of a registration list, and each
34
+ * statement enclosing a `setElementCreationCallback(tag, ...)` call.
35
+ * Ancestor tracking lets us tell an "entry" array from a random
36
+ * `["moz-foo", ...]` literal (only direct children of a registration list
37
+ * are entries). Shared by removal and the in-place rename.
38
+ *
39
+ * @param ast - Parsed customElements.js
40
+ * @param tagName - Tag whose registrations to find
41
+ * @returns Unexpanded `[start, end)` ranges, in source order
42
+ */
43
+ export declare function findRegistrationRanges(ast: AcornESTreeNode<estree.Program>, tagName: string): RemovalRange[];
23
44
  /**
24
45
  * Removes a custom element registration from customElements.js.
25
46
  *
@@ -16,7 +16,8 @@
16
16
  * - Two registration shapes are recognised:
17
17
  * (A) Standalone statement:
18
18
  * customElements.setElementCreationCallback("tag", ...);
19
- * (B) Entry inside a `for (... of [ ... ])` registration array:
19
+ * (B) Entry inside a `for (... of [ ... ])` registration array, or an
20
+ * array declared by name and iterated with `for (... of NAME)`:
20
21
  * ["tag", "chrome://..."]
21
22
  * Both are deleted together with any trailing comma and newline so the
22
23
  * resulting file is still valid JavaScript.
@@ -120,28 +121,63 @@ function isStandaloneCallbackCallFor(call, tagName) {
120
121
  return tagArg.value === tagName;
121
122
  }
122
123
  /**
123
- * Walks the AST and collects every removal range for `tagName`. Ancestor
124
- * tracking lets us tell an "entry" array from a random `["moz-foo", ...]`
125
- * literal (only direct children of the outer registration `ArrayExpression`
126
- * are entries) and lets us lift a `setElementCreationCallback` call up to
127
- * its enclosing statement.
124
+ * Names of the identifiers used as the right-hand side of a `for (... of X)`
125
+ * loop. A registration list declared as `const ELEMENTS = [...]` and
126
+ * iterated by name is a registration array just like an inline literal: the
127
+ * add path (`addCustomElementRegistration`) has always written into
128
+ * it, so the remove and rename paths must find entries there too or a
129
+ * rename leaves the old entry behind.
128
130
  */
129
- function collectRemovalRanges(ast, content, tagName) {
131
+ function collectForOfIdentifiers(ast) {
132
+ const names = new Set();
133
+ walkAST(ast, {
134
+ enter(node) {
135
+ if (node.type !== 'ForOfStatement')
136
+ return;
137
+ const right = asEstree(node).right;
138
+ if (right.type === 'Identifier')
139
+ names.add(right.name);
140
+ },
141
+ });
142
+ return names;
143
+ }
144
+ /** True when `parent`/`grandparent` make an array a registration list. */
145
+ function isRegistrationList(parent, grandparent, forOfIdentifiers) {
146
+ if (parent?.type !== 'ArrayExpression' || !grandparent)
147
+ return false;
148
+ if (grandparent.type === 'ForOfStatement')
149
+ return true;
150
+ if (grandparent.type !== 'VariableDeclarator')
151
+ return false;
152
+ const id = asEstree(grandparent).id;
153
+ return id.type === 'Identifier' && forOfIdentifiers.has(id.name);
154
+ }
155
+ /**
156
+ * Walks the AST and returns the source range of every registration of
157
+ * `tagName`: each `[tag, module]` entry of a registration list, and each
158
+ * statement enclosing a `setElementCreationCallback(tag, ...)` call.
159
+ * Ancestor tracking lets us tell an "entry" array from a random
160
+ * `["moz-foo", ...]` literal (only direct children of a registration list
161
+ * are entries). Shared by removal and the in-place rename.
162
+ *
163
+ * @param ast - Parsed customElements.js
164
+ * @param tagName - Tag whose registrations to find
165
+ * @returns Unexpanded `[start, end)` ranges, in source order
166
+ */
167
+ export function findRegistrationRanges(ast, tagName) {
130
168
  const ranges = [];
131
169
  const ancestors = [];
170
+ const forOfIdentifiers = collectForOfIdentifiers(ast);
132
171
  walkAST(ast, {
133
172
  enter(node) {
134
- // Entry array: [tag, module] inside the outer for-of array literal.
135
- // The immediate parent must be an ArrayExpression (the registration
136
- // list) whose own grandparent is the `for (...) of <array>` loop.
173
+ // Entry array: [tag, module] inside a registration list, either the
174
+ // for-of array literal itself or an array declared by name and
175
+ // iterated by a for-of loop.
137
176
  if (node.type === 'ArrayExpression' && isEntryArrayFor(node, tagName)) {
138
177
  const parent = ancestors[ancestors.length - 1];
139
178
  const grandparent = ancestors[ancestors.length - 2];
140
- if (parent &&
141
- parent.type === 'ArrayExpression' &&
142
- grandparent &&
143
- grandparent.type === 'ForOfStatement') {
144
- ranges.push(expandRemovalRange(content, node.start, node.end));
179
+ if (isRegistrationList(parent, grandparent, forOfIdentifiers)) {
180
+ ranges.push({ start: node.start, end: node.end });
145
181
  }
146
182
  }
147
183
  // Standalone call: customElements.setElementCreationCallback(tag, …)
@@ -162,7 +198,7 @@ function collectRemovalRanges(ast, content, tagName) {
162
198
  }
163
199
  }
164
200
  const target = enclosing ?? call;
165
- ranges.push(expandRemovalRange(content, target.start, target.end));
201
+ ranges.push({ start: target.start, end: target.end });
166
202
  }
167
203
  }
168
204
  ancestors.push(node);
@@ -207,7 +243,7 @@ export async function removeCustomElementRegistration(engineDir, tagName) {
207
243
  // risks corrupting it. The caller's regex fallback handles the retry.
208
244
  return;
209
245
  }
210
- const ranges = collectRemovalRanges(ast, content, tagName);
246
+ const ranges = findRegistrationRanges(ast, tagName).map((range) => expandRemovalRange(content, range.start, range.end));
211
247
  if (ranges.length === 0) {
212
248
  return;
213
249
  }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * In-place rename of a custom element registration in customElements.js.
3
+ *
4
+ * `furnace rename` used to remove the old entry and add a new one. On a
5
+ * registration list the remove path could not see, that left the old entry
6
+ * beside the new one, and the new entry was written without the project
7
+ * marker every sibling line carries (`// <MARKER>:`), so the next lint
8
+ * flagged it as an unmarked engine edit. Rewriting the tag and module
9
+ * literals inside the existing entry keeps the entry's position, layout and
10
+ * trailing marker comments exactly as they were.
11
+ */
12
+ /**
13
+ * Renames `oldTag`'s registration in customElements.js in place.
14
+ *
15
+ * Returns false, writing nothing, when the file is missing or unparsable,
16
+ * holds no registration for `oldTag`, or already registers `newTag` (an
17
+ * in-place rewrite would then register it twice); the caller then falls
18
+ * back to remove-and-add, whose add is idempotent. Never parses with a line-based heuristic: an unparsable
19
+ * file is a doctor problem, not something a rename edits around.
20
+ *
21
+ * @param engineDir - Path to the Firefox engine source root
22
+ * @param oldTag - Tag being renamed away from
23
+ * @param newTag - Tag being renamed to
24
+ * @returns True when at least one registration was rewritten
25
+ */
26
+ export declare function renameCustomElementRegistration(engineDir: string, oldTag: string, newTag: string): Promise<boolean>;
@@ -0,0 +1,77 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * In-place rename of a custom element registration in customElements.js.
4
+ *
5
+ * `furnace rename` used to remove the old entry and add a new one. On a
6
+ * registration list the remove path could not see, that left the old entry
7
+ * beside the new one, and the new entry was written without the project
8
+ * marker every sibling line carries (`// <MARKER>:`), so the next lint
9
+ * flagged it as an unmarked engine edit. Rewriting the tag and module
10
+ * literals inside the existing entry keeps the entry's position, layout and
11
+ * trailing marker comments exactly as they were.
12
+ */
13
+ import { join } from 'node:path';
14
+ import MagicString from 'magic-string';
15
+ import { pathExists, readText, writeText } from '../utils/fs.js';
16
+ import { escapeRegex } from '../utils/regex.js';
17
+ import { parseScript } from './ast-utils.js';
18
+ import { CUSTOM_ELEMENTS_JS } from './furnace-constants.js';
19
+ import { isTagAlreadyRegistered } from './furnace-registration-ast.js';
20
+ import { findRegistrationRanges } from './furnace-registration-remove.js';
21
+ /**
22
+ * Rewrites the old tag's literals inside one registration's source: the
23
+ * quoted tag itself and the module file name in its chrome:// URI.
24
+ * Everything else in the range (indentation, line breaks, comments between
25
+ * elements) is left byte-for-byte.
26
+ */
27
+ function renameRegistrationSource(source, oldTag, newTag) {
28
+ const escaped = escapeRegex(oldTag);
29
+ return source
30
+ .replace(new RegExp(`(["'])${escaped}\\1`, 'g'), `$1${newTag}$1`)
31
+ .replace(new RegExp(`/${escaped}(\\.m?js["'])`, 'g'), `/${newTag}$1`);
32
+ }
33
+ /**
34
+ * Renames `oldTag`'s registration in customElements.js in place.
35
+ *
36
+ * Returns false, writing nothing, when the file is missing or unparsable,
37
+ * holds no registration for `oldTag`, or already registers `newTag` (an
38
+ * in-place rewrite would then register it twice); the caller then falls
39
+ * back to remove-and-add, whose add is idempotent. Never parses with a line-based heuristic: an unparsable
40
+ * file is a doctor problem, not something a rename edits around.
41
+ *
42
+ * @param engineDir - Path to the Firefox engine source root
43
+ * @param oldTag - Tag being renamed away from
44
+ * @param newTag - Tag being renamed to
45
+ * @returns True when at least one registration was rewritten
46
+ */
47
+ export async function renameCustomElementRegistration(engineDir, oldTag, newTag) {
48
+ const filePath = join(engineDir, CUSTOM_ELEMENTS_JS);
49
+ if (!(await pathExists(filePath)))
50
+ return false;
51
+ const content = await readText(filePath);
52
+ if (!content.includes(`"${oldTag}"`) && !content.includes(`'${oldTag}'`))
53
+ return false;
54
+ if (isTagAlreadyRegistered(content, newTag))
55
+ return false;
56
+ let ranges;
57
+ try {
58
+ ranges = findRegistrationRanges(parseScript(content), oldTag);
59
+ }
60
+ catch {
61
+ return false;
62
+ }
63
+ if (ranges.length === 0)
64
+ return false;
65
+ const ms = new MagicString(content);
66
+ for (const range of ranges) {
67
+ const source = content.slice(range.start, range.end);
68
+ const renamed = renameRegistrationSource(source, oldTag, newTag);
69
+ if (renamed !== source)
70
+ ms.overwrite(range.start, range.end, renamed);
71
+ }
72
+ const next = ms.toString();
73
+ if (next === content)
74
+ return false;
75
+ await writeText(filePath, next);
76
+ return true;
77
+ }
@@ -1,5 +1,6 @@
1
1
  export { addCustomElementRegistration, validateCustomElementRegistration, } from './furnace-registration-ast.js';
2
2
  export { removeCustomElementRegistration } from './furnace-registration-remove.js';
3
+ export { renameCustomElementRegistration } from './furnace-registration-rename.js';
3
4
  /**
4
5
  * Adds jar.mn entries that map chrome:// URIs to on-disk paths for a
5
6
  * component's files.
@@ -21,9 +22,9 @@ export { removeCustomElementRegistration } from './furnace-registration-remove.j
21
22
  * in alphabetical order relative to these.
22
23
  * - **Fallback**: if no `content/global/elements/` line exists (empty
23
24
  * project), looks for any `content/global/` line and inserts after it.
24
- * - **Idempotency**: entries already present (checked by exact path match
25
- * on `content/global/elements/{file}` with a trailing whitespace or
26
- * end-of-line boundary) are skipped.
25
+ * - **Idempotency**: entries already present anywhere in the file (an exact
26
+ * target match on `content/global/elements/{file}`, or a source match on
27
+ * `(widgets/{tagName}/{file})`) are skipped, including hand-written ones.
27
28
  *
28
29
  * If Firefox upstream changes the jar.mn section ordering or switches to a
29
30
  * different resource registration mechanism, the preflight validation in
@@ -57,11 +58,20 @@ export declare function addJarMnEntries(engineDir: string, tagName: string, file
57
58
  * @returns Number of entries inserted (0 when already present, or jar.mn missing)
58
59
  */
59
60
  export declare function addLocaleFtlJarMnEntry(engineDir: string, jarMnRelPath: string, tagName: string, chromeSubPath: string): Promise<number>;
61
+ /**
62
+ * True when the locale jar.mn carries a per-widget line for `tagName`.
63
+ * False when the jar.mn is missing. Used by `furnace rename --dry-run` to
64
+ * say whether a rename would re-point a line or leave the jar alone.
65
+ */
66
+ export declare function hasLocaleFtlJarMnEntry(engineDir: string, jarMnRelPath: string, tagName: string, chromeSubPath: string): Promise<boolean>;
60
67
  /**
61
68
  * Removes a locale jar.mn entry previously written by `addLocaleFtlJarMnEntry`.
62
69
  * Idempotent: if the entry is absent or the file is missing, nothing happens.
70
+ *
71
+ * @returns True when a line was removed, so a rename re-points only an
72
+ * entry that existed instead of inventing one
63
73
  */
64
- export declare function removeLocaleFtlJarMnEntry(engineDir: string, jarMnRelPath: string, tagName: string, chromeSubPath: string): Promise<void>;
74
+ export declare function removeLocaleFtlJarMnEntry(engineDir: string, jarMnRelPath: string, tagName: string, chromeSubPath: string): Promise<boolean>;
65
75
  /**
66
76
  * Removes all jar.mn entries for a given tag name.
67
77
  *
@@ -27,8 +27,24 @@ function detectJarMnIndent(lines) {
27
27
  // Re-export everything from the AST module so existing imports keep working
28
28
  export { addCustomElementRegistration, validateCustomElementRegistration, } from './furnace-registration-ast.js';
29
29
  export { removeCustomElementRegistration } from './furnace-registration-remove.js';
30
+ export { renameCustomElementRegistration } from './furnace-registration-rename.js';
30
31
  import { escapeRegex } from '../utils/regex.js';
31
32
  import { JAR_MN } from './furnace-constants.js';
33
+ /**
34
+ * True when jar.mn already registers `file` of component `tagName` anywhere
35
+ * in the file: either a line targeting `content/global/elements/<file>` or a
36
+ * line sourcing `(widgets/<tagName>/<file>)`. The position of the existing
37
+ * line is irrelevant. A hand-written line below an unrelated entry counts
38
+ * exactly like one Furnace wrote itself, so apply never adds a second line
39
+ * and mach never fails with "Item already in manifest". Boundary-aware so
40
+ * `moz-card.css` does not match `moz-card-group.css`.
41
+ */
42
+ function isJarMnFileRegistered(content, tagName, file) {
43
+ const escapedFile = escapeRegex(file);
44
+ const target = new RegExp(`content/global/elements/${escapedFile}(?:\\s|$)`, 'm');
45
+ const source = new RegExp(`\\(widgets/${escapeRegex(tagName)}/${escapedFile}\\)`);
46
+ return target.test(content) || source.test(content);
47
+ }
32
48
  /**
33
49
  * Adds jar.mn entries that map chrome:// URIs to on-disk paths for a
34
50
  * component's files.
@@ -50,9 +66,9 @@ import { JAR_MN } from './furnace-constants.js';
50
66
  * in alphabetical order relative to these.
51
67
  * - **Fallback**: if no `content/global/elements/` line exists (empty
52
68
  * project), looks for any `content/global/` line and inserts after it.
53
- * - **Idempotency**: entries already present (checked by exact path match
54
- * on `content/global/elements/{file}` with a trailing whitespace or
55
- * end-of-line boundary) are skipped.
69
+ * - **Idempotency**: entries already present anywhere in the file (an exact
70
+ * target match on `content/global/elements/{file}`, or a source match on
71
+ * `(widgets/{tagName}/{file})`) are skipped, including hand-written ones.
56
72
  *
57
73
  * If Firefox upstream changes the jar.mn section ordering or switches to a
58
74
  * different resource registration mechanism, the preflight validation in
@@ -70,9 +86,8 @@ export async function addJarMnEntries(engineDir, tagName, files) {
70
86
  }
71
87
  let content = await readText(filePath);
72
88
  const lines = content.split('\n');
73
- // Filter to files not already registered. Use a word-boundary-aware
74
- // check so that "moz-card.css" does not match "moz-card-group.css".
75
- const newFiles = files.filter((f) => !new RegExp(`content/global/elements/${escapeRegex(f)}(?:\\s|$)`, 'm').test(content));
89
+ // Filter to files not already registered anywhere in the jar.
90
+ const newFiles = [...new Set(files)].filter((f) => !isJarMnFileRegistered(content, tagName, f));
76
91
  if (newFiles.length === 0)
77
92
  return 0;
78
93
  // Build new entry lines using the indent detected from existing entries.
@@ -181,22 +196,41 @@ function detectLocaleJarMnIndent(lines, chromeSubPath) {
181
196
  // Fall back to any existing `locale/...` indent before giving up.
182
197
  return detectIndent(lines, exact) ?? detectIndent(lines, /^(\s+)locale\//) ?? ' ';
183
198
  }
199
+ /** Matches a per-widget locale jar.mn line for `<chromeSubPath>/<tagName>.ftl`. */
200
+ function localeFtlJarMnPattern(tagName, chromeSubPath) {
201
+ return new RegExp(`locale\\/(?:@AB_CD@|[a-zA-Z-]+)\\/${escapeRegex(chromeSubPath)}\\/${escapeRegex(tagName)}\\.ftl`);
202
+ }
203
+ /**
204
+ * True when the locale jar.mn carries a per-widget line for `tagName`.
205
+ * False when the jar.mn is missing. Used by `furnace rename --dry-run` to
206
+ * say whether a rename would re-point a line or leave the jar alone.
207
+ */
208
+ export async function hasLocaleFtlJarMnEntry(engineDir, jarMnRelPath, tagName, chromeSubPath) {
209
+ const filePath = join(engineDir, jarMnRelPath);
210
+ if (!(await pathExists(filePath)))
211
+ return false;
212
+ return localeFtlJarMnPattern(tagName, chromeSubPath).test(await readText(filePath));
213
+ }
184
214
  /**
185
215
  * Removes a locale jar.mn entry previously written by `addLocaleFtlJarMnEntry`.
186
216
  * Idempotent: if the entry is absent or the file is missing, nothing happens.
217
+ *
218
+ * @returns True when a line was removed, so a rename re-points only an
219
+ * entry that existed instead of inventing one
187
220
  */
188
221
  export async function removeLocaleFtlJarMnEntry(engineDir, jarMnRelPath, tagName, chromeSubPath) {
189
222
  const filePath = join(engineDir, jarMnRelPath);
190
223
  if (!(await pathExists(filePath))) {
191
- return;
224
+ return false;
192
225
  }
193
226
  const content = await readText(filePath);
194
227
  const lines = content.split('\n');
195
- const pattern = new RegExp(`locale\\/(?:@AB_CD@|[a-zA-Z-]+)\\/${escapeRegex(chromeSubPath)}\\/${escapeRegex(tagName)}\\.ftl`);
228
+ const pattern = localeFtlJarMnPattern(tagName, chromeSubPath);
196
229
  const filtered = lines.filter((line) => !pattern.test(line));
197
230
  if (filtered.length === lines.length)
198
- return;
231
+ return false;
199
232
  await writeText(filePath, filtered.join('\n'));
233
+ return true;
200
234
  }
201
235
  /**
202
236
  * Removes all jar.mn entries for a given tag name.
@@ -284,7 +318,7 @@ export async function validateJarMnInsertionForFiles(engineDir, tagName, files)
284
318
  }
285
319
  const content = await readText(filePath);
286
320
  const lines = content.split('\n');
287
- const newFiles = files.filter((f) => !new RegExp(`content/global/elements/${escapeRegex(f)}(?:\\s|$)`, 'm').test(content));
321
+ const newFiles = files.filter((f) => !isJarMnFileRegistered(content, tagName, f));
288
322
  if (newFiles.length === 0)
289
323
  return;
290
324
  const elementLinePattern = /^\s+content\/global\/elements\/([^.]+)\./;
@@ -8,6 +8,13 @@
8
8
  * The check is advisory only: errors (broken furnace config, partial
9
9
  * state, transient filesystem failure) must never block the caller.
10
10
  */
11
+ /**
12
+ * Names of the tracked override and custom components whose source changed
13
+ * on disk since the last apply, i.e. edits engine/ does not hold yet.
14
+ * Throws on a broken config or state; {@link warnIfFurnaceStale} downgrades
15
+ * that.
16
+ */
17
+ export declare function listStaleFurnaceComponents(projectRoot: string): Promise<string[]>;
11
18
  /**
12
19
  * Emits a warning when any tracked override or custom component has
13
20
  * changed on disk since the last apply. Safe to call from any build-time
@@ -13,6 +13,39 @@ import { pathExists } from '../utils/fs.js';
13
13
  import { verbose, warn } from '../utils/logger.js';
14
14
  import { extractComponentChecksums, hasComponentChanged } from './furnace-apply-helpers.js';
15
15
  import { furnaceConfigExists, getFurnacePaths, loadFurnaceConfig, loadFurnaceState, } from './furnace-config.js';
16
+ /**
17
+ * Names of the tracked override and custom components whose source changed
18
+ * on disk since the last apply, i.e. edits engine/ does not hold yet.
19
+ * Throws on a broken config or state; {@link warnIfFurnaceStale} downgrades
20
+ * that.
21
+ */
22
+ export async function listStaleFurnaceComponents(projectRoot) {
23
+ if (!(await furnaceConfigExists(projectRoot)))
24
+ return [];
25
+ const config = await loadFurnaceConfig(projectRoot);
26
+ const state = await loadFurnaceState(projectRoot);
27
+ const furnacePaths = getFurnacePaths(projectRoot);
28
+ if (!state.appliedChecksums)
29
+ return [];
30
+ const stale = [];
31
+ for (const name of Object.keys(config.overrides)) {
32
+ const dir = `${furnacePaths.overridesDir}/${name}`;
33
+ if (!(await pathExists(dir)))
34
+ continue;
35
+ const prev = extractComponentChecksums(state.appliedChecksums, 'override', name);
36
+ if (await hasComponentChanged(dir, prev))
37
+ stale.push(name);
38
+ }
39
+ for (const name of Object.keys(config.custom)) {
40
+ const dir = `${furnacePaths.customDir}/${name}`;
41
+ if (!(await pathExists(dir)))
42
+ continue;
43
+ const prev = extractComponentChecksums(state.appliedChecksums, 'custom', name);
44
+ if (await hasComponentChanged(dir, prev))
45
+ stale.push(name);
46
+ }
47
+ return stale;
48
+ }
16
49
  /**
17
50
  * Emits a warning when any tracked override or custom component has
18
51
  * changed on disk since the last apply. Safe to call from any build-time
@@ -21,30 +54,7 @@ import { furnaceConfigExists, getFurnacePaths, loadFurnaceConfig, loadFurnaceSta
21
54
  */
22
55
  export async function warnIfFurnaceStale(projectRoot) {
23
56
  try {
24
- if (!(await furnaceConfigExists(projectRoot)))
25
- return;
26
- const config = await loadFurnaceConfig(projectRoot);
27
- const state = await loadFurnaceState(projectRoot);
28
- const furnacePaths = getFurnacePaths(projectRoot);
29
- if (!state.appliedChecksums)
30
- return;
31
- const stale = [];
32
- for (const name of Object.keys(config.overrides)) {
33
- const dir = `${furnacePaths.overridesDir}/${name}`;
34
- if (!(await pathExists(dir)))
35
- continue;
36
- const prev = extractComponentChecksums(state.appliedChecksums, 'override', name);
37
- if (await hasComponentChanged(dir, prev))
38
- stale.push(name);
39
- }
40
- for (const name of Object.keys(config.custom)) {
41
- const dir = `${furnacePaths.customDir}/${name}`;
42
- if (!(await pathExists(dir)))
43
- continue;
44
- const prev = extractComponentChecksums(state.appliedChecksums, 'custom', name);
45
- if (await hasComponentChanged(dir, prev))
46
- stale.push(name);
47
- }
57
+ const stale = await listStaleFurnaceComponents(projectRoot);
48
58
  if (stale.length > 0) {
49
59
  warn(`Furnace component${stale.length === 1 ? '' : 's'} modified since last apply: ${stale.join(', ')}. ` +
50
60
  'Run "fireforge furnace apply" (or "fireforge build" which auto-applies) to update the engine.');