@hominis/fireforge 0.49.2 → 0.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (334) hide show
  1. package/CHANGELOG.md +40 -1
  2. package/README.md +1 -1
  3. package/dist/bin/fireforge.js +6 -0
  4. package/dist/build-info.json +4 -4
  5. package/dist/src/commands/build-prelock.d.ts +20 -0
  6. package/dist/src/commands/build-prelock.js +50 -0
  7. package/dist/src/commands/build.d.ts +3 -1
  8. package/dist/src/commands/build.js +13 -6
  9. package/dist/src/commands/check-report.d.ts +28 -0
  10. package/dist/src/commands/check-report.js +78 -0
  11. package/dist/src/commands/check-run.d.ts +28 -0
  12. package/dist/src/commands/check-run.js +104 -0
  13. package/dist/src/commands/check-steps.d.ts +67 -0
  14. package/dist/src/commands/check-steps.js +175 -0
  15. package/dist/src/commands/check.d.ts +32 -0
  16. package/dist/src/commands/check.js +160 -0
  17. package/dist/src/commands/config.js +5 -6
  18. package/dist/src/commands/doctor-engine-eslint.js +6 -1
  19. package/dist/src/commands/doctor-source-pin.js +4 -1
  20. package/dist/src/commands/export-adopt-check.d.ts +38 -0
  21. package/dist/src/commands/export-adopt-check.js +98 -0
  22. package/dist/src/commands/export-flow.js +29 -14
  23. package/dist/src/commands/export-placement-gate.d.ts +6 -2
  24. package/dist/src/commands/export-shared.d.ts +2 -1
  25. package/dist/src/commands/export-staged-registration.d.ts +46 -0
  26. package/dist/src/commands/export-staged-registration.js +91 -0
  27. package/dist/src/commands/forward-import-remedy.d.ts +32 -0
  28. package/dist/src/commands/forward-import-remedy.js +63 -0
  29. package/dist/src/commands/furnace/apply.js +3 -3
  30. package/dist/src/commands/furnace/chrome-doc-remove.d.ts +2 -1
  31. package/dist/src/commands/furnace/chrome-doc-remove.js +2 -2
  32. package/dist/src/commands/furnace/chrome-doc.d.ts +2 -1
  33. package/dist/src/commands/furnace/chrome-doc.js +2 -2
  34. package/dist/src/commands/furnace/create.js +2 -2
  35. package/dist/src/commands/furnace/deploy-named.d.ts +48 -0
  36. package/dist/src/commands/furnace/deploy-named.js +107 -0
  37. package/dist/src/commands/furnace/deploy.d.ts +3 -2
  38. package/dist/src/commands/furnace/deploy.js +50 -86
  39. package/dist/src/commands/furnace/diff.js +26 -3
  40. package/dist/src/commands/furnace/index.js +66 -41
  41. package/dist/src/commands/furnace/init.js +13 -6
  42. package/dist/src/commands/furnace/override.js +4 -2
  43. package/dist/src/commands/furnace/preview.js +4 -3
  44. package/dist/src/commands/furnace/refresh.js +2 -2
  45. package/dist/src/commands/furnace/remove.js +4 -3
  46. package/dist/src/commands/furnace/rename-dry-run.d.ts +29 -0
  47. package/dist/src/commands/furnace/rename-dry-run.js +127 -0
  48. package/dist/src/commands/furnace/rename-helpers.d.ts +7 -0
  49. package/dist/src/commands/furnace/rename-helpers.js +15 -1
  50. package/dist/src/commands/furnace/rename-registrations.d.ts +41 -0
  51. package/dist/src/commands/furnace/rename-registrations.js +93 -0
  52. package/dist/src/commands/furnace/rename.d.ts +2 -1
  53. package/dist/src/commands/furnace/rename.js +28 -66
  54. package/dist/src/commands/furnace/scan.d.ts +9 -4
  55. package/dist/src/commands/furnace/scan.js +11 -9
  56. package/dist/src/commands/furnace/sync.js +3 -2
  57. package/dist/src/commands/furnace/validate-report.d.ts +45 -0
  58. package/dist/src/commands/furnace/validate-report.js +91 -0
  59. package/dist/src/commands/furnace/validate.d.ts +1 -1
  60. package/dist/src/commands/furnace/validate.js +127 -114
  61. package/dist/src/commands/furnace/validation-output.js +14 -3
  62. package/dist/src/commands/lint-per-patch.js +31 -60
  63. package/dist/src/commands/lint-report.d.ts +8 -4
  64. package/dist/src/commands/lint-report.js +15 -5
  65. package/dist/src/commands/lint.js +15 -34
  66. package/dist/src/commands/manifest.js +2 -0
  67. package/dist/src/commands/patch/delete-gap-notice.js +3 -1
  68. package/dist/src/commands/patch/delete.d.ts +1 -1
  69. package/dist/src/commands/patch/delete.js +13 -98
  70. package/dist/src/commands/patch/index.d.ts +1 -1
  71. package/dist/src/commands/patch/index.js +4 -2
  72. package/dist/src/commands/patch/lint-ignore.d.ts +5 -1
  73. package/dist/src/commands/patch/lint-ignore.js +65 -28
  74. package/dist/src/commands/patch/move-files-create.js +26 -17
  75. package/dist/src/commands/patch/move-files-into.js +5 -3
  76. package/dist/src/commands/patch/move-files.js +3 -1
  77. package/dist/src/commands/patch/removal-dependents.d.ts +63 -0
  78. package/dist/src/commands/patch/removal-dependents.js +133 -0
  79. package/dist/src/commands/patch/rename.js +7 -0
  80. package/dist/src/commands/patch/retire.d.ts +34 -0
  81. package/dist/src/commands/patch/retire.js +128 -0
  82. package/dist/src/commands/patch/split-plan.d.ts +7 -7
  83. package/dist/src/commands/patch/split-plan.js +45 -28
  84. package/dist/src/commands/patch/split-staged.d.ts +108 -0
  85. package/dist/src/commands/patch/split-staged.js +202 -0
  86. package/dist/src/commands/patch/split.d.ts +5 -0
  87. package/dist/src/commands/patch/split.js +51 -15
  88. package/dist/src/commands/patch/staged-dependency-batch.d.ts +48 -0
  89. package/dist/src/commands/patch/staged-dependency-batch.js +176 -0
  90. package/dist/src/commands/patch/staged-dependency-entries.d.ts +36 -0
  91. package/dist/src/commands/patch/staged-dependency-entries.js +52 -0
  92. package/dist/src/commands/patch/staged-dependency-from.d.ts +14 -0
  93. package/dist/src/commands/patch/staged-dependency-from.js +108 -0
  94. package/dist/src/commands/patch/staged-dependency.js +14 -41
  95. package/dist/src/commands/re-export-adjacent-registrations.d.ts +20 -0
  96. package/dist/src/commands/re-export-adjacent-registrations.js +75 -0
  97. package/dist/src/commands/re-export-adjacent.d.ts +45 -2
  98. package/dist/src/commands/re-export-adjacent.js +156 -37
  99. package/dist/src/commands/re-export-drift.d.ts +36 -8
  100. package/dist/src/commands/re-export-drift.js +73 -49
  101. package/dist/src/commands/re-export-files.js +11 -8
  102. package/dist/src/commands/re-export-lint.d.ts +2 -1
  103. package/dist/src/commands/re-export-lint.js +23 -10
  104. package/dist/src/commands/re-export-options.js +45 -4
  105. package/dist/src/commands/re-export-preflight.d.ts +23 -0
  106. package/dist/src/commands/re-export-preflight.js +160 -0
  107. package/dist/src/commands/re-export-register.js +42 -17
  108. package/dist/src/commands/re-export-removed.d.ts +12 -1
  109. package/dist/src/commands/re-export-removed.js +41 -11
  110. package/dist/src/commands/re-export-scan-owner.d.ts +44 -0
  111. package/dist/src/commands/re-export-scan-owner.js +116 -0
  112. package/dist/src/commands/re-export-scan.d.ts +17 -9
  113. package/dist/src/commands/re-export-scan.js +56 -23
  114. package/dist/src/commands/re-export-single.js +22 -6
  115. package/dist/src/commands/re-export-summary.d.ts +28 -0
  116. package/dist/src/commands/re-export-summary.js +36 -0
  117. package/dist/src/commands/re-export.js +37 -24
  118. package/dist/src/commands/rebase/index.js +17 -17
  119. package/dist/src/commands/run.js +16 -2
  120. package/dist/src/commands/status-check.d.ts +55 -16
  121. package/dist/src/commands/status-check.js +92 -37
  122. package/dist/src/commands/status-json.d.ts +22 -9
  123. package/dist/src/commands/status-json.js +47 -32
  124. package/dist/src/commands/status-output.js +3 -1
  125. package/dist/src/commands/status-scope.d.ts +37 -0
  126. package/dist/src/commands/status-scope.js +54 -0
  127. package/dist/src/commands/status.js +66 -23
  128. package/dist/src/commands/test-browser-preflight.js +19 -3
  129. package/dist/src/commands/test-build-furnace.d.ts +21 -0
  130. package/dist/src/commands/test-build-furnace.js +48 -0
  131. package/dist/src/commands/test-build-phase.js +47 -41
  132. package/dist/src/commands/test-checkjs.js +7 -0
  133. package/dist/src/commands/test-diagnose.js +7 -0
  134. package/dist/src/commands/test-modes.d.ts +16 -1
  135. package/dist/src/commands/test-modes.js +24 -1
  136. package/dist/src/commands/test-multi-verdict.d.ts +61 -0
  137. package/dist/src/commands/test-multi-verdict.js +111 -0
  138. package/dist/src/commands/test-orchestrate.d.ts +43 -0
  139. package/dist/src/commands/test-orchestrate.js +222 -0
  140. package/dist/src/commands/test-perf-scope.d.ts +29 -0
  141. package/dist/src/commands/test-perf-scope.js +83 -0
  142. package/dist/src/commands/test-prelock.d.ts +51 -0
  143. package/dist/src/commands/test-prelock.js +177 -0
  144. package/dist/src/commands/test-register.js +54 -10
  145. package/dist/src/commands/test-run.js +4 -1
  146. package/dist/src/commands/test-stale-gate.js +57 -27
  147. package/dist/src/commands/test-verdict.d.ts +10 -1
  148. package/dist/src/commands/test-verdict.js +28 -1
  149. package/dist/src/commands/test.d.ts +6 -1
  150. package/dist/src/commands/test.js +66 -60
  151. package/dist/src/commands/token-lint.d.ts +14 -0
  152. package/dist/src/commands/token-lint.js +111 -0
  153. package/dist/src/commands/token.js +10 -0
  154. package/dist/src/commands/verify-scope.d.ts +28 -0
  155. package/dist/src/commands/verify-scope.js +67 -0
  156. package/dist/src/commands/verify.d.ts +10 -2
  157. package/dist/src/commands/verify.js +59 -35
  158. package/dist/src/core/build-baseline-types.d.ts +46 -0
  159. package/dist/src/core/build-baseline-validate.js +19 -0
  160. package/dist/src/core/build-baseline.d.ts +5 -1
  161. package/dist/src/core/build-baseline.js +3 -1
  162. package/dist/src/core/build-overwrite-guard.d.ts +50 -0
  163. package/dist/src/core/build-overwrite-guard.js +84 -4
  164. package/dist/src/core/build-prepare.d.ts +7 -0
  165. package/dist/src/core/build-prepare.js +14 -8
  166. package/dist/src/core/build-run-record.d.ts +74 -0
  167. package/dist/src/core/build-run-record.js +136 -0
  168. package/dist/src/core/checkjs-support-heads.d.ts +41 -0
  169. package/dist/src/core/checkjs-support-heads.js +104 -0
  170. package/dist/src/core/config-paths.d.ts +2 -2
  171. package/dist/src/core/config-paths.js +8 -0
  172. package/dist/src/core/config-validate-check.d.ts +8 -0
  173. package/dist/src/core/config-validate-check.js +143 -0
  174. package/dist/src/core/config-validate-file-size.d.ts +9 -6
  175. package/dist/src/core/config-validate-file-size.js +91 -2
  176. package/dist/src/core/config-validate-history.d.ts +11 -0
  177. package/dist/src/core/config-validate-history.js +38 -0
  178. package/dist/src/core/config-validate-patch-policy.js +100 -0
  179. package/dist/src/core/config-validate-test-toolchains.js +7 -0
  180. package/dist/src/core/config-validate-token-scales.d.ts +6 -0
  181. package/dist/src/core/config-validate-token-scales.js +50 -0
  182. package/dist/src/core/config-validate.js +14 -5
  183. package/dist/src/core/config.d.ts +5 -0
  184. package/dist/src/core/config.js +16 -0
  185. package/dist/src/core/coverage-dispatch.d.ts +24 -0
  186. package/dist/src/core/coverage-dispatch.js +44 -0
  187. package/dist/src/core/coverage-extend.d.ts +70 -45
  188. package/dist/src/core/coverage-extend.js +199 -144
  189. package/dist/src/core/coverage-records.d.ts +97 -0
  190. package/dist/src/core/coverage-records.js +350 -0
  191. package/dist/src/core/destructive.d.ts +7 -4
  192. package/dist/src/core/destructive.js +10 -10
  193. package/dist/src/core/engine-session-lock.d.ts +10 -1
  194. package/dist/src/core/engine-session-lock.js +28 -8
  195. package/dist/src/core/export-moved-code-shape.d.ts +49 -11
  196. package/dist/src/core/export-moved-code-shape.js +135 -44
  197. package/dist/src/core/file-lock-queue.d.ts +67 -0
  198. package/dist/src/core/file-lock-queue.js +221 -0
  199. package/dist/src/core/file-lock.d.ts +4 -25
  200. package/dist/src/core/file-lock.js +105 -138
  201. package/dist/src/core/furnace-apply-output.d.ts +10 -0
  202. package/dist/src/core/furnace-apply-output.js +31 -1
  203. package/dist/src/core/furnace-apply.js +12 -2
  204. package/dist/src/core/furnace-composed-source.d.ts +76 -0
  205. package/dist/src/core/furnace-composed-source.js +167 -0
  206. package/dist/src/core/furnace-config-validate-exceptions.d.ts +9 -0
  207. package/dist/src/core/furnace-config-validate-exceptions.js +71 -0
  208. package/dist/src/core/furnace-config-validate.js +8 -0
  209. package/dist/src/core/furnace-css-fragments.d.ts +10 -0
  210. package/dist/src/core/furnace-css-fragments.js +26 -0
  211. package/dist/src/core/furnace-operation.d.ts +3 -3
  212. package/dist/src/core/furnace-registration-remove.d.ts +22 -1
  213. package/dist/src/core/furnace-registration-remove.js +53 -17
  214. package/dist/src/core/furnace-registration-rename.d.ts +26 -0
  215. package/dist/src/core/furnace-registration-rename.js +77 -0
  216. package/dist/src/core/furnace-registration.d.ts +14 -4
  217. package/dist/src/core/furnace-registration.js +44 -10
  218. package/dist/src/core/furnace-staleness.d.ts +7 -0
  219. package/dist/src/core/furnace-staleness.js +34 -24
  220. package/dist/src/core/furnace-validate-accessibility.d.ts +4 -2
  221. package/dist/src/core/furnace-validate-accessibility.js +10 -1
  222. package/dist/src/core/furnace-validate-exceptions.d.ts +40 -0
  223. package/dist/src/core/furnace-validate-exceptions.js +43 -0
  224. package/dist/src/core/furnace-validate-icon-button.d.ts +7 -0
  225. package/dist/src/core/furnace-validate-icon-button.js +49 -0
  226. package/dist/src/core/furnace-validate.js +1 -1
  227. package/dist/src/core/harness-port-fallback.d.ts +60 -0
  228. package/dist/src/core/harness-port-fallback.js +96 -0
  229. package/dist/src/core/history-log-file.d.ts +61 -0
  230. package/dist/src/core/history-log-file.js +157 -0
  231. package/dist/src/core/history-log.d.ts +2 -1
  232. package/dist/src/core/history-log.js +2 -1
  233. package/dist/src/core/json-preserving-edit.d.ts +22 -0
  234. package/dist/src/core/json-preserving-edit.js +199 -0
  235. package/dist/src/core/mach.js +8 -1
  236. package/dist/src/core/negative-control-diff.d.ts +39 -0
  237. package/dist/src/core/negative-control-diff.js +136 -0
  238. package/dist/src/core/negative-control.d.ts +138 -0
  239. package/dist/src/core/negative-control.js +356 -0
  240. package/dist/src/core/objdir-install-manifests.d.ts +73 -0
  241. package/dist/src/core/objdir-install-manifests.js +226 -0
  242. package/dist/src/core/patch-export-update.d.ts +2 -0
  243. package/dist/src/core/patch-export-update.js +1 -0
  244. package/dist/src/core/patch-export.js +3 -4
  245. package/dist/src/core/patch-lint-checkjs-any-import.d.ts +37 -0
  246. package/dist/src/core/patch-lint-checkjs-any-import.js +61 -0
  247. package/dist/src/core/patch-lint-checkjs.js +8 -11
  248. package/dist/src/core/patch-lint-code-lines.d.ts +59 -0
  249. package/dist/src/core/patch-lint-code-lines.js +105 -0
  250. package/dist/src/core/patch-lint-cross.js +12 -4
  251. package/dist/src/core/patch-lint-css.d.ts +8 -2
  252. package/dist/src/core/patch-lint-css.js +10 -4
  253. package/dist/src/core/patch-lint-file-size.d.ts +12 -5
  254. package/dist/src/core/patch-lint-file-size.js +22 -11
  255. package/dist/src/core/patch-lint-forward-registration-extended.d.ts +18 -0
  256. package/dist/src/core/patch-lint-forward-registration-extended.js +35 -10
  257. package/dist/src/core/patch-lint-forward-registration.js +10 -0
  258. package/dist/src/core/patch-lint-ignore.d.ts +152 -0
  259. package/dist/src/core/patch-lint-ignore.js +299 -0
  260. package/dist/src/core/patch-lint-size-thresholds.d.ts +52 -0
  261. package/dist/src/core/patch-lint-size-thresholds.js +110 -0
  262. package/dist/src/core/patch-lint-size.d.ts +117 -0
  263. package/dist/src/core/patch-lint-size.js +323 -0
  264. package/dist/src/core/patch-lint-staged-registration.d.ts +18 -1
  265. package/dist/src/core/patch-lint-staged-registration.js +20 -0
  266. package/dist/src/core/patch-lint-token-scale.d.ts +39 -0
  267. package/dist/src/core/patch-lint-token-scale.js +109 -0
  268. package/dist/src/core/patch-lint.d.ts +11 -83
  269. package/dist/src/core/patch-lint.js +64 -286
  270. package/dist/src/core/patch-lock.js +7 -6
  271. package/dist/src/core/patch-manifest-consistency.js +7 -6
  272. package/dist/src/core/patch-manifest-io.d.ts +6 -3
  273. package/dist/src/core/patch-manifest-io.js +19 -8
  274. package/dist/src/core/patch-manifest-validate.js +34 -2
  275. package/dist/src/core/patch-order-legal.d.ts +25 -0
  276. package/dist/src/core/patch-order-legal.js +83 -0
  277. package/dist/src/core/patch-policy-content.d.ts +40 -0
  278. package/dist/src/core/patch-policy-content.js +216 -0
  279. package/dist/src/core/patch-policy.d.ts +16 -3
  280. package/dist/src/core/patch-policy.js +16 -9
  281. package/dist/src/core/patch-retired.d.ts +35 -0
  282. package/dist/src/core/patch-retired.js +54 -0
  283. package/dist/src/core/patch-subset.d.ts +16 -0
  284. package/dist/src/core/patch-subset.js +66 -0
  285. package/dist/src/core/preflight-wait.d.ts +20 -0
  286. package/dist/src/core/preflight-wait.js +38 -0
  287. package/dist/src/core/rebase-from-version.d.ts +46 -0
  288. package/dist/src/core/rebase-from-version.js +71 -0
  289. package/dist/src/core/run-log.d.ts +20 -0
  290. package/dist/src/core/run-log.js +89 -3
  291. package/dist/src/core/run-profile.d.ts +7 -0
  292. package/dist/src/core/run-profile.js +7 -1
  293. package/dist/src/core/smoke-patterns.d.ts +5 -0
  294. package/dist/src/core/smoke-patterns.js +13 -0
  295. package/dist/src/core/test-harness-crash.d.ts +18 -1
  296. package/dist/src/core/test-harness-crash.js +6 -4
  297. package/dist/src/core/test-harness-signature.d.ts +17 -0
  298. package/dist/src/core/test-harness-signature.js +4 -0
  299. package/dist/src/core/test-run-completeness.d.ts +57 -0
  300. package/dist/src/core/test-run-completeness.js +144 -0
  301. package/dist/src/core/test-stale-check.d.ts +27 -1
  302. package/dist/src/core/test-stale-check.js +39 -49
  303. package/dist/src/core/test-stale-exempt.d.ts +40 -0
  304. package/dist/src/core/test-stale-exempt.js +70 -0
  305. package/dist/src/core/token-lint.d.ts +50 -0
  306. package/dist/src/core/token-lint.js +126 -0
  307. package/dist/src/core/tree-guard.js +3 -0
  308. package/dist/src/core/typecheck-shim.d.ts +1 -1
  309. package/dist/src/core/typecheck-shim.js +90 -2
  310. package/dist/src/index.d.ts +8 -3
  311. package/dist/src/index.js +3 -1
  312. package/dist/src/types/commands/index.d.ts +2 -2
  313. package/dist/src/types/commands/options.d.ts +148 -21
  314. package/dist/src/types/commands/patches.d.ts +75 -1
  315. package/dist/src/types/config.d.ts +192 -0
  316. package/dist/src/types/config.js +9 -0
  317. package/dist/src/types/furnace.d.ts +43 -1
  318. package/dist/src/types/furnace.js +7 -0
  319. package/dist/src/types/index.d.ts +1 -1
  320. package/dist/src/utils/options.d.ts +34 -6
  321. package/dist/src/utils/options.js +86 -5
  322. package/dist/src/utils/path-glob.d.ts +15 -0
  323. package/dist/src/utils/path-glob.js +47 -0
  324. package/dist/src/utils/regex.d.ts +15 -3
  325. package/dist/src/utils/regex.js +37 -16
  326. package/docs/configuration.md +282 -9
  327. package/docs/exit-codes.md +18 -1
  328. package/docs/furnace.md +97 -3
  329. package/docs/lifecycle-invariants.md +52 -19
  330. package/docs/machine-output.md +74 -12
  331. package/docs/patch-workflow.md +297 -22
  332. package/docs/run-logs.md +20 -1
  333. package/docs/testing.md +276 -20
  334. package/package.json +1 -1
@@ -0,0 +1,199 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Format-preserving rewrites of a JSON document.
4
+ *
5
+ * `fireforge source set`, `fireforge config` and `furnace init` change a
6
+ * few keys of `fireforge.json`. Re-serializing the whole document with
7
+ * `JSON.stringify` expands every compact object and array the project keeps
8
+ * on one line, so a one-key change becomes a diff across unrelated policy
9
+ * sections. This module diffs the document on disk against the wanted one
10
+ * and edits only the values that differ, in place, keeping every other byte
11
+ * (indentation, compact containers, key order, trailing newline).
12
+ *
13
+ * Inserted values follow the style of the container they land in: one line
14
+ * inside a one-line container, the container's own indentation otherwise.
15
+ */
16
+ import { applyEdits, findNodeAtLocation, modify, parseTree } from 'jsonc-parser';
17
+ import { isJsonObject } from '../utils/validation.js';
18
+ /** The same reviver `readJson` uses, so both sides of the diff agree. */
19
+ function parseJsonDocument(text) {
20
+ return JSON.parse(text, (key, value) => key === '__proto__' || key === 'constructor' || key === 'prototype' ? undefined : value);
21
+ }
22
+ function jsonEqual(a, b) {
23
+ if (a === b)
24
+ return true;
25
+ if (Array.isArray(a) || Array.isArray(b)) {
26
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length)
27
+ return false;
28
+ return a.every((value, index) => jsonEqual(value, b[index]));
29
+ }
30
+ if (!isJsonObject(a) || !isJsonObject(b))
31
+ return false;
32
+ const keysA = definedKeys(a);
33
+ const keysB = definedKeys(b);
34
+ return keysA.length === keysB.length && keysA.every((key) => jsonEqual(a[key], b[key]));
35
+ }
36
+ /** Keys whose value survives `JSON.stringify` (an `undefined` value is an absent key). */
37
+ function definedKeys(object) {
38
+ return Object.keys(object).filter((key) => object[key] !== undefined);
39
+ }
40
+ function collectChanges(path, before, after, changes) {
41
+ if (jsonEqual(before, after))
42
+ return;
43
+ if (isJsonObject(before) && isJsonObject(after)) {
44
+ for (const key of definedKeys(before)) {
45
+ if (after[key] === undefined)
46
+ changes.push({ kind: 'remove', path: [...path, key] });
47
+ }
48
+ for (const key of definedKeys(after)) {
49
+ const next = after[key];
50
+ const previous = before[key];
51
+ if (previous === undefined) {
52
+ changes.push({ kind: 'set', path: [...path, key], value: next });
53
+ }
54
+ else {
55
+ collectChanges([...path, key], previous, next, changes);
56
+ }
57
+ }
58
+ return;
59
+ }
60
+ if (Array.isArray(before) && Array.isArray(after)) {
61
+ const isAppend = after.length > before.length &&
62
+ before.every((value, index) => jsonEqual(value, after[index]));
63
+ if (isAppend) {
64
+ for (const value of after.slice(before.length))
65
+ changes.push({ kind: 'append', path, value });
66
+ return;
67
+ }
68
+ if (after.length === before.length) {
69
+ before.forEach((value, index) => {
70
+ collectChanges([...path, index], value, after[index], changes);
71
+ });
72
+ return;
73
+ }
74
+ }
75
+ changes.push({ kind: 'set', path, value: after });
76
+ }
77
+ function detectStyle(text) {
78
+ const indent = /^([ \t]+)\S/m.exec(text)?.[1];
79
+ return {
80
+ eol: text.includes('\r\n') ? '\r\n' : '\n',
81
+ indentUnit: indent === undefined ? ' ' : indent.startsWith('\t') ? '\t' : indent,
82
+ };
83
+ }
84
+ function isMultiline(text, node) {
85
+ return node !== undefined && text.slice(node.offset, node.offset + node.length).includes('\n');
86
+ }
87
+ /** The object or array holding `node` (skipping the property node of an object member). */
88
+ function containerOf(node) {
89
+ const parent = node.parent;
90
+ return parent?.type === 'property' ? parent.parent : parent;
91
+ }
92
+ /** Leading whitespace of the line `offset` sits on. */
93
+ function lineIndent(text, offset) {
94
+ const lineStart = text.lastIndexOf('\n', offset - 1) + 1;
95
+ return /^[ \t]*/.exec(text.slice(lineStart))?.[0] ?? '';
96
+ }
97
+ function renderCompact(value) {
98
+ if (Array.isArray(value)) {
99
+ return `[${value.map(renderCompact).join(', ')}]`;
100
+ }
101
+ if (isJsonObject(value)) {
102
+ const entries = definedKeys(value).map((key) => `${JSON.stringify(key)}: ${renderCompact(value[key])}`);
103
+ return entries.length === 0 ? '{}' : `{ ${entries.join(', ')} }`;
104
+ }
105
+ return JSON.stringify(value);
106
+ }
107
+ function renderValue(value, multiline, indent, style) {
108
+ if (!multiline)
109
+ return renderCompact(value);
110
+ return JSON.stringify(value, null, style.indentUnit)
111
+ .split('\n')
112
+ .join(style.eol + indent);
113
+ }
114
+ function replaceRange(text, offset, length, content) {
115
+ return text.slice(0, offset) + content + text.slice(offset + length);
116
+ }
117
+ /** Replaces an existing value, keeping a container's one-line or multi-line shape. */
118
+ function replaceNode(text, node, value, style) {
119
+ const isContainer = node.type === 'object' || node.type === 'array';
120
+ const multiline = isContainer ? isMultiline(text, node) : isMultiline(text, containerOf(node));
121
+ const rendered = renderValue(value, multiline, lineIndent(text, node.offset), style);
122
+ return replaceRange(text, node.offset, node.length, rendered);
123
+ }
124
+ /** Appends `entry` (a property or an array element) after the container's last child. */
125
+ function appendEntry(text, container, renderEntry, style) {
126
+ const children = container.children ?? [];
127
+ const last = children[children.length - 1];
128
+ const containerIndent = lineIndent(text, container.offset);
129
+ if (last === undefined) {
130
+ // Empty container: open it up the way its parent is laid out.
131
+ const multiline = isMultiline(text, containerOf(container));
132
+ const [open, close] = container.type === 'array' ? ['[', ']'] : ['{', '}'];
133
+ const childIndent = containerIndent + style.indentUnit;
134
+ const body = multiline
135
+ ? `${open}${style.eol}${childIndent}${renderEntry(true, childIndent)}${style.eol}${containerIndent}${close}`
136
+ : container.type === 'array'
137
+ ? `${open}${renderEntry(false, childIndent)}${close}`
138
+ : `${open} ${renderEntry(false, childIndent)} ${close}`;
139
+ return replaceRange(text, container.offset, container.length, body);
140
+ }
141
+ const multiline = isMultiline(text, container);
142
+ const indent = multiline ? lineIndent(text, last.offset) : containerIndent;
143
+ const separator = multiline ? `,${style.eol}${indent}` : ', ';
144
+ return replaceRange(text, last.offset + last.length, 0, separator + renderEntry(multiline, indent));
145
+ }
146
+ function applyChange(text, change, style) {
147
+ const root = parseTree(text);
148
+ if (root === undefined) {
149
+ throw new Error('document is not valid JSON');
150
+ }
151
+ if (change.kind === 'remove') {
152
+ return applyEdits(text, modify(text, change.path, undefined, {}));
153
+ }
154
+ const target = findNodeAtLocation(root, change.path);
155
+ if (change.kind === 'append') {
156
+ if (target?.type !== 'array')
157
+ throw new Error('append target is not an array');
158
+ return appendEntry(text, target, (multiline, indent) => renderValue(change.value, multiline, indent, style), style);
159
+ }
160
+ if (target !== undefined) {
161
+ return replaceNode(text, target, change.value, style);
162
+ }
163
+ const parent = findNodeAtLocation(root, change.path.slice(0, -1));
164
+ const key = change.path[change.path.length - 1];
165
+ if (parent?.type !== 'object' || typeof key !== 'string') {
166
+ throw new Error('insertion parent is not an object');
167
+ }
168
+ return appendEntry(text, parent, (multiline, indent) => `${JSON.stringify(key)}: ${renderValue(change.value, multiline, indent, style)}`, style);
169
+ }
170
+ /**
171
+ * Rewrites `text` so it parses to `next`, editing only the values that
172
+ * differ. Returns `undefined` when `text` is not a JSON object or the edit
173
+ * cannot be proven equivalent, so the caller can fall back to a full
174
+ * re-serialization.
175
+ */
176
+ export function rewriteJsonPreservingFormat(text, next) {
177
+ let current;
178
+ try {
179
+ current = parseJsonDocument(text);
180
+ }
181
+ catch {
182
+ return undefined;
183
+ }
184
+ if (!isJsonObject(current))
185
+ return undefined;
186
+ const changes = [];
187
+ collectChanges([], current, next, changes);
188
+ const style = detectStyle(text);
189
+ try {
190
+ let edited = text;
191
+ for (const change of changes)
192
+ edited = applyChange(edited, change, style);
193
+ // The edits are structural; prove the result before trusting it.
194
+ return jsonEqual(parseJsonDocument(edited), next) ? edited : undefined;
195
+ }
196
+ catch {
197
+ return undefined;
198
+ }
199
+ }
@@ -1,9 +1,10 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
- import { basename, join } from 'node:path';
2
+ import { basename, dirname, join } from 'node:path';
3
3
  import { MachNotFoundError } from '../errors/build.js';
4
4
  import { pathExists } from '../utils/fs.js';
5
5
  import { info, warn } from '../utils/logger.js';
6
6
  import { exec, execInherit, execInheritCapture, execSmokeRun, execStream, } from '../utils/process.js';
7
+ import { withBuildRunRecord } from './build-run-record.js';
7
8
  import { createSiblingLockPath, withFileLock } from './file-lock.js';
8
9
  import { ensureFirefoxIgnorefileCompatibility } from './firefox-ignorefile.js';
9
10
  import { explainMachError } from './mach-error-hints.js';
@@ -227,6 +228,12 @@ export async function runProtectedMachBuild(kind, engineDir, options = {}) {
227
228
  if (kind === 'full' && options.jobs !== undefined) {
228
229
  args.push('-j', String(options.jobs));
229
230
  }
231
+ // The run record lets any session see why a bundle is missing after this
232
+ // build dies part-way (see build-run-record.ts). The engine directory is
233
+ // always `<projectRoot>/engine`.
234
+ return withBuildRunRecord(dirname(engineDir), `mach ${args.join(' ')}`, () => runProtectedMachBuildAttempts(args, kind, engineDir, options));
235
+ }
236
+ async function runProtectedMachBuildAttempts(args, kind, engineDir, options) {
230
237
  const maxAttempts = Math.max(1, (options.retries ?? DEFAULT_BUILD_CRASH_RETRIES) + 1);
231
238
  for (let attempt = 1;; attempt += 1) {
232
239
  const { env } = await installMachResourceGuard(engineDir);
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Parsing and strict application of a `fireforge test --negative-control`
3
+ * diff.
4
+ *
5
+ * A control is a reviewed mutation, so the applier is deliberately strict:
6
+ * every context and removed line must match exactly, the only tolerance is
7
+ * a line offset (the same one `git apply` allows, never fuzz), and a hunk
8
+ * that does not apply refuses the whole control before any file is
9
+ * written. Only plain text modifications of existing files are accepted: a
10
+ * created, deleted, renamed or binary file has no byte-exact restore story
11
+ * that is worth its risk for a neuter.
12
+ */
13
+ import { type ParsedHunk } from './patch-parse.js';
14
+ /** One file of a control diff. */
15
+ export interface ControlDiffFile {
16
+ /** The path the diff names, without git's `a/`/`b/` prefix. */
17
+ path: string;
18
+ /** The hunks to apply, in file order. */
19
+ hunks: ParsedHunk[];
20
+ }
21
+ /**
22
+ * Parses a control diff. Accepts `git diff` output (a `diff --git` header
23
+ * per file), which is what `git -C engine diff -- <file>` or a project-root
24
+ * `git diff` writes.
25
+ *
26
+ * @param content - The diff file's text
27
+ * @returns One entry per file, in diff order
28
+ */
29
+ export declare function parseControlDiff(content: string): ControlDiffFile[];
30
+ /**
31
+ * Applies `hunks` to `original`, strictly.
32
+ *
33
+ * @param original - The file's current text
34
+ * @param hunks - Hunks parsed from the control diff
35
+ * @param label - File label for refusal messages
36
+ * @returns The mutated text
37
+ * @throws GeneralError when any hunk does not apply
38
+ */
39
+ export declare function applyControlHunks(original: string, hunks: readonly ParsedHunk[], label: string): string;
@@ -0,0 +1,136 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Parsing and strict application of a `fireforge test --negative-control`
4
+ * diff.
5
+ *
6
+ * A control is a reviewed mutation, so the applier is deliberately strict:
7
+ * every context and removed line must match exactly, the only tolerance is
8
+ * a line offset (the same one `git apply` allows, never fuzz), and a hunk
9
+ * that does not apply refuses the whole control before any file is
10
+ * written. Only plain text modifications of existing files are accepted: a
11
+ * created, deleted, renamed or binary file has no byte-exact restore story
12
+ * that is worth its risk for a neuter.
13
+ */
14
+ import { GeneralError } from '../errors/base.js';
15
+ import { isContainedRelativePath, normalizePathSlashes } from '../utils/paths.js';
16
+ import { parseDiffSections } from './patch-parse.js';
17
+ function refuse(message) {
18
+ throw new GeneralError(`Negative control diff refused: ${message}`);
19
+ }
20
+ /**
21
+ * Parses a control diff. Accepts `git diff` output (a `diff --git` header
22
+ * per file), which is what `git -C engine diff -- <file>` or a project-root
23
+ * `git diff` writes.
24
+ *
25
+ * @param content - The diff file's text
26
+ * @returns One entry per file, in diff order
27
+ */
28
+ export function parseControlDiff(content) {
29
+ const sections = parseDiffSections(content);
30
+ if (sections.length === 0) {
31
+ refuse('no `diff --git` sections found. Write the control with git, e.g. ' +
32
+ '`git -C engine diff -- <file> > control.diff` after making the edit, then revert the edit.');
33
+ }
34
+ const seen = new Set();
35
+ return sections.map((section) => {
36
+ const path = normalizePathSlashes(section.targetPath);
37
+ if (section.isBinary)
38
+ refuse(`${path} is a binary section.`);
39
+ if (section.isNewFile || section.isDeletedFile) {
40
+ refuse(`${path} creates or deletes a file; a control may only modify existing text files.`);
41
+ }
42
+ if (section.sourcePath !== section.targetPath) {
43
+ refuse(`${section.sourcePath} -> ${path} is a rename; a control may only modify files.`);
44
+ }
45
+ if (!isContainedRelativePath(path)) {
46
+ refuse(`${path} is not a contained relative path.`);
47
+ }
48
+ if (section.hunks.length === 0)
49
+ refuse(`${path} has no hunks.`);
50
+ if (seen.has(path))
51
+ refuse(`${path} appears in more than one section.`);
52
+ seen.add(path);
53
+ return { path, hunks: section.hunks };
54
+ });
55
+ }
56
+ function hunkSides(hunk, label, index) {
57
+ const oldLines = [];
58
+ const newLines = [];
59
+ for (const line of hunk.lines) {
60
+ const body = line.slice(1);
61
+ if (line.startsWith(' ') || line.startsWith('-'))
62
+ oldLines.push(body);
63
+ if (line.startsWith(' ') || line.startsWith('+'))
64
+ newLines.push(body);
65
+ }
66
+ if (oldLines.length !== hunk.oldCount || newLines.length !== hunk.newCount) {
67
+ refuse(`hunk ${index + 1} of ${label} is malformed: its header counts ` +
68
+ `(-${hunk.oldCount} +${hunk.newCount}) do not match its body ` +
69
+ `(-${oldLines.length} +${newLines.length}).`);
70
+ }
71
+ return { oldLines, newLines };
72
+ }
73
+ function matchesAt(lines, block, position) {
74
+ if (position < 0 || position + block.length > lines.length)
75
+ return false;
76
+ return block.every((line, offset) => lines[position + offset] === line);
77
+ }
78
+ /**
79
+ * The position nearest `anchor`, at or after `floor`, where `block`
80
+ * matches, or -1.
81
+ */
82
+ function findBlock(lines, block, anchor, floor) {
83
+ const start = Math.max(anchor, floor);
84
+ for (let delta = 0; delta <= lines.length; delta += 1) {
85
+ if (matchesAt(lines, block, start + delta))
86
+ return start + delta;
87
+ const below = anchor - delta;
88
+ if (delta > 0 && below >= floor && matchesAt(lines, block, below))
89
+ return below;
90
+ }
91
+ return -1;
92
+ }
93
+ /**
94
+ * Applies `hunks` to `original`, strictly.
95
+ *
96
+ * @param original - The file's current text
97
+ * @param hunks - Hunks parsed from the control diff
98
+ * @param label - File label for refusal messages
99
+ * @returns The mutated text
100
+ * @throws GeneralError when any hunk does not apply
101
+ */
102
+ export function applyControlHunks(original, hunks, label) {
103
+ const hasFinalNewline = original.endsWith('\n');
104
+ const lines = original === '' ? [] : (hasFinalNewline ? original.slice(0, -1) : original).split('\n');
105
+ const result = [];
106
+ let cursor = 0;
107
+ let finalNewline = hasFinalNewline;
108
+ hunks.forEach((hunk, index) => {
109
+ const { oldLines, newLines } = hunkSides(hunk, label, index);
110
+ const anchor = hunk.oldCount === 0 ? hunk.oldStart : hunk.oldStart - 1;
111
+ const position = oldLines.length === 0
112
+ ? anchor >= cursor && anchor <= lines.length
113
+ ? anchor
114
+ : -1
115
+ : findBlock(lines, oldLines, anchor, cursor);
116
+ const reachesEnd = position + oldLines.length === lines.length;
117
+ const endMatches = position >= 0 &&
118
+ (reachesEnd && oldLines.length > 0
119
+ ? hunk.noNewlineAtEndOld === !hasFinalNewline
120
+ : !hunk.noNewlineAtEndOld);
121
+ if (position < 0 || !endMatches) {
122
+ const alreadyApplied = newLines.length > 0 && findBlock(lines, newLines, Math.max(0, hunk.newStart - 1), 0) >= 0;
123
+ refuse(`hunk ${index + 1} of ${label} (@@ -${hunk.oldStart},${hunk.oldCount} ` +
124
+ `+${hunk.newStart},${hunk.newCount} @@) does not apply to the current file.` +
125
+ (alreadyApplied
126
+ ? ' Its new side is already present: the mutation looks applied already (an earlier control that was not restored?).'
127
+ : ' The file changed since the diff was written; regenerate the control against it.'));
128
+ }
129
+ result.push(...lines.slice(cursor, position), ...newLines);
130
+ cursor = position + oldLines.length;
131
+ if (reachesEnd)
132
+ finalNewline = !hunk.noNewlineAtEndNew;
133
+ });
134
+ result.push(...lines.slice(cursor));
135
+ return result.join('\n') + (finalNewline && result.length > 0 ? '\n' : '');
136
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * `fireforge test --negative-control <diff>`: apply a reviewed mutation to
3
+ * engine sources for exactly one harness dispatch, then put every byte back.
4
+ *
5
+ * Safety model (also stated in docs/testing.md):
6
+ *
7
+ * - The control is applied and restored only while the caller holds the
8
+ * engine-session lock, after the pre-test build has packaged and before
9
+ * the harness spawns, so no peer build or test can observe it and a
10
+ * Furnace deploy cannot overwrite it.
11
+ * - Before the first mutated byte is written, the original bytes of every
12
+ * target go to `.fireforge/negative-control/` together with a journal
13
+ * recording each file's original and mutated SHA-256, mode and
14
+ * timestamps. A killed FireForge therefore leaves enough on disk to undo
15
+ * the control, and the next `fireforge test` does so before anything else.
16
+ * - Restore writes the original bytes back atomically, re-applies the
17
+ * original mode and timestamps, and reads the file back to prove the
18
+ * bytes match. It only overwrites a file that still holds exactly the
19
+ * bytes the control wrote: anything else means someone edited the file
20
+ * while the control ran, and that edit is kept, never clobbered, with the
21
+ * original left in the journal directory and the conflict reported.
22
+ * - A SIGINT or SIGTERM restores through {@link restoreActiveNegativeControlsForSignal},
23
+ * which the entry point runs after the harness has shut down and before
24
+ * it releases the held locks.
25
+ */
26
+ import type { ParsedHunk } from './patch-parse.js';
27
+ /** One file a control mutates. */
28
+ export interface NegativeControlTarget {
29
+ /** The path as the diff names it. */
30
+ diffPath: string;
31
+ /** The engine-relative file the control mutates. */
32
+ enginePath: string;
33
+ /** Hunks to apply to the engine file. */
34
+ hunks: ParsedHunk[];
35
+ /** True when the diff named a `components/` source mapped to its deployed copy. */
36
+ fromComponents: boolean;
37
+ }
38
+ /** A parsed, resolved control, ready to apply. */
39
+ export interface NegativeControlPlan {
40
+ /** The diff file, as the operator passed it. */
41
+ diffFile: string;
42
+ targets: NegativeControlTarget[];
43
+ }
44
+ /** Locations a control works against. */
45
+ export interface NegativeControlPaths {
46
+ engineDir: string;
47
+ /** The project's `.fireforge/` directory. */
48
+ fireforgeDir: string;
49
+ }
50
+ interface JournalEntry {
51
+ enginePath: string;
52
+ backup: string;
53
+ originalSha256: string;
54
+ mutatedSha256: string;
55
+ mode: number;
56
+ atimeMs: number;
57
+ mtimeMs: number;
58
+ }
59
+ interface Journal {
60
+ version: 1;
61
+ diffFile: string;
62
+ appliedAt: string;
63
+ entries: JournalEntry[];
64
+ }
65
+ /** A control currently applied to the engine. */
66
+ export interface ActiveNegativeControl {
67
+ paths: NegativeControlPaths;
68
+ journal: Journal;
69
+ /**
70
+ * The one restore in flight, set synchronously by whichever path (the
71
+ * run's own `finally`, or the signal handler) gets there first, so the
72
+ * other awaits the same restore instead of racing or skipping it.
73
+ */
74
+ restoring?: Promise<NegativeControlRestoreResult>;
75
+ }
76
+ /**
77
+ * Reads, parses and resolves a control diff, and (unless `deferApplyCheck`)
78
+ * proves every hunk applies to the current engine file, so a broken control
79
+ * is refused before any build time is spent.
80
+ *
81
+ * `deferApplyCheck` is for a run that builds first: Furnace redeploys a
82
+ * component's engine copy during the build, so a `components/` control can
83
+ * only be checked against what the build left behind.
84
+ *
85
+ * @param projectRoot - Project root; the diff path resolves against it
86
+ * @param engineDir - Absolute engine directory
87
+ * @param diffFile - The `--negative-control` argument
88
+ * @param options - `deferApplyCheck` skips the up-front apply check for
89
+ * `components/` targets
90
+ */
91
+ export declare function planNegativeControl(projectRoot: string, engineDir: string, diffFile: string, options?: {
92
+ deferApplyCheck?: boolean;
93
+ }): Promise<NegativeControlPlan>;
94
+ /**
95
+ * Applies the control: every target is computed first (a hunk that does
96
+ * not apply refuses with nothing written), the originals and the journal
97
+ * are persisted, then the mutated bytes are written. Runs as a signal
98
+ * critical section, so a Ctrl+C mid-apply waits for the apply to finish
99
+ * and the signal path then restores a complete journal.
100
+ *
101
+ * @returns The handle {@link restoreNegativeControl} takes
102
+ */
103
+ export declare function applyNegativeControl(paths: NegativeControlPaths, plan: NegativeControlPlan): Promise<ActiveNegativeControl>;
104
+ /** What happened to each file of a restored control. */
105
+ export interface NegativeControlRestoreResult {
106
+ /** Engine-relative files put back to their original bytes. */
107
+ restored: string[];
108
+ /** Engine-relative files that held neither the control's bytes nor the original. */
109
+ conflicts: string[];
110
+ }
111
+ /**
112
+ * Restores a control applied by {@link applyNegativeControl}. Idempotent:
113
+ * a second caller (the signal handler racing the run's own `finally`)
114
+ * receives the restore already in flight.
115
+ *
116
+ * @throws GeneralError when a file changed under the control (the edit is
117
+ * kept and the original stays in the journal directory) or did not read
118
+ * back byte-exact
119
+ */
120
+ export declare function restoreNegativeControl(control: ActiveNegativeControl): Promise<NegativeControlRestoreResult>;
121
+ /**
122
+ * Restores every applied control on the way out of a SIGINT/SIGTERM. Called
123
+ * by the entry point after child shutdown and before the held locks are
124
+ * force-released. Never throws: a failure is reported on stderr and the
125
+ * journal stays for the next run's recovery.
126
+ */
127
+ export declare function restoreActiveNegativeControlsForSignal(): Promise<void>;
128
+ /**
129
+ * Undoes a control a killed FireForge left applied. Called at the start of
130
+ * every `fireforge test`, under the engine-session lock. A file that still
131
+ * holds the control's bytes is restored; a file somebody changed since is
132
+ * refused rather than overwritten.
133
+ *
134
+ * @throws PreflightRefusalError (`note=negative-control-pending`) when a
135
+ * journal entry cannot be restored safely
136
+ */
137
+ export declare function recoverPendingNegativeControl(paths: NegativeControlPaths): Promise<void>;
138
+ export {};