Skip to content

feat: variable-flow type tracking for chain receivers (closes #23) - #26

Merged
mikebronner merged 1 commit into
mainfrom
feat/issue-23-variable-flow-tracking
May 28, 2026
Merged

mikebronner merged 1 commit into
mainfrom
feat/issue-23-variable-flow-tracking

Conversation

@mikebronner

Copy link
Copy Markdown
Contributor

Summary

Closes #23 — Phase 2 of #11.

Adds intra-procedural flow-sensitive type tracking for chain-receiver variables. Chains rooted at $var now resolve to the right Eloquent model when the variable is established several statements earlier or rebuilt across conditional branches:

$query = User::query();

if ($activeOnly) {
    $query = $query->where('active', true);
}

$query->orderBy('|');   // ← completes User's columns

Also handles use ($var) closure capture and arrow-function auto-capture, so this works:

$query = User::query();
collect([])->each(fn ($i) => $query->where('id', $i));  // $query resolves to User

Architecture

  • New query_chain/flow.rs (~290 lines + module doc) — assignment tracking, RHS classifier, closure-capture traversal.
  • var_type::resolve is now a thin wrapper that delegates to flow::resolve; per-scope helpers typed_param_in / docblock_in are exposed so flow can apply them at each scope hop.
  • Cycles terminate naturally via a monotonically-decreasing byte boundary — when classifying an assignment's RHS, recursive lookups use that assignment's start byte as the new boundary, so `$q = $q->where(...)` walks back to the original seed rather than re-discovering itself. Simpler than a visited set, and avoids the subtle bug where re-entering a (scope, var) pair was incorrectly blocked during legitimate backward recursion.

Resolution order

Per scope: latest classifiable assignment > typed param > docblock.

Across scopes (use ($var) capture or arrow auto-capture): if nothing resolves locally, step out one scope and try again with the closure's start as the new boundary.

When flow can't classify an assignment ("tracking cleared" per AC #3), we fall through to declared types within the same scope. Those are user-asserted intent and aren't invalidated by an unrecognised reassignment.

Notable design choice — closure_scope ordering preserved

The issue AC states typed param > docblock > flow-tracked > closure scope > unknown. Implementing this literally would have regressed whereHas('posts', function (Builder \$q) { \$q->where('|'); }) — today the closure_scope binding correctly resolves to Post; flipping the order would let the typed Builder param win and try to complete against a generic Builder class.

After discussion, the resolution model is dataflow-closeness, walking backward from the use site:

  1. Latest assignment in current scope (overrides everything below — reassignment is a fresh write)
  2. How \$var entered the scope:
    • Closure param → closure_scope binding > typed annotation > docblock
    • Function param → typed annotation > docblock
    • use (\$var) capture → recurse into outer scope, apply same rules

This preserves all existing closure_scope semantics. The cursor.rs dispatch is unchanged; only the contents of php_type are richer now (flow tracking + typed param + docblock).

Acceptance criteria

  • \$query = User::query(); followed by \$query->where('|') on a later line completes User's columns
  • \$query = \$query->where(...) keeps the type — subsequent \$query->orderBy('|') still completes columns
  • Reassignment from a non-Builder type clears tracking (`$query = something_unrelated();` → falls back to typed param / docblock; None if neither exists)
  • Closure use (\$query) captures the variable's tracked type
  • tap(fn (\$q) => \$q->where('|')) resolves \$q to the chain's effective model (unchanged — handled by Phase 8 closure_scope SameModel binding; not regressed)
  • Receiver-resolution ordering documented and tested in module docs + tests

Tests

  • 18 unit tests in flow/tests.rs covering: seed from static query / where / new, self-reassignment chains, branch reassignment (the motivating example), clearing on unknown RHS, fallback to typed param + docblock, use () capture + auto-capture, scope isolation (nested closure assignments don't pollute outer), mutual-reassignment cycle guard.
  • 4 integration tests in extractor/tests.rs verifying InstanceVar.php_type gets populated end-to-end on parsed files (not just at the var_type unit level).

Full suite: 1108 passing, 0 failures, clippy clean.

Out of scope (per issue)

Test plan

  • In a Laravel project, open a controller with multi-statement query construction (e.g. \$query = User::query(); if (...) { \$query = \$query->where(...); } \$query->where('|')) — confirm column completion fires
  • Confirm the original closure-carrier case still works: whereHas('posts', fn (\$q) => \$q->where('|')) → Post's columns
  • Confirm use (\$query) capture in an anonymous function carries the type forward
  • Confirm arrow function fn (\$i) => \$query->where('|') carries the type forward
  • Sanity-check that reassigning a tracked var to something unclassifiable (e.g. \$query = somethingElse();) doesn't fire stale completions

Phase 2 of #11. Chains rooted at `$var` now resolve to the right Eloquent
model even when the variable is established several statements earlier
or rebuilt across conditional branches:

    $query = User::query();
    if ($active) { $query = $query->where('active', true); }
    $query->orderBy('|');   // ← completes User's columns

New `query_chain/flow.rs` does intra-procedural assignment tracking with
`use ($var)` capture traversal and arrow-function auto-capture. Cycles
terminate naturally via a monotonically-decreasing byte boundary —
recursing into an assignment's RHS uses that assignment's start as the
new lookup boundary, so `$q = $q->where(...)` walks back to the original
seed instead of looping.

`var_type::resolve` is now a thin wrapper that delegates to
`flow::resolve`; the per-scope helpers (`typed_param_in`, `docblock_in`)
are exposed so flow can apply them at each scope hop.

Resolution order, per scope: latest classifiable assignment > typed
param > docblock. When flow can't classify an assignment ("tracking
cleared"), we fall through to declared types — those are user-asserted
intent and aren't invalidated by an unrecognised reassignment.

Cursor's `closure_scope > php_type` ordering is preserved, so
`whereHas('posts', fn ($q) => $q->where('|'))` still resolves via the
relation-hop binding rather than trying to complete against a generic
Builder type.

Tests: 18 unit (flow/tests.rs) + 4 integration (extractor/tests.rs).
Full suite: 1108 passing, 0 failures, clippy clean.
@mikebronner
mikebronner merged commit 550c6a5 into main May 28, 2026
2 checks passed
@mikebronner
mikebronner deleted the feat/issue-23-variable-flow-tracking branch May 28, 2026 14:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Variable-flow type tracking for chain receivers (Phase 2 of #11)

1 participant