parseman 0.46.0 → 0.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (316) hide show
  1. package/README.md +8 -3
  2. package/dist/analysis/choice-cost-render.d.ts +12 -4
  3. package/dist/analysis/choice-cost-render.d.ts.map +1 -1
  4. package/dist/analysis/choice-cost.d.ts +2 -2
  5. package/dist/analysis/choice-cost.d.ts.map +1 -1
  6. package/dist/analysis/commitment.d.ts +131 -0
  7. package/dist/analysis/commitment.d.ts.map +1 -0
  8. package/dist/analysis/diagnostics.cjs +9929 -7465
  9. package/dist/analysis/diagnostics.cjs.map +3 -4
  10. package/dist/analysis/diagnostics.d.ts +2 -2
  11. package/dist/analysis/diagnostics.js +9929 -7465
  12. package/dist/analysis/diagnostics.js.map +3 -4
  13. package/dist/analysis/duplication.d.ts.map +1 -1
  14. package/dist/analysis/fix.d.ts +3 -3
  15. package/dist/analysis/fix.d.ts.map +1 -1
  16. package/dist/analysis/format-number.d.ts +23 -0
  17. package/dist/analysis/format-number.d.ts.map +1 -0
  18. package/dist/analysis/gating.d.ts +39 -2
  19. package/dist/analysis/gating.d.ts.map +1 -1
  20. package/dist/analysis/rebuild.d.ts.map +1 -1
  21. package/dist/analysis/terminal.d.ts +5 -2
  22. package/dist/analysis/terminal.d.ts.map +1 -1
  23. package/dist/cli/index.cjs +10865 -8535
  24. package/dist/cli/index.cjs.map +3 -4
  25. package/dist/cli/index.js +10865 -8535
  26. package/dist/cli/index.js.map +3 -4
  27. package/dist/combinators/adjacency.d.ts +100 -0
  28. package/dist/combinators/adjacency.d.ts.map +1 -0
  29. package/dist/combinators/choice.d.ts.map +1 -1
  30. package/dist/combinators/completions.d.ts.map +1 -1
  31. package/dist/combinators/dispatch.d.ts +8 -1
  32. package/dist/combinators/dispatch.d.ts.map +1 -1
  33. package/dist/combinators/expect.d.ts.map +1 -1
  34. package/dist/combinators/first-set.d.ts.map +1 -1
  35. package/dist/combinators/gate.d.ts.map +1 -1
  36. package/dist/combinators/grammar.d.ts +22 -0
  37. package/dist/combinators/grammar.d.ts.map +1 -1
  38. package/dist/combinators/literal.d.ts +8 -0
  39. package/dist/combinators/literal.d.ts.map +1 -1
  40. package/dist/combinators/map.d.ts +0 -1
  41. package/dist/combinators/map.d.ts.map +1 -1
  42. package/dist/combinators/node.d.ts +13 -3
  43. package/dist/combinators/node.d.ts.map +1 -1
  44. package/dist/combinators/parser.d.ts +1 -1
  45. package/dist/combinators/parser.d.ts.map +1 -1
  46. package/dist/combinators/ref.d.ts.map +1 -1
  47. package/dist/combinators/regex.d.ts +1 -16
  48. package/dist/combinators/regex.d.ts.map +1 -1
  49. package/dist/combinators/repeat.d.ts +30 -2
  50. package/dist/combinators/repeat.d.ts.map +1 -1
  51. package/dist/combinators/scanTo.d.ts +71 -1
  52. package/dist/combinators/scanTo.d.ts.map +1 -1
  53. package/dist/combinators/sequence.d.ts.map +1 -1
  54. package/dist/combinators/token.d.ts.map +1 -1
  55. package/dist/combinators/trivia-boundary.d.ts +26 -0
  56. package/dist/combinators/trivia-boundary.d.ts.map +1 -0
  57. package/dist/combinators/trivia-skip.d.ts +40 -1
  58. package/dist/combinators/trivia-skip.d.ts.map +1 -1
  59. package/dist/combinators/withCtx.d.ts.map +1 -1
  60. package/dist/compiler/compile-linkable-table.d.ts +104 -0
  61. package/dist/compiler/compile-linkable-table.d.ts.map +1 -0
  62. package/dist/compiler/degradation.d.ts +0 -2
  63. package/dist/compiler/degradation.d.ts.map +1 -1
  64. package/dist/compiler/fields.d.ts +13 -0
  65. package/dist/compiler/fields.d.ts.map +1 -1
  66. package/dist/compiler/grammar-coverage-ids.d.ts +5 -5
  67. package/dist/compiler/grammar-coverage-ids.d.ts.map +1 -1
  68. package/dist/compiler/ir-serialize.d.ts.map +1 -1
  69. package/dist/compiler/linker.d.ts +47 -53
  70. package/dist/compiler/linker.d.ts.map +1 -1
  71. package/dist/compiler/token-alphabet.d.ts +81 -0
  72. package/dist/compiler/token-alphabet.d.ts.map +1 -0
  73. package/dist/compiler/token-dispatch.d.ts +95 -0
  74. package/dist/compiler/token-dispatch.d.ts.map +1 -0
  75. package/dist/compiler/token-scanner.d.ts +53 -0
  76. package/dist/compiler/token-scanner.d.ts.map +1 -0
  77. package/dist/compiler/value-usage.d.ts.map +1 -1
  78. package/dist/coverage.d.ts +1 -1
  79. package/dist/coverage.d.ts.map +1 -1
  80. package/dist/cst/capture-buffer.d.ts +35 -0
  81. package/dist/cst/capture-buffer.d.ts.map +1 -1
  82. package/dist/cst/reflection-attach.d.ts +8 -0
  83. package/dist/cst/reflection-attach.d.ts.map +1 -0
  84. package/dist/cst/reflection-symbols.d.ts +10 -0
  85. package/dist/cst/reflection-symbols.d.ts.map +1 -0
  86. package/dist/cst/reflection.d.ts +2 -4
  87. package/dist/cst/reflection.d.ts.map +1 -1
  88. package/dist/cst/root-trivia-scope.d.ts +26 -0
  89. package/dist/cst/root-trivia-scope.d.ts.map +1 -0
  90. package/dist/cst/trivia-charscan.d.ts +18 -0
  91. package/dist/cst/trivia-charscan.d.ts.map +1 -0
  92. package/dist/cst/trivia-kinds.d.ts +19 -0
  93. package/dist/cst/trivia-kinds.d.ts.map +1 -1
  94. package/dist/functional/doc.d.ts.map +1 -1
  95. package/dist/functional/run-tabled.d.ts +12 -0
  96. package/dist/functional/run-tabled.d.ts.map +1 -0
  97. package/dist/functional/run.d.ts +25 -42
  98. package/dist/functional/run.d.ts.map +1 -1
  99. package/dist/grammar-metadata.d.ts +3 -0
  100. package/dist/grammar-metadata.d.ts.map +1 -0
  101. package/dist/index.cjs +9224 -7203
  102. package/dist/index.cjs.map +3 -4
  103. package/dist/index.d.ts +18 -9
  104. package/dist/index.d.ts.map +1 -1
  105. package/dist/index.js +9219 -7198
  106. package/dist/index.js.map +3 -4
  107. package/dist/language-service/index.cjs +311 -121
  108. package/dist/language-service/index.cjs.map +3 -4
  109. package/dist/language-service/index.js +311 -121
  110. package/dist/language-service/index.js.map +3 -4
  111. package/dist/oracle/index.cjs.map +0 -1
  112. package/dist/oracle/index.js.map +0 -1
  113. package/dist/parse-context.d.ts +49 -0
  114. package/dist/parse-context.d.ts.map +1 -0
  115. package/dist/plugin/evaluator.d.ts +40 -1
  116. package/dist/plugin/evaluator.d.ts.map +1 -1
  117. package/dist/plugin/index.cjs +11146 -9593
  118. package/dist/plugin/index.cjs.map +3 -4
  119. package/dist/plugin/index.d.ts.map +1 -1
  120. package/dist/plugin/index.js +11146 -9593
  121. package/dist/plugin/index.js.map +3 -4
  122. package/dist/regex/classes.d.ts +28 -0
  123. package/dist/regex/classes.d.ts.map +1 -1
  124. package/dist/regex/first-set.d.ts +14 -0
  125. package/dist/regex/first-set.d.ts.map +1 -1
  126. package/dist/run/index.cjs +136 -51
  127. package/dist/run/index.cjs.map +2 -3
  128. package/dist/run/index.js +136 -51
  129. package/dist/run/index.js.map +2 -3
  130. package/dist/spec/index.cjs +2898 -1623
  131. package/dist/spec/index.cjs.map +3 -4
  132. package/dist/spec/index.js +2898 -1623
  133. package/dist/spec/index.js.map +3 -4
  134. package/dist/spec/model.d.ts +8 -0
  135. package/dist/spec/model.d.ts.map +1 -1
  136. package/dist/table/assemble.d.ts +307 -0
  137. package/dist/table/assemble.d.ts.map +1 -0
  138. package/dist/table/cell.d.ts +42 -0
  139. package/dist/table/cell.d.ts.map +1 -0
  140. package/dist/table/child-slots.d.ts +30 -0
  141. package/dist/table/child-slots.d.ts.map +1 -0
  142. package/dist/table/compile-rule-map.d.ts +136 -0
  143. package/dist/table/compile-rule-map.d.ts.map +1 -0
  144. package/dist/table/compile.d.ts +56 -0
  145. package/dist/table/compile.d.ts.map +1 -0
  146. package/dist/table/duplication-hook.d.ts +32 -0
  147. package/dist/table/duplication-hook.d.ts.map +1 -0
  148. package/dist/table/emit-assembly.d.ts +139 -0
  149. package/dist/table/emit-assembly.d.ts.map +1 -0
  150. package/dist/table/emit.d.ts +113 -0
  151. package/dist/table/emit.d.ts.map +1 -0
  152. package/dist/table/encode-baseline.d.ts +16 -0
  153. package/dist/table/encode-baseline.d.ts.map +1 -0
  154. package/dist/table/encode.d.ts +71 -0
  155. package/dist/table/encode.d.ts.map +1 -0
  156. package/dist/table/exec-baseline.d.ts +9 -0
  157. package/dist/table/exec-baseline.d.ts.map +1 -0
  158. package/dist/table/exec.d.ts +72 -0
  159. package/dist/table/exec.d.ts.map +1 -0
  160. package/dist/table/fold.d.ts +25 -0
  161. package/dist/table/fold.d.ts.map +1 -0
  162. package/dist/table/index.cjs +11375 -0
  163. package/dist/table/index.cjs.map +6 -0
  164. package/dist/table/index.d.ts +100 -0
  165. package/dist/table/index.d.ts.map +1 -0
  166. package/dist/table/index.js +11352 -0
  167. package/dist/table/index.js.map +6 -0
  168. package/dist/table/inspect.d.ts +17 -0
  169. package/dist/table/inspect.d.ts.map +1 -0
  170. package/dist/table/ops.d.ts +446 -0
  171. package/dist/table/ops.d.ts.map +1 -0
  172. package/dist/table/program.d.ts +493 -0
  173. package/dist/table/program.d.ts.map +1 -0
  174. package/dist/table/run-support.d.ts +38 -0
  175. package/dist/table/run-support.d.ts.map +1 -0
  176. package/dist/{compiler/scannable-run.d.ts → table/scan-shapes.d.ts} +14 -13
  177. package/dist/table/scan-shapes.d.ts.map +1 -0
  178. package/dist/table/site-labels.d.ts +58 -0
  179. package/dist/table/site-labels.d.ts.map +1 -0
  180. package/dist/table/stamp.d.ts +70 -0
  181. package/dist/table/stamp.d.ts.map +1 -0
  182. package/dist/types.d.ts +80 -24
  183. package/dist/types.d.ts.map +1 -1
  184. package/dist/version.d.ts +1 -1
  185. package/package.json +25 -2
  186. package/src/analysis/choice-cost-gate.ts +359 -0
  187. package/src/analysis/choice-cost-render.ts +293 -0
  188. package/src/analysis/choice-cost.ts +1008 -0
  189. package/src/analysis/commitment.ts +557 -0
  190. package/src/analysis/corpus.ts +130 -0
  191. package/src/analysis/diagnose-render.ts +625 -0
  192. package/src/analysis/diagnose.ts +317 -0
  193. package/src/analysis/diagnostics.ts +33 -0
  194. package/src/analysis/duplication.ts +1955 -0
  195. package/src/analysis/fix-render.ts +217 -0
  196. package/src/analysis/fix.ts +669 -0
  197. package/src/analysis/format-number.ts +31 -0
  198. package/src/analysis/gating.ts +792 -0
  199. package/src/analysis/grammar.ts +72 -0
  200. package/src/analysis/rebuild.ts +287 -0
  201. package/src/analysis/terminal.ts +336 -0
  202. package/src/cli/index.ts +413 -0
  203. package/src/combinators/adjacency.ts +171 -0
  204. package/src/combinators/attempt.ts +41 -0
  205. package/src/combinators/case-fold.ts +90 -0
  206. package/src/combinators/choice.ts +466 -0
  207. package/src/combinators/completions.ts +64 -0
  208. package/src/combinators/dispatch.ts +358 -0
  209. package/src/combinators/expect.ts +159 -0
  210. package/src/combinators/first-set.ts +322 -0
  211. package/src/combinators/gate.ts +50 -0
  212. package/src/combinators/grammar.ts +272 -0
  213. package/src/combinators/keywords.ts +145 -0
  214. package/src/combinators/literal.ts +102 -0
  215. package/src/combinators/map.ts +106 -0
  216. package/src/combinators/node.ts +345 -0
  217. package/src/combinators/not.ts +55 -0
  218. package/src/combinators/parser.ts +266 -0
  219. package/src/combinators/peek.ts +63 -0
  220. package/src/combinators/probe.ts +25 -0
  221. package/src/combinators/ref.ts +109 -0
  222. package/src/combinators/regex.ts +170 -0
  223. package/src/combinators/repeat.ts +561 -0
  224. package/src/combinators/scanTo.ts +393 -0
  225. package/src/combinators/sequence.ts +221 -0
  226. package/src/combinators/token.ts +120 -0
  227. package/src/combinators/trivia-boundary.ts +48 -0
  228. package/src/combinators/trivia-skip.ts +821 -0
  229. package/src/combinators/withCtx.ts +45 -0
  230. package/src/compiler/build-arity.ts +330 -0
  231. package/src/compiler/compile-linkable-table.ts +209 -0
  232. package/src/compiler/degradation.ts +266 -0
  233. package/src/compiler/fields.ts +191 -0
  234. package/src/compiler/grammar-coverage-ids.ts +129 -0
  235. package/src/compiler/ir-serialize.ts +615 -0
  236. package/src/compiler/linker.ts +954 -0
  237. package/src/compiler/token-alphabet.ts +209 -0
  238. package/src/compiler/token-dispatch.ts +353 -0
  239. package/src/compiler/token-scanner.ts +318 -0
  240. package/src/compiler/value-usage.ts +147 -0
  241. package/src/coverage.ts +383 -0
  242. package/src/cst/capture-buffer.ts +297 -0
  243. package/src/cst/host-mode.ts +69 -0
  244. package/src/cst/reflection-attach.ts +11 -0
  245. package/src/cst/reflection-symbols.ts +9 -0
  246. package/src/cst/reflection.ts +149 -0
  247. package/src/cst/relative-spans.ts +207 -0
  248. package/src/cst/root-trivia-scope.ts +30 -0
  249. package/src/cst/trivia-charscan.ts +312 -0
  250. package/src/cst/trivia-entries.ts +285 -0
  251. package/src/cst/trivia-index.ts +131 -0
  252. package/src/cst/trivia-kinds.ts +358 -0
  253. package/src/cst/types.ts +76 -0
  254. package/src/cst/walk.ts +101 -0
  255. package/src/functional/doc.ts +959 -0
  256. package/src/functional/run-tabled.ts +153 -0
  257. package/src/functional/run.ts +558 -0
  258. package/src/grammar-metadata.ts +2 -0
  259. package/src/index.ts +189 -0
  260. package/src/language-service/index.ts +221 -0
  261. package/src/line-index.ts +131 -0
  262. package/src/oracle/digest.ts +620 -0
  263. package/src/oracle/index.ts +55 -0
  264. package/src/parse-context.ts +91 -0
  265. package/src/plugin/direct-builder-static.ts +102 -0
  266. package/src/plugin/evaluator.ts +1505 -0
  267. package/src/plugin/free-identifiers.ts +338 -0
  268. package/src/plugin/index.ts +2281 -0
  269. package/src/plugin/reducer-resolver.ts +606 -0
  270. package/src/recovery/scan.ts +155 -0
  271. package/src/regex/classes.ts +183 -0
  272. package/src/regex/first-set.ts +316 -0
  273. package/src/run/index.ts +31 -0
  274. package/src/spec/ebnf.ts +128 -0
  275. package/src/spec/index.ts +54 -0
  276. package/src/spec/model.ts +636 -0
  277. package/src/spec/railroad-lib.ts +559 -0
  278. package/src/spec/railroad.ts +256 -0
  279. package/src/table/assemble.ts +2994 -0
  280. package/src/table/cell.ts +42 -0
  281. package/src/table/child-slots.ts +113 -0
  282. package/src/table/compile-rule-map.ts +336 -0
  283. package/src/table/compile.ts +244 -0
  284. package/src/table/duplication-hook.ts +89 -0
  285. package/src/table/emit-assembly.ts +1766 -0
  286. package/src/table/emit.ts +418 -0
  287. package/src/table/encode-baseline.ts +340 -0
  288. package/src/table/encode.ts +1569 -0
  289. package/src/table/exec-baseline.ts +537 -0
  290. package/src/table/exec.ts +1672 -0
  291. package/src/table/fold.ts +38 -0
  292. package/src/table/index.ts +108 -0
  293. package/src/table/inspect.ts +52 -0
  294. package/src/table/ops.ts +459 -0
  295. package/src/table/program.ts +855 -0
  296. package/src/table/run-support.ts +66 -0
  297. package/src/table/scan-shapes.ts +1578 -0
  298. package/src/table/site-labels.ts +228 -0
  299. package/src/table/stamp.ts +229 -0
  300. package/src/types.ts +563 -0
  301. package/src/version.ts +16 -0
  302. package/dist/compiler/codegen.d.ts +0 -334
  303. package/dist/compiler/codegen.d.ts.map +0 -1
  304. package/dist/compiler/inline-build.d.ts +0 -11
  305. package/dist/compiler/inline-build.d.ts.map +0 -1
  306. package/dist/compiler/inline-callback.d.ts +0 -21
  307. package/dist/compiler/inline-callback.d.ts.map +0 -1
  308. package/dist/compiler/line-index.d.ts +0 -2
  309. package/dist/compiler/line-index.d.ts.map +0 -1
  310. package/dist/compiler/module-hoist.d.ts +0 -106
  311. package/dist/compiler/module-hoist.d.ts.map +0 -1
  312. package/dist/compiler/scannable-run.d.ts.map +0 -1
  313. package/dist/compiler/scannable-terminal.d.ts +0 -23
  314. package/dist/compiler/scannable-terminal.d.ts.map +0 -1
  315. package/dist/compiler/trivia-fast-path.d.ts +0 -38
  316. package/dist/compiler/trivia-fast-path.d.ts.map +0 -1
@@ -0,0 +1,669 @@
1
+ /**
2
+ * VERIFIED grammar fixes — the propose → apply → recompile → compare loop.
3
+ *
4
+ * rustc can tell you a suggestion is machine-applicable. It cannot tell you the
5
+ * suggestion is CORRECT: there is no cheap oracle for "this rewrite of your program
6
+ * means the same thing". A parser generator has one. A grammar's whole observable
7
+ * behaviour is the tree it produces for an input, so a rewrite can be applied, the
8
+ * grammar recompiled, a corpus re-parsed, and the two outputs compared byte for byte.
9
+ * Unchanged output is not a heuristic that the rewrite was safe; it is the evidence.
10
+ *
11
+ * propose → apply → recompile → compare parse output
12
+ * unchanged → PROVEN over this corpus; offer it
13
+ * changed → WRONG; discard, never show it
14
+ *
15
+ * TWO STATES, NEVER A THIRD
16
+ * -------------------------
17
+ * Every candidate site ends as ACTIONABLE (here is the rewrite, here is the evidence,
18
+ * here is the measured benefit) or LOCATED (here is the exact site and the exact reason
19
+ * no rewrite can be offered). There is no "consider refactoring": a sentence of advice
20
+ * with no location and no rewrite is the thing this module exists to replace. A rewrite
21
+ * that moved the output is reported as LOCATED with the rejection as its reason — the
22
+ * site is real, the rewrite is not shown, and the reader is never handed a wrong fix.
23
+ *
24
+ * WHAT "PROVEN" MEANS HERE, EXACTLY
25
+ * ---------------------------------
26
+ * Proven OVER THE SUPPLIED CORPUS, on every engine the grammar supports — both when it
27
+ * compiles, the interpreted one alone when it does not — and that is stated in every
28
+ * rendering rather than implied. A corpus that never reaches the rewritten arm proves
29
+ * nothing about it, so the evidence carries the sample and byte counts and the caller
30
+ * can judge. Two deliberate exclusions from the comparison:
31
+ *
32
+ * - FAILURE `expected` LABELS are compared as position only, not text. A keyword
33
+ * rewrite changes `/\@media/` to `keyword` by design; that is diagnostic text, not
34
+ * parse output. Values, spans, and WHERE a parse failed are compared exactly.
35
+ * - The rebuild itself is not trusted, it is CHECKED: before any candidate is
36
+ * considered, the grammar is rebuilt with NO substitution and that identity rebuild
37
+ * must reproduce the corpus output exactly. If `rebuild.ts` threads any option
38
+ * wrongly, this check fails and NO fix is offered. Wrong makes the tool silent.
39
+ *
40
+ * BENEFIT IS MEASURED TOO
41
+ * -----------------------
42
+ * A verified-but-pointless rewrite is not offered either. Benefit is read off the same
43
+ * analysis a CI gate reads — ungated choices, anti-patterns — plus the compiled artifact
44
+ * size, which is deterministic and therefore safe to print. No timings: this must be
45
+ * diffable.
46
+ */
47
+ import type { Combinator, ParseResult, ParserDef } from '../types.ts'
48
+ import { firstSetOf } from '../combinators/first-set.ts'
49
+ import { keywords } from '../combinators/keywords.ts'
50
+ import { peek } from '../combinators/peek.ts'
51
+ import { parse } from '../combinators/grammar.ts'
52
+ import { compile } from '../table/compile.ts'
53
+ import { digestValue } from '../oracle/digest.ts'
54
+ import { analyzeGating, choiceArms, firstSetToString, peelToLeading, type GatingReport } from './gating.ts'
55
+ import { rebuildCombinator, type FrozenSubtree } from './rebuild.ts'
56
+
57
+ /** One corpus document. `name` is a label for the report; it is never a path lookup. */
58
+ export type FixSample = { name: string; text: string }
59
+
60
+ /** Stable, greppable fix class. Mirrors the `anti-pattern` kinds `diagnoseGrammar` reports. */
61
+ export type FixCode = 'keyword-regex' | 'double-not'
62
+
63
+ /** The engines a comparison ran on. `compiled` is absent when the grammar does not compile. */
64
+ export type FixEngine = 'interpreted' | 'compiled'
65
+
66
+ export type FixEvidence = {
67
+ samples: number
68
+ bytes: number
69
+ engines: FixEngine[]
70
+ /** Always true on a VERIFIED fix — the field exists so the JSON states the claim. */
71
+ outputUnchanged: true
72
+ }
73
+
74
+ export type FixBenefit = {
75
+ ungatedChoicesBefore: number
76
+ ungatedChoicesAfter: number
77
+ antiPatternsBefore: number
78
+ antiPatternsAfter: number
79
+ gatedChoicesBefore: number
80
+ gatedChoicesAfter: number
81
+ /** Bytes of `compile().source` (the table artifact module). Deterministic. `null` when the grammar does not compile. */
82
+ artifactBytesBefore: number | null
83
+ artifactBytesAfter: number | null
84
+ }
85
+
86
+ /** A source edit, offered only when the site is UNAMBIGUOUS in the supplied text. */
87
+ export type FixEdit = {
88
+ path: string
89
+ /** 1-based, for a diagnostic header. */
90
+ line: number
91
+ column: number
92
+ start: number
93
+ end: number
94
+ oldText: string
95
+ newText: string
96
+ /** The whole source line the edit sits on, so a renderer can draw a real code frame. */
97
+ lineText: string
98
+ }
99
+
100
+ export type VerifiedFix = {
101
+ id: string
102
+ code: FixCode
103
+ rule: string
104
+ armIndex: number
105
+ /** How the site reads now, and how it would read. Rendering, not source text. */
106
+ before: string
107
+ after: string
108
+ /** The arm's deep first-set before and after — the number that makes the fix self-evident. */
109
+ armFirstSetBefore: string
110
+ armFirstSetAfter: string
111
+ /** The enclosing choice's gating verdict, before and after. */
112
+ choiceId: string
113
+ choiceGatesBefore: 'yes' | 'recoverable' | 'no'
114
+ choiceGatesAfter: 'yes' | 'recoverable' | 'no'
115
+ benefit: FixBenefit
116
+ evidence: FixEvidence
117
+ /** Present only when the site was located unambiguously in a supplied source file. */
118
+ edit?: FixEdit
119
+ }
120
+
121
+ export type LocatedFinding = {
122
+ id: string
123
+ code: FixCode
124
+ rule: string
125
+ armIndex: number
126
+ /** How the site reads now. */
127
+ site: string
128
+ /** The exact reason no rewrite can be offered. Never advice. */
129
+ reason: string
130
+ }
131
+
132
+ export type FixReport = {
133
+ schema: 'parseman.fix/1'
134
+ /**
135
+ * True when every candidate site got a verdict — i.e. the corpus was usable and the
136
+ * identity rebuild held. FALSE IS NOT "no fixes": it means the loop could not run, and
137
+ * an empty `verified` list under it proves nothing. Fails closed.
138
+ */
139
+ ok: boolean
140
+ /** Why `ok` is false, or `null`. */
141
+ blocked: string | null
142
+ corpus: { samples: number; bytes: number }
143
+ engines: FixEngine[]
144
+ verified: VerifiedFix[]
145
+ located: LocatedFinding[]
146
+ /** Subtrees `rebuild.ts` reused verbatim; a candidate inside one cannot be applied. */
147
+ frozen: FrozenSubtree[]
148
+ }
149
+
150
+ export type ProposeFixOptions = {
151
+ /**
152
+ * The documents the before/after comparison runs over. REQUIRED and non-empty: with no
153
+ * corpus there is no evidence, and this module does not offer unverified rewrites.
154
+ */
155
+ corpus: readonly FixSample[]
156
+ /** Grammar source, to locate edits in. Without it, fixes carry no `edit`. */
157
+ source?: { path: string; text: string }
158
+ /** Passed through to `analyzeGating` when measuring benefit. */
159
+ accept?: Iterable<string>
160
+ entryName?: string
161
+ }
162
+
163
+ // ── output comparison ────────────────────────────────────────────────────────
164
+
165
+ /**
166
+ * One sample's output, as a comparable token.
167
+ *
168
+ * `OK:`/`ERR:` keeps success and failure in disjoint spaces (the identity-oracle rule),
169
+ * and a failure compares by POSITION rather than by its `expected` labels — see the
170
+ * module header for why that exclusion is deliberate and not a loosened oracle.
171
+ */
172
+ function sampleToken(res: ParseResult<unknown>): string {
173
+ return res.ok
174
+ ? `OK:${digestValue({ value: res.value, span: res.span })}`
175
+ : `ERR:${res.span.start}-${res.span.end}`
176
+ }
177
+
178
+ type Outputs = { interpreted: string[]; compiled: string[] | null; artifactBytes: number | null }
179
+
180
+ /**
181
+ * THE COMPILED LEG IS THE TABLE — `compile`, which is what `compile()` means
182
+ * at the library entry (`src/index.ts`). It used to be the source lowering, which
183
+ * made this module a live consumer of an engine that no longer ships anything.
184
+ *
185
+ * `.source` still reads as a deterministic byte count on a table artifact (the
186
+ * emitted module text), so the size half of the benefit measurement is unchanged
187
+ * in kind — only in scale, which is why the field is no longer called
188
+ * `codegenBytes`. Nothing here compares it across engines, only before/after
189
+ * within one run, so the two are never mixed.
190
+ */
191
+ function outputsOf(root: Combinator<unknown>, corpus: readonly FixSample[]): Outputs {
192
+ const interpreted = corpus.map((s) => {
193
+ try { return sampleToken(parse(root, s.text)) }
194
+ catch (e) { return `THROW:${e instanceof Error ? e.name : 'unknown'}` }
195
+ })
196
+ let compiled: string[] | null = null
197
+ let artifactBytes: number | null = null
198
+ try {
199
+ const c = compile(root)
200
+ artifactBytes = c.source.length
201
+ compiled = corpus.map((s) => {
202
+ try { return sampleToken(c.parse(s.text)) }
203
+ catch (e) { return `THROW:${e instanceof Error ? e.name : 'unknown'}` }
204
+ })
205
+ }
206
+ catch { compiled = null; artifactBytes = null }
207
+ return { interpreted, compiled, artifactBytes }
208
+ }
209
+
210
+ const sameList = (a: readonly string[], b: readonly string[]): boolean =>
211
+ a.length === b.length && a.every((x, i) => x === b[i])
212
+
213
+ /** Identical on every engine BOTH sides could run. A side that compiles and a side that
214
+ * stopped compiling are NOT identical — losing the compiled engine is a behaviour change. */
215
+ function sameOutputs(a: Outputs, b: Outputs): boolean {
216
+ if (!sameList(a.interpreted, b.interpreted)) return false
217
+ if ((a.compiled === null) !== (b.compiled === null)) return false
218
+ if (a.compiled !== null && b.compiled !== null) return sameList(a.compiled, b.compiled)
219
+ return true
220
+ }
221
+
222
+ // ── candidate detection ──────────────────────────────────────────────────────
223
+
224
+ /**
225
+ * A regex that is really a fixed keyword: an optional `^`, a run of characters with no
226
+ * regex meaning, and an optional trailing `(?![…])` word boundary.
227
+ *
228
+ * Deliberately NARROWER than `gating.ts`'s detector, which decides whether to REPORT.
229
+ * This one decides whether to REWRITE, so it must recognise only shapes where
230
+ * `keywords()` is exactly equivalent by construction, before any corpus is consulted.
231
+ */
232
+ const EXACT_KEYWORD_RE = /^(\^?)([^\\^$*+?()[\]{}|./]+)(?:\(\?!(?:\[([^\]]*)\]|(\\[wWdDsS]))\))?$/
233
+
234
+ type KeywordRewrite = { word: string; boundary: string | undefined; caseInsensitive: boolean }
235
+
236
+ /** `null` when the regex is not provably a keyword, with the reason carried by the caller. */
237
+ function keywordRewriteOf(source: string, flags: string): KeywordRewrite | string {
238
+ const m = EXACT_KEYWORD_RE.exec(source)
239
+ if (m === null) return 'the pattern is not a plain keyword (it uses regex syntax beyond a literal word and a trailing (?![…]) boundary)'
240
+ if (m[1] === '^') {
241
+ // `regex()` compiles sticky (`y`) without `m`, so a leading `^` matches only at
242
+ // offset 0 — this arm can only ever fire at the start of the document. That is a
243
+ // different parser from the keyword, and worth knowing about on its own.
244
+ return 'the pattern is `^`-anchored; under regex()\'s sticky compilation that matches only at offset 0, which keywords() does not reproduce'
245
+ }
246
+ const extra = flags.replace(/[iy]/g, '')
247
+ if (extra !== '') return `the pattern carries flags /${flags}/ — only /i/ has a keywords() equivalent`
248
+ // `(?!\w)` and `(?![\w])` are the same assertion; keywords() spells its boundary as a
249
+ // character CLASS, so a bare escape is carried through as the class body.
250
+ const boundary = m[3] !== undefined && m[3] !== '' ? m[3] : m[4]
251
+ return { word: m[2]!, boundary, caseInsensitive: flags.includes('i') }
252
+ }
253
+
254
+ type Candidate = {
255
+ id: string
256
+ code: FixCode
257
+ rule: string
258
+ armIndex: number
259
+ /** The enclosing choice's `analyzeGating` id — `rule`, or `rule#N` when the rule holds
260
+ * more than one choice. Resolved by NODE IDENTITY, never by re-deriving occurrence
261
+ * order in a second walk. */
262
+ choiceId: string
263
+ /** The node to replace. */
264
+ target: Combinator<unknown>
265
+ /** The arm the target leads, for the first-set reading. */
266
+ arm: Combinator<unknown>
267
+ before: string
268
+ /** The replacement, or a reason it cannot be built. */
269
+ replacement: Combinator<unknown> | null
270
+ after: string | null
271
+ reason: string | null
272
+ }
273
+
274
+ const renderRegex = (d: { source: string; flags: string }): string => `regex(/${d.source}/${d.flags.replace(/y/g, '')})`
275
+
276
+ function renderKeyword(k: KeywordRewrite): string {
277
+ const ci = k.caseInsensitive ? ', { caseInsensitive: true }' : ''
278
+ return k.boundary === undefined
279
+ ? `keywords(['${k.word}']${ci === '' ? '' : `,${ci.slice(1)}`})`
280
+ : `word('${k.word}', '${k.boundary}'${ci})`
281
+ }
282
+
283
+ /**
284
+ * Walk every choice arm, attributing each to the CHOICE that owns it.
285
+ *
286
+ * The identity a reader greps — and the one `--apply` attribution reads — is the choice,
287
+ * not the rule: `analyzeGating` already spells a rule's second choice `rule#1` precisely
288
+ * because a rule can hold several. An id built from the rule alone collides across them,
289
+ * and the report then sorts and renders two distinct sites as one.
290
+ *
291
+ * The choice id is taken from `report` by ARM-ARRAY IDENTITY rather than recomputed here.
292
+ * `gating.ts` says out loud that a second walk drifts out of step with its `id`
293
+ * assignment — it did, silently, and mislabelled every arm — so this does not walk twice.
294
+ */
295
+ function collectCandidates(root: Combinator<unknown>, report: GatingReport): Candidate[] {
296
+ const out: Candidate[] = []
297
+ const seen = new Set<Combinator<unknown>>()
298
+ // arms array (the choice's own `d.parsers`) → the id `analyzeGating` gave that choice.
299
+ const idOfChoice = new Map<readonly Combinator<unknown>[], string>()
300
+ for (const c of report.choices) {
301
+ const arms = choiceArms(c)
302
+ if (arms !== undefined) idOfChoice.set(arms, c.id)
303
+ }
304
+ const visit = (p: Combinator<unknown>, enclosing: string): void => {
305
+ if (seen.has(p)) return
306
+ seen.add(p)
307
+ const d = (p as { _def?: unknown })._def as ParserDef | undefined
308
+ if (d === undefined || typeof d !== 'object') return
309
+ const rule = (p as { _ruleName?: string })._ruleName ?? enclosing
310
+ if (d.tag === 'choice') {
311
+ // A choice the report did not describe (an unanalysable one) still gets a site; it
312
+ // falls back to the rule name, which is what the id read before choices were told
313
+ // apart, and is unique whenever the rule holds only this one.
314
+ const choiceId = idOfChoice.get(d.parsers) ?? rule
315
+ d.parsers.forEach((arm, armIndex) => {
316
+ const lead = peelToLeading(arm)
317
+ const ld = lead._def as ParserDef
318
+ const id = `${choiceId}#arm${armIndex}`
319
+ if (ld.tag === 'not') {
320
+ const inner = ld.parser
321
+ if ((inner._def as ParserDef).tag === 'not') {
322
+ const body = (inner._def as Extract<ParserDef, { tag: 'not' }>).parser
323
+ out.push({
324
+ id, code: 'double-not', rule, armIndex, choiceId, target: lead, arm,
325
+ before: 'not(not(…))', replacement: peek(body), after: 'peek(…)', reason: null,
326
+ })
327
+ }
328
+ }
329
+ if (ld.tag === 'regex') {
330
+ const k = keywordRewriteOf(ld.source, ld.flags)
331
+ const before = renderRegex(ld)
332
+ if (typeof k === 'string') {
333
+ // Only a site `gating.ts` would REPORT is worth locating here; a general
334
+ // regex that happens not to be a keyword is not a finding at all.
335
+ if (/^\^?[@#.-]?[A-Za-z][\w-]*(\(\?![^)]*\))?\$?$/.test(ld.source)) {
336
+ out.push({ id, code: 'keyword-regex', rule, armIndex, choiceId, target: lead, arm, before, replacement: null, after: null, reason: k })
337
+ }
338
+ }
339
+ else {
340
+ out.push({
341
+ id, code: 'keyword-regex', rule, armIndex, choiceId, target: lead, arm, before,
342
+ replacement: keywords([k.word], {
343
+ ...(k.caseInsensitive ? { caseInsensitive: true } : {}),
344
+ ...(k.boundary === undefined ? {} : { boundary: k.boundary }),
345
+ }),
346
+ after: renderKeyword(k), reason: null,
347
+ })
348
+ }
349
+ }
350
+ })
351
+ }
352
+ const rec = d as unknown as Record<string, unknown>
353
+ const kids: Combinator<unknown>[] = []
354
+ if (Array.isArray(rec.parsers)) kids.push(...(rec.parsers as Combinator<unknown>[]))
355
+ if (Array.isArray(rec.skip)) kids.push(...(rec.skip as Combinator<unknown>[]))
356
+ for (const k of ['parser', 'main', 'skipped', 'separator', 'sentinel', 'selector', 'otherwise', 'fallback', 'triviaParser'])
357
+ if (rec[k]) kids.push(rec[k] as Combinator<unknown>)
358
+ if (d.tag === 'dispatch') {
359
+ for (const c of d.cases) kids.push(c.parser)
360
+ if (d.matchers) for (const c of d.matchers) kids.push(c.parser)
361
+ }
362
+ if (d.tag === 'lazy') { try { kids.push((d as { thunk(): Combinator<unknown> }).thunk()) } catch { /* undefined ref */ } }
363
+ for (const k of kids) visit(k, rule)
364
+ }
365
+ visit(root, (root as { _ruleName?: string })._ruleName ?? '<entry>')
366
+ // Deterministic order, independent of walk order.
367
+ out.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : a.code < b.code ? -1 : a.code > b.code ? 1 : 0))
368
+ return out
369
+ }
370
+
371
+ // ── source location ──────────────────────────────────────────────────────────
372
+
373
+ /**
374
+ * Locate a candidate in the grammar source, and refuse unless it is UNAMBIGUOUS.
375
+ *
376
+ * A source edit is the one part of this loop the verification cannot cover: the loop
377
+ * proves a GRAPH rewrite is output-neutral, and a text edit is only that rewrite if the
378
+ * text really is the site. So the correspondence has to be exact — one occurrence, in
379
+ * one spelling — and anything else declines. An edit applied to the wrong site is
380
+ * strictly worse than no `--apply` at all.
381
+ */
382
+ function locateEdit(
383
+ source: { path: string; text: string },
384
+ c: Candidate,
385
+ ): FixEdit | string {
386
+ const at = (start: number, end: number, oldText: string, newText: string): FixEdit => {
387
+ let line = 1
388
+ let lastNl = -1
389
+ for (let i = 0; i < start; i++) if (source.text.charCodeAt(i) === 10) { line++; lastNl = i }
390
+ let lineEnd = source.text.indexOf('\n', lastNl + 1)
391
+ if (lineEnd === -1) lineEnd = source.text.length
392
+ return {
393
+ path: source.path, line, column: start - lastNl, start, end, oldText, newText,
394
+ lineText: source.text.slice(lastNl + 1, lineEnd),
395
+ }
396
+ }
397
+ if (c.code === 'keyword-regex') {
398
+ const spellings = [c.before, c.before.replace(/\/\)$/, '/)')]
399
+ const hits: number[] = []
400
+ for (const s of new Set(spellings)) {
401
+ let i = source.text.indexOf(s)
402
+ while (i !== -1) { hits.push(i); i = source.text.indexOf(s, i + 1) }
403
+ }
404
+ if (hits.length === 0) {
405
+ return `\`${c.before}\` does not appear literally in ${source.path}, so parseman cannot tell `
406
+ + 'which text to change (it is probably built from a helper or a shared constant)'
407
+ }
408
+ if (hits.length > 1) {
409
+ return `\`${c.before}\` appears ${hits.length} times in ${source.path}, and editing the wrong `
410
+ + 'one would be worse than editing none'
411
+ }
412
+ return at(hits[0]!, hits[0]! + c.before.length, c.before, c.after!)
413
+ }
414
+ // double-not: find `not(not(` and balance to the outer close.
415
+ const needle = 'not(not('
416
+ const hits: number[] = []
417
+ let i = source.text.indexOf(needle)
418
+ while (i !== -1) { hits.push(i); i = source.text.indexOf(needle, i + 1) }
419
+ if (hits.length === 0) return `\`not(not(\` does not appear literally in ${source.path}`
420
+ if (hits.length > 1) {
421
+ return `\`not(not(\` appears ${hits.length} times in ${source.path}, and editing the wrong one `
422
+ + 'would be worse than editing none'
423
+ }
424
+ const start = hits[0]!
425
+ // `not(not(` is the needle, so both opens are at fixed offsets. Deriving the inner one
426
+ // with `indexOf('(')` would find a paren inside a string literal in the outer argument.
427
+ const openOuter = start + 3
428
+ const openInner = start + 7
429
+ const end = matchingParen(source.text, openOuter)
430
+ if (typeof end === 'string') return end
431
+ const innerEnd = matchingParen(source.text, openInner)
432
+ if (typeof innerEnd === 'string') return innerEnd
433
+ // `not()` takes one argument, so nothing but whitespace may sit between the inner
434
+ // close and the outer one. Anything else is a shape this rewrite does not describe.
435
+ if (source.text.slice(innerEnd, end - 1).trim() !== '') {
436
+ return 'something other than the inner call sits between the `not(not(` parentheses, so parseman cannot tell which text the rewrite replaces'
437
+ }
438
+ const whole = source.text.slice(start, end)
439
+ const body = source.text.slice(openInner + 1, innerEnd - 1)
440
+ return at(start, end, whole, `peek(${body})`)
441
+ }
442
+
443
+ /**
444
+ * Index one past the `)` that closes the `(` at `open`, or the reason it cannot be found.
445
+ *
446
+ * A raw character scan is NOT enough here. Grammar source is full of string literals, and
447
+ * `not(not(literal(')')))` closes a naive counter one paren early — which yields an
448
+ * `oldText` that is not the call. `applyFixEdits` cannot catch that: its `oldText` check
449
+ * compares against the same mis-delimited span, so it passes and writes a WRONG edit into
450
+ * the user's file. So this skips strings, template literals, and comments.
451
+ *
452
+ * Regex literals are deliberately NOT lexed: telling `/` apart from division needs the
453
+ * full expression grammar, and a regex body can hold unbalanced parens. A `/` that is not
454
+ * a comment therefore DECLINES the site rather than risking a guess.
455
+ */
456
+ function matchingParen(text: string, open: number): number | string {
457
+ const AMBIGUOUS = 'a `/` inside the `not(not(` site could start a regular expression, and parseman cannot tell where the site ends without reading the whole file as JavaScript'
458
+ const UNBALANCED = 'the parentheses after `not(not(` do not balance, so parseman cannot tell where the site ends'
459
+ let depth = 0
460
+ // Template-literal quasis interleave with `${…}` expressions, which are code again.
461
+ // `inTemplate` is the stack of enclosing quasis; `braces` counts the `{` of the
462
+ // expression frame we are in, so its closing `}` is told from a nested object literal.
463
+ let inTemplate = 0
464
+ let braces = 0
465
+ const braceStack: number[] = []
466
+ let i = open
467
+ while (i < text.length) {
468
+ const ch = text[i]!
469
+ if (inTemplate > 0 && braceStack.length < inTemplate) {
470
+ // Inside a quasi: only `\`, `` ` `` and `${` mean anything.
471
+ if (ch === '\\') { i += 2; continue }
472
+ if (ch === '`') { inTemplate--; i++; continue }
473
+ if (ch === '$' && text[i + 1] === '{') { braceStack.push(braces); braces = 0; i += 2; continue }
474
+ i++
475
+ continue
476
+ }
477
+ if (ch === '\'' || ch === '"') {
478
+ const quote = ch
479
+ i++
480
+ for (;;) {
481
+ if (i >= text.length) return UNBALANCED
482
+ const c = text[i]!
483
+ if (c === '\\') { i += 2; continue }
484
+ if (c === '\n') return UNBALANCED // an unterminated string — this is not the shape we think it is
485
+ i++
486
+ if (c === quote) break
487
+ }
488
+ continue
489
+ }
490
+ if (ch === '`') { inTemplate++; i++; continue }
491
+ if (ch === '/' && text[i + 1] === '/') {
492
+ const nl = text.indexOf('\n', i)
493
+ if (nl === -1) return UNBALANCED
494
+ i = nl + 1
495
+ continue
496
+ }
497
+ if (ch === '/' && text[i + 1] === '*') {
498
+ const close = text.indexOf('*/', i + 2)
499
+ if (close === -1) return UNBALANCED
500
+ i = close + 2
501
+ continue
502
+ }
503
+ if (ch === '/') return AMBIGUOUS
504
+ if (ch === '{') { braces++; i++; continue }
505
+ if (ch === '}') {
506
+ if (braces === 0 && braceStack.length > 0) { braces = braceStack.pop()!; i++; continue }
507
+ braces--
508
+ i++
509
+ continue
510
+ }
511
+ if (ch === '(') { depth++; i++; continue }
512
+ if (ch === ')') {
513
+ depth--
514
+ if (depth === 0) return i + 1
515
+ i++
516
+ continue
517
+ }
518
+ i++
519
+ }
520
+ return UNBALANCED
521
+ }
522
+
523
+ // ── the loop ─────────────────────────────────────────────────────────────────
524
+
525
+ const gatingCounts = (r: GatingReport): { ungated: number; anti: number; gated: number } =>
526
+ ({ ungated: r.ungated.length, anti: r.antiPatterns.length, gated: r.gated })
527
+
528
+ /**
529
+ * Propose fixes, and offer only the ones proven output-neutral over `opts.corpus`.
530
+ *
531
+ * Never throws: a loop that cannot run reports `ok: false` with the reason, because a
532
+ * thrown proposal and a grammar with nothing to fix must not look the same.
533
+ */
534
+ export function proposeFixes(root: Combinator<unknown>, opts: ProposeFixOptions): FixReport {
535
+ const corpus = opts.corpus
536
+ const bytes = corpus.reduce((n, s) => n + s.text.length, 0)
537
+ const empty = (blocked: string): FixReport => ({
538
+ schema: 'parseman.fix/1', ok: false, blocked,
539
+ corpus: { samples: corpus.length, bytes }, engines: [],
540
+ verified: [], located: [], frozen: [],
541
+ })
542
+ if (corpus.length === 0) {
543
+ return empty('no files were given to check against. Pass --corpus <dir> pointing at some input '
544
+ + 'your grammar parses, and parseman will apply each candidate change, rebuild the parser, and '
545
+ + 'offer only the ones that leave your parse output exactly as it was')
546
+ }
547
+
548
+ const baseline = outputsOf(root, corpus)
549
+ const engines: FixEngine[] = baseline.compiled === null ? ['interpreted'] : ['interpreted', 'compiled']
550
+
551
+ // The rebuilder is checked before it is used: an identity rebuild must reproduce the
552
+ // corpus output exactly, or nothing below is trustworthy and nothing is offered.
553
+ let identity: ReturnType<typeof rebuildCombinator>
554
+ try { identity = rebuildCombinator(root, new Map()) }
555
+ catch (e) {
556
+ return empty('the grammar could not be rebuilt, so no change to it could be checked — '
557
+ + `${e instanceof Error ? `${e.name}: ${e.message}` : String(e)}`)
558
+ }
559
+ if (!sameOutputs(baseline, outputsOf(identity.root, corpus))) {
560
+ return {
561
+ ...empty('parseman rebuilt this grammar without changing anything and got DIFFERENT parse output, '
562
+ + 'which means its own rebuild of your grammar is not faithful. No change to it can be trusted, '
563
+ + 'so none is offered. This is a parseman bug, not a problem with your grammar'),
564
+ engines, frozen: identity.frozen,
565
+ }
566
+ }
567
+
568
+ const before = analyzeGating(root, { ...(opts.accept === undefined ? {} : { accept: opts.accept }), ...(opts.entryName === undefined ? {} : { entryName: opts.entryName }) })
569
+ const beforeCounts = gatingCounts(before)
570
+ const beforeChoice = new Map(before.choices.map(c => [c.id, c]))
571
+
572
+ const verified: VerifiedFix[] = []
573
+ const located: LocatedFinding[] = []
574
+
575
+ for (const c of collectCandidates(root, before)) {
576
+ if (c.replacement === null || c.after === null) {
577
+ located.push({ id: c.id, code: c.code, rule: c.rule, armIndex: c.armIndex, site: c.before, reason: c.reason! })
578
+ continue
579
+ }
580
+ let attempt: ReturnType<typeof rebuildCombinator>
581
+ try { attempt = rebuildCombinator(root, new Map([[c.target, c.replacement]])) }
582
+ catch (e) {
583
+ located.push({ id: c.id, code: c.code, rule: c.rule, armIndex: c.armIndex, site: c.before,
584
+ reason: `applying the rewrite failed outright — ${e instanceof Error ? `${e.name}: ${e.message}` : String(e)}` })
585
+ continue
586
+ }
587
+ if (attempt.unapplied.length > 0) {
588
+ const frozenTag = attempt.frozen.map(f => f.tag).join(', ')
589
+ located.push({ id: c.id, code: c.code, rule: c.rule, armIndex: c.armIndex, site: c.before,
590
+ reason: `it sits inside a ${frozenTag}, which parseman cannot rebuild exactly. It will not `
591
+ + 'apply a change it cannot then check, so this one is left for you' })
592
+ continue
593
+ }
594
+ const after = outputsOf(attempt.root, corpus)
595
+ if (!sameOutputs(baseline, after)) {
596
+ // Wrong. Not shown — the site is reported, the rewrite is not.
597
+ located.push({ id: c.id, code: c.code, rule: c.rule, armIndex: c.armIndex, site: c.before,
598
+ reason: 'parseman tried the obvious rewrite here, and your files parsed DIFFERENTLY afterwards. '
599
+ + 'The rewrite is therefore wrong and is not shown' })
600
+ continue
601
+ }
602
+ const afterReport = analyzeGating(attempt.root, { ...(opts.accept === undefined ? {} : { accept: opts.accept }), ...(opts.entryName === undefined ? {} : { entryName: opts.entryName }) })
603
+ const afterCounts = gatingCounts(afterReport)
604
+ if (afterCounts.ungated >= beforeCounts.ungated && afterCounts.anti >= beforeCounts.anti) {
605
+ located.push({ id: c.id, code: c.code, rule: c.rule, armIndex: c.armIndex, site: c.before,
606
+ reason: 'a rewrite here is safe but pointless — it changes nothing measurable, so it is not '
607
+ + 'worth the diff' })
608
+ continue
609
+ }
610
+ // `c.choiceId` came from `beforeChoice`'s own id assignment by node identity, so this
611
+ // is the choice the candidate actually sits in — not the rule's first one.
612
+ const choiceId = c.choiceId
613
+ const afterChoice = afterReport.choices.find(x => x.id === choiceId)
614
+ const fix: VerifiedFix = {
615
+ id: c.id, code: c.code, rule: c.rule, armIndex: c.armIndex,
616
+ before: c.before, after: c.after,
617
+ armFirstSetBefore: firstSetToString(firstSetOf(c.arm)),
618
+ armFirstSetAfter: firstSetToString(firstSetOf(c.replacement)),
619
+ choiceId,
620
+ choiceGatesBefore: beforeChoice.get(choiceId)?.gates ?? 'no',
621
+ choiceGatesAfter: afterChoice?.gates ?? 'no',
622
+ benefit: {
623
+ ungatedChoicesBefore: beforeCounts.ungated, ungatedChoicesAfter: afterCounts.ungated,
624
+ antiPatternsBefore: beforeCounts.anti, antiPatternsAfter: afterCounts.anti,
625
+ gatedChoicesBefore: beforeCounts.gated, gatedChoicesAfter: afterCounts.gated,
626
+ artifactBytesBefore: baseline.artifactBytes, artifactBytesAfter: after.artifactBytes,
627
+ },
628
+ evidence: { samples: corpus.length, bytes, engines, outputUnchanged: true },
629
+ }
630
+ if (opts.source !== undefined) {
631
+ const e = locateEdit(opts.source, c)
632
+ if (typeof e === 'string') {
633
+ located.push({ id: c.id, code: c.code, rule: c.rule, armIndex: c.armIndex, site: c.before,
634
+ reason: `the change itself is proven safe, but ${e}. Make it by hand: ${c.before} → ${c.after}` })
635
+ continue
636
+ }
637
+ fix.edit = e
638
+ }
639
+ verified.push(fix)
640
+ }
641
+
642
+ verified.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
643
+ located.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : a.reason < b.reason ? -1 : a.reason > b.reason ? 1 : 0))
644
+ return {
645
+ schema: 'parseman.fix/1', ok: true, blocked: null,
646
+ corpus: { samples: corpus.length, bytes }, engines,
647
+ verified, located, frozen: identity.frozen,
648
+ }
649
+ }
650
+
651
+ /**
652
+ * Apply verified edits to a source text, right-to-left so earlier offsets stay valid.
653
+ *
654
+ * Pure: it returns the new text and never touches the filesystem. Writing is the
655
+ * caller's explicit second step, which is what keeps `--apply` from being something that
656
+ * happens to you.
657
+ */
658
+ export function applyFixEdits(sourceText: string, fixes: readonly VerifiedFix[]): { text: string; applied: number } {
659
+ const edits = fixes.map(f => f.edit).filter((e): e is FixEdit => e !== undefined)
660
+ .sort((a, b) => b.start - a.start)
661
+ let text = sourceText
662
+ let applied = 0
663
+ for (const e of edits) {
664
+ if (text.slice(e.start, e.end) !== e.oldText) continue // the text moved under us; skip
665
+ text = text.slice(0, e.start) + e.newText + text.slice(e.end)
666
+ applied++
667
+ }
668
+ return { text, applied }
669
+ }