parseman 0.45.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 (333) hide show
  1. package/README.md +8 -3
  2. package/dist/analysis/choice-cost-gate.d.ts +158 -0
  3. package/dist/analysis/choice-cost-gate.d.ts.map +1 -0
  4. package/dist/analysis/choice-cost-render.d.ts +96 -0
  5. package/dist/analysis/choice-cost-render.d.ts.map +1 -0
  6. package/dist/analysis/choice-cost.d.ts +383 -0
  7. package/dist/analysis/choice-cost.d.ts.map +1 -0
  8. package/dist/analysis/commitment.d.ts +131 -0
  9. package/dist/analysis/commitment.d.ts.map +1 -0
  10. package/dist/analysis/corpus.d.ts +73 -0
  11. package/dist/analysis/corpus.d.ts.map +1 -0
  12. package/dist/analysis/diagnose-render.d.ts +65 -0
  13. package/dist/analysis/diagnose-render.d.ts.map +1 -0
  14. package/dist/analysis/diagnose.d.ts +15 -0
  15. package/dist/analysis/diagnose.d.ts.map +1 -1
  16. package/dist/analysis/diagnostics.cjs +13736 -0
  17. package/dist/analysis/diagnostics.cjs.map +6 -0
  18. package/dist/analysis/diagnostics.d.ts +27 -0
  19. package/dist/analysis/diagnostics.d.ts.map +1 -0
  20. package/dist/analysis/diagnostics.js +13713 -0
  21. package/dist/analysis/diagnostics.js.map +6 -0
  22. package/dist/analysis/duplication.d.ts.map +1 -1
  23. package/dist/analysis/fix-render.d.ts +43 -0
  24. package/dist/analysis/fix-render.d.ts.map +1 -0
  25. package/dist/analysis/fix.d.ts +172 -0
  26. package/dist/analysis/fix.d.ts.map +1 -0
  27. package/dist/analysis/format-number.d.ts +23 -0
  28. package/dist/analysis/format-number.d.ts.map +1 -0
  29. package/dist/analysis/gating.d.ts +47 -2
  30. package/dist/analysis/gating.d.ts.map +1 -1
  31. package/dist/analysis/rebuild.d.ts +61 -0
  32. package/dist/analysis/rebuild.d.ts.map +1 -0
  33. package/dist/analysis/terminal.d.ts +145 -0
  34. package/dist/analysis/terminal.d.ts.map +1 -0
  35. package/dist/cli/index.cjs +14837 -0
  36. package/dist/cli/index.cjs.map +6 -0
  37. package/dist/cli/index.d.ts +3 -0
  38. package/dist/cli/index.d.ts.map +1 -0
  39. package/dist/cli/index.js +14814 -0
  40. package/dist/cli/index.js.map +6 -0
  41. package/dist/combinators/adjacency.d.ts +100 -0
  42. package/dist/combinators/adjacency.d.ts.map +1 -0
  43. package/dist/combinators/choice.d.ts.map +1 -1
  44. package/dist/combinators/completions.d.ts.map +1 -1
  45. package/dist/combinators/dispatch.d.ts +8 -1
  46. package/dist/combinators/dispatch.d.ts.map +1 -1
  47. package/dist/combinators/expect.d.ts.map +1 -1
  48. package/dist/combinators/first-set.d.ts.map +1 -1
  49. package/dist/combinators/gate.d.ts.map +1 -1
  50. package/dist/combinators/grammar.d.ts +22 -0
  51. package/dist/combinators/grammar.d.ts.map +1 -1
  52. package/dist/combinators/literal.d.ts +8 -0
  53. package/dist/combinators/literal.d.ts.map +1 -1
  54. package/dist/combinators/map.d.ts +0 -1
  55. package/dist/combinators/map.d.ts.map +1 -1
  56. package/dist/combinators/node.d.ts +13 -3
  57. package/dist/combinators/node.d.ts.map +1 -1
  58. package/dist/combinators/parser.d.ts +1 -1
  59. package/dist/combinators/parser.d.ts.map +1 -1
  60. package/dist/combinators/ref.d.ts.map +1 -1
  61. package/dist/combinators/regex.d.ts +1 -16
  62. package/dist/combinators/regex.d.ts.map +1 -1
  63. package/dist/combinators/repeat.d.ts +30 -2
  64. package/dist/combinators/repeat.d.ts.map +1 -1
  65. package/dist/combinators/scanTo.d.ts +71 -1
  66. package/dist/combinators/scanTo.d.ts.map +1 -1
  67. package/dist/combinators/sequence.d.ts.map +1 -1
  68. package/dist/combinators/token.d.ts.map +1 -1
  69. package/dist/combinators/trivia-boundary.d.ts +26 -0
  70. package/dist/combinators/trivia-boundary.d.ts.map +1 -0
  71. package/dist/combinators/trivia-skip.d.ts +40 -1
  72. package/dist/combinators/trivia-skip.d.ts.map +1 -1
  73. package/dist/combinators/withCtx.d.ts.map +1 -1
  74. package/dist/compiler/build-arity.d.ts.map +1 -1
  75. package/dist/compiler/compile-linkable-table.d.ts +104 -0
  76. package/dist/compiler/compile-linkable-table.d.ts.map +1 -0
  77. package/dist/compiler/degradation.d.ts +0 -2
  78. package/dist/compiler/degradation.d.ts.map +1 -1
  79. package/dist/compiler/fields.d.ts +13 -0
  80. package/dist/compiler/fields.d.ts.map +1 -1
  81. package/dist/compiler/grammar-coverage-ids.d.ts +5 -5
  82. package/dist/compiler/grammar-coverage-ids.d.ts.map +1 -1
  83. package/dist/compiler/ir-serialize.d.ts.map +1 -1
  84. package/dist/compiler/linker.d.ts +79 -47
  85. package/dist/compiler/linker.d.ts.map +1 -1
  86. package/dist/compiler/token-alphabet.d.ts +81 -0
  87. package/dist/compiler/token-alphabet.d.ts.map +1 -0
  88. package/dist/compiler/token-dispatch.d.ts +95 -0
  89. package/dist/compiler/token-dispatch.d.ts.map +1 -0
  90. package/dist/compiler/token-scanner.d.ts +53 -0
  91. package/dist/compiler/token-scanner.d.ts.map +1 -0
  92. package/dist/compiler/value-usage.d.ts.map +1 -1
  93. package/dist/coverage.d.ts +1 -1
  94. package/dist/coverage.d.ts.map +1 -1
  95. package/dist/cst/capture-buffer.d.ts +35 -0
  96. package/dist/cst/capture-buffer.d.ts.map +1 -1
  97. package/dist/cst/reflection-attach.d.ts +8 -0
  98. package/dist/cst/reflection-attach.d.ts.map +1 -0
  99. package/dist/cst/reflection-symbols.d.ts +10 -0
  100. package/dist/cst/reflection-symbols.d.ts.map +1 -0
  101. package/dist/cst/reflection.d.ts +2 -4
  102. package/dist/cst/reflection.d.ts.map +1 -1
  103. package/dist/cst/root-trivia-scope.d.ts +26 -0
  104. package/dist/cst/root-trivia-scope.d.ts.map +1 -0
  105. package/dist/cst/trivia-charscan.d.ts +18 -0
  106. package/dist/cst/trivia-charscan.d.ts.map +1 -0
  107. package/dist/cst/trivia-kinds.d.ts +19 -0
  108. package/dist/cst/trivia-kinds.d.ts.map +1 -1
  109. package/dist/functional/doc.d.ts.map +1 -1
  110. package/dist/functional/run-tabled.d.ts +12 -0
  111. package/dist/functional/run-tabled.d.ts.map +1 -0
  112. package/dist/functional/run.d.ts +25 -42
  113. package/dist/functional/run.d.ts.map +1 -1
  114. package/dist/grammar-metadata.d.ts +3 -0
  115. package/dist/grammar-metadata.d.ts.map +1 -0
  116. package/dist/index.cjs +10499 -7478
  117. package/dist/index.cjs.map +3 -4
  118. package/dist/index.d.ts +24 -9
  119. package/dist/index.d.ts.map +1 -1
  120. package/dist/index.js +10499 -7478
  121. package/dist/index.js.map +3 -4
  122. package/dist/language-service/index.cjs +311 -121
  123. package/dist/language-service/index.cjs.map +3 -4
  124. package/dist/language-service/index.js +311 -121
  125. package/dist/language-service/index.js.map +3 -4
  126. package/dist/oracle/index.cjs.map +0 -1
  127. package/dist/oracle/index.js.map +0 -1
  128. package/dist/parse-context.d.ts +49 -0
  129. package/dist/parse-context.d.ts.map +1 -0
  130. package/dist/plugin/evaluator.d.ts +41 -2
  131. package/dist/plugin/evaluator.d.ts.map +1 -1
  132. package/dist/plugin/free-identifiers.d.ts +23 -0
  133. package/dist/plugin/free-identifiers.d.ts.map +1 -0
  134. package/dist/plugin/index.cjs +11381 -9442
  135. package/dist/plugin/index.cjs.map +3 -4
  136. package/dist/plugin/index.d.ts.map +1 -1
  137. package/dist/plugin/index.js +11381 -9442
  138. package/dist/plugin/index.js.map +3 -4
  139. package/dist/plugin/reducer-resolver.d.ts +17 -1
  140. package/dist/plugin/reducer-resolver.d.ts.map +1 -1
  141. package/dist/regex/classes.d.ts +28 -0
  142. package/dist/regex/classes.d.ts.map +1 -1
  143. package/dist/regex/first-set.d.ts +14 -0
  144. package/dist/regex/first-set.d.ts.map +1 -1
  145. package/dist/run/index.cjs +136 -51
  146. package/dist/run/index.cjs.map +2 -3
  147. package/dist/run/index.js +136 -51
  148. package/dist/run/index.js.map +2 -3
  149. package/dist/spec/index.cjs +2898 -1622
  150. package/dist/spec/index.cjs.map +3 -4
  151. package/dist/spec/index.js +2898 -1622
  152. package/dist/spec/index.js.map +3 -4
  153. package/dist/spec/model.d.ts +8 -0
  154. package/dist/spec/model.d.ts.map +1 -1
  155. package/dist/table/assemble.d.ts +307 -0
  156. package/dist/table/assemble.d.ts.map +1 -0
  157. package/dist/table/cell.d.ts +42 -0
  158. package/dist/table/cell.d.ts.map +1 -0
  159. package/dist/table/child-slots.d.ts +30 -0
  160. package/dist/table/child-slots.d.ts.map +1 -0
  161. package/dist/table/compile-rule-map.d.ts +136 -0
  162. package/dist/table/compile-rule-map.d.ts.map +1 -0
  163. package/dist/table/compile.d.ts +56 -0
  164. package/dist/table/compile.d.ts.map +1 -0
  165. package/dist/table/duplication-hook.d.ts +32 -0
  166. package/dist/table/duplication-hook.d.ts.map +1 -0
  167. package/dist/table/emit-assembly.d.ts +139 -0
  168. package/dist/table/emit-assembly.d.ts.map +1 -0
  169. package/dist/table/emit.d.ts +113 -0
  170. package/dist/table/emit.d.ts.map +1 -0
  171. package/dist/table/encode-baseline.d.ts +16 -0
  172. package/dist/table/encode-baseline.d.ts.map +1 -0
  173. package/dist/table/encode.d.ts +71 -0
  174. package/dist/table/encode.d.ts.map +1 -0
  175. package/dist/table/exec-baseline.d.ts +9 -0
  176. package/dist/table/exec-baseline.d.ts.map +1 -0
  177. package/dist/table/exec.d.ts +72 -0
  178. package/dist/table/exec.d.ts.map +1 -0
  179. package/dist/table/fold.d.ts +25 -0
  180. package/dist/table/fold.d.ts.map +1 -0
  181. package/dist/table/index.cjs +11375 -0
  182. package/dist/table/index.cjs.map +6 -0
  183. package/dist/table/index.d.ts +100 -0
  184. package/dist/table/index.d.ts.map +1 -0
  185. package/dist/table/index.js +11352 -0
  186. package/dist/table/index.js.map +6 -0
  187. package/dist/table/inspect.d.ts +17 -0
  188. package/dist/table/inspect.d.ts.map +1 -0
  189. package/dist/table/ops.d.ts +446 -0
  190. package/dist/table/ops.d.ts.map +1 -0
  191. package/dist/table/program.d.ts +493 -0
  192. package/dist/table/program.d.ts.map +1 -0
  193. package/dist/table/run-support.d.ts +38 -0
  194. package/dist/table/run-support.d.ts.map +1 -0
  195. package/dist/{compiler/scannable-run.d.ts → table/scan-shapes.d.ts} +14 -13
  196. package/dist/table/scan-shapes.d.ts.map +1 -0
  197. package/dist/table/site-labels.d.ts +58 -0
  198. package/dist/table/site-labels.d.ts.map +1 -0
  199. package/dist/table/stamp.d.ts +70 -0
  200. package/dist/table/stamp.d.ts.map +1 -0
  201. package/dist/types.d.ts +80 -24
  202. package/dist/types.d.ts.map +1 -1
  203. package/dist/version.d.ts +1 -1
  204. package/package.json +41 -3
  205. package/src/analysis/choice-cost-gate.ts +359 -0
  206. package/src/analysis/choice-cost-render.ts +293 -0
  207. package/src/analysis/choice-cost.ts +1008 -0
  208. package/src/analysis/commitment.ts +557 -0
  209. package/src/analysis/corpus.ts +130 -0
  210. package/src/analysis/diagnose-render.ts +625 -0
  211. package/src/analysis/diagnose.ts +317 -0
  212. package/src/analysis/diagnostics.ts +33 -0
  213. package/src/analysis/duplication.ts +1955 -0
  214. package/src/analysis/fix-render.ts +217 -0
  215. package/src/analysis/fix.ts +669 -0
  216. package/src/analysis/format-number.ts +31 -0
  217. package/src/analysis/gating.ts +792 -0
  218. package/src/analysis/grammar.ts +72 -0
  219. package/src/analysis/rebuild.ts +287 -0
  220. package/src/analysis/terminal.ts +336 -0
  221. package/src/cli/index.ts +413 -0
  222. package/src/combinators/adjacency.ts +171 -0
  223. package/src/combinators/attempt.ts +41 -0
  224. package/src/combinators/case-fold.ts +90 -0
  225. package/src/combinators/choice.ts +466 -0
  226. package/src/combinators/completions.ts +64 -0
  227. package/src/combinators/dispatch.ts +358 -0
  228. package/src/combinators/expect.ts +159 -0
  229. package/src/combinators/first-set.ts +322 -0
  230. package/src/combinators/gate.ts +50 -0
  231. package/src/combinators/grammar.ts +272 -0
  232. package/src/combinators/keywords.ts +145 -0
  233. package/src/combinators/literal.ts +102 -0
  234. package/src/combinators/map.ts +106 -0
  235. package/src/combinators/node.ts +345 -0
  236. package/src/combinators/not.ts +55 -0
  237. package/src/combinators/parser.ts +266 -0
  238. package/src/combinators/peek.ts +63 -0
  239. package/src/combinators/probe.ts +25 -0
  240. package/src/combinators/ref.ts +109 -0
  241. package/src/combinators/regex.ts +170 -0
  242. package/src/combinators/repeat.ts +561 -0
  243. package/src/combinators/scanTo.ts +393 -0
  244. package/src/combinators/sequence.ts +221 -0
  245. package/src/combinators/token.ts +120 -0
  246. package/src/combinators/trivia-boundary.ts +48 -0
  247. package/src/combinators/trivia-skip.ts +821 -0
  248. package/src/combinators/withCtx.ts +45 -0
  249. package/src/compiler/build-arity.ts +330 -0
  250. package/src/compiler/compile-linkable-table.ts +209 -0
  251. package/src/compiler/degradation.ts +266 -0
  252. package/src/compiler/fields.ts +191 -0
  253. package/src/compiler/grammar-coverage-ids.ts +129 -0
  254. package/src/compiler/ir-serialize.ts +615 -0
  255. package/src/compiler/linker.ts +954 -0
  256. package/src/compiler/token-alphabet.ts +209 -0
  257. package/src/compiler/token-dispatch.ts +353 -0
  258. package/src/compiler/token-scanner.ts +318 -0
  259. package/src/compiler/value-usage.ts +147 -0
  260. package/src/coverage.ts +383 -0
  261. package/src/cst/capture-buffer.ts +297 -0
  262. package/src/cst/host-mode.ts +69 -0
  263. package/src/cst/reflection-attach.ts +11 -0
  264. package/src/cst/reflection-symbols.ts +9 -0
  265. package/src/cst/reflection.ts +149 -0
  266. package/src/cst/relative-spans.ts +207 -0
  267. package/src/cst/root-trivia-scope.ts +30 -0
  268. package/src/cst/trivia-charscan.ts +312 -0
  269. package/src/cst/trivia-entries.ts +285 -0
  270. package/src/cst/trivia-index.ts +131 -0
  271. package/src/cst/trivia-kinds.ts +358 -0
  272. package/src/cst/types.ts +76 -0
  273. package/src/cst/walk.ts +101 -0
  274. package/src/functional/doc.ts +959 -0
  275. package/src/functional/run-tabled.ts +153 -0
  276. package/src/functional/run.ts +558 -0
  277. package/src/grammar-metadata.ts +2 -0
  278. package/src/index.ts +189 -0
  279. package/src/language-service/index.ts +221 -0
  280. package/src/line-index.ts +131 -0
  281. package/src/oracle/digest.ts +620 -0
  282. package/src/oracle/index.ts +55 -0
  283. package/src/parse-context.ts +91 -0
  284. package/src/plugin/direct-builder-static.ts +102 -0
  285. package/src/plugin/evaluator.ts +1505 -0
  286. package/src/plugin/free-identifiers.ts +338 -0
  287. package/src/plugin/index.ts +2281 -0
  288. package/src/plugin/reducer-resolver.ts +606 -0
  289. package/src/recovery/scan.ts +155 -0
  290. package/src/regex/classes.ts +183 -0
  291. package/src/regex/first-set.ts +316 -0
  292. package/src/run/index.ts +31 -0
  293. package/src/spec/ebnf.ts +128 -0
  294. package/src/spec/index.ts +54 -0
  295. package/src/spec/model.ts +636 -0
  296. package/src/spec/railroad-lib.ts +559 -0
  297. package/src/spec/railroad.ts +256 -0
  298. package/src/table/assemble.ts +2994 -0
  299. package/src/table/cell.ts +42 -0
  300. package/src/table/child-slots.ts +113 -0
  301. package/src/table/compile-rule-map.ts +336 -0
  302. package/src/table/compile.ts +244 -0
  303. package/src/table/duplication-hook.ts +89 -0
  304. package/src/table/emit-assembly.ts +1766 -0
  305. package/src/table/emit.ts +418 -0
  306. package/src/table/encode-baseline.ts +340 -0
  307. package/src/table/encode.ts +1569 -0
  308. package/src/table/exec-baseline.ts +537 -0
  309. package/src/table/exec.ts +1672 -0
  310. package/src/table/fold.ts +38 -0
  311. package/src/table/index.ts +108 -0
  312. package/src/table/inspect.ts +52 -0
  313. package/src/table/ops.ts +459 -0
  314. package/src/table/program.ts +855 -0
  315. package/src/table/run-support.ts +66 -0
  316. package/src/table/scan-shapes.ts +1578 -0
  317. package/src/table/site-labels.ts +228 -0
  318. package/src/table/stamp.ts +229 -0
  319. package/src/types.ts +563 -0
  320. package/src/version.ts +16 -0
  321. package/dist/compiler/codegen.d.ts +0 -334
  322. package/dist/compiler/codegen.d.ts.map +0 -1
  323. package/dist/compiler/inline-build.d.ts +0 -11
  324. package/dist/compiler/inline-build.d.ts.map +0 -1
  325. package/dist/compiler/inline-callback.d.ts +0 -21
  326. package/dist/compiler/inline-callback.d.ts.map +0 -1
  327. package/dist/compiler/line-index.d.ts +0 -2
  328. package/dist/compiler/line-index.d.ts.map +0 -1
  329. package/dist/compiler/scannable-run.d.ts.map +0 -1
  330. package/dist/compiler/scannable-terminal.d.ts +0 -23
  331. package/dist/compiler/scannable-terminal.d.ts.map +0 -1
  332. package/dist/compiler/trivia-fast-path.d.ts +0 -38
  333. package/dist/compiler/trivia-fast-path.d.ts.map +0 -1
@@ -0,0 +1,266 @@
1
+ /**
2
+ * Degradation diagnostics — "parseman could not do the fast/complete thing here".
3
+ *
4
+ * A fallback that is CORRECT but slower (or less complete) and announces nothing is
5
+ * indistinguishable from working properly. Every such path in the compiler and the
6
+ * analysis APIs reports through this one channel so a consumer's build gate can
7
+ * assert ZERO degradations, exactly the way jess's `check:macro` already asserts zero
8
+ * `"falling back to runtime"` lines.
9
+ *
10
+ * Machine-greppable contract (stable across minor versions):
11
+ *
12
+ * [parseman] degraded [<code>] <where>: <subject> — <fell back to>; otherwise <what>
13
+ *
14
+ * Grep token is the literal `[parseman] degraded`. The `<code>` is a stable kebab-case
15
+ * identifier; new codes may be added, existing ones are not renamed without a note in
16
+ * CHANGELOG.md.
17
+ *
18
+ * NOT for: user errors (those throw), un-taken optimizations that cost nothing, or
19
+ * anything a grammar author cannot act on. A diagnostic that fires on every rule gets
20
+ * filtered out and becomes the same silence we are trying to end — so a code that is
21
+ * common and unavoidable AGGREGATES (see `DETAIL_CAP`) into one summary line with a
22
+ * count instead of one line per rule.
23
+ */
24
+
25
+ /** Off / print / throw. */
26
+ export type DegradationLevel = 'off' | 'warn' | 'error'
27
+
28
+ /** Stable, greppable code per degradation class. */
29
+ export type DegradationCode =
30
+ /** A node build's formal parameter list could not be read → all capture tiers kept. */
31
+ | 'build-arity-unconfirmed'
32
+ /** A composed/carried piece is an opaque artifact → its rules were not analysed. */
33
+ | 'opaque-artifact'
34
+ /** A coverage-definition request could not read the grammar → empty is NOT a zero. */
35
+ | 'coverage-definitions-unavailable'
36
+
37
+ /**
38
+ * Severity. `warn` = a real, measurable cost the author can remove. `info` = the author
39
+ * has nothing to act on (e.g. an imported reducer in someone else's package), but the
40
+ * fact still has to be visible and countable.
41
+ */
42
+ export type DegradationSeverity = 'warn' | 'info'
43
+
44
+ export type Degradation = {
45
+ code: DegradationCode
46
+ severity: DegradationSeverity
47
+ /** Rule / node type / artifact the cost lands on. Never a bare "<unknown>" if avoidable. */
48
+ where: string
49
+ /** The reducer, identifier, or input that could not be analysed. */
50
+ subject: string
51
+ /** What parseman did instead. */
52
+ fellBackTo: string
53
+ /** What it would have done had the input been analysable. */
54
+ otherwise: string
55
+ }
56
+
57
+ /** One line per finding, in the documented greppable shape. */
58
+ export function formatDegradation(d: Degradation): string {
59
+ return `[parseman] degraded [${d.code}] ${d.where}: ${d.subject} — ${d.fellBackTo}; otherwise ${d.otherwise}`
60
+ }
61
+
62
+ /**
63
+ * Per-code detail cap. Above it the remaining sites collapse to one counted summary
64
+ * line. Chosen small on purpose: the point of a detail line is to give an author a
65
+ * concrete place to start, and eight starting points is already more than anyone acts
66
+ * on in one pass. The COUNT is what makes the rest visible.
67
+ */
68
+ const DETAIL_CAP = 8
69
+
70
+ /** Group by code, cap the detail lines, and append a counted summary for the rest. */
71
+ export function formatDegradations(list: readonly Degradation[]): string[] {
72
+ const byCode = new Map<DegradationCode, Degradation[]>()
73
+ for (const d of list) {
74
+ const bucket = byCode.get(d.code)
75
+ if (bucket) bucket.push(d)
76
+ else byCode.set(d.code, [d])
77
+ }
78
+ const out: string[] = []
79
+ for (const [code, items] of byCode) {
80
+ for (const d of items.slice(0, DETAIL_CAP)) out.push(formatDegradation(d))
81
+ if (items.length > DETAIL_CAP) {
82
+ out.push(
83
+ `[parseman] degraded [${code}] +${items.length - DETAIL_CAP} more site(s) not listed `
84
+ + `(${items.length} total). Set PARSEMAN_DEGRADATION=error to fail the build on these.`,
85
+ )
86
+ }
87
+ }
88
+ return out
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Capture sink
93
+ //
94
+ // Compilation is single-module and synchronous, so a module-level sink (opened per
95
+ // transform, drained after) records findings without threading a field through every
96
+ // Ctx and every compile*() return — the same shape as the regex-lowering sink in
97
+ // codegen.ts, for the same reason.
98
+ // ---------------------------------------------------------------------------
99
+ //
100
+ // A STACK, not a single slot. `transformMacro` re-enters itself when it lowers a private
101
+ // source module, so a single slot let the inner call's `endDegradationCapture()` close
102
+ // the OUTER module's sink — every later finding in the outer pass then bypassed capture
103
+ // and printed instead. A stack scopes each frame to its own module.
104
+ // ---------------------------------------------------------------------------
105
+ const _sinks: Array<Map<string, Degradation>> = []
106
+
107
+ /** Begin collecting degradations instead of printing them. */
108
+ export function beginDegradationCapture(): void {
109
+ _sinks.push(new Map())
110
+ }
111
+
112
+ /** Stop collecting and return the findings (deduped, insertion-ordered). */
113
+ export function endDegradationCapture(): Degradation[] {
114
+ const sink = _sinks.pop()
115
+ return sink ? [...sink.values()] : []
116
+ }
117
+
118
+ /** Open-sink depth, for a caller that must unwind exactly its own frames. */
119
+ export function degradationCaptureDepth(): number {
120
+ return _sinks.length
121
+ }
122
+
123
+ /**
124
+ * Close every sink opened above `depth` and return their findings, outermost first.
125
+ *
126
+ * The safety net for an ABORTED capture. `beginDegradationCapture()` and its matching
127
+ * `end` used to sit on the straight-line path of a function that throws between them, so
128
+ * one failed macro transform left the sink open for the REST OF THE PROCESS: every later
129
+ * `recordDegradation` — including from an unrelated runtime `compile()` — went into a
130
+ * dead Map and printed nothing. Calling this from a `finally` bounds the damage to the
131
+ * frame that failed, and returns what it had collected so it can still be reported
132
+ * rather than silently dropped.
133
+ */
134
+ export function unwindDegradationCapture(depth: number): Degradation[] {
135
+ const found: Degradation[] = []
136
+ while (_sinks.length > depth) {
137
+ const sink = _sinks.pop()!
138
+ found.unshift(...sink.values())
139
+ }
140
+ return found
141
+ }
142
+
143
+ /**
144
+ * Resolve the level: explicit argument wins, else `PARSEMAN_DEGRADATION`, else
145
+ * default-on `'warn'`. Default-on is the whole point — this exists because the
146
+ * default was silence.
147
+ */
148
+ export function resolveDegradationLevel(explicit?: DegradationLevel): DegradationLevel {
149
+ if (explicit !== undefined) return explicit
150
+ const env = typeof process !== 'undefined' ? (process.env?.PARSEMAN_DEGRADATION as DegradationLevel | undefined) : undefined
151
+ if (env === 'off' || env === 'warn' || env === 'error') return env
152
+ return 'warn'
153
+ }
154
+
155
+ /**
156
+ * The dedup key for one finding.
157
+ *
158
+ * `JSON.stringify` of the tuple, NOT a delimiter-joined string. Two of the three
159
+ * components are unbounded author text — `where` embeds a node type and `subject` is a
160
+ * verbatim slice of reducer SOURCE — so no single delimiter character can be shown absent
161
+ * from them, and picking a rarer one is the same bug one notch weaker. A JSON array is
162
+ * injective by construction and needs no such argument.
163
+ *
164
+ * It is also PRINTABLE, which is the property this file lost twice. The key was built
165
+ * with RAW 0x00 bytes in a template literal, which makes the whole source file BINARY:
166
+ * `git diff --numstat` reported `- -` for it, GitHub refused to render it, and `grep -rn`
167
+ * skipped it SILENTLY — no "binary file matches", no output, exit 0. The largest new file
168
+ * in the release was invisible to ordinary review. Commit `ed81612` had already removed
169
+ * exactly this pattern from `gating.ts` and `duplication.ts`; it came straight back here.
170
+ * `scripts/check-control-bytes.mjs` now fails CI on it, because a fix without a gate got
171
+ * us a second occurrence and would get us a third.
172
+ *
173
+ * This runs only when a degradation is actually recorded — never per match — so the
174
+ * encoding cost is irrelevant.
175
+ */
176
+ function degradationKey(d: Degradation): string {
177
+ return JSON.stringify([d.code, d.where, d.subject])
178
+ }
179
+
180
+ /**
181
+ * Record one degradation. Deduped on `code + where + subject`, so the four capture-tier
182
+ * probes that all consult the same unreadable parameter list produce ONE finding, not
183
+ * four.
184
+ *
185
+ * With a sink open (a macro transform) the finding is collected and the drain site
186
+ * decides what to do with it. With NO sink open — a runtime `compile()` — this is the
187
+ * only place that ever sees the finding, so it is also the only place that can honour
188
+ * `'error'`. It therefore throws here. `docs/guide/degradation-diagnostics.md` documents
189
+ * `PARSEMAN_DEGRADATION=error` as "fail the build" without qualification, and until this
190
+ * throw existed that was false in library mode: `error` silently behaved as `warn`,
191
+ * because `endDegradationCapture()` had exactly one call site, in the macro plugin.
192
+ */
193
+ export function recordDegradation(d: Degradation): void {
194
+ const level = resolveDegradationLevel()
195
+ if (level === 'off') return
196
+ const key = degradationKey(d)
197
+ const sink = _sinks[_sinks.length - 1]
198
+ if (sink) {
199
+ if (!sink.has(key)) sink.set(key, d)
200
+ return
201
+ }
202
+ if (_immediate.has(key)) return
203
+ _immediate.add(key)
204
+ if (level === 'error') throw new Error(`parseman: degraded compilation path\n${formatDegradation(d)}`)
205
+ console.warn(formatDegradation(d))
206
+ }
207
+
208
+ /** Dedup memo for the sink-less (runtime `compile()`) path. */
209
+ const _immediate = new Set<string>()
210
+
211
+ /**
212
+ * Open a per-`compile()` drain and return the function that closes it.
213
+ *
214
+ * This channel stays LOUD. `compile()` no longer prints gating advice — that is a
215
+ * deliberate diagnostic now (`diagnoseGrammar`) — but a degradation is not advice: it
216
+ * is parseman reporting that it could not do the thing the caller asked for, and this
217
+ * release exists to stop that happening silently. What changes here is only the SHAPE.
218
+ *
219
+ * Before, the sink-less path printed one ~500-character line per site as it went, with
220
+ * no aggregation — 31 of them for a single code in one `pnpm perf:workloads` run, each
221
+ * repeating the same advice. The macro drain has always aggregated (`formatDegradations`,
222
+ * `DETAIL_CAP`); the runtime path simply had no drain to aggregate at. It has one now,
223
+ * so both paths report the same way and a real count survives instead of a wall.
224
+ *
225
+ * Nested inside a macro transform this is a NO-OP: that transform's sink owns the whole
226
+ * module's findings and returns them on the bundler's warning channel. A per-compile
227
+ * drain underneath it would steal them and print them instead.
228
+ *
229
+ * @returns `drain(report)` — always unwinds the sink (so a `compile()` that throws
230
+ * cannot leave it open for the rest of the process), and reports only when
231
+ * `report` is true, so a failed compile does not mask its own error with a second one.
232
+ */
233
+ export function beginCompileDegradationDrain(): (report: boolean) => void {
234
+ if (resolveDegradationLevel() === 'off' || _sinks.length > 0) return () => {}
235
+ const depth = degradationCaptureDepth()
236
+ beginDegradationCapture()
237
+ return (report: boolean) => {
238
+ const found = unwindDegradationCapture(depth)
239
+ if (!report || found.length === 0) return
240
+ // Process-level dedup, preserved from the immediate path: compiling the same grammar
241
+ // twice must not re-print what the author has already been told once.
242
+ const fresh = found.filter(d => {
243
+ const k = degradationKey(d)
244
+ if (_immediate.has(k)) return false
245
+ _immediate.add(k)
246
+ return true
247
+ })
248
+ if (fresh.length === 0) return
249
+ const lines = formatDegradations(fresh)
250
+ if (resolveDegradationLevel() === 'error') {
251
+ throw new Error(`parseman: ${fresh.length} degraded compilation path(s)\n${lines.join('\n')}`)
252
+ }
253
+ for (const l of lines) console.warn(l)
254
+ }
255
+ }
256
+
257
+ /** Test-only: forget the sink-less dedup memo. */
258
+ export function resetDegradationMemo(): void {
259
+ _immediate.clear()
260
+ }
261
+
262
+ /** Test-only: is a sink open? Guards against a leaked capture between test files. */
263
+ export function hasOpenDegradationSink(): boolean {
264
+ return _sinks.length > 0
265
+ }
266
+
@@ -0,0 +1,191 @@
1
+ import type { Combinator, FieldMap, ParserDef } from '../types.ts'
2
+ import { confirmedArityForDef } from './build-arity.ts'
3
+
4
+ type NodeDef = Extract<ParserDef, { tag: 'node' }>
5
+
6
+ export function buildReadsFields(def: NodeDef): boolean {
7
+ if (!def.build) return true
8
+ const arity = confirmedArityForDef(def)
9
+ if (arity === null) return true
10
+ return arity >= 2
11
+ }
12
+
13
+ export function parserHasOwnFields(p: Combinator<unknown>, seen: Set<Combinator<unknown>> = new Set()): boolean {
14
+ if (seen.has(p)) return false
15
+ seen.add(p)
16
+ const d = p._def
17
+ switch (d.tag) {
18
+ case 'field': return true
19
+ case 'node': return false
20
+ case 'lazy': {
21
+ try { return parserHasOwnFields(d.thunk(), seen) } catch { return false }
22
+ }
23
+ case 'sequence':
24
+ case 'choice': return d.parsers.some(x => parserHasOwnFields(x, seen))
25
+ case 'dispatch': return parserHasOwnFields(d.selector, seen) || d.cases.some(x => parserHasOwnFields(x.parser, seen)) || (d.matchers ? d.matchers.some(entry => parserHasOwnFields(entry.parser, seen)) : false) || (d.otherwise ? parserHasOwnFields(d.otherwise, seen) : false)
26
+ case 'sepBy': return parserHasOwnFields(d.parser, seen) || parserHasOwnFields(d.separator, seen)
27
+ case 'grammar': return parserHasOwnFields(d.parser, seen) || (d.triviaParser ? parserHasOwnFields(d.triviaParser, seen) : false)
28
+ case 'scanTo': return parserHasOwnFields(d.sentinel, seen) || d.skip.some(x => parserHasOwnFields(x, seen))
29
+ case 'recover': return parserHasOwnFields(d.parser, seen) || parserHasOwnFields(d.sentinel, seen)
30
+ case 'routed': return d.fallback ? parserHasOwnFields(d.fallback, seen) : false
31
+ case 'many':
32
+ case 'oneOrMore':
33
+ case 'optional':
34
+ case 'attempt':
35
+ case 'transform':
36
+ case 'trivia':
37
+ case 'token':
38
+ case 'leaf':
39
+ case 'label':
40
+ case 'expect':
41
+ case 'withCtx':
42
+ case 'not':
43
+ case 'peek':
44
+ return parserHasOwnFields(d.parser, seen)
45
+ default:
46
+ return false
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Does the node's OWN parse frame ever have a trivia run logged into it?
52
+ * Trivia is logged only by the trivia-skip fn, which runs at `sequence`/`many`/
53
+ * repeat boundaries. A bare terminal (regex/literal/keywords/…) has NO trivia
54
+ * site, so its `_cstTriviaLog` stays empty and its `captureTrivia`/`_cstTriviaLog`/
55
+ * `_triviaCaptureMask` save+install+restore is dead work — the per-node scope
56
+ * trivia frame can be elided for it (see emitNode `needsTriviaFrame`).
57
+ *
58
+ * Stops at a nested `node()` (it manages its OWN trivia frame; trivia inside it
59
+ * never logs to THIS node's frame). CONSERVATIVE: any unknown / trivia-bearing
60
+ * shape returns `true` (keep the frame) — we only elide when provably safe.
61
+ */
62
+ export function parserHasTriviaSite(p: Combinator<unknown>, seen: Set<Combinator<unknown>> = new Set()): boolean {
63
+ return hasTriviaSite(p, seen, false)
64
+ }
65
+
66
+ /**
67
+ * The same question asked of the ROOT trivia log (`_ctx._rootTriviaLog`) instead
68
+ * of the current node's frame.
69
+ *
70
+ * The two differ in exactly one case, and it is the case that matters: a nested
71
+ * `node()` manages its own FRAME, so nothing inside it reaches the enclosing
72
+ * frame's log — but the root log is not a frame, and every trivia run inside that
73
+ * node still lands in it. Reusing `parserHasTriviaSite` for the root sink
74
+ * therefore under-reports: it dropped the mark on `node()` arms whose trivia does
75
+ * accumulate, and a parse-tree diff caught it as trivia entries that were no
76
+ * longer rewound on a failed choice arm (`length: 3` becoming `length: 6`).
77
+ */
78
+ export function parserHasRootTriviaSite(p: Combinator<unknown>, seen: Set<Combinator<unknown>> = new Set()): boolean {
79
+ return hasTriviaSite(p, seen, true)
80
+ }
81
+
82
+ function hasTriviaSite(p: Combinator<unknown>, seen: Set<Combinator<unknown>>, throughNode: boolean): boolean {
83
+ if (seen.has(p)) return true // cycle → conservative (keep the frame)
84
+ seen.add(p)
85
+ const parserHasTriviaSite = (
86
+ x: Combinator<unknown>,
87
+ s: Set<Combinator<unknown>> = new Set(),
88
+ ): boolean => hasTriviaSite(x, s, throughNode)
89
+ const d = p._def
90
+ switch (d.tag) {
91
+ // Trivia is skipped between/around elements or iterations → a site.
92
+ case 'sequence':
93
+ case 'many':
94
+ case 'oneOrMore':
95
+ case 'sepBy':
96
+ case 'scanTo':
97
+ case 'recover':
98
+ return true
99
+ // Nested node manages its own trivia frame; none logs into THIS frame. The
100
+ // root log has no frames, so it must look inside.
101
+ case 'node': return throughNode ? parserHasTriviaSite(d.parser, seen) : false
102
+ // Pure terminals + trivia-suppressing token: no site.
103
+ case 'regex':
104
+ case 'literal':
105
+ case 'keywords':
106
+ case 'guard':
107
+ // The adjacency probe scans with capture OFF, so it logs nothing.
108
+ case 'adjacency':
109
+ case 'token':
110
+ case 'leaf':
111
+ return false
112
+ // In dispatch position routed() reuses the already-consumed selector and has
113
+ // no trivia boundary. Outside dispatch, only its concrete fallback runs.
114
+ case 'routed': return d.fallback ? parserHasTriviaSite(d.fallback, seen) : false
115
+ // Transparent single-child wrappers: recurse.
116
+ case 'optional':
117
+ case 'attempt':
118
+ case 'transform':
119
+ case 'trivia':
120
+ case 'label':
121
+ case 'expect':
122
+ case 'withCtx':
123
+ case 'not':
124
+ case 'peek':
125
+ case 'field':
126
+ return parserHasTriviaSite(d.parser, seen)
127
+ case 'choice': return d.parsers.some(x => parserHasTriviaSite(x, seen))
128
+ case 'dispatch': return parserHasTriviaSite(d.selector, seen) || d.cases.some(x => parserHasTriviaSite(x.parser, seen)) || (d.matchers ? d.matchers.some(entry => parserHasTriviaSite(entry.parser, seen)) : false) || (d.otherwise ? parserHasTriviaSite(d.otherwise, seen) : false)
129
+ case 'grammar': return parserHasTriviaSite(d.parser, seen)
130
+ case 'lazy': {
131
+ try { return parserHasTriviaSite(d.thunk(), seen) } catch { return true }
132
+ }
133
+ default: return true // unknown shape → conservative
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Can this node's inner grammar explicitly enable trivia capture? A surrounding
139
+ * node owns the collector, so a nested `parser({ captureTrivia: true })` must
140
+ * allocate that collector before it enters the grammar scope. Nested node()
141
+ * rules own their separate collector and therefore do not contribute here.
142
+ */
143
+ export function parserEnablesTriviaCapture(p: Combinator<unknown>, seen: Set<Combinator<unknown>> = new Set()): boolean {
144
+ if (seen.has(p)) return false
145
+ seen.add(p)
146
+ const d = p._def
147
+ switch (d.tag) {
148
+ case 'grammar': return d.captureTrivia === true || parserEnablesTriviaCapture(d.parser, seen)
149
+ case 'node': return false
150
+ case 'sequence':
151
+ case 'choice': return d.parsers.some(x => parserEnablesTriviaCapture(x, seen))
152
+ case 'dispatch': return parserEnablesTriviaCapture(d.selector, seen) || d.cases.some(x => parserEnablesTriviaCapture(x.parser, seen)) || (d.matchers ? d.matchers.some(entry => parserEnablesTriviaCapture(entry.parser, seen)) : false) || (d.otherwise ? parserEnablesTriviaCapture(d.otherwise, seen) : false)
153
+ case 'sepBy': return parserEnablesTriviaCapture(d.parser, seen) || parserEnablesTriviaCapture(d.separator, seen)
154
+ case 'scanTo': return parserEnablesTriviaCapture(d.sentinel, seen) || d.skip.some(x => parserEnablesTriviaCapture(x, seen))
155
+ case 'recover': return parserEnablesTriviaCapture(d.parser, seen) || parserEnablesTriviaCapture(d.sentinel, seen)
156
+ case 'routed': return d.fallback ? parserEnablesTriviaCapture(d.fallback, seen) : false
157
+ case 'many':
158
+ case 'oneOrMore':
159
+ case 'optional':
160
+ case 'attempt':
161
+ case 'transform':
162
+ case 'trivia':
163
+ case 'token':
164
+ case 'label':
165
+ case 'field':
166
+ case 'expect':
167
+ case 'withCtx':
168
+ case 'not':
169
+ case 'peek': return parserEnablesTriviaCapture(d.parser, seen)
170
+ // leaf() clears the inner CST/trivia collector and publishes only its own
171
+ // terminal result. An inner parser() capture cannot reach this node.
172
+ case 'leaf': return false
173
+ case 'lazy': {
174
+ try { return parserEnablesTriviaCapture(d.thunk(), seen) } catch { return false }
175
+ }
176
+ default: return false
177
+ }
178
+ }
179
+
180
+ export function buildFieldMap(captures: ReadonlyArray<{ name: string; value: unknown; span: { start: number; end: number } }> | undefined): FieldMap | undefined {
181
+ if (!captures?.length) return undefined
182
+ const out: FieldMap = {}
183
+ for (const cap of captures) {
184
+ const entry = { value: cap.value, span: cap.span }
185
+ const current = out[cap.name]
186
+ if (current === undefined) out[cap.name] = entry
187
+ else if (Array.isArray(current)) current.push(entry)
188
+ else out[cap.name] = [current, entry]
189
+ }
190
+ return out
191
+ }
@@ -0,0 +1,129 @@
1
+ import type { Combinator, ParserDef } from '../types.ts'
2
+ import { recordDegradation } from './degradation.ts'
3
+
4
+ export type GrammarCoverageDefinition = { id: string; kind: 'rule' | 'choice-arm' | 'dispatch-arm' | 'label' }
5
+ export type GrammarCoveragePlan = {
6
+ definitions: readonly GrammarCoverageDefinition[]
7
+ choices: Map<Combinator<unknown>, readonly string[]>
8
+ dispatches: Map<Combinator<unknown>, readonly string[]>
9
+ /** Trace-only transaction identity. Attempts are not coverage definitions: a
10
+ * rejected transaction must not create a semantic winner or denominator. */
11
+ attempts: Map<Combinator<unknown>, string>
12
+ labels: Map<Combinator<unknown>, readonly string[]>
13
+ rules: Map<Combinator<unknown>, string>
14
+ }
15
+
16
+ function idPart(value: string): string {
17
+ return encodeURIComponent(value)
18
+ }
19
+
20
+ function dispatchArmIds(def: Extract<ParserDef, { tag: 'dispatch' }>, path: string): string[] {
21
+ const ids: string[] = []
22
+ for (const entry of def.cases) ids.push(`dispatch:${path}/when:${entry.keys.map(idPart).join('|')}`)
23
+ for (const entry of def.matchers ?? []) {
24
+ ids.push(`dispatch:${path}/matcher:${entry.kind}:${idPart(entry.value)}${entry.kind === 'matches' && entry.flags ? `:${idPart(entry.flags)}` : ''}`)
25
+ }
26
+ if (def.otherwise) ids.push(`dispatch:${path}/otherwise`)
27
+ return ids
28
+ }
29
+
30
+ function children(def: ParserDef, winners?: Record<string, Combinator<unknown>>): Combinator<unknown>[] {
31
+ switch (def.tag) {
32
+ case 'sequence': case 'choice': return def.parsers
33
+ case 'dispatch': return [def.selector, ...def.cases.map(entry => entry.parser), ...(def.matchers ? def.matchers.map(entry => entry.parser) : []), ...(def.otherwise ? [def.otherwise] : [])]
34
+ case 'many': case 'oneOrMore': case 'optional': case 'attempt': case 'transform': case 'trivia': case 'token': case 'leaf': case 'label': case 'field': case 'grammar': case 'not': case 'peek': case 'node': case 'guard': case 'adjacency': case 'withCtx': case 'recover': case 'expect': return 'parser' in def ? [def.parser] : []
35
+ case 'sepBy': return [def.parser, def.separator]
36
+ case 'scanTo': return [def.sentinel, ...def.skip]
37
+ case 'lazy': {
38
+ let resolved: Combinator<unknown>
39
+ try { resolved = def.thunk() } catch (e) {
40
+ // An unresolvable `ref()` / cross-artifact hole truncates the walk, so every
41
+ // definition beneath it is missing from the plan. That SHRINKS the coverage
42
+ // denominator, which pushes the reported ratio UP — a blind spot that reads as
43
+ // better coverage. `analyzeGatingRules` routes the same situation into
44
+ // `unanalysable`; the coverage walk has no such field, so it reports here.
45
+ recordDegradation({
46
+ code: 'coverage-definitions-unavailable',
47
+ severity: 'warn',
48
+ where: '<lazy rule reference>',
49
+ subject: `unresolvable reference (${(e as Error).message})`,
50
+ fellBackTo: 'the coverage walk stopped at this reference, so every definition below '
51
+ + 'it is ABSENT from the denominator — which raises the reported ratio rather than '
52
+ + 'lowering it',
53
+ otherwise: 'the referenced rule and its whole subtree would contribute definitions',
54
+ })
55
+ return []
56
+ }
57
+ const name = (resolved as Combinator<unknown> & { _ruleName?: string })._ruleName
58
+ return name && winners?.[name] ? [winners[name]!] : [resolved]
59
+ }
60
+ default: return []
61
+ }
62
+ }
63
+
64
+ /** Canonical deterministic IDs for one explicit entry closure, or every public
65
+ * entry in a `rules()` map. Multiple roots share identity maps so a shared
66
+ * subtree still has one stable owner. */
67
+ export function buildGrammarPlan(entry: Combinator<unknown> | readonly Combinator<unknown>[], winners?: Record<string, Combinator<unknown>>): GrammarCoveragePlan {
68
+ const definitions = new Map<string, GrammarCoverageDefinition>()
69
+ // This plan is per grammar compilation and returned with its definitions; it
70
+ // does not need a process-global weak side cache. Ordinary Maps make that
71
+ // finite ownership explicit and keep WeakMap out of the shipped table graph.
72
+ const choices = new Map<Combinator<unknown>, readonly string[]>()
73
+ const dispatches = new Map<Combinator<unknown>, readonly string[]>()
74
+ const attempts = new Map<Combinator<unknown>, string>()
75
+ const labels = new Map<Combinator<unknown>, readonly string[]>()
76
+ const rules = new Map<Combinator<unknown>, string>()
77
+ // A `rules()` map stores named lazy proxies while code generation may emit
78
+ // either that proxy or its resolved body. Final composed winners are the
79
+ // authority for both identities; relying on `_ruleName` alone loses direct
80
+ // local leaf rules and cross-piece references after IR hydration.
81
+ const winnerNames = new Map<Combinator<unknown>, string>()
82
+ if (winners) {
83
+ for (const [name, winner] of Object.entries(winners)) {
84
+ winnerNames.set(winner, name)
85
+ if (winner._def.tag === 'lazy') {
86
+ try { winnerNames.set(winner._def.thunk(), name) } catch { /* unresolved external stays absent */ }
87
+ }
88
+ }
89
+ }
90
+ const seen = new Set<Combinator<unknown>>()
91
+ const visit = (parser: Combinator<unknown>, path: string): void => {
92
+ if (seen.has(parser)) return
93
+ seen.add(parser)
94
+ const rule = winnerNames.get(parser) ?? (parser as Combinator<unknown> & { _ruleName?: string })._ruleName
95
+ // `rules()` references are tagged too for linker naming, but only the final
96
+ // rule definition owns execution coverage; otherwise ref + target double-hit.
97
+ if (rule && (winnerNames.has(parser) || parser._def.tag !== 'lazy')) {
98
+ const id = `rule:${rule}`
99
+ definitions.set(id, { id, kind: 'rule' })
100
+ rules.set(parser, id)
101
+ }
102
+ if (parser._def.tag === 'choice') {
103
+ const ids = parser._def.parsers.map((_, index) => `choice:${path}/arm:${index}`)
104
+ choices.set(parser, ids)
105
+ ids.forEach(id => definitions.set(id, { id, kind: 'choice-arm' }))
106
+ }
107
+ if (parser._def.tag === 'dispatch') {
108
+ const ids = dispatchArmIds(parser._def, path)
109
+ dispatches.set(parser, ids)
110
+ ids.forEach(id => definitions.set(id, { id, kind: 'dispatch-arm' }))
111
+ }
112
+ if (parser._def.tag === 'attempt') attempts.set(parser, `attempt:${path}`)
113
+ if (parser._def.tag === 'label') {
114
+ const id = `label:${path}`
115
+ labels.set(parser, [id])
116
+ definitions.set(id, { id, kind: 'label' })
117
+ }
118
+ children(parser._def, winners).forEach((child, index) => visit(child, `${path}/${parser._def.tag}:${index}`))
119
+ }
120
+ const roots = Array.isArray(entry) ? entry : [entry]
121
+ for (const root of roots) {
122
+ // Rule-map compilation can receive ordinary, unannotated combinators. The
123
+ // caller's winner map is then the only durable public identity for each
124
+ // root; using the generic `entry` path would merge separate roots' choice
125
+ // IDs and omit their rule definitions.
126
+ visit(root, winnerNames.get(root) ?? (root as Combinator<unknown> & { _ruleName?: string })._ruleName ?? 'entry')
127
+ }
128
+ return { definitions: [...definitions.values()].sort((a, b) => a.id.localeCompare(b.id)), choices, dispatches, attempts, labels, rules }
129
+ }