parseman 0.46.0 → 0.47.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (316) hide show
  1. package/README.md +8 -3
  2. package/dist/analysis/choice-cost-render.d.ts +12 -4
  3. package/dist/analysis/choice-cost-render.d.ts.map +1 -1
  4. package/dist/analysis/choice-cost.d.ts +2 -2
  5. package/dist/analysis/choice-cost.d.ts.map +1 -1
  6. package/dist/analysis/commitment.d.ts +131 -0
  7. package/dist/analysis/commitment.d.ts.map +1 -0
  8. package/dist/analysis/diagnostics.cjs +10339 -7787
  9. package/dist/analysis/diagnostics.cjs.map +3 -4
  10. package/dist/analysis/diagnostics.d.ts +2 -2
  11. package/dist/analysis/diagnostics.js +10339 -7787
  12. package/dist/analysis/diagnostics.js.map +3 -4
  13. package/dist/analysis/duplication.d.ts.map +1 -1
  14. package/dist/analysis/fix.d.ts +3 -3
  15. package/dist/analysis/fix.d.ts.map +1 -1
  16. package/dist/analysis/format-number.d.ts +23 -0
  17. package/dist/analysis/format-number.d.ts.map +1 -0
  18. package/dist/analysis/gating.d.ts +39 -2
  19. package/dist/analysis/gating.d.ts.map +1 -1
  20. package/dist/analysis/rebuild.d.ts.map +1 -1
  21. package/dist/analysis/terminal.d.ts +5 -2
  22. package/dist/analysis/terminal.d.ts.map +1 -1
  23. package/dist/cli/index.cjs +10924 -8506
  24. package/dist/cli/index.cjs.map +3 -4
  25. package/dist/cli/index.js +10924 -8506
  26. package/dist/cli/index.js.map +3 -4
  27. package/dist/combinators/adjacency.d.ts +100 -0
  28. package/dist/combinators/adjacency.d.ts.map +1 -0
  29. package/dist/combinators/choice.d.ts.map +1 -1
  30. package/dist/combinators/completions.d.ts.map +1 -1
  31. package/dist/combinators/dispatch.d.ts +8 -1
  32. package/dist/combinators/dispatch.d.ts.map +1 -1
  33. package/dist/combinators/expect.d.ts.map +1 -1
  34. package/dist/combinators/first-set.d.ts.map +1 -1
  35. package/dist/combinators/gate.d.ts.map +1 -1
  36. package/dist/combinators/grammar.d.ts +22 -0
  37. package/dist/combinators/grammar.d.ts.map +1 -1
  38. package/dist/combinators/literal.d.ts +8 -0
  39. package/dist/combinators/literal.d.ts.map +1 -1
  40. package/dist/combinators/map.d.ts +0 -1
  41. package/dist/combinators/map.d.ts.map +1 -1
  42. package/dist/combinators/node.d.ts +13 -3
  43. package/dist/combinators/node.d.ts.map +1 -1
  44. package/dist/combinators/parser.d.ts +1 -1
  45. package/dist/combinators/parser.d.ts.map +1 -1
  46. package/dist/combinators/ref.d.ts.map +1 -1
  47. package/dist/combinators/regex.d.ts +1 -16
  48. package/dist/combinators/regex.d.ts.map +1 -1
  49. package/dist/combinators/repeat.d.ts +30 -2
  50. package/dist/combinators/repeat.d.ts.map +1 -1
  51. package/dist/combinators/scanTo.d.ts +71 -1
  52. package/dist/combinators/scanTo.d.ts.map +1 -1
  53. package/dist/combinators/sequence.d.ts.map +1 -1
  54. package/dist/combinators/token.d.ts.map +1 -1
  55. package/dist/combinators/trivia-boundary.d.ts +26 -0
  56. package/dist/combinators/trivia-boundary.d.ts.map +1 -0
  57. package/dist/combinators/trivia-skip.d.ts +45 -1
  58. package/dist/combinators/trivia-skip.d.ts.map +1 -1
  59. package/dist/combinators/withCtx.d.ts.map +1 -1
  60. package/dist/compiler/compile-linkable-table.d.ts +104 -0
  61. package/dist/compiler/compile-linkable-table.d.ts.map +1 -0
  62. package/dist/compiler/degradation.d.ts +0 -2
  63. package/dist/compiler/degradation.d.ts.map +1 -1
  64. package/dist/compiler/fields.d.ts +13 -0
  65. package/dist/compiler/fields.d.ts.map +1 -1
  66. package/dist/compiler/grammar-coverage-ids.d.ts +5 -5
  67. package/dist/compiler/grammar-coverage-ids.d.ts.map +1 -1
  68. package/dist/compiler/ir-serialize.d.ts.map +1 -1
  69. package/dist/compiler/linker.d.ts +47 -53
  70. package/dist/compiler/linker.d.ts.map +1 -1
  71. package/dist/compiler/token-alphabet.d.ts +81 -0
  72. package/dist/compiler/token-alphabet.d.ts.map +1 -0
  73. package/dist/compiler/token-dispatch.d.ts +95 -0
  74. package/dist/compiler/token-dispatch.d.ts.map +1 -0
  75. package/dist/compiler/token-scanner.d.ts +53 -0
  76. package/dist/compiler/token-scanner.d.ts.map +1 -0
  77. package/dist/compiler/value-usage.d.ts.map +1 -1
  78. package/dist/coverage.d.ts +1 -1
  79. package/dist/coverage.d.ts.map +1 -1
  80. package/dist/cst/capture-buffer.d.ts +35 -0
  81. package/dist/cst/capture-buffer.d.ts.map +1 -1
  82. package/dist/cst/reflection-attach.d.ts +8 -0
  83. package/dist/cst/reflection-attach.d.ts.map +1 -0
  84. package/dist/cst/reflection-symbols.d.ts +10 -0
  85. package/dist/cst/reflection-symbols.d.ts.map +1 -0
  86. package/dist/cst/reflection.d.ts +2 -4
  87. package/dist/cst/reflection.d.ts.map +1 -1
  88. package/dist/cst/root-trivia-scope.d.ts +26 -0
  89. package/dist/cst/root-trivia-scope.d.ts.map +1 -0
  90. package/dist/cst/trivia-charscan.d.ts +18 -0
  91. package/dist/cst/trivia-charscan.d.ts.map +1 -0
  92. package/dist/cst/trivia-kinds.d.ts +19 -0
  93. package/dist/cst/trivia-kinds.d.ts.map +1 -1
  94. package/dist/functional/doc.d.ts.map +1 -1
  95. package/dist/functional/run-tabled.d.ts +12 -0
  96. package/dist/functional/run-tabled.d.ts.map +1 -0
  97. package/dist/functional/run.d.ts +25 -42
  98. package/dist/functional/run.d.ts.map +1 -1
  99. package/dist/grammar-metadata.d.ts +3 -0
  100. package/dist/grammar-metadata.d.ts.map +1 -0
  101. package/dist/index.cjs +9290 -7181
  102. package/dist/index.cjs.map +3 -4
  103. package/dist/index.d.ts +18 -9
  104. package/dist/index.d.ts.map +1 -1
  105. package/dist/index.js +9300 -7191
  106. package/dist/index.js.map +3 -4
  107. package/dist/language-service/index.cjs +313 -121
  108. package/dist/language-service/index.cjs.map +3 -4
  109. package/dist/language-service/index.js +313 -121
  110. package/dist/language-service/index.js.map +3 -4
  111. package/dist/oracle/index.cjs.map +0 -1
  112. package/dist/oracle/index.js.map +0 -1
  113. package/dist/parse-context.d.ts +49 -0
  114. package/dist/parse-context.d.ts.map +1 -0
  115. package/dist/plugin/evaluator.d.ts +40 -1
  116. package/dist/plugin/evaluator.d.ts.map +1 -1
  117. package/dist/plugin/index.cjs +11175 -9534
  118. package/dist/plugin/index.cjs.map +3 -4
  119. package/dist/plugin/index.d.ts.map +1 -1
  120. package/dist/plugin/index.js +11175 -9534
  121. package/dist/plugin/index.js.map +3 -4
  122. package/dist/regex/classes.d.ts +28 -0
  123. package/dist/regex/classes.d.ts.map +1 -1
  124. package/dist/regex/first-set.d.ts +14 -0
  125. package/dist/regex/first-set.d.ts.map +1 -1
  126. package/dist/run/index.cjs +136 -51
  127. package/dist/run/index.cjs.map +2 -3
  128. package/dist/run/index.js +136 -51
  129. package/dist/run/index.js.map +2 -3
  130. package/dist/spec/index.cjs +2934 -1611
  131. package/dist/spec/index.cjs.map +3 -4
  132. package/dist/spec/index.js +2934 -1611
  133. package/dist/spec/index.js.map +3 -4
  134. package/dist/spec/model.d.ts +8 -0
  135. package/dist/spec/model.d.ts.map +1 -1
  136. package/dist/table/assemble.d.ts +307 -0
  137. package/dist/table/assemble.d.ts.map +1 -0
  138. package/dist/table/cell.d.ts +42 -0
  139. package/dist/table/cell.d.ts.map +1 -0
  140. package/dist/table/child-slots.d.ts +30 -0
  141. package/dist/table/child-slots.d.ts.map +1 -0
  142. package/dist/table/compile-rule-map.d.ts +136 -0
  143. package/dist/table/compile-rule-map.d.ts.map +1 -0
  144. package/dist/table/compile.d.ts +56 -0
  145. package/dist/table/compile.d.ts.map +1 -0
  146. package/dist/table/duplication-hook.d.ts +32 -0
  147. package/dist/table/duplication-hook.d.ts.map +1 -0
  148. package/dist/table/emit-assembly.d.ts +139 -0
  149. package/dist/table/emit-assembly.d.ts.map +1 -0
  150. package/dist/table/emit.d.ts +113 -0
  151. package/dist/table/emit.d.ts.map +1 -0
  152. package/dist/table/encode-baseline.d.ts +16 -0
  153. package/dist/table/encode-baseline.d.ts.map +1 -0
  154. package/dist/table/encode.d.ts +71 -0
  155. package/dist/table/encode.d.ts.map +1 -0
  156. package/dist/table/exec-baseline.d.ts +9 -0
  157. package/dist/table/exec-baseline.d.ts.map +1 -0
  158. package/dist/table/exec.d.ts +72 -0
  159. package/dist/table/exec.d.ts.map +1 -0
  160. package/dist/table/fold.d.ts +25 -0
  161. package/dist/table/fold.d.ts.map +1 -0
  162. package/dist/table/index.cjs +11463 -0
  163. package/dist/table/index.cjs.map +6 -0
  164. package/dist/table/index.d.ts +100 -0
  165. package/dist/table/index.d.ts.map +1 -0
  166. package/dist/table/index.js +11440 -0
  167. package/dist/table/index.js.map +6 -0
  168. package/dist/table/inspect.d.ts +17 -0
  169. package/dist/table/inspect.d.ts.map +1 -0
  170. package/dist/table/ops.d.ts +453 -0
  171. package/dist/table/ops.d.ts.map +1 -0
  172. package/dist/table/program.d.ts +496 -0
  173. package/dist/table/program.d.ts.map +1 -0
  174. package/dist/table/run-support.d.ts +38 -0
  175. package/dist/table/run-support.d.ts.map +1 -0
  176. package/dist/{compiler/scannable-run.d.ts → table/scan-shapes.d.ts} +14 -13
  177. package/dist/table/scan-shapes.d.ts.map +1 -0
  178. package/dist/table/site-labels.d.ts +58 -0
  179. package/dist/table/site-labels.d.ts.map +1 -0
  180. package/dist/table/stamp.d.ts +70 -0
  181. package/dist/table/stamp.d.ts.map +1 -0
  182. package/dist/types.d.ts +80 -24
  183. package/dist/types.d.ts.map +1 -1
  184. package/dist/version.d.ts +1 -1
  185. package/package.json +26 -2
  186. package/src/analysis/choice-cost-gate.ts +359 -0
  187. package/src/analysis/choice-cost-render.ts +293 -0
  188. package/src/analysis/choice-cost.ts +1008 -0
  189. package/src/analysis/commitment.ts +557 -0
  190. package/src/analysis/corpus.ts +130 -0
  191. package/src/analysis/diagnose-render.ts +625 -0
  192. package/src/analysis/diagnose.ts +317 -0
  193. package/src/analysis/diagnostics.ts +33 -0
  194. package/src/analysis/duplication.ts +1955 -0
  195. package/src/analysis/fix-render.ts +217 -0
  196. package/src/analysis/fix.ts +669 -0
  197. package/src/analysis/format-number.ts +31 -0
  198. package/src/analysis/gating.ts +792 -0
  199. package/src/analysis/grammar.ts +72 -0
  200. package/src/analysis/rebuild.ts +287 -0
  201. package/src/analysis/terminal.ts +336 -0
  202. package/src/cli/index.ts +413 -0
  203. package/src/combinators/adjacency.ts +171 -0
  204. package/src/combinators/attempt.ts +41 -0
  205. package/src/combinators/case-fold.ts +90 -0
  206. package/src/combinators/choice.ts +466 -0
  207. package/src/combinators/completions.ts +64 -0
  208. package/src/combinators/dispatch.ts +358 -0
  209. package/src/combinators/expect.ts +159 -0
  210. package/src/combinators/first-set.ts +348 -0
  211. package/src/combinators/gate.ts +50 -0
  212. package/src/combinators/grammar.ts +272 -0
  213. package/src/combinators/keywords.ts +145 -0
  214. package/src/combinators/literal.ts +102 -0
  215. package/src/combinators/map.ts +106 -0
  216. package/src/combinators/node.ts +345 -0
  217. package/src/combinators/not.ts +55 -0
  218. package/src/combinators/parser.ts +266 -0
  219. package/src/combinators/peek.ts +63 -0
  220. package/src/combinators/probe.ts +25 -0
  221. package/src/combinators/ref.ts +109 -0
  222. package/src/combinators/regex.ts +170 -0
  223. package/src/combinators/repeat.ts +561 -0
  224. package/src/combinators/scanTo.ts +393 -0
  225. package/src/combinators/sequence.ts +237 -0
  226. package/src/combinators/token.ts +120 -0
  227. package/src/combinators/trivia-boundary.ts +48 -0
  228. package/src/combinators/trivia-skip.ts +852 -0
  229. package/src/combinators/withCtx.ts +45 -0
  230. package/src/compiler/build-arity.ts +330 -0
  231. package/src/compiler/compile-linkable-table.ts +209 -0
  232. package/src/compiler/degradation.ts +266 -0
  233. package/src/compiler/fields.ts +191 -0
  234. package/src/compiler/grammar-coverage-ids.ts +129 -0
  235. package/src/compiler/ir-serialize.ts +615 -0
  236. package/src/compiler/linker.ts +954 -0
  237. package/src/compiler/token-alphabet.ts +209 -0
  238. package/src/compiler/token-dispatch.ts +353 -0
  239. package/src/compiler/token-scanner.ts +318 -0
  240. package/src/compiler/value-usage.ts +147 -0
  241. package/src/coverage.ts +383 -0
  242. package/src/cst/capture-buffer.ts +297 -0
  243. package/src/cst/host-mode.ts +69 -0
  244. package/src/cst/reflection-attach.ts +11 -0
  245. package/src/cst/reflection-symbols.ts +9 -0
  246. package/src/cst/reflection.ts +149 -0
  247. package/src/cst/relative-spans.ts +207 -0
  248. package/src/cst/root-trivia-scope.ts +30 -0
  249. package/src/cst/trivia-charscan.ts +312 -0
  250. package/src/cst/trivia-entries.ts +285 -0
  251. package/src/cst/trivia-index.ts +131 -0
  252. package/src/cst/trivia-kinds.ts +358 -0
  253. package/src/cst/types.ts +76 -0
  254. package/src/cst/walk.ts +101 -0
  255. package/src/functional/doc.ts +959 -0
  256. package/src/functional/run-tabled.ts +153 -0
  257. package/src/functional/run.ts +558 -0
  258. package/src/grammar-metadata.ts +2 -0
  259. package/src/index.ts +189 -0
  260. package/src/language-service/index.ts +221 -0
  261. package/src/line-index.ts +131 -0
  262. package/src/oracle/digest.ts +620 -0
  263. package/src/oracle/index.ts +55 -0
  264. package/src/parse-context.ts +91 -0
  265. package/src/plugin/direct-builder-static.ts +102 -0
  266. package/src/plugin/evaluator.ts +1505 -0
  267. package/src/plugin/free-identifiers.ts +338 -0
  268. package/src/plugin/index.ts +2281 -0
  269. package/src/plugin/reducer-resolver.ts +606 -0
  270. package/src/recovery/scan.ts +155 -0
  271. package/src/regex/classes.ts +183 -0
  272. package/src/regex/first-set.ts +316 -0
  273. package/src/run/index.ts +31 -0
  274. package/src/spec/ebnf.ts +128 -0
  275. package/src/spec/index.ts +54 -0
  276. package/src/spec/model.ts +636 -0
  277. package/src/spec/railroad-lib.ts +559 -0
  278. package/src/spec/railroad.ts +256 -0
  279. package/src/table/assemble.ts +3006 -0
  280. package/src/table/cell.ts +42 -0
  281. package/src/table/child-slots.ts +113 -0
  282. package/src/table/compile-rule-map.ts +336 -0
  283. package/src/table/compile.ts +244 -0
  284. package/src/table/duplication-hook.ts +89 -0
  285. package/src/table/emit-assembly.ts +1777 -0
  286. package/src/table/emit.ts +429 -0
  287. package/src/table/encode-baseline.ts +340 -0
  288. package/src/table/encode.ts +1569 -0
  289. package/src/table/exec-baseline.ts +537 -0
  290. package/src/table/exec.ts +1680 -0
  291. package/src/table/fold.ts +38 -0
  292. package/src/table/index.ts +108 -0
  293. package/src/table/inspect.ts +52 -0
  294. package/src/table/ops.ts +467 -0
  295. package/src/table/program.ts +876 -0
  296. package/src/table/run-support.ts +66 -0
  297. package/src/table/scan-shapes.ts +1578 -0
  298. package/src/table/site-labels.ts +228 -0
  299. package/src/table/stamp.ts +229 -0
  300. package/src/types.ts +563 -0
  301. package/src/version.ts +16 -0
  302. package/dist/compiler/codegen.d.ts +0 -334
  303. package/dist/compiler/codegen.d.ts.map +0 -1
  304. package/dist/compiler/inline-build.d.ts +0 -11
  305. package/dist/compiler/inline-build.d.ts.map +0 -1
  306. package/dist/compiler/inline-callback.d.ts +0 -21
  307. package/dist/compiler/inline-callback.d.ts.map +0 -1
  308. package/dist/compiler/line-index.d.ts +0 -2
  309. package/dist/compiler/line-index.d.ts.map +0 -1
  310. package/dist/compiler/module-hoist.d.ts +0 -106
  311. package/dist/compiler/module-hoist.d.ts.map +0 -1
  312. package/dist/compiler/scannable-run.d.ts.map +0 -1
  313. package/dist/compiler/scannable-terminal.d.ts +0 -23
  314. package/dist/compiler/scannable-terminal.d.ts.map +0 -1
  315. package/dist/compiler/trivia-fast-path.d.ts +0 -38
  316. package/dist/compiler/trivia-fast-path.d.ts.map +0 -1
@@ -0,0 +1,1955 @@
1
+ /**
2
+ * Static grammar-duplication / overlap / rewrite diagnostic.
3
+ *
4
+ * A parseman grammar is a COMBINATOR TREE, not source text — so the question
5
+ * "did I write this production twice?" is a structural one a machine answers
6
+ * exactly, and a reviewer answers badly. A few hundred productions is tens of
7
+ * thousands of pairs; nobody reads that. This module walks the same tree
8
+ * `analyzeGating` walks and reports nine families:
9
+ *
10
+ * 1. `duplicates` — subtrees that are structurally IDENTICAL, in ≥2 places.
11
+ * 2. `nearDuplicates` — subtrees identical except at ONE slot. This is the
12
+ * high-value case: a general rule cloned with one term
13
+ * swapped, where the fix is one production whose varying
14
+ * slot is a `choice`, not N copies of the scaffolding.
15
+ * 3. `regexFragments` — alternation runs re-spelled across several `regex()`
16
+ * terminals. Structural hashing cannot see inside a
17
+ * regex, so this is its own pass.
18
+ * 4. `regexClasses` — character classes re-spelled across terminals, and, more
19
+ * usefully, NEAR-identical ones with the drift shown side
20
+ * by side.
21
+ * 5. `overlaps` — `choice` arms whose first-sets intersect, reported as
22
+ * "these two arms, on these chars, sharing these leading
23
+ * terms" rather than gating's "dispatch failed".
24
+ * 6. `rewrites` — ALGEBRAIC simplifications: mechanically-derived exact
25
+ * rewrites (`choice(sequence(A,B), B)` → `sequence(
26
+ * optional(A), B)`), plus the dead-arm cases that are
27
+ * outright bugs.
28
+ * 7. `divergentNodes` — ONE `node()` type built by several structurally different
29
+ * productions that are nonetheless variants of one shape.
30
+ * 8. `structureLoss` — an earlier `choice` arm that FLATTENS what a later arm
31
+ * would have structured: same `node()` type, overlapping
32
+ * first-sets, and the earlier body contains no `node()`
33
+ * at all. The parse succeeds either way, so nothing else
34
+ * — not a test suite, not an output diff — reports it.
35
+ * The ordered, consequential half of `divergentNodes`.
36
+ * 9. `keywordRegexes` — hand-rolled keyword regexes (`regex(/not(?![-\w])/i)`)
37
+ * that should be `word()`/`keywords()`. Not a style note:
38
+ * `/i` without `/u` gets non-ASCII case folding wrong,
39
+ * and the fix that lives inside `keywords()` (see
40
+ * `combinators/case-fold.ts`) never reached the copies.
41
+ *
42
+ * ## What this CANNOT see
43
+ *
44
+ * Structural hashing is exactly that: STRUCTURAL. Two productions that accept the
45
+ * same language but are shaped differently — `many(x)` vs `optional(oneOrMore(x))`,
46
+ * a `regex` spelling of what another rule builds from combinators, a rule inlined
47
+ * once and referenced once — are invisible to `duplicates`/`nearDuplicates` unless
48
+ * they happen to fall into the `rewrites` algebra below. Equally, two subtrees that
49
+ * differ only in a `transform`/`node` CALLBACK are reported as distinct when the
50
+ * callbacks' source text differs, even if the functions are equivalent. This finds
51
+ * COPY-PASTE and mechanical redundancy. It does not find semantic redundancy, and a
52
+ * clean report is not a proof that a grammar has none.
53
+ *
54
+ * `analyzeDuplication()` / `analyzeDuplicationRules()` are the programmatic
55
+ * surface, mirroring `analyzeGating` / `analyzeGatingRules`. The compile-time
56
+ * wiring is OPT-IN (`compile(g, { duplication: 'warn' })` /
57
+ * `PARSEMAN_DUPLICATION=warn`) and runs on ALL THREE lowering paths — `compile`,
58
+ * `compileRuleMap` and `compileLinkable` — because the macro build takes the
59
+ * latter two and a diagnostic that only runs on the first is a diagnostic that
60
+ * reports zero findings forever.
61
+ */
62
+ import type { Combinator, FirstSet, ParserDef } from '../types.ts'
63
+ import { firstSetOf, type RefResolver } from '../combinators/first-set.ts'
64
+ import { childrenOf, firstSetToString, intersects } from './gating.ts'
65
+
66
+ // ── public shapes ────────────────────────────────────────────────────────────
67
+
68
+ /** Where a combinator sits: its owning rule, and the accessor path within it. */
69
+ export type Site = {
70
+ /** Nearest enclosing `_ruleName`, or the seed name it was reached under. */
71
+ rule: string
72
+ /** Structural path inside that rule, e.g. `choice[0] › node(Declaration) › seq[4]`. */
73
+ path: string
74
+ }
75
+
76
+ export const siteToString = (s: Site): string => (s.path === '' ? s.rule : `${s.rule} › ${s.path}`)
77
+
78
+ /** N structurally identical copies of one subtree. */
79
+ export type DuplicateFinding = {
80
+ kind: 'exact-duplicate'
81
+ id: string
82
+ /** Node count of the repeated subtree. */
83
+ size: number
84
+ /** How many DISTINCT combinator instances share the shape (shared-by-reference
85
+ * reuse is not duplication and is never counted here). */
86
+ count: number
87
+ /** Nodes removed by hoisting the shape to one shared const: `(count - 1) * size`. */
88
+ savings: number
89
+ /** One-line rendering of the repeated shape. */
90
+ shape: string
91
+ sites: Site[]
92
+ suggestion: string
93
+ }
94
+
95
+ /** N subtrees identical except at ONE slot. */
96
+ export type NearDuplicateFinding = {
97
+ kind: 'near-duplicate'
98
+ id: string
99
+ /** Node count shared by every member (the scaffolding). */
100
+ sharedSize: number
101
+ count: number
102
+ savings: number
103
+ /** The scaffolding with the varying slot rendered as `‹slot›`. */
104
+ shape: string
105
+ /** Path to the varying slot, relative to the members' root. */
106
+ slotPath: string
107
+ /** What each member puts in that slot, aligned with `sites`. */
108
+ variants: string[]
109
+ sites: Site[]
110
+ suggestion: string
111
+ }
112
+
113
+ /** An alternation run re-spelled across several `regex()` terminals. */
114
+ export type RegexFragmentFinding = {
115
+ kind: 'regex-fragment'
116
+ id: string
117
+ /** The shared run, as it appears in the sources (`>=|<=|=>|=<|=~|[<>=]`). */
118
+ fragment: string
119
+ /** Number of alternation branches in the run. */
120
+ branches: number
121
+ count: number
122
+ /** Characters removed by hoisting: `(count - 1) * fragment.length`. */
123
+ savings: number
124
+ /** The full source of each regex that carries it, aligned with `sites`. */
125
+ sources: string[]
126
+ sites: Site[]
127
+ suggestion: string
128
+ }
129
+
130
+ /** One spelling of a character class, and where it appears. */
131
+ export type RegexClassVariant = {
132
+ /** The class as written, including any `^`: `-_a-zA-Z0-9€-￿`. */
133
+ source: string
134
+ /** Members this spelling has that the cluster's most common spelling does not,
135
+ * and vice versa — the DRIFT, rendered `+a-f / -0-9`. */
136
+ delta: string
137
+ count: number
138
+ sites: Site[]
139
+ }
140
+
141
+ /**
142
+ * A character class (or boundary lookahead class) re-spelled across several
143
+ * `regex()` terminals — and, when the spellings are not identical, the drift
144
+ * between them.
145
+ */
146
+ export type RegexClassFinding = {
147
+ kind: 'regex-class'
148
+ id: string
149
+ /** The cluster's most common spelling. */
150
+ canonical: string
151
+ /** Every spelling in the cluster, most common first. Two variants side by side
152
+ * IS the finding: one of them is wrong and reading cannot tell you which. */
153
+ variants: RegexClassVariant[]
154
+ /** Distinct `regex()` terminals across the whole cluster. */
155
+ count: number
156
+ /** True when the cluster holds more than one spelling. */
157
+ drifted: boolean
158
+ /** The class's highest code point is U+FFFF and it was written as an explicit
159
+ * range — astral-plane characters fall outside it. */
160
+ bmpCeiling: boolean
161
+ suggestion: string
162
+ }
163
+
164
+ /** Two arms of one `choice` whose first-sets intersect. */
165
+ export type ArmOverlapFinding = {
166
+ kind: 'arm-overlap'
167
+ id: string
168
+ site: Site
169
+ a: number
170
+ b: number
171
+ /** The SHARED first characters (not the union). */
172
+ on: FirstSet
173
+ /** How many leading terms the two arms spell identically. */
174
+ sharedLeadingTerms: number
175
+ /** Rendering of those shared leading terms, when there are any. */
176
+ sharedPrefix: string | null
177
+ /** True when parseman's `sharedPrefix` choice strategy already recognizes the
178
+ * common prefix once at runtime — the finding is then about READABILITY, not
179
+ * speed. */
180
+ handledByStrategy: boolean
181
+ /** Both arms lead with a `regex()` whose character classes intersect — usually
182
+ * a sign the two arms want to be one terminal. */
183
+ regexPair: boolean
184
+ suggestion: string
185
+ }
186
+
187
+ export type RewriteKind =
188
+ | 'optional-prefix' // choice(sequence(A, R…), sequence(R…)) → sequence(optional(A), R…)
189
+ | 'optional-suffix' // choice(sequence(R…, A), sequence(R…)) → sequence(R…, optional(A))
190
+ | 'left-factor' // choice(sequence(A, B…), sequence(A, C…)) → sequence(A, choice(B…, C…))
191
+ | 'hand-rolled-sepby' // sequence(X, many(sequence(S, X))) → sepBy(X, S)
192
+ | 'idempotent-nesting' // optional(optional(X)), many(many(X)), …
193
+ | 'single-element' // choice(X) / sequence(X)
194
+ | 'duplicate-arm' // choice(…, X, …, X, …) — the later arm is dead
195
+ | 'shadowed-arm' // choice(…, X, …, sequence(X, …), …) — the later arm is dead
196
+
197
+ /** A mechanically-derived rewrite. */
198
+ export type RewriteFinding = {
199
+ kind: 'rewrite'
200
+ id: string
201
+ rewrite: RewriteKind
202
+ site: Site
203
+ /** What is there now. */
204
+ from: string
205
+ /** What it is equal to. */
206
+ to: string
207
+ /**
208
+ * `true` only when the rewrite provably cannot move the parse VALUE — which,
209
+ * here, means it only deletes an arm that can never be selected. Every other
210
+ * rewrite in this family changes the child arity or nesting of the value the
211
+ * site produces, so a `node()` build / `transform` / downstream consumer that
212
+ * reads positionally WILL see a different tree. Those are reported as
213
+ * CANDIDATES to verify, never as "fix this".
214
+ */
215
+ astNeutral: boolean
216
+ /** Non-empty when the rewrite removes speculative work at parse time. */
217
+ perf: string
218
+ /** Only on `hand-rolled-sepby`: whether this SITE can actually take `sepBy`. A
219
+ * count of matches is not a worklist — in a real grammar most matches are
220
+ * blocked, and reporting them all as convertible generates false work. */
221
+ sepByVerdict?: SepByVerdict
222
+ /** True when this is a latent BUG (an unreachable arm), not a verbosity smell. */
223
+ bug: boolean
224
+ suggestion: string
225
+ }
226
+
227
+ /**
228
+ * One AST node type built by two or more STRUCTURALLY DIFFERENT productions that
229
+ * are nonetheless variants of a single shape (they spell several of the same
230
+ * terms). This is the clone family near-duplicate detection cannot see: the copies
231
+ * diverge in more than one slot — extra scaffolding, a hand-rolled whitespace run,
232
+ * a terminal guard — so no single hole explains them, yet every edit to "the
233
+ * declaration shape" still has to land in all of them, and nothing checks that it did.
234
+ */
235
+ export type DivergentNodeFinding = {
236
+ kind: 'divergent-node'
237
+ id: string
238
+ /** The `node()` type every member produces. */
239
+ nodeType: string
240
+ /** Number of structurally distinct productions building it. */
241
+ count: number
242
+ /** Terms every member spells identically — the evidence they are one shape. */
243
+ sharedTerms: string[]
244
+ productions: {
245
+ shape: string
246
+ site: Site
247
+ /** Terms this production has that at least one sibling does not. */
248
+ distinctTerms: string[]
249
+ }[]
250
+ suggestion: string
251
+ }
252
+
253
+ /**
254
+ * An earlier `choice` arm that FLATTENS what a later arm would have STRUCTURED.
255
+ *
256
+ * Both arms build the same `node()` type and can start on the same character, so
257
+ * on the inputs both accept the earlier one wins — and its body contains no
258
+ * `node()` at all, so the tree it produces is that node over bare leaves. The
259
+ * later arm, on those same inputs, would have produced child nodes. Nothing about
260
+ * this is visible in a parse that succeeds: `ok` is true, the span is right, the
261
+ * text round-trips. Only the SHAPE moved, and only on the subset of inputs the
262
+ * flattening arm happens to accept — which is why it survives a test suite that
263
+ * checks "does it parse" and a corpus diff that checks emitted output. It shows
264
+ * up in whatever consumes the tree: a language service that lints number tokens,
265
+ * a formatter, a refactor.
266
+ *
267
+ * This is the ordered, consequential half of `divergent-node`. That finding says
268
+ * two productions build one type and nothing keeps them in sync, and it expressly
269
+ * allows "the variants exist for a parse-order reason (a fast path tried first)".
270
+ * This one is the case where that excuse is the bug: the fast path is not
271
+ * tree-neutral, and here is the list of node types it deletes.
272
+ */
273
+ export type StructureLossFinding = {
274
+ kind: 'structure-loss'
275
+ id: string
276
+ /** The `choice` that orders the two arms. */
277
+ site: Site
278
+ /** The `node()` type BOTH arms build. */
279
+ nodeType: string
280
+ /** Index of the flattening arm (the earlier one). */
281
+ earlier: number
282
+ /** Index of the structuring arm it shadows. */
283
+ later: number
284
+ /** The characters on which both arms can start — where the shadowing bites. */
285
+ on: FirstSet
286
+ /** Node types the later arm builds under `nodeType` and the earlier cannot. */
287
+ lostNodeTypes: string[]
288
+ earlierShape: string
289
+ laterShape: string
290
+ suggestion: string
291
+ }
292
+
293
+ /** A `regex()` that hand-rolls a keyword + word boundary. */
294
+ export type KeywordRegexFinding = {
295
+ kind: 'keyword-regex'
296
+ id: string
297
+ site: Site
298
+ source: string
299
+ flags: string
300
+ words: string[]
301
+ /** The boundary character class, as a `word()`/`keywords()` `boundary` argument.
302
+ * `null` when the regex enumerates a vocabulary with no guard at all. */
303
+ boundary: string | null
304
+ /** `/i` without `/u`: the fold class is NOT `{c, upper(c), lower(c)}`. */
305
+ caseFoldRisk: boolean
306
+ /** Sibling arm indices in the same `choice` that are also hand-rolled keywords. */
307
+ siblingArms: number[]
308
+ /**
309
+ * >= 3 literal alternatives: a fixed VOCABULARY enumerated by hand rather than a
310
+ * keyword with a guard. The interesting sub-case, because ordering starts to matter
311
+ * and the list is usually long enough that nobody re-reads it.
312
+ */
313
+ vocabulary: boolean
314
+ /**
315
+ * Alternatives are in non-increasing length order. `keywords()` sorts longest-first
316
+ * by construction; a hand-written alternation does not, and nothing checks it.
317
+ */
318
+ longestFirst: boolean
319
+ /**
320
+ * Earlier alternatives that are strict PREFIXES of later ones. Regex alternation is
321
+ * first-match, so the longer branch is reachable only when a trailing boundary guard
322
+ * rejects the following character and forces a backtrack (`rescuedByBoundary`).
323
+ */
324
+ hazards: { shorter: string; longer: string; at: string; rescuedByBoundary: boolean }[]
325
+ /**
326
+ * At least one hazard is NOT rescued by a boundary guard — a later alternative can
327
+ * never match. That is a live bug, not a cleanup.
328
+ */
329
+ bug: boolean
330
+ suggestion: string
331
+ }
332
+
333
+ export type DuplicationReport = {
334
+ duplicates: DuplicateFinding[]
335
+ nearDuplicates: NearDuplicateFinding[]
336
+ regexFragments: RegexFragmentFinding[]
337
+ regexClasses: RegexClassFinding[]
338
+ overlaps: ArmOverlapFinding[]
339
+ rewrites: RewriteFinding[]
340
+ divergentNodes: DivergentNodeFinding[]
341
+ structureLoss: StructureLossFinding[]
342
+ keywordRegexes: KeywordRegexFinding[]
343
+ /** Ids listed in `accept` that matched no finding — stale entries to prune. */
344
+ acceptedUnused: string[]
345
+ stats: {
346
+ rules: number
347
+ /** Distinct combinator INSTANCES reached. */
348
+ nodes: number
349
+ /** Distinct structural shapes among them. */
350
+ shapes: number
351
+ }
352
+ }
353
+
354
+ export type AnalyzeDuplicationOptions = {
355
+ /** Smallest repeated subtree worth reporting, in nodes. Default 3 — which is
356
+ * what keeps `optional(ws)` (2 nodes) out of the ranking. */
357
+ minSize?: number
358
+ /** Cap per category, applied AFTER ranking. Default 25. */
359
+ maxFindings?: number
360
+ /** Finding `id`s to suppress — the single per-finding acknowledgement channel,
361
+ * mirroring the gating snapshot allowlist. */
362
+ accept?: Iterable<string>
363
+ /** Bind cross-artifact `g.Foo` holes when computing first-sets for `overlaps`. */
364
+ resolveRef?: RefResolver
365
+ /** Name to attribute an unnamed entry to, instead of `<entry>`. */
366
+ entryName?: string
367
+ }
368
+
369
+ // ── canonical structural hashing ─────────────────────────────────────────────
370
+
371
+ // `childrenOf` is imported from ./gating.ts; this module used to carry a copy.
372
+
373
+ const ruleNameOf = (p: Combinator<unknown>): string | undefined =>
374
+ (p as unknown as { _ruleName?: string })._ruleName
375
+
376
+ /** Source text of a callback, so two COPY-PASTED lambdas hash alike (identity
377
+ * would make every copy distinct and defeat the whole diagnostic). */
378
+ const fnKey = (src: string | undefined, fn: unknown): string =>
379
+ src ?? (typeof fn === 'function' ? String(fn) : '')
380
+
381
+ /** The discriminating payload of a node, excluding its children. */
382
+ function payloadKey(p: Combinator<unknown>, d: ParserDef): string {
383
+ switch (d.tag) {
384
+ case 'literal': return `literal\u0000${d.value}\u0000${d.caseInsensitive}`
385
+ case 'regex': return `regex\u0000${d.source}\u0000${d.flags}`
386
+ case 'keywords': return `keywords\u0000${[...d.words].join('\u0001')}\u0000${d.caseInsensitive}\u0000${d.boundary ?? ''}`
387
+ case 'lazy': {
388
+ const n = ruleNameOf(p)
389
+ return n === undefined ? `ref\u0000#${anonId(p)}` : `ref\u0000${n}`
390
+ }
391
+ case 'label': return `label\u0000${d.label}`
392
+ case 'field': return `field\u0000${d.name}`
393
+ case 'node': return `node\u0000${d.type ?? ''}\u0000${d.unwrap === true}\u0000${d.collapse === true}\u0000${fnKey(d.buildSrc, d.build)}`
394
+ case 'transform': return `transform\u0000${fnKey(d.fnSrc, d.fn)}\u0000${d.recognitionOnly === true}`
395
+ case 'leaf': return `leaf\u0000${fnKey(d.fnSrc, d.fn)}`
396
+ case 'many': case 'oneOrMore': return `${d.tag}\u0000${d.min}\u0000${d.max ?? ''}`
397
+ case 'sepBy': return `sepBy\u0000${d.min}\u0000${d.max ?? ''}\u0000${d.trailing ?? ''}`
398
+ case 'dispatch': return `dispatch\u0000${d.cases.map(c => JSON.stringify(c.keys)).join('\u0002')}\u0000${d.otherwise !== undefined}`
399
+ case 'expect': return `expect\u0000${d.label ?? ''}\u0000${d.expected.join('\u0001')}`
400
+ case 'scanTo': return `scanTo\u0000${d.raw}\u0000${d.orEOF}`
401
+ case 'guard': return `guard\u0000${fnKey(d.predSrc, d.predicate)}`
402
+ case 'adjacency': return `adjacency\u0000${d.polarity}\u0000${d.kinds ? d.kinds.join(',') : ''}`
403
+ case 'withCtx': return `withCtx\u0000${d.extraSrc ?? safeJson(d.extra)}`
404
+ case 'grammar': return `grammar\u0000${d.clearTrivia === true}\u0000${d.captureTrivia === true}\u0000${d.trackLines}`
405
+ default: return d.tag
406
+ }
407
+ }
408
+
409
+ function safeJson(v: unknown): string {
410
+ try { return JSON.stringify(v) ?? String(v) } catch { return '?' }
411
+ }
412
+
413
+ let anonCounter = 0
414
+ /** Duplication analysis is an opt-in construction-time diagnostic. Do not
415
+ * allocate its identity map merely because a table artifact imports the public
416
+ * table entry; create it only when an unresolvable anonymous ref is diagnosed. */
417
+ let anonIds: Map<object, number> | undefined
418
+ /** Identity key for an UNNAMED unresolvable ref: nothing can bind it by name, so
419
+ * two of them are the same slot only when they are the same object. */
420
+ function anonId(p: object): number {
421
+ const ids = anonIds ??= new Map<object, number>()
422
+ let id = ids.get(p)
423
+ if (id === undefined) { id = ++anonCounter; ids.set(p, id) }
424
+ return id
425
+ }
426
+
427
+ /**
428
+ * Interning gives EXACT structural equality with short keys: a node's key is its
429
+ * payload plus the interned ids of its children, so equal ids ⇔ equal subtrees,
430
+ * with no hash collisions to reason about.
431
+ */
432
+ class Interner {
433
+ private readonly ids = new Map<string, number>()
434
+ readonly hole: number
435
+ constructor() { this.hole = this.of('\u0000HOLE\u0000') }
436
+ of(key: string): number {
437
+ let id = this.ids.get(key)
438
+ if (id === undefined) { id = this.ids.size; this.ids.set(key, id) }
439
+ return id
440
+ }
441
+ }
442
+
443
+ // ── rendering ────────────────────────────────────────────────────────────────
444
+
445
+ const MAX_RENDER = 160
446
+
447
+ /** A compact one-line rendering of a subtree, for the finding text. */
448
+ function render(p: Combinator<unknown>, depth = 3): string {
449
+ const d = p._def as ParserDef
450
+ const name = ruleNameOf(p)
451
+ switch (d.tag) {
452
+ case 'literal': return `literal('${d.value}')`
453
+ case 'regex': return `regex(/${d.source}/${d.flags})`
454
+ case 'keywords': return `keywords([${[...d.words].slice(0, 4).map(w => `'${w}'`).join(', ')}${d.words.length > 4 ? ', …' : ''}])`
455
+ case 'lazy': return `g.${name ?? '?'}`
456
+ case 'guard': return 'gate(…)'
457
+ case 'adjacency': return `${d.polarity}(…)`
458
+ default: break
459
+ }
460
+ if (depth <= 0) return `${d.tag}(…)`
461
+ const kids = childrenOf(d).map(k => render(k, depth - 1))
462
+ switch (d.tag) {
463
+ case 'sequence': return `sequence(${kids.join(', ')})`
464
+ case 'choice': return `choice(${kids.join(', ')})`
465
+ case 'dispatch': {
466
+ const arms = d.cases.map((c, i) => `when([${c.keys.map(k => JSON.stringify(k)).join(', ')}], ${kids[i + 1] ?? ''})`)
467
+ if (d.otherwise !== undefined) arms.push(`otherwise(${kids[kids.length - 1] ?? ''})`)
468
+ return `dispatch(${kids[0] ?? ''}, ${arms.join(', ')})`
469
+ }
470
+ case 'node': return `node(${d.type ? `'${d.type}', ` : ''}${kids[0] ?? ''})`
471
+ case 'label': return `label('${d.label}', ${kids[0] ?? ''})`
472
+ case 'field': return `field('${d.name}', ${kids[0] ?? ''})`
473
+ case 'many': return `many(${kids[0] ?? ''}${d.min === 0 && d.max === undefined ? '' : `, { min: ${d.min}${d.max === undefined ? '' : `, max: ${d.max}`} }`})`
474
+ case 'oneOrMore': return `oneOrMore(${kids[0] ?? ''}${d.min === 1 && d.max === undefined ? '' : `, { min: ${d.min}${d.max === undefined ? '' : `, max: ${d.max}`} }`})`
475
+ case 'sepBy': return `sepBy(${kids[0] ?? ''}, ${kids[1] ?? ''})`
476
+ default: return `${d.tag}(${kids.join(', ')})`
477
+ }
478
+ }
479
+
480
+ const clamp = (s: string): string => (s.length <= MAX_RENDER ? s : `${s.slice(0, MAX_RENDER - 1)}…`)
481
+
482
+ /** Path segment for a child index, used to build `Site.path` and slot paths. */
483
+ function segment(d: ParserDef, index: number): string {
484
+ switch (d.tag) {
485
+ case 'sequence': return `seq[${index}]`
486
+ case 'choice': return `choice[${index}]`
487
+ case 'dispatch':
488
+ if (index === 0) return 'dispatch.selector'
489
+ if (index <= d.cases.length) return `dispatch.when[${index - 1}]`
490
+ return 'dispatch.otherwise'
491
+ case 'node': return `node(${d.type ?? ''})`
492
+ case 'label': return `label(${d.label})`
493
+ case 'field': return `field(${d.name})`
494
+ case 'sepBy': return index === 0 ? 'sepBy.item' : 'sepBy.sep'
495
+ case 'recover': return index === 0 ? 'recover.body' : 'recover.sentinel'
496
+ case 'scanTo': return index === 0 ? 'scanTo.sentinel' : `scanTo.skip[${index - 1}]`
497
+ default: return d.tag
498
+ }
499
+ }
500
+
501
+ // ── regex source: alternation runs + keyword shape ───────────────────────────
502
+
503
+ /**
504
+ * Every alternation branch list in a regex source, at ANY nesting depth. Depth
505
+ * matters: `/a|b/` and `/x(?:a|b)y/` re-spell the same run, and only one of them
506
+ * has it at top level.
507
+ */
508
+ export function alternationGroups(src: string): string[][] {
509
+ const out: string[][] = []
510
+ type Frame = { branches: string[]; last: number }
511
+ const stack: Frame[] = [{ branches: [], last: 0 }]
512
+ let i = 0
513
+ while (i < src.length) {
514
+ const c = src[i]!
515
+ if (c === '\\') { i += 2; continue }
516
+ if (c === '[') {
517
+ let j = i + 1
518
+ if (src[j] === '^') j++
519
+ if (src[j] === ']') j++
520
+ while (j < src.length && src[j] !== ']') { if (src[j] === '\\') j++; j++ }
521
+ i = j + 1
522
+ continue
523
+ }
524
+ if (c === '(') {
525
+ let open = i + 1
526
+ if (src[open] === '?') {
527
+ const rest = src.slice(open)
528
+ const m = /^\?(?::|=|!|<=|<!|<[^>]*>)/.exec(rest)
529
+ open += m ? m[0].length : 1
530
+ }
531
+ stack.push({ branches: [], last: open })
532
+ i = open
533
+ continue
534
+ }
535
+ if (c === ')') {
536
+ const top = stack.pop()
537
+ if (top !== undefined) {
538
+ top.branches.push(src.slice(top.last, i))
539
+ if (top.branches.length >= 2) out.push(top.branches)
540
+ }
541
+ i++
542
+ continue
543
+ }
544
+ if (c === '|') {
545
+ const top = stack[stack.length - 1]!
546
+ top.branches.push(src.slice(top.last, i))
547
+ top.last = i + 1
548
+ i++
549
+ continue
550
+ }
551
+ i++
552
+ }
553
+ const root = stack[0]!
554
+ root.branches.push(src.slice(root.last))
555
+ if (root.branches.length >= 2) out.push(root.branches)
556
+ return out
557
+ }
558
+
559
+ /**
560
+ * A LITERAL word: letters, digits, `_`, `-`, optionally vendor-prefixed. No
561
+ * character class, no quantifier, no escape, no group — anything with regex
562
+ * machinery in it is a pattern, not a vocabulary entry, and is left alone.
563
+ */
564
+ const WORD_RE_SRC = '-?[A-Za-z][A-Za-z0-9_-]*'
565
+ const WORDS_RE = new RegExp(`^${WORD_RE_SRC}(?:\\|${WORD_RE_SRC})*$`)
566
+
567
+ /**
568
+ * How many literal alternatives make a regex a VOCABULARY rather than a pattern,
569
+ * when there is no boundary guard to mark it as a keyword. With a guard, one word
570
+ * is already enough (`regex(/not(?![-\w])/)`); without one, two alternatives are
571
+ * as likely to be an ordinary either/or as a keyword set, so the bar is three.
572
+ */
573
+ const VOCABULARY_MIN_WORDS = 3
574
+
575
+ /**
576
+ * Recognize a `regex()` that hand-rolls what `word()`/`keywords()` owns.
577
+ *
578
+ * TWO shapes, because they fail differently:
579
+ *
580
+ * - a word (or word alternation) plus a trailing word-boundary guard —
581
+ * `regex(/not(?![-\w])/i)`;
582
+ * - a bare alternation of >= 3 literal words with NO guard — a fixed vocabulary
583
+ * enumerated by hand. A regex enumerating a fixed vocabulary is a keyword set
584
+ * written the hard way: it loses first-set gating, it hand-maintains an ordering
585
+ * the combinator guarantees, and with `/i` and no `/u` it inherits the non-ASCII
586
+ * case-folding bug `keywords()` fixes internally. The no-guard form is also where
587
+ * ordering is load-bearing, since nothing backtracks past a shorter match.
588
+ *
589
+ * Returns the words and the boundary CLASS in the exact form `word(str, boundary)` /
590
+ * `keywords(words, { boundary })` take, so the suggestion names a real call rather
591
+ * than describing one.
592
+ */
593
+ export function keywordRegexShape(source: string): { words: string[]; boundary: string | null } | null {
594
+ let s = source
595
+ if (s.startsWith('^')) s = s.slice(1)
596
+ // A leading `(?<![-\w])` is the same boundary on the other side — word()/keywords()
597
+ // do not emit it, but its presence does not stop the body from being a keyword.
598
+ s = s.replace(/^\(\?<!\[[^\]]+\]\)/, '')
599
+ let boundary: string | null = null
600
+ // Peel a trailing boundary guard.
601
+ const tail = /(?:\(\?!\[([^\]]+)\]\)|\\b)$/.exec(s)
602
+ if (tail !== null) {
603
+ const cls = tail[1]
604
+ // `(?![^-\w])` is NOT a boundary guard — a negative lookahead of a NEGATED
605
+ // class asserts the opposite, that the next char IS in the class. The capture
606
+ // keeps the leading `^`, so passing it on would hand `word(str, boundary)` a
607
+ // class meaning the reverse, and `boundaryMatches` would read `^` as a literal
608
+ // member and return the inverted verdict. `bug` is derived from unrescued
609
+ // hazards, so that inversion can invent or hide a BUG in error mode. The shape
610
+ // is simply not expressible as a `boundary` argument: reject it.
611
+ if (cls !== undefined && cls.startsWith('^')) return null
612
+ boundary = cls ?? '_0-9A-Za-z'
613
+ s = s.slice(0, tail.index)
614
+ }
615
+ // Unwrap one redundant non-capturing group around the word set.
616
+ const grouped = /^\(\?:([^()]*)\)$/.exec(s)
617
+ if (grouped !== null) s = grouped[1]!
618
+ if (s.endsWith('$')) s = s.slice(0, -1)
619
+ if (!WORDS_RE.test(s)) return null
620
+ const words = s.split('|')
621
+ if (boundary === null && words.length < VOCABULARY_MIN_WORDS) return null
622
+ return { words, boundary }
623
+ }
624
+
625
+ /** Does a boundary class (`-\w`, `_0-9A-Za-z`, …) match this character? */
626
+ function boundaryMatches(boundary: string, ch: string): boolean {
627
+ const cp = ch.codePointAt(0)!
628
+ for (const m of charClassMembers(boundary)) {
629
+ if (m === '\\w') { if (/[A-Za-z0-9_]/.test(ch)) return true; continue }
630
+ if (m === '\\d') { if (/[0-9]/.test(ch)) return true; continue }
631
+ if (m === '\\s') { if (/\s/.test(ch)) return true; continue }
632
+ const range = /^(.|\\u[0-9a-f]{4})-(.|\\u[0-9a-f]{4})$/.exec(m)
633
+ if (range !== null) {
634
+ const dec = (t: string): number => (t.startsWith('\\u') ? parseInt(t.slice(2), 16) : t.codePointAt(0)!)
635
+ if (cp >= dec(range[1]!) && cp <= dec(range[2]!)) return true
636
+ continue
637
+ }
638
+ if (m === ch) return true
639
+ }
640
+ return false
641
+ }
642
+
643
+ /**
644
+ * Ordering hazards in a hand-written alternation: an EARLIER alternative that is a
645
+ * strict prefix of a LATER one. Regex alternation is first-match, not longest-match,
646
+ * so `red|redish` matches only `red` — unless a trailing boundary guard rejects the
647
+ * character that follows, which makes the engine backtrack into the longer branch.
648
+ *
649
+ * That distinction is the whole point of reporting this: without a rescuing guard the
650
+ * longer word is UNREACHABLE, which is a live bug, not a style finding. `keywords()`
651
+ * sorts longest-first by construction and the hazard cannot exist there at all.
652
+ */
653
+ export function keywordAlternationHazards(
654
+ words: readonly string[],
655
+ boundary: string | null,
656
+ ): { shorter: string; longer: string; at: string; rescuedByBoundary: boolean }[] {
657
+ const out: { shorter: string; longer: string; at: string; rescuedByBoundary: boolean }[] = []
658
+ for (let i = 0; i < words.length; i++) {
659
+ for (let j = i + 1; j < words.length; j++) {
660
+ const a = words[i]!, b = words[j]!
661
+ if (a.length >= b.length || !b.startsWith(a)) continue
662
+ const at = b[a.length]!
663
+ out.push({ shorter: a, longer: b, at, rescuedByBoundary: boundary !== null && boundaryMatches(boundary, at) })
664
+ }
665
+ }
666
+ return out
667
+ }
668
+
669
+ // ── the walk ─────────────────────────────────────────────────────────────────
670
+
671
+ export function analyzeDuplication(
672
+ entry: Combinator<unknown>,
673
+ opts?: AnalyzeDuplicationOptions,
674
+ ): DuplicationReport {
675
+ return analyzeDuplicationRules([[ruleNameOf(entry) ?? opts?.entryName ?? '<entry>', entry]], opts)
676
+ }
677
+
678
+ type Visited = {
679
+ p: Combinator<unknown>
680
+ d: ParserDef
681
+ site: Site
682
+ shape: number
683
+ size: number
684
+ /** Nearest enclosing `node()` type, if any. A rewrite under one changes the
685
+ * children that node's build fn sees, which is the AST-neutrality question. */
686
+ enclosingNode: string | null
687
+ /** An enclosing `node()`/`transform()`/`leaf()` carries a REDUCER — a callback
688
+ * that reads the children array. Any rewrite changing that array's length or
689
+ * stride changes what the callback sees. */
690
+ enclosingReducer: boolean
691
+ }
692
+
693
+ /**
694
+ * The analysis input is the COMBINATOR TREE — a `rules()` map, or the map handed to
695
+ * `compileRuleMap`/`compileLinkable`. It is NOT the value `compose()` returns: that
696
+ * is a fused, already-compiled artifact whose entries are emitted parse FUNCTIONS
697
+ * with no `_def` to walk.
698
+ *
699
+ * This is checked, loudly, because the alternative failure mode is the one that
700
+ * matters. `analyzeGating()` handed a composed artifact throws deep inside its
701
+ * walker on the first descriptor-less node — and a diagnostic that can throw or
702
+ * silently report zero on exactly the grammars it exists to serve is worse than no
703
+ * diagnostic, because a clean run reads as "no findings" instead of "saw nothing".
704
+ * Silence is not a permitted outcome here.
705
+ */
706
+ function assertAnalyzable(ruleMap: ReadonlyArray<readonly [string, Combinator<unknown>]>): void {
707
+ for (const [name, rule] of ruleMap) {
708
+ const def = (rule as { _def?: unknown } | null | undefined)?._def
709
+ if (def !== null && typeof def === 'object' && typeof (def as { tag?: unknown }).tag === 'string') continue
710
+ throw new TypeError(
711
+ `analyzeDuplicationRules: rule '${name}' is not a combinator (no _def). ` +
712
+ `This analysis walks the COMBINATOR TREE; the value returned by compose()/composeLeaf() is a ` +
713
+ `fused, already-compiled artifact whose entries are parse functions. Pass the rules() map itself ` +
714
+ `(or the map given to compileRuleMap/compileLinkable) instead — for a composed grammar, analyze ` +
715
+ `each piece's own rules() map.`,
716
+ )
717
+ }
718
+ }
719
+
720
+ export function analyzeDuplicationRules(
721
+ ruleMap: ReadonlyArray<readonly [string, Combinator<unknown>]>,
722
+ opts?: AnalyzeDuplicationOptions,
723
+ ): DuplicationReport {
724
+ assertAnalyzable(ruleMap)
725
+ const minSize = opts?.minSize ?? 3
726
+ const maxFindings = opts?.maxFindings ?? 25
727
+ const interner = new Interner()
728
+
729
+ const shapeCache = new Map<Combinator<unknown>, number>()
730
+ const sizeCache = new Map<Combinator<unknown>, number>()
731
+
732
+ const shapeOf = (p: Combinator<unknown>): number => {
733
+ const hit = shapeCache.get(p)
734
+ if (hit !== undefined) return hit
735
+ const d = p._def as ParserDef
736
+ // Seed against self-reference before recursing (a `lazy` is a leaf, so a true
737
+ // cycle cannot form here — but a defensive seed keeps a malformed def finite).
738
+ shapeCache.set(p, interner.hole)
739
+ const kids = childrenOf(d)
740
+ const id = interner.of(`${payloadKey(p, d)}|${kids.map(shapeOf).join(',')}`)
741
+ shapeCache.set(p, id)
742
+ return id
743
+ }
744
+ const sizeOf = (p: Combinator<unknown>): number => {
745
+ const hit = sizeCache.get(p)
746
+ if (hit !== undefined) return hit
747
+ sizeCache.set(p, 1)
748
+ let n = 1
749
+ for (const k of childrenOf(p._def as ParserDef)) n += sizeOf(k)
750
+ sizeCache.set(p, n)
751
+ return n
752
+ }
753
+
754
+ // ── traverse: one entry per distinct combinator INSTANCE ──
755
+ const visited = new Map<Combinator<unknown>, Visited>()
756
+ const visit = (p: Combinator<unknown>, rule: string, path: string, enclosingNode: string | null, enclosingReducer: boolean): void => {
757
+ if (visited.has(p)) return
758
+ const d = p._def as ParserDef
759
+ const own = ruleNameOf(p)
760
+ const here = own !== undefined && own !== rule ? { rule: own, path: '' } : { rule, path }
761
+ visited.set(p, { p, d, site: here, shape: shapeOf(p), size: sizeOf(p), enclosingNode, enclosingReducer })
762
+ const inner = d.tag === 'node' ? d.type ?? '(anonymous)' : enclosingNode
763
+ const innerReducer = enclosingReducer
764
+ || (d.tag === 'node' && (d.build !== undefined || d.buildSrc !== undefined))
765
+ || d.tag === 'transform' || d.tag === 'leaf'
766
+ const kids = childrenOf(d)
767
+ kids.forEach((k, i) => {
768
+ const seg = segment(d, i)
769
+ visit(k, here.rule, here.path === '' ? seg : `${here.path} › ${seg}`, inner, innerReducer)
770
+ })
771
+ // Follow refs so a rule reachable only through one is still attributed — under
772
+ // its OWN name, because the ref carries `_ruleName`.
773
+ if (d.tag === 'lazy') {
774
+ try {
775
+ const target = (d as { thunk(): Combinator<unknown> }).thunk()
776
+ visit(target, ruleNameOf(target) ?? ruleNameOf(p) ?? here.rule, '', null, false)
777
+ } catch { /* unresolved cross-artifact hole */ }
778
+ }
779
+ }
780
+ for (const [name, root] of ruleMap) visit(root, ruleNameOf(root) ?? name, '', null, false)
781
+
782
+ const accept = new Set(opts?.accept ?? [])
783
+ const used = new Set<string>()
784
+ const keep = <T extends { id: string }>(f: T): boolean => {
785
+ if (!accept.has(f.id)) return true
786
+ used.add(f.id)
787
+ return false
788
+ }
789
+
790
+ const duplicates = findExactDuplicates(visited, minSize)
791
+ const nearDuplicates = findNearDuplicates(visited, interner, shapeOf, minSize)
792
+ const regexFragments = findRegexFragments(visited)
793
+ const regexClasses = findRegexClasses(visited)
794
+ const overlaps = findOverlaps(visited, shapeOf, opts?.resolveRef)
795
+ const rewrites = findRewrites(visited, shapeOf)
796
+ const divergentNodes = findDivergentNodes(visited, shapeOf)
797
+ const structureLoss = findStructureLoss(visited, opts?.resolveRef)
798
+ const keywordRegexes = findKeywordRegexes(visited)
799
+
800
+ // Subsumption, WITHIN each category: a 40-node clone family otherwise reports
801
+ // once at its root and again at every internal node, since each of those is
802
+ // repeated exactly as often. Deliberately NOT applied across categories — a
803
+ // near-duplicate family and an exact duplicate inside it are two different facts
804
+ // about the same code, and suppressing the second because the first happens to
805
+ // rank higher hides a real repetition behind a maybe-refactor.
806
+ const subsume = <T extends { id: string; savings: number; _members: Combinator<unknown>[] }>(fs: T[]): Set<string> => {
807
+ const covered = new Set<Combinator<unknown>>()
808
+ const surviving = new Set<string>()
809
+ for (const f of [...fs].sort((a, b) => b.savings - a.savings)) {
810
+ if (f._members.every(m => covered.has(m))) continue
811
+ surviving.add(f.id)
812
+ for (const m of f._members) markSubtree(m, covered)
813
+ }
814
+ return surviving
815
+ }
816
+ const survivingExact = subsume(duplicates)
817
+ const survivingNear = subsume(nearDuplicates)
818
+
819
+ const report: DuplicationReport = {
820
+ duplicates: duplicates.filter(f => survivingExact.has(f.id)).filter(keep).slice(0, maxFindings).map(stripMembers),
821
+ nearDuplicates: nearDuplicates.filter(f => survivingNear.has(f.id)).filter(keep).slice(0, maxFindings).map(stripMembers),
822
+ regexFragments: regexFragments.filter(keep).slice(0, maxFindings),
823
+ regexClasses: regexClasses.filter(keep).slice(0, maxFindings),
824
+ overlaps: overlaps.filter(keep).slice(0, maxFindings),
825
+ rewrites: rewrites.filter(keep).slice(0, maxFindings),
826
+ divergentNodes: divergentNodes.filter(keep).slice(0, maxFindings),
827
+ structureLoss: structureLoss.filter(keep).slice(0, maxFindings),
828
+ keywordRegexes: keywordRegexes.filter(keep).slice(0, maxFindings),
829
+ acceptedUnused: [...accept].filter(id => !used.has(id)),
830
+ stats: { rules: ruleMap.length, nodes: visited.size, shapes: new Set([...visited.values()].map(v => v.shape)).size },
831
+ }
832
+ return report
833
+ }
834
+
835
+ function markSubtree(p: Combinator<unknown>, into: Set<Combinator<unknown>>): void {
836
+ if (into.has(p)) return
837
+ into.add(p)
838
+ for (const k of childrenOf(p._def as ParserDef)) markSubtree(k, into)
839
+ }
840
+
841
+ type WithMembers<T> = T & { _members: Combinator<unknown>[] }
842
+ const stripMembers = <T extends object>(f: WithMembers<T>): T => {
843
+ const { _members, ...rest } = f
844
+ void _members
845
+ return rest as T
846
+ }
847
+
848
+ // ── 1. exact duplicates ──────────────────────────────────────────────────────
849
+
850
+ function findExactDuplicates(
851
+ visited: Map<Combinator<unknown>, Visited>,
852
+ minSize: number,
853
+ ): WithMembers<DuplicateFinding>[] {
854
+ const byShape = new Map<number, Visited[]>()
855
+ for (const v of visited.values()) {
856
+ if (v.size < minSize) continue
857
+ const list = byShape.get(v.shape)
858
+ if (list === undefined) byShape.set(v.shape, [v]); else list.push(v)
859
+ }
860
+ const out: WithMembers<DuplicateFinding>[] = []
861
+ for (const group of byShape.values()) {
862
+ if (group.length < 2) continue
863
+ const size = group[0]!.size
864
+ out.push({
865
+ kind: 'exact-duplicate',
866
+ id: `exact-duplicate:${siteToString(group[0]!.site)}`,
867
+ size,
868
+ count: group.length,
869
+ savings: (group.length - 1) * size,
870
+ shape: clamp(render(group[0]!.p)),
871
+ sites: group.map(g => g.site),
872
+ suggestion: `${group.length} DISTINCT instances build this identical ${size}-node shape. Bind it to one const and reference that — hoisting removes ${(group.length - 1) * size} nodes and makes a future edit land in one place instead of ${group.length}.`,
873
+ _members: group.map(g => g.p),
874
+ })
875
+ }
876
+ return out.sort((a, b) => b.savings - a.savings)
877
+ }
878
+
879
+ // ── 2. near duplicates (identical but for one slot) ──────────────────────────
880
+
881
+ /**
882
+ * "Same shape, one differing slot" is decided by HOLE KEYS: the key of a subtree
883
+ * with one descendant position blanked. Two subtrees that differ at exactly one
884
+ * position share exactly the hole key that blanks it, so grouping by hole key and
885
+ * keeping the groups whose members' full shapes DIFFER is precisely the family.
886
+ *
887
+ * The root hole is excluded — blanking the root makes every subtree in the grammar
888
+ * a "near duplicate" of every other.
889
+ */
890
+ function findNearDuplicates(
891
+ visited: Map<Combinator<unknown>, Visited>,
892
+ interner: Interner,
893
+ shapeOf: (p: Combinator<unknown>) => number,
894
+ minSize: number,
895
+ ): WithMembers<NearDuplicateFinding>[] {
896
+ const holeCache = new Map<Combinator<unknown>, number[]>()
897
+ const holesOf = (p: Combinator<unknown>): number[] => {
898
+ const hit = holeCache.get(p)
899
+ if (hit !== undefined) return hit
900
+ holeCache.set(p, [])
901
+ const d = p._def as ParserDef
902
+ const kids = childrenOf(d)
903
+ const kidShapes = kids.map(shapeOf)
904
+ const payload = payloadKey(p, d)
905
+ const out: number[] = []
906
+ kids.forEach((k, i) => {
907
+ for (const inner of [interner.hole, ...holesOf(k)]) {
908
+ const arr = kidShapes.slice()
909
+ arr[i] = inner
910
+ out.push(interner.of(`${payload}|${arr.join(',')}`))
911
+ }
912
+ })
913
+ holeCache.set(p, out)
914
+ return out
915
+ }
916
+
917
+ const byHole = new Map<number, Visited[]>()
918
+ for (const v of visited.values()) {
919
+ if (v.size < minSize) continue
920
+ for (const h of holesOf(v.p)) {
921
+ const list = byHole.get(h)
922
+ if (list === undefined) byHole.set(h, [v]); else list.push(v)
923
+ }
924
+ }
925
+
926
+ const out: WithMembers<NearDuplicateFinding>[] = []
927
+ const seenGroups = new Set<string>()
928
+ for (const group of byHole.values()) {
929
+ if (group.length < 2) continue
930
+ // Exact duplicates share every hole key — they are the OTHER family.
931
+ const shapes = new Set(group.map(g => g.shape))
932
+ if (shapes.size < 2) continue
933
+ // One representative per distinct shape: N copies of variant A and M of variant
934
+ // B is one clone family with two variants, not N×M findings.
935
+ const perShape = new Map<number, Visited>()
936
+ for (const g of group) if (!perShape.has(g.shape)) perShape.set(g.shape, g)
937
+ const members = [...perShape.values()]
938
+ const key = members.map(m => m.shape).sort((a, b) => a - b).join(',')
939
+ if (seenGroups.has(key)) continue
940
+ seenGroups.add(key)
941
+ const slot = findDivergentSlot(members.map(m => m.p), shapeOf)
942
+ if (slot === null) continue
943
+ const sharedSize = Math.min(...members.map(m => m.size)) - slot.slotSize
944
+ if (sharedSize < minSize) continue
945
+ out.push({
946
+ kind: 'near-duplicate',
947
+ id: `near-duplicate:${siteToString(members[0]!.site)}`,
948
+ sharedSize,
949
+ count: members.length,
950
+ savings: (members.length - 1) * sharedSize,
951
+ shape: clamp(renderWithHole(members[0]!.p, slot.path)),
952
+ slotPath: slot.path.map((i, k) => segment(slot.defs[k]!, i)).join(' › '),
953
+ variants: slot.variants.map(v => clamp(render(v))),
954
+ sites: members.map(m => m.site),
955
+ suggestion: `${members.length} productions share this ${sharedSize}-node scaffolding and differ ONLY at \`${slot.path.map((i, k) => segment(slot.defs[k]!, i)).join(' › ')}\`. That is one production whose varying slot is a \`choice(${slot.variants.map(v => clamp(render(v, 1))).join(', ')})\` — not ${members.length} copies of the scaffolding. Re-check the AST: a single production emits one node type where the clones may emit several.`,
956
+ _members: members.map(m => m.p),
957
+ })
958
+ }
959
+ return out.sort((a, b) => b.savings - a.savings)
960
+ }
961
+
962
+ /** The single child position at which the given subtrees diverge, if there is one. */
963
+ function findDivergentSlot(
964
+ members: readonly Combinator<unknown>[],
965
+ shapeOf: (p: Combinator<unknown>) => number,
966
+ ): { path: number[]; defs: ParserDef[]; variants: Combinator<unknown>[]; slotSize: number } | null {
967
+ const path: number[] = []
968
+ const defs: ParserDef[] = []
969
+ let cur = [...members]
970
+ for (let guard = 0; guard < 64; guard++) {
971
+ const d0 = cur[0]!._def as ParserDef
972
+ const kidLists = cur.map(c => childrenOf(c._def as ParserDef))
973
+ const arity = kidLists[0]!.length
974
+ if (kidLists.some(k => k.length !== arity) || arity === 0) break
975
+ const diff: number[] = []
976
+ for (let i = 0; i < arity; i++) {
977
+ const s = shapeOf(kidLists[0]![i]!)
978
+ if (kidLists.some(k => shapeOf(k[i]!) !== s)) diff.push(i)
979
+ }
980
+ if (diff.length !== 1) break
981
+ const i = diff[0]!
982
+ path.push(i)
983
+ defs.push(d0)
984
+ cur = kidLists.map(k => k[i]!)
985
+ // Descend while the divergence is still a single slot; stop when the subtrees
986
+ // are wholly different (that IS the varying slot).
987
+ const nextArity = childrenOf(cur[0]!._def as ParserDef).length
988
+ const samePayload = cur.every(c => payloadKey(c, c._def as ParserDef) === payloadKey(cur[0]!, cur[0]!._def as ParserDef))
989
+ if (!samePayload || nextArity === 0) break
990
+ }
991
+ if (path.length === 0) return null
992
+ let slotSize = 0
993
+ const sizeOf = (p: Combinator<unknown>): number => {
994
+ let n = 1
995
+ for (const k of childrenOf(p._def as ParserDef)) n += sizeOf(k)
996
+ return n
997
+ }
998
+ for (const c of cur) slotSize = Math.max(slotSize, sizeOf(c))
999
+ return { path, defs, variants: cur, slotSize }
1000
+ }
1001
+
1002
+ function renderWithHole(p: Combinator<unknown>, path: readonly number[], depth = 0): string {
1003
+ if (depth === path.length) return '\u2039slot\u203a'
1004
+ const d = p._def as ParserDef
1005
+ const kids = childrenOf(d)
1006
+ const idx = path[depth]!
1007
+ const parts = kids.map((k, i) => (i === idx ? renderWithHole(k, path, depth + 1) : render(k, 2)))
1008
+ switch (d.tag) {
1009
+ case 'sequence': return `sequence(${parts.join(', ')})`
1010
+ case 'choice': return `choice(${parts.join(', ')})`
1011
+ case 'node': return `node(${d.type ? `'${d.type}', ` : ''}${parts[0] ?? ''})`
1012
+ default: return `${d.tag}(${parts.join(', ')})`
1013
+ }
1014
+ }
1015
+
1016
+ // ── 3. regex alternation fragments ───────────────────────────────────────────
1017
+
1018
+ /** Longest contiguous branch window compared across regexes. Windows shorter than
1019
+ * the true maximal shared run are dropped by the containment pass below, so this
1020
+ * only has to be long enough to REACH the maximal run — a whole shared alternation
1021
+ * list is additionally offered as one candidate, however long it is. */
1022
+ const MAX_RUN = 32
1023
+
1024
+ function findRegexFragments(visited: Map<Combinator<unknown>, Visited>): RegexFragmentFinding[] {
1025
+ type Occurrence = { site: Site; source: string }
1026
+ const byRun = new Map<string, { occ: Occurrence[]; nodes: Set<Combinator<unknown>>; branches: number }>()
1027
+ for (const v of visited.values()) {
1028
+ const d = v.d
1029
+ if (d.tag !== 'regex') continue
1030
+ const runs = new Set<string>()
1031
+ for (const branches of alternationGroups(d.source)) {
1032
+ if (branches.length >= 2) runs.add(branches.join('|'))
1033
+ for (let i = 0; i < branches.length; i++) {
1034
+ for (let n = 2; n <= Math.min(MAX_RUN, branches.length - i); n++) {
1035
+ const run = branches.slice(i, i + n).join('|')
1036
+ if (run.length < 4) continue
1037
+ runs.add(run)
1038
+ }
1039
+ }
1040
+ }
1041
+ for (const run of runs) {
1042
+ let e = byRun.get(run)
1043
+ if (e === undefined) { e = { occ: [], nodes: new Set(), branches: run.split('|').length }; byRun.set(run, e) }
1044
+ if (e.nodes.has(v.p)) continue
1045
+ e.nodes.add(v.p)
1046
+ e.occ.push({ site: v.site, source: d.source })
1047
+ }
1048
+ }
1049
+ const candidates = [...byRun.entries()]
1050
+ .filter(([, e]) => e.occ.length >= 2)
1051
+ .map(([run, e]) => ({ run, e, savings: (e.occ.length - 1) * run.length }))
1052
+ // COUNT first: "re-spelled in 7 terminals" is the headline — it is the number
1053
+ // of places an edit has to land, and the number of chances for one of them to
1054
+ // drift. Length only breaks ties.
1055
+ .sort((a, b) => b.e.occ.length - a.e.occ.length || b.savings - a.savings || b.run.length - a.run.length)
1056
+
1057
+ // Keep only MAXIMAL runs: a 6-branch run that occurs 7× makes its own 5-branch
1058
+ // sub-runs (also 7×) noise.
1059
+ const kept: typeof candidates = []
1060
+ for (const c of candidates) {
1061
+ if (kept.some(k => k.e.occ.length >= c.e.occ.length && (`|${k.run}|`).includes(`|${c.run}|`))) continue
1062
+ kept.push(c)
1063
+ }
1064
+ return kept.map(({ run, e }) => ({
1065
+ kind: 'regex-fragment' as const,
1066
+ id: `regex-fragment:${run}`,
1067
+ fragment: run,
1068
+ branches: e.branches,
1069
+ count: e.occ.length,
1070
+ savings: (e.occ.length - 1) * run.length,
1071
+ sources: e.occ.map(o => `/${o.source}/`),
1072
+ sites: e.occ.map(o => o.site),
1073
+ suggestion: `this ${e.branches}-branch alternation is re-spelled in ${e.occ.length} DISTINCT regex() terminals. Hoist it to one shared source string and interpolate it (\`new RegExp(\\\`…\\\${OP}…\\\`)\`) or lift the shared part into its own terminal — an edit to the operator set currently has to land in ${e.occ.length} places, and the analysis cannot tell you when one of them drifts.`,
1074
+ }))
1075
+ }
1076
+
1077
+ // ── 3b. character classes across regexes (and their DRIFT) ───────────────────
1078
+
1079
+ /**
1080
+ * Every `[...]` in a regex source, INCLUDING the ones inside boundary lookaheads
1081
+ * (`(?![-\w])` yields `-\w`) — those are the same duplication in a different
1082
+ * costume, and the same drift risk.
1083
+ */
1084
+ export function extractCharClasses(src: string): string[] {
1085
+ const out: string[] = []
1086
+ let i = 0
1087
+ while (i < src.length) {
1088
+ const c = src[i]!
1089
+ if (c === '\\') { i += 2; continue }
1090
+ if (c !== '[') { i++; continue }
1091
+ let j = i + 1
1092
+ if (src[j] === '^') j++
1093
+ if (src[j] === ']') j++
1094
+ while (j < src.length && src[j] !== ']') { if (src[j] === '\\') j++; j++ }
1095
+ out.push(src.slice(i + 1, j))
1096
+ i = j + 1
1097
+ }
1098
+ return out
1099
+ }
1100
+
1101
+ /**
1102
+ * Split a class body into normalized MEMBERS (`a-z`, `\w`, `-`, `€-￿`).
1103
+ * Non-ASCII literals are rewritten to `\uXXXX` so a class typed with a raw `￿`
1104
+ * and one typed with `￿` compare equal — otherwise the diagnostic reports
1105
+ * drift that is only an editor's.
1106
+ */
1107
+ export function charClassMembers(body: string): string[] {
1108
+ const esc = (ch: string): string => {
1109
+ const cp = ch.codePointAt(0)!
1110
+ return cp >= 0x80 ? `\\u${cp.toString(16).padStart(4, '0')}` : ch
1111
+ }
1112
+ const atoms: string[] = []
1113
+ let i = 0
1114
+ while (i < body.length) {
1115
+ if (body[i] === '\\') {
1116
+ // A code-point escape and the RAW character it denotes are the same member.
1117
+ // Missing this is not cosmetic: it splits one drifting family into two
1118
+ // clusters purely by which file's author typed the escape.
1119
+ const m = /^\\(?:u\{([0-9a-fA-F]+)\}|u([0-9a-fA-F]{4})|x([0-9a-fA-F]{2}))/.exec(body.slice(i))
1120
+ if (m !== null) {
1121
+ const cp = parseInt(m[1] ?? m[2] ?? m[3]!, 16)
1122
+ atoms.push(cp >= 0x80 ? `\\u${cp.toString(16).padStart(4, '0')}` : String.fromCodePoint(cp))
1123
+ i += m[0].length
1124
+ continue
1125
+ }
1126
+ atoms.push(body.slice(i, i + 2)); i += 2; continue
1127
+ }
1128
+ atoms.push(esc(body[i]!)); i++
1129
+ }
1130
+ const members: string[] = []
1131
+ for (let k = 0; k < atoms.length; k++) {
1132
+ if (k + 2 < atoms.length && atoms[k + 1] === '-' && atoms[k + 2] !== undefined) {
1133
+ members.push(`${atoms[k]}-${atoms[k + 2]}`)
1134
+ k += 2
1135
+ continue
1136
+ }
1137
+ members.push(atoms[k]!)
1138
+ }
1139
+ return members
1140
+ }
1141
+
1142
+ const CLASS_MIN_MEMBERS = 2
1143
+
1144
+ function findRegexClasses(visited: Map<Combinator<unknown>, Visited>): RegexClassFinding[] {
1145
+ type Spelling = { source: string; members: string[]; key: string; nodes: Set<Combinator<unknown>>; sites: Site[] }
1146
+ const spellings = new Map<string, Spelling>()
1147
+ for (const v of visited.values()) {
1148
+ if (v.d.tag !== 'regex') continue
1149
+ for (const body of new Set(extractCharClasses(v.d.source))) {
1150
+ const members = charClassMembers(body)
1151
+ if (members.length < CLASS_MIN_MEMBERS) continue
1152
+ const key = [...new Set(members)].sort().join('\u0001')
1153
+ let s = spellings.get(key)
1154
+ if (s === undefined) { s = { source: body, members, key, nodes: new Set(), sites: [] }; spellings.set(key, s) }
1155
+ if (s.nodes.has(v.p)) continue
1156
+ s.nodes.add(v.p)
1157
+ s.sites.push(v.site)
1158
+ }
1159
+ }
1160
+
1161
+ // Cluster spellings that differ by at most `MAX_DELTA` members while sharing a
1162
+ // clear majority — that relation is what makes DRIFT visible: two classes nobody
1163
+ // can tell apart by reading, one of which is wrong.
1164
+ const MAX_DELTA = 3
1165
+ /** Members shared / members in either. Below this the two classes are simply
1166
+ * different classes that happen to share a couple of characters — clustering
1167
+ * them would bury the real drift under coincidence. */
1168
+ const MIN_JACCARD = 0.6
1169
+ const all = [...spellings.values()]
1170
+ const parent = all.map((_, i) => i)
1171
+ const find = (i: number): number => { while (parent[i] !== i) { parent[i] = parent[parent[i]!]!; i = parent[i]! } return i }
1172
+ const union = (a: number, b: number): void => { const ra = find(a), rb = find(b); if (ra !== rb) parent[ra] = rb }
1173
+ for (let i = 0; i < all.length; i++) for (let j = i + 1; j < all.length; j++) {
1174
+ // A negated class and a positive one are opposites, never drift of each other.
1175
+ if (all[i]!.members.includes('^') !== all[j]!.members.includes('^')) continue
1176
+ const A = new Set(all[i]!.members), B = new Set(all[j]!.members)
1177
+ let shared = 0
1178
+ for (const m of A) if (B.has(m)) shared++
1179
+ const delta = (A.size - shared) + (B.size - shared)
1180
+ const jaccard = shared / (shared + delta)
1181
+ if (shared >= 2 && delta > 0 && delta <= MAX_DELTA && jaccard >= MIN_JACCARD) union(i, j)
1182
+ }
1183
+ const clusters = new Map<number, number[]>()
1184
+ all.forEach((_, i) => {
1185
+ const r = find(i)
1186
+ const list = clusters.get(r)
1187
+ if (list === undefined) clusters.set(r, [i]); else list.push(i)
1188
+ })
1189
+
1190
+ const out: RegexClassFinding[] = []
1191
+ for (const idxs of clusters.values()) {
1192
+ const members = idxs.map(i => all[i]!).sort((a, b) => b.nodes.size - a.nodes.size)
1193
+ const total = members.reduce((n, m) => n + m.nodes.size, 0)
1194
+ const drifted = members.length > 1
1195
+ if (!drifted && total < 2) continue
1196
+ const base = new Set(members[0]!.members)
1197
+ const variants: RegexClassVariant[] = members.map(m => {
1198
+ const mine = new Set(m.members)
1199
+ const plus = [...mine].filter(x => !base.has(x))
1200
+ const minus = [...base].filter(x => !mine.has(x))
1201
+ return {
1202
+ source: m.source,
1203
+ delta: m === members[0]
1204
+ ? '(reference)'
1205
+ : `${plus.map(p => `+\`${p}\``).join(' ')}${plus.length > 0 && minus.length > 0 ? ' ' : ''}${minus.map(p => `-\`${p}\``).join(' ')}` || '(same members, different spelling)',
1206
+ count: m.nodes.size,
1207
+ sites: m.sites,
1208
+ }
1209
+ })
1210
+ const bmpCeiling = members.some(m => m.members.some(x => x.endsWith('-\\uffff')))
1211
+ out.push({
1212
+ kind: 'regex-class',
1213
+ id: `regex-class:${members[0]!.key}`,
1214
+ canonical: members[0]!.source,
1215
+ variants,
1216
+ count: total,
1217
+ drifted,
1218
+ bmpCeiling,
1219
+ suggestion: [
1220
+ drifted
1221
+ ? `${members.length} NEARLY-identical spellings of one class, across ${total} regex() terminals. They differ by ${variants.slice(1).map(v => v.delta).join('; ')} — which is drift, not intent, unless every difference is deliberate. Two classes this close cannot be told apart by reading, so one of them is probably wrong and the tests cannot say which.`
1222
+ : `this class is re-spelled in ${total} DISTINCT regex() terminals. Extract it once — a named sub-leaf, or an interpolated source constant — so a range fix lands in one place. Re-spelling is how the near-identical variants elsewhere in this report came to exist.`,
1223
+ `Prefer the shared recognition surface where one already covers this class rather than a local extraction.`,
1224
+ bmpCeiling
1225
+ ? `The class tops out at U+FFFF, so astral-plane characters (emoji, many CJK extensions) fall OUTSIDE it. Widening the ceiling is exactly the kind of edit that has to reach every spelling.`
1226
+ : '',
1227
+ `Single-regex hygiene (duplicate class members, obscure ranges, a missing \`u\` flag) is \`eslint-plugin-regexp\`'s job and is not duplicated here — it sees one regex at a time, which is why it cannot see this.`,
1228
+ ].filter(s => s !== '').join(' '),
1229
+ })
1230
+ }
1231
+ // Drift first: an exact re-spelling is a tidy-up, a near-match is a latent bug.
1232
+ return out.sort((a, b) => Number(b.drifted) - Number(a.drifted) || b.count - a.count)
1233
+ }
1234
+
1235
+ // ── 4. choice-arm overlap ────────────────────────────────────────────────────
1236
+
1237
+ // `intersects` is imported from ./gating.ts; this module used to carry a copy.
1238
+
1239
+ function intersection(a: FirstSet, b: FirstSet): FirstSet {
1240
+ if (a.kind === 'any') return b
1241
+ if (b.kind === 'any') return a
1242
+ if (a.kind === 'empty' || b.kind === 'empty') return { kind: 'empty' }
1243
+ const ranges: { lo: number; hi: number }[] = []
1244
+ for (const ra of a.ranges) for (const rb of b.ranges) {
1245
+ const lo = Math.max(ra.lo, rb.lo)
1246
+ const hi = Math.min(ra.hi, rb.hi)
1247
+ if (lo <= hi) ranges.push({ lo, hi })
1248
+ }
1249
+ return ranges.length === 0 ? { kind: 'empty' } : { kind: 'ranges', ranges }
1250
+ }
1251
+
1252
+ /** The arm's term list, for shared-prefix comparison (a non-sequence arm is one term). */
1253
+ function termsOf(arm: Combinator<unknown>): readonly Combinator<unknown>[] {
1254
+ let cur = arm
1255
+ for (;;) {
1256
+ const d = cur._def as ParserDef
1257
+ if (d.tag === 'node' || d.tag === 'transform' || d.tag === 'label' || d.tag === 'attempt' || d.tag === 'leaf') {
1258
+ cur = (d as { parser: Combinator<unknown> }).parser
1259
+ continue
1260
+ }
1261
+ break
1262
+ }
1263
+ const d = cur._def as ParserDef
1264
+ return d.tag === 'sequence' ? d.parsers : [cur]
1265
+ }
1266
+
1267
+ function leadingRegex(arm: Combinator<unknown>): boolean {
1268
+ const t = termsOf(arm)[0]
1269
+ return t !== undefined && (t._def as ParserDef).tag === 'regex'
1270
+ }
1271
+
1272
+ function findOverlaps(
1273
+ visited: Map<Combinator<unknown>, Visited>,
1274
+ shapeOf: (p: Combinator<unknown>) => number,
1275
+ resolve?: RefResolver,
1276
+ ): ArmOverlapFinding[] {
1277
+ const out: ArmOverlapFinding[] = []
1278
+ for (const v of visited.values()) {
1279
+ const d = v.d
1280
+ if (d.tag !== 'choice') continue
1281
+ const arms = d.parsers
1282
+ const fs = arms.map(a => firstSetOf(a, new Set(), resolve))
1283
+ const strategy = d.strategy as { tag: string; members?: number[] } | undefined
1284
+ for (let i = 0; i < arms.length; i++) {
1285
+ for (let j = i + 1; j < arms.length; j++) {
1286
+ const A = fs[i]!, B = fs[j]!
1287
+ if (A.kind === 'any' || B.kind === 'any') continue // gating's `anyArms` owns that
1288
+ if (!intersects(A, B)) continue
1289
+ const ta = termsOf(arms[i]!), tb = termsOf(arms[j]!)
1290
+ let shared = 0
1291
+ while (shared < ta.length && shared < tb.length && shapeOf(ta[shared]!) === shapeOf(tb[shared]!)) shared++
1292
+ const handled = strategy?.tag === 'sharedPrefix'
1293
+ && Array.isArray(strategy.members) && strategy.members.includes(i) && strategy.members.includes(j)
1294
+ const regexPair = leadingRegex(arms[i]!) && leadingRegex(arms[j]!)
1295
+ out.push({
1296
+ kind: 'arm-overlap',
1297
+ id: `arm-overlap:${siteToString(v.site)}:${i}-${j}`,
1298
+ site: v.site,
1299
+ a: i, b: j,
1300
+ on: intersection(A, B),
1301
+ sharedLeadingTerms: shared,
1302
+ sharedPrefix: shared > 0 ? clamp(ta.slice(0, shared).map(t => render(t, 2)).join(', ')) : null,
1303
+ handledByStrategy: handled,
1304
+ regexPair,
1305
+ suggestion: overlapSuggestion(shared, handled, regexPair),
1306
+ })
1307
+ }
1308
+ }
1309
+ }
1310
+ return out.sort((a, b) => b.sharedLeadingTerms - a.sharedLeadingTerms)
1311
+ }
1312
+
1313
+ function overlapSuggestion(shared: number, handled: boolean, regexPair: boolean): string {
1314
+ if (shared > 0 && handled)
1315
+ return `the arms spell ${shared} identical leading term(s). parseman's \`sharedPrefix\` strategy already recognizes that prefix ONCE at runtime, so this costs nothing at parse time — it is a READABILITY finding: left-factoring into \`sequence(prefix, choice(…))\` says in the grammar what the compiler had to infer.`
1316
+ if (shared > 0)
1317
+ return `the arms spell ${shared} identical leading term(s) but do NOT qualify for the \`sharedPrefix\` strategy (it needs every arm to be a bare \`sequence\` starting with the same concrete terminal). Left-factor into \`sequence(prefix, choice(…))\` — that both removes the re-spelling and lets the inner choice dispatch on its own first char.`
1318
+ if (regexPair)
1319
+ return `two regex() arms whose character classes intersect. Nothing dispatches between them: the first is tried, and on failure the second re-scans the same position. Two regexes that can start on the same char are usually one terminal — merge them, or make the distinction explicit with a leading literal.`
1320
+ return `the arms can start on the same character, so the choice cannot dispatch between them: on those chars the first arm is entered speculatively and rolled back before the second is tried. Give the arms disjoint leading terminals, or left-factor the common part out.`
1321
+ }
1322
+
1323
+ // ── 5. algebraic rewrites ────────────────────────────────────────────────────
1324
+
1325
+ const PERF_ARM = 'removes a speculative arm: on every input position reaching this choice, the deleted arm no longer costs a ctx save, a child-array push and a rollback before the surviving arm is tried. The saving scales with how often the choice is reached and how much the doomed arm consumes before failing.'
1326
+ const PERF_NONE = ''
1327
+
1328
+ function findRewrites(
1329
+ visited: Map<Combinator<unknown>, Visited>,
1330
+ shapeOf: (p: Combinator<unknown>) => number,
1331
+ ): RewriteFinding[] {
1332
+ const out: RewriteFinding[] = []
1333
+ // `at` discriminates findings that a single site can produce MORE than one of.
1334
+ // Without it every `duplicate-arm`/`shadowed-arm` pair at one choice shares an
1335
+ // id, so accepting one pair silently suppresses the rest — and these are
1336
+ // `bug: true` findings that error mode is supposed to hold.
1337
+ const push = (f: Omit<RewriteFinding, 'kind' | 'id'>, at?: string): void => {
1338
+ const site = siteToString(f.site)
1339
+ out.push({ kind: 'rewrite', id: `rewrite:${f.rewrite}:${site}${at === undefined ? '' : `:${at}`}`, ...f })
1340
+ }
1341
+
1342
+ for (const v of visited.values()) {
1343
+ const d = v.d
1344
+ const site = v.site
1345
+
1346
+ // (a) idempotent nesting.
1347
+ if (d.tag === 'optional' || d.tag === 'many' || d.tag === 'oneOrMore') {
1348
+ const inner = (d as { parser: Combinator<unknown> }).parser
1349
+ const id = inner._def as ParserDef
1350
+ const collapsed = collapseRepeat(d, id)
1351
+ if (collapsed !== null) {
1352
+ push({
1353
+ rewrite: 'idempotent-nesting', site,
1354
+ from: clamp(render(v.p, 2)), to: collapsed,
1355
+ astNeutral: false,
1356
+ perf: 'the outer wrapper still runs its own bookkeeping (and, for `many(optional(X))`, guards a zero-width iteration on every pass) for a nullability the inner combinator already has.',
1357
+ bug: id.tag === 'optional' && d.tag === 'many',
1358
+ suggestion: `\`${clamp(render(v.p, 2))}\` is exactly \`${collapsed}\`. ${id.tag === 'optional' && d.tag === 'many' ? 'The nested form is also a zero-width-iteration hazard: the inner `optional` succeeds consuming nothing, which the repeat has to detect and break on every pass.' : ''} Candidate — verify AST identity: the collapsed form produces ONE level of array/undefined where the nested form produces two.`,
1359
+ })
1360
+ }
1361
+ }
1362
+
1363
+ // (b) single-element choice/sequence.
1364
+ if ((d.tag === 'choice' || d.tag === 'sequence') && d.parsers.length === 1) {
1365
+ push({
1366
+ rewrite: 'single-element', site,
1367
+ from: clamp(render(v.p, 2)),
1368
+ to: d.tag === 'choice' ? clamp(render(d.parsers[0]!, 2)) : `${clamp(render(d.parsers[0]!, 2))} (note: sequence() of one still wraps its value in a 1-tuple)`,
1369
+ astNeutral: d.tag === 'choice',
1370
+ perf: d.tag === 'choice' ? 'a one-arm choice still emits its dispatch scaffolding.' : PERF_NONE,
1371
+ bug: false,
1372
+ suggestion: d.tag === 'choice'
1373
+ ? 'a `choice` with one arm IS that arm — the wrapper only adds dispatch scaffolding. Safe to unwrap (choice forwards its arm value unchanged).'
1374
+ : 'a `sequence` with one element only exists to build a 1-tuple. If nothing reads that tuple, drop the wrapper — CANDIDATE, verify AST identity first.',
1375
+ })
1376
+ }
1377
+
1378
+ if (d.tag !== 'choice') continue
1379
+ const arms = d.parsers
1380
+ const armShapes = arms.map(shapeOf)
1381
+ const strategy = d.strategy as { tag: string; members?: number[] } | undefined
1382
+
1383
+ // (c) duplicate arms — the later one is dead.
1384
+ for (let i = 0; i < arms.length; i++) for (let j = i + 1; j < arms.length; j++) {
1385
+ if (armShapes[i] !== armShapes[j]) continue
1386
+ push({
1387
+ rewrite: 'duplicate-arm', site,
1388
+ from: `choice(… arm[${i}] …, arm[${j}] …)`,
1389
+ to: `delete arm[${j}]`,
1390
+ astNeutral: true,
1391
+ perf: PERF_ARM,
1392
+ bug: true,
1393
+ suggestion: `arm[${i}] and arm[${j}] are the SAME shape (\`${clamp(render(arms[i]!, 2))}\`). Under ordered choice arm[${i}] always wins, so arm[${j}] is unreachable — delete it. This is AST-neutral by construction: an arm that can never be selected contributes nothing to any parse.`,
1394
+ }, `${i}-${j}`)
1395
+ }
1396
+
1397
+ // (d) shadowed arm: an earlier arm is a strict TERM-PREFIX of a later one.
1398
+ for (let i = 0; i < arms.length; i++) for (let j = 0; j < arms.length; j++) {
1399
+ if (i >= j) continue
1400
+ if (armShapes[i] === armShapes[j]) continue
1401
+ const ta = termsOf(arms[i]!), tb = termsOf(arms[j]!)
1402
+ if (ta.length === 0 || ta.length >= tb.length) continue
1403
+ let k = 0
1404
+ while (k < ta.length && shapeOf(ta[k]!) === shapeOf(tb[k]!)) k++
1405
+ if (k !== ta.length) continue
1406
+ push({
1407
+ rewrite: 'shadowed-arm', site,
1408
+ from: `choice(… arm[${i}]=${clamp(render(arms[i]!, 2))}, … arm[${j}]=${clamp(render(arms[j]!, 2))} …)`,
1409
+ to: `arm[${j}] is unreachable`,
1410
+ astNeutral: true,
1411
+ perf: PERF_ARM,
1412
+ bug: true,
1413
+ suggestion: `arm[${i}] is a strict prefix of arm[${j}] and comes FIRST. Whenever arm[${j}] would match, arm[${i}] also matches, and ordered choice commits to it — so arm[${j}] can never be selected. Either swap them (longest first) or fold arm[${j}]'s extra terms into arm[${i}] as \`optional(…)\`. Verify by hand where a \`not()\`/\`gate()\` in arm[${i}] makes its success input-dependent in a way this static check cannot see.`,
1414
+ }, `${i}-${j}`)
1415
+ }
1416
+
1417
+ // (e) optional prefix / suffix — `choice(sequence(A, R…), R…)` is `sequence(optional(A), R…)`.
1418
+ for (let i = 0; i + 1 < arms.length; i++) {
1419
+ const j = i + 1
1420
+ const longer = termsOf(arms[i]!), shorter = termsOf(arms[j]!)
1421
+ const ordered = detectOptionalTerm(longer, shorter, shapeOf)
1422
+ const reversed = ordered === null ? detectOptionalTerm(shorter, longer, shapeOf) : null
1423
+ const hit = ordered ?? reversed
1424
+ if (hit === null) continue
1425
+ const nodeWrapped = v.enclosingNode ?? wrappingNodeType(arms[i]!) ?? wrappingNodeType(arms[j]!)
1426
+ const rest = hit.rest.map(t => render(t, 2)).join(', ')
1427
+ const to = hit.where === 'prefix'
1428
+ ? `sequence(optional(${render(hit.term, 2)}), ${rest})`
1429
+ : `sequence(${rest}, optional(${render(hit.term, 2)}))`
1430
+ push({
1431
+ rewrite: hit.where === 'prefix' ? 'optional-prefix' : 'optional-suffix',
1432
+ site,
1433
+ from: clamp(render(v.p, 2)),
1434
+ to: clamp(to),
1435
+ astNeutral: false,
1436
+ perf: PERF_ARM,
1437
+ bug: false,
1438
+ suggestion: `these two arms differ only by a ${hit.where === 'prefix' ? 'leading' : 'trailing'} \`${clamp(render(hit.term, 2))}\`, so the choice is exactly \`${clamp(to)}\` — same language, one arm instead of two.${reversed !== null ? ` NOTE THE ORDER: the shorter arm is FIRST, so under ordered choice the longer arm is only reachable when the shorter one fails — check that is what you meant.` : ''} CANDIDATE — verify AST identity before applying:${nodeWrapped === null ? '' : ` this sits under \`node('${nodeWrapped}')\`, and`} the two forms produce different child arrays (two children vs. one child plus an absent optional). If a build fn or a downstream consumer reads children positionally, the rewrite MOVES the tree.`,
1439
+ }, `${i}-${j}`)
1440
+ }
1441
+
1442
+ // (f) left-factoring.
1443
+ for (let i = 0; i + 1 < arms.length; i++) {
1444
+ const j = i + 1
1445
+ const ta = termsOf(arms[i]!), tb = termsOf(arms[j]!)
1446
+ if (ta.length < 2 || tb.length < 2) continue
1447
+ let k = 0
1448
+ while (k < ta.length - 1 && k < tb.length - 1 && shapeOf(ta[k]!) === shapeOf(tb[k]!)) k++
1449
+ if (k === 0) continue
1450
+ const handled = strategy?.tag === 'sharedPrefix'
1451
+ && Array.isArray(strategy.members) && strategy.members.includes(i) && strategy.members.includes(j)
1452
+ push({
1453
+ rewrite: 'left-factor', site,
1454
+ from: `choice(sequence(${ta.map(t => render(t, 1)).join(', ')}), sequence(${tb.map(t => render(t, 1)).join(', ')}))`,
1455
+ to: clamp(`sequence(${ta.slice(0, k).map(t => render(t, 2)).join(', ')}, choice(sequence(${ta.slice(k).map(t => render(t, 1)).join(', ')}), sequence(${tb.slice(k).map(t => render(t, 1)).join(', ')})))`),
1456
+ astNeutral: false,
1457
+ perf: handled
1458
+ ? 'NONE — parseman\'s `sharedPrefix` choice strategy already recognizes this prefix once and replays it, so the runtime cost is identical. This is a readability finding.'
1459
+ : PERF_ARM,
1460
+ bug: false,
1461
+ suggestion: `arm[${i}] and arm[${j}] spell the same ${k} leading term(s). ${handled ? 'The `sharedPrefix` strategy already collapses this AT RUNTIME, so left-factoring buys readability, not speed — but writing it out means the next reader does not have to trust the optimizer.' : 'Left-factor to `sequence(prefix, choice(tailA, tailB))`: the prefix is then recognized once and the inner choice dispatches on its own first char.'} CANDIDATE — verify AST identity: factoring nests the tails one level deeper in the value.`,
1462
+ }, `${i}-${j}`)
1463
+ }
1464
+ }
1465
+
1466
+ // (g) hand-rolled sepBy — sequence(X, many(sequence(S, X))).
1467
+ for (const v of visited.values()) {
1468
+ if (v.d.tag !== 'sequence') continue
1469
+ const hit = detectHandRolledSepBy(v.d.parsers, shapeOf)
1470
+ if (hit === null) continue
1471
+ // Anywhere in the REPETITION, not just on the separator itself: a `field()`
1472
+ // wrapping the whole `sequence(sep, item)` is the same blocker.
1473
+ const captured = v.d.parsers.some(t => capturesPositionally(t))
1474
+ const verdict: SepByVerdict = captured ? 'blocked-by-capture' : v.enclosingReducer ? 'reducer-stride-review' : 'convertible'
1475
+ const to = `sepBy(${clamp(render(hit.item, 2))}, ${clamp(render(hit.sep, 2))}${hit.trailing ? `, { trailing: 'allow' }` : ''}, { min: ${hit.min} })`
1476
+ out.push({
1477
+ kind: 'rewrite',
1478
+ id: `rewrite:hand-rolled-sepby:${siteToString(v.site)}`,
1479
+ rewrite: 'hand-rolled-sepby',
1480
+ site: v.site,
1481
+ sepByVerdict: verdict,
1482
+ from: clamp(render(v.p, 3)),
1483
+ to: verdict === 'convertible' ? to : `${to} — NOT APPLICABLE HERE (${verdict})`,
1484
+ astNeutral: false,
1485
+ perf: verdict === 'convertible'
1486
+ ? 'the hand-rolled form allocates a tuple per repetition (`sequence(sep, item)`) and a wrapper array around them; `sepBy` builds the item list directly.'
1487
+ : PERF_NONE,
1488
+ bug: false,
1489
+ suggestion: `${SEPBY_SPELLED_OUT(hit.trailing)} ${SEPBY_VERDICT_TEXT[verdict](hit.trailing)}`,
1490
+ })
1491
+ }
1492
+
1493
+ return out
1494
+ }
1495
+
1496
+ function collapseRepeat(outer: ParserDef, inner: ParserDef): string | null {
1497
+ const o = outer.tag, i = inner.tag
1498
+ if (o === 'optional' && i === 'optional') return 'optional(X)'
1499
+ if (o === 'optional' && i === 'many') return 'many(X)'
1500
+ if (o === 'many' && i === 'many') return 'many(X)'
1501
+ if (o === 'many' && i === 'optional') return 'many(X)'
1502
+ if (o === 'optional' && i === 'oneOrMore' && inner.min === 1 && inner.max === undefined) return 'many(X)'
1503
+ if (o === 'many' && i === 'oneOrMore' && inner.min === 1 && inner.max === undefined) return 'many(X)'
1504
+ return null
1505
+ }
1506
+
1507
+ /** `node()` type wrapping an arm, for the AST-neutrality caveat. */
1508
+ function wrappingNodeType(arm: Combinator<unknown>): string | null {
1509
+ const d = arm._def as ParserDef
1510
+ return d.tag === 'node' ? d.type ?? '(anonymous)' : null
1511
+ }
1512
+
1513
+ /**
1514
+ * `longer` = `shorter` plus ONE extra term at the front or the back. That extra
1515
+ * term is exactly an `optional()`.
1516
+ */
1517
+ function detectOptionalTerm(
1518
+ longer: readonly Combinator<unknown>[],
1519
+ shorter: readonly Combinator<unknown>[],
1520
+ shapeOf: (p: Combinator<unknown>) => number,
1521
+ ): { where: 'prefix' | 'suffix'; term: Combinator<unknown>; rest: readonly Combinator<unknown>[] } | null {
1522
+ if (longer.length !== shorter.length + 1 || shorter.length === 0) return null
1523
+ const same = (a: readonly Combinator<unknown>[], b: readonly Combinator<unknown>[]): boolean =>
1524
+ a.length === b.length && a.every((x, i) => shapeOf(x) === shapeOf(b[i]!))
1525
+ if (same(longer.slice(1), shorter)) return { where: 'prefix', term: longer[0]!, rest: shorter }
1526
+ if (same(longer.slice(0, -1), shorter)) return { where: 'suffix', term: longer[longer.length - 1]!, rest: shorter }
1527
+ return null
1528
+ }
1529
+
1530
+ const SEPBY_SPELLED_OUT = (trailing: boolean): string =>
1531
+ `this is \`sepBy(item, sep${trailing ? ", { trailing: 'allow' }" : ''})\` spelled out.`
1532
+
1533
+ /** The verdict text. Each says what to DO, and the blocked ones say why not. */
1534
+ const SEPBY_VERDICT_TEXT: Record<SepByVerdict, (trailing: boolean) => string> = {
1535
+ 'blocked-by-capture': () =>
1536
+ 'BLOCKED: the repetition CAPTURES its separator (`field`/`label`), which `sepBy` cannot express — it yields items and discards separators, so byte-faithful layout replay is lost. Leave this site alone; read it as a parseman gap (a separator-capturing repeat), not as work.',
1537
+ 'reducer-stride-review': () =>
1538
+ 'NEEDS REVIEW, not conversion: an enclosing `node()`/`transform()` reducer reads this repetition\'s children, and a left-associating reducer typically STRIDES BY TWO over `[item, sep, item, sep, …]` and re-emits the operator text. `sepBy` yields a FLAT ITEM list with the separators gone, so the reducer has to be rewritten in the same change or the tree moves. Convert only together with the reducer.',
1539
+ convertible: (trailing: boolean) =>
1540
+ `No separator capture, and no enclosing reducer reads these children — this site is convertible. Beyond the ${trailing ? 'four' : 'three'} combinators it replaces, \`sepBy\` carries the right NULLABILITY: \`min: 0\` (the default) matches empty and does NOT gate as a choice arm, \`min: 1\` does — a distinction the hand-rolled shape leaves implicit and gets wrong silently. Still check the consumer: \`sepBy\` yields a flat item list where the hand-rolled form yields \`[first, [[sep, item], …]]\`.`,
1541
+ }
1542
+
1543
+ /**
1544
+ * Whether a hand-rolled separated list can ACTUALLY become `sepBy` — a verdict per
1545
+ * site, not a count. Reporting every match as convertible generates false work: on
1546
+ * the reference Less grammar only a minority are, and the rest are blocked for two
1547
+ * concrete, detectable reasons.
1548
+ */
1549
+ export type SepByVerdict = 'convertible' | 'blocked-by-capture' | 'reducer-stride-review'
1550
+
1551
+ /** Does this subtree capture anything positionally (`field`/`label`)? */
1552
+ function capturesPositionally(p: Combinator<unknown>, seen = new Set<Combinator<unknown>>()): boolean {
1553
+ if (seen.has(p)) return false
1554
+ seen.add(p)
1555
+ const d = p._def as ParserDef
1556
+ if (d.tag === 'field' || d.tag === 'label') return true
1557
+ for (const k of childrenOf(d)) if (capturesPositionally(k, seen)) return true
1558
+ return false
1559
+ }
1560
+
1561
+ /** `sequence(X, many(sequence(S, X)) [, optional(S)])` — a `sepBy` written out. */
1562
+ function detectHandRolledSepBy(
1563
+ terms: readonly Combinator<unknown>[],
1564
+ shapeOf: (p: Combinator<unknown>) => number,
1565
+ ): { item: Combinator<unknown>; sep: Combinator<unknown>; trailing: boolean; min: number } | null {
1566
+ if (terms.length < 2 || terms.length > 3) return null
1567
+ const item = terms[0]!
1568
+ const rep = terms[1]!._def as ParserDef
1569
+ if (rep.tag !== 'many' && rep.tag !== 'oneOrMore') return null
1570
+ if (rep.max !== undefined) return null
1571
+ const innerDef = rep.parser._def as ParserDef
1572
+ if (innerDef.tag !== 'sequence' || innerDef.parsers.length !== 2) return null
1573
+ const [sep, repeated] = innerDef.parsers as [Combinator<unknown>, Combinator<unknown>]
1574
+ if (shapeOf(repeated) !== shapeOf(item)) return null
1575
+ let trailing = false
1576
+ if (terms.length === 3) {
1577
+ const t = terms[2]!._def as ParserDef
1578
+ if (t.tag !== 'optional' || shapeOf(t.parser) !== shapeOf(sep)) return null
1579
+ trailing = true
1580
+ }
1581
+ // The leading item plus whatever the repetition itself requires. `rep.min` is
1582
+ // already RESOLVED for both tags (`many` defaults it to 0, `oneOrMore` to 1), so
1583
+ // one expression covers both — special-casing the tag dropped an explicit
1584
+ // `many(…, { min: n })` back to 1 and under-reported the rewrite's bound.
1585
+ return { item, sep, trailing, min: 1 + rep.min }
1586
+ }
1587
+
1588
+ // ── 5b. one node type, several divergent productions ─────────────────────────
1589
+
1590
+ /**
1591
+ * The term list of a `node()`'s BODY. Peels more wrappers than `termsOf` — a
1592
+ * clone typically differs from its original by exactly such a wrapper
1593
+ * (`noTrivia(…)` around the sequence), and that difference is the thing to see
1594
+ * through, not the thing to report.
1595
+ */
1596
+ function bodyTermsOf(p: Combinator<unknown>): readonly Combinator<unknown>[] {
1597
+ let cur = p
1598
+ for (;;) {
1599
+ const d = cur._def as ParserDef
1600
+ if (d.tag === 'node' || d.tag === 'grammar' || d.tag === 'transform' || d.tag === 'label'
1601
+ || d.tag === 'attempt' || d.tag === 'leaf' || d.tag === 'token' || d.tag === 'expect' || d.tag === 'field') {
1602
+ cur = (d as { parser: Combinator<unknown> }).parser
1603
+ continue
1604
+ }
1605
+ break
1606
+ }
1607
+ const d = cur._def as ParserDef
1608
+ return d.tag === 'sequence' ? d.parsers : [cur]
1609
+ }
1610
+
1611
+ /** Terms must overlap this much before two productions count as variants of one
1612
+ * shape rather than two genuinely different constructs that share a node type. */
1613
+ const MIN_SHARED_TERMS = 2
1614
+
1615
+ function findDivergentNodes(
1616
+ visited: Map<Combinator<unknown>, Visited>,
1617
+ shapeOf: (p: Combinator<unknown>) => number,
1618
+ ): DivergentNodeFinding[] {
1619
+ const byType = new Map<string, Map<number, Visited>>()
1620
+ for (const v of visited.values()) {
1621
+ if (v.d.tag !== 'node' || v.d.type === undefined) continue
1622
+ let m = byType.get(v.d.type)
1623
+ if (m === undefined) { m = new Map(); byType.set(v.d.type, m) }
1624
+ if (!m.has(v.shape)) m.set(v.shape, v)
1625
+ }
1626
+
1627
+ const out: DivergentNodeFinding[] = []
1628
+ for (const [type, m] of byType) {
1629
+ if (m.size < 2) continue
1630
+ const members = [...m.values()]
1631
+ const termSets = members.map(v => bodyTermsOf(v.p).map(t => ({ id: shapeOf(t), text: clamp(render(t, 2)) })))
1632
+ // Shared = spelled by EVERY production. That is what makes them one shape.
1633
+ const shared = termSets[0]!.filter(t => termSets.every(set => set.some(x => x.id === t.id)))
1634
+ const uniqueShared = [...new Map(shared.map(t => [t.id, t])).values()]
1635
+ if (uniqueShared.length < MIN_SHARED_TERMS) continue
1636
+ const sharedIds = new Set(uniqueShared.map(t => t.id))
1637
+ out.push({
1638
+ kind: 'divergent-node',
1639
+ id: `divergent-node:${type}`,
1640
+ nodeType: type,
1641
+ count: members.length,
1642
+ sharedTerms: uniqueShared.map(t => t.text),
1643
+ productions: members.map((v, i) => ({
1644
+ shape: clamp(render(v.p, 3)),
1645
+ site: v.site,
1646
+ distinctTerms: termSets[i]!.filter(t => !sharedIds.has(t.id)).map(t => t.text),
1647
+ })),
1648
+ suggestion: `\`${type}\` is built by ${members.length} structurally different productions that nevertheless spell ${uniqueShared.length} identical term(s) (${uniqueShared.map(t => `\`${t.text}\``).join(', ')}) — they are variants of ONE shape, not ${members.length} constructs. Nothing keeps them in sync: an edit to the ${type} shape has to land in all ${members.length}, and no test fails when it lands in one. Fold them into a single production whose varying part is a \`choice\`, or, if the variants exist for a parse-order reason (a fast path tried first), say so at the definition — the cost of the split is that a reader cannot tell which it is.`,
1649
+ })
1650
+ }
1651
+ return out.sort((a, b) => b.sharedTerms.length * b.count - a.sharedTerms.length * a.count)
1652
+ }
1653
+
1654
+ // ── 5c. an earlier arm that flattens what a later arm structures ─────────────
1655
+
1656
+ /**
1657
+ * Peel the wrappers that are REFERENTIAL or purely annotative, so an arm written
1658
+ * `g.FlatDecl` is compared as the production it names. `transform`/`leaf`/`token`
1659
+ * are deliberately NOT peeled: each of them collapses its interior to one value
1660
+ * on purpose, and treating a `leaf(node('X', …))` as if it still produced an `X`
1661
+ * would invert the very question this analysis asks.
1662
+ */
1663
+ function resolveArm(p: Combinator<unknown>): Combinator<unknown> {
1664
+ let cur = p
1665
+ const seen = new Set<Combinator<unknown>>()
1666
+ for (;;) {
1667
+ if (seen.has(cur)) return cur
1668
+ seen.add(cur)
1669
+ const d = cur._def as ParserDef
1670
+ if (d.tag === 'label' || d.tag === 'attempt') { cur = d.parser; continue }
1671
+ if (d.tag === 'lazy') {
1672
+ try { cur = (d as { thunk(): Combinator<unknown> }).thunk(); continue }
1673
+ catch { return cur } // unresolved cross-artifact hole
1674
+ }
1675
+ return cur
1676
+ }
1677
+ }
1678
+
1679
+ /**
1680
+ * The `node()` types this subtree can put INSIDE the tree, excluding its own root
1681
+ * wrapper. Refs are followed (the structure a declaration's value builds usually
1682
+ * lives behind `g.valueList`, and an analysis that stopped at the ref would report
1683
+ * every grammar as flat). `leaf()`/`token()` are opaque: they reduce their
1684
+ * interior to a single value, so nothing under one reaches the tree.
1685
+ */
1686
+ function nestedNodeTypes(
1687
+ root: Combinator<unknown>,
1688
+ out: Set<string>,
1689
+ seen: Set<Combinator<unknown>>,
1690
+ isRoot: boolean,
1691
+ ): void {
1692
+ if (seen.has(root)) return
1693
+ seen.add(root)
1694
+ const d = root._def as ParserDef
1695
+ if (d.tag === 'leaf' || d.tag === 'token') return
1696
+ if (d.tag === 'node' && !isRoot) out.add(d.type ?? '(anonymous)')
1697
+ if (d.tag === 'lazy') {
1698
+ try { nestedNodeTypes((d as { thunk(): Combinator<unknown> }).thunk(), out, seen, false) }
1699
+ catch { /* unresolved cross-artifact hole */ }
1700
+ return
1701
+ }
1702
+ for (const k of childrenOf(d)) nestedNodeTypes(k, out, seen, false)
1703
+ }
1704
+
1705
+ /** Most node types worth naming in one finding before it stops being a list. */
1706
+ const MAX_LOST_TYPES = 8
1707
+
1708
+ function findStructureLoss(
1709
+ visited: Map<Combinator<unknown>, Visited>,
1710
+ resolve?: RefResolver,
1711
+ ): StructureLossFinding[] {
1712
+ const nestedCache = new Map<Combinator<unknown>, Set<string>>()
1713
+ const nested = (p: Combinator<unknown>): Set<string> => {
1714
+ const hit = nestedCache.get(p)
1715
+ if (hit !== undefined) return hit
1716
+ const out = new Set<string>()
1717
+ nestedNodeTypes(p, out, new Set(), true)
1718
+ nestedCache.set(p, out)
1719
+ return out
1720
+ }
1721
+
1722
+ const findings: StructureLossFinding[] = []
1723
+ for (const v of visited.values()) {
1724
+ const d = v.d
1725
+ if (d.tag !== 'choice') continue
1726
+ const arms = d.parsers
1727
+ const resolved = arms.map(resolveArm)
1728
+ const nodeTypes = resolved.map(wrappingNodeType)
1729
+ const fs = arms.map(a => firstSetOf(a, new Set(), resolve))
1730
+
1731
+ for (let i = 0; i < arms.length; i++) {
1732
+ // A GATED arm is selected by a runtime predicate, not by order alone. The
1733
+ // author has said which branch applies when; that is a deliberate split,
1734
+ // not a shadow, so it is not reported as a bug.
1735
+ if (d.gates[i] != null) continue
1736
+ const type = nodeTypes[i] ?? null
1737
+ if (type === null) continue
1738
+ // Only the arm that produces NO nested node is reported. "Earlier arm is
1739
+ // strictly poorer than later arm" is the same family, but grading it means
1740
+ // comparing two ref-reachable type sets, and in a recursive grammar the
1741
+ // later set reaches most of the grammar — so the graded rule fires on
1742
+ // almost every pair and stops being a signal. Empty-vs-nonempty needs no
1743
+ // threshold and is exactly the reported defect: a node over bare leaves.
1744
+ if (nested(resolved[i]!).size !== 0) continue
1745
+ const A = fs[i]!
1746
+ if (A.kind === 'any') continue // gating's `anyArms` owns the any-first-set case
1747
+
1748
+ for (let j = i + 1; j < arms.length; j++) {
1749
+ if (nodeTypes[j] !== type) continue
1750
+ const B = fs[j]!
1751
+ // OVER-APPROXIMATION, and the one place this finding can be wrong. A shared
1752
+ // first character is NECESSARY for the earlier arm to shadow the later one,
1753
+ // not sufficient: two arms can both start on `-` and still accept disjoint
1754
+ // languages (`-webkit-x` vs `-5px`), in which case no input reaches both and
1755
+ // nothing is shadowed. Deciding "do these two arms share an input" is
1756
+ // language intersection, which is undecidable for a general CFG, so a
1757
+ // first-set test is the strongest decidable proxy available. The finding is
1758
+ // therefore "these arms overlap on these characters, and IF an input reaches
1759
+ // both then structure is lost" — which is what the suggestion text says, and
1760
+ // why a reader is pointed at the two shapes rather than handed a verdict.
1761
+ if (B.kind === 'any' || !intersects(A, B)) continue
1762
+ const lost = nested(resolved[j]!)
1763
+ if (lost.size === 0) continue
1764
+ const lostNodeTypes = [...lost].sort()
1765
+ findings.push({
1766
+ kind: 'structure-loss',
1767
+ id: `structure-loss:${siteToString(v.site)}:${i}-${j}`,
1768
+ site: v.site,
1769
+ nodeType: type,
1770
+ earlier: i,
1771
+ later: j,
1772
+ on: intersection(A, B),
1773
+ lostNodeTypes: lostNodeTypes.slice(0, MAX_LOST_TYPES),
1774
+ earlierShape: clamp(render(resolved[i]!, 3)),
1775
+ laterShape: clamp(render(resolved[j]!, 3)),
1776
+ suggestion: structureLossSuggestion(type, i, j, lostNodeTypes),
1777
+ })
1778
+ }
1779
+ }
1780
+ }
1781
+ return findings.sort((a, b) => b.lostNodeTypes.length - a.lostNodeTypes.length)
1782
+ }
1783
+
1784
+ function structureLossSuggestion(
1785
+ type: string,
1786
+ i: number,
1787
+ j: number,
1788
+ lost: readonly string[],
1789
+ ): string {
1790
+ const names = lost.slice(0, MAX_LOST_TYPES).map(t => `\`${t}\``).join(', ')
1791
+ const more = lost.length > MAX_LOST_TYPES ? ` (and ${lost.length - MAX_LOST_TYPES} more)` : ''
1792
+ return `arm[${i}] and arm[${j}] both build \`${type}\` and can start on the same character, so on every input BOTH accept, arm[${i}] wins — and arm[${i}] contains no \`node()\`, so it yields a \`${type}\` over bare leaves. arm[${j}] would have built ${names}${more} there. The parse still succeeds and the span is still right; only the tree shape changes, and only on the inputs arm[${i}] happens to accept — so a suite that asserts "it parses" and an output diff both stay green while every consumer of the tree (language service, formatter, refactor) silently loses those nodes on the commonest inputs. Decide which tree is the contract: if the structured one is, DELETE arm[${i}] or move it after arm[${j}]; if the flat one is, it must be flat for ALL inputs of this shape, not just the ones arm[${i}] matches — a fast path that is not tree-neutral is not a fast path.`
1793
+ }
1794
+
1795
+ // ── 6. hand-rolled keyword regexes ───────────────────────────────────────────
1796
+
1797
+ function findKeywordRegexes(visited: Map<Combinator<unknown>, Visited>): KeywordRegexFinding[] {
1798
+ // Sibling map: which choice arms are themselves hand-rolled keywords.
1799
+ const armIndexOf = new Map<Combinator<unknown>, { owner: Combinator<unknown>; index: number }>()
1800
+ for (const v of visited.values()) {
1801
+ if (v.d.tag !== 'choice') continue
1802
+ v.d.parsers.forEach((a, i) => {
1803
+ let cur = a
1804
+ for (;;) {
1805
+ const cd = cur._def as ParserDef
1806
+ if (cd.tag === 'node' || cd.tag === 'label' || cd.tag === 'transform' || cd.tag === 'leaf' || cd.tag === 'attempt') { cur = cd.parser; continue }
1807
+ break
1808
+ }
1809
+ armIndexOf.set(cur, { owner: v.p, index: i })
1810
+ })
1811
+ }
1812
+ const keywordArms = new Map<Combinator<unknown>, number[]>()
1813
+ for (const [arm, { owner }] of armIndexOf) {
1814
+ const d = arm._def as ParserDef
1815
+ if (d.tag !== 'regex' || keywordRegexShape(d.source) === null) continue
1816
+ const list = keywordArms.get(owner) ?? []
1817
+ list.push(armIndexOf.get(arm)!.index)
1818
+ keywordArms.set(owner, list)
1819
+ }
1820
+
1821
+ const out: KeywordRegexFinding[] = []
1822
+ for (const v of visited.values()) {
1823
+ const d = v.d
1824
+ if (d.tag !== 'regex') continue
1825
+ const shape = keywordRegexShape(d.source)
1826
+ if (shape === null) continue
1827
+ const ci = d.flags.includes('i')
1828
+ const unicode = d.flags.includes('u') || d.flags.includes('v')
1829
+ const own = armIndexOf.get(v.p)
1830
+ const siblings = own === undefined ? [] : (keywordArms.get(own.owner) ?? []).filter(i => i !== own.index)
1831
+ const bq = (s: string): string => `'${s.replace(/\\/g, '\\\\')}'`
1832
+ const vocabulary = shape.words.length >= VOCABULARY_MIN_WORDS
1833
+ const hazards = keywordAlternationHazards(shape.words, shape.boundary)
1834
+ const unrescued = hazards.filter(h => !h.rescuedByBoundary)
1835
+ const longestFirst = shape.words.every((w, i) => i === 0 || shape.words[i - 1]!.length >= w.length)
1836
+ const shown = shape.words.length > 8 ? [...shape.words.slice(0, 6), '/* … */', shape.words[shape.words.length - 1]!] : shape.words
1837
+ const call = shape.words.length === 1
1838
+ ? `word(${bq(shape.words[0]!)}, ${bq(shape.boundary ?? '_0-9A-Za-z')}${ci ? ', { caseInsensitive: true }' : ''})`
1839
+ : `keywords([${shown.map(w => (w === '/* … */' ? w : bq(w))).join(', ')}], { boundary: ${bq(shape.boundary ?? '_0-9A-Za-z')}${ci ? ', caseInsensitive: true' : ''} })`
1840
+ out.push({
1841
+ kind: 'keyword-regex',
1842
+ id: `keyword-regex:${siteToString(v.site)}`,
1843
+ site: v.site,
1844
+ source: d.source,
1845
+ flags: d.flags,
1846
+ words: shape.words,
1847
+ boundary: shape.boundary,
1848
+ caseFoldRisk: ci && !unicode,
1849
+ siblingArms: siblings,
1850
+ vocabulary,
1851
+ longestFirst,
1852
+ hazards,
1853
+ bug: unrescued.length > 0,
1854
+ suggestion: [
1855
+ vocabulary
1856
+ ? `this regex enumerates a FIXED VOCABULARY of ${shape.words.length} literal words${shape.words.length > 20 ? ' — the alternation is long enough that nobody re-reads it' : ''}. A regex enumerating a fixed vocabulary is a keyword set written the hard way. Use \`${call}\` — it exposes an EXACT first-set that \`choice\` can dispatch on (the alternation exposes none), it owns the boundary so it is not re-spelled per word, it sorts LONGEST-FIRST by construction, and it lowers to a single charCodeAt scan.`
1857
+ : `\`regex(/${d.source}/${d.flags})\` hand-rolls a keyword and its word boundary. Use \`${call}\` — it owns the boundary (so \`${shape.boundary ?? ''}\` is not re-spelled per keyword), exposes an EXACT first-set that \`choice\` can dispatch on, and lowers to the same charCodeAt scan.`,
1858
+ unrescued.length > 0
1859
+ ? `LIVE BUG — the alternation is ordered so ${unrescued.length} later alternative(s) can NEVER match: ${unrescued.slice(0, 3).map(h => `\`${h.shorter}\` precedes \`${h.longer}\` and nothing rejects the following \`${h.at}\``).join('; ')}${unrescued.length > 3 ? `, and ${unrescued.length - 3} more` : ''}. Regex alternation is FIRST-match, not longest-match, and with no boundary guard to force a backtrack the shorter branch wins outright. \`keywords()\` cannot express this bug: it sorts longest-first.`
1860
+ : hazards.length > 0
1861
+ ? `ORDERING HAZARD (currently harmless): ${hazards.length} earlier alternative(s) are strict prefixes of later ones — e.g. \`${hazards[0]!.shorter}\` before \`${hazards[0]!.longer}\`. Regex alternation is first-match, so these work ONLY because the trailing boundary guard rejects the next character and the engine backtracks into the longer branch. That correctness rests on a hand-maintained order plus a guard that happens to cover the right characters; \`keywords()\` sorts longest-first by construction and does not depend on either.`
1862
+ : !longestFirst && vocabulary
1863
+ ? `The alternation is not in longest-first order. It is correct today${shape.boundary === null ? '' : ' (the boundary guard forces a backtrack where it matters)'}, but the ordering is hand-maintained and nothing checks it — adding a word that extends an existing one is a silent bug. \`keywords()\` sorts longest-first by construction.`
1864
+ : '',
1865
+ ci && !unicode
1866
+ ? `CORRECTNESS, not style: \`/i\` without \`/u\` does NOT fold case the way \`{c, toUpperCase(c), toLowerCase(c)}\` suggests — 67 BMP code points fold in ways those three miss (ς/σ, µ/μ, the DŽDždž digraphs, combining iota subscript). parseman fixed exactly this INSIDE \`keywords()\` (see combinators/case-fold.ts); a hand-rolled copy never received the fix, so its first-set is unsound for those inputs.`
1867
+ : '',
1868
+ siblings.length > 0
1869
+ ? `arm[${own?.index}] here sits alongside arm(s) [${siblings.join(', ')}] in the same \`choice\`, which are also hand-rolled keywords. A single \`keywords([…])\` replaces the whole group and gives longest-match dispatch across it — which is what that combinator exists for.`
1870
+ : '',
1871
+ ].filter(s => s !== '').join(' '),
1872
+ })
1873
+ }
1874
+ // Bugs first, then the biggest vocabularies: a 150-word alternation is both the
1875
+ // most valuable conversion and the least likely to be spotted by reading.
1876
+ return out.sort((a, b) =>
1877
+ Number(b.bug) - Number(a.bug)
1878
+ || b.hazards.length - a.hazards.length
1879
+ || b.words.length - a.words.length
1880
+ || b.siblingArms.length - a.siblingArms.length)
1881
+ }
1882
+
1883
+ // ── formatting ───────────────────────────────────────────────────────────────
1884
+
1885
+ export type DuplicationWarnLevel = 'off' | 'warn' | 'error'
1886
+
1887
+ const sitesLine = (sites: readonly Site[]): string =>
1888
+ sites.slice(0, 6).map(siteToString).join('\n ') + (sites.length > 6 ? `\n … and ${sites.length - 6} more` : '')
1889
+
1890
+ /**
1891
+ * Ranked, actionable lines — same tone and shape as `formatGatingWarnings`: what
1892
+ * it is, where it is, and one concrete thing to do about it.
1893
+ */
1894
+ export function formatDuplicationFindings(report: DuplicationReport): string[] {
1895
+ const lines: string[] = []
1896
+ // First: the only family here that is silently WRONG rather than redundant —
1897
+ // the parse succeeds, so nothing else reports it.
1898
+ for (const f of report.structureLoss) {
1899
+ lines.push(`parseman BUG [structure-loss] @ ${siteToString(f.site)}: arm[${f.earlier}] flattens \`${f.nodeType}\` where arm[${f.later}] structures it, on ${firstSetToString(f.on)}`)
1900
+ lines.push(` flat: ${f.earlierShape}`)
1901
+ lines.push(` rich: ${f.laterShape}`)
1902
+ lines.push(` lost: ${f.lostNodeTypes.map(t => `\`${t}\``).join(', ')}`)
1903
+ lines.push(` fix: ${f.suggestion}`)
1904
+ }
1905
+ for (const f of report.rewrites) {
1906
+ lines.push(`parseman ${f.bug ? 'BUG' : 'rewrite'} [${f.rewrite}${f.sepByVerdict === undefined ? '' : `/${f.sepByVerdict}`}] @ ${siteToString(f.site)}${f.astNeutral ? '' : ' (candidate — verify AST identity)'}`)
1907
+ lines.push(` from: ${f.from}`)
1908
+ lines.push(` to: ${f.to}`)
1909
+ lines.push(` fix: ${f.suggestion}`)
1910
+ if (f.perf !== '') lines.push(` perf: ${f.perf}`)
1911
+ }
1912
+ for (const f of report.divergentNodes) {
1913
+ lines.push(`parseman divergent-node: \`${f.nodeType}\` is built by ${f.count} different productions sharing ${f.sharedTerms.length} term(s): ${f.sharedTerms.map(t => `\`${t}\``).join(', ')}`)
1914
+ for (const p of f.productions) lines.push(` ${siteToString(p.site)}\n ${p.shape}\n unique to it: ${p.distinctTerms.length === 0 ? '(none)' : p.distinctTerms.map(t => `\`${t}\``).join(', ')}`)
1915
+ lines.push(` fix: ${f.suggestion}`)
1916
+ }
1917
+ for (const f of report.keywordRegexes) {
1918
+ const src = f.source.length > 90 ? `${f.source.slice(0, 88)}…` : f.source
1919
+ lines.push(`parseman ${f.bug ? 'BUG ' : ''}keyword-regex @ ${siteToString(f.site)}: /${src}/${f.flags}${f.vocabulary ? ` [${f.words.length} literal words${f.longestFirst ? '' : ', NOT longest-first'}]` : ''}${f.hazards.length > 0 ? ` [${f.hazards.length} prefix hazard(s)${f.bug ? ', UNRESCUED' : ''}]` : ''}${f.caseFoldRisk ? ' [CASE-FOLD RISK]' : ''}`)
1920
+ lines.push(` fix: ${f.suggestion}`)
1921
+ }
1922
+ for (const f of report.duplicates) {
1923
+ lines.push(`parseman duplication: ${f.count}× identical ${f.size}-node shape (saves ${f.savings} nodes) — ${f.shape}`)
1924
+ lines.push(` at: ${sitesLine(f.sites)}`)
1925
+ lines.push(` fix: ${f.suggestion}`)
1926
+ }
1927
+ for (const f of report.nearDuplicates) {
1928
+ lines.push(`parseman near-duplication: ${f.count} clones of a ${f.sharedSize}-node shape differing at ONE slot (saves ${f.savings} nodes) — ${f.shape}`)
1929
+ lines.push(` slot: ${f.slotPath} ∈ { ${f.variants.join(' | ')} }`)
1930
+ lines.push(` at: ${sitesLine(f.sites)}`)
1931
+ lines.push(` fix: ${f.suggestion}`)
1932
+ }
1933
+ for (const f of report.regexFragments) {
1934
+ lines.push(`parseman regex-fragment: \`${f.fragment}\` (${f.branches} branches) re-spelled in ${f.count} regex() terminals`)
1935
+ lines.push(` at: ${sitesLine(f.sites)}`)
1936
+ lines.push(` fix: ${f.suggestion}`)
1937
+ }
1938
+ for (const f of report.regexClasses) {
1939
+ lines.push(`parseman regex-class${f.drifted ? ' DRIFT' : ''}: [${f.canonical}] across ${f.count} regex() terminals${f.bmpCeiling ? ' [BMP ceiling]' : ''}`)
1940
+ for (const v of f.variants) lines.push(` [${v.source}] ×${v.count} ${v.delta}\n ${sitesLine(v.sites)}`)
1941
+ lines.push(` fix: ${f.suggestion}`)
1942
+ }
1943
+ for (const f of report.overlaps) {
1944
+ lines.push(`parseman overlap @ ${siteToString(f.site)}: arm[${f.a}] ∩ arm[${f.b}] on ${firstSetToString(f.on)}${f.sharedPrefix === null ? '' : ` — shared prefix: ${f.sharedPrefix}`}${f.handledByStrategy ? ' [sharedPrefix strategy already handles this]' : ''}`)
1945
+ lines.push(` fix: ${f.suggestion}`)
1946
+ }
1947
+ return lines
1948
+ }
1949
+
1950
+ /** Total findings across every category — the number the `'error'` gate keys on. */
1951
+ export function duplicationFindingCount(report: DuplicationReport): number {
1952
+ return report.duplicates.length + report.nearDuplicates.length + report.regexFragments.length
1953
+ + report.regexClasses.length + report.overlaps.length + report.rewrites.length
1954
+ + report.divergentNodes.length + report.structureLoss.length + report.keywordRegexes.length
1955
+ }