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,792 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static first-char gating diagnostic.
|
|
3
|
+
*
|
|
4
|
+
* Parseman is scannerless PEG: a `choice` is CORRECT regardless of whether it
|
|
5
|
+
* first-char-gates. When a hot choice fails to gate, every non-matching input
|
|
6
|
+
* position speculatively ENTERS a doomed arm (ctx save/restore + child array +
|
|
7
|
+
* recognizer + rollback) instead of being skipped by a cheap first-char test —
|
|
8
|
+
* and nothing tells the author, because the grammar still passes every test. The
|
|
9
|
+
* only symptom is a CPU profile. This module surfaces, at build time, exactly what
|
|
10
|
+
* the compiler already knows: which choices gate, and for those that don't, which
|
|
11
|
+
* arm poisons dispatch and why.
|
|
12
|
+
*
|
|
13
|
+
* `analyzeGating()` is the pure programmatic surface, and `diagnoseGrammar()`
|
|
14
|
+
* (`src/analysis/diagnose.ts`) is the entry point most callers want. `compile()` does
|
|
15
|
+
* NOT run either: a diagnostic is a deliberate act, not a side effect of producing an
|
|
16
|
+
* artifact. Accept a deliberately-ungated choice by listing its `id` in the gating
|
|
17
|
+
* snapshot allowlist (`analyzeGating(entry, { accept })` / `diagnoseGrammar(g, {
|
|
18
|
+
* accept })`) — the single suppression mechanism.
|
|
19
|
+
*
|
|
20
|
+
* WHERE the question is asked matters as much as the answer. A SHARED SHAPE — a
|
|
21
|
+
* `rules()` map referencing a rule it doesn't define (`g.Value`) — has no verdict of
|
|
22
|
+
* its own: the hole makes every first-set through it `any`, but that configuration is
|
|
23
|
+
* never executed and its author cannot fix it. Such a choice is `deferred` here, and
|
|
24
|
+
* re-asked with `resolveRef` at the site that BINDS the name — `analyzeGrammarGating`
|
|
25
|
+
* on the fused artifact, which really runs and whose author really can fix it.
|
|
26
|
+
*/
|
|
27
|
+
import type { Combinator, FirstSet, ParserDef } from '../types.ts'
|
|
28
|
+
import { firstSetOf, intersects, matchesEmpty, type RefResolver } from '../combinators/first-set.ts'
|
|
29
|
+
|
|
30
|
+
/** Why an arm's (deep) first-set is `any` / over-broad — the poison source. */
|
|
31
|
+
export type FirstSetCause =
|
|
32
|
+
| 'leading-not' // a leading zero-width not(...) (its first-set is `any`)
|
|
33
|
+
| 'nullable-prefix' // a leading optional/many skips to a broad following term
|
|
34
|
+
| 'cross-artifact-ref' // a g.Foo ref resolves to `any` (unresolved across a boundary)
|
|
35
|
+
| 'broad-recognizer' // a scanTo / any-first-set regex / guard leads the arm
|
|
36
|
+
| 'opaque-wrapper' // guard/withCtx/recover contributes `any`
|
|
37
|
+
| 'ref-cycle' // a mutually-recursive ref cycle resolved to `any`
|
|
38
|
+
|
|
39
|
+
/** An arm whose deep first-set is `any` / over-broad. */
|
|
40
|
+
export type AnyArm = {
|
|
41
|
+
index: number
|
|
42
|
+
cause: FirstSetCause
|
|
43
|
+
/** Human-readable trail to the poison, e.g. "via ref g.anyValue → broad recognizer (regex)". */
|
|
44
|
+
detail: string
|
|
45
|
+
/**
|
|
46
|
+
* True when only the CONSTRUCTION-time (shallow) first-set was `any` but the
|
|
47
|
+
* deep, ref-resolving first-set is finite — the monolithic compile recovers a
|
|
48
|
+
* real per-arm guard, so this is NOT a genuine cliff. Never present on a
|
|
49
|
+
* genuinely-ungated finding.
|
|
50
|
+
*/
|
|
51
|
+
shallowAnyOnly: boolean
|
|
52
|
+
/**
|
|
53
|
+
* True when the poison is a NAMED cross-artifact hole this artifact cannot resolve
|
|
54
|
+
* (`g.Value` in a shared shape). The author of THIS artifact cannot act on it — the
|
|
55
|
+
* arm's real first-set only exists once a consumer binds the name — so a choice
|
|
56
|
+
* whose every `any` arm is one of these is `deferred`, not `ungated`.
|
|
57
|
+
*
|
|
58
|
+
* An UNNAMED unresolved `ref()` is NOT this: nobody can bind it by name, so it is a
|
|
59
|
+
* genuine local finding and stays reportable here.
|
|
60
|
+
*/
|
|
61
|
+
unresolvedExternal: boolean
|
|
62
|
+
/** Concrete fix, naming a real primitive. */
|
|
63
|
+
suggestion: string
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Two arms whose finite first-sets intersect — a shared prefix. */
|
|
67
|
+
export type Overlap = {
|
|
68
|
+
a: number
|
|
69
|
+
b: number
|
|
70
|
+
on: FirstSet
|
|
71
|
+
suggestion: string
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** An API-misuse pattern detected in a choice's arms (independent of gating). */
|
|
75
|
+
export type AntiPattern = {
|
|
76
|
+
kind: 'double-not' | 'leading-not' | 'keyword-regex'
|
|
77
|
+
rule: string
|
|
78
|
+
armIndex: number
|
|
79
|
+
message: string
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type ChoiceStrategyTag = 'firstMatch' | 'greedyClassify' | 'literalsLongestFirst' | 'sharedPrefix'
|
|
83
|
+
|
|
84
|
+
export type ChoiceGating = {
|
|
85
|
+
/**
|
|
86
|
+
* Stable per-choice identity for the accepted-snapshot allowlist. The enclosing
|
|
87
|
+
* rule name when that rule holds exactly one choice, else `rule#N` (0-based
|
|
88
|
+
* occurrence order within the rule). This is the key you list in the snapshot to
|
|
89
|
+
* ACCEPT a known ungated choice.
|
|
90
|
+
*/
|
|
91
|
+
id: string
|
|
92
|
+
/** Nearest enclosing rule name (from `_ruleName`), or a synthetic path label. */
|
|
93
|
+
rule: string
|
|
94
|
+
strategy: ChoiceStrategyTag
|
|
95
|
+
/**
|
|
96
|
+
* `yes` — emits O(1) first-char dispatch (a switch/if jump table).
|
|
97
|
+
* `recoverable` — not O(1) dispatch, but every arm still first-char-guards via
|
|
98
|
+
* the deep, ref-resolving first-set (monolithic compile) / fuse-time resolution
|
|
99
|
+
* (compose). NOT a cliff; never warned.
|
|
100
|
+
* `no` — genuinely ungated: a broad/any arm or a finite overlap forces ordered
|
|
101
|
+
* speculative entry with no per-arm first-char skip.
|
|
102
|
+
*/
|
|
103
|
+
gates: 'yes' | 'recoverable' | 'no'
|
|
104
|
+
/** True when this ungated choice's `id` is in the accepted-snapshot allowlist. */
|
|
105
|
+
accepted: boolean
|
|
106
|
+
/**
|
|
107
|
+
* `gates: 'no'` was decided by cross-artifact HOLES ONLY — every `any` arm is an
|
|
108
|
+
* unresolved NAMED `g.Foo` ref and no two finite arms overlap. The verdict is not
|
|
109
|
+
* this artifact's to make: the shape module can't fix it (the hole has no body
|
|
110
|
+
* here) and the configuration it describes never runs. The answer belongs to the
|
|
111
|
+
* FUSED artifact, where the name is bound — see `analyzeGrammarGating`.
|
|
112
|
+
*
|
|
113
|
+
* Deferred choices are excluded from `ungated`: they neither warn nor fail the
|
|
114
|
+
* `'error'` gate at this site.
|
|
115
|
+
*/
|
|
116
|
+
deferred: boolean
|
|
117
|
+
combinedFirstSet: { shallow: FirstSet; deep: FirstSet }
|
|
118
|
+
anyArms: AnyArm[]
|
|
119
|
+
overlaps: Overlap[]
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The arms a `ChoiceGating` describes, in arm order — or `undefined` when the report did
|
|
124
|
+
* not come from a live walk (a deserialized snapshot). Carried non-enumerably, so it
|
|
125
|
+
* never reaches the JSON a CI snapshot diffs.
|
|
126
|
+
*/
|
|
127
|
+
export const choiceArms = (c: ChoiceGating): readonly Combinator<unknown>[] | undefined =>
|
|
128
|
+
(c as { arms?: readonly Combinator<unknown>[] }).arms
|
|
129
|
+
|
|
130
|
+
/** Options for `analyzeGating` — the accepted-snapshot allowlist. */
|
|
131
|
+
export type AnalyzeGatingOptions = {
|
|
132
|
+
/**
|
|
133
|
+
* Choice `id`s that are accepted as intentionally ungated. An ungated choice
|
|
134
|
+
* whose id is here is moved to `accepted` (silent, does not fail the CI gate); one
|
|
135
|
+
* whose id is NOT here stays in `ungated` (warned + fails the gate). This is the
|
|
136
|
+
* SINGLE per-choice suppression mechanism.
|
|
137
|
+
*/
|
|
138
|
+
accept?: Iterable<string>
|
|
139
|
+
/**
|
|
140
|
+
* Name to attribute an UNNAMED entry to, instead of the synthetic `<entry>`.
|
|
141
|
+
* The macro plugin passes the binding's own variable name, so a warning on a
|
|
142
|
+
* top-level combinator const reads `choice @ directMixinReferenceAhead`
|
|
143
|
+
* (actionable, and a discriminating `accept` key) rather than `choice @
|
|
144
|
+
* <entry>` repeated once per const. Ignored when the entry already carries a
|
|
145
|
+
* `_ruleName`.
|
|
146
|
+
*/
|
|
147
|
+
entryName?: string
|
|
148
|
+
/**
|
|
149
|
+
* Bind NAMED cross-artifact holes (`g.Foo`) by name — supply the FUSED winner map's
|
|
150
|
+
* lookup. With it, an arm led by a shared shape's hole reports the first-set it
|
|
151
|
+
* really has once bound, so `deferred` collapses to a real `yes`/`no` verdict.
|
|
152
|
+
* Without it (the authoring site) such a choice stays `deferred`.
|
|
153
|
+
*/
|
|
154
|
+
resolveRef?: RefResolver
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* A rule the walk could NOT introspect. Its choices were never examined, so no
|
|
159
|
+
* verdict about it — clean or otherwise — is available.
|
|
160
|
+
*
|
|
161
|
+
* This exists because the alternative is silence. A `compose()` result is a map of
|
|
162
|
+
* FUSED rule functions with no `_def` combinator graph; walking one used to throw a
|
|
163
|
+
* bare `TypeError: Cannot read properties of undefined (reading 'tag')`, and the
|
|
164
|
+
* default-on diagnostic swallowed that throw and returned `undefined` — a failed
|
|
165
|
+
* analysis and a clean grammar were indistinguishable. Every unanalysable input is
|
|
166
|
+
* now counted and named here, and `formatGatingWarnings` always reports it.
|
|
167
|
+
*/
|
|
168
|
+
export type Unanalysable = {
|
|
169
|
+
/** The rule name (or seed name) whose walk stopped. */
|
|
170
|
+
rule: string
|
|
171
|
+
/** Why it could not be walked, in terms the caller can act on. */
|
|
172
|
+
reason: string
|
|
173
|
+
kind: 'fused-rule' | 'opaque-artifact' | 'not-a-combinator'
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export type GatingReport = {
|
|
177
|
+
totalChoices: number
|
|
178
|
+
gated: number
|
|
179
|
+
recoverable: number
|
|
180
|
+
/**
|
|
181
|
+
* Rules the walk could not introspect (see `Unanalysable`). NON-EMPTY MEANS THE
|
|
182
|
+
* REPORT IS PARTIAL: `totalChoices === 0` with a non-empty `unanalysable` is a
|
|
183
|
+
* blind walk, not a clean grammar. Callers that treat an empty `ungated` as a pass
|
|
184
|
+
* MUST also assert this is empty.
|
|
185
|
+
*/
|
|
186
|
+
unanalysable: Unanalysable[]
|
|
187
|
+
/** Genuinely-ungated choices NOT in the accepted allowlist — warned + gate-failing. */
|
|
188
|
+
ungated: ChoiceGating[]
|
|
189
|
+
/** Ungated choices whose id was in the accepted allowlist — silent, accepted with intent. */
|
|
190
|
+
accepted: ChoiceGating[]
|
|
191
|
+
/**
|
|
192
|
+
* Choices whose verdict is NOT this artifact's to make — every `any` arm is an
|
|
193
|
+
* unresolved cross-artifact hole (see `ChoiceGating.deferred`). Silent here; the
|
|
194
|
+
* fused artifact re-asks the question with the hole bound.
|
|
195
|
+
*/
|
|
196
|
+
deferred: ChoiceGating[]
|
|
197
|
+
/** Accepted ids that matched no ungated choice — stale snapshot entries to prune. */
|
|
198
|
+
acceptedUnused: string[]
|
|
199
|
+
/** Every choice, for full inspection / CI snapshots. */
|
|
200
|
+
choices: ChoiceGating[]
|
|
201
|
+
antiPatterns: AntiPattern[]
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ── grammar walk ─────────────────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Ordered structural children per def tag. Explicit rather than "every key that
|
|
208
|
+
* holds a Combinator" because a SLOT's position is what near-duplicate detection
|
|
209
|
+
* varies — a stable, meaningful order is load-bearing, not cosmetic.
|
|
210
|
+
*
|
|
211
|
+
* Lives here, in the module every analysis pass already imports, because
|
|
212
|
+
* `./choice-cost.ts` and `./duplication.ts` each carried a byte-identical copy.
|
|
213
|
+
*/
|
|
214
|
+
export function childrenOf(d: ParserDef): readonly Combinator<unknown>[] {
|
|
215
|
+
switch (d.tag) {
|
|
216
|
+
case 'sequence': case 'choice': return d.parsers
|
|
217
|
+
case 'dispatch': return [
|
|
218
|
+
d.selector,
|
|
219
|
+
...d.cases.map(c => c.parser),
|
|
220
|
+
...(d.matchers ? d.matchers.map(c => c.parser) : []),
|
|
221
|
+
...(d.otherwise === undefined ? [] : [d.otherwise]),
|
|
222
|
+
]
|
|
223
|
+
case 'sepBy': return [d.parser, d.separator]
|
|
224
|
+
case 'recover': return [d.parser, d.sentinel]
|
|
225
|
+
case 'scanTo': return [d.sentinel, ...d.skip]
|
|
226
|
+
case 'grammar': return d.triviaParser ? [d.parser, d.triviaParser] : [d.parser]
|
|
227
|
+
case 'routed': return d.fallback ? [d.fallback] : []
|
|
228
|
+
// A `lazy` is a REFERENCE, not a subtree. Descending through it would make
|
|
229
|
+
// every rule's walk cover the whole reachable grammar — site paths become
|
|
230
|
+
// nonsense, and a structural hash includes half the grammar. Treated as a
|
|
231
|
+
// leaf, keyed by the rule name it refers to, which is also the right
|
|
232
|
+
// semantics: two productions referencing `g.Ident` really do fill that slot
|
|
233
|
+
// the same way.
|
|
234
|
+
case 'lazy': case 'literal': case 'regex': case 'keywords': case 'guard': case 'adjacency': case 'unknown':
|
|
235
|
+
return []
|
|
236
|
+
default: {
|
|
237
|
+
const rec = d as unknown as { parser?: Combinator<unknown> }
|
|
238
|
+
return rec.parser ? [rec.parser] : []
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ── first-set helpers (local, to avoid importing codegen and creating a cycle) ──
|
|
244
|
+
|
|
245
|
+
const isAny = (fs: FirstSet): boolean => fs.kind === 'any'
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Do two first-sets share any character?
|
|
249
|
+
*
|
|
250
|
+
* THE DEFINITION LIVES IN `../combinators/first-set.ts`, beside `union` and the
|
|
251
|
+
* rest of the first-set algebra, and is re-exported here only because
|
|
252
|
+
* `./duplication.ts` imports it from this module.
|
|
253
|
+
*
|
|
254
|
+
* This used to be a third copy. Two byte-identical copies in `./choice-cost.ts`
|
|
255
|
+
* and `./duplication.ts` were collapsed into a declaration here, and the note
|
|
256
|
+
* recording that said `intersects` now "lives once" — while
|
|
257
|
+
* `../combinators/first-set.ts` had been exporting its own since before any of
|
|
258
|
+
* them. INV-4 could not see it: the two bodies differ only in whether the nested
|
|
259
|
+
* `for` carries braces, and INV-4 decides on byte-identity after whitespace is
|
|
260
|
+
* stripped. INV-8 sees it, because it decides on the NAME.
|
|
261
|
+
*/
|
|
262
|
+
export { intersects }
|
|
263
|
+
|
|
264
|
+
/** The SHARED first characters of two sets (the actual overlap, not the union). */
|
|
265
|
+
function intersection(a: FirstSet, b: FirstSet): FirstSet {
|
|
266
|
+
if (a.kind === 'any') return b
|
|
267
|
+
if (b.kind === 'any') return a
|
|
268
|
+
if (a.kind === 'empty' || b.kind === 'empty') return { kind: 'empty' }
|
|
269
|
+
const ranges = []
|
|
270
|
+
for (const ra of a.ranges) for (const rb of b.ranges) {
|
|
271
|
+
const lo = Math.max(ra.lo, rb.lo)
|
|
272
|
+
const hi = Math.min(ra.hi, rb.hi)
|
|
273
|
+
if (lo <= hi) ranges.push({ lo, hi })
|
|
274
|
+
}
|
|
275
|
+
return ranges.length === 0 ? { kind: 'empty' } : { kind: 'ranges', ranges }
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function combine(sets: FirstSet[]): FirstSet {
|
|
279
|
+
let acc: FirstSet = { kind: 'empty' }
|
|
280
|
+
for (const s of sets) {
|
|
281
|
+
if (s.kind === 'any') return { kind: 'any' }
|
|
282
|
+
if (s.kind === 'empty') continue
|
|
283
|
+
acc = acc.kind === 'ranges' ? { kind: 'ranges', ranges: [...acc.ranges, ...s.ranges] } : s
|
|
284
|
+
}
|
|
285
|
+
return acc
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const ch = (c: number): string => (c >= 32 && c < 127 ? `'${String.fromCharCode(c)}'` : `\\u${c}`)
|
|
289
|
+
export function firstSetToString(fs: FirstSet): string {
|
|
290
|
+
if (fs.kind === 'any') return 'ANY'
|
|
291
|
+
if (fs.kind === 'empty') return '(empty)'
|
|
292
|
+
return fs.ranges.map(r => (r.lo === r.hi ? ch(r.lo) : `${ch(r.lo)}-${ch(r.hi)}`)).join(',')
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const ruleNameOf = (p: Combinator<unknown>): string | undefined =>
|
|
296
|
+
(p as unknown as { _ruleName?: string })._ruleName
|
|
297
|
+
|
|
298
|
+
// ── cause attribution: walk the leading structure, stop at the first `any` ──
|
|
299
|
+
|
|
300
|
+
/** A regex source that is really a keyword (a literal word, optionally with a
|
|
301
|
+
* trailing boundary lookahead) — the case word()/keywords() should own. */
|
|
302
|
+
const KEYWORD_REGEX_RE = /^\^?[@#.-]?[A-Za-z][\w-]*(\(\?![^)]*\))?\$?$/
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* What each cause MEANS and what to do about it, in words a grammar author can act on
|
|
306
|
+
* without having read parseman's source.
|
|
307
|
+
*
|
|
308
|
+
* These strings are the diagnostic. An earlier version of them was accurate and
|
|
309
|
+
* unreadable — "give the arm a concrete leading terminal", "resolves a g.Foo ref
|
|
310
|
+
* first-set at fuse time" — which is the same as having no diagnostic, because the
|
|
311
|
+
* reader cannot tell what happened or what to change. Every term of art here is either
|
|
312
|
+
* replaced or explained where it is used; nothing is deferred to a glossary.
|
|
313
|
+
*/
|
|
314
|
+
const SUGGESTIONS: Record<FirstSetCause, string> = {
|
|
315
|
+
'broad-recognizer':
|
|
316
|
+
'This arm can begin with any character, so no single-character test can rule it out. '
|
|
317
|
+
+ 'The parser has to enter it — set up, try, and undo — at every position it reaches, '
|
|
318
|
+
+ 'instead of skipping it for free.\n'
|
|
319
|
+
+ 'To fix: make the arm begin with a fixed character, word or keyword. If it is meant '
|
|
320
|
+
+ 'to be a catch-all that matches anything, leave it and add the choice to the accept '
|
|
321
|
+
+ 'list at the end.',
|
|
322
|
+
'leading-not':
|
|
323
|
+
'This arm begins with a not(...) check, which matches nothing and so says nothing about '
|
|
324
|
+
+ 'which character the arm starts with. The parser then cannot rule the arm out and '
|
|
325
|
+
+ 'enters it everywhere.\n'
|
|
326
|
+
+ 'To fix: put the term that actually consumes text first and keep not(...) after it as '
|
|
327
|
+
+ 'a trailing boundary. If you need to REQUIRE something ahead without consuming it, use '
|
|
328
|
+
+ 'peek(X) — it checks the same thing but still tells parseman that the arm starts with X.',
|
|
329
|
+
'nullable-prefix':
|
|
330
|
+
'This arm begins with something optional — an optional(...) or a repeat that allows zero '
|
|
331
|
+
+ 'items — so the arm can also start with whatever comes AFTER it, and that is broad '
|
|
332
|
+
+ 'enough that no character rules the arm out.\n'
|
|
333
|
+
+ 'To fix: give the optional part its own arm so each arm has a definite beginning, or '
|
|
334
|
+
+ 'require at least one item. A plain sepBy(item, sep) also matches nothing at all; pass '
|
|
335
|
+
+ '{ min: 1 } when the list must not be empty.',
|
|
336
|
+
'cross-artifact-ref':
|
|
337
|
+
'This arm hands off to another rule, and that rule has the same problem — it can begin '
|
|
338
|
+
+ 'with any character — so the cost is inherited rather than caused here.\n'
|
|
339
|
+
+ 'To fix: run this check on the rule it refers to and fix it there. One rule given a '
|
|
340
|
+
+ 'definite beginning fixes every choice that uses it.',
|
|
341
|
+
'opaque-wrapper':
|
|
342
|
+
'This arm begins with a wrapper that only inspects parser state (a gate/withCtx/recover) '
|
|
343
|
+
+ 'and consumes no text, so it says nothing about which character the arm starts with.\n'
|
|
344
|
+
+ 'To fix: put a term that actually matches text in front of the wrapper.',
|
|
345
|
+
'ref-cycle':
|
|
346
|
+
'This arm refers back to itself through a cycle of rules, and parseman could not work out '
|
|
347
|
+
+ 'a definite first character for any of them.\n'
|
|
348
|
+
+ 'To fix: make sure the base case of the recursion begins with a fixed character or '
|
|
349
|
+
+ 'keyword, so the cycle has somewhere definite to start.',
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* The generic per-cause advice, sharpened by what the arm ACTUALLY leads with.
|
|
354
|
+
*
|
|
355
|
+
* One string per cause was quietly wrong in the common case. `broad-recognizer` covers
|
|
356
|
+
* both `regex(/@media/)` — which `word()` fixes outright — and `regex(/[^()]+/)`, a
|
|
357
|
+
* genuine catch-all scanner that no primitive can gate and where the keyword advice is
|
|
358
|
+
* noise the reader has to discard. Telling someone "if this is X, do Y" when the tool
|
|
359
|
+
* can already see whether it is X is exactly the gap between advice and a diagnostic.
|
|
360
|
+
*/
|
|
361
|
+
function refineSuggestion(cause: FirstSetCause, arm: Combinator<unknown>): string {
|
|
362
|
+
if (cause !== 'broad-recognizer') return SUGGESTIONS[cause]
|
|
363
|
+
const lead = peelToLeading(arm)
|
|
364
|
+
const d = lead._def as ParserDef
|
|
365
|
+
if (d.tag === 'regex' && KEYWORD_REGEX_RE.test(d.source)) {
|
|
366
|
+
return `This arm matches the fixed word \`${d.source}\` with a regular expression, and parseman `
|
|
367
|
+
+ 'cannot always tell from a regular expression which character it starts with.\n'
|
|
368
|
+
+ "To fix: write it as word('…') or keywords([…]) instead. They match exactly the same text and "
|
|
369
|
+
+ 'compile to the same character scan, but they also tell parseman the first character, which is '
|
|
370
|
+
+ 'what lets the parser skip this arm when it cannot match.'
|
|
371
|
+
}
|
|
372
|
+
if (d.tag === 'scanTo') {
|
|
373
|
+
return 'This arm ends in a scanTo(...) catch-all, which reads forward until it finds something and '
|
|
374
|
+
+ 'so can begin at any character. The parser can never rule it out. That is usually exactly what '
|
|
375
|
+
+ 'you want from a fallback.\n'
|
|
376
|
+
+ 'To fix: usually nothing. Add this choice to the accept list at the end so the check keeps '
|
|
377
|
+
+ 'flagging the choices that are real problems.'
|
|
378
|
+
}
|
|
379
|
+
return SUGGESTIONS[cause]
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** The classifier's verdict for one arm: the poison, and whether it is a hole that
|
|
383
|
+
* only a downstream fuse can fill (`unresolvedExternal`). */
|
|
384
|
+
type ArmCause = { cause: FirstSetCause; detail: string; unresolvedExternal?: boolean }
|
|
385
|
+
|
|
386
|
+
function classifyBroadArm(arm: Combinator<unknown>, resolve?: RefResolver): ArmCause {
|
|
387
|
+
const seen = new Set<Combinator<unknown>>()
|
|
388
|
+
/** Re-label an inner verdict, carrying its `unresolvedExternal` through unchanged —
|
|
389
|
+
* the cause changes, but who can FIX it does not. */
|
|
390
|
+
const relabel = (inner: ArmCause | null, cause: FirstSetCause, detail: string): ArmCause =>
|
|
391
|
+
({ cause, detail, ...(inner?.unresolvedExternal ? { unresolvedExternal: true } : {}) })
|
|
392
|
+
const walk = (p: Combinator<unknown>): ArmCause | null => {
|
|
393
|
+
if (seen.has(p)) return { cause: 'ref-cycle', detail: 'ref cycle' }
|
|
394
|
+
seen.add(p)
|
|
395
|
+
const d = p._def as ParserDef
|
|
396
|
+
switch (d.tag) {
|
|
397
|
+
case 'literal': case 'regex': case 'keywords':
|
|
398
|
+
return isAny(p._meta.firstSet) ? { cause: 'broad-recognizer', detail: `broad recognizer (${d.tag})` } : null
|
|
399
|
+
case 'not':
|
|
400
|
+
return { cause: 'leading-not', detail: 'leading not(...) (first-set ANY)' }
|
|
401
|
+
// peek() CARRIES its body's first-set, so it is only broad when the body is
|
|
402
|
+
// (or the body is nullable) — walk in for the real cause.
|
|
403
|
+
case 'peek':
|
|
404
|
+
return walk(d.parser)
|
|
405
|
+
case 'scanTo':
|
|
406
|
+
return { cause: 'broad-recognizer', detail: 'scanTo (any first char)' }
|
|
407
|
+
// `guard` (the `gate()` state predicate) has a genuinely-`any` first-set of
|
|
408
|
+
// its own — it is the poison. `withCtx`/`recover` FORWARD their inner
|
|
409
|
+
// first-set (see firstSetOf), so a broad result comes from the inner parser:
|
|
410
|
+
// walk into it for a precise cause.
|
|
411
|
+
case 'guard':
|
|
412
|
+
return { cause: 'opaque-wrapper', detail: `opaque wrapper (${d.tag})` }
|
|
413
|
+
// Zero-width and dropped from a sequence's first-set (isZeroWidthAssertion),
|
|
414
|
+
// so it is never the reason an arm is broad — but it can be reached as the
|
|
415
|
+
// sole term of a degenerate arm. Report it precisely rather than as a wrapper.
|
|
416
|
+
case 'adjacency':
|
|
417
|
+
return { cause: 'broad-recognizer', detail: `${d.polarity}() (zero-width assertion)` }
|
|
418
|
+
// attempt/withCtx/recover FORWARD their inner first-set (transparent for
|
|
419
|
+
// dispatch), so a broad result comes from the inner parser — walk into it.
|
|
420
|
+
case 'attempt': case 'withCtx': case 'recover':
|
|
421
|
+
return walk(d.parser)
|
|
422
|
+
case 'lazy': {
|
|
423
|
+
const name = (p as unknown as { _ruleName?: string })._ruleName
|
|
424
|
+
let target: Combinator<unknown>
|
|
425
|
+
try { target = (d as { thunk(): Combinator<unknown> }).thunk() }
|
|
426
|
+
catch {
|
|
427
|
+
// A NAMED ref the fuse resolver can bind is analyzed against its real body;
|
|
428
|
+
// otherwise the hole itself is the poison — and it is only the AUTHOR's
|
|
429
|
+
// problem when it is unnamed (nothing can ever bind it by name).
|
|
430
|
+
const bound = name !== undefined ? resolve?.(name) : undefined
|
|
431
|
+
if (bound === undefined)
|
|
432
|
+
return { cause: 'cross-artifact-ref', detail: `unresolved ref${name ? ` g.${name}` : ''}`, unresolvedExternal: name !== undefined }
|
|
433
|
+
target = bound
|
|
434
|
+
}
|
|
435
|
+
const inner = walk(target)
|
|
436
|
+
if (name !== undefined) return inner ? relabel(inner, 'cross-artifact-ref', `via ref g.${name} → ${inner.detail}`) : null
|
|
437
|
+
return inner
|
|
438
|
+
}
|
|
439
|
+
case 'optional': case 'many': {
|
|
440
|
+
const inner = walk(d.parser)
|
|
441
|
+
return relabel(inner, 'nullable-prefix', inner ? `nullable prefix → ${inner.detail}` : 'nullable prefix to broad term')
|
|
442
|
+
}
|
|
443
|
+
case 'oneOrMore': case 'transform': case 'label': case 'field':
|
|
444
|
+
case 'trivia': case 'token': case 'leaf': case 'node': case 'grammar': case 'expect':
|
|
445
|
+
return walk(d.parser)
|
|
446
|
+
case 'sequence': {
|
|
447
|
+
// Scan the nullable prefix the way sequenceFirstSet does: a `not(...)` or a
|
|
448
|
+
// FINITE nullable term (optional/many/nullable regex) is skipped so a LATER
|
|
449
|
+
// term's first chars can start the sequence. Stop at the first term that is
|
|
450
|
+
// non-nullable (it gates → return null) or broad (it's the poison).
|
|
451
|
+
let sawNullablePrefix = false
|
|
452
|
+
for (const t of d.parsers) {
|
|
453
|
+
if ((t._def as ParserDef).tag === 'not') { sawNullablePrefix = true; continue }
|
|
454
|
+
if (isAny(firstSetOf(t, new Set(), resolve))) {
|
|
455
|
+
const inner = walk(t)
|
|
456
|
+
if (sawNullablePrefix)
|
|
457
|
+
return relabel(inner, 'nullable-prefix', inner ? `nullable prefix → ${inner.detail}` : 'nullable prefix to broad term')
|
|
458
|
+
return inner ?? { cause: 'broad-recognizer', detail: 'sequence leading term is broad' }
|
|
459
|
+
}
|
|
460
|
+
if (matchesEmpty(t, new Set(), resolve)) { sawNullablePrefix = true; continue } // finite but nullable → keep scanning
|
|
461
|
+
return null // finite, non-nullable → this term gates the sequence
|
|
462
|
+
}
|
|
463
|
+
return { cause: 'broad-recognizer', detail: 'sequence of only nullable/zero-width terms' }
|
|
464
|
+
}
|
|
465
|
+
case 'choice': {
|
|
466
|
+
for (const a of d.parsers) { const r = walk(a); if (r) return relabel(r, r.cause, `choice arm → ${r.detail}`) }
|
|
467
|
+
return null
|
|
468
|
+
}
|
|
469
|
+
case 'dispatch':
|
|
470
|
+
return walk(d.selector)
|
|
471
|
+
default:
|
|
472
|
+
return { cause: 'broad-recognizer', detail: `unmodeled construct (${d.tag})` }
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return walk(arm) ?? { cause: 'broad-recognizer', detail: 'broad (cause not localized)' }
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// ── anti-pattern detection ──
|
|
479
|
+
|
|
480
|
+
/** Peel non-consuming wrappers to the arm's leading term (mirrors leadingTermOfArm). */
|
|
481
|
+
export function peelToLeading(arm: Combinator<unknown>): Combinator<unknown> {
|
|
482
|
+
let d = arm._def as ParserDef
|
|
483
|
+
let cur = arm
|
|
484
|
+
for (;;) {
|
|
485
|
+
if (d.tag === 'node' || d.tag === 'grammar' || d.tag === 'transform' || d.tag === 'label' || d.tag === 'attempt') {
|
|
486
|
+
cur = (d as { parser: Combinator<unknown> }).parser
|
|
487
|
+
d = cur._def as ParserDef
|
|
488
|
+
continue
|
|
489
|
+
}
|
|
490
|
+
break
|
|
491
|
+
}
|
|
492
|
+
if (d.tag === 'sequence' && d.parsers.length >= 1) return d.parsers[0]!
|
|
493
|
+
return cur
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function detectAntiPatterns(rule: string, arms: readonly Combinator<unknown>[]): AntiPattern[] {
|
|
497
|
+
const out: AntiPattern[] = []
|
|
498
|
+
arms.forEach((arm, i) => {
|
|
499
|
+
const lead = peelToLeading(arm)
|
|
500
|
+
const ld = lead._def as ParserDef
|
|
501
|
+
// (a) not(not(...)) — hand-rolled first-char gating that MISCOMPILES among
|
|
502
|
+
// sibling arms sharing a first char.
|
|
503
|
+
if (ld.tag === 'not') {
|
|
504
|
+
const inner = (ld as { parser: Combinator<unknown> }).parser
|
|
505
|
+
if ((inner._def as ParserDef).tag === 'not') {
|
|
506
|
+
out.push({ kind: 'double-not', rule, armIndex: i,
|
|
507
|
+
message: 'not(not(X)) is a hand-written way of saying "X must come next, but do not consume it". '
|
|
508
|
+
+ 'parseman does that for you, and the hand-written form is worse than nothing here: because '
|
|
509
|
+
+ 'not(...) reveals no first character, the arm cannot be skipped, and among sibling arms that '
|
|
510
|
+
+ 'share a first character it can select the wrong one.\n'
|
|
511
|
+
+ 'To fix: write peek(X). It checks the same thing without consuming, and it also tells '
|
|
512
|
+
+ 'parseman that the arm starts with whatever X starts with.' })
|
|
513
|
+
} else {
|
|
514
|
+
out.push({ kind: 'leading-not', rule, armIndex: i,
|
|
515
|
+
message: 'This arm begins with not(...), which matches no text and so reveals no first '
|
|
516
|
+
+ 'character. The parser therefore cannot skip the arm and enters it at every position.\n'
|
|
517
|
+
+ 'To fix: put the term that actually consumes text first, and keep not(...) after it as a '
|
|
518
|
+
+ 'trailing boundary check.' })
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
// (b) bare leading regex(/keyword/) — word()/keywords() would give an exact,
|
|
522
|
+
// resolvable first-set (and lower identically).
|
|
523
|
+
if (ld.tag === 'regex' && KEYWORD_REGEX_RE.test(ld.source)) {
|
|
524
|
+
out.push({ kind: 'keyword-regex', rule, armIndex: i,
|
|
525
|
+
message: `This arm starts with the fixed word \`${ld.source}\` written as a regular expression. `
|
|
526
|
+
+ "Writing it as word('…') or keywords([…]) matches exactly the same text and compiles to the "
|
|
527
|
+
+ 'same character scan, but also tells parseman the first character, which is what lets the '
|
|
528
|
+
+ 'parser skip the arm when it cannot match.' })
|
|
529
|
+
}
|
|
530
|
+
})
|
|
531
|
+
return out
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// ── the walk ──
|
|
535
|
+
|
|
536
|
+
export function analyzeGating(entry: Combinator<unknown>, opts?: AnalyzeGatingOptions): GatingReport {
|
|
537
|
+
return analyzeGatingRules([[ruleNameOf(entry) ?? opts?.entryName ?? '<entry>', entry]], opts)
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Multi-root variant: analyze a WHOLE `rules()` map in one walk, so every choice
|
|
542
|
+
* is attributed to the rule that owns it.
|
|
543
|
+
*
|
|
544
|
+
* The macro build compiles a grammar through `compileRuleMap`/`compileLinkable`,
|
|
545
|
+
* never through the single-entry `compile()`. Analyzing one entry at a time (or
|
|
546
|
+
* not at all) is what produced unnamed `choice @ <entry>` warnings and ZERO
|
|
547
|
+
* anti-patterns for grammars that in fact have dozens: an unnamed warning in a
|
|
548
|
+
* multi-thousand-line grammar is unactionable. Seeding the walk with EVERY named
|
|
549
|
+
* root is what recovers the rule names — and, because the walk is shared, each
|
|
550
|
+
* choice is still analyzed exactly once however many rules reach it.
|
|
551
|
+
*/
|
|
552
|
+
export function analyzeGatingRules(
|
|
553
|
+
ruleMap: ReadonlyArray<readonly [string, Combinator<unknown>]>,
|
|
554
|
+
opts?: AnalyzeGatingOptions,
|
|
555
|
+
): GatingReport {
|
|
556
|
+
const raw: { g: Omit<ChoiceGating, 'id' | 'accepted'>; rule: string; arms: readonly Combinator<unknown>[] }[] = []
|
|
557
|
+
const antiPatterns: AntiPattern[] = []
|
|
558
|
+
const unanalysable: Unanalysable[] = []
|
|
559
|
+
const seenUnanalysable = new Set<string>()
|
|
560
|
+
const visited = new Set<Combinator<unknown>>()
|
|
561
|
+
|
|
562
|
+
const noteUnanalysable = (u: Unanalysable): void => {
|
|
563
|
+
const key = `${u.rule}\u0000${u.kind}`
|
|
564
|
+
if (seenUnanalysable.has(key)) return
|
|
565
|
+
seenUnanalysable.add(key)
|
|
566
|
+
unanalysable.push(u)
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const visit = (p: Combinator<unknown>, enclosingRule: string): void => {
|
|
570
|
+
if (visited.has(p)) return
|
|
571
|
+
visited.add(p)
|
|
572
|
+
// A grammar VALUE that carries no combinator descriptor cannot be walked. The
|
|
573
|
+
// dominant case is a `compose()` result: fusion lowers every rule to an executable
|
|
574
|
+
// function, discarding the combinator graph. Record it — never throw, and never
|
|
575
|
+
// let the caller mistake the resulting empty walk for a clean grammar.
|
|
576
|
+
const def = (p as { _def?: unknown } | null | undefined)?._def
|
|
577
|
+
if (def === null || typeof def !== 'object') {
|
|
578
|
+
noteUnanalysable(
|
|
579
|
+
typeof p === 'function'
|
|
580
|
+
? {
|
|
581
|
+
rule: enclosingRule, kind: 'fused-rule',
|
|
582
|
+
reason: 'fused rule function — a compose()/fuse result has no combinator graph. '
|
|
583
|
+
+ 'Analyze the composed grammar itself (analyzeGrammarGating) so the carried IR is re-lowered first.',
|
|
584
|
+
}
|
|
585
|
+
: {
|
|
586
|
+
rule: enclosingRule, kind: 'not-a-combinator',
|
|
587
|
+
reason: `value of type ${p === null ? 'null' : typeof p} is not a combinator and carries no _def descriptor.`,
|
|
588
|
+
},
|
|
589
|
+
)
|
|
590
|
+
return
|
|
591
|
+
}
|
|
592
|
+
const d = def as ParserDef
|
|
593
|
+
const rule = ruleNameOf(p) ?? enclosingRule
|
|
594
|
+
if (d.tag === 'choice') {
|
|
595
|
+
raw.push({ g: analyzeChoice(p, d, rule, opts?.resolveRef), rule, arms: d.parsers })
|
|
596
|
+
antiPatterns.push(...detectAntiPatterns(rule, d.parsers))
|
|
597
|
+
}
|
|
598
|
+
// Structural recursion (+ through refs once). Keep the child inventory in
|
|
599
|
+
// one place: choice-cost, duplication and dependency analysis use the same
|
|
600
|
+
// authored edge order, including matcher arms, grammar trivia, and routed
|
|
601
|
+
// fallbacks.
|
|
602
|
+
const kids = [...childrenOf(d)]
|
|
603
|
+
// Deliberately NOT `resolveRef`-aware: the WALK must visit the same choices with
|
|
604
|
+
// and without a resolver, so a choice's `id` (per-rule occurrence order) is the
|
|
605
|
+
// same in both passes — that identity is what lets the fuse-time diagnostic report
|
|
606
|
+
// exactly the choices its authoring site deferred. Nothing is lost: every rule of
|
|
607
|
+
// the fused map is its own seed below, so a rule reached only through a hole is
|
|
608
|
+
// still analyzed, under its own name.
|
|
609
|
+
if (d.tag === 'lazy') { try { kids.push((d as { thunk(): Combinator<unknown> }).thunk()) } catch { /* unresolved */ } }
|
|
610
|
+
for (const k of kids) visit(k, rule)
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// Seed with the map's OWN names first: a rule root is usually a `ref()` that
|
|
614
|
+
// already carries `_ruleName`, but a plain (untagged) root would otherwise
|
|
615
|
+
// inherit whichever rule reached it first.
|
|
616
|
+
for (const [name, root] of ruleMap) visit(root, ruleNameOf(root) ?? name)
|
|
617
|
+
|
|
618
|
+
// Assign a stable per-choice id: bare rule name when unique in the rule, else
|
|
619
|
+
// `rule#N` (occurrence order). This is the key the accepted allowlist uses.
|
|
620
|
+
const perRule = new Map<string, number>()
|
|
621
|
+
for (const r of raw) perRule.set(r.rule, (perRule.get(r.rule) ?? 0) + 1)
|
|
622
|
+
const seenInRule = new Map<string, number>()
|
|
623
|
+
const accept = new Set(opts?.accept ?? [])
|
|
624
|
+
const usedAccept = new Set<string>()
|
|
625
|
+
const choices: ChoiceGating[] = raw.map(({ g, rule, arms }) => {
|
|
626
|
+
const n = seenInRule.get(rule) ?? 0
|
|
627
|
+
seenInRule.set(rule, n + 1)
|
|
628
|
+
const id = (perRule.get(rule) ?? 1) === 1 ? rule : `${rule}#${n}`
|
|
629
|
+
const isAccepted = g.gates === 'no' && accept.has(id)
|
|
630
|
+
if (isAccepted) usedAccept.add(id)
|
|
631
|
+
const out: ChoiceGating = { ...g, id, accepted: isAccepted }
|
|
632
|
+
// The arms themselves, carried for a RENDERER that wants to show the ordering with a
|
|
633
|
+
// dispatch key beside each arm. NON-ENUMERABLE deliberately: `GatingReport` is
|
|
634
|
+
// JSON-serialized into a committed, byte-identity-tested snapshot, and combinators are
|
|
635
|
+
// cyclic graphs full of closures. Attaching them here rather than letting a caller
|
|
636
|
+
// re-walk the grammar is what keeps a second walk from drifting out of step with this
|
|
637
|
+
// one's `id` assignment — which it did, silently, and mislabelled every arm.
|
|
638
|
+
Object.defineProperty(out, 'arms', { value: arms, enumerable: false, writable: false })
|
|
639
|
+
return out
|
|
640
|
+
})
|
|
641
|
+
|
|
642
|
+
const gated = choices.filter(c => c.gates === 'yes').length
|
|
643
|
+
const recoverable = choices.filter(c => c.gates === 'recoverable').length
|
|
644
|
+
const ungated = choices.filter(c => c.gates === 'no' && !c.accepted && !c.deferred)
|
|
645
|
+
const accepted = choices.filter(c => c.gates === 'no' && c.accepted)
|
|
646
|
+
const deferred = choices.filter(c => c.gates === 'no' && !c.accepted && c.deferred)
|
|
647
|
+
const acceptedUnused = [...accept].filter(id => !usedAccept.has(id))
|
|
648
|
+
return {
|
|
649
|
+
totalChoices: choices.length, gated, recoverable, unanalysable,
|
|
650
|
+
ungated, accepted, deferred, acceptedUnused, choices, antiPatterns,
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// analyzeChoice returns a ChoiceGating WITHOUT id/accepted — analyzeGating assigns
|
|
655
|
+
// those after the full walk (id needs per-rule counts; accepted needs the allowlist).
|
|
656
|
+
function analyzeChoice(
|
|
657
|
+
p: Combinator<unknown>,
|
|
658
|
+
d: Extract<ParserDef, { tag: 'choice' }>,
|
|
659
|
+
rule: string,
|
|
660
|
+
resolve?: RefResolver,
|
|
661
|
+
): Omit<ChoiceGating, 'id' | 'accepted'> {
|
|
662
|
+
const arms = d.parsers
|
|
663
|
+
const shallow = arms.map(a => a._meta.firstSet)
|
|
664
|
+
const deep = arms.map(a => firstSetOf(a, new Set(), resolve))
|
|
665
|
+
|
|
666
|
+
const anyArms: AnyArm[] = deep
|
|
667
|
+
.map((fs, index) => ({ fs, index }))
|
|
668
|
+
.filter(x => isAny(x.fs))
|
|
669
|
+
.map(({ index }) => {
|
|
670
|
+
const { cause, detail, unresolvedExternal } = classifyBroadArm(arms[index]!, resolve)
|
|
671
|
+
return {
|
|
672
|
+
index, cause, detail, shallowAnyOnly: false,
|
|
673
|
+
unresolvedExternal: unresolvedExternal === true,
|
|
674
|
+
suggestion: refineSuggestion(cause, arms[index]!),
|
|
675
|
+
}
|
|
676
|
+
})
|
|
677
|
+
|
|
678
|
+
const overlaps: Overlap[] = []
|
|
679
|
+
for (let i = 0; i < deep.length; i++)
|
|
680
|
+
for (let j = i + 1; j < deep.length; j++)
|
|
681
|
+
if (!isAny(deep[i]!) && !isAny(deep[j]!) && intersects(deep[i]!, deep[j]!))
|
|
682
|
+
overlaps.push({ a: i, b: j, on: intersection(deep[i]!, deep[j]!),
|
|
683
|
+
suggestion: 'Two arms of this choice can begin with the same character, so the parser cannot '
|
|
684
|
+
+ 'tell from that character which one to try. It tries them in order and undoes the ones that '
|
|
685
|
+
+ 'do not match.\n'
|
|
686
|
+
+ 'To fix: pull the shared beginning out in front of the choice so it is matched once — '
|
|
687
|
+
+ 'sequence(shared, choice(rest…)) — instead of repeating it inside each arm. parseman '
|
|
688
|
+
+ 'recognises that shape automatically and turns it back into a single test.' })
|
|
689
|
+
|
|
690
|
+
// Classification. `disjoint` (from construction, shallow) ⇒ O(1) dispatch.
|
|
691
|
+
// Otherwise, if the DEEP (ref-resolved) arms are all-finite and pairwise
|
|
692
|
+
// disjoint, monolithic compile still first-char-guards each arm (codegen emits
|
|
693
|
+
// firstSetOf() per-arm guards) — 'recoverable', not a cliff. Else 'no'.
|
|
694
|
+
let gates: ChoiceGating['gates']
|
|
695
|
+
if (d.disjoint) gates = 'yes'
|
|
696
|
+
else if (anyArms.length === 0 && overlaps.length === 0) gates = 'recoverable'
|
|
697
|
+
else gates = 'no'
|
|
698
|
+
|
|
699
|
+
// Ungated SOLELY because of holes this artifact can't fill: nothing here is
|
|
700
|
+
// actionable and the configuration described never runs. A finite-arm overlap is a
|
|
701
|
+
// real local finding, so it keeps the choice reportable even alongside a hole.
|
|
702
|
+
const deferred = gates === 'no' && overlaps.length === 0 && anyArms.length > 0 && anyArms.every(a => a.unresolvedExternal)
|
|
703
|
+
|
|
704
|
+
return {
|
|
705
|
+
rule,
|
|
706
|
+
strategy: ((d.strategy as { tag: ChoiceStrategyTag } | undefined)?.tag) ?? 'firstMatch',
|
|
707
|
+
gates,
|
|
708
|
+
deferred,
|
|
709
|
+
combinedFirstSet: { shallow: combine(shallow), deep: combine(deep) },
|
|
710
|
+
anyArms, overlaps,
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// ── warning formatting ──
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* Format the genuinely-ungated findings + anti-patterns as ready-to-print lines.
|
|
718
|
+
* Precise by design: only 'no'-gated choices NOT in the accepted allowlist, plus
|
|
719
|
+
* the anti-pattern lints. Recoverable / gated / accepted / DEFERRED choices produce
|
|
720
|
+
* nothing (a deferred choice's verdict belongs to the fusing artifact, not here).
|
|
721
|
+
*/
|
|
722
|
+
export function formatGatingWarnings(report: GatingReport): string[] {
|
|
723
|
+
const lines: string[] = []
|
|
724
|
+
// FIRST, and unconditionally: a partial walk must never present as a clean one.
|
|
725
|
+
// These lines are emitted even when there are no findings at all, because "no
|
|
726
|
+
// findings" over an unanalysable grammar is precisely the failure this reports.
|
|
727
|
+
if (report.unanalysable.length > 0) {
|
|
728
|
+
lines.push(
|
|
729
|
+
`parseman gating: ${report.unanalysable.length} rule(s) UNANALYSABLE — this report is PARTIAL `
|
|
730
|
+
+ `(${report.totalChoices} choice(s) examined). An empty finding list below does NOT mean the grammar is clean.`,
|
|
731
|
+
)
|
|
732
|
+
for (const u of report.unanalysable)
|
|
733
|
+
lines.push(` · ${u.rule} [${u.kind}]: ${u.reason}`)
|
|
734
|
+
}
|
|
735
|
+
for (const c of report.ungated) {
|
|
736
|
+
lines.push(`parseman gating: choice @ ${c.id} is UNGATED [${c.strategy}] — no first-char dispatch; every position speculatively enters doomed arms.`)
|
|
737
|
+
for (const a of c.anyArms)
|
|
738
|
+
lines.push(` · arm[${a.index}] first-set ANY (${a.cause}): ${a.detail}\n fix: ${a.suggestion}`)
|
|
739
|
+
for (const o of c.overlaps)
|
|
740
|
+
lines.push(` · arm[${o.a}] ∩ arm[${o.b}] overlap on ${firstSetToString(o.on)}\n fix: ${o.suggestion}`)
|
|
741
|
+
lines.push(` (intentional? accept it in the gating snapshot: { accept: ['${c.id}'] }.)`)
|
|
742
|
+
}
|
|
743
|
+
for (const ap of report.antiPatterns)
|
|
744
|
+
lines.push(`parseman anti-pattern [${ap.kind}] @ ${ap.rule} arm[${ap.armIndex}]: ${ap.message}`)
|
|
745
|
+
return lines
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* Dependency manifest for a rule map: for each rule, the set of OTHER rule names
|
|
750
|
+
* its body references. A referenced rule is a BOUNDARY — record the edge and do
|
|
751
|
+
* NOT descend into it (its own deps are its own entry). Self-references are
|
|
752
|
+
* included, because a recursive rule does depend on itself.
|
|
753
|
+
*
|
|
754
|
+
* Used for a la carte dep-closure selection (`pick`) and the compose-time name
|
|
755
|
+
* closure check. This lives here rather than in a lowering because it is a walk
|
|
756
|
+
* over the COMBINATOR GRAPH and has nothing to do with how that graph is lowered
|
|
757
|
+
* — it outlived the source lowering it was first written inside.
|
|
758
|
+
*/
|
|
759
|
+
export function ruleDependencies(
|
|
760
|
+
ruleMap: ReadonlyArray<readonly [string, Combinator<unknown>]>,
|
|
761
|
+
): Map<string, string[]> {
|
|
762
|
+
const nameOf = new Map<Combinator<unknown>, string>()
|
|
763
|
+
for (const [name, comb] of ruleMap) nameOf.set(comb, name)
|
|
764
|
+
|
|
765
|
+
const deps = new Map<string, string[]>()
|
|
766
|
+
for (const [name, comb] of ruleMap) {
|
|
767
|
+
const found = new Set<string>()
|
|
768
|
+
const seen = new Set<Combinator<unknown>>()
|
|
769
|
+
const walk = (p: Combinator<unknown>, isRoot: boolean): void => {
|
|
770
|
+
const def = p._def
|
|
771
|
+
// `_ruleName` (set by `rules()`) also catches EXTERNAL refs — rules referenced
|
|
772
|
+
// by name but defined in another artifact — which is what makes the closure
|
|
773
|
+
// correct across a composition boundary.
|
|
774
|
+
const boundary = def.tag === 'lazy'
|
|
775
|
+
? (nameOf.get(p) ?? (p as unknown as { _ruleName?: string })._ruleName)
|
|
776
|
+
: nameOf.get(p)
|
|
777
|
+
if (!isRoot && boundary !== undefined) { found.add(boundary); return }
|
|
778
|
+
if (seen.has(p)) return
|
|
779
|
+
seen.add(p)
|
|
780
|
+
if (def.tag === 'lazy') {
|
|
781
|
+
let resolved: Combinator<unknown>
|
|
782
|
+
try { resolved = def.thunk() } catch { return }
|
|
783
|
+
walk(resolved, false)
|
|
784
|
+
return
|
|
785
|
+
}
|
|
786
|
+
for (const child of childrenOf(def)) walk(child, false)
|
|
787
|
+
}
|
|
788
|
+
walk(comb, true)
|
|
789
|
+
deps.set(name, [...found])
|
|
790
|
+
}
|
|
791
|
+
return deps
|
|
792
|
+
}
|