@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,117 @@
1
+ /**
2
+ * Patch-size lint: the `large-patch-lines` and `large-patch-files` rules,
3
+ * the tier decision they share, and the measurement they read.
4
+ *
5
+ * Split out of `patch-lint.ts` (per-file line budget). `patch-lint.ts`
6
+ * re-exports this module's surface so callers keep importing from there.
7
+ */
8
+ import type { PatchLintIssue } from '../types/commands/index.js';
9
+ import type { FireForgeConfig, PatchLintPatchSizeMetric } from '../types/config.js';
10
+ import { type PatchSizeThresholds, type PatchSizeTierName } from './patch-lint-size-thresholds.js';
11
+ /**
12
+ * Returns true if the file path looks like a test file.
13
+ * Matches paths containing `/test/` or filenames starting with
14
+ * `browser_`, `test_`, or `xpcshell_` (all `.js`). This is the test class
15
+ * of both size rules: `file-too-large` picks the test thresholds with it,
16
+ * and the patch-size tier and the `addedCode` per-class split read it.
17
+ */
18
+ export declare function isTestFile(file: string): boolean;
19
+ /**
20
+ * Counts the total lines in a unified diff and the number of non-binary
21
+ * text lines, so binary hunks do not inflate patch size checks.
22
+ *
23
+ * Line counting agrees with `wc -l`: a trailing newline does not add a
24
+ * phantom empty line to either `total` or the binary-hunk accounting.
25
+ *
26
+ * @param diffContent - Raw unified diff string
27
+ * @returns Object with `total` line count and `textLines` (total minus binary hunk lines)
28
+ */
29
+ export declare function countNonBinaryDiffLines(diffContent: string): {
30
+ total: number;
31
+ textLines: number;
32
+ };
33
+ /**
34
+ * Describes which tier `resolvePatchSizeTier` selected and why.
35
+ * Consumers that want to surface the tier choice to the operator
36
+ * (e.g. a one-line `info()` when branding thresholds kick in) read
37
+ * this alongside the issues array from `lintPatchSize`.
38
+ */
39
+ export type PatchSizeTierDecision = {
40
+ tier: 'general';
41
+ } | {
42
+ tier: 'test';
43
+ } | {
44
+ tier: 'branding';
45
+ source: 'auto' | 'explicit';
46
+ };
47
+ /**
48
+ * Decides which patch-size threshold tier applies to a patch.
49
+ * Exported so `runPatchLint` and the per-patch `lint` command can
50
+ * surface the tier choice to the operator *without* depending on
51
+ * `lintPatchSize`'s internal return shape.
52
+ *
53
+ * Precedence: test > branding (explicit) > branding (auto) > general.
54
+ * The test tier beats branding because a table-driven regression test
55
+ * is legitimately large independent of whether the patch also claims
56
+ * branding shape, and the test-tier thresholds are already more
57
+ * permissive than branding, so "tests beat branding" is the
58
+ * defensive-for-tests choice.
59
+ */
60
+ export declare function resolvePatchSizeTier(filesAffected: ReadonlyArray<string>, patchTier?: 'branding'): PatchSizeTierDecision;
61
+ /**
62
+ * The size thresholds a tier enforces, with `patchLint.patchSizeThresholds`
63
+ * applied when `config` is passed. Public-API companion to
64
+ * {@link resolvePatchSizeTier} / {@link countNonBinaryDiffLines} so
65
+ * consumers can build waiver-freshness checks against the same numbers
66
+ * `large-patch-lines` / `large-patch-files` fire on instead of mirroring
67
+ * them. Without `config` it returns the built-in defaults.
68
+ *
69
+ * @param tier - The tier to read
70
+ * @param config - The project configuration, whose overrides apply
71
+ */
72
+ export declare function getPatchSizeThresholds(tier: PatchSizeTierName, config?: Pick<FireForgeConfig, 'patchLint'>): PatchSizeThresholds;
73
+ /** What a patch measures under the configured patch-size metric. */
74
+ export interface PatchSizeMeasurement {
75
+ /** The metric `lines` is counted in. */
76
+ metric: PatchLintPatchSizeMetric;
77
+ /** Counted lines, registration files left out. */
78
+ lines: number;
79
+ /**
80
+ * Of `lines`, those in test files. Only `addedCode` attributes lines per
81
+ * file, so this is 0 under `diff`.
82
+ */
83
+ testLines: number;
84
+ /** Files `large-patch-files` counts: the patch's files minus registration files. */
85
+ files: number;
86
+ /** Files left out of both counts as registration files. */
87
+ registrationFiles: number;
88
+ }
89
+ /**
90
+ * Measures a patch under `patchLint.patchSizeMetric`.
91
+ *
92
+ * `diff` counts the non-binary lines of the unified diff, headers and
93
+ * context included. `addedCode` counts the added lines that carry code,
94
+ * judged on each file's post-image read from `repoDir` (a hunk can start
95
+ * inside a block comment, so the `+` lines alone cannot tell), and skips
96
+ * lines inside Furnace fragment expansions. Files matching
97
+ * `patchLint.patchSizeRegistrationFiles` count toward neither number.
98
+ *
99
+ * @param repoDir - Engine directory holding the post-images
100
+ * @param diffContent - The patch's unified diff
101
+ * @param filesAffected - The patch's files
102
+ * @param config - Project configuration
103
+ */
104
+ export declare function measurePatchSize(repoDir: string, diffContent: string, filesAffected: readonly string[], config: Pick<FireForgeConfig, 'patchLint'>): Promise<PatchSizeMeasurement>;
105
+ /**
106
+ * Checks patch size against the tier's thresholds.
107
+ *
108
+ * @param filesAffected - Files touched by the patch
109
+ * @param measured - The {@link measurePatchSize} result, or a bare
110
+ * non-binary diff line count (the `diff` metric over every file)
111
+ * @param patchTier - Optional explicit tier override declared on
112
+ * `PatchMetadata.tier`. When `"branding"`, forces the branding
113
+ * thresholds regardless of `filesAffected`. Tests still win over
114
+ * branding (precedence `test > branding > general`).
115
+ * @param config - Project configuration, for `patchLint.patchSizeThresholds`
116
+ */
117
+ export declare function lintPatchSize(filesAffected: string[], measured: number | PatchSizeMeasurement, patchTier?: 'branding', config?: Pick<FireForgeConfig, 'patchLint'>): PatchLintIssue[];
@@ -0,0 +1,323 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Patch-size lint: the `large-patch-lines` and `large-patch-files` rules,
4
+ * the tier decision they share, and the measurement they read.
5
+ *
6
+ * Split out of `patch-lint.ts` (per-file line budget). `patch-lint.ts`
7
+ * re-exports this module's surface so callers keep importing from there.
8
+ */
9
+ import { join } from 'node:path';
10
+ import { pathExists, readText } from '../utils/fs.js';
11
+ import { codeLineMask } from './patch-lint-code-lines.js';
12
+ import { AGGREGATE_PATCH_FILE } from './patch-lint-diff-tag.js';
13
+ import { isPatchSizeRegistrationFile, resolvePatchSizeThresholds, } from './patch-lint-size-thresholds.js';
14
+ import { parseDiffGitHeader, parseDiffSections } from './patch-parse.js';
15
+ /**
16
+ * Shared remedy sentence for the over-budget findings. Every place an
17
+ * operator stands when a patch outgrows its budget must name the
18
+ * sanctioned one-transaction way out, or they reach for the wrong tools
19
+ * first. One constant, so the wording cannot drift.
20
+ */
21
+ const SPLIT_HINT = 'Consider splitting into smaller, focused patches — ' +
22
+ '"fireforge patch move-files <from> <to> --file <path> --create --order <n>" ' +
23
+ 'moves files into a new patch in one transaction.';
24
+ /**
25
+ * Fixed allowlist of non-branding sibling paths that real-world Firefox
26
+ * branding patches legitimately need to touch to register the new branding
27
+ * flavor with the top-level configure. A predicate requiring every file to
28
+ * live under `browser/branding/` drops a branding patch that also touches
29
+ * `browser/moz.configure` (the canonical registration point) into the
30
+ * general lint tier.
31
+ *
32
+ * Narrow on purpose: add entries only when a genuine branding patch
33
+ * cannot be expressed without a specific registration sibling. Pinned
34
+ * against ESR 140.x conventions at time of writing.
35
+ */
36
+ const BRANDING_REGISTRATION_FILES = new Set([
37
+ 'browser/moz.configure',
38
+ 'browser/confvars.sh',
39
+ ]);
40
+ /**
41
+ * Returns true when a patch qualifies for the branding threshold tier:
42
+ * every file lives either under `browser/branding/` or in the narrow
43
+ * registration allowlist, and the patch contains at least one file
44
+ * under `browser/branding/` (guard against a config-only patch
45
+ * accidentally qualifying as branding).
46
+ */
47
+ function isBrandingOnlyPatch(files) {
48
+ if (files.length === 0)
49
+ return false;
50
+ let hasBrandingFile = false;
51
+ for (const file of files) {
52
+ if (file.startsWith('browser/branding/')) {
53
+ hasBrandingFile = true;
54
+ continue;
55
+ }
56
+ if (BRANDING_REGISTRATION_FILES.has(file))
57
+ continue;
58
+ return false;
59
+ }
60
+ return hasBrandingFile;
61
+ }
62
+ /**
63
+ * Returns true if the file path looks like a test file.
64
+ * Matches paths containing `/test/` or filenames starting with
65
+ * `browser_`, `test_`, or `xpcshell_` (all `.js`). This is the test class
66
+ * of both size rules: `file-too-large` picks the test thresholds with it,
67
+ * and the patch-size tier and the `addedCode` per-class split read it.
68
+ */
69
+ export function isTestFile(file) {
70
+ if (file.includes('/test/'))
71
+ return true;
72
+ const basename = file.split('/').pop() ?? '';
73
+ return /^(?:browser_|test_|xpcshell_).*\.js$/.test(basename);
74
+ }
75
+ /**
76
+ * Counts the total lines in a unified diff and the number of non-binary
77
+ * text lines, so binary hunks do not inflate patch size checks.
78
+ *
79
+ * Line counting agrees with `wc -l`: a trailing newline does not add a
80
+ * phantom empty line to either `total` or the binary-hunk accounting.
81
+ *
82
+ * @param diffContent - Raw unified diff string
83
+ * @returns Object with `total` line count and `textLines` (total minus binary hunk lines)
84
+ */
85
+ export function countNonBinaryDiffLines(diffContent) {
86
+ return countDiffLines(diffContent, () => false);
87
+ }
88
+ /**
89
+ * {@link countNonBinaryDiffLines} with whole file sections left out of
90
+ * `textLines` when `isExcluded` names their path (registration files).
91
+ */
92
+ function countDiffLines(diffContent, isExcluded) {
93
+ const lines = diffContent.split('\n');
94
+ if (lines[lines.length - 1] === '')
95
+ lines.pop();
96
+ const total = lines.length;
97
+ let uncounted = 0;
98
+ let inBinaryHunk = false;
99
+ let inExcluded = false;
100
+ for (const line of lines) {
101
+ if (line.startsWith('GIT binary patch')) {
102
+ inBinaryHunk = true;
103
+ }
104
+ else if (line.startsWith('diff --git ')) {
105
+ inBinaryHunk = false;
106
+ const header = parseDiffGitHeader(line);
107
+ inExcluded = header !== null && isExcluded(header.targetPath);
108
+ }
109
+ if (inBinaryHunk || inExcluded) {
110
+ uncounted++;
111
+ }
112
+ }
113
+ return { total, textLines: total - uncounted };
114
+ }
115
+ /**
116
+ * Decides which patch-size threshold tier applies to a patch.
117
+ * Exported so `runPatchLint` and the per-patch `lint` command can
118
+ * surface the tier choice to the operator *without* depending on
119
+ * `lintPatchSize`'s internal return shape.
120
+ *
121
+ * Precedence: test > branding (explicit) > branding (auto) > general.
122
+ * The test tier beats branding because a table-driven regression test
123
+ * is legitimately large independent of whether the patch also claims
124
+ * branding shape, and the test-tier thresholds are already more
125
+ * permissive than branding, so "tests beat branding" is the
126
+ * defensive-for-tests choice.
127
+ */
128
+ export function resolvePatchSizeTier(filesAffected, patchTier) {
129
+ const allTests = filesAffected.length > 0 && filesAffected.every(isTestFile);
130
+ if (allTests)
131
+ return { tier: 'test' };
132
+ if (patchTier === 'branding')
133
+ return { tier: 'branding', source: 'explicit' };
134
+ if (isBrandingOnlyPatch(filesAffected))
135
+ return { tier: 'branding', source: 'auto' };
136
+ return { tier: 'general' };
137
+ }
138
+ /**
139
+ * The size thresholds a tier enforces, with `patchLint.patchSizeThresholds`
140
+ * applied when `config` is passed. Public-API companion to
141
+ * {@link resolvePatchSizeTier} / {@link countNonBinaryDiffLines} so
142
+ * consumers can build waiver-freshness checks against the same numbers
143
+ * `large-patch-lines` / `large-patch-files` fire on instead of mirroring
144
+ * them. Without `config` it returns the built-in defaults.
145
+ *
146
+ * @param tier - The tier to read
147
+ * @param config - The project configuration, whose overrides apply
148
+ */
149
+ export function getPatchSizeThresholds(tier, config) {
150
+ return resolvePatchSizeThresholds(tier, config?.patchLint?.patchSizeThresholds);
151
+ }
152
+ /**
153
+ * Measures a patch under `patchLint.patchSizeMetric`.
154
+ *
155
+ * `diff` counts the non-binary lines of the unified diff, headers and
156
+ * context included. `addedCode` counts the added lines that carry code,
157
+ * judged on each file's post-image read from `repoDir` (a hunk can start
158
+ * inside a block comment, so the `+` lines alone cannot tell), and skips
159
+ * lines inside Furnace fragment expansions. Files matching
160
+ * `patchLint.patchSizeRegistrationFiles` count toward neither number.
161
+ *
162
+ * @param repoDir - Engine directory holding the post-images
163
+ * @param diffContent - The patch's unified diff
164
+ * @param filesAffected - The patch's files
165
+ * @param config - Project configuration
166
+ */
167
+ export async function measurePatchSize(repoDir, diffContent, filesAffected, config) {
168
+ const globs = config.patchLint?.patchSizeRegistrationFiles ?? [];
169
+ const isRegistration = (file) => isPatchSizeRegistrationFile(file, globs);
170
+ const registrationFiles = filesAffected.filter(isRegistration).length;
171
+ const files = filesAffected.length - registrationFiles;
172
+ if (config.patchLint?.patchSizeMetric !== 'addedCode') {
173
+ const lines = countDiffLines(diffContent, isRegistration).textLines;
174
+ return { metric: 'diff', lines, testLines: 0, files, registrationFiles };
175
+ }
176
+ let lines = 0;
177
+ let testLines = 0;
178
+ for (const section of parseDiffSections(diffContent)) {
179
+ if (section.isBinary || isRegistration(section.targetPath))
180
+ continue;
181
+ const counted = await countAddedCodeLines(repoDir, section);
182
+ lines += counted;
183
+ if (isTestFile(section.targetPath))
184
+ testLines += counted;
185
+ }
186
+ return { metric: 'addedCode', lines, testLines, files, registrationFiles };
187
+ }
188
+ /**
189
+ * Counts one section's added lines that carry code in the post-image.
190
+ * Without a readable post-image, every non-blank added line counts: the
191
+ * upper bound, so a missing file never shrinks the measurement.
192
+ */
193
+ async function countAddedCodeLines(repoDir, section) {
194
+ const path = join(repoDir, section.targetPath);
195
+ const mask = (await pathExists(path))
196
+ ? codeLineMask(section.targetPath, await readText(path)).code
197
+ : undefined;
198
+ let count = 0;
199
+ for (const hunk of section.hunks) {
200
+ let newLine = hunk.newStart;
201
+ for (const line of hunk.lines) {
202
+ if (line.startsWith('\\') || line.startsWith('-'))
203
+ continue;
204
+ if (line.startsWith('+')) {
205
+ const isCode = mask ? mask[newLine - 1] === true : line.slice(1).trim() !== '';
206
+ if (isCode)
207
+ count++;
208
+ }
209
+ newLine++;
210
+ }
211
+ }
212
+ return count;
213
+ }
214
+ function bandFor(count, thresholds) {
215
+ if (thresholds.error !== null && count > thresholds.error)
216
+ return 'error';
217
+ if (count > thresholds.warning)
218
+ return 'warning';
219
+ if (count > thresholds.notice)
220
+ return 'notice';
221
+ return undefined;
222
+ }
223
+ /**
224
+ * Renders a patch-size finding. The notice band is advisory and says so;
225
+ * the warning band names what a `--max-warnings 0` gate does with it and
226
+ * where to tune it; the split hint rides on the bands that can fail a gate.
227
+ */
228
+ function formatPatchSizeMessage(subject, band, thresholds) {
229
+ const errorPart = thresholds.error === null ? '' : `, error threshold: ${thresholds.error}`;
230
+ if (band === 'error') {
231
+ return `${subject} (error threshold: ${thresholds.error}). ${SPLIT_HINT}`;
232
+ }
233
+ if (band === 'notice') {
234
+ return (`${subject}, over the design target of ${thresholds.notice} ` +
235
+ `(warning threshold: ${thresholds.warning}${errorPart}). ` +
236
+ 'Advisory; does not fail --max-warnings 0.');
237
+ }
238
+ return (`${subject} (warning threshold: ${thresholds.warning}${errorPart}). ` +
239
+ 'Reported as a warning, which a gate running --max-warnings 0 treats as a failure; ' +
240
+ 'tune "patchLint.patchSizeThresholds" if these limits are wrong for this project. ' +
241
+ SPLIT_HINT);
242
+ }
243
+ /**
244
+ * The line-count comparisons for a patch. Under `addedCode` a general-tier
245
+ * patch splits per class: test files against the test tier's lines,
246
+ * everything else against the general tier's, so a test riding with its
247
+ * subject is held to the same budget as a test-only patch.
248
+ */
249
+ function lineChecks(measurement, tier, thresholds, config) {
250
+ const perClass = measurement.metric === 'addedCode' && tier === 'general';
251
+ if (!perClass || measurement.testLines === 0) {
252
+ return [{ count: measurement.lines, thresholds: thresholds.lines, scope: '' }];
253
+ }
254
+ return [
255
+ {
256
+ count: measurement.lines - measurement.testLines,
257
+ thresholds: thresholds.lines,
258
+ scope: ' in non-test files',
259
+ },
260
+ {
261
+ count: measurement.testLines,
262
+ thresholds: getPatchSizeThresholds('test', config).lines,
263
+ scope: ' in test files (test tier)',
264
+ },
265
+ ];
266
+ }
267
+ /**
268
+ * Checks patch size against the tier's thresholds.
269
+ *
270
+ * @param filesAffected - Files touched by the patch
271
+ * @param measured - The {@link measurePatchSize} result, or a bare
272
+ * non-binary diff line count (the `diff` metric over every file)
273
+ * @param patchTier - Optional explicit tier override declared on
274
+ * `PatchMetadata.tier`. When `"branding"`, forces the branding
275
+ * thresholds regardless of `filesAffected`. Tests still win over
276
+ * branding (precedence `test > branding > general`).
277
+ * @param config - Project configuration, for `patchLint.patchSizeThresholds`
278
+ */
279
+ export function lintPatchSize(filesAffected, measured, patchTier, config) {
280
+ const measurement = typeof measured === 'number'
281
+ ? {
282
+ metric: 'diff',
283
+ lines: measured,
284
+ testLines: 0,
285
+ files: filesAffected.length,
286
+ registrationFiles: 0,
287
+ }
288
+ : measured;
289
+ const issues = [];
290
+ // Both rules read off the same decision so the file-count and line-count
291
+ // rules cannot disagree about which tier applies.
292
+ const decision = resolvePatchSizeTier(filesAffected, patchTier);
293
+ const thresholds = getPatchSizeThresholds(decision.tier, config);
294
+ const filesBand = bandFor(measurement.files, thresholds.files);
295
+ if (filesBand !== undefined) {
296
+ const registration = measurement.registrationFiles > 0
297
+ ? ` (not counting ${measurement.registrationFiles} registration file(s))`
298
+ : '';
299
+ issues.push({
300
+ file: AGGREGATE_PATCH_FILE,
301
+ check: 'large-patch-files',
302
+ message: formatPatchSizeMessage(`Patch affects ${measurement.files} files${registration}`, filesBand, thresholds.files),
303
+ severity: filesBand,
304
+ measurement: measurement.files,
305
+ });
306
+ }
307
+ for (const check of lineChecks(measurement, decision.tier, thresholds, config)) {
308
+ const band = bandFor(check.count, check.thresholds);
309
+ if (band === undefined)
310
+ continue;
311
+ const subject = measurement.metric === 'addedCode'
312
+ ? `Patch adds ${check.count} code lines${check.scope}`
313
+ : `Patch is ${check.count} lines`;
314
+ issues.push({
315
+ file: AGGREGATE_PATCH_FILE,
316
+ check: 'large-patch-lines',
317
+ message: formatPatchSizeMessage(subject, band, check.thresholds),
318
+ severity: band,
319
+ measurement: check.count,
320
+ });
321
+ }
322
+ return issues;
323
+ }
@@ -1,4 +1,11 @@
1
- import type { PatchStagedRegistration } from '../types/commands/index.js';
1
+ import type { PatchStagedForwardImport, PatchStagedRegistration } from '../types/commands/index.js';
2
+ /**
3
+ * The `--reason` argument every printed `patch staged-dependency --add`
4
+ * remedy ends with. A declaration without a reason reads as unreviewed
5
+ * residue to a queue audit, so the paste-and-run command asks for one
6
+ * rather than inviting the operator to leave it out.
7
+ */
8
+ export declare const STAGED_DEPENDENCY_REASON_PLACEHOLDER = "--reason '<why this patch references a file the owner creates later>'";
2
9
  /** A patch that creates a given path, with its position in the queue. */
3
10
  export interface NewFileOwner {
4
11
  filename: string;
@@ -59,3 +66,13 @@ export declare function describeRegistrationFailure(failure: StagedRegistrationF
59
66
  * forward-registration remedy leave the declaring patch red.
60
67
  */
61
68
  export declare function quoteRegistrationLine(line: string): string;
69
+ /**
70
+ * The `staged-dependency-unused` fingerprint for one declaration on
71
+ * `holder`, the one spelling both the rule and the callers that need to
72
+ * recognise its findings (`patch split`/`move-files --create` deciding which
73
+ * declarations a move made unused) build it with.
74
+ *
75
+ * @param holder - Filename of the patch carrying the declaration
76
+ * @param declaration - Either declaration kind
77
+ */
78
+ export declare function stagedDependencyUnusedFingerprint(holder: string, declaration: PatchStagedForwardImport | PatchStagedRegistration): string;
@@ -12,6 +12,13 @@
12
12
  * `patch-lint-forward-registration.ts`, with no registrar or rule of its own.
13
13
  */
14
14
  import { basename } from 'node:path';
15
+ /**
16
+ * The `--reason` argument every printed `patch staged-dependency --add`
17
+ * remedy ends with. A declaration without a reason reads as unreviewed
18
+ * residue to a queue audit, so the paste-and-run command asks for one
19
+ * rather than inviting the operator to leave it out.
20
+ */
21
+ export const STAGED_DEPENDENCY_REASON_PLACEHOLDER = "--reason '<why this patch references a file the owner creates later>'";
15
22
  /**
16
23
  * Later-in-apply-order predicate shared by the forward-import scan, the
17
24
  * forward-registration rule and the registration validation: strictly
@@ -88,3 +95,16 @@ function isRegistrationLinePresent(entry, registration) {
88
95
  export function quoteRegistrationLine(line) {
89
96
  return line.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
90
97
  }
98
+ /**
99
+ * The `staged-dependency-unused` fingerprint for one declaration on
100
+ * `holder`, the one spelling both the rule and the callers that need to
101
+ * recognise its findings (`patch split`/`move-files --create` deciding which
102
+ * declarations a move made unused) build it with.
103
+ *
104
+ * @param holder - Filename of the patch carrying the declaration
105
+ * @param declaration - Either declaration kind
106
+ */
107
+ export function stagedDependencyUnusedFingerprint(holder, declaration) {
108
+ const matcher = 'specifier' in declaration ? declaration.specifier : `reg:${declaration.line}`;
109
+ return `staged-dependency-unused|${holder}|${declaration.file}|${matcher}|${declaration.creates}|${declaration.owner ?? ''}`;
110
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * `token-scale`: values of governed CSS properties must resolve to a token
3
+ * on the property's scale (`patchLint.tokenScales`).
4
+ *
5
+ * The raw-colour and token-prefix rules stop a patch from inventing colours
6
+ * or referencing foreign custom properties, but say nothing about
7
+ * `gap: 7px`, `font-size: 13px` or `z-index: 9999`. Those are the values a
8
+ * design scale exists to remove, and without a rule they drift back one
9
+ * literal at a time. A scale names its properties, the token prefix that
10
+ * defines its steps, and the literals that are not steps at all (`0`,
11
+ * `auto`, a percentage).
12
+ *
13
+ * Judged per value atom, after peeling `var()` innermost-first so a fallback
14
+ * is held to the same scale as the token it backs: `var(--x-gap, 7px)` is a
15
+ * literal in disguise. `calc()`, `min()`, `max()` and `clamp()` are
16
+ * transparent, so a value built from tokens passes and one that adds a raw
17
+ * length does not. Unitless multipliers inside them are literals too and
18
+ * need an `allowLiterals` entry.
19
+ *
20
+ * Runs over CSS files the patch creates, not files it modifies: an upstream
21
+ * stylesheet is not the fork's to put on a scale. A line carrying
22
+ * `fireforge-ignore: token-scale` is skipped.
23
+ */
24
+ import type { PatchLintIssue } from '../types/commands/index.js';
25
+ import type { PatchLintTokenScale } from '../types/config.js';
26
+ /**
27
+ * Returns the first atom of `value` that is off `scale`, or undefined when
28
+ * every atom resolves to the scale.
29
+ */
30
+ export declare function findOffScaleAtom(value: string, scale: PatchLintTokenScale): string | undefined;
31
+ /**
32
+ * Lints one patch-created CSS file against the configured scales.
33
+ *
34
+ * @param file - Engine-relative path, for the issue
35
+ * @param css - The file's full content
36
+ * @param scales - `patchLint.tokenScales`
37
+ * @returns One `token-scale` error per off-scale declaration
38
+ */
39
+ export declare function lintTokenScales(file: string, css: string, scales: readonly PatchLintTokenScale[]): PatchLintIssue[];
@@ -0,0 +1,109 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * `token-scale`: values of governed CSS properties must resolve to a token
4
+ * on the property's scale (`patchLint.tokenScales`).
5
+ *
6
+ * The raw-colour and token-prefix rules stop a patch from inventing colours
7
+ * or referencing foreign custom properties, but say nothing about
8
+ * `gap: 7px`, `font-size: 13px` or `z-index: 9999`. Those are the values a
9
+ * design scale exists to remove, and without a rule they drift back one
10
+ * literal at a time. A scale names its properties, the token prefix that
11
+ * defines its steps, and the literals that are not steps at all (`0`,
12
+ * `auto`, a percentage).
13
+ *
14
+ * Judged per value atom, after peeling `var()` innermost-first so a fallback
15
+ * is held to the same scale as the token it backs: `var(--x-gap, 7px)` is a
16
+ * literal in disguise. `calc()`, `min()`, `max()` and `clamp()` are
17
+ * transparent, so a value built from tokens passes and one that adds a raw
18
+ * length does not. Unitless multipliers inside them are literals too and
19
+ * need an `allowLiterals` entry.
20
+ *
21
+ * Runs over CSS files the patch creates, not files it modifies: an upstream
22
+ * stylesheet is not the fork's to put on a scale. A line carrying
23
+ * `fireforge-ignore: token-scale` is skipped.
24
+ */
25
+ import { escapeRegex } from '../utils/regex.js';
26
+ const IGNORE_MARKER = 'fireforge-ignore: token-scale';
27
+ /** CSS-wide keywords: never a step on any scale, always legal. */
28
+ const CSS_WIDE_KEYWORDS = new Set(['inherit', 'initial', 'unset', 'revert', 'revert-layer']);
29
+ const TOKEN_PLACEHOLDER = '\u0000token\u0000';
30
+ /** Exact name, or a `*`-suffixed prefix. */
31
+ function matchesName(name, patterns) {
32
+ return patterns.some((pattern) => pattern.endsWith('*') ? name.startsWith(pattern.slice(0, -1)) : name === pattern);
33
+ }
34
+ function matchesLiteral(atom, patterns) {
35
+ return patterns.some((pattern) => new RegExp(`^${pattern.split('*').map(escapeRegex).join('.*')}$`).test(atom));
36
+ }
37
+ /**
38
+ * Returns the first atom of `value` that is off `scale`, or undefined when
39
+ * every atom resolves to the scale.
40
+ */
41
+ export function findOffScaleAtom(value, scale) {
42
+ let rest = value.replace(/!\s*important\s*$/i, '');
43
+ const varPattern = /var\(\s*(--[\w-]+)\s*(?:,([^()]*))?\)/;
44
+ for (let match = varPattern.exec(rest); match !== null; match = varPattern.exec(rest)) {
45
+ const name = match[1] ?? '';
46
+ if (!name.startsWith(scale.prefix) && !matchesName(name, scale.allow ?? [])) {
47
+ return `var(${name})`;
48
+ }
49
+ const fallback = match[2];
50
+ if (fallback !== undefined && fallback.trim() !== '') {
51
+ const offending = findOffScaleAtom(fallback, scale);
52
+ if (offending !== undefined)
53
+ return offending;
54
+ }
55
+ rest = `${rest.slice(0, match.index)} ${TOKEN_PLACEHOLDER} ${rest.slice(match.index + match[0].length)}`;
56
+ }
57
+ const atoms = rest
58
+ .replace(/\b(?:calc|min|max|clamp)\(/gi, ' ')
59
+ .replace(/[(),]/g, ' ')
60
+ .split(/\s+/)
61
+ .filter((atom) => atom !== '' && !['+', '-', '*', '/'].includes(atom));
62
+ return atoms.find((atom) => atom !== TOKEN_PLACEHOLDER &&
63
+ !CSS_WIDE_KEYWORDS.has(atom.toLowerCase()) &&
64
+ !matchesLiteral(atom, scale.allowLiterals ?? []));
65
+ }
66
+ /** Blanks block comments, keeping newlines so offsets map to lines. */
67
+ function maskComments(css) {
68
+ return css.replace(/\/\*[\s\S]*?\*\//g, (comment) => comment.replace(/[^\n]/g, ' '));
69
+ }
70
+ /**
71
+ * Lints one patch-created CSS file against the configured scales.
72
+ *
73
+ * @param file - Engine-relative path, for the issue
74
+ * @param css - The file's full content
75
+ * @param scales - `patchLint.tokenScales`
76
+ * @returns One `token-scale` error per off-scale declaration
77
+ */
78
+ export function lintTokenScales(file, css, scales) {
79
+ if (scales.length === 0)
80
+ return [];
81
+ const rawLines = css.split('\n');
82
+ const masked = maskComments(css);
83
+ const issues = [];
84
+ const declaration = /(^|[{;\s])([a-zA-Z][a-zA-Z-]*)\s*:\s*([^;{}]*)/g;
85
+ for (let match = declaration.exec(masked); match !== null; match = declaration.exec(masked)) {
86
+ const property = (match[2] ?? '').toLowerCase();
87
+ const value = (match[3] ?? '').trim();
88
+ const scale = scales.find((candidate) => matchesName(property, candidate.properties));
89
+ if (scale === undefined || value === '')
90
+ continue;
91
+ const start = match.index + (match[1] ?? '').length;
92
+ const line = masked.slice(0, start).split('\n').length;
93
+ if ((rawLines[line - 1] ?? '').includes(IGNORE_MARKER))
94
+ continue;
95
+ const atom = findOffScaleAtom(value, scale);
96
+ if (atom === undefined)
97
+ continue;
98
+ issues.push({
99
+ file,
100
+ check: 'token-scale',
101
+ fingerprint: `token-scale:${file}:${property}:${value}`,
102
+ message: `Line ${String(line)}: ${property}: ${value} is off the ${scale.prefix}* scale ` +
103
+ `("${atom}"). Use a ${scale.prefix}* token, or list a reviewed exception in ` +
104
+ `patchLint.tokenScales (allowLiterals / allow) or mark the line "${IGNORE_MARKER}".`,
105
+ severity: 'error',
106
+ });
107
+ }
108
+ return issues;
109
+ }