parseman 0.46.0 → 0.47.1

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 (316) hide show
  1. package/README.md +8 -3
  2. package/dist/analysis/choice-cost-render.d.ts +12 -4
  3. package/dist/analysis/choice-cost-render.d.ts.map +1 -1
  4. package/dist/analysis/choice-cost.d.ts +2 -2
  5. package/dist/analysis/choice-cost.d.ts.map +1 -1
  6. package/dist/analysis/commitment.d.ts +131 -0
  7. package/dist/analysis/commitment.d.ts.map +1 -0
  8. package/dist/analysis/diagnostics.cjs +10339 -7787
  9. package/dist/analysis/diagnostics.cjs.map +3 -4
  10. package/dist/analysis/diagnostics.d.ts +2 -2
  11. package/dist/analysis/diagnostics.js +10339 -7787
  12. package/dist/analysis/diagnostics.js.map +3 -4
  13. package/dist/analysis/duplication.d.ts.map +1 -1
  14. package/dist/analysis/fix.d.ts +3 -3
  15. package/dist/analysis/fix.d.ts.map +1 -1
  16. package/dist/analysis/format-number.d.ts +23 -0
  17. package/dist/analysis/format-number.d.ts.map +1 -0
  18. package/dist/analysis/gating.d.ts +39 -2
  19. package/dist/analysis/gating.d.ts.map +1 -1
  20. package/dist/analysis/rebuild.d.ts.map +1 -1
  21. package/dist/analysis/terminal.d.ts +5 -2
  22. package/dist/analysis/terminal.d.ts.map +1 -1
  23. package/dist/cli/index.cjs +10924 -8506
  24. package/dist/cli/index.cjs.map +3 -4
  25. package/dist/cli/index.js +10924 -8506
  26. package/dist/cli/index.js.map +3 -4
  27. package/dist/combinators/adjacency.d.ts +100 -0
  28. package/dist/combinators/adjacency.d.ts.map +1 -0
  29. package/dist/combinators/choice.d.ts.map +1 -1
  30. package/dist/combinators/completions.d.ts.map +1 -1
  31. package/dist/combinators/dispatch.d.ts +8 -1
  32. package/dist/combinators/dispatch.d.ts.map +1 -1
  33. package/dist/combinators/expect.d.ts.map +1 -1
  34. package/dist/combinators/first-set.d.ts.map +1 -1
  35. package/dist/combinators/gate.d.ts.map +1 -1
  36. package/dist/combinators/grammar.d.ts +22 -0
  37. package/dist/combinators/grammar.d.ts.map +1 -1
  38. package/dist/combinators/literal.d.ts +8 -0
  39. package/dist/combinators/literal.d.ts.map +1 -1
  40. package/dist/combinators/map.d.ts +0 -1
  41. package/dist/combinators/map.d.ts.map +1 -1
  42. package/dist/combinators/node.d.ts +13 -3
  43. package/dist/combinators/node.d.ts.map +1 -1
  44. package/dist/combinators/parser.d.ts +1 -1
  45. package/dist/combinators/parser.d.ts.map +1 -1
  46. package/dist/combinators/ref.d.ts.map +1 -1
  47. package/dist/combinators/regex.d.ts +1 -16
  48. package/dist/combinators/regex.d.ts.map +1 -1
  49. package/dist/combinators/repeat.d.ts +30 -2
  50. package/dist/combinators/repeat.d.ts.map +1 -1
  51. package/dist/combinators/scanTo.d.ts +71 -1
  52. package/dist/combinators/scanTo.d.ts.map +1 -1
  53. package/dist/combinators/sequence.d.ts.map +1 -1
  54. package/dist/combinators/token.d.ts.map +1 -1
  55. package/dist/combinators/trivia-boundary.d.ts +26 -0
  56. package/dist/combinators/trivia-boundary.d.ts.map +1 -0
  57. package/dist/combinators/trivia-skip.d.ts +45 -1
  58. package/dist/combinators/trivia-skip.d.ts.map +1 -1
  59. package/dist/combinators/withCtx.d.ts.map +1 -1
  60. package/dist/compiler/compile-linkable-table.d.ts +104 -0
  61. package/dist/compiler/compile-linkable-table.d.ts.map +1 -0
  62. package/dist/compiler/degradation.d.ts +0 -2
  63. package/dist/compiler/degradation.d.ts.map +1 -1
  64. package/dist/compiler/fields.d.ts +13 -0
  65. package/dist/compiler/fields.d.ts.map +1 -1
  66. package/dist/compiler/grammar-coverage-ids.d.ts +5 -5
  67. package/dist/compiler/grammar-coverage-ids.d.ts.map +1 -1
  68. package/dist/compiler/ir-serialize.d.ts.map +1 -1
  69. package/dist/compiler/linker.d.ts +47 -53
  70. package/dist/compiler/linker.d.ts.map +1 -1
  71. package/dist/compiler/token-alphabet.d.ts +81 -0
  72. package/dist/compiler/token-alphabet.d.ts.map +1 -0
  73. package/dist/compiler/token-dispatch.d.ts +95 -0
  74. package/dist/compiler/token-dispatch.d.ts.map +1 -0
  75. package/dist/compiler/token-scanner.d.ts +53 -0
  76. package/dist/compiler/token-scanner.d.ts.map +1 -0
  77. package/dist/compiler/value-usage.d.ts.map +1 -1
  78. package/dist/coverage.d.ts +1 -1
  79. package/dist/coverage.d.ts.map +1 -1
  80. package/dist/cst/capture-buffer.d.ts +35 -0
  81. package/dist/cst/capture-buffer.d.ts.map +1 -1
  82. package/dist/cst/reflection-attach.d.ts +8 -0
  83. package/dist/cst/reflection-attach.d.ts.map +1 -0
  84. package/dist/cst/reflection-symbols.d.ts +10 -0
  85. package/dist/cst/reflection-symbols.d.ts.map +1 -0
  86. package/dist/cst/reflection.d.ts +2 -4
  87. package/dist/cst/reflection.d.ts.map +1 -1
  88. package/dist/cst/root-trivia-scope.d.ts +26 -0
  89. package/dist/cst/root-trivia-scope.d.ts.map +1 -0
  90. package/dist/cst/trivia-charscan.d.ts +18 -0
  91. package/dist/cst/trivia-charscan.d.ts.map +1 -0
  92. package/dist/cst/trivia-kinds.d.ts +19 -0
  93. package/dist/cst/trivia-kinds.d.ts.map +1 -1
  94. package/dist/functional/doc.d.ts.map +1 -1
  95. package/dist/functional/run-tabled.d.ts +12 -0
  96. package/dist/functional/run-tabled.d.ts.map +1 -0
  97. package/dist/functional/run.d.ts +25 -42
  98. package/dist/functional/run.d.ts.map +1 -1
  99. package/dist/grammar-metadata.d.ts +3 -0
  100. package/dist/grammar-metadata.d.ts.map +1 -0
  101. package/dist/index.cjs +9290 -7181
  102. package/dist/index.cjs.map +3 -4
  103. package/dist/index.d.ts +18 -9
  104. package/dist/index.d.ts.map +1 -1
  105. package/dist/index.js +9300 -7191
  106. package/dist/index.js.map +3 -4
  107. package/dist/language-service/index.cjs +313 -121
  108. package/dist/language-service/index.cjs.map +3 -4
  109. package/dist/language-service/index.js +313 -121
  110. package/dist/language-service/index.js.map +3 -4
  111. package/dist/oracle/index.cjs.map +0 -1
  112. package/dist/oracle/index.js.map +0 -1
  113. package/dist/parse-context.d.ts +49 -0
  114. package/dist/parse-context.d.ts.map +1 -0
  115. package/dist/plugin/evaluator.d.ts +40 -1
  116. package/dist/plugin/evaluator.d.ts.map +1 -1
  117. package/dist/plugin/index.cjs +11175 -9534
  118. package/dist/plugin/index.cjs.map +3 -4
  119. package/dist/plugin/index.d.ts.map +1 -1
  120. package/dist/plugin/index.js +11175 -9534
  121. package/dist/plugin/index.js.map +3 -4
  122. package/dist/regex/classes.d.ts +28 -0
  123. package/dist/regex/classes.d.ts.map +1 -1
  124. package/dist/regex/first-set.d.ts +14 -0
  125. package/dist/regex/first-set.d.ts.map +1 -1
  126. package/dist/run/index.cjs +136 -51
  127. package/dist/run/index.cjs.map +2 -3
  128. package/dist/run/index.js +136 -51
  129. package/dist/run/index.js.map +2 -3
  130. package/dist/spec/index.cjs +2934 -1611
  131. package/dist/spec/index.cjs.map +3 -4
  132. package/dist/spec/index.js +2934 -1611
  133. package/dist/spec/index.js.map +3 -4
  134. package/dist/spec/model.d.ts +8 -0
  135. package/dist/spec/model.d.ts.map +1 -1
  136. package/dist/table/assemble.d.ts +307 -0
  137. package/dist/table/assemble.d.ts.map +1 -0
  138. package/dist/table/cell.d.ts +42 -0
  139. package/dist/table/cell.d.ts.map +1 -0
  140. package/dist/table/child-slots.d.ts +30 -0
  141. package/dist/table/child-slots.d.ts.map +1 -0
  142. package/dist/table/compile-rule-map.d.ts +136 -0
  143. package/dist/table/compile-rule-map.d.ts.map +1 -0
  144. package/dist/table/compile.d.ts +56 -0
  145. package/dist/table/compile.d.ts.map +1 -0
  146. package/dist/table/duplication-hook.d.ts +32 -0
  147. package/dist/table/duplication-hook.d.ts.map +1 -0
  148. package/dist/table/emit-assembly.d.ts +139 -0
  149. package/dist/table/emit-assembly.d.ts.map +1 -0
  150. package/dist/table/emit.d.ts +113 -0
  151. package/dist/table/emit.d.ts.map +1 -0
  152. package/dist/table/encode-baseline.d.ts +16 -0
  153. package/dist/table/encode-baseline.d.ts.map +1 -0
  154. package/dist/table/encode.d.ts +71 -0
  155. package/dist/table/encode.d.ts.map +1 -0
  156. package/dist/table/exec-baseline.d.ts +9 -0
  157. package/dist/table/exec-baseline.d.ts.map +1 -0
  158. package/dist/table/exec.d.ts +72 -0
  159. package/dist/table/exec.d.ts.map +1 -0
  160. package/dist/table/fold.d.ts +25 -0
  161. package/dist/table/fold.d.ts.map +1 -0
  162. package/dist/table/index.cjs +11463 -0
  163. package/dist/table/index.cjs.map +6 -0
  164. package/dist/table/index.d.ts +100 -0
  165. package/dist/table/index.d.ts.map +1 -0
  166. package/dist/table/index.js +11440 -0
  167. package/dist/table/index.js.map +6 -0
  168. package/dist/table/inspect.d.ts +17 -0
  169. package/dist/table/inspect.d.ts.map +1 -0
  170. package/dist/table/ops.d.ts +453 -0
  171. package/dist/table/ops.d.ts.map +1 -0
  172. package/dist/table/program.d.ts +496 -0
  173. package/dist/table/program.d.ts.map +1 -0
  174. package/dist/table/run-support.d.ts +38 -0
  175. package/dist/table/run-support.d.ts.map +1 -0
  176. package/dist/{compiler/scannable-run.d.ts → table/scan-shapes.d.ts} +14 -13
  177. package/dist/table/scan-shapes.d.ts.map +1 -0
  178. package/dist/table/site-labels.d.ts +58 -0
  179. package/dist/table/site-labels.d.ts.map +1 -0
  180. package/dist/table/stamp.d.ts +70 -0
  181. package/dist/table/stamp.d.ts.map +1 -0
  182. package/dist/types.d.ts +80 -24
  183. package/dist/types.d.ts.map +1 -1
  184. package/dist/version.d.ts +1 -1
  185. package/package.json +26 -2
  186. package/src/analysis/choice-cost-gate.ts +359 -0
  187. package/src/analysis/choice-cost-render.ts +293 -0
  188. package/src/analysis/choice-cost.ts +1008 -0
  189. package/src/analysis/commitment.ts +557 -0
  190. package/src/analysis/corpus.ts +130 -0
  191. package/src/analysis/diagnose-render.ts +625 -0
  192. package/src/analysis/diagnose.ts +317 -0
  193. package/src/analysis/diagnostics.ts +33 -0
  194. package/src/analysis/duplication.ts +1955 -0
  195. package/src/analysis/fix-render.ts +217 -0
  196. package/src/analysis/fix.ts +669 -0
  197. package/src/analysis/format-number.ts +31 -0
  198. package/src/analysis/gating.ts +792 -0
  199. package/src/analysis/grammar.ts +72 -0
  200. package/src/analysis/rebuild.ts +287 -0
  201. package/src/analysis/terminal.ts +336 -0
  202. package/src/cli/index.ts +413 -0
  203. package/src/combinators/adjacency.ts +171 -0
  204. package/src/combinators/attempt.ts +41 -0
  205. package/src/combinators/case-fold.ts +90 -0
  206. package/src/combinators/choice.ts +466 -0
  207. package/src/combinators/completions.ts +64 -0
  208. package/src/combinators/dispatch.ts +358 -0
  209. package/src/combinators/expect.ts +159 -0
  210. package/src/combinators/first-set.ts +348 -0
  211. package/src/combinators/gate.ts +50 -0
  212. package/src/combinators/grammar.ts +272 -0
  213. package/src/combinators/keywords.ts +145 -0
  214. package/src/combinators/literal.ts +102 -0
  215. package/src/combinators/map.ts +106 -0
  216. package/src/combinators/node.ts +345 -0
  217. package/src/combinators/not.ts +55 -0
  218. package/src/combinators/parser.ts +266 -0
  219. package/src/combinators/peek.ts +63 -0
  220. package/src/combinators/probe.ts +25 -0
  221. package/src/combinators/ref.ts +109 -0
  222. package/src/combinators/regex.ts +170 -0
  223. package/src/combinators/repeat.ts +561 -0
  224. package/src/combinators/scanTo.ts +393 -0
  225. package/src/combinators/sequence.ts +237 -0
  226. package/src/combinators/token.ts +120 -0
  227. package/src/combinators/trivia-boundary.ts +48 -0
  228. package/src/combinators/trivia-skip.ts +852 -0
  229. package/src/combinators/withCtx.ts +45 -0
  230. package/src/compiler/build-arity.ts +330 -0
  231. package/src/compiler/compile-linkable-table.ts +209 -0
  232. package/src/compiler/degradation.ts +266 -0
  233. package/src/compiler/fields.ts +191 -0
  234. package/src/compiler/grammar-coverage-ids.ts +129 -0
  235. package/src/compiler/ir-serialize.ts +615 -0
  236. package/src/compiler/linker.ts +954 -0
  237. package/src/compiler/token-alphabet.ts +209 -0
  238. package/src/compiler/token-dispatch.ts +353 -0
  239. package/src/compiler/token-scanner.ts +318 -0
  240. package/src/compiler/value-usage.ts +147 -0
  241. package/src/coverage.ts +383 -0
  242. package/src/cst/capture-buffer.ts +297 -0
  243. package/src/cst/host-mode.ts +69 -0
  244. package/src/cst/reflection-attach.ts +11 -0
  245. package/src/cst/reflection-symbols.ts +9 -0
  246. package/src/cst/reflection.ts +149 -0
  247. package/src/cst/relative-spans.ts +207 -0
  248. package/src/cst/root-trivia-scope.ts +30 -0
  249. package/src/cst/trivia-charscan.ts +312 -0
  250. package/src/cst/trivia-entries.ts +285 -0
  251. package/src/cst/trivia-index.ts +131 -0
  252. package/src/cst/trivia-kinds.ts +358 -0
  253. package/src/cst/types.ts +76 -0
  254. package/src/cst/walk.ts +101 -0
  255. package/src/functional/doc.ts +959 -0
  256. package/src/functional/run-tabled.ts +153 -0
  257. package/src/functional/run.ts +558 -0
  258. package/src/grammar-metadata.ts +2 -0
  259. package/src/index.ts +189 -0
  260. package/src/language-service/index.ts +221 -0
  261. package/src/line-index.ts +131 -0
  262. package/src/oracle/digest.ts +620 -0
  263. package/src/oracle/index.ts +55 -0
  264. package/src/parse-context.ts +91 -0
  265. package/src/plugin/direct-builder-static.ts +102 -0
  266. package/src/plugin/evaluator.ts +1505 -0
  267. package/src/plugin/free-identifiers.ts +338 -0
  268. package/src/plugin/index.ts +2281 -0
  269. package/src/plugin/reducer-resolver.ts +606 -0
  270. package/src/recovery/scan.ts +155 -0
  271. package/src/regex/classes.ts +183 -0
  272. package/src/regex/first-set.ts +316 -0
  273. package/src/run/index.ts +31 -0
  274. package/src/spec/ebnf.ts +128 -0
  275. package/src/spec/index.ts +54 -0
  276. package/src/spec/model.ts +636 -0
  277. package/src/spec/railroad-lib.ts +559 -0
  278. package/src/spec/railroad.ts +256 -0
  279. package/src/table/assemble.ts +3006 -0
  280. package/src/table/cell.ts +42 -0
  281. package/src/table/child-slots.ts +113 -0
  282. package/src/table/compile-rule-map.ts +336 -0
  283. package/src/table/compile.ts +244 -0
  284. package/src/table/duplication-hook.ts +89 -0
  285. package/src/table/emit-assembly.ts +1777 -0
  286. package/src/table/emit.ts +429 -0
  287. package/src/table/encode-baseline.ts +340 -0
  288. package/src/table/encode.ts +1569 -0
  289. package/src/table/exec-baseline.ts +537 -0
  290. package/src/table/exec.ts +1680 -0
  291. package/src/table/fold.ts +38 -0
  292. package/src/table/index.ts +108 -0
  293. package/src/table/inspect.ts +52 -0
  294. package/src/table/ops.ts +467 -0
  295. package/src/table/program.ts +876 -0
  296. package/src/table/run-support.ts +66 -0
  297. package/src/table/scan-shapes.ts +1578 -0
  298. package/src/table/site-labels.ts +228 -0
  299. package/src/table/stamp.ts +229 -0
  300. package/src/types.ts +563 -0
  301. package/src/version.ts +16 -0
  302. package/dist/compiler/codegen.d.ts +0 -334
  303. package/dist/compiler/codegen.d.ts.map +0 -1
  304. package/dist/compiler/inline-build.d.ts +0 -11
  305. package/dist/compiler/inline-build.d.ts.map +0 -1
  306. package/dist/compiler/inline-callback.d.ts +0 -21
  307. package/dist/compiler/inline-callback.d.ts.map +0 -1
  308. package/dist/compiler/line-index.d.ts +0 -2
  309. package/dist/compiler/line-index.d.ts.map +0 -1
  310. package/dist/compiler/module-hoist.d.ts +0 -106
  311. package/dist/compiler/module-hoist.d.ts.map +0 -1
  312. package/dist/compiler/scannable-run.d.ts.map +0 -1
  313. package/dist/compiler/scannable-terminal.d.ts +0 -23
  314. package/dist/compiler/scannable-terminal.d.ts.map +0 -1
  315. package/dist/compiler/trivia-fast-path.d.ts +0 -38
  316. package/dist/compiler/trivia-fast-path.d.ts.map +0 -1
@@ -0,0 +1,359 @@
1
+ /**
2
+ * CHOICE COST — the policy layer.
3
+ * ===============================
4
+ *
5
+ * Analysis says what the grammar costs. This says which of those numbers fail a build.
6
+ * They are separate because they have different consumers and different lifetimes: a
7
+ * developer wants the ranked list on every run, CI wants one bit, and the threshold
8
+ * that produces that bit is a decision the owner makes once and writes down.
9
+ *
10
+ * This module is PURE — it takes a report and a baseline and returns a verdict. It
11
+ * reads no files, spawns nothing, and prints nothing, so it is testable without a
12
+ * fixture checkout and cannot be the reason a gate is flaky.
13
+ *
14
+ * THE BASELINE IS ABSOLUTE, NOT DIFFERENTIAL
15
+ * ------------------------------------------
16
+ * Gating each commit against its parent lets +2% per commit land forever: every
17
+ * individual step is under tolerance and the total is unbounded. So the committed
18
+ * baseline records the ACTUAL byte counts, and drift is measured against those
19
+ * numbers, not against yesterday's.
20
+ *
21
+ * A baseline is committed DATA, reviewed like any other diff. Nothing here writes one.
22
+ * Rebaselining is a deliberate act with a reviewable artifact and owner sign-off; there
23
+ * is no automatic refresh, because a gate that rebaselines itself is a gate that
24
+ * records regressions rather than catching them.
25
+ *
26
+ * AND IT IS TWO-SIDED — A WIN MUST BE BANKED
27
+ * ------------------------------------------
28
+ * Growth past the committed number fails, and so does an unbanked IMPROVEMENT. This is
29
+ * the half usually left to a comment asking a human politely, and it is the half that
30
+ * rots: `bench/grammar-density/config.json` and `bench/workloads/config.json` each
31
+ * carried exactly such a comment and sat unbumped for TEN releases. If a reorder takes
32
+ * a site from 7,846 wasted bytes to 2,000 and the baseline does not move with it, 5,846
33
+ * bytes of fresh headroom silently become budget for the next regression — and the gate
34
+ * that was supposed to catch that regression reads green through the whole of it.
35
+ *
36
+ * So the committed number is a BAND, not a floor: leaving it in either direction fails,
37
+ * and only the remedy differs. Raising a number needs owner sign-off; lowering one is
38
+ * mandatory. Both are the same one-line rebaseline and the same reviewable diff.
39
+ *
40
+ * `bench/size-guard.ts` is the shape this follows — it earned the tightness in 0.45 by
41
+ * catching +0.14% moves a 1% tolerance would have waved through.
42
+ *
43
+ * A BASELINE CANNOT LAUNDER A CEILING
44
+ * -----------------------------------
45
+ * If a ceiling is configured, a baseline that records a number ABOVE it is rejected as
46
+ * invalid rather than honoured. Otherwise the ceiling would be waivable by rebaselining,
47
+ * which makes it a suggestion. (`bench/size-guard.ts` validates its own baseline for
48
+ * exactly this reason; this follows it.)
49
+ *
50
+ * FAILS CLOSED
51
+ * ------------
52
+ * Every way of not having measured is a failure, never a pass: a missing baseline, a
53
+ * baseline of the wrong shape, an empty baseline, a report over zero corpus files, a
54
+ * report where no site was instrumentable, a report whose grammar walk was incomplete
55
+ * (`unresolvedRoots` non-empty), a baselined key that no longer exists, and a measured
56
+ * key with no baseline entry. This repo already contains the alternative — `ratio:
57
+ * ordered.length === 0 ? 1` in src/coverage.ts reports 100% covered when nothing was
58
+ * analysable — and the whole point of a gate is to not be that.
59
+ */
60
+
61
+ import type { WastedWorkReport } from './choice-cost.ts'
62
+
63
+ export type WastedWorkBaseline = {
64
+ readonly schema: 'parseman.wasted-work-baseline/1'
65
+ /** Informational: which revision produced the numbers. Never compared. */
66
+ gitRev: string
67
+ /** Informational, `YYYY-MM-DD`. Never compared — comparing it would make the
68
+ * verdict depend on the clock. */
69
+ updatedAt: string
70
+ /**
71
+ * Absolute per-corpus totals, keyed by corpus id.
72
+ *
73
+ * `totalWastedBytes` is the INTERPRETED column, and that is deliberate even though
74
+ * `WastedWorkReport` names `totalGatedWastedBytes` as the headline. For BYTES the two
75
+ * are the same number, structurally: the modelled first-char guard is derived from the
76
+ * arm's first SET, which over-approximates what the arm can start with, so wherever the
77
+ * guard rejects, the arm's own leading terminal would have rejected at the same position
78
+ * having consumed nothing. Measured over four dialect grammars, zero arms differ (see the
79
+ * header of `choice-cost.ts`). What the guard removes is ATTEMPTS, not rescanned bytes —
80
+ * so `gatedAttempts`, `gatedFailures` and the inversion ranking DO read the gated columns,
81
+ * and only the byte totals are recorded from the interpreted one, where it is additionally
82
+ * the conservative UPPER bound of the two.
83
+ *
84
+ * That identity is not enforced anywhere it could be relied on silently: `checkWastedWork`
85
+ * judges the compiled column against this same band wherever the two columns part, so a
86
+ * divergence goes red instead of unnoticed.
87
+ */
88
+ totals: Record<string, { corpusBytes: number; totalWastedBytes: number; instrumentedSites: number }>
89
+ /** Absolute per-site wasted bytes, keyed by `<corpus>::<siteKey>`. Interpreted column,
90
+ * for the reason given on `totals` above. */
91
+ sites: Record<string, number>
92
+ }
93
+
94
+ export type WastedWorkPolicy = {
95
+ /**
96
+ * Maximum wasted bytes per corpus byte. A hard ceiling no rebaseline can waive.
97
+ * Omit for drift-only gating; a project should set it once it knows its own number.
98
+ */
99
+ ceilingRatio?: number
100
+ /**
101
+ * Half-width of the band around each committed number, as a percentage. Default 1.
102
+ *
103
+ * SYMMETRIC: a measurement more than this ABOVE its baseline is a regression, and a
104
+ * measurement more than this BELOW it is an unbanked win. Both fail.
105
+ *
106
+ * This is not a noise allowance — the metric is a deterministic count of input bytes,
107
+ * so its noise floor is exactly zero and any non-zero value here is pure headroom.
108
+ * It exists only so incidental one-byte churn does not thrash the baseline in both
109
+ * directions. Set it as near zero as the corpus permits; `bench/choice-cost-guard.ts`
110
+ * runs at 0.1, matching `bench/size-guard.ts`'s ratchet slack.
111
+ */
112
+ driftTolerancePct?: number
113
+ /**
114
+ * Fail on an ordering inversion — an arm that failed every one of its attempts while
115
+ * a later arm at the same site matched. Off by default: on an existing grammar this
116
+ * fires immediately, and a gate that is red on arrival for a pre-existing condition
117
+ * gets disabled rather than fixed. Turn it on once the backlog is empty, to keep it
118
+ * empty.
119
+ */
120
+ failOnInversions?: boolean
121
+ }
122
+
123
+ export type GateBreach = {
124
+ /** `drift` is growth past the band; `shrank` is an improvement that was not banked.
125
+ * Both are failures, and the `detail` says which remedy applies. */
126
+ kind: 'ceiling' | 'drift' | 'shrank' | 'unbaselined' | 'stale' | 'inversion' | 'unmeasurable' | 'invalid-baseline'
127
+ key: string
128
+ /** One line. Says what happened and what number to look at — never advice. */
129
+ detail: string
130
+ }
131
+
132
+ export type GateVerdict = {
133
+ readonly schema: 'parseman.wasted-work-verdict/1'
134
+ ok: boolean
135
+ /** Ascending by (kind, key). Deterministic, so a verdict is diffable. */
136
+ breaches: readonly GateBreach[]
137
+ checkedCorpora: number
138
+ checkedSites: number
139
+ }
140
+
141
+ const KIND_ORDER: GateBreach['kind'][] = [
142
+ 'invalid-baseline', 'unmeasurable', 'ceiling', 'drift', 'shrank', 'inversion', 'unbaselined', 'stale',
143
+ ]
144
+
145
+ const pct = (actual: number, base: number): number => base === 0 ? (actual === 0 ? 0 : Infinity) : (actual / base - 1) * 100
146
+
147
+ /**
148
+ * Judge one or more measured corpora against a committed baseline.
149
+ *
150
+ * `reports` is keyed by corpus id — a grammar is usually gated over more than one body
151
+ * of input, and a single blended number would let a regression on one hide behind an
152
+ * improvement on another.
153
+ */
154
+ export function checkWastedWork(
155
+ reports: Readonly<Record<string, WastedWorkReport>>,
156
+ baseline: unknown,
157
+ policy: WastedWorkPolicy = {},
158
+ ): GateVerdict {
159
+ const breaches: GateBreach[] = []
160
+ const tolerance = policy.driftTolerancePct ?? 1
161
+
162
+ const add = (kind: GateBreach['kind'], key: string, detail: string): void => { breaches.push({ kind, key, detail }) }
163
+
164
+ const finish = (checkedCorpora: number, checkedSites: number): GateVerdict => {
165
+ breaches.sort((a, b) =>
166
+ KIND_ORDER.indexOf(a.kind) - KIND_ORDER.indexOf(b.kind)
167
+ || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
168
+ return {
169
+ schema: 'parseman.wasted-work-verdict/1',
170
+ ok: breaches.length === 0,
171
+ breaches,
172
+ checkedCorpora,
173
+ checkedSites,
174
+ }
175
+ }
176
+
177
+ // ── the baseline must be a baseline ───────────────────────────────────────
178
+ const b = baseline as Partial<WastedWorkBaseline> | null | undefined
179
+ if (b === null || b === undefined || typeof b !== 'object') {
180
+ add('invalid-baseline', '<baseline>', 'no baseline supplied — a gate with no baseline measures nothing, and passing here is how a budget stops being enforced')
181
+ return finish(0, 0)
182
+ }
183
+ if (b.schema !== 'parseman.wasted-work-baseline/1') {
184
+ add('invalid-baseline', '<baseline>', `baseline schema is ${JSON.stringify(b.schema)}, expected "parseman.wasted-work-baseline/1"`)
185
+ return finish(0, 0)
186
+ }
187
+ // `typeof null === 'object'`, so a `"totals": null` would pass a bare `typeof` test
188
+ // and reach `Object.keys` below as an uncaught TypeError. A baseline of the wrong
189
+ // shape is a documented `invalid-baseline` breach, not a crash.
190
+ const isRecord = (v: unknown): v is Record<string, unknown> =>
191
+ typeof v === 'object' && v !== null && !Array.isArray(v)
192
+ if (!isRecord(b.totals) || !isRecord(b.sites)) {
193
+ add('invalid-baseline', '<baseline>', 'baseline `totals` or `sites` is missing or is not an object')
194
+ return finish(0, 0)
195
+ }
196
+ if (Object.keys(b.totals).length === 0) {
197
+ add('invalid-baseline', '<baseline>', 'baseline records ZERO corpora')
198
+ return finish(0, 0)
199
+ }
200
+
201
+ // EVERY recorded value must be a finite number, checked before any of them is read.
202
+ // `baseline` is `unknown` — parsed JSON off disk — and a non-numeric value does not
203
+ // announce itself at the comparison: `pct` returns NaN, and `NaN > tolerance` and
204
+ // `NaN < -tolerance` are BOTH false, so the entry yields no breach and the gate
205
+ // reports a pass over a number it never compared. That is the fail-open this module
206
+ // exists to not be, and it is silent, so it is caught at the shape check instead.
207
+ const finite = (v: unknown): boolean => typeof v === 'number' && Number.isFinite(v)
208
+ for (const [id, t] of Object.entries(b.totals).sort(([x], [y]) => (x < y ? -1 : 1))) {
209
+ if (!isRecord(t) || !finite(t.corpusBytes) || !finite(t.totalWastedBytes)) {
210
+ add('invalid-baseline', id, 'baseline entry has no finite numeric `corpusBytes` / `totalWastedBytes` — the comparison against it would silently produce NaN and no breach')
211
+ }
212
+ }
213
+ for (const [key, v] of Object.entries(b.sites).sort(([x], [y]) => (x < y ? -1 : 1))) {
214
+ if (!finite(v)) add('invalid-baseline', key, 'baseline site value is not a finite number — the comparison against it would silently produce NaN and no breach')
215
+ }
216
+ if (breaches.length > 0) return finish(0, 0)
217
+ if (policy.ceilingRatio !== undefined) {
218
+ for (const [id, t] of Object.entries(b.totals).sort(([x], [y]) => (x < y ? -1 : 1))) {
219
+ if (t.corpusBytes > 0 && t.totalWastedBytes / t.corpusBytes > policy.ceilingRatio) {
220
+ add('invalid-baseline', id,
221
+ `baseline records ${t.totalWastedBytes} wasted / ${t.corpusBytes} corpus bytes = `
222
+ + `${(t.totalWastedBytes / t.corpusBytes).toFixed(3)}x, above the ${policy.ceilingRatio}x ceiling — `
223
+ + 'a rebaseline cannot accept an over-ceiling number')
224
+ }
225
+ }
226
+ if (breaches.length > 0) return finish(0, 0)
227
+ }
228
+
229
+ if (Object.keys(reports).length === 0) {
230
+ add('unmeasurable', '<reports>', 'no corpus was measured — refusing to report a pass on an empty measurement')
231
+ return finish(0, 0)
232
+ }
233
+
234
+ let checkedSites = 0
235
+
236
+ for (const id of Object.keys(reports).sort()) {
237
+ const r = reports[id]!
238
+
239
+ // ── could this report have measured anything? ───────────────────────────
240
+ if (r.corpusFiles === 0) { add('unmeasurable', id, 'report covers ZERO corpus files'); continue }
241
+ if (r.instrumentedSites === 0) { add('unmeasurable', id, 'report instrumented ZERO choice sites — the total below would be zero for a reason unrelated to the grammar'); continue }
242
+ if (r.unresolvedRoots.length > 0) {
243
+ add('unmeasurable', id,
244
+ `${r.unresolvedRoots.length} rule(s) could not be resolved and were NOT walked `
245
+ + `(${r.unresolvedRoots.slice(0, 4).join(', ')}${r.unresolvedRoots.length > 4 ? ', …' : ''}) — `
246
+ + 'the total is a lower bound over an unknown fraction of the grammar')
247
+ continue
248
+ }
249
+
250
+ const base = b.totals[id]
251
+ if (base === undefined) {
252
+ add('unbaselined', id, `measured but absent from the baseline (${r.totalWastedBytes} wasted bytes over ${r.corpusBytes})`)
253
+ continue
254
+ }
255
+
256
+ if (policy.ceilingRatio !== undefined && r.corpusBytes > 0) {
257
+ const ratio = r.totalWastedBytes / r.corpusBytes
258
+ if (ratio > policy.ceilingRatio) {
259
+ add('ceiling', id, `${r.totalWastedBytes} wasted / ${r.corpusBytes} corpus bytes = ${ratio.toFixed(3)}x, over the ${policy.ceilingRatio}x ceiling`)
260
+ }
261
+ }
262
+
263
+ // The corpus itself is part of the measurement. If it changed, the totals are
264
+ // not comparable and a "no drift" verdict would be meaningless.
265
+ if (r.corpusBytes !== base.corpusBytes) {
266
+ add('unmeasurable', id, `corpus changed: baseline ${base.corpusBytes} bytes, measured ${r.corpusBytes} — totals are not comparable until the baseline is refreshed`)
267
+ continue
268
+ }
269
+
270
+ // Two-sided. Growth is a regression; an unbanked win is next quarter's regression
271
+ // budget. Same band, same rebaseline, different sentence.
272
+ const d = pct(r.totalWastedBytes, base.totalWastedBytes)
273
+ if (d > tolerance) {
274
+ add('drift', id, `total wasted ${base.totalWastedBytes} -> ${r.totalWastedBytes} bytes (${d >= 0 ? '+' : ''}${d.toFixed(2)}%, band ±${tolerance}%)`)
275
+ } else if (d < -tolerance) {
276
+ add('shrank', id, `total wasted ${base.totalWastedBytes} -> ${r.totalWastedBytes} bytes (${d.toFixed(2)}%, band ±${tolerance}%) — BANK THE WIN: rebaseline so the ${base.totalWastedBytes - r.totalWastedBytes} bytes you just saved cannot become headroom for the next regression`)
277
+ }
278
+
279
+ // AND THE COMPILED COLUMN IS HELD TO THE SAME BAND. See `WastedWorkBaseline` for
280
+ // why the INTERPRETED column is the one recorded. While the two agree — which is
281
+ // structural, not a property of these corpora — this adds nothing, so it is only
282
+ // evaluated where they part, and there it says so rather than judging one column
283
+ // and reporting the other.
284
+ if (r.totalGatedWastedBytes !== r.totalWastedBytes) {
285
+ const dg = pct(r.totalGatedWastedBytes, base.totalWastedBytes)
286
+ if (dg > tolerance) {
287
+ add('drift', id, `compiled-model total wasted ${base.totalWastedBytes} -> ${r.totalGatedWastedBytes} bytes (${dg >= 0 ? '+' : ''}${dg.toFixed(2)}%, band ±${tolerance}%) — the compiled and interpreted columns have PARTED (${r.totalGatedWastedBytes} vs ${r.totalWastedBytes}); the baseline records the interpreted one`)
288
+ } else if (dg < -tolerance) {
289
+ add('shrank', id, `compiled-model total wasted ${base.totalWastedBytes} -> ${r.totalGatedWastedBytes} bytes (${dg.toFixed(2)}%, band ±${tolerance}%) — the compiled and interpreted columns have PARTED (${r.totalGatedWastedBytes} vs ${r.totalWastedBytes}); the baseline records the interpreted one`)
290
+ }
291
+ }
292
+
293
+ for (const s of r.sites) {
294
+ const key = `${id}::${s.siteKey}`
295
+ const bs = b.sites[key]
296
+ if (bs === undefined) {
297
+ if (s.wastedBytes > 0) add('unbaselined', key, `site measured ${s.wastedBytes} wasted bytes but has no baseline entry`)
298
+ continue
299
+ }
300
+ checkedSites++
301
+ const sd = pct(s.wastedBytes, bs)
302
+ if (sd > tolerance) add('drift', key, `${bs} -> ${s.wastedBytes} bytes (${sd >= 0 ? '+' : ''}${sd.toFixed(2)}%, band ±${tolerance}%)`)
303
+ else if (sd < -tolerance) add('shrank', key, `${bs} -> ${s.wastedBytes} bytes (${sd.toFixed(2)}%, band ±${tolerance}%) — BANK THE WIN: rebaseline, or this site's ${bs - s.wastedBytes} recovered bytes become budget`)
304
+
305
+ if (s.gatedWastedBytes !== s.wastedBytes) {
306
+ const sg = pct(s.gatedWastedBytes, bs)
307
+ if (sg > tolerance) add('drift', key, `compiled-model ${bs} -> ${s.gatedWastedBytes} bytes (${sg >= 0 ? '+' : ''}${sg.toFixed(2)}%, band ±${tolerance}%) — columns PARTED (${s.gatedWastedBytes} vs ${s.wastedBytes})`)
308
+ else if (sg < -tolerance) add('shrank', key, `compiled-model ${bs} -> ${s.gatedWastedBytes} bytes (${sg.toFixed(2)}%, band ±${tolerance}%) — columns PARTED (${s.gatedWastedBytes} vs ${s.wastedBytes})`)
309
+ }
310
+ }
311
+
312
+ if (policy.failOnInversions === true) {
313
+ for (const inv of r.inversions) {
314
+ add('inversion', `${id}::${inv.siteKey}#${inv.arm}`,
315
+ // The GATED columns, because that is what `inversions` was computed from
316
+ // (choice-cost.ts filters on `gatedAttempts`/`gatedFailures`). Printing the
317
+ // interpreted counts here would state "failed all N" with an N the condition
318
+ // never tested — the two differ wherever `firstCharGated` is true.
319
+ `arm ${inv.arm} (${inv.label}) failed all ${inv.gatedAttempts} compiled entries while a later arm matched; ${inv.gatedWastedBytes} bytes re-scanned`)
320
+ }
321
+ }
322
+ }
323
+
324
+ const measuredKeys = new Set<string>()
325
+ for (const id of Object.keys(reports)) for (const s of reports[id]!.sites) measuredKeys.add(`${id}::${s.siteKey}`)
326
+ for (const key of Object.keys(b.sites).sort()) {
327
+ if (!measuredKeys.has(key)) {
328
+ add('stale', key, 'baselined site matches no measured site — a rule was renamed or removed, and ignoring it silently shrinks the gated set')
329
+ }
330
+ }
331
+ for (const id of Object.keys(b.totals).sort()) {
332
+ if (reports[id] === undefined) add('stale', id, 'baselined corpus was not measured')
333
+ }
334
+
335
+ return finish(Object.keys(reports).length, checkedSites)
336
+ }
337
+
338
+ /**
339
+ * Build the baseline a passing measurement would record.
340
+ *
341
+ * Deliberately NOT called by the gate. It exists so a rebaseline is one explicit
342
+ * command whose output is a file in the diff — the reviewable record — rather than
343
+ * something the gate can do to itself on a red run.
344
+ */
345
+ export function buildWastedWorkBaseline(
346
+ reports: Readonly<Record<string, WastedWorkReport>>,
347
+ meta: { gitRev: string; updatedAt: string },
348
+ ): WastedWorkBaseline {
349
+ const totals: WastedWorkBaseline['totals'] = {}
350
+ const sites: WastedWorkBaseline['sites'] = {}
351
+ for (const id of Object.keys(reports).sort()) {
352
+ const r = reports[id]!
353
+ totals[id] = { corpusBytes: r.corpusBytes, totalWastedBytes: r.totalWastedBytes, instrumentedSites: r.instrumentedSites }
354
+ for (const s of [...r.sites].sort((a, c) => (a.siteKey < c.siteKey ? -1 : 1))) {
355
+ if (s.wastedBytes > 0) sites[`${id}::${s.siteKey}`] = s.wastedBytes
356
+ }
357
+ }
358
+ return { schema: 'parseman.wasted-work-baseline/1', gitRev: meta.gitRev, updatedAt: meta.updatedAt, totals, sites }
359
+ }
@@ -0,0 +1,293 @@
1
+ /**
2
+ * CHOICE COST — the human layer.
3
+ * ==============================
4
+ *
5
+ * Three layers, not two:
6
+ *
7
+ * ANALYSIS src/analysis/choice-cost.ts — what the sites are and what they cost.
8
+ * POLICY bench/choice-cost-guard.ts — which of those numbers fail a build.
9
+ * RENDERING this file — how a person reads either of them.
10
+ *
11
+ * The structured report is the source of truth. Nothing here computes a finding, and
12
+ * nothing here decides pass or fail; a gate consumes the data and never this text. If
13
+ * a number appears below that is not in the report, that is a bug in this file.
14
+ *
15
+ * WHAT A PARSER GENERATOR CAN SHOW THAT A COMPILER CANNOT
16
+ * -------------------------------------------------------
17
+ * A type error has one source to point at. A wasted-work finding has TWO, and relating
18
+ * them is the whole diagnostic:
19
+ *
20
+ * - the GRAMMAR site — the ordered choice whose arm ordering costs the time
21
+ * - the COST — measured, over a real corpus, per alternative
22
+ *
23
+ * So the ordering is printed WITH its measured cost beside each arm, which makes the
24
+ * fix self-evident without a paragraph explaining it:
25
+ *
26
+ * StylesheetAtRule › dispatch[0] 1.31 MB rescanned
27
+ * 0 RoutedAtRuleStatement failed 4,182 / 4,271 1.31 MB
28
+ * 1 RoutedLayerBlock matched 4,182
29
+ *
30
+ * Reading that, nobody needs to be told what to do.
31
+ *
32
+ * QUIET BY DEFAULT
33
+ * ----------------
34
+ * Nothing in this module prints. It returns strings. There is no console call and no
35
+ * process-wide default-on channel, deliberately: importing `examples/css/parser.ts`
36
+ * already emits roughly sixty lines of gating advice before any user code runs, and a
37
+ * beautiful diagnostic that nobody reads because it is buried in build noise is not
38
+ * beautiful. This one is available on request — from the CLI gate, or by calling it —
39
+ * and silent otherwise.
40
+ *
41
+ * DETERMINISM BINDS THE RENDERING TOO
42
+ * -----------------------------------
43
+ * The rendering is diffable: it preserves the report's ordering, contains no timings,
44
+ * no dates, no absolute paths, and colour is opt-in rather than auto-detected, so the
45
+ * same report always renders to the same bytes. A renderer that sniffed `isTTY` would
46
+ * produce two different outputs from one report and break exactly that.
47
+ */
48
+
49
+ import type {
50
+ ChoiceInventoryReport, ChoiceInventoryEntry, WastedWorkReport, ArmDeclineReason, SiteDeclineReason,
51
+ } from './choice-cost.ts'
52
+ import { groupDigits } from './format-number.ts'
53
+
54
+ export type RenderOptions = {
55
+ /** Rows to show. Default 20. The report always holds all of them. */
56
+ limit?: number
57
+ /**
58
+ * ANSI colour. Default false — NOT auto-detected from `isTTY`, because a renderer
59
+ * whose bytes depend on where it is piped cannot be diffed or snapshotted.
60
+ */
61
+ color?: boolean
62
+ }
63
+
64
+ // ── formatting primitives ────────────────────────────────────────────────────
65
+
66
+ /**
67
+ * Deterministic thousands grouping. `toLocaleString()` is locale-dependent and
68
+ * would make the output differ between machines — the one thing a gateable
69
+ * rendering cannot do.
70
+ *
71
+ * DEFINED IN `./format-number.ts` and re-exported here because this module's own
72
+ * callers import it from here. It was a byte-identical second copy: `./terminal.ts`
73
+ * declared one, this file declared another, and the two were one edit away from
74
+ * disagreeing about how a report reads on two different screens.
75
+ */
76
+ export { groupDigits }
77
+
78
+ /** Byte counts at a glance. Binary units, fixed to one decimal, so 1,376,256 renders
79
+ * as `1.3 MB` on every machine. */
80
+ export function bytes(n: number): string {
81
+ if (n < 1024) return `${groupDigits(n)} B`
82
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} kB`
83
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`
84
+ }
85
+
86
+ // Written as \u001B escapes, never as raw ESC bytes. A raw control byte makes the
87
+ // whole file BINARY to git: `git diff --numstat` shows `- -`, GitHub declines to
88
+ // render it, and `grep -rn` skips it SILENTLY — no output and exit 0. Enforced by
89
+ // `pnpm check:control-bytes`.
90
+ const ESC = '\u001B'
91
+ const ANSI = {
92
+ dim: `${ESC}[2m`, bold: `${ESC}[1m`, red: `${ESC}[31m`,
93
+ yellow: `${ESC}[33m`, cyan: `${ESC}[36m`, reset: `${ESC}[0m`,
94
+ } as const
95
+
96
+ const paint = (on: boolean) => (code: keyof typeof ANSI, s: string): string =>
97
+ on ? `${ANSI[code]}${s}${ANSI.reset}` : s
98
+
99
+ const pad = (s: string, w: number): string => s.length >= w ? s : s + ' '.repeat(w - s.length)
100
+ const padStart = (s: string, w: number): string => s.length >= w ? s : ' '.repeat(w - s.length) + s
101
+
102
+ // ── why a site declined, in one line each ────────────────────────────────────
103
+
104
+ /**
105
+ * Every entry lands in exactly one of two states — ACTIONABLE (here is the rewrite) or
106
+ * LOCATED (here is the site and the precise reason no rewrite can be offered). There
107
+ * is no third "consider refactoring" state; a sentence of advice with no location and
108
+ * no rewrite is what this table exists to replace.
109
+ */
110
+ const ARM_DECLINE_LINE: Record<ArmDeclineReason, string> = {
111
+ 'not-a-sequence':
112
+ 'arm is not a sequence, so there is no leading term to lift out',
113
+ 'sequence-shorter-than-2':
114
+ 'arm is a one-term sequence; lifting its only term would leave an empty arm',
115
+ 'lead-case-insensitive-literal':
116
+ 'leading literal is case-insensitive, so the matched text can differ from the literal — a lifted prefix would carry the wrong string',
117
+ 'lead-not-concrete-terminal':
118
+ 'leading term is not a bare literal or regex; lifting it would change the arm’s value or capture shape',
119
+ }
120
+
121
+ const SITE_DECLINE_LINE: Record<SiteDeclineReason, string> = {
122
+ 'disjoint-dispatch': 'first-char dispatch already selects one arm — nothing is re-scanned',
123
+ 'gated-arms': 'an arm carries a runtime gate; per-arm predicates are incompatible with factoring',
124
+ 'strategy-preempted': 'a stronger non-backtracking strategy already applies',
125
+ 'fewer-than-two-arms': 'fewer than two arms',
126
+ 'arms-not-factorable': 'at least one arm cannot contribute a leading term (see below)',
127
+ 'leads-differ': 'the arms do not all begin with the same term',
128
+ }
129
+
130
+ /**
131
+ * The combinator source a left-factoring would produce, ready to paste.
132
+ *
133
+ * Offered ONLY for the shape where it is mechanical and total: every arm of the choice
134
+ * in one group. A partial group needs the author to decide which arms move and in what
135
+ * order — an ordering decision that changes what the grammar accepts, which no
136
+ * generated rewrite may make silently.
137
+ *
138
+ * This is a PREVIEW. It is not applied, and — until the digest-verification loop
139
+ * described in `docs/` is wired — it is not yet proven behaviour-preserving, so it is
140
+ * labelled a candidate rather than offered as a fix. An unverified rewrite presented
141
+ * as verified is worse than no suggestion at all.
142
+ */
143
+ export function leftFactorPreview(e: ChoiceInventoryEntry): string | null {
144
+ if (e.factored) return null
145
+ if (e.groups.length !== 1) return null
146
+ const g = e.groups[0]!
147
+ if (g.members.length !== e.arity) return null
148
+ const tails = g.members.map(i => `sequence(…arm[${i}] tail…)`).join(',\n ')
149
+ return `sequence(\n ${g.render === '' ? '<prefix>' : (g.render.startsWith('/') ? `regex(${g.render})` : `literal(${g.render})`)},\n choice(\n ${tails},\n ),\n)`
150
+ }
151
+
152
+ // ── static inventory rendering ───────────────────────────────────────────────
153
+
154
+ export function renderChoiceInventory(r: ChoiceInventoryReport, opts: RenderOptions = {}): string {
155
+ const c = paint(opts.color === true)
156
+ const limit = opts.limit ?? 20
157
+ const out: string[] = []
158
+
159
+ out.push(c('bold', 'shared-prefix inventory'))
160
+ out.push(c('dim', ' Static grammar shape only. A site listed here may already cost nothing at runtime:'))
161
+ out.push(c('dim', ' compiled output gates each arm on its first character. Rank with profileWastedWork().'))
162
+ out.push(` ${groupDigits(r.rules)} rules, ${groupDigits(r.choiceSites)} choice sites`)
163
+ out.push(` ${groupDigits(r.factoredSites)} left-factored by the compiler`)
164
+ out.push(` ${groupDigits(r.backlogSites)} sites where alternatives share a leading term and the compiler DECLINED (${groupDigits(r.backlogArms)} arms)`)
165
+
166
+ const backlog = r.entries
167
+ .filter(e => !e.factored && e.groups.length > 0
168
+ && e.declineReason !== 'disjoint-dispatch' && e.declineReason !== 'strategy-preempted')
169
+ .sort((a, b) => b.unfactoredArms - a.unfactoredArms || (a.siteKey < b.siteKey ? -1 : 1))
170
+
171
+ if (backlog.length === 0) {
172
+ out.push('')
173
+ out.push(' no declined shared prefixes.')
174
+ return out.join('\n')
175
+ }
176
+
177
+ out.push('')
178
+ for (const e of backlog.slice(0, limit)) {
179
+ out.push(` ${c('cyan', e.siteKey)} ${c('dim', `${e.arity} arms · ${e.strategy}`)}`)
180
+ for (const g of e.groups) {
181
+ out.push(` arms ${g.members.join(', ')} all begin with ${c('bold', g.render)}`)
182
+ }
183
+ out.push(` ${c('yellow', 'declined')}: ${SITE_DECLINE_LINE[e.declineReason!]}`)
184
+ for (const a of e.armDeclines) {
185
+ out.push(` arm[${a.arm}] — ${ARM_DECLINE_LINE[a.reason]}`)
186
+ }
187
+ const fix = leftFactorPreview(e)
188
+ if (fix !== null) {
189
+ out.push(` ${c('dim', 'candidate rewrite (preview — not applied, not yet digest-verified):')}`)
190
+ for (const line of fix.split('\n')) out.push(` ${line}`)
191
+ }
192
+ out.push('')
193
+ }
194
+ if (backlog.length > limit) out.push(` … and ${groupDigits(backlog.length - limit)} more (the report holds all of them)`)
195
+ return out.join('\n')
196
+ }
197
+
198
+ // ── wasted-work rendering ────────────────────────────────────────────────────
199
+
200
+ /**
201
+ * The ranked list, with each top site expanded into its arm ordering.
202
+ *
203
+ * The expansion is the point. A ranked list of site keys tells you WHERE; the arm
204
+ * breakdown beside it tells you WHY, and "arm 0 failed 4,182 of 4,271 times" is an
205
+ * argument for moving arm 0 that needs no prose attached.
206
+ */
207
+ export function renderWastedWork(r: WastedWorkReport, opts: RenderOptions = {}): string {
208
+ const c = paint(opts.color === true)
209
+ const limit = opts.limit ?? 20
210
+ const out: string[] = []
211
+ const removed = r.arms.reduce((n, a) => n + (a.attempts - a.gatedAttempts), 0)
212
+ const totalAttempts = r.arms.reduce((n, a) => n + a.attempts, 0)
213
+
214
+ out.push(c('bold', 'wasted work — input bytes re-scanned after a failed alternative'))
215
+ out.push(...interpretedBanner(c, removed, totalAttempts))
216
+ out.push(` corpus: ${groupDigits(r.corpusFiles)} files, ${bytes(r.corpusBytes)} (${groupDigits(r.parsedOk)} parsed, ${groupDigits(r.parsedFailed)} failed)`)
217
+ out.push(` sites: ${groupDigits(r.instrumentedSites)} instrumented, ${groupDigits(r.uninstrumentableSites)} not instrumentable`)
218
+ if (r.unresolvedRoots.length > 0) {
219
+ out.push(` ${c('yellow', 'PARTIAL')}: ${groupDigits(r.unresolvedRoots.length)} rule(s) could not be resolved and were NOT walked — the total below is a lower bound`)
220
+ }
221
+ out.push(` total: ${c('bold', bytes(r.totalGatedWastedBytes))} re-scanned` +
222
+ (r.corpusBytes > 0 ? ` — ${(r.totalGatedWastedBytes / r.corpusBytes).toFixed(2)}x the corpus` : '') +
223
+ (r.totalWastedBytes === r.totalGatedWastedBytes ? '' : ` (interpreted: ${bytes(r.totalWastedBytes)})`))
224
+
225
+ const ranked = r.sites.filter(s => s.gatedWastedBytes > 0 || s.wastedBytes > 0)
226
+ if (ranked.length === 0) {
227
+ out.push('')
228
+ out.push(' no alternative failed on this corpus.')
229
+ return out.join('\n')
230
+ }
231
+
232
+ out.push('')
233
+ out.push(c('dim', ' per arm: "entered" counts what the COMPILED parser reaches; the parenthesised'))
234
+ out.push(c('dim', ' number is the interpreted count, shown only where the first-char gate differs.'))
235
+ out.push('')
236
+ const w = Math.min(52, Math.max(...ranked.slice(0, limit).map(s => s.siteKey.length)))
237
+ for (const s of ranked.slice(0, limit)) {
238
+ const share = r.totalGatedWastedBytes > 0 ? ` ${padStart((100 * s.gatedWastedBytes / r.totalGatedWastedBytes).toFixed(1) + '%', 7)}` : ''
239
+ out.push(` ${c('cyan', pad(s.siteKey.slice(0, w), w))} ${padStart(bytes(s.gatedWastedBytes), 10)}${c('dim', share)}`)
240
+ const arms = r.arms.filter(a => a.siteKey === s.siteKey).sort((a, b) => a.arm - b.arm)
241
+ for (const a of arms) {
242
+ const entered = a.gatedAttempts === a.attempts
243
+ ? groupDigits(a.attempts)
244
+ : `${groupDigits(a.gatedAttempts)} (${groupDigits(a.attempts)} interp)`
245
+ // A 100%-failure arm is the actionable shape, but only if the SHIPPED parser
246
+ // enters it — so the verdict reads the gated columns.
247
+ // PAD FIRST, PAINT SECOND. `pad` measures `s.length`, so padding a string that
248
+ // already carries ANSI escapes spends the column width on invisible bytes and the
249
+ // arm table loses its alignment in colour mode. Every other cell here does it in
250
+ // this order; this one is not allowed to be the exception.
251
+ const verdict: { text: string; color: 'dim' | 'red' | null } = a.gatedAttempts === 0
252
+ ? { text: 'never entered when compiled', color: 'dim' }
253
+ : a.gatedFailures === a.gatedAttempts
254
+ ? { text: `failed ALL ${entered}`, color: 'red' }
255
+ : a.gatedFailures === 0
256
+ ? { text: `matched ${entered}`, color: 'dim' }
257
+ : { text: `failed ${groupDigits(a.gatedFailures)} / ${entered}`, color: null }
258
+ const cell = verdict.color === null ? pad(verdict.text, 34) : c(verdict.color, pad(verdict.text, 34))
259
+ out.push(` ${padStart(String(a.arm), 2)} ${pad(a.label.slice(0, 30), 30)} ${cell} ${a.gatedWastedBytes > 0 ? padStart(bytes(a.gatedWastedBytes), 10) : ''}`)
260
+ }
261
+ out.push('')
262
+ }
263
+ if (ranked.length > limit) out.push(` … and ${groupDigits(ranked.length - limit)} more sites (the report holds all of them)`)
264
+
265
+ if (r.inversions.length > 0) {
266
+ out.push(c('bold', ' ordering inversions — arm failed EVERY compiled entry while a later arm matched'))
267
+ out.push(c('dim', ' ranked by entries, not bytes: an arm can fail 100% of the time and re-scan nothing.'))
268
+ for (const a of r.inversions.slice(0, limit)) {
269
+ out.push(` ${c('cyan', pad(a.siteKey.slice(0, w), w))} arm ${padStart(String(a.arm), 2)} ${pad(a.label.slice(0, 26), 26)} ${padStart(groupDigits(a.gatedAttempts), 8)} entries ${a.gatedWastedBytes > 0 ? padStart(bytes(a.gatedWastedBytes), 10) : padStart('0 B', 10)}`)
270
+ }
271
+ if (r.inversions.length > limit) out.push(` … and ${groupDigits(r.inversions.length - limit)} more`)
272
+ }
273
+ return out.join('\n')
274
+ }
275
+
276
+ /**
277
+ * The interpreted-vs-compiled warning, on EVERY rendered report.
278
+ *
279
+ * Not a footnote and not a docs link. A consumer who reads only the ranking must be
280
+ * told, in the ranking, that first-set gating — the largest parse lever this project
281
+ * has — is modelled rather than measured, or they will chase arms codegen already
282
+ * skips for free. The measured share of removed entries is included because "some
283
+ * attempts are gated" is ignorable and "78% of them are" is not.
284
+ */
285
+ function interpretedBanner(c: (k: 'dim' | 'bold' | 'red' | 'yellow' | 'cyan', s: string) => string, removed: number, total: number): string[] {
286
+ const share = total > 0 ? ` — ${(100 * removed / total).toFixed(0)}% of interpreted arm entries` : ''
287
+ return [
288
+ c('yellow', ' MEASURED IN THE INTERPRETER. Compiled output gates each arm on its first'),
289
+ c('yellow', ` character; ${groupDigits(removed)} entries below never happen in the shipped parser${share}.`),
290
+ c('dim', ' Counts shown are the modelled COMPILED ones. Byte totals are unaffected by the'),
291
+ c('dim', ' gate (a gated-out arm consumes nothing); entry counts and failure RATES are not.'),
292
+ ]
293
+ }