parseman 0.36.0 → 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +34 -19
  2. package/dist/analysis/duplication.d.ts +421 -0
  3. package/dist/analysis/duplication.d.ts.map +1 -0
  4. package/dist/analysis/gating.d.ts +25 -0
  5. package/dist/analysis/gating.d.ts.map +1 -1
  6. package/dist/analysis/grammar.d.ts +12 -0
  7. package/dist/analysis/grammar.d.ts.map +1 -0
  8. package/dist/combinators/keywords.d.ts +9 -8
  9. package/dist/combinators/keywords.d.ts.map +1 -1
  10. package/dist/combinators/parser.d.ts +26 -0
  11. package/dist/combinators/parser.d.ts.map +1 -1
  12. package/dist/compiler/build-arity.d.ts +12 -0
  13. package/dist/compiler/build-arity.d.ts.map +1 -1
  14. package/dist/compiler/codegen.d.ts +43 -1
  15. package/dist/compiler/codegen.d.ts.map +1 -1
  16. package/dist/compiler/linker.d.ts +64 -7
  17. package/dist/compiler/linker.d.ts.map +1 -1
  18. package/dist/cst/host-mode.d.ts +43 -0
  19. package/dist/cst/host-mode.d.ts.map +1 -0
  20. package/dist/functional/doc.d.ts.map +1 -1
  21. package/dist/functional/run.d.ts.map +1 -1
  22. package/dist/index.cjs +1657 -64
  23. package/dist/index.cjs.map +4 -4
  24. package/dist/index.d.ts +7 -3
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +1657 -64
  27. package/dist/index.js.map +4 -4
  28. package/dist/language-service/index.cjs +37 -1
  29. package/dist/language-service/index.cjs.map +3 -3
  30. package/dist/language-service/index.js +37 -1
  31. package/dist/language-service/index.js.map +3 -3
  32. package/dist/oracle/corpus.d.ts +32 -0
  33. package/dist/oracle/corpus.d.ts.map +1 -0
  34. package/dist/oracle/digest.d.ts +21 -0
  35. package/dist/oracle/digest.d.ts.map +1 -0
  36. package/dist/oracle/identity.d.ts +117 -0
  37. package/dist/oracle/identity.d.ts.map +1 -0
  38. package/dist/oracle/index.cjs +423 -0
  39. package/dist/oracle/index.cjs.map +7 -0
  40. package/dist/oracle/index.d.ts +37 -0
  41. package/dist/oracle/index.d.ts.map +1 -0
  42. package/dist/oracle/index.js +400 -0
  43. package/dist/oracle/index.js.map +7 -0
  44. package/dist/plugin/evaluator.d.ts +2 -1
  45. package/dist/plugin/evaluator.d.ts.map +1 -1
  46. package/dist/plugin/index.cjs +1696 -85
  47. package/dist/plugin/index.cjs.map +4 -4
  48. package/dist/plugin/index.d.ts.map +1 -1
  49. package/dist/plugin/index.js +1696 -85
  50. package/dist/plugin/index.js.map +4 -4
  51. package/dist/run/index.cjs +23 -0
  52. package/dist/run/index.cjs.map +3 -3
  53. package/dist/run/index.js +23 -0
  54. package/dist/run/index.js.map +3 -3
  55. package/dist/spec/index.cjs +3001 -60
  56. package/dist/spec/index.cjs.map +4 -4
  57. package/dist/spec/index.js +3001 -60
  58. package/dist/spec/index.js.map +4 -4
  59. package/dist/spec/model.d.ts.map +1 -1
  60. package/dist/types.d.ts +7 -0
  61. package/dist/types.d.ts.map +1 -1
  62. package/dist/version.d.ts +1 -1
  63. package/package.json +10 -3
package/dist/index.js CHANGED
@@ -919,8 +919,10 @@ function word(str, boundaryOrOpts, opts) {
919
919
  const rest = typeof boundaryOrOpts === "object" && boundaryOrOpts !== null ? boundaryOrOpts : opts;
920
920
  return keywords([str], { ...rest, boundary });
921
921
  }
922
- function makeWord(boundary = "_0-9A-Za-z") {
923
- return (str) => keywords([str], { boundary });
922
+ function makeWord(boundaryOrOpts = "_0-9A-Za-z", opts) {
923
+ const boundary = typeof boundaryOrOpts === "string" ? boundaryOrOpts : "_0-9A-Za-z";
924
+ const rest = typeof boundaryOrOpts === "object" && boundaryOrOpts !== null ? boundaryOrOpts : opts;
925
+ return (str) => keywords([str], { ...rest, boundary });
924
926
  }
925
927
  function escapeRe(s) {
926
928
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -2357,6 +2359,16 @@ function tagRule(r, key) {
2357
2359
  r._ruleName = key;
2358
2360
  if (r._def.tag === "node" && r._def.type === void 0) r._def.type = key;
2359
2361
  }
2362
+ function isDefinableRef(v2) {
2363
+ return !!v2 && typeof v2 === "object" && "_def" in v2 && v2._def.tag === "lazy" && typeof v2.define === "function";
2364
+ }
2365
+ function ruleNameOf(r) {
2366
+ return r._ruleName;
2367
+ }
2368
+ function isNamedRuleRefForAnotherRule(r, key) {
2369
+ const name = r._def.tag === "lazy" ? ruleNameOf(r) : void 0;
2370
+ return name !== void 0 && name !== key;
2371
+ }
2360
2372
  function rules(a, b) {
2361
2373
  const factory = typeof a === "function" ? a : b;
2362
2374
  const options = typeof a === "function" ? b : a;
@@ -2377,11 +2389,21 @@ function rules(a, b) {
2377
2389
  for (const key of Object.keys(definitions)) {
2378
2390
  const placeholder = cache[key];
2379
2391
  const parser2 = definitions[key];
2380
- if (placeholder !== void 0 && typeof placeholder.define === "function") {
2381
- tagRule(parser2, key);
2392
+ if (placeholder === parser2) {
2393
+ throw new Error(`rules(): rule "${key}" cannot be a direct alias to itself`);
2394
+ }
2395
+ if (isDefinableRef(placeholder)) {
2396
+ if (!isNamedRuleRefForAnotherRule(parser2, key)) tagRule(parser2, key);
2382
2397
  placeholder.define(parser2);
2383
2398
  placeholder._meta.firstSet = parser2._meta.firstSet;
2384
2399
  placeholder._meta.canMatchNewline = parser2._meta.canMatchNewline;
2400
+ } else if (isNamedRuleRefForAnotherRule(parser2, key)) {
2401
+ const alias = ref();
2402
+ tagRule(alias, key);
2403
+ alias.define(parser2);
2404
+ alias._meta.firstSet = parser2._meta.firstSet;
2405
+ alias._meta.canMatchNewline = parser2._meta.canMatchNewline;
2406
+ cache[key] = alias;
2385
2407
  } else {
2386
2408
  tagRule(parser2, key);
2387
2409
  cache[key] = parser2;
@@ -2399,6 +2421,12 @@ function rules(a, b) {
2399
2421
  if (rule && !rule._meta.isTrivia) rule._meta.grammarScanSkip = options.scanSkip;
2400
2422
  }
2401
2423
  }
2424
+ if (options?.hostMode === "cst") {
2425
+ for (const key of Object.keys(definitions)) {
2426
+ const rule = cache[key];
2427
+ if (rule && !rule._meta.isTrivia) rule._meta.grammarHostMode = "cst";
2428
+ }
2429
+ }
2402
2430
  for (const key of Object.keys(definitions)) {
2403
2431
  markUnusedValues(cache[key]);
2404
2432
  }
@@ -2457,6 +2485,9 @@ function peek(combinator) {
2457
2485
  }
2458
2486
 
2459
2487
  // src/compiler/build-arity.ts
2488
+ function cstOutputHost(build) {
2489
+ return build?._parsemanCstOutput === true;
2490
+ }
2460
2491
  var PARAM_LIST_RE = (
2461
2492
  // (params) => ... | function name?(params) ... | single-ident arrow `x => ...`
2462
2493
  /^(?:function\b[^(]*\(([^)]*)\)|\(([^)]*)\)\s*=>|([A-Za-z_$][\w$]*)\s*=>)/
@@ -2706,7 +2737,8 @@ function node(typeOrCombinator, combinatorOrBuild, buildOrOpts, maybeOpts) {
2706
2737
  const def = unwrap || collapse || captureTrivia || trailingTrivia ? { ...baseDef, ...unwrap ? { unwrap: true } : {}, ...collapse ? { collapse: true } : {}, ...captureTrivia ? { captureTrivia: true } : {}, ...trailingTrivia ? { trailingTrivia: true } : {} } : baseDef;
2707
2738
  const capturesTrivia = captureTrivia || trailingTrivia || (build ? buildReadsTrivia(def) : true);
2708
2739
  const clonesState = build ? buildReadsState(def) : true;
2709
- const capturesFields = parserHasOwnFields(combinator) && (build ? buildReadsFields(def) : true);
2740
+ const hasOwnFields = parserHasOwnFields(combinator);
2741
+ const capturesFields = hasOwnFields && (build ? buildReadsFields(def) : true);
2710
2742
  const guardable = combinator._meta.firstSet.kind !== "any" && !matchesEmpty(combinator);
2711
2743
  let failExpected;
2712
2744
  return {
@@ -2721,28 +2753,32 @@ function node(typeOrCombinator, combinatorOrBuild, buildOrOpts, maybeOpts) {
2721
2753
  }
2722
2754
  return { ok: false, expected: failExpected, span: { start: pos, end: pos } };
2723
2755
  }
2756
+ const hostCst = build !== void 0 && cstOutputHost(ctx.build);
2757
+ const effTrivia = capturesTrivia || hostCst;
2758
+ const effFields = capturesFields || hostCst && hasOwnFields;
2724
2759
  const saved = beginCstNodeCapture(ctx);
2725
2760
  const savedFields = ctx._fields;
2726
- ctx._fields = capturesFields ? [] : void 0;
2761
+ ctx._fields = effFields ? [] : void 0;
2727
2762
  const savedMask = ctx._triviaCaptureMask;
2728
2763
  if (build === void 0 && ctx.build?._parsemanTriviaKinds !== void 0 && def.type !== void 0) {
2729
2764
  ctx._triviaCaptureMask = ctx.build._parsemanTriviaKinds(def.type);
2730
2765
  }
2731
- if (!capturesTrivia) ctx.captureTrivia = false;
2766
+ if (!effTrivia) ctx.captureTrivia = false;
2732
2767
  let r = combinator.parse(input, pos, ctx);
2733
2768
  if (r.ok && trailingTrivia && ctx.trivia) {
2734
2769
  const end = consumeTrivia(input, r.span.end, ctx);
2735
2770
  r = { ...r, span: { start: r.span.start, end } };
2736
2771
  }
2737
- const fields = capturesFields ? buildFieldMap(ctx._fields) : void 0;
2772
+ const fields = effFields ? buildFieldMap(ctx._fields) : void 0;
2738
2773
  ctx._fields = savedFields;
2739
2774
  ctx._triviaCaptureMask = savedMask;
2740
2775
  const { children: children2, rawChildren, triviaLog } = endCstNodeCapture(ctx, saved);
2741
2776
  if (!r.ok) return r;
2742
2777
  const st = clonesState && ctx.state !== void 0 ? Object.assign({}, ctx.state) : void 0;
2743
2778
  const nodeType = def.type ?? missingInferredType();
2744
- const cstOutput = ctx.build?._parsemanCstOutput === true;
2745
- const built = unwrap && children2.length === 1 ? unwrapChild(children2[0]) : collapse && children2.length === 1 ? children2[0] : !build && ctx.build?._parsemanCstCollapse && children2.length === 1 && rawChildren.length === 1 && ctx.build._parsemanCstCollapse(nodeType, children2[0], children2, rawChildren) ? children2[0] : build ? cstOutput && ctx.build ? ctx.build(nodeType, children2, fields, r.span, rawChildren, triviaLog, st) : build(children2, fields, r.span, rawChildren, triviaLog, st) : ctx.build ? ctx.build(nodeType, children2, fields, r.span, rawChildren, triviaLog, st) : { _tag: "node", type: nodeType, span: { start: r.span.start, end: r.span.end }, state: st ?? null, children: children2 };
2779
+ const cstOutput = cstOutputHost(ctx.build);
2780
+ const hostState = clonesState ? st : ctx.state !== void 0 ? Object.assign({}, ctx.state) : void 0;
2781
+ const built = unwrap && children2.length === 1 ? unwrapChild(children2[0]) : collapse && children2.length === 1 ? children2[0] : !build && ctx.build?._parsemanCstCollapse && children2.length === 1 && rawChildren.length === 1 && ctx.build._parsemanCstCollapse(nodeType, children2[0], children2, rawChildren) ? children2[0] : build ? cstOutput && ctx.build ? ctx.build(nodeType, children2, fields, r.span, rawChildren, triviaLog, hostState) : build(children2, fields, r.span, rawChildren, triviaLog, st) : ctx.build ? ctx.build(nodeType, children2, fields, r.span, rawChildren, triviaLog, st) : { _tag: "node", type: nodeType, span: { start: r.span.start, end: r.span.end }, state: st ?? null, children: children2 };
2746
2782
  const rawEntry = isCstChild(built) ? built : { _tag: "leaf", value: typeof built === "string" ? built : typeof built === "object" && built !== null ? input.slice(r.span.start, r.span.end) : "", span: r.span };
2747
2783
  if (saved.buf !== void 0 || saved.ch !== void 0) {
2748
2784
  pushCstChild(ctx, built, rawEntry);
@@ -3020,7 +3056,24 @@ function leaf(root, fn) {
3020
3056
  }
3021
3057
 
3022
3058
  // src/version.ts
3023
- var PARSEMAN_VERSION = "0.36.0";
3059
+ var PARSEMAN_VERSION = "0.38.0";
3060
+
3061
+ // src/cst/host-mode.ts
3062
+ var FUSED_HOST_MODE = /* @__PURE__ */ Symbol.for("parseman.fusedHostMode");
3063
+ var FUSED_HOST_ELIDED = /* @__PURE__ */ Symbol.for("parseman.fusedHostElided");
3064
+ function assertHostModeCompatible(mode, build, hostBranchElided = true) {
3065
+ const isCstHost = build?._parsemanCstOutput === true;
3066
+ if (mode === "ast" && isCstHost && hostBranchElided) {
3067
+ throw new Error(
3068
+ "parseman: this parser was compiled for host mode \"ast\" (the default), so its nodes build through their own `build` callbacks and no positioned-CST branch was emitted. It cannot be driven with a positioned-CST host (cstBuildHost / a language-service host). Compile a second artifact from the SAME grammar with `compile(grammar, { hostMode: 'cst' })` \u2014 or, under the macro, a second `rules({ hostMode: 'cst' }, factory)` call site \u2014 and drive that one instead."
3069
+ );
3070
+ }
3071
+ if (mode === "cst" && !isCstHost) {
3072
+ throw new Error(
3073
+ 'parseman: this parser was compiled for host mode "cst", so every node builds through a positioned-CST `ctx.build` host. Pass one (e.g. `{ build: cstBuildHost }`), or use an artifact compiled with the default `hostMode: "ast"` to get the grammar\'s own AST.'
3074
+ );
3075
+ }
3076
+ }
3024
3077
 
3025
3078
  // src/analysis/gating.ts
3026
3079
  var isAny = (fs) => fs.kind === "any";
@@ -3057,7 +3110,7 @@ function firstSetToString(fs) {
3057
3110
  if (fs.kind === "empty") return "(empty)";
3058
3111
  return fs.ranges.map((r) => r.lo === r.hi ? ch(r.lo) : `${ch(r.lo)}-${ch(r.hi)}`).join(",");
3059
3112
  }
3060
- var ruleNameOf = (p) => p._ruleName;
3113
+ var ruleNameOf2 = (p) => p._ruleName;
3061
3114
  var SUGGESTIONS = {
3062
3115
  "broad-recognizer": "if this arm leads with a keyword regex, use word('kw', boundary) / keywords([...]) \u2014 they expose an EXACT, gating first-set and lower to the same charCodeAt scan. A genuine scanTo fallback is fine; accept it in the gating snapshot if intentional.",
3063
3116
  "leading-not": "let the arm lead with its actual consuming terminal (first-sets gate it automatically); keep not(...) only as a TRAILING boundary. Do not hand-roll not(not(...)) to fake gating \u2014 use peek(X), which is zero-width AND carries X first-set.",
@@ -3215,17 +3268,40 @@ function detectAntiPatterns(rule, arms) {
3215
3268
  return out;
3216
3269
  }
3217
3270
  function analyzeGating(entry, opts) {
3218
- return analyzeGatingRules([[ruleNameOf(entry) ?? opts?.entryName ?? "<entry>", entry]], opts);
3271
+ return analyzeGatingRules([[ruleNameOf2(entry) ?? opts?.entryName ?? "<entry>", entry]], opts);
3219
3272
  }
3220
3273
  function analyzeGatingRules(ruleMap, opts) {
3221
3274
  const raw = [];
3222
3275
  const antiPatterns = [];
3276
+ const unanalysable = [];
3277
+ const seenUnanalysable = /* @__PURE__ */ new Set();
3223
3278
  const visited = /* @__PURE__ */ new Set();
3279
+ const noteUnanalysable = (u) => {
3280
+ const key = `${u.rule}\0${u.kind}`;
3281
+ if (seenUnanalysable.has(key)) return;
3282
+ seenUnanalysable.add(key);
3283
+ unanalysable.push(u);
3284
+ };
3224
3285
  const visit = (p, enclosingRule) => {
3225
3286
  if (visited.has(p)) return;
3226
3287
  visited.add(p);
3227
- const d = p._def;
3228
- const rule = ruleNameOf(p) ?? enclosingRule;
3288
+ const def = p?._def;
3289
+ if (def === null || typeof def !== "object") {
3290
+ noteUnanalysable(
3291
+ typeof p === "function" ? {
3292
+ rule: enclosingRule,
3293
+ kind: "fused-rule",
3294
+ reason: "fused rule function \u2014 a compose()/fuse result has no combinator graph. Analyze the composed grammar itself (analyzeGrammarGating) so the carried IR is re-lowered first."
3295
+ } : {
3296
+ rule: enclosingRule,
3297
+ kind: "not-a-combinator",
3298
+ reason: `value of type ${p === null ? "null" : typeof p} is not a combinator and carries no _def descriptor.`
3299
+ }
3300
+ );
3301
+ return;
3302
+ }
3303
+ const d = def;
3304
+ const rule = ruleNameOf2(p) ?? enclosingRule;
3229
3305
  if (d.tag === "choice") {
3230
3306
  raw.push({ g: analyzeChoice(p, d, rule, opts?.resolveRef), rule });
3231
3307
  antiPatterns.push(...detectAntiPatterns(rule, d.parsers));
@@ -3244,7 +3320,7 @@ function analyzeGatingRules(ruleMap, opts) {
3244
3320
  }
3245
3321
  for (const k of kids) visit(k, rule);
3246
3322
  };
3247
- for (const [name, root] of ruleMap) visit(root, ruleNameOf(root) ?? name);
3323
+ for (const [name, root] of ruleMap) visit(root, ruleNameOf2(root) ?? name);
3248
3324
  const perRule = /* @__PURE__ */ new Map();
3249
3325
  for (const r of raw) perRule.set(r.rule, (perRule.get(r.rule) ?? 0) + 1);
3250
3326
  const seenInRule = /* @__PURE__ */ new Map();
@@ -3264,7 +3340,18 @@ function analyzeGatingRules(ruleMap, opts) {
3264
3340
  const accepted = choices.filter((c) => c.gates === "no" && c.accepted);
3265
3341
  const deferred = choices.filter((c) => c.gates === "no" && !c.accepted && c.deferred);
3266
3342
  const acceptedUnused = [...accept].filter((id) => !usedAccept.has(id));
3267
- return { totalChoices: choices.length, gated, recoverable, ungated, accepted, deferred, acceptedUnused, choices, antiPatterns };
3343
+ return {
3344
+ totalChoices: choices.length,
3345
+ gated,
3346
+ recoverable,
3347
+ unanalysable,
3348
+ ungated,
3349
+ accepted,
3350
+ deferred,
3351
+ acceptedUnused,
3352
+ choices,
3353
+ antiPatterns
3354
+ };
3268
3355
  }
3269
3356
  function analyzeChoice(p, d, rule, resolve) {
3270
3357
  const arms = d.parsers;
@@ -3301,6 +3388,13 @@ function analyzeChoice(p, d, rule, resolve) {
3301
3388
  }
3302
3389
  function formatGatingWarnings(report) {
3303
3390
  const lines = [];
3391
+ if (report.unanalysable.length > 0) {
3392
+ lines.push(
3393
+ `parseman gating: ${report.unanalysable.length} rule(s) UNANALYSABLE \u2014 this report is PARTIAL (${report.totalChoices} choice(s) examined). An empty finding list below does NOT mean the grammar is clean.`
3394
+ );
3395
+ for (const u of report.unanalysable)
3396
+ lines.push(` \xB7 ${u.rule} [${u.kind}]: ${u.reason}`);
3397
+ }
3304
3398
  for (const c of report.ungated) {
3305
3399
  lines.push(`parseman gating: choice @ ${c.id} is UNGATED [${c.strategy}] \u2014 no first-char dispatch; every position speculatively enters doomed arms.`);
3306
3400
  for (const a of c.anyArms)
@@ -3316,6 +3410,1284 @@ function formatGatingWarnings(report) {
3316
3410
  return lines;
3317
3411
  }
3318
3412
 
3413
+ // src/analysis/duplication.ts
3414
+ var siteToString = (s) => s.path === "" ? s.rule : `${s.rule} \u203A ${s.path}`;
3415
+ function childrenOf(d) {
3416
+ switch (d.tag) {
3417
+ case "sequence":
3418
+ case "choice":
3419
+ return d.parsers;
3420
+ case "skip":
3421
+ return [d.main, d.skipped];
3422
+ case "sepBy":
3423
+ return [d.parser, d.separator];
3424
+ case "recover":
3425
+ return [d.parser, d.sentinel];
3426
+ case "scanTo":
3427
+ return [d.sentinel, ...d.skip];
3428
+ case "grammar":
3429
+ return d.triviaParser ? [d.parser, d.triviaParser] : [d.parser];
3430
+ // A `lazy` is a REFERENCE, not a subtree: hashing through it would make every
3431
+ // rule's shape include the whole reachable grammar. Treated as a leaf, keyed
3432
+ // by the rule name it refers to — which is also the right semantics, since two
3433
+ // productions referencing `g.Ident` really do fill that slot the same way.
3434
+ case "lazy":
3435
+ case "literal":
3436
+ case "regex":
3437
+ case "keywords":
3438
+ case "guard":
3439
+ case "unknown":
3440
+ return [];
3441
+ default: {
3442
+ const rec = d;
3443
+ return rec.parser ? [rec.parser] : [];
3444
+ }
3445
+ }
3446
+ }
3447
+ var ruleNameOf3 = (p) => p._ruleName;
3448
+ var fnKey = (src, fn) => src ?? (typeof fn === "function" ? String(fn) : "");
3449
+ function payloadKey(p, d) {
3450
+ switch (d.tag) {
3451
+ case "literal":
3452
+ return `literal\0${d.value}\0${d.caseInsensitive}`;
3453
+ case "regex":
3454
+ return `regex\0${d.source}\0${d.flags}`;
3455
+ case "keywords":
3456
+ return `keywords\0${[...d.words].join("")}\0${d.caseInsensitive}\0${d.boundary ?? ""}`;
3457
+ case "lazy": {
3458
+ const n = ruleNameOf3(p);
3459
+ return n === void 0 ? `ref\0#${anonId(p)}` : `ref\0${n}`;
3460
+ }
3461
+ case "label":
3462
+ return `label\0${d.label}`;
3463
+ case "field":
3464
+ return `field\0${d.name}`;
3465
+ case "node":
3466
+ return `node\0${d.type ?? ""}\0${d.unwrap === true}\0${d.collapse === true}\0${fnKey(d.buildSrc, d.build)}`;
3467
+ case "transform":
3468
+ return `transform\0${fnKey(d.fnSrc, d.fn)}\0${d.recognitionOnly === true}`;
3469
+ case "leaf":
3470
+ return `leaf\0${fnKey(d.fnSrc, d.fn)}`;
3471
+ case "many":
3472
+ case "oneOrMore":
3473
+ return `${d.tag}\0${d.min}\0${d.max ?? ""}`;
3474
+ case "sepBy":
3475
+ return `sepBy\0${d.min}\0${d.max ?? ""}\0${d.trailing ?? ""}`;
3476
+ case "expect":
3477
+ return `expect\0${d.label ?? ""}\0${d.expected.join("")}`;
3478
+ case "scanTo":
3479
+ return `scanTo\0${d.raw}\0${d.orEOF}`;
3480
+ case "guard":
3481
+ return `guard\0${fnKey(d.predSrc, d.predicate)}`;
3482
+ case "withCtx":
3483
+ return `withCtx\0${d.extraSrc ?? safeJson(d.extra)}`;
3484
+ case "grammar":
3485
+ return `grammar\0${d.clearTrivia === true}\0${d.captureTrivia === true}\0${d.trackLines}`;
3486
+ default:
3487
+ return d.tag;
3488
+ }
3489
+ }
3490
+ function safeJson(v2) {
3491
+ try {
3492
+ return JSON.stringify(v2) ?? String(v2);
3493
+ } catch {
3494
+ return "?";
3495
+ }
3496
+ }
3497
+ var anonCounter = 0;
3498
+ var anonIds = /* @__PURE__ */ new WeakMap();
3499
+ function anonId(p) {
3500
+ let id = anonIds.get(p);
3501
+ if (id === void 0) {
3502
+ id = ++anonCounter;
3503
+ anonIds.set(p, id);
3504
+ }
3505
+ return id;
3506
+ }
3507
+ var Interner = class {
3508
+ ids = /* @__PURE__ */ new Map();
3509
+ hole;
3510
+ constructor() {
3511
+ this.hole = this.of("\0HOLE\0");
3512
+ }
3513
+ of(key) {
3514
+ let id = this.ids.get(key);
3515
+ if (id === void 0) {
3516
+ id = this.ids.size;
3517
+ this.ids.set(key, id);
3518
+ }
3519
+ return id;
3520
+ }
3521
+ };
3522
+ var MAX_RENDER = 160;
3523
+ function render(p, depth = 3) {
3524
+ const d = p._def;
3525
+ const name = ruleNameOf3(p);
3526
+ switch (d.tag) {
3527
+ case "literal":
3528
+ return `literal('${d.value}')`;
3529
+ case "regex":
3530
+ return `regex(/${d.source}/${d.flags})`;
3531
+ case "keywords":
3532
+ return `keywords([${[...d.words].slice(0, 4).map((w) => `'${w}'`).join(", ")}${d.words.length > 4 ? ", \u2026" : ""}])`;
3533
+ case "lazy":
3534
+ return `g.${name ?? "?"}`;
3535
+ case "guard":
3536
+ return "gate(\u2026)";
3537
+ default:
3538
+ break;
3539
+ }
3540
+ if (depth <= 0) return `${d.tag}(\u2026)`;
3541
+ const kids = childrenOf(d).map((k) => render(k, depth - 1));
3542
+ switch (d.tag) {
3543
+ case "sequence":
3544
+ return `sequence(${kids.join(", ")})`;
3545
+ case "choice":
3546
+ return `choice(${kids.join(", ")})`;
3547
+ case "node":
3548
+ return `node(${d.type ? `'${d.type}', ` : ""}${kids[0] ?? ""})`;
3549
+ case "label":
3550
+ return `label('${d.label}', ${kids[0] ?? ""})`;
3551
+ case "field":
3552
+ return `field('${d.name}', ${kids[0] ?? ""})`;
3553
+ case "many":
3554
+ return `many(${kids[0] ?? ""}${d.min === 0 && d.max === void 0 ? "" : `, { min: ${d.min}${d.max === void 0 ? "" : `, max: ${d.max}`} }`})`;
3555
+ case "oneOrMore":
3556
+ return `oneOrMore(${kids[0] ?? ""}${d.min === 1 && d.max === void 0 ? "" : `, { min: ${d.min}${d.max === void 0 ? "" : `, max: ${d.max}`} }`})`;
3557
+ case "sepBy":
3558
+ return `sepBy(${kids[0] ?? ""}, ${kids[1] ?? ""})`;
3559
+ default:
3560
+ return `${d.tag}(${kids.join(", ")})`;
3561
+ }
3562
+ }
3563
+ var clamp = (s) => s.length <= MAX_RENDER ? s : `${s.slice(0, MAX_RENDER - 1)}\u2026`;
3564
+ function segment(d, index) {
3565
+ switch (d.tag) {
3566
+ case "sequence":
3567
+ return `seq[${index}]`;
3568
+ case "choice":
3569
+ return `choice[${index}]`;
3570
+ case "node":
3571
+ return `node(${d.type ?? ""})`;
3572
+ case "label":
3573
+ return `label(${d.label})`;
3574
+ case "field":
3575
+ return `field(${d.name})`;
3576
+ case "sepBy":
3577
+ return index === 0 ? "sepBy.item" : "sepBy.sep";
3578
+ case "skip":
3579
+ return index === 0 ? "skip.main" : "skip.skipped";
3580
+ case "recover":
3581
+ return index === 0 ? "recover.body" : "recover.sentinel";
3582
+ case "scanTo":
3583
+ return index === 0 ? "scanTo.sentinel" : `scanTo.skip[${index - 1}]`;
3584
+ default:
3585
+ return d.tag;
3586
+ }
3587
+ }
3588
+ function alternationGroups(src) {
3589
+ const out = [];
3590
+ const stack = [{ branches: [], last: 0 }];
3591
+ let i = 0;
3592
+ while (i < src.length) {
3593
+ const c = src[i];
3594
+ if (c === "\\") {
3595
+ i += 2;
3596
+ continue;
3597
+ }
3598
+ if (c === "[") {
3599
+ let j = i + 1;
3600
+ if (src[j] === "^") j++;
3601
+ if (src[j] === "]") j++;
3602
+ while (j < src.length && src[j] !== "]") {
3603
+ if (src[j] === "\\") j++;
3604
+ j++;
3605
+ }
3606
+ i = j + 1;
3607
+ continue;
3608
+ }
3609
+ if (c === "(") {
3610
+ let open = i + 1;
3611
+ if (src[open] === "?") {
3612
+ const rest = src.slice(open);
3613
+ const m = /^\?(?::|=|!|<=|<!|<[^>]*>)/.exec(rest);
3614
+ open += m ? m[0].length : 1;
3615
+ }
3616
+ stack.push({ branches: [], last: open });
3617
+ i = open;
3618
+ continue;
3619
+ }
3620
+ if (c === ")") {
3621
+ const top = stack.pop();
3622
+ if (top !== void 0) {
3623
+ top.branches.push(src.slice(top.last, i));
3624
+ if (top.branches.length >= 2) out.push(top.branches);
3625
+ }
3626
+ i++;
3627
+ continue;
3628
+ }
3629
+ if (c === "|") {
3630
+ const top = stack[stack.length - 1];
3631
+ top.branches.push(src.slice(top.last, i));
3632
+ top.last = i + 1;
3633
+ i++;
3634
+ continue;
3635
+ }
3636
+ i++;
3637
+ }
3638
+ const root = stack[0];
3639
+ root.branches.push(src.slice(root.last));
3640
+ if (root.branches.length >= 2) out.push(root.branches);
3641
+ return out;
3642
+ }
3643
+ var WORD_RE_SRC = "-?[A-Za-z][A-Za-z0-9_-]*";
3644
+ var WORDS_RE = new RegExp(`^${WORD_RE_SRC}(?:\\|${WORD_RE_SRC})*$`);
3645
+ var VOCABULARY_MIN_WORDS = 3;
3646
+ function keywordRegexShape(source) {
3647
+ let s = source;
3648
+ if (s.startsWith("^")) s = s.slice(1);
3649
+ s = s.replace(/^\(\?<!\[[^\]]+\]\)/, "");
3650
+ let boundary = null;
3651
+ const tail = /(?:\(\?!\[([^\]]+)\]\)|\\b)$/.exec(s);
3652
+ if (tail !== null) {
3653
+ const cls = tail[1];
3654
+ if (cls !== void 0 && cls.startsWith("^")) return null;
3655
+ boundary = cls ?? "_0-9A-Za-z";
3656
+ s = s.slice(0, tail.index);
3657
+ }
3658
+ const grouped = /^\(\?:([^()]*)\)$/.exec(s);
3659
+ if (grouped !== null) s = grouped[1];
3660
+ if (s.endsWith("$")) s = s.slice(0, -1);
3661
+ if (!WORDS_RE.test(s)) return null;
3662
+ const words = s.split("|");
3663
+ if (boundary === null && words.length < VOCABULARY_MIN_WORDS) return null;
3664
+ return { words, boundary };
3665
+ }
3666
+ function boundaryMatches(boundary, ch2) {
3667
+ const cp = ch2.codePointAt(0);
3668
+ for (const m of charClassMembers(boundary)) {
3669
+ if (m === "\\w") {
3670
+ if (/[A-Za-z0-9_]/.test(ch2)) return true;
3671
+ continue;
3672
+ }
3673
+ if (m === "\\d") {
3674
+ if (/[0-9]/.test(ch2)) return true;
3675
+ continue;
3676
+ }
3677
+ if (m === "\\s") {
3678
+ if (/\s/.test(ch2)) return true;
3679
+ continue;
3680
+ }
3681
+ const range = /^(.|\\u[0-9a-f]{4})-(.|\\u[0-9a-f]{4})$/.exec(m);
3682
+ if (range !== null) {
3683
+ const dec = (t) => t.startsWith("\\u") ? parseInt(t.slice(2), 16) : t.codePointAt(0);
3684
+ if (cp >= dec(range[1]) && cp <= dec(range[2])) return true;
3685
+ continue;
3686
+ }
3687
+ if (m === ch2) return true;
3688
+ }
3689
+ return false;
3690
+ }
3691
+ function keywordAlternationHazards(words, boundary) {
3692
+ const out = [];
3693
+ for (let i = 0; i < words.length; i++) {
3694
+ for (let j = i + 1; j < words.length; j++) {
3695
+ const a = words[i], b = words[j];
3696
+ if (a.length >= b.length || !b.startsWith(a)) continue;
3697
+ const at = b[a.length];
3698
+ out.push({ shorter: a, longer: b, at, rescuedByBoundary: boundary !== null && boundaryMatches(boundary, at) });
3699
+ }
3700
+ }
3701
+ return out;
3702
+ }
3703
+ function analyzeDuplication(entry, opts) {
3704
+ return analyzeDuplicationRules([[ruleNameOf3(entry) ?? opts?.entryName ?? "<entry>", entry]], opts);
3705
+ }
3706
+ function assertAnalyzable(ruleMap) {
3707
+ for (const [name, rule] of ruleMap) {
3708
+ const def = rule?._def;
3709
+ if (def !== null && typeof def === "object" && typeof def.tag === "string") continue;
3710
+ throw new TypeError(
3711
+ `analyzeDuplicationRules: rule '${name}' is not a combinator (no _def). This analysis walks the COMBINATOR TREE; the value returned by compose()/composeLeaf() is a fused, already-compiled artifact whose entries are parse functions. Pass the rules() map itself (or the map given to compileRuleMap/compileLinkable) instead \u2014 for a composed grammar, analyze each piece's own rules() map.`
3712
+ );
3713
+ }
3714
+ }
3715
+ function analyzeDuplicationRules(ruleMap, opts) {
3716
+ assertAnalyzable(ruleMap);
3717
+ const minSize = opts?.minSize ?? 3;
3718
+ const maxFindings = opts?.maxFindings ?? 25;
3719
+ const interner = new Interner();
3720
+ const shapeCache = /* @__PURE__ */ new Map();
3721
+ const sizeCache = /* @__PURE__ */ new Map();
3722
+ const shapeOf = (p) => {
3723
+ const hit = shapeCache.get(p);
3724
+ if (hit !== void 0) return hit;
3725
+ const d = p._def;
3726
+ shapeCache.set(p, interner.hole);
3727
+ const kids = childrenOf(d);
3728
+ const id = interner.of(`${payloadKey(p, d)}|${kids.map(shapeOf).join(",")}`);
3729
+ shapeCache.set(p, id);
3730
+ return id;
3731
+ };
3732
+ const sizeOf = (p) => {
3733
+ const hit = sizeCache.get(p);
3734
+ if (hit !== void 0) return hit;
3735
+ sizeCache.set(p, 1);
3736
+ let n = 1;
3737
+ for (const k of childrenOf(p._def)) n += sizeOf(k);
3738
+ sizeCache.set(p, n);
3739
+ return n;
3740
+ };
3741
+ const visited = /* @__PURE__ */ new Map();
3742
+ const visit = (p, rule, path, enclosingNode, enclosingReducer) => {
3743
+ if (visited.has(p)) return;
3744
+ const d = p._def;
3745
+ const own = ruleNameOf3(p);
3746
+ const here = own !== void 0 && own !== rule ? { rule: own, path: "" } : { rule, path };
3747
+ visited.set(p, { p, d, site: here, shape: shapeOf(p), size: sizeOf(p), enclosingNode, enclosingReducer });
3748
+ const inner = d.tag === "node" ? d.type ?? "(anonymous)" : enclosingNode;
3749
+ const innerReducer = enclosingReducer || d.tag === "node" && (d.build !== void 0 || d.buildSrc !== void 0) || d.tag === "transform" || d.tag === "leaf";
3750
+ const kids = childrenOf(d);
3751
+ kids.forEach((k, i) => {
3752
+ const seg = segment(d, i);
3753
+ visit(k, here.rule, here.path === "" ? seg : `${here.path} \u203A ${seg}`, inner, innerReducer);
3754
+ });
3755
+ if (d.tag === "lazy") {
3756
+ try {
3757
+ const target = d.thunk();
3758
+ visit(target, ruleNameOf3(target) ?? ruleNameOf3(p) ?? here.rule, "", null, false);
3759
+ } catch {
3760
+ }
3761
+ }
3762
+ };
3763
+ for (const [name, root] of ruleMap) visit(root, ruleNameOf3(root) ?? name, "", null, false);
3764
+ const accept = new Set(opts?.accept ?? []);
3765
+ const used = /* @__PURE__ */ new Set();
3766
+ const keep = (f) => {
3767
+ if (!accept.has(f.id)) return true;
3768
+ used.add(f.id);
3769
+ return false;
3770
+ };
3771
+ const duplicates = findExactDuplicates(visited, minSize);
3772
+ const nearDuplicates = findNearDuplicates(visited, interner, shapeOf, minSize);
3773
+ const regexFragments = findRegexFragments(visited);
3774
+ const regexClasses = findRegexClasses(visited);
3775
+ const overlaps = findOverlaps(visited, shapeOf, opts?.resolveRef);
3776
+ const rewrites = findRewrites(visited, shapeOf);
3777
+ const divergentNodes = findDivergentNodes(visited, shapeOf);
3778
+ const structureLoss = findStructureLoss(visited, opts?.resolveRef);
3779
+ const keywordRegexes = findKeywordRegexes(visited);
3780
+ const subsume = (fs) => {
3781
+ const covered = /* @__PURE__ */ new Set();
3782
+ const surviving = /* @__PURE__ */ new Set();
3783
+ for (const f of [...fs].sort((a, b) => b.savings - a.savings)) {
3784
+ if (f._members.every((m) => covered.has(m))) continue;
3785
+ surviving.add(f.id);
3786
+ for (const m of f._members) markSubtree(m, covered);
3787
+ }
3788
+ return surviving;
3789
+ };
3790
+ const survivingExact = subsume(duplicates);
3791
+ const survivingNear = subsume(nearDuplicates);
3792
+ const report = {
3793
+ duplicates: duplicates.filter((f) => survivingExact.has(f.id)).filter(keep).slice(0, maxFindings).map(stripMembers),
3794
+ nearDuplicates: nearDuplicates.filter((f) => survivingNear.has(f.id)).filter(keep).slice(0, maxFindings).map(stripMembers),
3795
+ regexFragments: regexFragments.filter(keep).slice(0, maxFindings),
3796
+ regexClasses: regexClasses.filter(keep).slice(0, maxFindings),
3797
+ overlaps: overlaps.filter(keep).slice(0, maxFindings),
3798
+ rewrites: rewrites.filter(keep).slice(0, maxFindings),
3799
+ divergentNodes: divergentNodes.filter(keep).slice(0, maxFindings),
3800
+ structureLoss: structureLoss.filter(keep).slice(0, maxFindings),
3801
+ keywordRegexes: keywordRegexes.filter(keep).slice(0, maxFindings),
3802
+ acceptedUnused: [...accept].filter((id) => !used.has(id)),
3803
+ stats: { rules: ruleMap.length, nodes: visited.size, shapes: new Set([...visited.values()].map((v2) => v2.shape)).size }
3804
+ };
3805
+ return report;
3806
+ }
3807
+ function markSubtree(p, into) {
3808
+ if (into.has(p)) return;
3809
+ into.add(p);
3810
+ for (const k of childrenOf(p._def)) markSubtree(k, into);
3811
+ }
3812
+ var stripMembers = (f) => {
3813
+ const { _members, ...rest } = f;
3814
+ void _members;
3815
+ return rest;
3816
+ };
3817
+ function findExactDuplicates(visited, minSize) {
3818
+ const byShape = /* @__PURE__ */ new Map();
3819
+ for (const v2 of visited.values()) {
3820
+ if (v2.size < minSize) continue;
3821
+ const list = byShape.get(v2.shape);
3822
+ if (list === void 0) byShape.set(v2.shape, [v2]);
3823
+ else list.push(v2);
3824
+ }
3825
+ const out = [];
3826
+ for (const group of byShape.values()) {
3827
+ if (group.length < 2) continue;
3828
+ const size = group[0].size;
3829
+ out.push({
3830
+ kind: "exact-duplicate",
3831
+ id: `exact-duplicate:${siteToString(group[0].site)}`,
3832
+ size,
3833
+ count: group.length,
3834
+ savings: (group.length - 1) * size,
3835
+ shape: clamp(render(group[0].p)),
3836
+ sites: group.map((g) => g.site),
3837
+ suggestion: `${group.length} DISTINCT instances build this identical ${size}-node shape. Bind it to one const and reference that \u2014 hoisting removes ${(group.length - 1) * size} nodes and makes a future edit land in one place instead of ${group.length}.`,
3838
+ _members: group.map((g) => g.p)
3839
+ });
3840
+ }
3841
+ return out.sort((a, b) => b.savings - a.savings);
3842
+ }
3843
+ function findNearDuplicates(visited, interner, shapeOf, minSize) {
3844
+ const holeCache = /* @__PURE__ */ new Map();
3845
+ const holesOf = (p) => {
3846
+ const hit = holeCache.get(p);
3847
+ if (hit !== void 0) return hit;
3848
+ holeCache.set(p, []);
3849
+ const d = p._def;
3850
+ const kids = childrenOf(d);
3851
+ const kidShapes = kids.map(shapeOf);
3852
+ const payload = payloadKey(p, d);
3853
+ const out2 = [];
3854
+ kids.forEach((k, i) => {
3855
+ for (const inner of [interner.hole, ...holesOf(k)]) {
3856
+ const arr = kidShapes.slice();
3857
+ arr[i] = inner;
3858
+ out2.push(interner.of(`${payload}|${arr.join(",")}`));
3859
+ }
3860
+ });
3861
+ holeCache.set(p, out2);
3862
+ return out2;
3863
+ };
3864
+ const byHole = /* @__PURE__ */ new Map();
3865
+ for (const v2 of visited.values()) {
3866
+ if (v2.size < minSize) continue;
3867
+ for (const h of holesOf(v2.p)) {
3868
+ const list = byHole.get(h);
3869
+ if (list === void 0) byHole.set(h, [v2]);
3870
+ else list.push(v2);
3871
+ }
3872
+ }
3873
+ const out = [];
3874
+ const seenGroups = /* @__PURE__ */ new Set();
3875
+ for (const group of byHole.values()) {
3876
+ if (group.length < 2) continue;
3877
+ const shapes = new Set(group.map((g) => g.shape));
3878
+ if (shapes.size < 2) continue;
3879
+ const perShape = /* @__PURE__ */ new Map();
3880
+ for (const g of group) if (!perShape.has(g.shape)) perShape.set(g.shape, g);
3881
+ const members = [...perShape.values()];
3882
+ const key = members.map((m) => m.shape).sort((a, b) => a - b).join(",");
3883
+ if (seenGroups.has(key)) continue;
3884
+ seenGroups.add(key);
3885
+ const slot = findDivergentSlot(members.map((m) => m.p), shapeOf);
3886
+ if (slot === null) continue;
3887
+ const sharedSize = Math.min(...members.map((m) => m.size)) - slot.slotSize;
3888
+ if (sharedSize < minSize) continue;
3889
+ out.push({
3890
+ kind: "near-duplicate",
3891
+ id: `near-duplicate:${siteToString(members[0].site)}`,
3892
+ sharedSize,
3893
+ count: members.length,
3894
+ savings: (members.length - 1) * sharedSize,
3895
+ shape: clamp(renderWithHole(members[0].p, slot.path)),
3896
+ slotPath: slot.path.map((i, k) => segment(slot.defs[k], i)).join(" \u203A "),
3897
+ variants: slot.variants.map((v2) => clamp(render(v2))),
3898
+ sites: members.map((m) => m.site),
3899
+ suggestion: `${members.length} productions share this ${sharedSize}-node scaffolding and differ ONLY at \`${slot.path.map((i, k) => segment(slot.defs[k], i)).join(" \u203A ")}\`. That is one production whose varying slot is a \`choice(${slot.variants.map((v2) => clamp(render(v2, 1))).join(", ")})\` \u2014 not ${members.length} copies of the scaffolding. Re-check the AST: a single production emits one node type where the clones may emit several.`,
3900
+ _members: members.map((m) => m.p)
3901
+ });
3902
+ }
3903
+ return out.sort((a, b) => b.savings - a.savings);
3904
+ }
3905
+ function findDivergentSlot(members, shapeOf) {
3906
+ const path = [];
3907
+ const defs = [];
3908
+ let cur = [...members];
3909
+ for (let guard2 = 0; guard2 < 64; guard2++) {
3910
+ const d0 = cur[0]._def;
3911
+ const kidLists = cur.map((c) => childrenOf(c._def));
3912
+ const arity = kidLists[0].length;
3913
+ if (kidLists.some((k) => k.length !== arity) || arity === 0) break;
3914
+ const diff = [];
3915
+ for (let i2 = 0; i2 < arity; i2++) {
3916
+ const s = shapeOf(kidLists[0][i2]);
3917
+ if (kidLists.some((k) => shapeOf(k[i2]) !== s)) diff.push(i2);
3918
+ }
3919
+ if (diff.length !== 1) break;
3920
+ const i = diff[0];
3921
+ path.push(i);
3922
+ defs.push(d0);
3923
+ cur = kidLists.map((k) => k[i]);
3924
+ const nextArity = childrenOf(cur[0]._def).length;
3925
+ const samePayload = cur.every((c) => payloadKey(c, c._def) === payloadKey(cur[0], cur[0]._def));
3926
+ if (!samePayload || nextArity === 0) break;
3927
+ }
3928
+ if (path.length === 0) return null;
3929
+ let slotSize = 0;
3930
+ const sizeOf = (p) => {
3931
+ let n = 1;
3932
+ for (const k of childrenOf(p._def)) n += sizeOf(k);
3933
+ return n;
3934
+ };
3935
+ for (const c of cur) slotSize = Math.max(slotSize, sizeOf(c));
3936
+ return { path, defs, variants: cur, slotSize };
3937
+ }
3938
+ function renderWithHole(p, path, depth = 0) {
3939
+ if (depth === path.length) return "\u2039slot\u203A";
3940
+ const d = p._def;
3941
+ const kids = childrenOf(d);
3942
+ const idx = path[depth];
3943
+ const parts = kids.map((k, i) => i === idx ? renderWithHole(k, path, depth + 1) : render(k, 2));
3944
+ switch (d.tag) {
3945
+ case "sequence":
3946
+ return `sequence(${parts.join(", ")})`;
3947
+ case "choice":
3948
+ return `choice(${parts.join(", ")})`;
3949
+ case "node":
3950
+ return `node(${d.type ? `'${d.type}', ` : ""}${parts[0] ?? ""})`;
3951
+ default:
3952
+ return `${d.tag}(${parts.join(", ")})`;
3953
+ }
3954
+ }
3955
+ var MAX_RUN = 32;
3956
+ function findRegexFragments(visited) {
3957
+ const byRun = /* @__PURE__ */ new Map();
3958
+ for (const v2 of visited.values()) {
3959
+ const d = v2.d;
3960
+ if (d.tag !== "regex") continue;
3961
+ const runs = /* @__PURE__ */ new Set();
3962
+ for (const branches of alternationGroups(d.source)) {
3963
+ if (branches.length >= 2) runs.add(branches.join("|"));
3964
+ for (let i = 0; i < branches.length; i++) {
3965
+ for (let n = 2; n <= Math.min(MAX_RUN, branches.length - i); n++) {
3966
+ const run2 = branches.slice(i, i + n).join("|");
3967
+ if (run2.length < 4) continue;
3968
+ runs.add(run2);
3969
+ }
3970
+ }
3971
+ }
3972
+ for (const run2 of runs) {
3973
+ let e = byRun.get(run2);
3974
+ if (e === void 0) {
3975
+ e = { occ: [], nodes: /* @__PURE__ */ new Set(), branches: run2.split("|").length };
3976
+ byRun.set(run2, e);
3977
+ }
3978
+ if (e.nodes.has(v2.p)) continue;
3979
+ e.nodes.add(v2.p);
3980
+ e.occ.push({ site: v2.site, source: d.source });
3981
+ }
3982
+ }
3983
+ const candidates = [...byRun.entries()].filter(([, e]) => e.occ.length >= 2).map(([run2, e]) => ({ run: run2, e, savings: (e.occ.length - 1) * run2.length })).sort((a, b) => b.e.occ.length - a.e.occ.length || b.savings - a.savings || b.run.length - a.run.length);
3984
+ const kept = [];
3985
+ for (const c of candidates) {
3986
+ if (kept.some((k) => k.e.occ.length >= c.e.occ.length && `|${k.run}|`.includes(`|${c.run}|`))) continue;
3987
+ kept.push(c);
3988
+ }
3989
+ return kept.map(({ run: run2, e }) => ({
3990
+ kind: "regex-fragment",
3991
+ id: `regex-fragment:${run2}`,
3992
+ fragment: run2,
3993
+ branches: e.branches,
3994
+ count: e.occ.length,
3995
+ savings: (e.occ.length - 1) * run2.length,
3996
+ sources: e.occ.map((o) => `/${o.source}/`),
3997
+ sites: e.occ.map((o) => o.site),
3998
+ suggestion: `this ${e.branches}-branch alternation is re-spelled in ${e.occ.length} DISTINCT regex() terminals. Hoist it to one shared source string and interpolate it (\`new RegExp(\\\`\u2026\\\${OP}\u2026\\\`)\`) or lift the shared part into its own terminal \u2014 an edit to the operator set currently has to land in ${e.occ.length} places, and the analysis cannot tell you when one of them drifts.`
3999
+ }));
4000
+ }
4001
+ function extractCharClasses(src) {
4002
+ const out = [];
4003
+ let i = 0;
4004
+ while (i < src.length) {
4005
+ const c = src[i];
4006
+ if (c === "\\") {
4007
+ i += 2;
4008
+ continue;
4009
+ }
4010
+ if (c !== "[") {
4011
+ i++;
4012
+ continue;
4013
+ }
4014
+ let j = i + 1;
4015
+ if (src[j] === "^") j++;
4016
+ if (src[j] === "]") j++;
4017
+ while (j < src.length && src[j] !== "]") {
4018
+ if (src[j] === "\\") j++;
4019
+ j++;
4020
+ }
4021
+ out.push(src.slice(i + 1, j));
4022
+ i = j + 1;
4023
+ }
4024
+ return out;
4025
+ }
4026
+ function charClassMembers(body) {
4027
+ const esc = (ch2) => {
4028
+ const cp = ch2.codePointAt(0);
4029
+ return cp >= 128 ? `\\u${cp.toString(16).padStart(4, "0")}` : ch2;
4030
+ };
4031
+ const atoms = [];
4032
+ let i = 0;
4033
+ while (i < body.length) {
4034
+ if (body[i] === "\\") {
4035
+ const m = /^\\(?:u\{([0-9a-fA-F]+)\}|u([0-9a-fA-F]{4})|x([0-9a-fA-F]{2}))/.exec(body.slice(i));
4036
+ if (m !== null) {
4037
+ const cp = parseInt(m[1] ?? m[2] ?? m[3], 16);
4038
+ atoms.push(cp >= 128 ? `\\u${cp.toString(16).padStart(4, "0")}` : String.fromCodePoint(cp));
4039
+ i += m[0].length;
4040
+ continue;
4041
+ }
4042
+ atoms.push(body.slice(i, i + 2));
4043
+ i += 2;
4044
+ continue;
4045
+ }
4046
+ atoms.push(esc(body[i]));
4047
+ i++;
4048
+ }
4049
+ const members = [];
4050
+ for (let k = 0; k < atoms.length; k++) {
4051
+ if (k + 2 < atoms.length && atoms[k + 1] === "-" && atoms[k + 2] !== void 0) {
4052
+ members.push(`${atoms[k]}-${atoms[k + 2]}`);
4053
+ k += 2;
4054
+ continue;
4055
+ }
4056
+ members.push(atoms[k]);
4057
+ }
4058
+ return members;
4059
+ }
4060
+ var CLASS_MIN_MEMBERS = 2;
4061
+ function findRegexClasses(visited) {
4062
+ const spellings = /* @__PURE__ */ new Map();
4063
+ for (const v2 of visited.values()) {
4064
+ if (v2.d.tag !== "regex") continue;
4065
+ for (const body of new Set(extractCharClasses(v2.d.source))) {
4066
+ const members = charClassMembers(body);
4067
+ if (members.length < CLASS_MIN_MEMBERS) continue;
4068
+ const key = [...new Set(members)].sort().join("");
4069
+ let s = spellings.get(key);
4070
+ if (s === void 0) {
4071
+ s = { source: body, members, key, nodes: /* @__PURE__ */ new Set(), sites: [] };
4072
+ spellings.set(key, s);
4073
+ }
4074
+ if (s.nodes.has(v2.p)) continue;
4075
+ s.nodes.add(v2.p);
4076
+ s.sites.push(v2.site);
4077
+ }
4078
+ }
4079
+ const MAX_DELTA = 3;
4080
+ const MIN_JACCARD = 0.6;
4081
+ const all = [...spellings.values()];
4082
+ const parent = all.map((_, i) => i);
4083
+ const find = (i) => {
4084
+ while (parent[i] !== i) {
4085
+ parent[i] = parent[parent[i]];
4086
+ i = parent[i];
4087
+ }
4088
+ return i;
4089
+ };
4090
+ const union2 = (a, b) => {
4091
+ const ra = find(a), rb = find(b);
4092
+ if (ra !== rb) parent[ra] = rb;
4093
+ };
4094
+ for (let i = 0; i < all.length; i++) for (let j = i + 1; j < all.length; j++) {
4095
+ if (all[i].members.includes("^") !== all[j].members.includes("^")) continue;
4096
+ const A = new Set(all[i].members), B = new Set(all[j].members);
4097
+ let shared = 0;
4098
+ for (const m of A) if (B.has(m)) shared++;
4099
+ const delta = A.size - shared + (B.size - shared);
4100
+ const jaccard = shared / (shared + delta);
4101
+ if (shared >= 2 && delta > 0 && delta <= MAX_DELTA && jaccard >= MIN_JACCARD) union2(i, j);
4102
+ }
4103
+ const clusters = /* @__PURE__ */ new Map();
4104
+ all.forEach((_, i) => {
4105
+ const r = find(i);
4106
+ const list = clusters.get(r);
4107
+ if (list === void 0) clusters.set(r, [i]);
4108
+ else list.push(i);
4109
+ });
4110
+ const out = [];
4111
+ for (const idxs of clusters.values()) {
4112
+ const members = idxs.map((i) => all[i]).sort((a, b) => b.nodes.size - a.nodes.size);
4113
+ const total = members.reduce((n, m) => n + m.nodes.size, 0);
4114
+ const drifted = members.length > 1;
4115
+ if (!drifted && total < 2) continue;
4116
+ const base = new Set(members[0].members);
4117
+ const variants = members.map((m) => {
4118
+ const mine = new Set(m.members);
4119
+ const plus = [...mine].filter((x) => !base.has(x));
4120
+ const minus = [...base].filter((x) => !mine.has(x));
4121
+ return {
4122
+ source: m.source,
4123
+ delta: m === members[0] ? "(reference)" : `${plus.map((p) => `+\`${p}\``).join(" ")}${plus.length > 0 && minus.length > 0 ? " " : ""}${minus.map((p) => `-\`${p}\``).join(" ")}` || "(same members, different spelling)",
4124
+ count: m.nodes.size,
4125
+ sites: m.sites
4126
+ };
4127
+ });
4128
+ const bmpCeiling = members.some((m) => m.members.some((x) => x.endsWith("-\\uffff")));
4129
+ out.push({
4130
+ kind: "regex-class",
4131
+ id: `regex-class:${members[0].key}`,
4132
+ canonical: members[0].source,
4133
+ variants,
4134
+ count: total,
4135
+ drifted,
4136
+ bmpCeiling,
4137
+ suggestion: [
4138
+ drifted ? `${members.length} NEARLY-identical spellings of one class, across ${total} regex() terminals. They differ by ${variants.slice(1).map((v2) => v2.delta).join("; ")} \u2014 which is drift, not intent, unless every difference is deliberate. Two classes this close cannot be told apart by reading, so one of them is probably wrong and the tests cannot say which.` : `this class is re-spelled in ${total} DISTINCT regex() terminals. Extract it once \u2014 a named sub-leaf, or an interpolated source constant \u2014 so a range fix lands in one place. Re-spelling is how the near-identical variants elsewhere in this report came to exist.`,
4139
+ `Prefer the shared recognition surface where one already covers this class rather than a local extraction.`,
4140
+ bmpCeiling ? `The class tops out at U+FFFF, so astral-plane characters (emoji, many CJK extensions) fall OUTSIDE it. Widening the ceiling is exactly the kind of edit that has to reach every spelling.` : "",
4141
+ `Single-regex hygiene (duplicate class members, obscure ranges, a missing \`u\` flag) is \`eslint-plugin-regexp\`'s job and is not duplicated here \u2014 it sees one regex at a time, which is why it cannot see this.`
4142
+ ].filter((s) => s !== "").join(" ")
4143
+ });
4144
+ }
4145
+ return out.sort((a, b) => Number(b.drifted) - Number(a.drifted) || b.count - a.count);
4146
+ }
4147
+ function intersects3(a, b) {
4148
+ if (a.kind === "any" || b.kind === "any") return true;
4149
+ if (a.kind === "empty" || b.kind === "empty") return false;
4150
+ for (const ra of a.ranges) for (const rb of b.ranges) if (ra.lo <= rb.hi && rb.lo <= ra.hi) return true;
4151
+ return false;
4152
+ }
4153
+ function intersection2(a, b) {
4154
+ if (a.kind === "any") return b;
4155
+ if (b.kind === "any") return a;
4156
+ if (a.kind === "empty" || b.kind === "empty") return { kind: "empty" };
4157
+ const ranges = [];
4158
+ for (const ra of a.ranges) for (const rb of b.ranges) {
4159
+ const lo = Math.max(ra.lo, rb.lo);
4160
+ const hi = Math.min(ra.hi, rb.hi);
4161
+ if (lo <= hi) ranges.push({ lo, hi });
4162
+ }
4163
+ return ranges.length === 0 ? { kind: "empty" } : { kind: "ranges", ranges };
4164
+ }
4165
+ function termsOf(arm) {
4166
+ let cur = arm;
4167
+ for (; ; ) {
4168
+ const d2 = cur._def;
4169
+ if (d2.tag === "node" || d2.tag === "transform" || d2.tag === "label" || d2.tag === "attempt" || d2.tag === "leaf") {
4170
+ cur = d2.parser;
4171
+ continue;
4172
+ }
4173
+ break;
4174
+ }
4175
+ const d = cur._def;
4176
+ return d.tag === "sequence" ? d.parsers : [cur];
4177
+ }
4178
+ function leadingRegex(arm) {
4179
+ const t = termsOf(arm)[0];
4180
+ return t !== void 0 && t._def.tag === "regex";
4181
+ }
4182
+ function findOverlaps(visited, shapeOf, resolve) {
4183
+ const out = [];
4184
+ for (const v2 of visited.values()) {
4185
+ const d = v2.d;
4186
+ if (d.tag !== "choice") continue;
4187
+ const arms = d.parsers;
4188
+ const fs = arms.map((a) => firstSetOf(a, /* @__PURE__ */ new Set(), resolve));
4189
+ const strategy = d.strategy;
4190
+ for (let i = 0; i < arms.length; i++) {
4191
+ for (let j = i + 1; j < arms.length; j++) {
4192
+ const A = fs[i], B = fs[j];
4193
+ if (A.kind === "any" || B.kind === "any") continue;
4194
+ if (!intersects3(A, B)) continue;
4195
+ const ta = termsOf(arms[i]), tb = termsOf(arms[j]);
4196
+ let shared = 0;
4197
+ while (shared < ta.length && shared < tb.length && shapeOf(ta[shared]) === shapeOf(tb[shared])) shared++;
4198
+ const handled = strategy?.tag === "sharedPrefix" && Array.isArray(strategy.members) && strategy.members.includes(i) && strategy.members.includes(j);
4199
+ const regexPair = leadingRegex(arms[i]) && leadingRegex(arms[j]);
4200
+ out.push({
4201
+ kind: "arm-overlap",
4202
+ id: `arm-overlap:${siteToString(v2.site)}:${i}-${j}`,
4203
+ site: v2.site,
4204
+ a: i,
4205
+ b: j,
4206
+ on: intersection2(A, B),
4207
+ sharedLeadingTerms: shared,
4208
+ sharedPrefix: shared > 0 ? clamp(ta.slice(0, shared).map((t) => render(t, 2)).join(", ")) : null,
4209
+ handledByStrategy: handled,
4210
+ regexPair,
4211
+ suggestion: overlapSuggestion(shared, handled, regexPair)
4212
+ });
4213
+ }
4214
+ }
4215
+ }
4216
+ return out.sort((a, b) => b.sharedLeadingTerms - a.sharedLeadingTerms);
4217
+ }
4218
+ function overlapSuggestion(shared, handled, regexPair) {
4219
+ if (shared > 0 && handled)
4220
+ return `the arms spell ${shared} identical leading term(s). parseman's \`sharedPrefix\` strategy already recognizes that prefix ONCE at runtime, so this costs nothing at parse time \u2014 it is a READABILITY finding: left-factoring into \`sequence(prefix, choice(\u2026))\` says in the grammar what the compiler had to infer.`;
4221
+ if (shared > 0)
4222
+ return `the arms spell ${shared} identical leading term(s) but do NOT qualify for the \`sharedPrefix\` strategy (it needs every arm to be a bare \`sequence\` starting with the same concrete terminal). Left-factor into \`sequence(prefix, choice(\u2026))\` \u2014 that both removes the re-spelling and lets the inner choice dispatch on its own first char.`;
4223
+ if (regexPair)
4224
+ return `two regex() arms whose character classes intersect. Nothing dispatches between them: the first is tried, and on failure the second re-scans the same position. Two regexes that can start on the same char are usually one terminal \u2014 merge them, or make the distinction explicit with a leading literal.`;
4225
+ return `the arms can start on the same character, so the choice cannot dispatch between them: on those chars the first arm is entered speculatively and rolled back before the second is tried. Give the arms disjoint leading terminals, or left-factor the common part out.`;
4226
+ }
4227
+ var PERF_ARM = "removes a speculative arm: on every input position reaching this choice, the deleted arm no longer costs a ctx save, a child-array push and a rollback before the surviving arm is tried. The saving scales with how often the choice is reached and how much the doomed arm consumes before failing.";
4228
+ var PERF_NONE = "";
4229
+ function findRewrites(visited, shapeOf) {
4230
+ const out = [];
4231
+ const push = (f, at) => {
4232
+ const site = siteToString(f.site);
4233
+ out.push({ kind: "rewrite", id: `rewrite:${f.rewrite}:${site}${at === void 0 ? "" : `:${at}`}`, ...f });
4234
+ };
4235
+ for (const v2 of visited.values()) {
4236
+ const d = v2.d;
4237
+ const site = v2.site;
4238
+ if (d.tag === "optional" || d.tag === "many" || d.tag === "oneOrMore") {
4239
+ const inner = d.parser;
4240
+ const id = inner._def;
4241
+ const collapsed = collapseRepeat(d, id);
4242
+ if (collapsed !== null) {
4243
+ push({
4244
+ rewrite: "idempotent-nesting",
4245
+ site,
4246
+ from: clamp(render(v2.p, 2)),
4247
+ to: collapsed,
4248
+ astNeutral: false,
4249
+ perf: "the outer wrapper still runs its own bookkeeping (and, for `many(optional(X))`, guards a zero-width iteration on every pass) for a nullability the inner combinator already has.",
4250
+ bug: id.tag === "optional" && d.tag === "many",
4251
+ suggestion: `\`${clamp(render(v2.p, 2))}\` is exactly \`${collapsed}\`. ${id.tag === "optional" && d.tag === "many" ? "The nested form is also a zero-width-iteration hazard: the inner `optional` succeeds consuming nothing, which the repeat has to detect and break on every pass." : ""} Candidate \u2014 verify AST identity: the collapsed form produces ONE level of array/undefined where the nested form produces two.`
4252
+ });
4253
+ }
4254
+ }
4255
+ if ((d.tag === "choice" || d.tag === "sequence") && d.parsers.length === 1) {
4256
+ push({
4257
+ rewrite: "single-element",
4258
+ site,
4259
+ from: clamp(render(v2.p, 2)),
4260
+ to: d.tag === "choice" ? clamp(render(d.parsers[0], 2)) : `${clamp(render(d.parsers[0], 2))} (note: sequence() of one still wraps its value in a 1-tuple)`,
4261
+ astNeutral: d.tag === "choice",
4262
+ perf: d.tag === "choice" ? "a one-arm choice still emits its dispatch scaffolding." : PERF_NONE,
4263
+ bug: false,
4264
+ suggestion: d.tag === "choice" ? "a `choice` with one arm IS that arm \u2014 the wrapper only adds dispatch scaffolding. Safe to unwrap (choice forwards its arm value unchanged)." : "a `sequence` with one element only exists to build a 1-tuple. If nothing reads that tuple, drop the wrapper \u2014 CANDIDATE, verify AST identity first."
4265
+ });
4266
+ }
4267
+ if (d.tag !== "choice") continue;
4268
+ const arms = d.parsers;
4269
+ const armShapes = arms.map(shapeOf);
4270
+ const strategy = d.strategy;
4271
+ for (let i = 0; i < arms.length; i++) for (let j = i + 1; j < arms.length; j++) {
4272
+ if (armShapes[i] !== armShapes[j]) continue;
4273
+ push({
4274
+ rewrite: "duplicate-arm",
4275
+ site,
4276
+ from: `choice(\u2026 arm[${i}] \u2026, arm[${j}] \u2026)`,
4277
+ to: `delete arm[${j}]`,
4278
+ astNeutral: true,
4279
+ perf: PERF_ARM,
4280
+ bug: true,
4281
+ suggestion: `arm[${i}] and arm[${j}] are the SAME shape (\`${clamp(render(arms[i], 2))}\`). Under ordered choice arm[${i}] always wins, so arm[${j}] is unreachable \u2014 delete it. This is AST-neutral by construction: an arm that can never be selected contributes nothing to any parse.`
4282
+ }, `${i}-${j}`);
4283
+ }
4284
+ for (let i = 0; i < arms.length; i++) for (let j = 0; j < arms.length; j++) {
4285
+ if (i >= j) continue;
4286
+ if (armShapes[i] === armShapes[j]) continue;
4287
+ const ta = termsOf(arms[i]), tb = termsOf(arms[j]);
4288
+ if (ta.length === 0 || ta.length >= tb.length) continue;
4289
+ let k = 0;
4290
+ while (k < ta.length && shapeOf(ta[k]) === shapeOf(tb[k])) k++;
4291
+ if (k !== ta.length) continue;
4292
+ push({
4293
+ rewrite: "shadowed-arm",
4294
+ site,
4295
+ from: `choice(\u2026 arm[${i}]=${clamp(render(arms[i], 2))}, \u2026 arm[${j}]=${clamp(render(arms[j], 2))} \u2026)`,
4296
+ to: `arm[${j}] is unreachable`,
4297
+ astNeutral: true,
4298
+ perf: PERF_ARM,
4299
+ bug: true,
4300
+ suggestion: `arm[${i}] is a strict prefix of arm[${j}] and comes FIRST. Whenever arm[${j}] would match, arm[${i}] also matches, and ordered choice commits to it \u2014 so arm[${j}] can never be selected. Either swap them (longest first) or fold arm[${j}]'s extra terms into arm[${i}] as \`optional(\u2026)\`. Verify by hand where a \`not()\`/\`gate()\` in arm[${i}] makes its success input-dependent in a way this static check cannot see.`
4301
+ }, `${i}-${j}`);
4302
+ }
4303
+ for (let i = 0; i + 1 < arms.length; i++) {
4304
+ const j = i + 1;
4305
+ const longer = termsOf(arms[i]), shorter = termsOf(arms[j]);
4306
+ const ordered = detectOptionalTerm(longer, shorter, shapeOf);
4307
+ const reversed = ordered === null ? detectOptionalTerm(shorter, longer, shapeOf) : null;
4308
+ const hit = ordered ?? reversed;
4309
+ if (hit === null) continue;
4310
+ const nodeWrapped = v2.enclosingNode ?? wrappingNodeType(arms[i]) ?? wrappingNodeType(arms[j]);
4311
+ const rest = hit.rest.map((t) => render(t, 2)).join(", ");
4312
+ const to = hit.where === "prefix" ? `sequence(optional(${render(hit.term, 2)}), ${rest})` : `sequence(${rest}, optional(${render(hit.term, 2)}))`;
4313
+ push({
4314
+ rewrite: hit.where === "prefix" ? "optional-prefix" : "optional-suffix",
4315
+ site,
4316
+ from: clamp(render(v2.p, 2)),
4317
+ to: clamp(to),
4318
+ astNeutral: false,
4319
+ perf: PERF_ARM,
4320
+ bug: false,
4321
+ suggestion: `these two arms differ only by a ${hit.where === "prefix" ? "leading" : "trailing"} \`${clamp(render(hit.term, 2))}\`, so the choice is exactly \`${clamp(to)}\` \u2014 same language, one arm instead of two.${reversed !== null ? ` NOTE THE ORDER: the shorter arm is FIRST, so under ordered choice the longer arm is only reachable when the shorter one fails \u2014 check that is what you meant.` : ""} CANDIDATE \u2014 verify AST identity before applying:${nodeWrapped === null ? "" : ` this sits under \`node('${nodeWrapped}')\`, and`} the two forms produce different child arrays (two children vs. one child plus an absent optional). If a build fn or a downstream consumer reads children positionally, the rewrite MOVES the tree.`
4322
+ }, `${i}-${j}`);
4323
+ }
4324
+ for (let i = 0; i + 1 < arms.length; i++) {
4325
+ const j = i + 1;
4326
+ const ta = termsOf(arms[i]), tb = termsOf(arms[j]);
4327
+ if (ta.length < 2 || tb.length < 2) continue;
4328
+ let k = 0;
4329
+ while (k < ta.length - 1 && k < tb.length - 1 && shapeOf(ta[k]) === shapeOf(tb[k])) k++;
4330
+ if (k === 0) continue;
4331
+ const handled = strategy?.tag === "sharedPrefix" && Array.isArray(strategy.members) && strategy.members.includes(i) && strategy.members.includes(j);
4332
+ push({
4333
+ rewrite: "left-factor",
4334
+ site,
4335
+ from: `choice(sequence(${ta.map((t) => render(t, 1)).join(", ")}), sequence(${tb.map((t) => render(t, 1)).join(", ")}))`,
4336
+ to: clamp(`sequence(${ta.slice(0, k).map((t) => render(t, 2)).join(", ")}, choice(sequence(${ta.slice(k).map((t) => render(t, 1)).join(", ")}), sequence(${tb.slice(k).map((t) => render(t, 1)).join(", ")})))`),
4337
+ astNeutral: false,
4338
+ perf: handled ? "NONE \u2014 parseman's `sharedPrefix` choice strategy already recognizes this prefix once and replays it, so the runtime cost is identical. This is a readability finding." : PERF_ARM,
4339
+ bug: false,
4340
+ suggestion: `arm[${i}] and arm[${j}] spell the same ${k} leading term(s). ${handled ? "The `sharedPrefix` strategy already collapses this AT RUNTIME, so left-factoring buys readability, not speed \u2014 but writing it out means the next reader does not have to trust the optimizer." : "Left-factor to `sequence(prefix, choice(tailA, tailB))`: the prefix is then recognized once and the inner choice dispatches on its own first char."} CANDIDATE \u2014 verify AST identity: factoring nests the tails one level deeper in the value.`
4341
+ }, `${i}-${j}`);
4342
+ }
4343
+ }
4344
+ for (const v2 of visited.values()) {
4345
+ if (v2.d.tag !== "sequence") continue;
4346
+ const hit = detectHandRolledSepBy(v2.d.parsers, shapeOf);
4347
+ if (hit === null) continue;
4348
+ const captured = v2.d.parsers.some((t) => capturesPositionally(t));
4349
+ const verdict = captured ? "blocked-by-capture" : v2.enclosingReducer ? "reducer-stride-review" : "convertible";
4350
+ const to = `sepBy(${clamp(render(hit.item, 2))}, ${clamp(render(hit.sep, 2))}${hit.trailing ? `, { trailing: 'allow' }` : ""}, { min: ${hit.min} })`;
4351
+ out.push({
4352
+ kind: "rewrite",
4353
+ id: `rewrite:hand-rolled-sepby:${siteToString(v2.site)}`,
4354
+ rewrite: "hand-rolled-sepby",
4355
+ site: v2.site,
4356
+ sepByVerdict: verdict,
4357
+ from: clamp(render(v2.p, 3)),
4358
+ to: verdict === "convertible" ? to : `${to} \u2014 NOT APPLICABLE HERE (${verdict})`,
4359
+ astNeutral: false,
4360
+ perf: verdict === "convertible" ? "the hand-rolled form allocates a tuple per repetition (`sequence(sep, item)`) and a wrapper array around them; `sepBy` builds the item list directly." : PERF_NONE,
4361
+ bug: false,
4362
+ suggestion: `${SEPBY_SPELLED_OUT(hit.trailing)} ${SEPBY_VERDICT_TEXT[verdict](hit.trailing)}`
4363
+ });
4364
+ }
4365
+ return out;
4366
+ }
4367
+ function collapseRepeat(outer, inner) {
4368
+ const o = outer.tag, i = inner.tag;
4369
+ if (o === "optional" && i === "optional") return "optional(X)";
4370
+ if (o === "optional" && i === "many") return "many(X)";
4371
+ if (o === "many" && i === "many") return "many(X)";
4372
+ if (o === "many" && i === "optional") return "many(X)";
4373
+ if (o === "optional" && i === "oneOrMore" && inner.min === 1 && inner.max === void 0) return "many(X)";
4374
+ if (o === "many" && i === "oneOrMore" && inner.min === 1 && inner.max === void 0) return "many(X)";
4375
+ return null;
4376
+ }
4377
+ function wrappingNodeType(arm) {
4378
+ const d = arm._def;
4379
+ return d.tag === "node" ? d.type ?? "(anonymous)" : null;
4380
+ }
4381
+ function detectOptionalTerm(longer, shorter, shapeOf) {
4382
+ if (longer.length !== shorter.length + 1 || shorter.length === 0) return null;
4383
+ const same = (a, b) => a.length === b.length && a.every((x, i) => shapeOf(x) === shapeOf(b[i]));
4384
+ if (same(longer.slice(1), shorter)) return { where: "prefix", term: longer[0], rest: shorter };
4385
+ if (same(longer.slice(0, -1), shorter)) return { where: "suffix", term: longer[longer.length - 1], rest: shorter };
4386
+ return null;
4387
+ }
4388
+ var SEPBY_SPELLED_OUT = (trailing) => `this is \`sepBy(item, sep${trailing ? ", { trailing: 'allow' }" : ""})\` spelled out.`;
4389
+ var SEPBY_VERDICT_TEXT = {
4390
+ "blocked-by-capture": () => "BLOCKED: the repetition CAPTURES its separator (`field`/`label`), which `sepBy` cannot express \u2014 it yields items and discards separators, so byte-faithful layout replay is lost. Leave this site alone; read it as a parseman gap (a separator-capturing repeat), not as work.",
4391
+ "reducer-stride-review": () => "NEEDS REVIEW, not conversion: an enclosing `node()`/`transform()` reducer reads this repetition's children, and a left-associating reducer typically STRIDES BY TWO over `[item, sep, item, sep, \u2026]` and re-emits the operator text. `sepBy` yields a FLAT ITEM list with the separators gone, so the reducer has to be rewritten in the same change or the tree moves. Convert only together with the reducer.",
4392
+ convertible: (trailing) => `No separator capture, and no enclosing reducer reads these children \u2014 this site is convertible. Beyond the ${trailing ? "four" : "three"} combinators it replaces, \`sepBy\` carries the right NULLABILITY: \`min: 0\` (the default) matches empty and does NOT gate as a choice arm, \`min: 1\` does \u2014 a distinction the hand-rolled shape leaves implicit and gets wrong silently. Still check the consumer: \`sepBy\` yields a flat item list where the hand-rolled form yields \`[first, [[sep, item], \u2026]]\`.`
4393
+ };
4394
+ function capturesPositionally(p, seen = /* @__PURE__ */ new Set()) {
4395
+ if (seen.has(p)) return false;
4396
+ seen.add(p);
4397
+ const d = p._def;
4398
+ if (d.tag === "field" || d.tag === "label") return true;
4399
+ for (const k of childrenOf(d)) if (capturesPositionally(k, seen)) return true;
4400
+ return false;
4401
+ }
4402
+ function detectHandRolledSepBy(terms, shapeOf) {
4403
+ if (terms.length < 2 || terms.length > 3) return null;
4404
+ const item = terms[0];
4405
+ const rep = terms[1]._def;
4406
+ if (rep.tag !== "many" && rep.tag !== "oneOrMore") return null;
4407
+ if (rep.max !== void 0) return null;
4408
+ const innerDef = rep.parser._def;
4409
+ if (innerDef.tag !== "sequence" || innerDef.parsers.length !== 2) return null;
4410
+ const [sep, repeated] = innerDef.parsers;
4411
+ if (shapeOf(repeated) !== shapeOf(item)) return null;
4412
+ let trailing = false;
4413
+ if (terms.length === 3) {
4414
+ const t = terms[2]._def;
4415
+ if (t.tag !== "optional" || shapeOf(t.parser) !== shapeOf(sep)) return null;
4416
+ trailing = true;
4417
+ }
4418
+ return { item, sep, trailing, min: 1 + rep.min };
4419
+ }
4420
+ function bodyTermsOf(p) {
4421
+ let cur = p;
4422
+ for (; ; ) {
4423
+ const d2 = cur._def;
4424
+ if (d2.tag === "node" || d2.tag === "grammar" || d2.tag === "transform" || d2.tag === "label" || d2.tag === "attempt" || d2.tag === "leaf" || d2.tag === "token" || d2.tag === "expect" || d2.tag === "field") {
4425
+ cur = d2.parser;
4426
+ continue;
4427
+ }
4428
+ break;
4429
+ }
4430
+ const d = cur._def;
4431
+ return d.tag === "sequence" ? d.parsers : [cur];
4432
+ }
4433
+ var MIN_SHARED_TERMS = 2;
4434
+ function findDivergentNodes(visited, shapeOf) {
4435
+ const byType = /* @__PURE__ */ new Map();
4436
+ for (const v2 of visited.values()) {
4437
+ if (v2.d.tag !== "node" || v2.d.type === void 0) continue;
4438
+ let m = byType.get(v2.d.type);
4439
+ if (m === void 0) {
4440
+ m = /* @__PURE__ */ new Map();
4441
+ byType.set(v2.d.type, m);
4442
+ }
4443
+ if (!m.has(v2.shape)) m.set(v2.shape, v2);
4444
+ }
4445
+ const out = [];
4446
+ for (const [type, m] of byType) {
4447
+ if (m.size < 2) continue;
4448
+ const members = [...m.values()];
4449
+ const termSets = members.map((v2) => bodyTermsOf(v2.p).map((t) => ({ id: shapeOf(t), text: clamp(render(t, 2)) })));
4450
+ const shared = termSets[0].filter((t) => termSets.every((set) => set.some((x) => x.id === t.id)));
4451
+ const uniqueShared = [...new Map(shared.map((t) => [t.id, t])).values()];
4452
+ if (uniqueShared.length < MIN_SHARED_TERMS) continue;
4453
+ const sharedIds = new Set(uniqueShared.map((t) => t.id));
4454
+ out.push({
4455
+ kind: "divergent-node",
4456
+ id: `divergent-node:${type}`,
4457
+ nodeType: type,
4458
+ count: members.length,
4459
+ sharedTerms: uniqueShared.map((t) => t.text),
4460
+ productions: members.map((v2, i) => ({
4461
+ shape: clamp(render(v2.p, 3)),
4462
+ site: v2.site,
4463
+ distinctTerms: termSets[i].filter((t) => !sharedIds.has(t.id)).map((t) => t.text)
4464
+ })),
4465
+ suggestion: `\`${type}\` is built by ${members.length} structurally different productions that nevertheless spell ${uniqueShared.length} identical term(s) (${uniqueShared.map((t) => `\`${t.text}\``).join(", ")}) \u2014 they are variants of ONE shape, not ${members.length} constructs. Nothing keeps them in sync: an edit to the ${type} shape has to land in all ${members.length}, and no test fails when it lands in one. Fold them into a single production whose varying part is a \`choice\`, or, if the variants exist for a parse-order reason (a fast path tried first), say so at the definition \u2014 the cost of the split is that a reader cannot tell which it is.`
4466
+ });
4467
+ }
4468
+ return out.sort((a, b) => b.sharedTerms.length * b.count - a.sharedTerms.length * a.count);
4469
+ }
4470
+ function resolveArm(p) {
4471
+ let cur = p;
4472
+ const seen = /* @__PURE__ */ new Set();
4473
+ for (; ; ) {
4474
+ if (seen.has(cur)) return cur;
4475
+ seen.add(cur);
4476
+ const d = cur._def;
4477
+ if (d.tag === "label" || d.tag === "attempt") {
4478
+ cur = d.parser;
4479
+ continue;
4480
+ }
4481
+ if (d.tag === "lazy") {
4482
+ try {
4483
+ cur = d.thunk();
4484
+ continue;
4485
+ } catch {
4486
+ return cur;
4487
+ }
4488
+ }
4489
+ return cur;
4490
+ }
4491
+ }
4492
+ function nestedNodeTypes(root, out, seen, isRoot) {
4493
+ if (seen.has(root)) return;
4494
+ seen.add(root);
4495
+ const d = root._def;
4496
+ if (d.tag === "leaf" || d.tag === "token") return;
4497
+ if (d.tag === "node" && !isRoot) out.add(d.type ?? "(anonymous)");
4498
+ if (d.tag === "lazy") {
4499
+ try {
4500
+ nestedNodeTypes(d.thunk(), out, seen, false);
4501
+ } catch {
4502
+ }
4503
+ return;
4504
+ }
4505
+ for (const k of childrenOf(d)) nestedNodeTypes(k, out, seen, false);
4506
+ }
4507
+ var MAX_LOST_TYPES = 8;
4508
+ function findStructureLoss(visited, resolve) {
4509
+ const nestedCache = /* @__PURE__ */ new Map();
4510
+ const nested = (p) => {
4511
+ const hit = nestedCache.get(p);
4512
+ if (hit !== void 0) return hit;
4513
+ const out = /* @__PURE__ */ new Set();
4514
+ nestedNodeTypes(p, out, /* @__PURE__ */ new Set(), true);
4515
+ nestedCache.set(p, out);
4516
+ return out;
4517
+ };
4518
+ const findings = [];
4519
+ for (const v2 of visited.values()) {
4520
+ const d = v2.d;
4521
+ if (d.tag !== "choice") continue;
4522
+ const arms = d.parsers;
4523
+ const resolved = arms.map(resolveArm);
4524
+ const nodeTypes = resolved.map(wrappingNodeType);
4525
+ const fs = arms.map((a) => firstSetOf(a, /* @__PURE__ */ new Set(), resolve));
4526
+ for (let i = 0; i < arms.length; i++) {
4527
+ if (d.gates[i] != null) continue;
4528
+ const type = nodeTypes[i] ?? null;
4529
+ if (type === null) continue;
4530
+ if (nested(resolved[i]).size !== 0) continue;
4531
+ const A = fs[i];
4532
+ if (A.kind === "any") continue;
4533
+ for (let j = i + 1; j < arms.length; j++) {
4534
+ if (nodeTypes[j] !== type) continue;
4535
+ const B = fs[j];
4536
+ if (B.kind === "any" || !intersects3(A, B)) continue;
4537
+ const lost = nested(resolved[j]);
4538
+ if (lost.size === 0) continue;
4539
+ const lostNodeTypes = [...lost].sort();
4540
+ findings.push({
4541
+ kind: "structure-loss",
4542
+ id: `structure-loss:${siteToString(v2.site)}:${i}-${j}`,
4543
+ site: v2.site,
4544
+ nodeType: type,
4545
+ earlier: i,
4546
+ later: j,
4547
+ on: intersection2(A, B),
4548
+ lostNodeTypes: lostNodeTypes.slice(0, MAX_LOST_TYPES),
4549
+ earlierShape: clamp(render(resolved[i], 3)),
4550
+ laterShape: clamp(render(resolved[j], 3)),
4551
+ suggestion: structureLossSuggestion(type, i, j, lostNodeTypes)
4552
+ });
4553
+ }
4554
+ }
4555
+ }
4556
+ return findings.sort((a, b) => b.lostNodeTypes.length - a.lostNodeTypes.length);
4557
+ }
4558
+ function structureLossSuggestion(type, i, j, lost) {
4559
+ const names = lost.slice(0, MAX_LOST_TYPES).map((t) => `\`${t}\``).join(", ");
4560
+ const more = lost.length > MAX_LOST_TYPES ? ` (and ${lost.length - MAX_LOST_TYPES} more)` : "";
4561
+ return `arm[${i}] and arm[${j}] both build \`${type}\` and can start on the same character, so on every input BOTH accept, arm[${i}] wins \u2014 and arm[${i}] contains no \`node()\`, so it yields a \`${type}\` over bare leaves. arm[${j}] would have built ${names}${more} there. The parse still succeeds and the span is still right; only the tree shape changes, and only on the inputs arm[${i}] happens to accept \u2014 so a suite that asserts "it parses" and an output diff both stay green while every consumer of the tree (language service, formatter, refactor) silently loses those nodes on the commonest inputs. Decide which tree is the contract: if the structured one is, DELETE arm[${i}] or move it after arm[${j}]; if the flat one is, it must be flat for ALL inputs of this shape, not just the ones arm[${i}] matches \u2014 a fast path that is not tree-neutral is not a fast path.`;
4562
+ }
4563
+ function findKeywordRegexes(visited) {
4564
+ const armIndexOf = /* @__PURE__ */ new Map();
4565
+ for (const v2 of visited.values()) {
4566
+ if (v2.d.tag !== "choice") continue;
4567
+ v2.d.parsers.forEach((a, i) => {
4568
+ let cur = a;
4569
+ for (; ; ) {
4570
+ const cd = cur._def;
4571
+ if (cd.tag === "node" || cd.tag === "label" || cd.tag === "transform" || cd.tag === "leaf" || cd.tag === "attempt") {
4572
+ cur = cd.parser;
4573
+ continue;
4574
+ }
4575
+ break;
4576
+ }
4577
+ armIndexOf.set(cur, { owner: v2.p, index: i });
4578
+ });
4579
+ }
4580
+ const keywordArms = /* @__PURE__ */ new Map();
4581
+ for (const [arm, { owner }] of armIndexOf) {
4582
+ const d = arm._def;
4583
+ if (d.tag !== "regex" || keywordRegexShape(d.source) === null) continue;
4584
+ const list = keywordArms.get(owner) ?? [];
4585
+ list.push(armIndexOf.get(arm).index);
4586
+ keywordArms.set(owner, list);
4587
+ }
4588
+ const out = [];
4589
+ for (const v2 of visited.values()) {
4590
+ const d = v2.d;
4591
+ if (d.tag !== "regex") continue;
4592
+ const shape = keywordRegexShape(d.source);
4593
+ if (shape === null) continue;
4594
+ const ci = d.flags.includes("i");
4595
+ const unicode = d.flags.includes("u") || d.flags.includes("v");
4596
+ const own = armIndexOf.get(v2.p);
4597
+ const siblings = own === void 0 ? [] : (keywordArms.get(own.owner) ?? []).filter((i) => i !== own.index);
4598
+ const bq = (s) => `'${s.replace(/\\/g, "\\\\")}'`;
4599
+ const vocabulary = shape.words.length >= VOCABULARY_MIN_WORDS;
4600
+ const hazards = keywordAlternationHazards(shape.words, shape.boundary);
4601
+ const unrescued = hazards.filter((h) => !h.rescuedByBoundary);
4602
+ const longestFirst = shape.words.every((w, i) => i === 0 || shape.words[i - 1].length >= w.length);
4603
+ const shown = shape.words.length > 8 ? [...shape.words.slice(0, 6), "/* \u2026 */", shape.words[shape.words.length - 1]] : shape.words;
4604
+ const call = shape.words.length === 1 ? `word(${bq(shape.words[0])}, ${bq(shape.boundary ?? "_0-9A-Za-z")}${ci ? ", { caseInsensitive: true }" : ""})` : `keywords([${shown.map((w) => w === "/* \u2026 */" ? w : bq(w)).join(", ")}], { boundary: ${bq(shape.boundary ?? "_0-9A-Za-z")}${ci ? ", caseInsensitive: true" : ""} })`;
4605
+ out.push({
4606
+ kind: "keyword-regex",
4607
+ id: `keyword-regex:${siteToString(v2.site)}`,
4608
+ site: v2.site,
4609
+ source: d.source,
4610
+ flags: d.flags,
4611
+ words: shape.words,
4612
+ boundary: shape.boundary,
4613
+ caseFoldRisk: ci && !unicode,
4614
+ siblingArms: siblings,
4615
+ vocabulary,
4616
+ longestFirst,
4617
+ hazards,
4618
+ bug: unrescued.length > 0,
4619
+ suggestion: [
4620
+ vocabulary ? `this regex enumerates a FIXED VOCABULARY of ${shape.words.length} literal words${shape.words.length > 20 ? " \u2014 the alternation is long enough that nobody re-reads it" : ""}. A regex enumerating a fixed vocabulary is a keyword set written the hard way. Use \`${call}\` \u2014 it exposes an EXACT first-set that \`choice\` can dispatch on (the alternation exposes none), it owns the boundary so it is not re-spelled per word, it sorts LONGEST-FIRST by construction, and it lowers to a single charCodeAt scan.` : `\`regex(/${d.source}/${d.flags})\` hand-rolls a keyword and its word boundary. Use \`${call}\` \u2014 it owns the boundary (so \`${shape.boundary ?? ""}\` is not re-spelled per keyword), exposes an EXACT first-set that \`choice\` can dispatch on, and lowers to the same charCodeAt scan.`,
4621
+ unrescued.length > 0 ? `LIVE BUG \u2014 the alternation is ordered so ${unrescued.length} later alternative(s) can NEVER match: ${unrescued.slice(0, 3).map((h) => `\`${h.shorter}\` precedes \`${h.longer}\` and nothing rejects the following \`${h.at}\``).join("; ")}${unrescued.length > 3 ? `, and ${unrescued.length - 3} more` : ""}. Regex alternation is FIRST-match, not longest-match, and with no boundary guard to force a backtrack the shorter branch wins outright. \`keywords()\` cannot express this bug: it sorts longest-first.` : hazards.length > 0 ? `ORDERING HAZARD (currently harmless): ${hazards.length} earlier alternative(s) are strict prefixes of later ones \u2014 e.g. \`${hazards[0].shorter}\` before \`${hazards[0].longer}\`. Regex alternation is first-match, so these work ONLY because the trailing boundary guard rejects the next character and the engine backtracks into the longer branch. That correctness rests on a hand-maintained order plus a guard that happens to cover the right characters; \`keywords()\` sorts longest-first by construction and does not depend on either.` : !longestFirst && vocabulary ? `The alternation is not in longest-first order. It is correct today${shape.boundary === null ? "" : " (the boundary guard forces a backtrack where it matters)"}, but the ordering is hand-maintained and nothing checks it \u2014 adding a word that extends an existing one is a silent bug. \`keywords()\` sorts longest-first by construction.` : "",
4622
+ ci && !unicode ? `CORRECTNESS, not style: \`/i\` without \`/u\` does NOT fold case the way \`{c, toUpperCase(c), toLowerCase(c)}\` suggests \u2014 67 BMP code points fold in ways those three miss (\u03C2/\u03C3, \xB5/\u03BC, the \u01C4\u01C5\u01C6 digraphs, combining iota subscript). parseman fixed exactly this INSIDE \`keywords()\` (see combinators/case-fold.ts); a hand-rolled copy never received the fix, so its first-set is unsound for those inputs.` : "",
4623
+ siblings.length > 0 ? `arm[${own?.index}] here sits alongside arm(s) [${siblings.join(", ")}] in the same \`choice\`, which are also hand-rolled keywords. A single \`keywords([\u2026])\` replaces the whole group and gives longest-match dispatch across it \u2014 which is what that combinator exists for.` : ""
4624
+ ].filter((s) => s !== "").join(" ")
4625
+ });
4626
+ }
4627
+ return out.sort((a, b) => Number(b.bug) - Number(a.bug) || b.hazards.length - a.hazards.length || b.words.length - a.words.length || b.siblingArms.length - a.siblingArms.length);
4628
+ }
4629
+ var sitesLine = (sites) => sites.slice(0, 6).map(siteToString).join("\n ") + (sites.length > 6 ? `
4630
+ \u2026 and ${sites.length - 6} more` : "");
4631
+ function formatDuplicationFindings(report) {
4632
+ const lines = [];
4633
+ for (const f of report.structureLoss) {
4634
+ lines.push(`parseman BUG [structure-loss] @ ${siteToString(f.site)}: arm[${f.earlier}] flattens \`${f.nodeType}\` where arm[${f.later}] structures it, on ${firstSetToString(f.on)}`);
4635
+ lines.push(` flat: ${f.earlierShape}`);
4636
+ lines.push(` rich: ${f.laterShape}`);
4637
+ lines.push(` lost: ${f.lostNodeTypes.map((t) => `\`${t}\``).join(", ")}`);
4638
+ lines.push(` fix: ${f.suggestion}`);
4639
+ }
4640
+ for (const f of report.rewrites) {
4641
+ lines.push(`parseman ${f.bug ? "BUG" : "rewrite"} [${f.rewrite}${f.sepByVerdict === void 0 ? "" : `/${f.sepByVerdict}`}] @ ${siteToString(f.site)}${f.astNeutral ? "" : " (candidate \u2014 verify AST identity)"}`);
4642
+ lines.push(` from: ${f.from}`);
4643
+ lines.push(` to: ${f.to}`);
4644
+ lines.push(` fix: ${f.suggestion}`);
4645
+ if (f.perf !== "") lines.push(` perf: ${f.perf}`);
4646
+ }
4647
+ for (const f of report.divergentNodes) {
4648
+ lines.push(`parseman divergent-node: \`${f.nodeType}\` is built by ${f.count} different productions sharing ${f.sharedTerms.length} term(s): ${f.sharedTerms.map((t) => `\`${t}\``).join(", ")}`);
4649
+ for (const p of f.productions) lines.push(` ${siteToString(p.site)}
4650
+ ${p.shape}
4651
+ unique to it: ${p.distinctTerms.length === 0 ? "(none)" : p.distinctTerms.map((t) => `\`${t}\``).join(", ")}`);
4652
+ lines.push(` fix: ${f.suggestion}`);
4653
+ }
4654
+ for (const f of report.keywordRegexes) {
4655
+ const src = f.source.length > 90 ? `${f.source.slice(0, 88)}\u2026` : f.source;
4656
+ lines.push(`parseman ${f.bug ? "BUG " : ""}keyword-regex @ ${siteToString(f.site)}: /${src}/${f.flags}${f.vocabulary ? ` [${f.words.length} literal words${f.longestFirst ? "" : ", NOT longest-first"}]` : ""}${f.hazards.length > 0 ? ` [${f.hazards.length} prefix hazard(s)${f.bug ? ", UNRESCUED" : ""}]` : ""}${f.caseFoldRisk ? " [CASE-FOLD RISK]" : ""}`);
4657
+ lines.push(` fix: ${f.suggestion}`);
4658
+ }
4659
+ for (const f of report.duplicates) {
4660
+ lines.push(`parseman duplication: ${f.count}\xD7 identical ${f.size}-node shape (saves ${f.savings} nodes) \u2014 ${f.shape}`);
4661
+ lines.push(` at: ${sitesLine(f.sites)}`);
4662
+ lines.push(` fix: ${f.suggestion}`);
4663
+ }
4664
+ for (const f of report.nearDuplicates) {
4665
+ lines.push(`parseman near-duplication: ${f.count} clones of a ${f.sharedSize}-node shape differing at ONE slot (saves ${f.savings} nodes) \u2014 ${f.shape}`);
4666
+ lines.push(` slot: ${f.slotPath} \u2208 { ${f.variants.join(" | ")} }`);
4667
+ lines.push(` at: ${sitesLine(f.sites)}`);
4668
+ lines.push(` fix: ${f.suggestion}`);
4669
+ }
4670
+ for (const f of report.regexFragments) {
4671
+ lines.push(`parseman regex-fragment: \`${f.fragment}\` (${f.branches} branches) re-spelled in ${f.count} regex() terminals`);
4672
+ lines.push(` at: ${sitesLine(f.sites)}`);
4673
+ lines.push(` fix: ${f.suggestion}`);
4674
+ }
4675
+ for (const f of report.regexClasses) {
4676
+ lines.push(`parseman regex-class${f.drifted ? " DRIFT" : ""}: [${f.canonical}] across ${f.count} regex() terminals${f.bmpCeiling ? " [BMP ceiling]" : ""}`);
4677
+ for (const v2 of f.variants) lines.push(` [${v2.source}] \xD7${v2.count} ${v2.delta}
4678
+ ${sitesLine(v2.sites)}`);
4679
+ lines.push(` fix: ${f.suggestion}`);
4680
+ }
4681
+ for (const f of report.overlaps) {
4682
+ lines.push(`parseman overlap @ ${siteToString(f.site)}: arm[${f.a}] \u2229 arm[${f.b}] on ${firstSetToString(f.on)}${f.sharedPrefix === null ? "" : ` \u2014 shared prefix: ${f.sharedPrefix}`}${f.handledByStrategy ? " [sharedPrefix strategy already handles this]" : ""}`);
4683
+ lines.push(` fix: ${f.suggestion}`);
4684
+ }
4685
+ return lines;
4686
+ }
4687
+ function duplicationFindingCount(report) {
4688
+ return report.duplicates.length + report.nearDuplicates.length + report.regexFragments.length + report.regexClasses.length + report.overlaps.length + report.rewrites.length + report.divergentNodes.length + report.structureLoss.length + report.keywordRegexes.length;
4689
+ }
4690
+
3319
4691
  // src/combinators/scanTo.ts
3320
4692
  function resolveScanSkip(explicitSkip, raw, ctx) {
3321
4693
  if (raw) return [];
@@ -6536,12 +7908,14 @@ function emitNode(def, ctx, pos) {
6536
7908
  ctx.buildSrcs.push(def.buildSrc ?? null);
6537
7909
  }
6538
7910
  const i = ind(ctx);
6539
- const capturesTrivia = mkType !== null || def.captureTrivia === true || def.trailingTrivia === true || !structural && buildReadsTrivia(def);
6540
- const clonesState = !structural && buildReadsState(def);
6541
- const capturesChildren = !structural && (mkType !== null || def.unwrap || def.collapse || buildReadsChildren(def));
6542
- const capturesRaw = !structural && (mkType !== null || buildReadsRaw(def));
7911
+ const cstOut = ctx.hostMode === "cst" && !structural;
7912
+ if (!structural && !cstOut) ctx.hostBranchElided = true;
7913
+ const capturesTrivia = cstOut || mkType !== null || def.captureTrivia === true || def.trailingTrivia === true || !structural && buildReadsTrivia(def);
7914
+ const clonesState = !structural && (cstOut || buildReadsState(def));
7915
+ const capturesChildren = !structural && (cstOut || mkType !== null || def.unwrap || def.collapse || buildReadsChildren(def));
7916
+ const capturesRaw = !structural && (cstOut || mkType !== null || buildReadsRaw(def));
6543
7917
  const hasFields = parserHasOwnFields(def.parser);
6544
- const capturesFields = hasFields && !structural && buildReadsFields(def);
7918
+ const capturesFields = hasFields && !structural && (cstOut || buildReadsFields(def));
6545
7919
  const innerEnablesTriviaCapture = parserEnablesTriviaCapture(def.parser);
6546
7920
  const chV = v(ctx, "_ch");
6547
7921
  const rawV = v(ctx, "_raw");
@@ -6558,8 +7932,9 @@ function emitNode(def, ctx, pos) {
6558
7932
  const profHoist = `${i}const ${pmV} = _ctx._pmProfile, ${recV} = ${pmV}?.phase === 'recognizer', ${capV} = ${pmV}?.phase === 'capture'`;
6559
7933
  const profileRecognizer = recV;
6560
7934
  const profileCapture = capV;
6561
- const directCstV = !structural && (!capturesChildren || !capturesRaw) ? v(ctx, "_dcst") : null;
6562
- const directCstGate = directCstV === null ? "false" : `${profileCapture} || _ctx.build?._parsemanCstOutput === true`;
7935
+ const cstMode = ctx.hostMode === "cst";
7936
+ const directCstV = !structural && !cstMode && (!capturesChildren || !capturesRaw) ? v(ctx, "_dcst") : null;
7937
+ const directCstGate = directCstV === null ? "false" : profileCapture;
6563
7938
  const structuralCapturesTrivia = structural && (def.captureTrivia === true || def.trailingTrivia === true);
6564
7939
  const chNeededExpr = def.unwrap || def.collapse ? "true" : `(_ctx._pmReadsCh ??= (_ctx.build === undefined || _ctx.build._parsemanReadsChildren !== false || _ctx.build._parsemanCstCollapse !== undefined))`;
6565
7940
  const chAlloc = def.unwrap || def.collapse ? `${profileRecognizer} ? undefined : []` : `${profileRecognizer} ? undefined : ((${profileCapture} || ${chNeededExpr}) ? [] : undefined)`;
@@ -6640,7 +8015,7 @@ ${i}const ${chV} = ${chAlloc}, ${rawV} = ${profileRecognizer} ? undefined : [],
6640
8015
  const ndV = v(ctx, "_nd");
6641
8016
  const buildExpr = structural ? `{ _tag: 'node', type: ${JSON.stringify(def.type)}, span: { start: ${pos}, end: ${endVar} }, state: ${stV} ?? null, children: ${chV} }` : mkType ? emitInlineMkNodeExpr(mkType, chV, rawV, pos, endVar, tlV) : `${buildRef(ctx)}[${buildIdx}](${chV}, ${fObj}, { start: ${pos}, end: ${endVar} }, ${rawV}, ${tlV}, ${stV})`;
6642
8017
  const hostBuildExpr = `_ctx._pmProfile?.phase === 'host' && _ctx._pmProfile.hostCalls++, _ctx.build(${JSON.stringify(def.type)}, ${chV}, ${fObj}, { start: ${pos}, end: ${endVar} }, ${rawV}, ${tlV}, ${stV})`;
6643
- const ndExpr = structural ? `_ctx.build !== undefined ? (${hostBuildExpr}) : (${buildExpr})` : `_ctx.build?._parsemanCstOutput === true ? (${hostBuildExpr}) : (${buildExpr})`;
8018
+ const ndExpr = structural ? `_ctx.build !== undefined ? (${hostBuildExpr}) : (${buildExpr})` : cstOut ? hostBuildExpr : buildExpr;
6644
8019
  const hostCollapseExpr = structural ? `_ctx.build !== undefined && _ctx.build._parsemanCstCollapse !== undefined && ${chV}.length === 1 && ${rawV}.length === 1 && _ctx.build._parsemanCstCollapse(${JSON.stringify(def.type)}, ${chV}[0], ${chV}, ${rawV}) ? ${chV}[0] : (${ndExpr})` : ndExpr;
6645
8020
  const unwrapExpr = `${chV}.length === 1 ? (${chV}[0] !== null && typeof ${chV}[0] === 'object' && ${chV}[0]._tag === 'leaf' ? ${chV}[0].value : ${chV}[0]) : (${ndExpr})`;
6646
8021
  const collapseExpr = `${chV}.length === 1 ? ${chV}[0] : (${ndExpr})`;
@@ -7089,9 +8464,9 @@ function hasDirectBuildDef(p, seen = /* @__PURE__ */ new Set()) {
7089
8464
  return false;
7090
8465
  }
7091
8466
  }
7092
- return childrenOf(d).some((child) => hasDirectBuildDef(child, seen));
8467
+ return childrenOf2(d).some((child) => hasDirectBuildDef(child, seen));
7093
8468
  }
7094
- function childrenOf(def) {
8469
+ function childrenOf2(def) {
7095
8470
  switch (def.tag) {
7096
8471
  case "sequence":
7097
8472
  case "choice":
@@ -7162,7 +8537,7 @@ function subtreeSizes(roots) {
7162
8537
  const def = p._def;
7163
8538
  let s = 1;
7164
8539
  if (def.tag !== "lazy") {
7165
- for (const child of childrenOf(def)) s += child._def.tag === "lazy" ? 1 : sz(child);
8540
+ for (const child of childrenOf2(def)) s += child._def.tag === "lazy" ? 1 : sz(child);
7166
8541
  }
7167
8542
  sizes.set(p, s);
7168
8543
  return s;
@@ -7183,7 +8558,7 @@ function subtreeSizes(roots) {
7183
8558
  return;
7184
8559
  }
7185
8560
  sz(p);
7186
- for (const child of childrenOf(def)) visit(child);
8561
+ for (const child of childrenOf2(def)) visit(child);
7187
8562
  };
7188
8563
  for (const root of roots) visit(root);
7189
8564
  return sizes;
@@ -7217,7 +8592,7 @@ function analyzeLazyUsageMulti(roots) {
7217
8592
  if (isHoistableTag(def.tag)) {
7218
8593
  counts.set(p, (counts.get(p) ?? 0) + 1);
7219
8594
  }
7220
- for (const child of childrenOf(def)) walk2(child);
8595
+ for (const child of childrenOf2(def)) walk2(child);
7221
8596
  }
7222
8597
  const rootsArr = [...roots];
7223
8598
  for (const root of rootsArr) walk2(root);
@@ -7232,7 +8607,7 @@ function ruleDependencies(ruleMap) {
7232
8607
  const seen = /* @__PURE__ */ new Set();
7233
8608
  const walk2 = (p, isRoot) => {
7234
8609
  const def = p._def;
7235
- const boundary = def.tag === "lazy" ? p._ruleName ?? nameOf.get(p) : nameOf.get(p);
8610
+ const boundary = def.tag === "lazy" ? nameOf.get(p) ?? p._ruleName : nameOf.get(p);
7236
8611
  if (!isRoot && boundary !== void 0) {
7237
8612
  found.add(boundary);
7238
8613
  return;
@@ -7249,7 +8624,7 @@ function ruleDependencies(ruleMap) {
7249
8624
  walk2(resolved, false);
7250
8625
  return;
7251
8626
  }
7252
- for (const child of childrenOf(def)) walk2(child, false);
8627
+ for (const child of childrenOf2(def)) walk2(child, false);
7253
8628
  };
7254
8629
  walk2(comb, true);
7255
8630
  deps.set(name, [...found]);
@@ -7269,10 +8644,30 @@ function runGatingDiagnostic(combinator, opt) {
7269
8644
  function runGatingDiagnosticRules(ruleMap, opt) {
7270
8645
  return reportGating(opt, (o) => analyzeGatingRules(ruleMap, o));
7271
8646
  }
7272
- function runFusedGatingDiagnostic(getRuleMaps, opt) {
8647
+ function runFusedGatingDiagnostic(getRuleMaps, opt, getOpaque) {
7273
8648
  if (resolveGatingLevel(opt) === "off") return void 0;
7274
8649
  const ruleMaps = getRuleMaps();
7275
- if (!ruleMaps.some((map) => hasExternalRuleRef(map))) return void 0;
8650
+ const opaque = getOpaque?.() ?? [];
8651
+ const opaqueFindings = opaque.map((o) => ({
8652
+ rule: o.ruleNames.length > 0 ? o.ruleNames.join(", ") : `<artifact ${o.ns}>`,
8653
+ kind: "opaque-artifact",
8654
+ reason: `precompiled artifact "${o.ns}" carries compiled rule functions, not re-lowerable IR \u2014 its choices were not examined at this fuse.`
8655
+ }));
8656
+ if (!ruleMaps.some((map) => hasExternalRuleRef(map))) {
8657
+ if (opaqueFindings.length === 0) return void 0;
8658
+ return reportGating(opt, () => ({
8659
+ totalChoices: 0,
8660
+ gated: 0,
8661
+ recoverable: 0,
8662
+ unanalysable: opaqueFindings,
8663
+ ungated: [],
8664
+ accepted: [],
8665
+ deferred: [],
8666
+ acceptedUnused: [],
8667
+ choices: [],
8668
+ antiPatterns: []
8669
+ }));
8670
+ }
7276
8671
  const winners = /* @__PURE__ */ new Map();
7277
8672
  for (const map of ruleMaps) for (const [name, rule] of map) {
7278
8673
  if (rule._def.tag === "lazy") {
@@ -7288,7 +8683,12 @@ function runFusedGatingDiagnostic(getRuleMaps, opt) {
7288
8683
  return reportGating(opt, (o) => {
7289
8684
  const deferredHere = new Set(analyzeGatingRules(entries, o).deferred.map((c) => c.id));
7290
8685
  const fused = analyzeGatingRules(entries, { ...o, resolveRef: (name) => winners.get(name) });
7291
- return { ...fused, ungated: fused.ungated.filter((c) => deferredHere.has(c.id)), antiPatterns: [] };
8686
+ return {
8687
+ ...fused,
8688
+ ungated: fused.ungated.filter((c) => deferredHere.has(c.id)),
8689
+ antiPatterns: [],
8690
+ unanalysable: [...fused.unanalysable, ...opaqueFindings]
8691
+ };
7292
8692
  });
7293
8693
  }
7294
8694
  function reportGating(opt, analyze) {
@@ -7299,22 +8699,82 @@ function reportGating(opt, analyze) {
7299
8699
  let report;
7300
8700
  try {
7301
8701
  report = analyze(analyzeOpts);
7302
- } catch {
7303
- return void 0;
8702
+ } catch (e) {
8703
+ const reason = e instanceof Error ? `${e.name}: ${e.message}` : String(e);
8704
+ const failed = {
8705
+ totalChoices: 0,
8706
+ gated: 0,
8707
+ recoverable: 0,
8708
+ unanalysable: [{ rule: "<whole grammar>", kind: "not-a-combinator", reason: `gating analysis threw \u2014 ${reason}` }],
8709
+ ungated: [],
8710
+ accepted: [],
8711
+ deferred: [],
8712
+ acceptedUnused: [],
8713
+ choices: [],
8714
+ antiPatterns: []
8715
+ };
8716
+ const failLines = formatGatingWarnings(failed);
8717
+ if (level === "error") throw new Error(`parseman: gating analysis FAILED (no verdict available)
8718
+ ${failLines.join("\n")}`);
8719
+ for (const l of failLines) console.warn(l);
8720
+ return failed;
7304
8721
  }
7305
8722
  const lines = formatGatingWarnings(report);
7306
8723
  if (lines.length > 0) {
7307
- if (level === "error") throw new Error(`parseman: ${report.ungated.length} ungated hot choice(s) / ${report.antiPatterns.length} anti-pattern(s)
8724
+ if (level === "error") throw new Error(`parseman: ${report.ungated.length} ungated hot choice(s) / ${report.antiPatterns.length} anti-pattern(s) / ${report.unanalysable.length} unanalysable rule(s)
8725
+ ${lines.join("\n")}`);
8726
+ for (const l of lines) console.warn(l);
8727
+ }
8728
+ return report;
8729
+ }
8730
+ function resolveDuplicationLevel(opt) {
8731
+ const explicit = typeof opt === "string" ? opt : opt?.level;
8732
+ if (explicit !== void 0) return explicit;
8733
+ const env = typeof process !== "undefined" ? process.env?.PARSEMAN_DUPLICATION : void 0;
8734
+ if (env === "off" || env === "warn" || env === "error") return env;
8735
+ return "off";
8736
+ }
8737
+ function reportDuplication(opt, analyze) {
8738
+ const level = resolveDuplicationLevel(opt);
8739
+ if (level === "off") return void 0;
8740
+ const obj = opt !== null && typeof opt === "object" ? opt : void 0;
8741
+ const analyzeOpts = obj === void 0 ? void 0 : {
8742
+ ...obj.accept !== void 0 ? { accept: obj.accept } : {},
8743
+ ...obj.minSize !== void 0 ? { minSize: obj.minSize } : {},
8744
+ ...obj.maxFindings !== void 0 ? { maxFindings: obj.maxFindings } : {},
8745
+ ...obj.entryName !== void 0 ? { entryName: obj.entryName } : {}
8746
+ };
8747
+ let report;
8748
+ try {
8749
+ report = analyze(analyzeOpts);
8750
+ } catch (err) {
8751
+ console.warn(
8752
+ `parseman: the duplication diagnostic could not run, so NOTHING was checked \u2014 this is not a clean result.
8753
+ ${err instanceof Error ? err.message : String(err)}`
8754
+ );
8755
+ return void 0;
8756
+ }
8757
+ const lines = formatDuplicationFindings(report);
8758
+ if (lines.length > 0) {
8759
+ if (level === "error") throw new Error(`parseman: ${duplicationFindingCount(report)} duplication/overlap finding(s)
7308
8760
  ${lines.join("\n")}`);
7309
8761
  for (const l of lines) console.warn(l);
7310
8762
  }
7311
8763
  return report;
7312
8764
  }
8765
+ function runDuplicationDiagnostic(combinator, opt) {
8766
+ return reportDuplication(opt, (o) => analyzeDuplication(combinator, o));
8767
+ }
8768
+ function runDuplicationDiagnosticRules(ruleMap, opt) {
8769
+ return reportDuplication(opt, (o) => analyzeDuplicationRules(ruleMap, o));
8770
+ }
7313
8771
  function compile(combinator, mapFnSources, opts) {
7314
8772
  const gatingReport = runGatingDiagnostic(combinator, opts?.gating);
8773
+ runDuplicationDiagnostic(combinator, opts?.duplication);
7315
8774
  markUnusedValues(combinator);
7316
8775
  const grammarTrivia = combinator._meta.grammarTrivia;
7317
8776
  const grammarScanSkip = combinator._meta.grammarScanSkip;
8777
+ const grammarHostMode = combinator._meta.grammarHostMode;
7318
8778
  const ctx = {
7319
8779
  vars: 0,
7320
8780
  indent: 1,
@@ -7334,6 +8794,7 @@ function compile(combinator, mapFnSources, opts) {
7334
8794
  namedFnDecls: [],
7335
8795
  capturing: hasNodeDef(combinator),
7336
8796
  recovery: opts?.recovery ?? false,
8797
+ hostMode: opts?.hostMode ?? grammarHostMode ?? "ast",
7337
8798
  ...opts?.coverage ? { coverage: { plan: buildGrammarPlan(combinator), entry: combinator } } : {},
7338
8799
  lazyUsage: analyzeLazyUsage(combinator),
7339
8800
  ...grammarTrivia ? { activeTrivia: grammarTrivia, triviaKindLabels: grammarTrivia._meta.triviaKindLabels } : {},
@@ -7406,6 +8867,7 @@ function compile(combinator, mapFnSources, opts) {
7406
8867
  return fn(input, pos, ctx.runtimeParsers, ctx.mapFns, ctx.buildFns, defaultCtx);
7407
8868
  },
7408
8869
  parseWithContext(input, parseCtx, pos = 0) {
8870
+ assertHostModeCompatible(ctx.hostMode ?? "ast", parseCtx.build, !!ctx.hostBranchElided);
7409
8871
  return fn(input, pos, ctx.runtimeParsers, ctx.mapFns, ctx.buildFns, parseCtx);
7410
8872
  },
7411
8873
  // Note: collects expect() errors via _errors. Unlike interpreter
@@ -7436,9 +8898,11 @@ function publicRuleWrapperSource(rule, fnSource) {
7436
8898
  function compileLinkable(ruleMapArg, ns, opts) {
7437
8899
  if (!ns) throw new Error("compileLinkable: ns must be a non-empty namespace");
7438
8900
  if (opts?.gating !== void 0) runGatingDiagnosticRules(ruleMapArg, opts.gating);
8901
+ if (opts?.duplication !== void 0) runDuplicationDiagnosticRules(ruleMapArg, opts.duplication);
7439
8902
  for (const [, rule] of ruleMapArg) markUnusedValues(rule);
7440
8903
  const grammarTrivia = opts?.trivia ?? ruleMapArg.map(([, r]) => r._meta.grammarTrivia).find(Boolean);
7441
8904
  const grammarScanSkip = opts?.scanSkip ?? ruleMapArg.map(([, r]) => r._meta.grammarScanSkip).find(Boolean);
8905
+ const grammarHostMode = opts?.hostMode ?? ruleMapArg.map(([, r]) => r._meta.grammarHostMode).find(Boolean);
7442
8906
  const ruleMap = ruleMapArg.filter(([, val]) => {
7443
8907
  const d = val._def;
7444
8908
  if (d.tag !== "lazy") return true;
@@ -7472,6 +8936,7 @@ function compileLinkable(ruleMapArg, ns, opts) {
7472
8936
  // merely returning their scalar parse values.
7473
8937
  capturing: opts?.captureTerminals === true || ruleMap.some(([, rule]) => hasNodeDef(rule)),
7474
8938
  recovery: opts?.recovery ?? false,
8939
+ hostMode: grammarHostMode ?? "ast",
7475
8940
  ...opts?.coverage ? { coverage: { plan: opts.coverage } } : {},
7476
8941
  lazyUsage: analyzeLazyUsageMulti(ruleMap.map(([, rule]) => rule)),
7477
8942
  ns,
@@ -7522,7 +8987,7 @@ function compileLinkable(ruleMapArg, ns, opts) {
7522
8987
  scanExternal(resolved);
7523
8988
  return;
7524
8989
  }
7525
- for (const child of childrenOf(def)) scanExternal(child);
8990
+ for (const child of childrenOf2(def)) scanExternal(child);
7526
8991
  };
7527
8992
  for (const [, rule] of ruleMap) scanExternal(rule);
7528
8993
  }
@@ -7627,6 +9092,8 @@ function compileLinkable(ruleMapArg, ns, opts) {
7627
9092
  deps: ruleDependencies(ruleMap),
7628
9093
  needsEmptyTl: !!ctx.needsEmptyTl,
7629
9094
  needsHostReads: !!ctx.needsHostReads,
9095
+ hostMode: ctx.hostMode ?? "ast",
9096
+ hostBranchElided: !!ctx.hostBranchElided,
7630
9097
  hasDirectBuilders: ruleMap.some(([, rule]) => hasDirectBuildDef(rule)),
7631
9098
  isRecognitionOnly: !hasSemanticReduction(ruleMap.map(([, rule]) => rule), externalRefs),
7632
9099
  mfFns: mfSrcs ? [] : ctx.mapFns,
@@ -7649,7 +9116,7 @@ function hasExternalRuleRef(ruleMap) {
7649
9116
  if (resolved === void 0) return p._ruleName !== void 0;
7650
9117
  return visit(resolved);
7651
9118
  }
7652
- return childrenOf(def).some(visit);
9119
+ return childrenOf2(def).some(visit);
7653
9120
  };
7654
9121
  return ruleMap.some(([, rule]) => visit(rule));
7655
9122
  }
@@ -7671,7 +9138,7 @@ function hasSemanticReduction(roots, externalRefs) {
7671
9138
  return true;
7672
9139
  }
7673
9140
  }
7674
- return childrenOf(def).some(visit);
9141
+ return childrenOf2(def).some(visit);
7675
9142
  };
7676
9143
  return roots.some(visit);
7677
9144
  }
@@ -7740,7 +9207,7 @@ function withCtx(extra, combinator) {
7740
9207
  }
7741
9208
 
7742
9209
  // src/compiler/ir-serialize.ts
7743
- function childrenOf2(def) {
9210
+ function childrenOf3(def) {
7744
9211
  switch (def.tag) {
7745
9212
  case "sequence":
7746
9213
  case "choice":
@@ -7779,7 +9246,7 @@ function childrenOf2(def) {
7779
9246
  return [];
7780
9247
  }
7781
9248
  }
7782
- var ruleNameOf2 = (c) => c._ruleName;
9249
+ var ruleNameOf4 = (c) => c._ruleName;
7783
9250
  function lazyTarget(c) {
7784
9251
  if (c._def.tag !== "lazy") return null;
7785
9252
  try {
@@ -7961,12 +9428,16 @@ var Serializer = class {
7961
9428
  emitted = /* @__PURE__ */ new Set();
7962
9429
  decls = [];
7963
9430
  ruleValues = /* @__PURE__ */ new Set();
9431
+ localRuleNames = /* @__PURE__ */ new Map();
7964
9432
  ruleMap;
7965
9433
  scanSkip;
7966
9434
  constructor(ruleMap, scanSkip = []) {
7967
9435
  this.ruleMap = ruleMap;
7968
9436
  this.scanSkip = scanSkip;
7969
- for (const [, c] of ruleMap) this.ruleValues.add(this.body(c));
9437
+ for (const [name, c] of ruleMap) {
9438
+ this.localRuleNames.set(c, name);
9439
+ this.ruleValues.add(this.body(c));
9440
+ }
7970
9441
  }
7971
9442
  /** A grammar-level `scanSkip` unit is an opaque TERMINAL (a string, a balanced
7972
9443
  * bracket) — it must not reach a grammar-rule ref (`g[name]`), because the
@@ -7977,18 +9448,18 @@ var Serializer = class {
7977
9448
  if (seen.has(c)) return;
7978
9449
  seen.add(c);
7979
9450
  if (c._def.tag === "lazy") {
7980
- if (ruleNameOf2(c) !== void 0) throw new Unserializable("scanSkip unit references a grammar rule");
9451
+ if (ruleNameOf4(c) !== void 0) throw new Unserializable("scanSkip unit references a grammar rule");
7981
9452
  const target = lazyTarget(c);
7982
9453
  if (target) this.assertNoRuleRef(target, seen);
7983
9454
  return;
7984
9455
  }
7985
- for (const child of childrenOf2(c._def)) this.assertNoRuleRef(child, seen);
9456
+ for (const child of childrenOf3(c._def)) this.assertNoRuleRef(child, seen);
7986
9457
  }
7987
9458
  /** A `rules()` map wraps each rule value in a named `lazy` proxy; the real body is
7988
9459
  * its target (compileLinkable unwraps the same way). A ref INSIDE a body stays a
7989
9460
  * `g[name]` ref — only the top-level entry is unwrapped. */
7990
9461
  body(c) {
7991
- return c._def.tag === "lazy" && ruleNameOf2(c) !== void 0 ? lazyTarget(c) ?? c : c;
9462
+ return c._def.tag === "lazy" && ruleNameOf4(c) !== void 0 ? lazyTarget(c) ?? c : c;
7992
9463
  }
7993
9464
  run() {
7994
9465
  for (const [, c] of this.ruleMap) this.analyze(this.body(c), /* @__PURE__ */ new Set());
@@ -8030,7 +9501,7 @@ ${outerDecls.map((d) => " " + d).join("\n")}
8030
9501
  analyze(c, active) {
8031
9502
  const def = c._def;
8032
9503
  if (def.tag === "lazy") {
8033
- if (ruleNameOf2(c) !== void 0) return;
9504
+ if (ruleNameOf4(c) !== void 0) return;
8034
9505
  const target = lazyTarget(c);
8035
9506
  if (!target) throw new Unserializable("unresolved unnamed ref");
8036
9507
  if (active.has(target)) {
@@ -8046,7 +9517,7 @@ ${outerDecls.map((d) => " " + d).join("\n")}
8046
9517
  this.counts.set(c, (this.counts.get(c) ?? 0) + 1);
8047
9518
  if ((this.counts.get(c) ?? 0) > 1) return;
8048
9519
  active.add(c);
8049
- for (const child of childrenOf2(def)) this.analyze(child, active);
9520
+ for (const child of childrenOf3(def)) this.analyze(child, active);
8050
9521
  active.delete(c);
8051
9522
  }
8052
9523
  /** Reference `c`: a const var if it has one (emitting its decl first), else inline.
@@ -8082,7 +9553,8 @@ ${outerDecls.map((d) => " " + d).join("\n")}
8082
9553
  const kid = (x) => this.ref(x, selfOf);
8083
9554
  switch (def.tag) {
8084
9555
  case "lazy": {
8085
- if (ruleNameOf2(c) !== void 0) return `g[${JSON.stringify(ruleNameOf2(c))}]`;
9556
+ const name = this.localRuleNames.get(c) ?? ruleNameOf4(c);
9557
+ if (name !== void 0) return `g[${JSON.stringify(name)}]`;
8086
9558
  const target = lazyTarget(c);
8087
9559
  if (selfOf && target === selfOf.comb) return selfOf.var;
8088
9560
  return this.ref(target, selfOf);
@@ -8182,8 +9654,12 @@ ${outerDecls.map((d) => " " + d).join("\n")}
8182
9654
 
8183
9655
  // src/compiler/linker.ts
8184
9656
  var _nsCounter = 0;
8185
- function linkable(rulesMap, ns, trivia2) {
8186
- const pieces = compileLinkable(Object.entries(rulesMap), ns ?? `_lk${_nsCounter++}_`, trivia2 ? { trivia: trivia2 } : void 0);
9657
+ function linkable(rulesMap, ns, trivia2, hostMode) {
9658
+ const pieces = compileLinkable(
9659
+ Object.entries(rulesMap),
9660
+ ns ?? `_lk${_nsCounter++}_`,
9661
+ { ...trivia2 ? { trivia: trivia2 } : {}, ...hostMode ? { hostMode } : {} }
9662
+ );
8187
9663
  if (!pieces) throw new Error("linkable(): this grammar cannot be compiled to a linkable artifact (contains a runtime-only parser fallback)");
8188
9664
  return pieces;
8189
9665
  }
@@ -8245,7 +9721,23 @@ function fusedBody(pieces) {
8245
9721
  ...[...winner].map(([k, p]) => p.ruleFns.get(k))
8246
9722
  ];
8247
9723
  const wrapperEntries = [...winner].map(([k, p]) => `${JSON.stringify(k)}: ${p.wrappers.get(k)}`);
8248
- const rawBody = [...lines, "return {", wrapperEntries.join(",\n"), "}"].join("\n");
9724
+ const { mode, elided } = hostModeOfPieces(pieces);
9725
+ const rawBody = [
9726
+ ...lines,
9727
+ "const _map = {",
9728
+ wrapperEntries.join(",\n"),
9729
+ "}",
9730
+ `Object.defineProperty(_map, Symbol.for(${JSON.stringify(FUSED_HOST_MODE.description)}), { value: ${JSON.stringify(mode)}, enumerable: false })`,
9731
+ `Object.defineProperty(_map, Symbol.for(${JSON.stringify(FUSED_HOST_ELIDED.description)}), { value: ${JSON.stringify(elided)}, enumerable: false })`,
9732
+ // …and on each rule FUNCTION, because `run(map.Rule, …)` is handed the rule, not the
9733
+ // map, and would otherwise have nothing to check against. Done once at fuse time,
9734
+ // before any rule has been called, so it costs nothing per parse.
9735
+ "for (const _k of Object.keys(_map)) {",
9736
+ ` Object.defineProperty(_map[_k], Symbol.for(${JSON.stringify(FUSED_HOST_MODE.description)}), { value: ${JSON.stringify(mode)}, enumerable: false })`,
9737
+ ` Object.defineProperty(_map[_k], Symbol.for(${JSON.stringify(FUSED_HOST_ELIDED.description)}), { value: ${JSON.stringify(elided)}, enumerable: false })`,
9738
+ "}",
9739
+ "return _map"
9740
+ ].join("\n");
8249
9741
  const ANY2 = { kind: "any" };
8250
9742
  const EMPTY3 = { kind: "empty" };
8251
9743
  const recipes = /* @__PURE__ */ new Map();
@@ -8304,19 +9796,78 @@ function fusedBody(pieces) {
8304
9796
  }
8305
9797
  return { body, env };
8306
9798
  }
9799
+ function hostModeOfPieces(pieces) {
9800
+ const mode = pieces.find((p) => p.hostMode !== void 0 && p.hostMode !== "ast")?.hostMode ?? "ast";
9801
+ const elided = pieces.filter((p) => p.hostBranchElided === true);
9802
+ if (mode === "cst" && elided.length > 0) {
9803
+ throw new Error(
9804
+ `compose: cannot build a positioned-CST artifact from pieces that were compiled for host mode "ast" \u2014 ${elided.map((p) => `"${p.ns}"`).join(", ")} ${elided.length === 1 ? "dropped its" : "dropped their"} direct builders' CST branch, so ${elided.length === 1 ? "it" : "they"} would return AST objects inside the CST. An already-compiled piece keeps the mode it was built with; only pieces carried as IR are re-lowered by this compose. Re-compile ${elided.length === 1 ? "it" : "them"} with hostMode: 'cst', or pass the source grammar so it can be re-lowered.`
9805
+ );
9806
+ }
9807
+ return { mode, elided: elided.length > 0 };
9808
+ }
8307
9809
  function fuseRules(pieces) {
8308
9810
  const { body, env } = fusedBody(pieces);
8309
9811
  return new Function("_env", body)(env);
8310
9812
  }
9813
+ function fusedHostModeOf(registry) {
9814
+ const m = registry[FUSED_HOST_MODE];
9815
+ return m === "cst" ? "cst" : "ast";
9816
+ }
9817
+ function fusedHostElidedOf(registry) {
9818
+ return registry[FUSED_HOST_ELIDED] === true;
9819
+ }
8311
9820
  var COMPOSED_PIECES = /* @__PURE__ */ Symbol.for("parseman.composedPieces");
9821
+ function composedPiecesOf(grammar) {
9822
+ const pieces = grammar[COMPOSED_PIECES];
9823
+ return Array.isArray(pieces) ? pieces : void 0;
9824
+ }
8312
9825
  var LEAF_COMPOSED = /* @__PURE__ */ Symbol.for("parseman.leafComposed");
8313
9826
  var COMPOSED_TRIVIA = /* @__PURE__ */ Symbol.for("parseman.composedTrivia");
8314
9827
  var COMPOSED_COVERAGE_RULES = /* @__PURE__ */ Symbol.for("parseman.composedCoverageRules");
8315
9828
  function isIRPiece(p) {
8316
9829
  return !!p && typeof p === "object" && typeof p.ir === "string" && typeof p.ns === "string" && !("ruleFns" in p);
8317
9830
  }
8318
- function carriedRuleMaps(carried) {
8319
- return carried.filter(isIRPiece).map((p) => evalRuleMapIR(p.ir));
9831
+ function once(fn) {
9832
+ let done = false;
9833
+ let value;
9834
+ return () => {
9835
+ if (!done) {
9836
+ value = fn();
9837
+ done = true;
9838
+ }
9839
+ return value;
9840
+ };
9841
+ }
9842
+ function carriedRuleMapsDetailed(carried) {
9843
+ const maps = [];
9844
+ const opaque = [];
9845
+ for (const p of carried) {
9846
+ if (isIRPiece(p)) {
9847
+ maps.push(evalRuleMapIR(p.ir));
9848
+ continue;
9849
+ }
9850
+ const ruleFns = p.ruleFns;
9851
+ opaque.push({ ns: p.ns, ruleNames: ruleFns instanceof Map ? [...ruleFns.keys()] : [] });
9852
+ }
9853
+ return { maps, opaque };
9854
+ }
9855
+ function recoverComposedRules(grammar) {
9856
+ const carried = composedPiecesOf(grammar);
9857
+ if (carried === void 0) return void 0;
9858
+ const { maps, opaque } = carriedRuleMapsDetailed(carried);
9859
+ const rules2 = /* @__PURE__ */ new Map();
9860
+ for (const map of maps) for (const [name, rule] of map) {
9861
+ if (rule._def.tag === "lazy") {
9862
+ try {
9863
+ rule._def.thunk();
9864
+ } catch {
9865
+ continue;
9866
+ }
9867
+ }
9868
+ rules2.set(name, rule);
9869
+ }
9870
+ return { rules: rules2, opaque };
8320
9871
  }
8321
9872
  function coverageRulesOf(carried) {
8322
9873
  const winners = {};
@@ -8339,11 +9890,12 @@ function coverageRulesOf(carried) {
8339
9890
  function composedCoverageRules(grammar) {
8340
9891
  return grammar[COMPOSED_COVERAGE_RULES];
8341
9892
  }
8342
- function materializePiece(p, trivia2, captureTerminals = false) {
9893
+ function materializePiece(p, trivia2, captureTerminals = false, hostMode) {
8343
9894
  if (!isIRPiece(p)) return p;
8344
9895
  const pieces = compileLinkable(evalRuleMapIR(p.ir), p.ns, {
8345
9896
  ...trivia2 ? { trivia: trivia2 } : {},
8346
- ...captureTerminals ? { captureTerminals: true } : {}
9897
+ ...captureTerminals ? { captureTerminals: true } : {},
9898
+ ...hostMode ? { hostMode } : {}
8347
9899
  });
8348
9900
  if (!pieces) throw new Error(`compose: carried IR for ns "${p.ns}" could not be re-lowered`);
8349
9901
  return pieces;
@@ -8356,7 +9908,7 @@ function nextComposeNs(used) {
8356
9908
  used.add(ns);
8357
9909
  return ns;
8358
9910
  }
8359
- function itemCarried(item, used, trivia2) {
9911
+ function itemCarried(item, used, trivia2, hostMode) {
8360
9912
  const carried = item[COMPOSED_PIECES];
8361
9913
  if (Array.isArray(carried)) {
8362
9914
  const items = carried;
@@ -8382,7 +9934,7 @@ function itemCarried(item, used, trivia2) {
8382
9934
  });
8383
9935
  const scanSkip = entries.map(([, val]) => val._meta.grammarScanSkip).find(Boolean);
8384
9936
  const ir = serializeRuleMap(entries, scanSkip);
8385
- return ir ? [{ ns, ir }] : [linkable(map, ns, trivia2)];
9937
+ return ir ? [{ ns, ir }] : [linkable(map, ns, trivia2, hostMode)];
8386
9938
  }
8387
9939
  function composingTriviaOf(items) {
8388
9940
  for (let i = items.length - 1; i >= 0; i--) {
@@ -8395,15 +9947,20 @@ function composingTriviaOf(items) {
8395
9947
  }
8396
9948
  return void 0;
8397
9949
  }
8398
- function compose(items) {
9950
+ function compose(items, opts) {
8399
9951
  if (items.some((item) => item[LEAF_COMPOSED] === true)) {
8400
9952
  throw new Error("compose: a composeLeaf() result is terminal and cannot be composed again");
8401
9953
  }
8402
9954
  const used = /* @__PURE__ */ new Set();
8403
9955
  const trivia2 = composingTriviaOf(items);
8404
- const carried = items.flatMap((item) => itemCarried(item, used, trivia2));
8405
- runFusedGatingDiagnostic(() => carriedRuleMaps(carried));
8406
- const pieces = carried.map((p) => materializePiece(p, trivia2));
9956
+ const carried = items.flatMap((item) => itemCarried(item, used, trivia2, opts?.hostMode));
9957
+ const detailed = once(() => carriedRuleMapsDetailed(carried));
9958
+ runFusedGatingDiagnostic(
9959
+ () => detailed().maps,
9960
+ void 0,
9961
+ () => detailed().opaque
9962
+ );
9963
+ const pieces = carried.map((p) => materializePiece(p, trivia2, false, opts?.hostMode));
8407
9964
  const map = fuseRules(pieces);
8408
9965
  Object.defineProperty(map, COMPOSED_PIECES, { value: carried, enumerable: false });
8409
9966
  if (trivia2) Object.defineProperty(map, COMPOSED_TRIVIA, { value: trivia2, enumerable: false });
@@ -8415,6 +9972,23 @@ function composeLeaf(_items) {
8415
9972
  throw new Error("composeLeaf(): requires Parseman macro lowering; runtime composition is forbidden");
8416
9973
  }
8417
9974
 
9975
+ // src/analysis/grammar.ts
9976
+ function analyzeGrammarGating(grammar, opts) {
9977
+ const recovered = recoverComposedRules(grammar);
9978
+ if (recovered === void 0) {
9979
+ return analyzeGatingRules(Object.entries(grammar), opts);
9980
+ }
9981
+ const { rules: winners, opaque } = recovered;
9982
+ const entries = [...winners];
9983
+ const report = analyzeGatingRules(entries, { ...opts, resolveRef: (name) => winners.get(name) });
9984
+ const fromOpaque = opaque.map((o) => ({
9985
+ rule: o.ruleNames.length > 0 ? o.ruleNames.join(", ") : `<artifact ${o.ns}>`,
9986
+ kind: "opaque-artifact",
9987
+ reason: `precompiled artifact "${o.ns}" carries compiled rule functions, not re-lowerable IR \u2014 its choices cannot be examined. Recompile the contributing grammar so it carries IR (the macro emits IR by default) to bring it back into analysis.`
9988
+ }));
9989
+ return fromOpaque.length === 0 ? report : { ...report, unanalysable: [...report.unanalysable, ...fromOpaque] };
9990
+ }
9991
+
8418
9992
  // src/combinators/gate.ts
8419
9993
  function gate(predicate) {
8420
9994
  const meta = {
@@ -8547,7 +10121,8 @@ function editNode(node2, at, delta, parentBase, parentShift, stats) {
8547
10121
  }
8548
10122
 
8549
10123
  // src/functional/doc.ts
8550
- function mkCtx(state, build, tolerant) {
10124
+ function mkCtx(rawState, build, tolerant) {
10125
+ const state = rawState ?? void 0;
8551
10126
  return tolerant ? { trackLines: false, state, build, _tolerant: true, _rec: REC, _errors: [] } : { trackLines: false, state, build };
8552
10127
  }
8553
10128
  function asRuleFn(entry) {
@@ -8944,6 +10519,7 @@ function collectEmbeddedErrors(node2, base, out) {
8944
10519
  function parseDoc(registry, rootRule, input, opts = {}) {
8945
10520
  const entry = registry[rootRule];
8946
10521
  if (!entry) throw new Error(`No rule '${rootRule}' in registry`);
10522
+ assertHostModeCompatible(fusedHostModeOf(registry), opts.build, fusedHostElidedOf(registry));
8947
10523
  const ctx = mkCtx(opts.state, opts.build, !!opts.tolerant);
8948
10524
  const r = asRuleFn(entry)(input, 0, ctx);
8949
10525
  if (r.ok) {
@@ -8969,6 +10545,12 @@ function runOnce(entry, input, options, phase, profileState) {
8969
10545
  const skipGlobalSinks = phase === "recognizer" || phase === "capture";
8970
10546
  const grammarTrivia = typeof entry !== "function" ? entry._meta.grammarTrivia : void 0;
8971
10547
  const grammarScanSkip = typeof entry !== "function" ? entry._meta.grammarScanSkip : void 0;
10548
+ const stamped = entry;
10549
+ assertHostModeCompatible(
10550
+ typeof entry === "function" ? stamped[FUSED_HOST_MODE] ?? "ast" : entry._meta.grammarHostMode ?? "ast",
10551
+ options.build,
10552
+ typeof entry === "function" && stamped[FUSED_HOST_ELIDED] === true
10553
+ );
8972
10554
  const ctx = {
8973
10555
  trackLines: false,
8974
10556
  ...skipGlobalSinks ? { _pmProfile: profile } : { _triviaLog: triviaLog, _errors: errors, ...profile === void 0 ? {} : { _pmProfile: profile } },
@@ -9631,8 +11213,12 @@ export {
9631
11213
  absoluteSpanCST,
9632
11214
  absolutize,
9633
11215
  absolutizeCST,
11216
+ alternationGroups,
11217
+ analyzeDuplication,
11218
+ analyzeDuplicationRules,
9634
11219
  analyzeGating,
9635
11220
  analyzeGatingRules,
11221
+ analyzeGrammarGating,
9636
11222
  annotateSpan,
9637
11223
  applyEdit,
9638
11224
  attempt,
@@ -9641,6 +11227,7 @@ export {
9641
11227
  buildLineIndex,
9642
11228
  buildOffsetIndex,
9643
11229
  buildTriviaIndex,
11230
+ charClassMembers,
9644
11231
  choice,
9645
11232
  collectLeafSlots,
9646
11233
  commentsIn,
@@ -9655,9 +11242,12 @@ export {
9655
11242
  createGrammarTraceSink,
9656
11243
  createVisitor,
9657
11244
  cstBuildHost,
11245
+ duplicationFindingCount,
9658
11246
  expect,
11247
+ extractCharClasses,
9659
11248
  field,
9660
11249
  firstSetToString,
11250
+ formatDuplicationFindings,
9661
11251
  formatGatingWarnings,
9662
11252
  gapIsSignificant,
9663
11253
  gapText,
@@ -9667,6 +11257,8 @@ export {
9667
11257
  indentMixed,
9668
11258
  indentWidth,
9669
11259
  isParseError,
11260
+ keywordAlternationHazards,
11261
+ keywordRegexShape,
9670
11262
  keywords,
9671
11263
  label,
9672
11264
  leaf,
@@ -9697,6 +11289,7 @@ export {
9697
11289
  sepBy,
9698
11290
  sequence,
9699
11291
  shiftAbsolute,
11292
+ siteToString,
9700
11293
  skip,
9701
11294
  token,
9702
11295
  transform,