/** * Static first-char gating diagnostic. * * Parseman is scannerless PEG: a `choice` is CORRECT regardless of whether it * first-char-gates. When a hot choice fails to gate, every non-matching input * position speculatively ENTERS a doomed arm (ctx save/restore + child array + * recognizer + rollback) instead of being skipped by a cheap first-char test — * and nothing tells the author, because the grammar still passes every test. The * only symptom is a CPU profile. This module surfaces, at build time, exactly what * the compiler already knows: which choices gate, and for those that don't, which * arm poisons dispatch and why. * * `analyzeGating()` is the pure programmatic surface, and `diagnoseGrammar()` * (`src/analysis/diagnose.ts`) is the entry point most callers want. `compile()` does * NOT run either: a diagnostic is a deliberate act, not a side effect of producing an * artifact. Accept a deliberately-ungated choice by listing its `id` in the gating * snapshot allowlist (`analyzeGating(entry, { accept })` / `diagnoseGrammar(g, { * accept })`) — the single suppression mechanism. * * WHERE the question is asked matters as much as the answer. A SHARED SHAPE — a * `rules()` map referencing a rule it doesn't define (`g.Value`) — has no verdict of * its own: the hole makes every first-set through it `any`, but that configuration is * never executed and its author cannot fix it. Such a choice is `deferred` here, and * re-asked with `resolveRef` at the site that BINDS the name — `analyzeGrammarGating` * on the fused artifact, which really runs and whose author really can fix it. */ import type { Combinator, FirstSet, ParserDef } from '../types.ts'; import { intersects, type RefResolver } from '../combinators/first-set.ts'; /** Why an arm's (deep) first-set is `any` / over-broad — the poison source. */ export type FirstSetCause = 'leading-not' | 'nullable-prefix' | 'cross-artifact-ref' | 'broad-recognizer' | 'opaque-wrapper' | 'ref-cycle'; /** An arm whose deep first-set is `any` / over-broad. */ export type AnyArm = { index: number; cause: FirstSetCause; /** Human-readable trail to the poison, e.g. "via ref g.anyValue → broad recognizer (regex)". */ detail: string; /** * True when only the CONSTRUCTION-time (shallow) first-set was `any` but the * deep, ref-resolving first-set is finite — the monolithic compile recovers a * real per-arm guard, so this is NOT a genuine cliff. Never present on a * genuinely-ungated finding. */ shallowAnyOnly: boolean; /** * True when the poison is a NAMED cross-artifact hole this artifact cannot resolve * (`g.Value` in a shared shape). The author of THIS artifact cannot act on it — the * arm's real first-set only exists once a consumer binds the name — so a choice * whose every `any` arm is one of these is `deferred`, not `ungated`. * * An UNNAMED unresolved `ref()` is NOT this: nobody can bind it by name, so it is a * genuine local finding and stays reportable here. */ unresolvedExternal: boolean; /** Concrete fix, naming a real primitive. */ suggestion: string; }; /** Two arms whose finite first-sets intersect — a shared prefix. */ export type Overlap = { a: number; b: number; on: FirstSet; suggestion: string; }; /** An API-misuse pattern detected in a choice's arms (independent of gating). */ export type AntiPattern = { kind: 'double-not' | 'leading-not' | 'keyword-regex'; rule: string; armIndex: number; message: string; }; export type ChoiceStrategyTag = 'firstMatch' | 'greedyClassify' | 'literalsLongestFirst' | 'sharedPrefix'; export type ChoiceGating = { /** * Stable per-choice identity for the accepted-snapshot allowlist. The enclosing * rule name when that rule holds exactly one choice, else `rule#N` (0-based * occurrence order within the rule). This is the key you list in the snapshot to * ACCEPT a known ungated choice. */ id: string; /** Nearest enclosing rule name (from `_ruleName`), or a synthetic path label. */ rule: string; strategy: ChoiceStrategyTag; /** * `yes` — emits O(1) first-char dispatch (a switch/if jump table). * `recoverable` — not O(1) dispatch, but every arm still first-char-guards via * the deep, ref-resolving first-set (monolithic compile) / fuse-time resolution * (compose). NOT a cliff; never warned. * `no` — genuinely ungated: a broad/any arm or a finite overlap forces ordered * speculative entry with no per-arm first-char skip. */ gates: 'yes' | 'recoverable' | 'no'; /** True when this ungated choice's `id` is in the accepted-snapshot allowlist. */ accepted: boolean; /** * `gates: 'no'` was decided by cross-artifact HOLES ONLY — every `any` arm is an * unresolved NAMED `g.Foo` ref and no two finite arms overlap. The verdict is not * this artifact's to make: the shape module can't fix it (the hole has no body * here) and the configuration it describes never runs. The answer belongs to the * FUSED artifact, where the name is bound — see `analyzeGrammarGating`. * * Deferred choices are excluded from `ungated`: they neither warn nor fail the * `'error'` gate at this site. */ deferred: boolean; combinedFirstSet: { shallow: FirstSet; deep: FirstSet; }; anyArms: AnyArm[]; overlaps: Overlap[]; }; /** * The arms a `ChoiceGating` describes, in arm order — or `undefined` when the report did * not come from a live walk (a deserialized snapshot). Carried non-enumerably, so it * never reaches the JSON a CI snapshot diffs. */ export declare const choiceArms: (c: ChoiceGating) => readonly Combinator[] | undefined; /** Options for `analyzeGating` — the accepted-snapshot allowlist. */ export type AnalyzeGatingOptions = { /** * Choice `id`s that are accepted as intentionally ungated. An ungated choice * whose id is here is moved to `accepted` (silent, does not fail the CI gate); one * whose id is NOT here stays in `ungated` (warned + fails the gate). This is the * SINGLE per-choice suppression mechanism. */ accept?: Iterable; /** * Name to attribute an UNNAMED entry to, instead of the synthetic ``. * The macro plugin passes the binding's own variable name, so a warning on a * top-level combinator const reads `choice @ directMixinReferenceAhead` * (actionable, and a discriminating `accept` key) rather than `choice @ * ` repeated once per const. Ignored when the entry already carries a * `_ruleName`. */ entryName?: string; /** * Bind NAMED cross-artifact holes (`g.Foo`) by name — supply the FUSED winner map's * lookup. With it, an arm led by a shared shape's hole reports the first-set it * really has once bound, so `deferred` collapses to a real `yes`/`no` verdict. * Without it (the authoring site) such a choice stays `deferred`. */ resolveRef?: RefResolver; }; /** * A rule the walk could NOT introspect. Its choices were never examined, so no * verdict about it — clean or otherwise — is available. * * This exists because the alternative is silence. A `compose()` result is a map of * FUSED rule functions with no `_def` combinator graph; walking one used to throw a * bare `TypeError: Cannot read properties of undefined (reading 'tag')`, and the * default-on diagnostic swallowed that throw and returned `undefined` — a failed * analysis and a clean grammar were indistinguishable. Every unanalysable input is * now counted and named here, and `formatGatingWarnings` always reports it. */ export type Unanalysable = { /** The rule name (or seed name) whose walk stopped. */ rule: string; /** Why it could not be walked, in terms the caller can act on. */ reason: string; kind: 'fused-rule' | 'opaque-artifact' | 'not-a-combinator'; }; export type GatingReport = { totalChoices: number; gated: number; recoverable: number; /** * Rules the walk could not introspect (see `Unanalysable`). NON-EMPTY MEANS THE * REPORT IS PARTIAL: `totalChoices === 0` with a non-empty `unanalysable` is a * blind walk, not a clean grammar. Callers that treat an empty `ungated` as a pass * MUST also assert this is empty. */ unanalysable: Unanalysable[]; /** Genuinely-ungated choices NOT in the accepted allowlist — warned + gate-failing. */ ungated: ChoiceGating[]; /** Ungated choices whose id was in the accepted allowlist — silent, accepted with intent. */ accepted: ChoiceGating[]; /** * Choices whose verdict is NOT this artifact's to make — every `any` arm is an * unresolved cross-artifact hole (see `ChoiceGating.deferred`). Silent here; the * fused artifact re-asks the question with the hole bound. */ deferred: ChoiceGating[]; /** Accepted ids that matched no ungated choice — stale snapshot entries to prune. */ acceptedUnused: string[]; /** Every choice, for full inspection / CI snapshots. */ choices: ChoiceGating[]; antiPatterns: AntiPattern[]; }; /** * Ordered structural children per def tag. Explicit rather than "every key that * holds a Combinator" because a SLOT's position is what near-duplicate detection * varies — a stable, meaningful order is load-bearing, not cosmetic. * * Lives here, in the module every analysis pass already imports, because * `./choice-cost.ts` and `./duplication.ts` each carried a byte-identical copy. */ export declare function childrenOf(d: ParserDef): readonly Combinator[]; /** * Do two first-sets share any character? * * THE DEFINITION LIVES IN `../combinators/first-set.ts`, beside `union` and the * rest of the first-set algebra, and is re-exported here only because * `./duplication.ts` imports it from this module. * * This used to be a third copy. Two byte-identical copies in `./choice-cost.ts` * and `./duplication.ts` were collapsed into a declaration here, and the note * recording that said `intersects` now "lives once" — while * `../combinators/first-set.ts` had been exporting its own since before any of * them. INV-4 could not see it: the two bodies differ only in whether the nested * `for` carries braces, and INV-4 decides on byte-identity after whitespace is * stripped. INV-8 sees it, because it decides on the NAME. */ export { intersects }; export declare function firstSetToString(fs: FirstSet): string; /** Peel non-consuming wrappers to the arm's leading term (mirrors leadingTermOfArm). */ export declare function peelToLeading(arm: Combinator): Combinator; export declare function analyzeGating(entry: Combinator, opts?: AnalyzeGatingOptions): GatingReport; /** * Multi-root variant: analyze a WHOLE `rules()` map in one walk, so every choice * is attributed to the rule that owns it. * * The macro build compiles a grammar through `compileRuleMap`/`compileLinkable`, * never through the single-entry `compile()`. Analyzing one entry at a time (or * not at all) is what produced unnamed `choice @ ` warnings and ZERO * anti-patterns for grammars that in fact have dozens: an unnamed warning in a * multi-thousand-line grammar is unactionable. Seeding the walk with EVERY named * root is what recovers the rule names — and, because the walk is shared, each * choice is still analyzed exactly once however many rules reach it. */ export declare function analyzeGatingRules(ruleMap: ReadonlyArray]>, opts?: AnalyzeGatingOptions): GatingReport; /** * Format the genuinely-ungated findings + anti-patterns as ready-to-print lines. * Precise by design: only 'no'-gated choices NOT in the accepted allowlist, plus * the anti-pattern lints. Recoverable / gated / accepted / DEFERRED choices produce * nothing (a deferred choice's verdict belongs to the fusing artifact, not here). */ export declare function formatGatingWarnings(report: GatingReport): string[]; /** * Dependency manifest for a rule map: for each rule, the set of OTHER rule names * its body references. A referenced rule is a BOUNDARY — record the edge and do * NOT descend into it (its own deps are its own entry). Self-references are * included, because a recursive rule does depend on itself. * * Used for a la carte dep-closure selection (`pick`) and the compose-time name * closure check. This lives here rather than in a lowering because it is a walk * over the COMBINATOR GRAPH and has nothing to do with how that graph is lowered * — it outlived the source lowering it was first written inside. */ export declare function ruleDependencies(ruleMap: ReadonlyArray]>): Map; //# sourceMappingURL=gating.d.ts.map