Skip to content

Single source of truth for capture value semantics (inference vs emission) #420

Description

@zharinov

Problem

Two independent implementations answer "what value does this capture hold":

  • crates/plotnik-compiler/src/analyze/type_check/infer.rs — flow-based, produces the declared types (infer, .d.ts generation).
  • crates/plotnik-compiler/src/compile/capture.rs (creates_structured_scope and friends) — syntactic, decides what the bytecode actually emits.

When they disagree, release builds produce type-unsound JSON. The only cross-check is debug_verify_type in crates/plotnik-vm/src/engine/verify.rs — debug builds only, at user runtime. Confirmed divergences, each is a corpus case:

  1. Duplicate tagged-alternation labels silently overwrite in a BTreeMap; check passes, exec panics (verified 2026-06-12):
    cargo run -p plotnik -- exec -q 'Q = (program (expression_statement [A: (identifier) @x  A: (number) @y]))' -s 'foo;' -l javascript
  2. Duplicate capture names inside a named node are accepted (or_insert; the sequence path correctly errors) → output is invalid JSON with duplicate keys (verified 2026-06-12):
    cargo run -p plotnik -- exec -q 'Q = (program (expression_statement (binary_expression (identifier) @x (identifier) @x)))' -s 'a + b' -l javascript
    # → {"x":…,"x":…}
  3. Tagged alternation under a node capture: inference promises a tagged union, runtime emits the node (debug panic).
  4. Uncaptured recursive refs are typed Void ("opaque"), but the VM bubbles their captures into the enclosing scope → duplicate keys.
  5. :: string / :: TypeName annotations on array captures rewrite struct elements or discard the array shape → panic. The annotation logic is scattered across ad-hoc cases.
  6. field: (Def) @cap infers flat, emits nested → panic.
  7. Null vs optional: typegen renders x?: T, the materializer always emits "x": null — under strictNullChecks real output violates every generated .d.ts. Also * and + differ on missing arrays ([] vs null), contradicting docs/type-system.md.
  8. Cross-file workspace inference re-walks a file with a fixed source_id, so refs into other files attribute diagnostics to the wrong source → out-of-bounds panic in the renderer.

Approach

  • One function answering "what value shape does this capture hold" (MatchedNode | StructuredScope | RefResult | Array), consumed by both infer.rs and capture.rs.
  • One apply_annotation(shape, annotation) that explicitly transforms, names, or rejects each combination.
  • Per-def (not per-file) inference, with source attribution derived from the owning symbol.
  • Decide the null-vs-optional policy once and align typegen, materializer, and docs/type-system.md.
  • Items 1 and 2 are also missing validation (duplicate labels / duplicate captures should be diagnostics) — add those regardless of the unification.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions