/** * Static grammar-duplication / overlap / rewrite diagnostic. * * A parseman grammar is a COMBINATOR TREE, not source text — so the question * "did I write this production twice?" is a structural one a machine answers * exactly, and a reviewer answers badly. A few hundred productions is tens of * thousands of pairs; nobody reads that. This module walks the same tree * `analyzeGating` walks and reports nine families: * * 1. `duplicates` — subtrees that are structurally IDENTICAL, in ≥2 places. * 2. `nearDuplicates` — subtrees identical except at ONE slot. This is the * high-value case: a general rule cloned with one term * swapped, where the fix is one production whose varying * slot is a `choice`, not N copies of the scaffolding. * 3. `regexFragments` — alternation runs re-spelled across several `regex()` * terminals. Structural hashing cannot see inside a * regex, so this is its own pass. * 4. `regexClasses` — character classes re-spelled across terminals, and, more * usefully, NEAR-identical ones with the drift shown side * by side. * 5. `overlaps` — `choice` arms whose first-sets intersect, reported as * "these two arms, on these chars, sharing these leading * terms" rather than gating's "dispatch failed". * 6. `rewrites` — ALGEBRAIC simplifications: mechanically-derived exact * rewrites (`choice(sequence(A,B), B)` → `sequence( * optional(A), B)`), plus the dead-arm cases that are * outright bugs. * 7. `divergentNodes` — ONE `node()` type built by several structurally different * productions that are nonetheless variants of one shape. * 8. `structureLoss` — an earlier `choice` arm that FLATTENS what a later arm * would have structured: same `node()` type, overlapping * first-sets, and the earlier body contains no `node()` * at all. The parse succeeds either way, so nothing else * — not a test suite, not an output diff — reports it. * The ordered, consequential half of `divergentNodes`. * 9. `keywordRegexes` — hand-rolled keyword regexes (`regex(/not(?![-\w])/i)`) * that should be `word()`/`keywords()`. Not a style note: * `/i` without `/u` gets non-ASCII case folding wrong, * and the fix that lives inside `keywords()` (see * `combinators/case-fold.ts`) never reached the copies. * * ## What this CANNOT see * * Structural hashing is exactly that: STRUCTURAL. Two productions that accept the * same language but are shaped differently — `many(x)` vs `optional(oneOrMore(x))`, * a `regex` spelling of what another rule builds from combinators, a rule inlined * once and referenced once — are invisible to `duplicates`/`nearDuplicates` unless * they happen to fall into the `rewrites` algebra below. Equally, two subtrees that * differ only in a `transform`/`node` CALLBACK are reported as distinct when the * callbacks' source text differs, even if the functions are equivalent. This finds * COPY-PASTE and mechanical redundancy. It does not find semantic redundancy, and a * clean report is not a proof that a grammar has none. * * `analyzeDuplication()` / `analyzeDuplicationRules()` are the programmatic * surface, mirroring `analyzeGating` / `analyzeGatingRules`. The compile-time * wiring is OPT-IN (`compile(g, { duplication: 'warn' })` / * `PARSEMAN_DUPLICATION=warn`) and runs on ALL THREE lowering paths — `compile`, * `compileRuleMap` and `compileLinkable` — because the macro build takes the * latter two and a diagnostic that only runs on the first is a diagnostic that * reports zero findings forever. */ import type { Combinator, FirstSet } from '../types.ts'; import { type RefResolver } from '../combinators/first-set.ts'; /** Where a combinator sits: its owning rule, and the accessor path within it. */ export type Site = { /** Nearest enclosing `_ruleName`, or the seed name it was reached under. */ rule: string; /** Structural path inside that rule, e.g. `choice[0] › node(Declaration) › seq[4]`. */ path: string; }; export declare const siteToString: (s: Site) => string; /** N structurally identical copies of one subtree. */ export type DuplicateFinding = { kind: 'exact-duplicate'; id: string; /** Node count of the repeated subtree. */ size: number; /** How many DISTINCT combinator instances share the shape (shared-by-reference * reuse is not duplication and is never counted here). */ count: number; /** Nodes removed by hoisting the shape to one shared const: `(count - 1) * size`. */ savings: number; /** One-line rendering of the repeated shape. */ shape: string; sites: Site[]; suggestion: string; }; /** N subtrees identical except at ONE slot. */ export type NearDuplicateFinding = { kind: 'near-duplicate'; id: string; /** Node count shared by every member (the scaffolding). */ sharedSize: number; count: number; savings: number; /** The scaffolding with the varying slot rendered as `‹slot›`. */ shape: string; /** Path to the varying slot, relative to the members' root. */ slotPath: string; /** What each member puts in that slot, aligned with `sites`. */ variants: string[]; sites: Site[]; suggestion: string; }; /** An alternation run re-spelled across several `regex()` terminals. */ export type RegexFragmentFinding = { kind: 'regex-fragment'; id: string; /** The shared run, as it appears in the sources (`>=|<=|=>|=<|=~|[<>=]`). */ fragment: string; /** Number of alternation branches in the run. */ branches: number; count: number; /** Characters removed by hoisting: `(count - 1) * fragment.length`. */ savings: number; /** The full source of each regex that carries it, aligned with `sites`. */ sources: string[]; sites: Site[]; suggestion: string; }; /** One spelling of a character class, and where it appears. */ export type RegexClassVariant = { /** The class as written, including any `^`: `-_a-zA-Z0-9€-￿`. */ source: string; /** Members this spelling has that the cluster's most common spelling does not, * and vice versa — the DRIFT, rendered `+a-f / -0-9`. */ delta: string; count: number; sites: Site[]; }; /** * A character class (or boundary lookahead class) re-spelled across several * `regex()` terminals — and, when the spellings are not identical, the drift * between them. */ export type RegexClassFinding = { kind: 'regex-class'; id: string; /** The cluster's most common spelling. */ canonical: string; /** Every spelling in the cluster, most common first. Two variants side by side * IS the finding: one of them is wrong and reading cannot tell you which. */ variants: RegexClassVariant[]; /** Distinct `regex()` terminals across the whole cluster. */ count: number; /** True when the cluster holds more than one spelling. */ drifted: boolean; /** The class's highest code point is U+FFFF and it was written as an explicit * range — astral-plane characters fall outside it. */ bmpCeiling: boolean; suggestion: string; }; /** Two arms of one `choice` whose first-sets intersect. */ export type ArmOverlapFinding = { kind: 'arm-overlap'; id: string; site: Site; a: number; b: number; /** The SHARED first characters (not the union). */ on: FirstSet; /** How many leading terms the two arms spell identically. */ sharedLeadingTerms: number; /** Rendering of those shared leading terms, when there are any. */ sharedPrefix: string | null; /** True when parseman's `sharedPrefix` choice strategy already recognizes the * common prefix once at runtime — the finding is then about READABILITY, not * speed. */ handledByStrategy: boolean; /** Both arms lead with a `regex()` whose character classes intersect — usually * a sign the two arms want to be one terminal. */ regexPair: boolean; suggestion: string; }; export type RewriteKind = 'optional-prefix' | 'optional-suffix' | 'left-factor' | 'hand-rolled-sepby' | 'idempotent-nesting' | 'single-element' | 'duplicate-arm' | 'shadowed-arm'; /** A mechanically-derived rewrite. */ export type RewriteFinding = { kind: 'rewrite'; id: string; rewrite: RewriteKind; site: Site; /** What is there now. */ from: string; /** What it is equal to. */ to: string; /** * `true` only when the rewrite provably cannot move the parse VALUE — which, * here, means it only deletes an arm that can never be selected. Every other * rewrite in this family changes the child arity or nesting of the value the * site produces, so a `node()` build / `transform` / downstream consumer that * reads positionally WILL see a different tree. Those are reported as * CANDIDATES to verify, never as "fix this". */ astNeutral: boolean; /** Non-empty when the rewrite removes speculative work at parse time. */ perf: string; /** Only on `hand-rolled-sepby`: whether this SITE can actually take `sepBy`. A * count of matches is not a worklist — in a real grammar most matches are * blocked, and reporting them all as convertible generates false work. */ sepByVerdict?: SepByVerdict; /** True when this is a latent BUG (an unreachable arm), not a verbosity smell. */ bug: boolean; suggestion: string; }; /** * One AST node type built by two or more STRUCTURALLY DIFFERENT productions that * are nonetheless variants of a single shape (they spell several of the same * terms). This is the clone family near-duplicate detection cannot see: the copies * diverge in more than one slot — extra scaffolding, a hand-rolled whitespace run, * a terminal guard — so no single hole explains them, yet every edit to "the * declaration shape" still has to land in all of them, and nothing checks that it did. */ export type DivergentNodeFinding = { kind: 'divergent-node'; id: string; /** The `node()` type every member produces. */ nodeType: string; /** Number of structurally distinct productions building it. */ count: number; /** Terms every member spells identically — the evidence they are one shape. */ sharedTerms: string[]; productions: { shape: string; site: Site; /** Terms this production has that at least one sibling does not. */ distinctTerms: string[]; }[]; suggestion: string; }; /** * An earlier `choice` arm that FLATTENS what a later arm would have STRUCTURED. * * Both arms build the same `node()` type and can start on the same character, so * on the inputs both accept the earlier one wins — and its body contains no * `node()` at all, so the tree it produces is that node over bare leaves. The * later arm, on those same inputs, would have produced child nodes. Nothing about * this is visible in a parse that succeeds: `ok` is true, the span is right, the * text round-trips. Only the SHAPE moved, and only on the subset of inputs the * flattening arm happens to accept — which is why it survives a test suite that * checks "does it parse" and a corpus diff that checks emitted output. It shows * up in whatever consumes the tree: a language service that lints number tokens, * a formatter, a refactor. * * This is the ordered, consequential half of `divergent-node`. That finding says * two productions build one type and nothing keeps them in sync, and it expressly * allows "the variants exist for a parse-order reason (a fast path tried first)". * This one is the case where that excuse is the bug: the fast path is not * tree-neutral, and here is the list of node types it deletes. */ export type StructureLossFinding = { kind: 'structure-loss'; id: string; /** The `choice` that orders the two arms. */ site: Site; /** The `node()` type BOTH arms build. */ nodeType: string; /** Index of the flattening arm (the earlier one). */ earlier: number; /** Index of the structuring arm it shadows. */ later: number; /** The characters on which both arms can start — where the shadowing bites. */ on: FirstSet; /** Node types the later arm builds under `nodeType` and the earlier cannot. */ lostNodeTypes: string[]; earlierShape: string; laterShape: string; suggestion: string; }; /** A `regex()` that hand-rolls a keyword + word boundary. */ export type KeywordRegexFinding = { kind: 'keyword-regex'; id: string; site: Site; source: string; flags: string; words: string[]; /** The boundary character class, as a `word()`/`keywords()` `boundary` argument. * `null` when the regex enumerates a vocabulary with no guard at all. */ boundary: string | null; /** `/i` without `/u`: the fold class is NOT `{c, upper(c), lower(c)}`. */ caseFoldRisk: boolean; /** Sibling arm indices in the same `choice` that are also hand-rolled keywords. */ siblingArms: number[]; /** * >= 3 literal alternatives: a fixed VOCABULARY enumerated by hand rather than a * keyword with a guard. The interesting sub-case, because ordering starts to matter * and the list is usually long enough that nobody re-reads it. */ vocabulary: boolean; /** * Alternatives are in non-increasing length order. `keywords()` sorts longest-first * by construction; a hand-written alternation does not, and nothing checks it. */ longestFirst: boolean; /** * Earlier alternatives that are strict PREFIXES of later ones. Regex alternation is * first-match, so the longer branch is reachable only when a trailing boundary guard * rejects the following character and forces a backtrack (`rescuedByBoundary`). */ hazards: { shorter: string; longer: string; at: string; rescuedByBoundary: boolean; }[]; /** * At least one hazard is NOT rescued by a boundary guard — a later alternative can * never match. That is a live bug, not a cleanup. */ bug: boolean; suggestion: string; }; export type DuplicationReport = { duplicates: DuplicateFinding[]; nearDuplicates: NearDuplicateFinding[]; regexFragments: RegexFragmentFinding[]; regexClasses: RegexClassFinding[]; overlaps: ArmOverlapFinding[]; rewrites: RewriteFinding[]; divergentNodes: DivergentNodeFinding[]; structureLoss: StructureLossFinding[]; keywordRegexes: KeywordRegexFinding[]; /** Ids listed in `accept` that matched no finding — stale entries to prune. */ acceptedUnused: string[]; stats: { rules: number; /** Distinct combinator INSTANCES reached. */ nodes: number; /** Distinct structural shapes among them. */ shapes: number; }; }; export type AnalyzeDuplicationOptions = { /** Smallest repeated subtree worth reporting, in nodes. Default 3 — which is * what keeps `optional(ws)` (2 nodes) out of the ranking. */ minSize?: number; /** Cap per category, applied AFTER ranking. Default 25. */ maxFindings?: number; /** Finding `id`s to suppress — the single per-finding acknowledgement channel, * mirroring the gating snapshot allowlist. */ accept?: Iterable; /** Bind cross-artifact `g.Foo` holes when computing first-sets for `overlaps`. */ resolveRef?: RefResolver; /** Name to attribute an unnamed entry to, instead of ``. */ entryName?: string; }; /** * Every alternation branch list in a regex source, at ANY nesting depth. Depth * matters: `/a|b/` and `/x(?:a|b)y/` re-spell the same run, and only one of them * has it at top level. */ export declare function alternationGroups(src: string): string[][]; /** * Recognize a `regex()` that hand-rolls what `word()`/`keywords()` owns. * * TWO shapes, because they fail differently: * * - a word (or word alternation) plus a trailing word-boundary guard — * `regex(/not(?![-\w])/i)`; * - a bare alternation of >= 3 literal words with NO guard — a fixed vocabulary * enumerated by hand. A regex enumerating a fixed vocabulary is a keyword set * written the hard way: it loses first-set gating, it hand-maintains an ordering * the combinator guarantees, and with `/i` and no `/u` it inherits the non-ASCII * case-folding bug `keywords()` fixes internally. The no-guard form is also where * ordering is load-bearing, since nothing backtracks past a shorter match. * * Returns the words and the boundary CLASS in the exact form `word(str, boundary)` / * `keywords(words, { boundary })` take, so the suggestion names a real call rather * than describing one. */ export declare function keywordRegexShape(source: string): { words: string[]; boundary: string | null; } | null; /** * Ordering hazards in a hand-written alternation: an EARLIER alternative that is a * strict prefix of a LATER one. Regex alternation is first-match, not longest-match, * so `red|redish` matches only `red` — unless a trailing boundary guard rejects the * character that follows, which makes the engine backtrack into the longer branch. * * That distinction is the whole point of reporting this: without a rescuing guard the * longer word is UNREACHABLE, which is a live bug, not a style finding. `keywords()` * sorts longest-first by construction and the hazard cannot exist there at all. */ export declare function keywordAlternationHazards(words: readonly string[], boundary: string | null): { shorter: string; longer: string; at: string; rescuedByBoundary: boolean; }[]; export declare function analyzeDuplication(entry: Combinator, opts?: AnalyzeDuplicationOptions): DuplicationReport; export declare function analyzeDuplicationRules(ruleMap: ReadonlyArray]>, opts?: AnalyzeDuplicationOptions): DuplicationReport; /** * Every `[...]` in a regex source, INCLUDING the ones inside boundary lookaheads * (`(?![-\w])` yields `-\w`) — those are the same duplication in a different * costume, and the same drift risk. */ export declare function extractCharClasses(src: string): string[]; /** * Split a class body into normalized MEMBERS (`a-z`, `\w`, `-`, `€-￿`). * Non-ASCII literals are rewritten to `\uXXXX` so a class typed with a raw `￿` * and one typed with `￿` compare equal — otherwise the diagnostic reports * drift that is only an editor's. */ export declare function charClassMembers(body: string): string[]; /** * Whether a hand-rolled separated list can ACTUALLY become `sepBy` — a verdict per * site, not a count. Reporting every match as convertible generates false work: on * the reference Less grammar only a minority are, and the rest are blocked for two * concrete, detectable reasons. */ export type SepByVerdict = 'convertible' | 'blocked-by-capture' | 'reducer-stride-review'; export type DuplicationWarnLevel = 'off' | 'warn' | 'error'; /** * Ranked, actionable lines — same tone and shape as `formatGatingWarnings`: what * it is, where it is, and one concrete thing to do about it. */ export declare function formatDuplicationFindings(report: DuplicationReport): string[]; /** Total findings across every category — the number the `'error'` gate keys on. */ export declare function duplicationFindingCount(report: DuplicationReport): number; //# sourceMappingURL=duplication.d.ts.map