parseman 0.46.0 → 0.47.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 (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 +9929 -7465
  9. package/dist/analysis/diagnostics.cjs.map +3 -4
  10. package/dist/analysis/diagnostics.d.ts +2 -2
  11. package/dist/analysis/diagnostics.js +9929 -7465
  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 +10865 -8535
  24. package/dist/cli/index.cjs.map +3 -4
  25. package/dist/cli/index.js +10865 -8535
  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 +40 -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 +9224 -7203
  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 +9219 -7198
  106. package/dist/index.js.map +3 -4
  107. package/dist/language-service/index.cjs +311 -121
  108. package/dist/language-service/index.cjs.map +3 -4
  109. package/dist/language-service/index.js +311 -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 +11146 -9593
  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 +11146 -9593
  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 +2898 -1623
  131. package/dist/spec/index.cjs.map +3 -4
  132. package/dist/spec/index.js +2898 -1623
  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 +11375 -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 +11352 -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 +446 -0
  171. package/dist/table/ops.d.ts.map +1 -0
  172. package/dist/table/program.d.ts +493 -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 +25 -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 +322 -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 +221 -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 +821 -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 +2994 -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 +1766 -0
  286. package/src/table/emit.ts +418 -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 +1672 -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 +459 -0
  295. package/src/table/program.ts +855 -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,625 @@
1
+ /**
2
+ * The human layer over `GrammarDiagnosis`.
3
+ *
4
+ * Three layers, not two: `gating.ts`/`corpus.ts` produce data, `diagnose.ts` decides
5
+ * `ok` (the CI verdict), and this file makes either readable. Nothing here computes a
6
+ * finding and nothing here decides pass or fail. A gate consumes the object; a person
7
+ * reads this. If a number appears below that is not in the report, that is a bug here.
8
+ *
9
+ * GROUPED BY CAUSE, NOT BY SITE
10
+ * -----------------------------
11
+ * The first version grouped by site: thirteen findings, each carrying its own copy of
12
+ * the explanation, the `do` line and the `ok as-is?` line. Measured on
13
+ * `examples/css/parser.ts`, that was three distinct explanations rendered nine times
14
+ * across 146 lines. Density that comes from repetition is not detail — it is the same
15
+ * sentence again, and it buries the arm tables, which are the part a reader acts on.
16
+ *
17
+ * A cause is now stated ONCE, given a glyph, and followed by its sites. That also
18
+ * disposes of the question a glossary would have answered: the glyph is defined at the
19
+ * head of the only group it labels, so there is nothing to look up. Shipping a glossary
20
+ * AND the inline explanations would just re-create the duplication this removes.
21
+ *
22
+ * WHAT CARRIES MEANING
23
+ * --------------------
24
+ * colour — severity, and importance WITHIN a finding. Exactly one span per block is
25
+ * the brightest thing in it; provenance and accept keys recede.
26
+ * glyph — the cause class, so sites are scannable without reading prose.
27
+ * rules — group boundaries, so the report is blocks rather than a paragraph.
28
+ * columns — the arm table is aligned, so an ANY arm or an overlap is visible as a
29
+ * shape before it is read as text.
30
+ *
31
+ * All of that lives in the STYLED path. The plain path is the same content with the same
32
+ * padding and no escapes, so `docs/samples/` stays diffable and the two cannot drift.
33
+ */
34
+ import { examinedNothing, type GrammarDiagnosis, type DiagnosisFinding } from './diagnose.ts'
35
+ import type { ChoiceCorpusCost } from './corpus.ts'
36
+ import {
37
+ DEFAULT_WIDTH, TONE, blank, codeFrame, groupDigits, pad, render, rule, t, wrap,
38
+ type Line, type RenderTarget, type Tone,
39
+ } from './terminal.ts'
40
+
41
+ export type DiagnoseRenderOptions = RenderTarget & {
42
+ /** Grammar label for the header. The CLI passes a cwd-relative path. */
43
+ name?: string
44
+ /** Sites to expand. Default 20; the report always holds all of them. */
45
+ limit?: number
46
+ /** Corpus cost per choice id, from `measureChoiceCost`. */
47
+ cost?: ReadonlyMap<string, ChoiceCorpusCost>
48
+ /** Per-arm first-set renderings per choice id, in arm order. */
49
+ armFirstSets?: ReadonlyMap<string, readonly string[]>
50
+ /** Per-arm leading-term labels per choice id, in arm order. */
51
+ armLabels?: ReadonlyMap<string, readonly string[]>
52
+ /** Absolute corpus root, used ONLY for the frame's terminal hyperlink. */
53
+ corpusRoot?: string
54
+ /**
55
+ * Finding ids for which `proposeFixes` PROVED a rewrite — applied it, recompiled, and
56
+ * re-parsed the corpus with identical output. Only these may carry the wrench. A
57
+ * candidate that was proposed and rejected must never appear here: offering a fix that
58
+ * does not exist would destroy the one guarantee the feature has.
59
+ */
60
+ fixable?: ReadonlySet<string>
61
+ /** The command that would apply them, printed verbatim so nobody has to guess it. */
62
+ fixCommand?: string
63
+ }
64
+
65
+ export { groupDigits }
66
+
67
+ /**
68
+ * Cause glyphs, in assignment order.
69
+ *
70
+ * Deliberately distinct in SHAPE, not just in colour: a report has to stay readable for
71
+ * a reader who cannot distinguish red from green, and in a log that has been stripped of
72
+ * colour entirely — which is exactly what `docs/samples/` is.
73
+ */
74
+ const GLYPHS = ['◆', '▲', '●', '■', '◇', '△', '○', '□'] as const
75
+
76
+ /** A short label per finding code, used in a group headline. */
77
+ const CODE_LABEL: Record<string, { one: string; many: string }> = {
78
+ 'ungated-choice': {
79
+ one: 'choice the parser cannot narrow down',
80
+ many: 'choices the parser cannot narrow down',
81
+ },
82
+ 'anti-pattern': { one: 'arm that hides its first character', many: 'arms that hide their first character' },
83
+ unanalysable: { one: 'rule parseman could not examine at all', many: 'rules parseman could not examine at all' },
84
+ degraded: { one: 'place the compiler took a slower route', many: 'places the compiler took a slower route' },
85
+ 'stale-accept': {
86
+ one: 'accept-list entry that no longer matches anything',
87
+ many: 'accept-list entries that no longer match anything',
88
+ },
89
+ }
90
+
91
+ /**
92
+ * The cause an anti-pattern finding belongs to, keyed by KIND.
93
+ *
94
+ * The finding's own message names the specific word it found, which is right for the
95
+ * site line and wrong for the group: keying on the whole message split one cause into
96
+ * one group per site, which is the duplication this rendering exists to remove. The
97
+ * group says what the pattern is; the site says where and which word.
98
+ */
99
+ const ANTI_KIND_BLURB: Record<string, string> = {
100
+ 'keyword-regex':
101
+ 'These arms match a fixed word using a regular expression. parseman cannot always tell '
102
+ + 'from a regular expression which character it starts with, so the parser cannot skip '
103
+ + 'the arm when that character rules it out.\n'
104
+ + "To fix: write the word with word('…') or keywords([…]). Same match, same compiled "
105
+ + 'character scan, but parseman then knows the first character.',
106
+ 'double-not':
107
+ 'These arms use not(not(X)) — a hand-written way of saying "X must come next, but do not '
108
+ + 'consume it". It hides the first character, so the arm can never be skipped, and among '
109
+ + 'arms that share a first character it can pick the wrong one.\n'
110
+ + 'To fix: write peek(X). Same check, and parseman still knows the first character.',
111
+ 'leading-not':
112
+ 'These arms begin with not(...), which matches no text and so reveals no first character. '
113
+ + 'The parser cannot skip the arm and enters it at every position.\n'
114
+ + 'To fix: put the term that actually consumes text first, and keep not(...) after it as a '
115
+ + 'trailing boundary check.',
116
+ }
117
+
118
+ /**
119
+ * A first set, said in words.
120
+ *
121
+ * `'#','*','-'-'.'` is how the model writes it and is unreadable to anyone who has not
122
+ * met the model. What a reader needs is the sentence: this arm can only begin with these
123
+ * characters, therefore the parser can skip it everywhere else.
124
+ */
125
+ function startsWithPhrase(fs: string): string {
126
+ if (fs === 'ANY') return 'can start with any character'
127
+ if (fs === '(empty)') return 'matches nothing'
128
+ const parts = fs.split(',')
129
+ const pretty = parts.map(x => x.replace(/'/g, '')).join(' ')
130
+ if (parts.length === 1) return `starts with "${pretty}"`
131
+ if (parts.length > 4) return `starts with ${groupDigits(parts.length)} char ranges`
132
+ return `starts with one of ${pretty.length > 20 ? `${pretty.slice(0, 19)}…` : pretty}`
133
+ }
134
+
135
+ /** The fallback explanation for a code whose findings carry no `fix:` of their own. */
136
+ const CODE_BLURB: Record<string, string> = {
137
+ 'ungated-choice': 'The parser cannot tell from the next character which alternative to try, so it '
138
+ + 'tries them in order and undoes the ones that do not match.',
139
+ 'anti-pattern': 'The arm is written in a way that hides which character it starts with, so the parser '
140
+ + 'cannot skip it when it cannot match.',
141
+ unanalysable: 'parseman could not read these rules at all, so it has NO opinion about them — good or '
142
+ + 'bad. Anything reported elsewhere is therefore incomplete.',
143
+ degraded: 'The compiler could not use its fast route here and fell back to a slower one that produces '
144
+ + 'the same result.',
145
+ 'stale-accept': 'This entry in your accept list no longer matches any finding — the grammar was fixed '
146
+ + 'and the line was left behind. Delete it.',
147
+ }
148
+
149
+ type CauseGroup = {
150
+ glyph: string
151
+ /** The one-per-cause explanation. */
152
+ text: string
153
+ code: string
154
+ severity: 'blocking' | 'advisory'
155
+ findings: DiagnosisFinding[]
156
+ }
157
+
158
+ /** Every `fix:` a finding carries, in order. The first is its primary cause. */
159
+ function suggestionsOf(f: DiagnosisFinding): string[] {
160
+ const out: string[] = []
161
+ for (const d of f.details) {
162
+ const i = d.indexOf('\nfix: ')
163
+ if (i === -1) continue
164
+ const s = d.slice(i + 6)
165
+ if (!out.includes(s)) out.push(s)
166
+ }
167
+ return out
168
+ }
169
+
170
+ /**
171
+ * Group findings by primary cause.
172
+ *
173
+ * A finding with two causes is listed ONCE, under its first, with the others flagged
174
+ * inline. Listing it under both would print its arm table twice, which is the
175
+ * duplication this grouping exists to remove.
176
+ */
177
+ function groupByCause(findings: readonly DiagnosisFinding[]): CauseGroup[] {
178
+ const groups: CauseGroup[] = []
179
+ const byKey = new Map<string, CauseGroup>()
180
+ for (const f of findings) {
181
+ // An anti-pattern's message names the specific word found at THIS site, which is
182
+ // right for the site line and wrong for the group: keying on it split one pattern
183
+ // into one group per site — the exact duplication this grouping removes. Key on the
184
+ // KIND, and let the site line carry the word.
185
+ const kind = f.code === 'anti-pattern' ? /^\[([^\]]+)\]/.exec(f.message)?.[1] : undefined
186
+ const text = suggestionsOf(f)[0]
187
+ ?? (kind === undefined ? undefined : ANTI_KIND_BLURB[kind])
188
+ ?? (f.code === 'anti-pattern' ? f.message.replace(/^\[[^\]]+\]\s*/, '') : undefined)
189
+ ?? CODE_BLURB[f.code]
190
+ ?? f.message
191
+ // JSON, not a delimiter character: a raw separator inside a template literal made
192
+ // this file binary to git once already (see 9dab15d), and a printable one can still
193
+ // collide with the text it separates.
194
+ const key = JSON.stringify([f.code, kind ?? text])
195
+ let g = byKey.get(key)
196
+ if (g === undefined) {
197
+ g = { glyph: '', text, code: f.code, severity: f.severity, findings: [] }
198
+ byKey.set(key, g)
199
+ groups.push(g)
200
+ }
201
+ g.findings.push(f)
202
+ }
203
+ groups.forEach((g, i) => { g.glyph = GLYPHS[i] ?? String.fromCharCode(65 + i) })
204
+ return groups
205
+ }
206
+
207
+ /** The lines a diagnosis renders to — data, so a caller can compose them into a larger
208
+ * report without going through a string. */
209
+ export function diagnosisLines(d: GrammarDiagnosis, opts: DiagnoseRenderOptions = {}): Line[] {
210
+ const limit = opts.limit ?? 20
211
+ const name = opts.name ?? 'grammar'
212
+ const width = opts.width ?? DEFAULT_WIDTH
213
+ const s = d.summary
214
+ const out: Line[] = []
215
+
216
+ // SUCCESS IS TWO LINES. It is the rendering people see most, and the one usually
217
+ // neglected into a wall of zero-valued tables.
218
+ if (d.ok && s.unanalysable === 0) {
219
+ const parts = [`${groupDigits(s.gated)}/${groupDigits(s.totalChoices)} choices gate on first char`]
220
+ if (s.recoverable > 0) parts.push(`${groupDigits(s.recoverable)} recoverable`)
221
+ if (s.accepted > 0) parts.push(`${groupDigits(s.accepted)} accepted`)
222
+ if (s.deferred > 0) parts.push(`${groupDigits(s.deferred)} deferred to the fusing artifact`)
223
+ if (s.staleAccepts > 0) parts.push(`${groupDigits(s.staleAccepts)} stale accept line(s) to prune`)
224
+ out.push([t('✓ ', TONE.good), t(name, TONE.strong), t(' — nothing to fix', TONE.good)])
225
+ out.push([t(` ${parts.join(' · ')}`, TONE.quiet)])
226
+ return out
227
+ }
228
+
229
+ // NOTHING WAS EXAMINED. Rendered as its own report, not as the finding report with a
230
+ // warning bolted on. The finding report's every sentence — "N problems", "underlying
231
+ // cause", "fixing one fixes every choice listed under it", "none of this is a
232
+ // correctness bug" — is a claim about defects parseman FOUND, and there are none:
233
+ // there is one blocking finding per rule it could not read. Printed over an opaque
234
+ // artifact that produced `176 problems, 176 failing the check, 1 cause`, which reads
235
+ // as 176 discovered defects and means the tool inspected zero choices.
236
+ if (examinedNothing(d)) return unexaminedLines(d, name, width, limit)
237
+
238
+ const groups = groupByCause(d.findings)
239
+ const blocking = d.findings.filter(f => f.severity === 'blocking')
240
+ /** Findings that are things parseman FOUND — never the rules it could not read. */
241
+ const problems = d.findings.length - s.unanalysable
242
+
243
+ out.push([
244
+ t('✗ ', TONE.loud),
245
+ t(name, TONE.strong),
246
+ t(' — ', TONE.quiet),
247
+ // `findings` and `totalChoices` count different things — an anti-pattern is a
248
+ // finding about an ARM, not a choice — so they must not be phrased as a ratio. They
249
+ // were, and a two-arm grammar reported "3 of 1 choices".
250
+ //
251
+ // `unanalysable` is excluded from the problem count on purpose: it is not something
252
+ // parseman FOUND in the grammar, it is a rule parseman could not look at. Counting
253
+ // the two together is what let a report that inspected nothing print a confident
254
+ // problem tally. It is stated separately, on the PARTIAL line below.
255
+ t(`${groupDigits(problems)} problem${problems === 1 ? '' : 's'} `
256
+ + `in ${groupDigits(s.totalChoices)} choice${s.totalChoices === 1 ? '' : 's'}`, TONE.loud),
257
+ ])
258
+ out.push([
259
+ t(' '),
260
+ t(`${groupDigits(groups.length)} underlying cause${groups.length === 1 ? '' : 's'}`, TONE.strong),
261
+ t(groups.length < d.findings.length ? '; fixing one fixes every choice listed under it.' : '.', TONE.quiet),
262
+ ])
263
+ for (const l of wrap(
264
+ (s.gated > 0
265
+ ? `${groupDigits(s.gated)} other choice${s.gated === 1 ? '' : 's'} already `
266
+ + `${s.gated === 1 ? 'picks' : 'pick'} the right `
267
+ + 'alternative straight from the next character. '
268
+ : '')
269
+ + 'None of this is a correctness bug — the grammar parses the same either way; it is work '
270
+ + 'the parser does and did not need to.',
271
+ width - 2, ' ')) out.push([t(l, TONE.faint)])
272
+ // Unanalysable is called out at the top as well as in its group: "no findings" over a
273
+ // grammar that was never walked is precisely the failure being reported.
274
+ if (s.unanalysable > 0) {
275
+ out.push([t(` PARTIAL — ${groupDigits(s.unanalysable)} rule(s) could not be examined; `
276
+ + 'an empty list below does NOT mean the grammar is clean.', TONE.warn)])
277
+ }
278
+
279
+ // ONE definition of the only term of art that survives, placed immediately before the
280
+ // first table that uses it. A reader meets `arm` here and nowhere earlier.
281
+ let defined = false
282
+
283
+ let shown = 0
284
+ for (const g of groups) {
285
+ const tone: Tone = g.severity === 'blocking' ? TONE.bad : TONE.warn
286
+ out.push(blank())
287
+ out.push(rule(width, TONE.frame))
288
+ const fixableHere = g.findings.filter(f => opts.fixable?.has(f.id) === true).length
289
+ out.push([
290
+ t(` ${g.glyph} `, tone),
291
+ t(`${groupDigits(g.findings.length)} `
292
+ + `${g.findings.length === 1 ? CODE_LABEL[g.code]?.one ?? g.code : CODE_LABEL[g.code]?.many ?? g.code}`,
293
+ TONE.strong),
294
+ t(g.severity === 'blocking' ? ' fails the check' : ' worth knowing', TONE.faint),
295
+ ])
296
+ for (const l of wrap(g.text, width - 6, '')) out.push([t(' '), t(l, TONE.quiet)])
297
+ if (!defined) {
298
+ defined = true
299
+ out.push(blank())
300
+ out.push([t(' Each numbered line below is one alternative of a choice — an "arm" — in the',
301
+ TONE.faint)])
302
+ out.push([t(' order the parser tries them.', TONE.faint)])
303
+ }
304
+ out.push(blank())
305
+
306
+ // ONE site per cause is expanded — its full arm ordering, and the corpus frame that
307
+ // illustrates the cause. The rest are a table: the same shape repeated thirteen
308
+ // times is what made the first version unreadable, and a reader who needs another
309
+ // site's ordering asks for it by id. `--limit` still governs how many are listed.
310
+ // +2 so the longest cell in a column still has a gap after it. Without it the
311
+ // widest row runs its term straight into the annotation, which is exactly the kind
312
+ // of ragged table this pass exists to remove.
313
+ const idW = Math.min(30, Math.max(...g.findings.map(f => f.id.length))) + 2
314
+ const headW = Math.min(34, Math.max(...g.findings.map(f => headlineOf(f, opts).term.length))) + 2
315
+ let expanded = false
316
+ for (const f of g.findings) {
317
+ if (shown >= limit) break
318
+ shown++
319
+ // Expand only when there IS an ordering to show. An anti-pattern finding names one
320
+ // arm and has no table, so an "expanded" version of it is just a lonelier line.
321
+ if (!expanded && opts.armFirstSets?.get(f.id) !== undefined) {
322
+ expanded = true
323
+ out.push(...siteLines(f, g, opts, true).lines)
324
+ if (g.findings.length > 1) out.push(blank())
325
+ continue
326
+ }
327
+ const h = headlineOf(f, opts)
328
+ out.push([
329
+ t(` ${g.glyph} `, tone),
330
+ t(f.id, TONE.ident, idW),
331
+ t(h.arm, TONE.faint, 8),
332
+ t(h.term, TONE.strong, headW),
333
+ t(h.note, h.loud ? TONE.loud : TONE.faint),
334
+ // LAST, always: the wrench is an emoji and may occupy two terminal columns, so
335
+ // nothing may be aligned after it.
336
+ t(opts.fixable?.has(f.id) === true ? ' 🔧 fixable' : '', TONE.good),
337
+ ])
338
+ }
339
+ if (shown >= limit) break
340
+ }
341
+
342
+ if (shown < d.findings.length) {
343
+ out.push(blank())
344
+ out.push([t(` … ${groupDigits(d.findings.length - shown)} more site(s) — `, TONE.quiet),
345
+ t(`--limit ${d.findings.length}`, TONE.strong),
346
+ t(' shows them, --json holds them all', TONE.quiet)])
347
+ }
348
+
349
+ // ONE accept line, carrying the whole set. A reader who wants to accept these wants a
350
+ // single pasteable thing, not one line per site saying the same thing differently.
351
+ if (!d.ok && d.acceptSnapshot.length > 0) {
352
+ out.push(blank())
353
+ out.push(rule(width, TONE.frame))
354
+ const snap = `{ accept: [${d.acceptSnapshot.map(i => `'${i}'`).join(', ')}] }`
355
+ out.push([t(' Meant to be this way? Pass this and they stop being reported:', TONE.quiet)])
356
+ for (const l of wrap(snap, width - 3, '')) out.push([t(' '), t(l, TONE.faint)])
357
+ }
358
+
359
+ // THE FOOTER, in a linter's shape and for a linter's reason: after a hundred lines the
360
+ // header has scrolled away, and the last thing on screen is what a person actually
361
+ // reads. It carries the tally, the exit code IN WORDS (the number is for the gate, the
362
+ // sentence is for the human) and — only when a rewrite has actually been proved — the
363
+ // exact command that applies it.
364
+ out.push(blank())
365
+ const fixCount = opts.fixable?.size ?? 0
366
+ const parts = [
367
+ `${groupDigits(problems)} problem${problems === 1 ? '' : 's'}`,
368
+ `${groupDigits(blocking.length - s.unanalysable)} failing the check`,
369
+ `${groupDigits(groups.length)} cause${groups.length === 1 ? '' : 's'}`,
370
+ // Never folded into the tally above. A rule that could not be read is a hole in the
371
+ // measurement, and a footer is the last thing a reader sees.
372
+ ...(s.unanalysable > 0 ? [`${groupDigits(s.unanalysable)} rule(s) NOT examined`] : []),
373
+ ]
374
+ out.push([
375
+ t('✗ ', TONE.loud),
376
+ t(parts.join(', '), TONE.strong),
377
+ t(` · exiting 1 (problems found)`, TONE.faint),
378
+ ])
379
+ if (fixCount > 0 && opts.fixCommand !== undefined) {
380
+ out.push([t(' 🔧 ', TONE.good),
381
+ t(`${groupDigits(fixCount)} of them can be fixed automatically. Run:`, TONE.good)])
382
+ for (const l of wrap(opts.fixCommand, width - 6, '')) out.push([t(' '), t(l, TONE.strong)])
383
+ for (const l of wrap('Each change is applied, the parser rebuilt and your files parsed again '
384
+ + 'before it is offered, so nothing is suggested that has not been checked.',
385
+ width - 6, '')) out.push([t(' '), t(l, TONE.faint)])
386
+ }
387
+ return out
388
+ }
389
+
390
+ /** What each `Unanalysable.kind` means and what the reader can do about it. Stated once
391
+ * per kind, at the head of the group it labels — the same rule the cause groups follow. */
392
+ const KIND_BLURB: Record<string, string> = {
393
+ 'fused-rule':
394
+ 'These rules are compiled functions. Fusion lowers every rule to executable code and '
395
+ + 'discards the combinator graph, so there is nothing left to read.\n'
396
+ + 'To analyse them: point this at the grammar SOURCE, or at a grammar value that still '
397
+ + 'carries its IR (a rules() map, or a compose() result — those keep their pieces).',
398
+ 'opaque-artifact':
399
+ 'These rules come from a precompiled artifact that carries rule functions rather than '
400
+ + 're-lowerable IR, so its choices cannot be examined.\n'
401
+ + 'To analyse them: recompile the contributing grammar so it carries IR — the macro '
402
+ + 'emits IR by default.',
403
+ 'not-a-combinator':
404
+ 'The value handed to parseman was not a grammar it knows how to walk.\n'
405
+ + 'To analyse it: pass a combinator, a rules() map, a compose() result, or name the '
406
+ + 'export that holds one with --export.',
407
+ }
408
+
409
+ /**
410
+ * The report for a run that examined NOTHING.
411
+ *
412
+ * Deliberately not the finding report with a banner on top. Everything that report says
413
+ * is a claim about defects found — the tally, the "underlying cause", the "fixing one
414
+ * fixes every choice listed under it", the "none of this is a correctness bug". None of
415
+ * it is true here, and printed together they read as a confident audit of a grammar the
416
+ * tool never opened.
417
+ */
418
+ function unexaminedLines(
419
+ d: GrammarDiagnosis, name: string, width: number, limit: number,
420
+ ): Line[] {
421
+ const out: Line[] = []
422
+ const items = d.gating.unanalysable
423
+ out.push([t('✗ ', TONE.loud), t(name, TONE.strong), t(' — ', TONE.quiet),
424
+ t('COULD NOT ANALYSE', TONE.loud)])
425
+ out.push([t(` 0 choices examined · ${groupDigits(items.length)} `
426
+ + `rule${items.length === 1 ? '' : 's'} unreadable`, TONE.warn)])
427
+ for (const l of wrap(
428
+ 'parseman has NO verdict about this grammar — not "clean", not "problems". Every line '
429
+ + 'below is a rule it could not read, not a defect it found.',
430
+ width - 2, ' ')) out.push([t(l, TONE.faint)])
431
+
432
+ // Grouped by KIND: the kind is what a reader acts on, and one artifact usually
433
+ // contributes hundreds of rules for one reason.
434
+ const byKind = new Map<string, typeof items[number][]>()
435
+ for (const u of items) {
436
+ const g = byKind.get(u.kind)
437
+ if (g === undefined) byKind.set(u.kind, [u])
438
+ else g.push(u)
439
+ }
440
+ let shown = 0
441
+ for (const [kind, group] of byKind) {
442
+ out.push(blank())
443
+ out.push(rule(width, TONE.frame))
444
+ out.push([
445
+ t(' ■ ', TONE.warn),
446
+ t(`${groupDigits(group.length)} rule${group.length === 1 ? '' : 's'} could not be read`, TONE.strong),
447
+ t(` [${kind}]`, TONE.faint),
448
+ ])
449
+ for (const l of wrap(KIND_BLURB[kind] ?? group[0]!.reason, width - 6, '')) {
450
+ out.push([t(' '), t(l, TONE.quiet)])
451
+ }
452
+ out.push(blank())
453
+ for (const u of group) {
454
+ if (shown >= limit) break
455
+ shown++
456
+ out.push([t(' '), t(u.rule, TONE.ident)])
457
+ }
458
+ if (shown >= limit) break
459
+ }
460
+ if (shown < items.length) {
461
+ out.push(blank())
462
+ out.push([t(` … ${groupDigits(items.length - shown)} more — `, TONE.quiet),
463
+ t(`--limit ${items.length}`, TONE.strong), t(' shows them, --json holds them all', TONE.quiet)])
464
+ }
465
+
466
+ out.push(blank())
467
+ // The exit code IN WORDS, and it is 2 — not 1. 1 means "measured, and it failed";
468
+ // this run did not measure. See the CLI's exit-code contract.
469
+ out.push([t('✗ ', TONE.loud),
470
+ t(`could not analyse — 0 of ${groupDigits(items.length)} rule(s) examined`, TONE.strong),
471
+ t(' · exiting 2 (analysis did not run)', TONE.faint)])
472
+ return out
473
+ }
474
+
475
+ export function renderDiagnosis(d: GrammarDiagnosis, opts: DiagnoseRenderOptions = {}): string {
476
+ return render(diagnosisLines(d, opts), opts)
477
+ }
478
+
479
+ /**
480
+ * The one arm a site is about, for the compact table rows.
481
+ *
482
+ * A reader scanning a group needs the site's NAME and the term that poisons it; the full
483
+ * ordering only matters once they start editing that rule, and the expanded exemplar
484
+ * above shows what that ordering looks like for this cause.
485
+ */
486
+ function headlineOf(f: DiagnosisFinding, opts: DiagnoseRenderOptions): {
487
+ arm: string; term: string; note: string; loud: boolean
488
+ } {
489
+ const sets = opts.armFirstSets?.get(f.id)
490
+ const labels = opts.armLabels?.get(f.id)
491
+ const cost = opts.cost?.get(f.id)
492
+ const anyIdx = sets?.findIndex(x => x === 'ANY') ?? -1
493
+ if (anyIdx >= 0) {
494
+ const c = cost?.arms[anyIdx]
495
+ return {
496
+ arm: `arm ${anyIdx}`,
497
+ term: (labels?.[anyIdx] ?? '').slice(0, 34),
498
+ note: c === undefined
499
+ ? 'any character — never skippable'
500
+ : `same — tried at all ${groupDigits(c.positions)}`,
501
+ loud: true,
502
+ }
503
+ }
504
+ const overlap = f.details.map(d => d.split('\n')[0]!).find(x => x.includes('overlap on'))
505
+ if (overlap !== undefined) {
506
+ const m = /arm\[(\d+)\] ∩ arm\[(\d+)\] overlap on (.*)$/.exec(overlap)
507
+ if (m !== null) {
508
+ return { arm: `arm ${m[1]}`, term: `and arm ${m[2]}`, note: `can both start with ${m[3]}`, loud: false }
509
+ }
510
+ }
511
+ const m = /^(.*)#arm(\d+)$/.exec(f.id)
512
+ if (m !== null) {
513
+ // The specific thing found at THIS site — the word, from the finding's own message.
514
+ const word = /`([^`]+)`/.exec(f.message)?.[1]
515
+ return { arm: `arm ${m[2]}`, term: `of ${m[1]}`, note: word === undefined ? '' : `matches \`${word}\``, loud: false }
516
+ }
517
+ return { arm: '', term: '', note: f.message.split('\n')[0]!.slice(0, 40), loud: false }
518
+ }
519
+
520
+ /** One site under its cause: the arm ordering, and nothing the group already said. */
521
+ function siteLines(
522
+ f: DiagnosisFinding,
523
+ g: CauseGroup,
524
+ opts: DiagnoseRenderOptions,
525
+ wantFrame: boolean,
526
+ ): { lines: Line[]; framed: boolean } {
527
+ const out: Line[] = []
528
+ const cost = opts.cost?.get(f.id)
529
+ const sets = opts.armFirstSets?.get(f.id)
530
+ const labels = opts.armLabels?.get(f.id)
531
+ let framed = false
532
+
533
+ out.push([
534
+ t(` ${g.glyph} `, f.severity === 'blocking' ? TONE.bad : TONE.warn),
535
+ t(f.id, TONE.ident),
536
+ t(cost === undefined
537
+ ? ''
538
+ : ` — reached at ${groupDigits(cost.positions)} places in your corpus`,
539
+ TONE.faint),
540
+ t(suggestionsOf(f).length > 1 ? ' (+ another cause)' : '', TONE.warn),
541
+ t(opts.fixable?.has(f.id) === true ? ' 🔧 fixable' : '', TONE.good),
542
+ ])
543
+
544
+ // WORLD 1 — the grammar site: the ordering, one column per meaning. This is the part
545
+ // worth keeping per site; everything that used to surround it was the repetition.
546
+ if (sets !== undefined) {
547
+ const lw = Math.min(26, Math.max(8, ...(labels ?? ['']).map(l => l.length)))
548
+ sets.forEach((fs, i) => {
549
+ const armCost = cost?.arms[i]
550
+ const any = fs === 'ANY'
551
+ // Every column says what it MEANS. `'('` and `ANY` were the model's vocabulary,
552
+ // not a sentence: a reader had to already know what a first-set was to read them.
553
+ const starts = startsWithPhrase(fs)
554
+ const note = armCost === undefined
555
+ ? ''
556
+ : any
557
+ ? `→ tried at all ${groupDigits(cost!.positions)}`
558
+ : `→ could match at ${groupDigits(armCost.positions)}`
559
+ out.push([
560
+ t(' ', undefined),
561
+ t(`arm ${pad(String(i), 2)}`, any ? TONE.bad : TONE.faint, 8),
562
+ t(' '),
563
+ t((labels?.[i] ?? '').slice(0, lw), any ? TONE.strong : TONE.quiet, lw),
564
+ t(' '),
565
+ t(starts, any ? TONE.loud : TONE.quiet, 29),
566
+ t(note, any ? TONE.loud : TONE.faint),
567
+ ])
568
+ })
569
+ }
570
+ // THE CONSEQUENCE, spelled out. The table above is an observation; this is why the
571
+ // reader should care, and it is the entire point of the tool.
572
+ if (sets !== undefined && cost !== undefined) {
573
+ const anyIdx = sets.findIndex(x => x === 'ANY')
574
+ if (anyIdx >= 0) {
575
+ for (const l of wrap(
576
+ `Because arm ${anyIdx} can begin with any character, no single-character test can rule it `
577
+ + `out. At all ${groupDigits(cost.positions)} of those places the parser has to enter it — set `
578
+ + 'up, try, undo — instead of skipping it for nothing.',
579
+ (opts.width ?? DEFAULT_WIDTH) - 7, '')) out.push([t(' '), t(l, TONE.quiet)])
580
+ }
581
+ }
582
+
583
+ // Overlaps name a PAIR, which the arm table alone does not show.
584
+ for (const detail of f.details) {
585
+ const first = detail.split('\n')[0]!
586
+ if (first.includes('overlap on')) {
587
+ const m = /arm\[(\d+)\] ∩ arm\[(\d+)\] overlap on (.*)$/.exec(first)
588
+ const text = m === null
589
+ ? first
590
+ : `arm ${m[1]} and arm ${m[2]} can both start with ${m[3]}, so that character cannot tell `
591
+ + 'the parser which to try'
592
+ for (const l of wrap(text, (opts.width ?? DEFAULT_WIDTH) - 7, '')) out.push([t(' '), t(l, TONE.warn)])
593
+ }
594
+ else if (sets === undefined && !detail.includes('\nfix: ')) {
595
+ for (const l of wrap(first, (opts.width ?? DEFAULT_WIDTH) - 6, '')) out.push([t(' '), t(l)])
596
+ }
597
+ }
598
+
599
+ // WORLD 2 — the input that pays for it, drawn once per cause. Point at a place a REAL
600
+ // arm wants, not at byte 0: an ANY arm's "first site" is always the first character of
601
+ // the corpus, which tells the reader nothing.
602
+ if (wantFrame && cost !== undefined) {
603
+ const broad = cost.arms.find(a => a.any)
604
+ const concrete = cost.arms.find(a => !a.any && a.firstSite !== undefined)
605
+ const site = (concrete ?? cost.arms.find(a => a.firstSite !== undefined))?.firstSite
606
+ if (site !== undefined) {
607
+ out.push(...codeFrame({
608
+ path: site.sample,
609
+ fullPath: opts.corpusRoot === undefined ? site.sample : `${opts.corpusRoot}/${site.sample}`,
610
+ line: site.line,
611
+ column: site.column,
612
+ lineText: site.lineText.replace(/\t/g, ' '),
613
+ message: broad === undefined
614
+ ? 'one of those places in your corpus'
615
+ : `one of the ${groupDigits(cost.positions)} places, in your own input`,
616
+ shortMessage: broad !== undefined && concrete !== undefined
617
+ ? `arm ${concrete.index} matches here; arm ${broad.index} is entered first anyway`
618
+ : 'the first place in your corpus this choice is reached',
619
+ type: 'warning',
620
+ }, opts, ' '))
621
+ framed = true
622
+ }
623
+ }
624
+ return { lines: out, framed }
625
+ }