parseman 0.45.0 → 0.47.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -3
- package/dist/analysis/choice-cost-gate.d.ts +158 -0
- package/dist/analysis/choice-cost-gate.d.ts.map +1 -0
- package/dist/analysis/choice-cost-render.d.ts +96 -0
- package/dist/analysis/choice-cost-render.d.ts.map +1 -0
- package/dist/analysis/choice-cost.d.ts +383 -0
- package/dist/analysis/choice-cost.d.ts.map +1 -0
- package/dist/analysis/commitment.d.ts +131 -0
- package/dist/analysis/commitment.d.ts.map +1 -0
- package/dist/analysis/corpus.d.ts +73 -0
- package/dist/analysis/corpus.d.ts.map +1 -0
- package/dist/analysis/diagnose-render.d.ts +65 -0
- package/dist/analysis/diagnose-render.d.ts.map +1 -0
- package/dist/analysis/diagnose.d.ts +15 -0
- package/dist/analysis/diagnose.d.ts.map +1 -1
- package/dist/analysis/diagnostics.cjs +13736 -0
- package/dist/analysis/diagnostics.cjs.map +6 -0
- package/dist/analysis/diagnostics.d.ts +27 -0
- package/dist/analysis/diagnostics.d.ts.map +1 -0
- package/dist/analysis/diagnostics.js +13713 -0
- package/dist/analysis/diagnostics.js.map +6 -0
- package/dist/analysis/duplication.d.ts.map +1 -1
- package/dist/analysis/fix-render.d.ts +43 -0
- package/dist/analysis/fix-render.d.ts.map +1 -0
- package/dist/analysis/fix.d.ts +172 -0
- package/dist/analysis/fix.d.ts.map +1 -0
- package/dist/analysis/format-number.d.ts +23 -0
- package/dist/analysis/format-number.d.ts.map +1 -0
- package/dist/analysis/gating.d.ts +47 -2
- package/dist/analysis/gating.d.ts.map +1 -1
- package/dist/analysis/rebuild.d.ts +61 -0
- package/dist/analysis/rebuild.d.ts.map +1 -0
- package/dist/analysis/terminal.d.ts +145 -0
- package/dist/analysis/terminal.d.ts.map +1 -0
- package/dist/cli/index.cjs +14837 -0
- package/dist/cli/index.cjs.map +6 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +14814 -0
- package/dist/cli/index.js.map +6 -0
- package/dist/combinators/adjacency.d.ts +100 -0
- package/dist/combinators/adjacency.d.ts.map +1 -0
- package/dist/combinators/choice.d.ts.map +1 -1
- package/dist/combinators/completions.d.ts.map +1 -1
- package/dist/combinators/dispatch.d.ts +8 -1
- package/dist/combinators/dispatch.d.ts.map +1 -1
- package/dist/combinators/expect.d.ts.map +1 -1
- package/dist/combinators/first-set.d.ts.map +1 -1
- package/dist/combinators/gate.d.ts.map +1 -1
- package/dist/combinators/grammar.d.ts +22 -0
- package/dist/combinators/grammar.d.ts.map +1 -1
- package/dist/combinators/literal.d.ts +8 -0
- package/dist/combinators/literal.d.ts.map +1 -1
- package/dist/combinators/map.d.ts +0 -1
- package/dist/combinators/map.d.ts.map +1 -1
- package/dist/combinators/node.d.ts +13 -3
- package/dist/combinators/node.d.ts.map +1 -1
- package/dist/combinators/parser.d.ts +1 -1
- package/dist/combinators/parser.d.ts.map +1 -1
- package/dist/combinators/ref.d.ts.map +1 -1
- package/dist/combinators/regex.d.ts +1 -16
- package/dist/combinators/regex.d.ts.map +1 -1
- package/dist/combinators/repeat.d.ts +30 -2
- package/dist/combinators/repeat.d.ts.map +1 -1
- package/dist/combinators/scanTo.d.ts +71 -1
- package/dist/combinators/scanTo.d.ts.map +1 -1
- package/dist/combinators/sequence.d.ts.map +1 -1
- package/dist/combinators/token.d.ts.map +1 -1
- package/dist/combinators/trivia-boundary.d.ts +26 -0
- package/dist/combinators/trivia-boundary.d.ts.map +1 -0
- package/dist/combinators/trivia-skip.d.ts +40 -1
- package/dist/combinators/trivia-skip.d.ts.map +1 -1
- package/dist/combinators/withCtx.d.ts.map +1 -1
- package/dist/compiler/build-arity.d.ts.map +1 -1
- package/dist/compiler/compile-linkable-table.d.ts +104 -0
- package/dist/compiler/compile-linkable-table.d.ts.map +1 -0
- package/dist/compiler/degradation.d.ts +0 -2
- package/dist/compiler/degradation.d.ts.map +1 -1
- package/dist/compiler/fields.d.ts +13 -0
- package/dist/compiler/fields.d.ts.map +1 -1
- package/dist/compiler/grammar-coverage-ids.d.ts +5 -5
- package/dist/compiler/grammar-coverage-ids.d.ts.map +1 -1
- package/dist/compiler/ir-serialize.d.ts.map +1 -1
- package/dist/compiler/linker.d.ts +79 -47
- package/dist/compiler/linker.d.ts.map +1 -1
- package/dist/compiler/token-alphabet.d.ts +81 -0
- package/dist/compiler/token-alphabet.d.ts.map +1 -0
- package/dist/compiler/token-dispatch.d.ts +95 -0
- package/dist/compiler/token-dispatch.d.ts.map +1 -0
- package/dist/compiler/token-scanner.d.ts +53 -0
- package/dist/compiler/token-scanner.d.ts.map +1 -0
- package/dist/compiler/value-usage.d.ts.map +1 -1
- package/dist/coverage.d.ts +1 -1
- package/dist/coverage.d.ts.map +1 -1
- package/dist/cst/capture-buffer.d.ts +35 -0
- package/dist/cst/capture-buffer.d.ts.map +1 -1
- package/dist/cst/reflection-attach.d.ts +8 -0
- package/dist/cst/reflection-attach.d.ts.map +1 -0
- package/dist/cst/reflection-symbols.d.ts +10 -0
- package/dist/cst/reflection-symbols.d.ts.map +1 -0
- package/dist/cst/reflection.d.ts +2 -4
- package/dist/cst/reflection.d.ts.map +1 -1
- package/dist/cst/root-trivia-scope.d.ts +26 -0
- package/dist/cst/root-trivia-scope.d.ts.map +1 -0
- package/dist/cst/trivia-charscan.d.ts +18 -0
- package/dist/cst/trivia-charscan.d.ts.map +1 -0
- package/dist/cst/trivia-kinds.d.ts +19 -0
- package/dist/cst/trivia-kinds.d.ts.map +1 -1
- package/dist/functional/doc.d.ts.map +1 -1
- package/dist/functional/run-tabled.d.ts +12 -0
- package/dist/functional/run-tabled.d.ts.map +1 -0
- package/dist/functional/run.d.ts +25 -42
- package/dist/functional/run.d.ts.map +1 -1
- package/dist/grammar-metadata.d.ts +3 -0
- package/dist/grammar-metadata.d.ts.map +1 -0
- package/dist/index.cjs +10499 -7478
- package/dist/index.cjs.map +3 -4
- package/dist/index.d.ts +24 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10499 -7478
- package/dist/index.js.map +3 -4
- package/dist/language-service/index.cjs +311 -121
- package/dist/language-service/index.cjs.map +3 -4
- package/dist/language-service/index.js +311 -121
- package/dist/language-service/index.js.map +3 -4
- package/dist/oracle/index.cjs.map +0 -1
- package/dist/oracle/index.js.map +0 -1
- package/dist/parse-context.d.ts +49 -0
- package/dist/parse-context.d.ts.map +1 -0
- package/dist/plugin/evaluator.d.ts +41 -2
- package/dist/plugin/evaluator.d.ts.map +1 -1
- package/dist/plugin/free-identifiers.d.ts +23 -0
- package/dist/plugin/free-identifiers.d.ts.map +1 -0
- package/dist/plugin/index.cjs +11381 -9442
- package/dist/plugin/index.cjs.map +3 -4
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +11381 -9442
- package/dist/plugin/index.js.map +3 -4
- package/dist/plugin/reducer-resolver.d.ts +17 -1
- package/dist/plugin/reducer-resolver.d.ts.map +1 -1
- package/dist/regex/classes.d.ts +28 -0
- package/dist/regex/classes.d.ts.map +1 -1
- package/dist/regex/first-set.d.ts +14 -0
- package/dist/regex/first-set.d.ts.map +1 -1
- package/dist/run/index.cjs +136 -51
- package/dist/run/index.cjs.map +2 -3
- package/dist/run/index.js +136 -51
- package/dist/run/index.js.map +2 -3
- package/dist/spec/index.cjs +2898 -1622
- package/dist/spec/index.cjs.map +3 -4
- package/dist/spec/index.js +2898 -1622
- package/dist/spec/index.js.map +3 -4
- package/dist/spec/model.d.ts +8 -0
- package/dist/spec/model.d.ts.map +1 -1
- package/dist/table/assemble.d.ts +307 -0
- package/dist/table/assemble.d.ts.map +1 -0
- package/dist/table/cell.d.ts +42 -0
- package/dist/table/cell.d.ts.map +1 -0
- package/dist/table/child-slots.d.ts +30 -0
- package/dist/table/child-slots.d.ts.map +1 -0
- package/dist/table/compile-rule-map.d.ts +136 -0
- package/dist/table/compile-rule-map.d.ts.map +1 -0
- package/dist/table/compile.d.ts +56 -0
- package/dist/table/compile.d.ts.map +1 -0
- package/dist/table/duplication-hook.d.ts +32 -0
- package/dist/table/duplication-hook.d.ts.map +1 -0
- package/dist/table/emit-assembly.d.ts +139 -0
- package/dist/table/emit-assembly.d.ts.map +1 -0
- package/dist/table/emit.d.ts +113 -0
- package/dist/table/emit.d.ts.map +1 -0
- package/dist/table/encode-baseline.d.ts +16 -0
- package/dist/table/encode-baseline.d.ts.map +1 -0
- package/dist/table/encode.d.ts +71 -0
- package/dist/table/encode.d.ts.map +1 -0
- package/dist/table/exec-baseline.d.ts +9 -0
- package/dist/table/exec-baseline.d.ts.map +1 -0
- package/dist/table/exec.d.ts +72 -0
- package/dist/table/exec.d.ts.map +1 -0
- package/dist/table/fold.d.ts +25 -0
- package/dist/table/fold.d.ts.map +1 -0
- package/dist/table/index.cjs +11375 -0
- package/dist/table/index.cjs.map +6 -0
- package/dist/table/index.d.ts +100 -0
- package/dist/table/index.d.ts.map +1 -0
- package/dist/table/index.js +11352 -0
- package/dist/table/index.js.map +6 -0
- package/dist/table/inspect.d.ts +17 -0
- package/dist/table/inspect.d.ts.map +1 -0
- package/dist/table/ops.d.ts +446 -0
- package/dist/table/ops.d.ts.map +1 -0
- package/dist/table/program.d.ts +493 -0
- package/dist/table/program.d.ts.map +1 -0
- package/dist/table/run-support.d.ts +38 -0
- package/dist/table/run-support.d.ts.map +1 -0
- package/dist/{compiler/scannable-run.d.ts → table/scan-shapes.d.ts} +14 -13
- package/dist/table/scan-shapes.d.ts.map +1 -0
- package/dist/table/site-labels.d.ts +58 -0
- package/dist/table/site-labels.d.ts.map +1 -0
- package/dist/table/stamp.d.ts +70 -0
- package/dist/table/stamp.d.ts.map +1 -0
- package/dist/types.d.ts +80 -24
- package/dist/types.d.ts.map +1 -1
- package/dist/version.d.ts +1 -1
- package/package.json +41 -3
- package/src/analysis/choice-cost-gate.ts +359 -0
- package/src/analysis/choice-cost-render.ts +293 -0
- package/src/analysis/choice-cost.ts +1008 -0
- package/src/analysis/commitment.ts +557 -0
- package/src/analysis/corpus.ts +130 -0
- package/src/analysis/diagnose-render.ts +625 -0
- package/src/analysis/diagnose.ts +317 -0
- package/src/analysis/diagnostics.ts +33 -0
- package/src/analysis/duplication.ts +1955 -0
- package/src/analysis/fix-render.ts +217 -0
- package/src/analysis/fix.ts +669 -0
- package/src/analysis/format-number.ts +31 -0
- package/src/analysis/gating.ts +792 -0
- package/src/analysis/grammar.ts +72 -0
- package/src/analysis/rebuild.ts +287 -0
- package/src/analysis/terminal.ts +336 -0
- package/src/cli/index.ts +413 -0
- package/src/combinators/adjacency.ts +171 -0
- package/src/combinators/attempt.ts +41 -0
- package/src/combinators/case-fold.ts +90 -0
- package/src/combinators/choice.ts +466 -0
- package/src/combinators/completions.ts +64 -0
- package/src/combinators/dispatch.ts +358 -0
- package/src/combinators/expect.ts +159 -0
- package/src/combinators/first-set.ts +322 -0
- package/src/combinators/gate.ts +50 -0
- package/src/combinators/grammar.ts +272 -0
- package/src/combinators/keywords.ts +145 -0
- package/src/combinators/literal.ts +102 -0
- package/src/combinators/map.ts +106 -0
- package/src/combinators/node.ts +345 -0
- package/src/combinators/not.ts +55 -0
- package/src/combinators/parser.ts +266 -0
- package/src/combinators/peek.ts +63 -0
- package/src/combinators/probe.ts +25 -0
- package/src/combinators/ref.ts +109 -0
- package/src/combinators/regex.ts +170 -0
- package/src/combinators/repeat.ts +561 -0
- package/src/combinators/scanTo.ts +393 -0
- package/src/combinators/sequence.ts +221 -0
- package/src/combinators/token.ts +120 -0
- package/src/combinators/trivia-boundary.ts +48 -0
- package/src/combinators/trivia-skip.ts +821 -0
- package/src/combinators/withCtx.ts +45 -0
- package/src/compiler/build-arity.ts +330 -0
- package/src/compiler/compile-linkable-table.ts +209 -0
- package/src/compiler/degradation.ts +266 -0
- package/src/compiler/fields.ts +191 -0
- package/src/compiler/grammar-coverage-ids.ts +129 -0
- package/src/compiler/ir-serialize.ts +615 -0
- package/src/compiler/linker.ts +954 -0
- package/src/compiler/token-alphabet.ts +209 -0
- package/src/compiler/token-dispatch.ts +353 -0
- package/src/compiler/token-scanner.ts +318 -0
- package/src/compiler/value-usage.ts +147 -0
- package/src/coverage.ts +383 -0
- package/src/cst/capture-buffer.ts +297 -0
- package/src/cst/host-mode.ts +69 -0
- package/src/cst/reflection-attach.ts +11 -0
- package/src/cst/reflection-symbols.ts +9 -0
- package/src/cst/reflection.ts +149 -0
- package/src/cst/relative-spans.ts +207 -0
- package/src/cst/root-trivia-scope.ts +30 -0
- package/src/cst/trivia-charscan.ts +312 -0
- package/src/cst/trivia-entries.ts +285 -0
- package/src/cst/trivia-index.ts +131 -0
- package/src/cst/trivia-kinds.ts +358 -0
- package/src/cst/types.ts +76 -0
- package/src/cst/walk.ts +101 -0
- package/src/functional/doc.ts +959 -0
- package/src/functional/run-tabled.ts +153 -0
- package/src/functional/run.ts +558 -0
- package/src/grammar-metadata.ts +2 -0
- package/src/index.ts +189 -0
- package/src/language-service/index.ts +221 -0
- package/src/line-index.ts +131 -0
- package/src/oracle/digest.ts +620 -0
- package/src/oracle/index.ts +55 -0
- package/src/parse-context.ts +91 -0
- package/src/plugin/direct-builder-static.ts +102 -0
- package/src/plugin/evaluator.ts +1505 -0
- package/src/plugin/free-identifiers.ts +338 -0
- package/src/plugin/index.ts +2281 -0
- package/src/plugin/reducer-resolver.ts +606 -0
- package/src/recovery/scan.ts +155 -0
- package/src/regex/classes.ts +183 -0
- package/src/regex/first-set.ts +316 -0
- package/src/run/index.ts +31 -0
- package/src/spec/ebnf.ts +128 -0
- package/src/spec/index.ts +54 -0
- package/src/spec/model.ts +636 -0
- package/src/spec/railroad-lib.ts +559 -0
- package/src/spec/railroad.ts +256 -0
- package/src/table/assemble.ts +2994 -0
- package/src/table/cell.ts +42 -0
- package/src/table/child-slots.ts +113 -0
- package/src/table/compile-rule-map.ts +336 -0
- package/src/table/compile.ts +244 -0
- package/src/table/duplication-hook.ts +89 -0
- package/src/table/emit-assembly.ts +1766 -0
- package/src/table/emit.ts +418 -0
- package/src/table/encode-baseline.ts +340 -0
- package/src/table/encode.ts +1569 -0
- package/src/table/exec-baseline.ts +537 -0
- package/src/table/exec.ts +1672 -0
- package/src/table/fold.ts +38 -0
- package/src/table/index.ts +108 -0
- package/src/table/inspect.ts +52 -0
- package/src/table/ops.ts +459 -0
- package/src/table/program.ts +855 -0
- package/src/table/run-support.ts +66 -0
- package/src/table/scan-shapes.ts +1578 -0
- package/src/table/site-labels.ts +228 -0
- package/src/table/stamp.ts +229 -0
- package/src/types.ts +563 -0
- package/src/version.ts +16 -0
- package/dist/compiler/codegen.d.ts +0 -334
- package/dist/compiler/codegen.d.ts.map +0 -1
- package/dist/compiler/inline-build.d.ts +0 -11
- package/dist/compiler/inline-build.d.ts.map +0 -1
- package/dist/compiler/inline-callback.d.ts +0 -21
- package/dist/compiler/inline-callback.d.ts.map +0 -1
- package/dist/compiler/line-index.d.ts +0 -2
- package/dist/compiler/line-index.d.ts.map +0 -1
- package/dist/compiler/scannable-run.d.ts.map +0 -1
- package/dist/compiler/scannable-terminal.d.ts +0 -23
- package/dist/compiler/scannable-terminal.d.ts.map +0 -1
- package/dist/compiler/trivia-fast-path.d.ts +0 -38
- 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
|
+
}
|