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,792 @@
1
+ /**
2
+ * Static first-char gating diagnostic.
3
+ *
4
+ * Parseman is scannerless PEG: a `choice` is CORRECT regardless of whether it
5
+ * first-char-gates. When a hot choice fails to gate, every non-matching input
6
+ * position speculatively ENTERS a doomed arm (ctx save/restore + child array +
7
+ * recognizer + rollback) instead of being skipped by a cheap first-char test —
8
+ * and nothing tells the author, because the grammar still passes every test. The
9
+ * only symptom is a CPU profile. This module surfaces, at build time, exactly what
10
+ * the compiler already knows: which choices gate, and for those that don't, which
11
+ * arm poisons dispatch and why.
12
+ *
13
+ * `analyzeGating()` is the pure programmatic surface, and `diagnoseGrammar()`
14
+ * (`src/analysis/diagnose.ts`) is the entry point most callers want. `compile()` does
15
+ * NOT run either: a diagnostic is a deliberate act, not a side effect of producing an
16
+ * artifact. Accept a deliberately-ungated choice by listing its `id` in the gating
17
+ * snapshot allowlist (`analyzeGating(entry, { accept })` / `diagnoseGrammar(g, {
18
+ * accept })`) — the single suppression mechanism.
19
+ *
20
+ * WHERE the question is asked matters as much as the answer. A SHARED SHAPE — a
21
+ * `rules()` map referencing a rule it doesn't define (`g.Value`) — has no verdict of
22
+ * its own: the hole makes every first-set through it `any`, but that configuration is
23
+ * never executed and its author cannot fix it. Such a choice is `deferred` here, and
24
+ * re-asked with `resolveRef` at the site that BINDS the name — `analyzeGrammarGating`
25
+ * on the fused artifact, which really runs and whose author really can fix it.
26
+ */
27
+ import type { Combinator, FirstSet, ParserDef } from '../types.ts'
28
+ import { firstSetOf, intersects, matchesEmpty, type RefResolver } from '../combinators/first-set.ts'
29
+
30
+ /** Why an arm's (deep) first-set is `any` / over-broad — the poison source. */
31
+ export type FirstSetCause =
32
+ | 'leading-not' // a leading zero-width not(...) (its first-set is `any`)
33
+ | 'nullable-prefix' // a leading optional/many skips to a broad following term
34
+ | 'cross-artifact-ref' // a g.Foo ref resolves to `any` (unresolved across a boundary)
35
+ | 'broad-recognizer' // a scanTo / any-first-set regex / guard leads the arm
36
+ | 'opaque-wrapper' // guard/withCtx/recover contributes `any`
37
+ | 'ref-cycle' // a mutually-recursive ref cycle resolved to `any`
38
+
39
+ /** An arm whose deep first-set is `any` / over-broad. */
40
+ export type AnyArm = {
41
+ index: number
42
+ cause: FirstSetCause
43
+ /** Human-readable trail to the poison, e.g. "via ref g.anyValue → broad recognizer (regex)". */
44
+ detail: string
45
+ /**
46
+ * True when only the CONSTRUCTION-time (shallow) first-set was `any` but the
47
+ * deep, ref-resolving first-set is finite — the monolithic compile recovers a
48
+ * real per-arm guard, so this is NOT a genuine cliff. Never present on a
49
+ * genuinely-ungated finding.
50
+ */
51
+ shallowAnyOnly: boolean
52
+ /**
53
+ * True when the poison is a NAMED cross-artifact hole this artifact cannot resolve
54
+ * (`g.Value` in a shared shape). The author of THIS artifact cannot act on it — the
55
+ * arm's real first-set only exists once a consumer binds the name — so a choice
56
+ * whose every `any` arm is one of these is `deferred`, not `ungated`.
57
+ *
58
+ * An UNNAMED unresolved `ref()` is NOT this: nobody can bind it by name, so it is a
59
+ * genuine local finding and stays reportable here.
60
+ */
61
+ unresolvedExternal: boolean
62
+ /** Concrete fix, naming a real primitive. */
63
+ suggestion: string
64
+ }
65
+
66
+ /** Two arms whose finite first-sets intersect — a shared prefix. */
67
+ export type Overlap = {
68
+ a: number
69
+ b: number
70
+ on: FirstSet
71
+ suggestion: string
72
+ }
73
+
74
+ /** An API-misuse pattern detected in a choice's arms (independent of gating). */
75
+ export type AntiPattern = {
76
+ kind: 'double-not' | 'leading-not' | 'keyword-regex'
77
+ rule: string
78
+ armIndex: number
79
+ message: string
80
+ }
81
+
82
+ export type ChoiceStrategyTag = 'firstMatch' | 'greedyClassify' | 'literalsLongestFirst' | 'sharedPrefix'
83
+
84
+ export type ChoiceGating = {
85
+ /**
86
+ * Stable per-choice identity for the accepted-snapshot allowlist. The enclosing
87
+ * rule name when that rule holds exactly one choice, else `rule#N` (0-based
88
+ * occurrence order within the rule). This is the key you list in the snapshot to
89
+ * ACCEPT a known ungated choice.
90
+ */
91
+ id: string
92
+ /** Nearest enclosing rule name (from `_ruleName`), or a synthetic path label. */
93
+ rule: string
94
+ strategy: ChoiceStrategyTag
95
+ /**
96
+ * `yes` — emits O(1) first-char dispatch (a switch/if jump table).
97
+ * `recoverable` — not O(1) dispatch, but every arm still first-char-guards via
98
+ * the deep, ref-resolving first-set (monolithic compile) / fuse-time resolution
99
+ * (compose). NOT a cliff; never warned.
100
+ * `no` — genuinely ungated: a broad/any arm or a finite overlap forces ordered
101
+ * speculative entry with no per-arm first-char skip.
102
+ */
103
+ gates: 'yes' | 'recoverable' | 'no'
104
+ /** True when this ungated choice's `id` is in the accepted-snapshot allowlist. */
105
+ accepted: boolean
106
+ /**
107
+ * `gates: 'no'` was decided by cross-artifact HOLES ONLY — every `any` arm is an
108
+ * unresolved NAMED `g.Foo` ref and no two finite arms overlap. The verdict is not
109
+ * this artifact's to make: the shape module can't fix it (the hole has no body
110
+ * here) and the configuration it describes never runs. The answer belongs to the
111
+ * FUSED artifact, where the name is bound — see `analyzeGrammarGating`.
112
+ *
113
+ * Deferred choices are excluded from `ungated`: they neither warn nor fail the
114
+ * `'error'` gate at this site.
115
+ */
116
+ deferred: boolean
117
+ combinedFirstSet: { shallow: FirstSet; deep: FirstSet }
118
+ anyArms: AnyArm[]
119
+ overlaps: Overlap[]
120
+ }
121
+
122
+ /**
123
+ * The arms a `ChoiceGating` describes, in arm order — or `undefined` when the report did
124
+ * not come from a live walk (a deserialized snapshot). Carried non-enumerably, so it
125
+ * never reaches the JSON a CI snapshot diffs.
126
+ */
127
+ export const choiceArms = (c: ChoiceGating): readonly Combinator<unknown>[] | undefined =>
128
+ (c as { arms?: readonly Combinator<unknown>[] }).arms
129
+
130
+ /** Options for `analyzeGating` — the accepted-snapshot allowlist. */
131
+ export type AnalyzeGatingOptions = {
132
+ /**
133
+ * Choice `id`s that are accepted as intentionally ungated. An ungated choice
134
+ * whose id is here is moved to `accepted` (silent, does not fail the CI gate); one
135
+ * whose id is NOT here stays in `ungated` (warned + fails the gate). This is the
136
+ * SINGLE per-choice suppression mechanism.
137
+ */
138
+ accept?: Iterable<string>
139
+ /**
140
+ * Name to attribute an UNNAMED entry to, instead of the synthetic `<entry>`.
141
+ * The macro plugin passes the binding's own variable name, so a warning on a
142
+ * top-level combinator const reads `choice @ directMixinReferenceAhead`
143
+ * (actionable, and a discriminating `accept` key) rather than `choice @
144
+ * <entry>` repeated once per const. Ignored when the entry already carries a
145
+ * `_ruleName`.
146
+ */
147
+ entryName?: string
148
+ /**
149
+ * Bind NAMED cross-artifact holes (`g.Foo`) by name — supply the FUSED winner map's
150
+ * lookup. With it, an arm led by a shared shape's hole reports the first-set it
151
+ * really has once bound, so `deferred` collapses to a real `yes`/`no` verdict.
152
+ * Without it (the authoring site) such a choice stays `deferred`.
153
+ */
154
+ resolveRef?: RefResolver
155
+ }
156
+
157
+ /**
158
+ * A rule the walk could NOT introspect. Its choices were never examined, so no
159
+ * verdict about it — clean or otherwise — is available.
160
+ *
161
+ * This exists because the alternative is silence. A `compose()` result is a map of
162
+ * FUSED rule functions with no `_def` combinator graph; walking one used to throw a
163
+ * bare `TypeError: Cannot read properties of undefined (reading 'tag')`, and the
164
+ * default-on diagnostic swallowed that throw and returned `undefined` — a failed
165
+ * analysis and a clean grammar were indistinguishable. Every unanalysable input is
166
+ * now counted and named here, and `formatGatingWarnings` always reports it.
167
+ */
168
+ export type Unanalysable = {
169
+ /** The rule name (or seed name) whose walk stopped. */
170
+ rule: string
171
+ /** Why it could not be walked, in terms the caller can act on. */
172
+ reason: string
173
+ kind: 'fused-rule' | 'opaque-artifact' | 'not-a-combinator'
174
+ }
175
+
176
+ export type GatingReport = {
177
+ totalChoices: number
178
+ gated: number
179
+ recoverable: number
180
+ /**
181
+ * Rules the walk could not introspect (see `Unanalysable`). NON-EMPTY MEANS THE
182
+ * REPORT IS PARTIAL: `totalChoices === 0` with a non-empty `unanalysable` is a
183
+ * blind walk, not a clean grammar. Callers that treat an empty `ungated` as a pass
184
+ * MUST also assert this is empty.
185
+ */
186
+ unanalysable: Unanalysable[]
187
+ /** Genuinely-ungated choices NOT in the accepted allowlist — warned + gate-failing. */
188
+ ungated: ChoiceGating[]
189
+ /** Ungated choices whose id was in the accepted allowlist — silent, accepted with intent. */
190
+ accepted: ChoiceGating[]
191
+ /**
192
+ * Choices whose verdict is NOT this artifact's to make — every `any` arm is an
193
+ * unresolved cross-artifact hole (see `ChoiceGating.deferred`). Silent here; the
194
+ * fused artifact re-asks the question with the hole bound.
195
+ */
196
+ deferred: ChoiceGating[]
197
+ /** Accepted ids that matched no ungated choice — stale snapshot entries to prune. */
198
+ acceptedUnused: string[]
199
+ /** Every choice, for full inspection / CI snapshots. */
200
+ choices: ChoiceGating[]
201
+ antiPatterns: AntiPattern[]
202
+ }
203
+
204
+ // ── grammar walk ─────────────────────────────────────────────────────────────
205
+
206
+ /**
207
+ * Ordered structural children per def tag. Explicit rather than "every key that
208
+ * holds a Combinator" because a SLOT's position is what near-duplicate detection
209
+ * varies — a stable, meaningful order is load-bearing, not cosmetic.
210
+ *
211
+ * Lives here, in the module every analysis pass already imports, because
212
+ * `./choice-cost.ts` and `./duplication.ts` each carried a byte-identical copy.
213
+ */
214
+ export function childrenOf(d: ParserDef): readonly Combinator<unknown>[] {
215
+ switch (d.tag) {
216
+ case 'sequence': case 'choice': return d.parsers
217
+ case 'dispatch': return [
218
+ d.selector,
219
+ ...d.cases.map(c => c.parser),
220
+ ...(d.matchers ? d.matchers.map(c => c.parser) : []),
221
+ ...(d.otherwise === undefined ? [] : [d.otherwise]),
222
+ ]
223
+ case 'sepBy': return [d.parser, d.separator]
224
+ case 'recover': return [d.parser, d.sentinel]
225
+ case 'scanTo': return [d.sentinel, ...d.skip]
226
+ case 'grammar': return d.triviaParser ? [d.parser, d.triviaParser] : [d.parser]
227
+ case 'routed': return d.fallback ? [d.fallback] : []
228
+ // A `lazy` is a REFERENCE, not a subtree. Descending through it would make
229
+ // every rule's walk cover the whole reachable grammar — site paths become
230
+ // nonsense, and a structural hash includes half the grammar. Treated as a
231
+ // leaf, keyed by the rule name it refers to, which is also the right
232
+ // semantics: two productions referencing `g.Ident` really do fill that slot
233
+ // the same way.
234
+ case 'lazy': case 'literal': case 'regex': case 'keywords': case 'guard': case 'adjacency': case 'unknown':
235
+ return []
236
+ default: {
237
+ const rec = d as unknown as { parser?: Combinator<unknown> }
238
+ return rec.parser ? [rec.parser] : []
239
+ }
240
+ }
241
+ }
242
+
243
+ // ── first-set helpers (local, to avoid importing codegen and creating a cycle) ──
244
+
245
+ const isAny = (fs: FirstSet): boolean => fs.kind === 'any'
246
+
247
+ /**
248
+ * Do two first-sets share any character?
249
+ *
250
+ * THE DEFINITION LIVES IN `../combinators/first-set.ts`, beside `union` and the
251
+ * rest of the first-set algebra, and is re-exported here only because
252
+ * `./duplication.ts` imports it from this module.
253
+ *
254
+ * This used to be a third copy. Two byte-identical copies in `./choice-cost.ts`
255
+ * and `./duplication.ts` were collapsed into a declaration here, and the note
256
+ * recording that said `intersects` now "lives once" — while
257
+ * `../combinators/first-set.ts` had been exporting its own since before any of
258
+ * them. INV-4 could not see it: the two bodies differ only in whether the nested
259
+ * `for` carries braces, and INV-4 decides on byte-identity after whitespace is
260
+ * stripped. INV-8 sees it, because it decides on the NAME.
261
+ */
262
+ export { intersects }
263
+
264
+ /** The SHARED first characters of two sets (the actual overlap, not the union). */
265
+ function intersection(a: FirstSet, b: FirstSet): FirstSet {
266
+ if (a.kind === 'any') return b
267
+ if (b.kind === 'any') return a
268
+ if (a.kind === 'empty' || b.kind === 'empty') return { kind: 'empty' }
269
+ const ranges = []
270
+ for (const ra of a.ranges) for (const rb of b.ranges) {
271
+ const lo = Math.max(ra.lo, rb.lo)
272
+ const hi = Math.min(ra.hi, rb.hi)
273
+ if (lo <= hi) ranges.push({ lo, hi })
274
+ }
275
+ return ranges.length === 0 ? { kind: 'empty' } : { kind: 'ranges', ranges }
276
+ }
277
+
278
+ function combine(sets: FirstSet[]): FirstSet {
279
+ let acc: FirstSet = { kind: 'empty' }
280
+ for (const s of sets) {
281
+ if (s.kind === 'any') return { kind: 'any' }
282
+ if (s.kind === 'empty') continue
283
+ acc = acc.kind === 'ranges' ? { kind: 'ranges', ranges: [...acc.ranges, ...s.ranges] } : s
284
+ }
285
+ return acc
286
+ }
287
+
288
+ const ch = (c: number): string => (c >= 32 && c < 127 ? `'${String.fromCharCode(c)}'` : `\\u${c}`)
289
+ export function firstSetToString(fs: FirstSet): string {
290
+ if (fs.kind === 'any') return 'ANY'
291
+ if (fs.kind === 'empty') return '(empty)'
292
+ return fs.ranges.map(r => (r.lo === r.hi ? ch(r.lo) : `${ch(r.lo)}-${ch(r.hi)}`)).join(',')
293
+ }
294
+
295
+ const ruleNameOf = (p: Combinator<unknown>): string | undefined =>
296
+ (p as unknown as { _ruleName?: string })._ruleName
297
+
298
+ // ── cause attribution: walk the leading structure, stop at the first `any` ──
299
+
300
+ /** A regex source that is really a keyword (a literal word, optionally with a
301
+ * trailing boundary lookahead) — the case word()/keywords() should own. */
302
+ const KEYWORD_REGEX_RE = /^\^?[@#.-]?[A-Za-z][\w-]*(\(\?![^)]*\))?\$?$/
303
+
304
+ /**
305
+ * What each cause MEANS and what to do about it, in words a grammar author can act on
306
+ * without having read parseman's source.
307
+ *
308
+ * These strings are the diagnostic. An earlier version of them was accurate and
309
+ * unreadable — "give the arm a concrete leading terminal", "resolves a g.Foo ref
310
+ * first-set at fuse time" — which is the same as having no diagnostic, because the
311
+ * reader cannot tell what happened or what to change. Every term of art here is either
312
+ * replaced or explained where it is used; nothing is deferred to a glossary.
313
+ */
314
+ const SUGGESTIONS: Record<FirstSetCause, string> = {
315
+ 'broad-recognizer':
316
+ 'This arm can begin with any character, so no single-character test can rule it out. '
317
+ + 'The parser has to enter it — set up, try, and undo — at every position it reaches, '
318
+ + 'instead of skipping it for free.\n'
319
+ + 'To fix: make the arm begin with a fixed character, word or keyword. If it is meant '
320
+ + 'to be a catch-all that matches anything, leave it and add the choice to the accept '
321
+ + 'list at the end.',
322
+ 'leading-not':
323
+ 'This arm begins with a not(...) check, which matches nothing and so says nothing about '
324
+ + 'which character the arm starts with. The parser then cannot rule the arm out and '
325
+ + 'enters it everywhere.\n'
326
+ + 'To fix: put the term that actually consumes text first and keep not(...) after it as '
327
+ + 'a trailing boundary. If you need to REQUIRE something ahead without consuming it, use '
328
+ + 'peek(X) — it checks the same thing but still tells parseman that the arm starts with X.',
329
+ 'nullable-prefix':
330
+ 'This arm begins with something optional — an optional(...) or a repeat that allows zero '
331
+ + 'items — so the arm can also start with whatever comes AFTER it, and that is broad '
332
+ + 'enough that no character rules the arm out.\n'
333
+ + 'To fix: give the optional part its own arm so each arm has a definite beginning, or '
334
+ + 'require at least one item. A plain sepBy(item, sep) also matches nothing at all; pass '
335
+ + '{ min: 1 } when the list must not be empty.',
336
+ 'cross-artifact-ref':
337
+ 'This arm hands off to another rule, and that rule has the same problem — it can begin '
338
+ + 'with any character — so the cost is inherited rather than caused here.\n'
339
+ + 'To fix: run this check on the rule it refers to and fix it there. One rule given a '
340
+ + 'definite beginning fixes every choice that uses it.',
341
+ 'opaque-wrapper':
342
+ 'This arm begins with a wrapper that only inspects parser state (a gate/withCtx/recover) '
343
+ + 'and consumes no text, so it says nothing about which character the arm starts with.\n'
344
+ + 'To fix: put a term that actually matches text in front of the wrapper.',
345
+ 'ref-cycle':
346
+ 'This arm refers back to itself through a cycle of rules, and parseman could not work out '
347
+ + 'a definite first character for any of them.\n'
348
+ + 'To fix: make sure the base case of the recursion begins with a fixed character or '
349
+ + 'keyword, so the cycle has somewhere definite to start.',
350
+ }
351
+
352
+ /**
353
+ * The generic per-cause advice, sharpened by what the arm ACTUALLY leads with.
354
+ *
355
+ * One string per cause was quietly wrong in the common case. `broad-recognizer` covers
356
+ * both `regex(/@media/)` — which `word()` fixes outright — and `regex(/[^()]+/)`, a
357
+ * genuine catch-all scanner that no primitive can gate and where the keyword advice is
358
+ * noise the reader has to discard. Telling someone "if this is X, do Y" when the tool
359
+ * can already see whether it is X is exactly the gap between advice and a diagnostic.
360
+ */
361
+ function refineSuggestion(cause: FirstSetCause, arm: Combinator<unknown>): string {
362
+ if (cause !== 'broad-recognizer') return SUGGESTIONS[cause]
363
+ const lead = peelToLeading(arm)
364
+ const d = lead._def as ParserDef
365
+ if (d.tag === 'regex' && KEYWORD_REGEX_RE.test(d.source)) {
366
+ return `This arm matches the fixed word \`${d.source}\` with a regular expression, and parseman `
367
+ + 'cannot always tell from a regular expression which character it starts with.\n'
368
+ + "To fix: write it as word('…') or keywords([…]) instead. They match exactly the same text and "
369
+ + 'compile to the same character scan, but they also tell parseman the first character, which is '
370
+ + 'what lets the parser skip this arm when it cannot match.'
371
+ }
372
+ if (d.tag === 'scanTo') {
373
+ return 'This arm ends in a scanTo(...) catch-all, which reads forward until it finds something and '
374
+ + 'so can begin at any character. The parser can never rule it out. That is usually exactly what '
375
+ + 'you want from a fallback.\n'
376
+ + 'To fix: usually nothing. Add this choice to the accept list at the end so the check keeps '
377
+ + 'flagging the choices that are real problems.'
378
+ }
379
+ return SUGGESTIONS[cause]
380
+ }
381
+
382
+ /** The classifier's verdict for one arm: the poison, and whether it is a hole that
383
+ * only a downstream fuse can fill (`unresolvedExternal`). */
384
+ type ArmCause = { cause: FirstSetCause; detail: string; unresolvedExternal?: boolean }
385
+
386
+ function classifyBroadArm(arm: Combinator<unknown>, resolve?: RefResolver): ArmCause {
387
+ const seen = new Set<Combinator<unknown>>()
388
+ /** Re-label an inner verdict, carrying its `unresolvedExternal` through unchanged —
389
+ * the cause changes, but who can FIX it does not. */
390
+ const relabel = (inner: ArmCause | null, cause: FirstSetCause, detail: string): ArmCause =>
391
+ ({ cause, detail, ...(inner?.unresolvedExternal ? { unresolvedExternal: true } : {}) })
392
+ const walk = (p: Combinator<unknown>): ArmCause | null => {
393
+ if (seen.has(p)) return { cause: 'ref-cycle', detail: 'ref cycle' }
394
+ seen.add(p)
395
+ const d = p._def as ParserDef
396
+ switch (d.tag) {
397
+ case 'literal': case 'regex': case 'keywords':
398
+ return isAny(p._meta.firstSet) ? { cause: 'broad-recognizer', detail: `broad recognizer (${d.tag})` } : null
399
+ case 'not':
400
+ return { cause: 'leading-not', detail: 'leading not(...) (first-set ANY)' }
401
+ // peek() CARRIES its body's first-set, so it is only broad when the body is
402
+ // (or the body is nullable) — walk in for the real cause.
403
+ case 'peek':
404
+ return walk(d.parser)
405
+ case 'scanTo':
406
+ return { cause: 'broad-recognizer', detail: 'scanTo (any first char)' }
407
+ // `guard` (the `gate()` state predicate) has a genuinely-`any` first-set of
408
+ // its own — it is the poison. `withCtx`/`recover` FORWARD their inner
409
+ // first-set (see firstSetOf), so a broad result comes from the inner parser:
410
+ // walk into it for a precise cause.
411
+ case 'guard':
412
+ return { cause: 'opaque-wrapper', detail: `opaque wrapper (${d.tag})` }
413
+ // Zero-width and dropped from a sequence's first-set (isZeroWidthAssertion),
414
+ // so it is never the reason an arm is broad — but it can be reached as the
415
+ // sole term of a degenerate arm. Report it precisely rather than as a wrapper.
416
+ case 'adjacency':
417
+ return { cause: 'broad-recognizer', detail: `${d.polarity}() (zero-width assertion)` }
418
+ // attempt/withCtx/recover FORWARD their inner first-set (transparent for
419
+ // dispatch), so a broad result comes from the inner parser — walk into it.
420
+ case 'attempt': case 'withCtx': case 'recover':
421
+ return walk(d.parser)
422
+ case 'lazy': {
423
+ const name = (p as unknown as { _ruleName?: string })._ruleName
424
+ let target: Combinator<unknown>
425
+ try { target = (d as { thunk(): Combinator<unknown> }).thunk() }
426
+ catch {
427
+ // A NAMED ref the fuse resolver can bind is analyzed against its real body;
428
+ // otherwise the hole itself is the poison — and it is only the AUTHOR's
429
+ // problem when it is unnamed (nothing can ever bind it by name).
430
+ const bound = name !== undefined ? resolve?.(name) : undefined
431
+ if (bound === undefined)
432
+ return { cause: 'cross-artifact-ref', detail: `unresolved ref${name ? ` g.${name}` : ''}`, unresolvedExternal: name !== undefined }
433
+ target = bound
434
+ }
435
+ const inner = walk(target)
436
+ if (name !== undefined) return inner ? relabel(inner, 'cross-artifact-ref', `via ref g.${name} → ${inner.detail}`) : null
437
+ return inner
438
+ }
439
+ case 'optional': case 'many': {
440
+ const inner = walk(d.parser)
441
+ return relabel(inner, 'nullable-prefix', inner ? `nullable prefix → ${inner.detail}` : 'nullable prefix to broad term')
442
+ }
443
+ case 'oneOrMore': case 'transform': case 'label': case 'field':
444
+ case 'trivia': case 'token': case 'leaf': case 'node': case 'grammar': case 'expect':
445
+ return walk(d.parser)
446
+ case 'sequence': {
447
+ // Scan the nullable prefix the way sequenceFirstSet does: a `not(...)` or a
448
+ // FINITE nullable term (optional/many/nullable regex) is skipped so a LATER
449
+ // term's first chars can start the sequence. Stop at the first term that is
450
+ // non-nullable (it gates → return null) or broad (it's the poison).
451
+ let sawNullablePrefix = false
452
+ for (const t of d.parsers) {
453
+ if ((t._def as ParserDef).tag === 'not') { sawNullablePrefix = true; continue }
454
+ if (isAny(firstSetOf(t, new Set(), resolve))) {
455
+ const inner = walk(t)
456
+ if (sawNullablePrefix)
457
+ return relabel(inner, 'nullable-prefix', inner ? `nullable prefix → ${inner.detail}` : 'nullable prefix to broad term')
458
+ return inner ?? { cause: 'broad-recognizer', detail: 'sequence leading term is broad' }
459
+ }
460
+ if (matchesEmpty(t, new Set(), resolve)) { sawNullablePrefix = true; continue } // finite but nullable → keep scanning
461
+ return null // finite, non-nullable → this term gates the sequence
462
+ }
463
+ return { cause: 'broad-recognizer', detail: 'sequence of only nullable/zero-width terms' }
464
+ }
465
+ case 'choice': {
466
+ for (const a of d.parsers) { const r = walk(a); if (r) return relabel(r, r.cause, `choice arm → ${r.detail}`) }
467
+ return null
468
+ }
469
+ case 'dispatch':
470
+ return walk(d.selector)
471
+ default:
472
+ return { cause: 'broad-recognizer', detail: `unmodeled construct (${d.tag})` }
473
+ }
474
+ }
475
+ return walk(arm) ?? { cause: 'broad-recognizer', detail: 'broad (cause not localized)' }
476
+ }
477
+
478
+ // ── anti-pattern detection ──
479
+
480
+ /** Peel non-consuming wrappers to the arm's leading term (mirrors leadingTermOfArm). */
481
+ export function peelToLeading(arm: Combinator<unknown>): Combinator<unknown> {
482
+ let d = arm._def as ParserDef
483
+ let cur = arm
484
+ for (;;) {
485
+ if (d.tag === 'node' || d.tag === 'grammar' || d.tag === 'transform' || d.tag === 'label' || d.tag === 'attempt') {
486
+ cur = (d as { parser: Combinator<unknown> }).parser
487
+ d = cur._def as ParserDef
488
+ continue
489
+ }
490
+ break
491
+ }
492
+ if (d.tag === 'sequence' && d.parsers.length >= 1) return d.parsers[0]!
493
+ return cur
494
+ }
495
+
496
+ function detectAntiPatterns(rule: string, arms: readonly Combinator<unknown>[]): AntiPattern[] {
497
+ const out: AntiPattern[] = []
498
+ arms.forEach((arm, i) => {
499
+ const lead = peelToLeading(arm)
500
+ const ld = lead._def as ParserDef
501
+ // (a) not(not(...)) — hand-rolled first-char gating that MISCOMPILES among
502
+ // sibling arms sharing a first char.
503
+ if (ld.tag === 'not') {
504
+ const inner = (ld as { parser: Combinator<unknown> }).parser
505
+ if ((inner._def as ParserDef).tag === 'not') {
506
+ out.push({ kind: 'double-not', rule, armIndex: i,
507
+ message: 'not(not(X)) is a hand-written way of saying "X must come next, but do not consume it". '
508
+ + 'parseman does that for you, and the hand-written form is worse than nothing here: because '
509
+ + 'not(...) reveals no first character, the arm cannot be skipped, and among sibling arms that '
510
+ + 'share a first character it can select the wrong one.\n'
511
+ + 'To fix: write peek(X). It checks the same thing without consuming, and it also tells '
512
+ + 'parseman that the arm starts with whatever X starts with.' })
513
+ } else {
514
+ out.push({ kind: 'leading-not', rule, armIndex: i,
515
+ message: 'This arm begins with not(...), which matches no text and so reveals no first '
516
+ + 'character. The parser therefore cannot skip the arm and enters it at every position.\n'
517
+ + 'To fix: put the term that actually consumes text first, and keep not(...) after it as a '
518
+ + 'trailing boundary check.' })
519
+ }
520
+ }
521
+ // (b) bare leading regex(/keyword/) — word()/keywords() would give an exact,
522
+ // resolvable first-set (and lower identically).
523
+ if (ld.tag === 'regex' && KEYWORD_REGEX_RE.test(ld.source)) {
524
+ out.push({ kind: 'keyword-regex', rule, armIndex: i,
525
+ message: `This arm starts with the fixed word \`${ld.source}\` written as a regular expression. `
526
+ + "Writing it as word('…') or keywords([…]) matches exactly the same text and compiles to the "
527
+ + 'same character scan, but also tells parseman the first character, which is what lets the '
528
+ + 'parser skip the arm when it cannot match.' })
529
+ }
530
+ })
531
+ return out
532
+ }
533
+
534
+ // ── the walk ──
535
+
536
+ export function analyzeGating(entry: Combinator<unknown>, opts?: AnalyzeGatingOptions): GatingReport {
537
+ return analyzeGatingRules([[ruleNameOf(entry) ?? opts?.entryName ?? '<entry>', entry]], opts)
538
+ }
539
+
540
+ /**
541
+ * Multi-root variant: analyze a WHOLE `rules()` map in one walk, so every choice
542
+ * is attributed to the rule that owns it.
543
+ *
544
+ * The macro build compiles a grammar through `compileRuleMap`/`compileLinkable`,
545
+ * never through the single-entry `compile()`. Analyzing one entry at a time (or
546
+ * not at all) is what produced unnamed `choice @ <entry>` warnings and ZERO
547
+ * anti-patterns for grammars that in fact have dozens: an unnamed warning in a
548
+ * multi-thousand-line grammar is unactionable. Seeding the walk with EVERY named
549
+ * root is what recovers the rule names — and, because the walk is shared, each
550
+ * choice is still analyzed exactly once however many rules reach it.
551
+ */
552
+ export function analyzeGatingRules(
553
+ ruleMap: ReadonlyArray<readonly [string, Combinator<unknown>]>,
554
+ opts?: AnalyzeGatingOptions,
555
+ ): GatingReport {
556
+ const raw: { g: Omit<ChoiceGating, 'id' | 'accepted'>; rule: string; arms: readonly Combinator<unknown>[] }[] = []
557
+ const antiPatterns: AntiPattern[] = []
558
+ const unanalysable: Unanalysable[] = []
559
+ const seenUnanalysable = new Set<string>()
560
+ const visited = new Set<Combinator<unknown>>()
561
+
562
+ const noteUnanalysable = (u: Unanalysable): void => {
563
+ const key = `${u.rule}\u0000${u.kind}`
564
+ if (seenUnanalysable.has(key)) return
565
+ seenUnanalysable.add(key)
566
+ unanalysable.push(u)
567
+ }
568
+
569
+ const visit = (p: Combinator<unknown>, enclosingRule: string): void => {
570
+ if (visited.has(p)) return
571
+ visited.add(p)
572
+ // A grammar VALUE that carries no combinator descriptor cannot be walked. The
573
+ // dominant case is a `compose()` result: fusion lowers every rule to an executable
574
+ // function, discarding the combinator graph. Record it — never throw, and never
575
+ // let the caller mistake the resulting empty walk for a clean grammar.
576
+ const def = (p as { _def?: unknown } | null | undefined)?._def
577
+ if (def === null || typeof def !== 'object') {
578
+ noteUnanalysable(
579
+ typeof p === 'function'
580
+ ? {
581
+ rule: enclosingRule, kind: 'fused-rule',
582
+ reason: 'fused rule function — a compose()/fuse result has no combinator graph. '
583
+ + 'Analyze the composed grammar itself (analyzeGrammarGating) so the carried IR is re-lowered first.',
584
+ }
585
+ : {
586
+ rule: enclosingRule, kind: 'not-a-combinator',
587
+ reason: `value of type ${p === null ? 'null' : typeof p} is not a combinator and carries no _def descriptor.`,
588
+ },
589
+ )
590
+ return
591
+ }
592
+ const d = def as ParserDef
593
+ const rule = ruleNameOf(p) ?? enclosingRule
594
+ if (d.tag === 'choice') {
595
+ raw.push({ g: analyzeChoice(p, d, rule, opts?.resolveRef), rule, arms: d.parsers })
596
+ antiPatterns.push(...detectAntiPatterns(rule, d.parsers))
597
+ }
598
+ // Structural recursion (+ through refs once). Keep the child inventory in
599
+ // one place: choice-cost, duplication and dependency analysis use the same
600
+ // authored edge order, including matcher arms, grammar trivia, and routed
601
+ // fallbacks.
602
+ const kids = [...childrenOf(d)]
603
+ // Deliberately NOT `resolveRef`-aware: the WALK must visit the same choices with
604
+ // and without a resolver, so a choice's `id` (per-rule occurrence order) is the
605
+ // same in both passes — that identity is what lets the fuse-time diagnostic report
606
+ // exactly the choices its authoring site deferred. Nothing is lost: every rule of
607
+ // the fused map is its own seed below, so a rule reached only through a hole is
608
+ // still analyzed, under its own name.
609
+ if (d.tag === 'lazy') { try { kids.push((d as { thunk(): Combinator<unknown> }).thunk()) } catch { /* unresolved */ } }
610
+ for (const k of kids) visit(k, rule)
611
+ }
612
+
613
+ // Seed with the map's OWN names first: a rule root is usually a `ref()` that
614
+ // already carries `_ruleName`, but a plain (untagged) root would otherwise
615
+ // inherit whichever rule reached it first.
616
+ for (const [name, root] of ruleMap) visit(root, ruleNameOf(root) ?? name)
617
+
618
+ // Assign a stable per-choice id: bare rule name when unique in the rule, else
619
+ // `rule#N` (occurrence order). This is the key the accepted allowlist uses.
620
+ const perRule = new Map<string, number>()
621
+ for (const r of raw) perRule.set(r.rule, (perRule.get(r.rule) ?? 0) + 1)
622
+ const seenInRule = new Map<string, number>()
623
+ const accept = new Set(opts?.accept ?? [])
624
+ const usedAccept = new Set<string>()
625
+ const choices: ChoiceGating[] = raw.map(({ g, rule, arms }) => {
626
+ const n = seenInRule.get(rule) ?? 0
627
+ seenInRule.set(rule, n + 1)
628
+ const id = (perRule.get(rule) ?? 1) === 1 ? rule : `${rule}#${n}`
629
+ const isAccepted = g.gates === 'no' && accept.has(id)
630
+ if (isAccepted) usedAccept.add(id)
631
+ const out: ChoiceGating = { ...g, id, accepted: isAccepted }
632
+ // The arms themselves, carried for a RENDERER that wants to show the ordering with a
633
+ // dispatch key beside each arm. NON-ENUMERABLE deliberately: `GatingReport` is
634
+ // JSON-serialized into a committed, byte-identity-tested snapshot, and combinators are
635
+ // cyclic graphs full of closures. Attaching them here rather than letting a caller
636
+ // re-walk the grammar is what keeps a second walk from drifting out of step with this
637
+ // one's `id` assignment — which it did, silently, and mislabelled every arm.
638
+ Object.defineProperty(out, 'arms', { value: arms, enumerable: false, writable: false })
639
+ return out
640
+ })
641
+
642
+ const gated = choices.filter(c => c.gates === 'yes').length
643
+ const recoverable = choices.filter(c => c.gates === 'recoverable').length
644
+ const ungated = choices.filter(c => c.gates === 'no' && !c.accepted && !c.deferred)
645
+ const accepted = choices.filter(c => c.gates === 'no' && c.accepted)
646
+ const deferred = choices.filter(c => c.gates === 'no' && !c.accepted && c.deferred)
647
+ const acceptedUnused = [...accept].filter(id => !usedAccept.has(id))
648
+ return {
649
+ totalChoices: choices.length, gated, recoverable, unanalysable,
650
+ ungated, accepted, deferred, acceptedUnused, choices, antiPatterns,
651
+ }
652
+ }
653
+
654
+ // analyzeChoice returns a ChoiceGating WITHOUT id/accepted — analyzeGating assigns
655
+ // those after the full walk (id needs per-rule counts; accepted needs the allowlist).
656
+ function analyzeChoice(
657
+ p: Combinator<unknown>,
658
+ d: Extract<ParserDef, { tag: 'choice' }>,
659
+ rule: string,
660
+ resolve?: RefResolver,
661
+ ): Omit<ChoiceGating, 'id' | 'accepted'> {
662
+ const arms = d.parsers
663
+ const shallow = arms.map(a => a._meta.firstSet)
664
+ const deep = arms.map(a => firstSetOf(a, new Set(), resolve))
665
+
666
+ const anyArms: AnyArm[] = deep
667
+ .map((fs, index) => ({ fs, index }))
668
+ .filter(x => isAny(x.fs))
669
+ .map(({ index }) => {
670
+ const { cause, detail, unresolvedExternal } = classifyBroadArm(arms[index]!, resolve)
671
+ return {
672
+ index, cause, detail, shallowAnyOnly: false,
673
+ unresolvedExternal: unresolvedExternal === true,
674
+ suggestion: refineSuggestion(cause, arms[index]!),
675
+ }
676
+ })
677
+
678
+ const overlaps: Overlap[] = []
679
+ for (let i = 0; i < deep.length; i++)
680
+ for (let j = i + 1; j < deep.length; j++)
681
+ if (!isAny(deep[i]!) && !isAny(deep[j]!) && intersects(deep[i]!, deep[j]!))
682
+ overlaps.push({ a: i, b: j, on: intersection(deep[i]!, deep[j]!),
683
+ suggestion: 'Two arms of this choice can begin with the same character, so the parser cannot '
684
+ + 'tell from that character which one to try. It tries them in order and undoes the ones that '
685
+ + 'do not match.\n'
686
+ + 'To fix: pull the shared beginning out in front of the choice so it is matched once — '
687
+ + 'sequence(shared, choice(rest…)) — instead of repeating it inside each arm. parseman '
688
+ + 'recognises that shape automatically and turns it back into a single test.' })
689
+
690
+ // Classification. `disjoint` (from construction, shallow) ⇒ O(1) dispatch.
691
+ // Otherwise, if the DEEP (ref-resolved) arms are all-finite and pairwise
692
+ // disjoint, monolithic compile still first-char-guards each arm (codegen emits
693
+ // firstSetOf() per-arm guards) — 'recoverable', not a cliff. Else 'no'.
694
+ let gates: ChoiceGating['gates']
695
+ if (d.disjoint) gates = 'yes'
696
+ else if (anyArms.length === 0 && overlaps.length === 0) gates = 'recoverable'
697
+ else gates = 'no'
698
+
699
+ // Ungated SOLELY because of holes this artifact can't fill: nothing here is
700
+ // actionable and the configuration described never runs. A finite-arm overlap is a
701
+ // real local finding, so it keeps the choice reportable even alongside a hole.
702
+ const deferred = gates === 'no' && overlaps.length === 0 && anyArms.length > 0 && anyArms.every(a => a.unresolvedExternal)
703
+
704
+ return {
705
+ rule,
706
+ strategy: ((d.strategy as { tag: ChoiceStrategyTag } | undefined)?.tag) ?? 'firstMatch',
707
+ gates,
708
+ deferred,
709
+ combinedFirstSet: { shallow: combine(shallow), deep: combine(deep) },
710
+ anyArms, overlaps,
711
+ }
712
+ }
713
+
714
+ // ── warning formatting ──
715
+
716
+ /**
717
+ * Format the genuinely-ungated findings + anti-patterns as ready-to-print lines.
718
+ * Precise by design: only 'no'-gated choices NOT in the accepted allowlist, plus
719
+ * the anti-pattern lints. Recoverable / gated / accepted / DEFERRED choices produce
720
+ * nothing (a deferred choice's verdict belongs to the fusing artifact, not here).
721
+ */
722
+ export function formatGatingWarnings(report: GatingReport): string[] {
723
+ const lines: string[] = []
724
+ // FIRST, and unconditionally: a partial walk must never present as a clean one.
725
+ // These lines are emitted even when there are no findings at all, because "no
726
+ // findings" over an unanalysable grammar is precisely the failure this reports.
727
+ if (report.unanalysable.length > 0) {
728
+ lines.push(
729
+ `parseman gating: ${report.unanalysable.length} rule(s) UNANALYSABLE — this report is PARTIAL `
730
+ + `(${report.totalChoices} choice(s) examined). An empty finding list below does NOT mean the grammar is clean.`,
731
+ )
732
+ for (const u of report.unanalysable)
733
+ lines.push(` · ${u.rule} [${u.kind}]: ${u.reason}`)
734
+ }
735
+ for (const c of report.ungated) {
736
+ lines.push(`parseman gating: choice @ ${c.id} is UNGATED [${c.strategy}] — no first-char dispatch; every position speculatively enters doomed arms.`)
737
+ for (const a of c.anyArms)
738
+ lines.push(` · arm[${a.index}] first-set ANY (${a.cause}): ${a.detail}\n fix: ${a.suggestion}`)
739
+ for (const o of c.overlaps)
740
+ lines.push(` · arm[${o.a}] ∩ arm[${o.b}] overlap on ${firstSetToString(o.on)}\n fix: ${o.suggestion}`)
741
+ lines.push(` (intentional? accept it in the gating snapshot: { accept: ['${c.id}'] }.)`)
742
+ }
743
+ for (const ap of report.antiPatterns)
744
+ lines.push(`parseman anti-pattern [${ap.kind}] @ ${ap.rule} arm[${ap.armIndex}]: ${ap.message}`)
745
+ return lines
746
+ }
747
+
748
+ /**
749
+ * Dependency manifest for a rule map: for each rule, the set of OTHER rule names
750
+ * its body references. A referenced rule is a BOUNDARY — record the edge and do
751
+ * NOT descend into it (its own deps are its own entry). Self-references are
752
+ * included, because a recursive rule does depend on itself.
753
+ *
754
+ * Used for a la carte dep-closure selection (`pick`) and the compose-time name
755
+ * closure check. This lives here rather than in a lowering because it is a walk
756
+ * over the COMBINATOR GRAPH and has nothing to do with how that graph is lowered
757
+ * — it outlived the source lowering it was first written inside.
758
+ */
759
+ export function ruleDependencies(
760
+ ruleMap: ReadonlyArray<readonly [string, Combinator<unknown>]>,
761
+ ): Map<string, string[]> {
762
+ const nameOf = new Map<Combinator<unknown>, string>()
763
+ for (const [name, comb] of ruleMap) nameOf.set(comb, name)
764
+
765
+ const deps = new Map<string, string[]>()
766
+ for (const [name, comb] of ruleMap) {
767
+ const found = new Set<string>()
768
+ const seen = new Set<Combinator<unknown>>()
769
+ const walk = (p: Combinator<unknown>, isRoot: boolean): void => {
770
+ const def = p._def
771
+ // `_ruleName` (set by `rules()`) also catches EXTERNAL refs — rules referenced
772
+ // by name but defined in another artifact — which is what makes the closure
773
+ // correct across a composition boundary.
774
+ const boundary = def.tag === 'lazy'
775
+ ? (nameOf.get(p) ?? (p as unknown as { _ruleName?: string })._ruleName)
776
+ : nameOf.get(p)
777
+ if (!isRoot && boundary !== undefined) { found.add(boundary); return }
778
+ if (seen.has(p)) return
779
+ seen.add(p)
780
+ if (def.tag === 'lazy') {
781
+ let resolved: Combinator<unknown>
782
+ try { resolved = def.thunk() } catch { return }
783
+ walk(resolved, false)
784
+ return
785
+ }
786
+ for (const child of childrenOf(def)) walk(child, false)
787
+ }
788
+ walk(comb, true)
789
+ deps.set(name, [...found])
790
+ }
791
+ return deps
792
+ }