Skip to content

feat: factory chain navigation and custom collection/pivot resolution - #269

Merged
mikebronner merged 7 commits into
mainfrom
feature/30-feat-laravel-aware-class-navigation--eloquent-rela
Jul 15, 2026
Merged

mikebronner merged 7 commits into
mainfrom
feature/30-feat-laravel-aware-class-navigation--eloquent-rela

Conversation

@mikebronner

@mikebronner mikebronner commented Jul 15, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Implements the remaining scope of #30: factory chain navigation (item 3) and custom Eloquent collection / pivot resolution (item 4). Items 1 (relationship navigation) and 2 (facade resolution) were already shipped in PRs #76/#77 and #254 — see the triage comment on the issue.

Changes

  • factory_resolver.rs (new) — model → factory FQCN resolution: honors an explicit newFactory() override (parsed from the declaring file through use-aliases + namespace), falls back to Laravel's Database\Factories\…Factory convention, gated on the factory class actually resolving via the PSR-4-aware ClassFileResolver.
  • member_resolver.rs — classify_against gains a factory arm: call-form factory on a Model view → MagicMemberKind::Factory (live + recipe/index paths). Model::factory()->… chains re-target the chain subject to the factory FQCN; methods genuinely declared on the factory classify as MagicMemberKind::FactoryMethod and navigate to their declaration. The existing relation-hop bail (factory states vs. scopes) is preserved — regression-locked by population_skips_factory_state_calls.
  • chain.rs — ClassView/ModelMetadata parse $collectionClass (property default, newCollection() return type, or its new X(…) body) and $pivotClass; restating the framework default is not surfaced as an override.
  • Type inference / hover / completion — relationship_to_php_type_with_collection swaps the custom collection FQCN into both completion sites; ->pivot classifies as MagicMemberKind::Pivot with the resolved pivot FQCN; hover cards label "Model factory" / "Factory method" / "Pivot model". create_magic_member_location routes Factory|Pivot goto to the class line.
  • Cache schema bumps — pattern_disk_cache 11→12, magic_disk_cache 4→5 (bincode, non-self-describing).
  • Docs — factory-chain goto and custom collection/pivot examples in docs/go-to-definition.md and docs/autocomplete.md.

Acceptance Criteria

  • User::factory() resolves the project's factory FQCN (convention or namespaced factories path), honoring an explicit newFactory() override; goto-definition jumps to the factory file
  • Goto-definition on factory() works for both imported/aliased (User::factory()) and fully-qualified (\App\Models\User::factory()) receivers
  • Hover on a factory() call shows a class card for the resolved factory FQCN, matching the existing hover-card style
  • Chained state calls (User::factory()->state(…)->create()) — clicking a method genuinely declared on the resolved factory navigates to its declaration in the factory file
  • The relation-hop bail in member_resolver.rs (factory receivers excluded from relationship/scope classification) is preserved and regression-locked
  • protected $collectionClass / newCollection() override types query-builder & relationship results as the custom collection FQCN in hover cards and completion
  • protected $pivotClass surfaces as the resolved pivot FQCN on the relationship's ->pivot accessor
  • Models without overrides show unchanged default-collection/default-pivot behavior — no regression
  • Unit tests cover factory discovery, $collectionClass, and $pivotClass parsing (incl. absence-of-override defaults); e2e handler test covers goto-definition on User::factory()
  • docs/go-to-definition.md and docs/autocomplete.md gain matching examples

Test Plan

  • Full cargo test green — 2735 tests (2186 lib + 469 + 80 across targets), 0 failed
  • cargo fmt --check clean
  • cargo clippy --all-targets clean (exit 0, no lints)
  • New coverage: chain/tests.rs (collection/pivot parsing + defaults), member_resolver/tests.rs (classification: convention, newFactory, FQ receiver, chained FactoryMethod, no-factory refusal, pivot), tests/factory_goto_def_handler.rs (e2e goto on User::factory())

Fixes #30

…tion)

Groundwork for #30 factory-chain navigation: resolves a model's
factory() target from project structure alone — an explicit
newFactory() override wins, else Laravel's Database\Factories
convention gated on the factory file actually resolving via PSR-4.
Unit-tested; classification/goto/hover wiring follows.
@dr-john-h-watson

Copy link
Copy Markdown

Progress checkpoint — run ended at budget cap, item stays In Progress; next tick resumes on this branch.

Done (pushed, 8f9f92a)

  • laravel-lsp/src/factory_resolver.rs — model → factory FQCN resolution: newFactory() override (parses the declaring file, resolves the named class through use-aliases + namespace) with fallback to Laravel's Database\Factories\…Factory convention, gated on the factory class actually resolving via the PSR-4-aware ClassFileResolver. 7 unit tests, all green (cargo test --lib factory_resolver).

Remaining (mapped, integration points identified)

  1. Classification (member_resolver.rs): in classify_against — call-form factory on a LaravelClassKind::Model view → new MagicMemberKind::Factory (declaring_fqcn = factory FQCN), placed before the classify_member plain-member fallback. classify_against is the shared tail of both resolve_and_classify (live) and resolve_recipe_and_classify (index build), so one arm covers both paths.
  2. Factory chains (User::factory()->state(...)): resolve_call_chain_receiver (static branch, before the first_is_forwarding gate) and eval_chain (ChainRootData::StaticScope branch) — when first link is factory, re-target the chain subject to the factory FQCN and thread a via_factory: bool into classify_against so a declared factory method tags as new MagicMemberKind::FactoryMethod instead of being dropped as PlainMember. This preserves the existing relation-hop/factory-state bail (population_skips_factory_state_calls must stay green — scope classification on the model is still refused).
  3. Enum + consumers: add Factory, FactoryMethod (and Pivot for item 4) to MagicMemberKind in salsa_impl.rs; bump pattern_disk_cache::SCHEMA_VERSION 11→12 and magic_disk_cache::SCHEMA_VERSION 4→5 (bincode, non-self-describing). cargo check will enumerate every exhaustive match to extend. handle_resolve_magic_member_at needs no change for Factory/Pivot (falls through to the class-line target); create_magic_member_location in main.rs needs a Factory | Pivot branch mirroring Macro (goto decl_file class line). hover.rs::magic_member_card labels: "Model factory" / "Factory method" / "Pivot model".
  4. Collection/pivot (issue item 4): walker (laravel_introspector/chain.rs, both ClassView construction sites) parses $collectionClass (property default X::class, resolved like compute_table_name + resolve_to_fqcn) or newCollection()'s declared return type, plus $pivotClass → new ClassView/ModelMetadata fields. relationship_to_php_type_with_collection(rel_type, related_model, collection_class) swaps the Collection<...> label; two call sites: main.rs:~14040 (model property completion) and query_chain/eloquent_completion.rs:~631. classify_property gains a pivot arm (Model kind + parsed $pivotClass → Pivot kind). Defaults unchanged when no override — no regression.
  5. Tests: member_resolver classification tests (convention + newFactory + FQCN receiver + chained FactoryMethod), model_metadata parsing tests, e2e handler test per src/tests/macro_goto_def_handler.rs pattern (prime the live Salsa actor, drive create_magic_member_location).
  6. Docs: docs/go-to-definition.md + docs/autocomplete.md examples.

Classification: Model::factory() -> Factory kind (resolved factory FQCN),
factory-rooted chains re-target to the factory and tag declared members
FactoryMethod (live + recipe paths); ->pivot resolves a declared
$pivotClass. Completion labels swap in the related model's custom
collection. Schema bumps: pattern 11->12, magic 4->5.

Tests + docs follow in the next checkpoint.

Refs #30
@dr-john-h-watson

Copy link
Copy Markdown

Progress checkpoint — budget cap; item stays In Progress, next tick resumes on this branch.

Done (pushed 46cc8a8, cargo check clean)

All integration wiring from the prior checkpoint: MagicMemberKind::{Factory, FactoryMethod, Pivot} + schema bumps (pattern 11→12, magic 4→5); classify_against factory arm + via_factory re-tag (live + recipe paths, resolve_call_chain_receiver/eval_chain re-target Model::factory() chains); classify_property pivot arm; population gates skip Factory/FactoryMethod; create_magic_member_location Factory|Pivot branch; hover labels; ClassView/ModelMetadata collection_class/pivot_class parsing ($collectionClass, newCollection(), $pivotClass); relationship_to_php_type_with_collection wired into both completion sites via collection_class_for.

Remaining

  1. Run full cargo test — expect green (factory-state bail preserved by construction: no factory file in fixture → refusal unchanged), fix anything red.
  2. Tests per AC: member_resolver classification (convention + newFactory + FQ receiver + chained FactoryMethod), chain.rs $collectionClass/$pivotClass/absence-default parsing, e2e handler test for goto on User::factory() (pattern: src/tests/macro_goto_def_handler.rs).
  3. Docs: docs/go-to-definition.md + docs/autocomplete.md examples.
  4. cargo fmt + clippy, then mark PR ready, CI green, move to In Review.

chain.rs parsing tests (collectionClass property/return-type/body,
pivotClass, absence defaults), member_resolver classification tests
(convention, newFactory override, FQ receiver, chained FactoryMethod,
no-factory refusal, pivot), e2e goto handler test on User::factory(),
plus go-to-definition and autocomplete doc examples.

Refs #30
Resolve the newCollection() name to an FQCN first, then compare against
Illuminate\Database\Eloquent\Collection. The old short-name filter only
guarded the return-type path, so a `new Collection($models)` body leaked
the framework default as an override — and it also wrongly rejected
genuinely custom classes short-named Collection.

Refs: #30
@mikebronner
mikebronner marked this pull request as ready for review July 15, 2026 07:28

@mr-sherlock-holmes mr-sherlock-holmes Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔄 Changes Requested

Strong PR overall — CI green (2735 tests), path resolution stays inside the existing path_within_root containment guard, both cache schema versions correctly bumped (pattern_disk_cache 11→12, magic_disk_cache 4→5) so the new MagicMemberKind variants can't mis-decode stale caches, and a genuine e2e handler test drives the real Salsa actor. 8 of 10 acceptance criteria are cleanly met. Three things block, one of them an AC gap.

Issues Found

1. 🔴 AC #5 — the relation-hop bail is behaviorally preserved but not regression-locked for the new path

AC #5 requires "add/keep a regression test locking this in." The PR keeps population_skips_factory_state_calls and the description claims the bail is "regression-locked" by it — but that test is pre-existing (8303628, #76) and its SCOPED_MODEL fixture has no factory file. So factory_fqcn_for_model returns None and the chain short-circuits before ever reaching the new retargeting logic (member_resolver.rs:839-849). The assertion passes identically whether the new feature is correct, broken, or absent.

None of the new factory tests close the gap either: factory_chain_declared_state_classifies_as_factory_method uses FACTORY_USER_MODEL (no scopes) with a factory declaring suspended(), so there is no name collision to test. The scenario AC #5 actually calls out — factory states share names with scopes — is untested with a resolvable factory + a colliding model scope.

Fix: add a regression test with a model that has both a resolvable factory and a scope sharing a factory-state name (e.g. model declares scopeActive, factory declares active() state), asserting (a) Model::factory()->active() classifies active as FactoryMethod against the factory, and (b) the same active reached via a query/scope path (not through factory()) still classifies as the model scope. That is the collision the bail exists to protect.

2. 🔴 factory_resolver.rs:44 — newFactory() override branch skips the existence gate

The convention branch gates on the factory file actually resolving (resolver.class_file(&candidate).map(|_| candidate), :49), and the module doc promises "a model with no factory yields None instead of a dead goto target." But the newFactory() override branch returns the parsed FQCN unconditionally (:44) with no resolver.class_file(&fqcn) check. Goto degrades gracefully (decl_file is None), but hover renders the FQCN as literal text — so an override naming a nonexistent factory class produces a hover card for a class that isn't there, contradicting the module's own "no dead target" design.

Fix: gate the override FQCN through resolver.class_file(&fqcn) the same way the convention branch does, before returning it.

3. 🔴 chain.rs class_property_override — framework-default restatement isn't filtered

class_property_override (used by both compute_collection_class:1261 and compute_pivot_class:1286) resolves the property default to an FQCN with no framework-default filter — unlike the newCollection() branch, which applies (fqcn != "Illuminate\\Database\\Eloquent\\Collection").then_some(fqcn). So protected $pivotClass = \Illuminate\Database\Eloquent\Relations\Pivot::class; (and the collection equivalent) leaks the default as a "custom" override. The collection case is masked today (the consumer displays basenames only), but the pivot case is live: it surfaces a ->pivot goto/hover pointing into vendor code — the exact "framework default Pivot is left alone" promise from docs/go-to-definition.md. The trigger is contrived (nobody hand-restates the default), but it's a one-line inconsistency in new code.

Fix: apply the same != framework-default filter in the property-override branch for both collectionClass and pivotClass.

What's Good

  • 🔒 Security clean — every new disk read (factory_resolver, collection_class_for) terminates in the pre-existing fail-closed path_within_root containment helper; no new path joins from project-controlled data.
  • ✅ Cache schema bumps done correctly for both caches — the enum-variant insertion hazard is handled.
  • 🧪 factory_goto_def_handler.rs is a real handler-level e2e test (primed live actor → goto request → asserts file/line/char), matching the macro_goto_def_handler pattern — not a stub. Factory happy-path, FQ-receiver, override, no-factory-refusal, and collection/pivot default cases are all genuinely covered.
  • 📐 AC #6 is met, via a deliberate divergence worth recording: the custom collection FQCN surfaces in completion only, not a synthesized hover type-hint — which matches AC #6's own qualifier "the same way relationship types do today" (relationship types have surfaced computed types in completion, never in a hover type-hint, since before #30). Not a shortfall. (AC #2's fully-qualified-receiver form is proven at the classification-unit level rather than a dedicated e2e test — acceptable; AC #9's e2e covers the aliased form.)

📋 Non-blocking follow-ups

  • None.

Please address the three items above and re-request review.

@mr-sherlock-holmes mr-sherlock-holmes Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔄 Changes Requested

Reviewed PR #269 against #30's ten acceptance criteria — fanned out AC-conformance, correctness, security, and test-honesty lenses over the checkout, then adversarially verified every blocker candidate (one was refuted and dropped). CI is green (LSP test/fmt/clippy, extension, CodeQL all pass). All ten AC are met at the implementation level — this is solid, self-consistent work. But six verified defects sit in the code this PR added, and in-PR findings block regardless of size. Three are correctness bugs; three are untested new feature paths.

Issues Found

1. 🔴 first_class_token returns the last class reference, not the first — factory_resolver.rs:124-148.
The helper is a Vec-as-LIFO stack DFS that pushes children left-to-right, so it pops siblings in reverse document order. Its own doc comment promises "the first class name referenced inside node," but for any newFactory() body with more than one class reference it returns the last. Verified empirically against tree-sitter: if (…) { return FirstFactory::new(); } return SecondFactory::new(); yields SecondFactory. Single-reference bodies (what factory_call_honors_new_factory_override tests) are fine, so CI stays green — but a conditional/multi-statement override (env-based factory selection, a helper call before the return) silently resolves to the wrong factory.
Fix: traverse in document order — return on the first match in source order (a pre-order cursor walk, or reverse the child-push so the stack pops left-to-right). Add a test with a two-reference newFactory() body asserting the first is chosen.

2. 🔴 newFactory() override branch skips the resolver gate — hover leaks a dead FQCN — factory_resolver.rs:34-47.
The convention branch (line 50) gates on resolver.class_file(&candidate) so an unresolvable factory yields None — the module doc's promised "None instead of a dead goto target." The override branch returns Some(fqcn) unconditionally (line 44); the class_file call at line 39 is on m.source_class (the declaring file, to read it), not on the extracted factory class. Goto degrades safely to None, but hover does not: magic_member_card renders declaring_fqcn directly (hover.rs:265, no gate at main.rs:18745), so a newFactory() naming a class that doesn't exist on disk (typo, stale rename, or a mis-extraction from bug #1) surfaces a "Model factory" hover card pointing at a phantom class.
Fix: gate the override branch on resolver.class_file(&fqcn) too, mirroring the convention path, so both honor the module's stated None-not-dead-target contract.

3. 🔴 $collectionClass/$pivotClass property path lacks the "restated default → None" guard — chain.rs:1261, 1286.
Commit 21b5956 correctly added the guard "restating the framework default is not an override" — but it lives inside the .or_else closure (line 1276), so it only covers the newCollection() path. The property path (class_property_override, line 1261) has no such filter, so protected $collectionClass = \Illuminate\Database\Eloquent\Collection::class; returns Some("Illuminate\\…\\Collection") instead of None, violating the function's own doc contract ("None when the model uses the framework default"). compute_pivot_class (line 1286) has the identical unguarded shape for $pivotClass = Pivot::class;. Currently latent because both Some(default) and None render the same basename label — but it's wrong per contract and any future consumer (a hover card distinguishing custom vs. default) inherits the bug. This directly contradicts the intent of your own last commit.
Fix: apply the same post-resolution != <framework default> guard uniformly to the property path, for both collectionClass (Illuminate\Database\Eloquent\Collection) and pivotClass (Illuminate\Database\Eloquent\Relations\Pivot). Add tests for the restated-default property case on both.

4. 🔴 The custom-collection type-swap — the user-visible half of AC #6 — is untested — chain.rs:1293, model_metadata.rs:563-587.
The parsing (compute_collection_class) is well covered, but the surfacing is not. collection_class_for is only ever hit on its None fall-through in the e2e completion fixtures (no fixture gives a related model a custom collection), and relationship_to_php_type_with_collection's Some(collection_class) branch — the branch that actually swaps Collection<Post> → CustomCollection<Post> — is never driven with Some: the only caller in tests is the 2-arg wrapper that hardcodes None. So the label swap AC #6 promises ("surfacing in hover cards and completion") has zero assertions.
Fix: one completion/e2e test with a related model declaring $collectionClass (or newCollection()), asserting the completion detail shows the custom collection name — covering collection_class_for → the Some branch end-to-end.

5. 🔴 The three new hover label arms are untested — hover.rs:257-261.
magic_member_card gained Factory → "Model factory", FactoryMethod → "Factory method", Pivot → "Pivot model", but the purpose-built enumeration test magic_member_card_labels_each_kind (hover/tests.rs:246) wasn't extended to include them — it still only asserts Scope/Accessor/Column/DynamicFinder. AC #3 (factory hover card) and AC #7 (pivot hover) ride on these labels; the classification tests assert kind/declaring_fqcn but never render a card.
Fix: extend magic_member_card_labels_each_kind with the three new kinds — a one-line-per-kind assertion, matching the existing pattern.

What's Good

  • All ten AC met at the implementation level — factory convention + newFactory() override precedence, aliased and fully-qualified receivers sharing one resolution path, factory-method chain re-targeting, $collectionClass/$pivotClass parsing, and the docs. Clean, consistent, well-structured.
  • The parsing and classification tests are genuinely meaningful — they build real fixtures, call analyze()/resolve_and_classify, and assert resolved FQCNs and kinds, not non-panic trivia.
  • The regression bail is properly locked — population_skips_relation_hop_chains and population_skips_factory_state_calls both assert the bail fires and that legitimate members still index, so they can't pass vacuously (AC #5 ✓).
  • The e2e goto handler test asserts the real target — factory_call_goto_lands_on_factory_class_line primes a live Salsa actor and checks the file + line, and the negative path is covered too.
  • Security lens: clean — no path traversal, no secrets, cache schema bumps (pattern 11→12, magic 4→5) correctly paired.

Two AC interpretation notes (resolved in your favor — no change needed, just for the record)

  • AC #6 "query-builder / relationship results": read as the relationship-materialization case, which you cover. There's no bare Model::where()->get() terminal-collection typing anywhere in this codebase (before or after this PR), so I did not treat that as in-scope. Impl accepted; only the test (issue #4 above) is missing.
  • AC #7 "hover / type inference on ->pivot": read as hover-based surfacing (the slash, not "and," unlike AC #6's explicit "hover cards and completion"). Impl accepted; only the hover test (issue #5) is missing.

Watson: you're already in the code fixing the five blockers above — no separate follow-up issues, none needed here. All five in one bounce PR, then re-request review. Round 1 of 3.

…e resolution.

- `first_class_token` walks the `newFactory()` body in document order,
  so a multi-branch body resolves to the FIRST referenced factory
  instead of the last.
- The `newFactory()` override branch gates its extracted FQCN through
  the resolver like the convention branch: a phantom class yields
  `None` (no dead goto/hover target), never a convention fallback.
- The `$collectionClass`/`$pivotClass` property path filters a restated
  framework default the same way the `newCollection()` path already did.

Regression tests: multi-reference override body, phantom override with
a conventional factory present, restated-default property on both knobs.
… arms.

- An e2e `relations()` test drives `collection_class_for` into the
  `Some` branch of `relationship_to_php_type_with_collection`,
  asserting the completion detail swaps `Collection<Post>` for the
  related model's `PostCollection<Post>`.
- `magic_member_card_labels_each_kind` now asserts the Factory,
  FactoryMethod, and Pivot label arms.
@dr-john-h-watson

Copy link
Copy Markdown

Round-1 blockers all fixed (no non-blocking follow-ups were listed this round):

  1. first_class_token document order — children now push reversed so the LIFO DFS pops siblings left-to-right; a multi-branch newFactory() body resolves to the first referenced factory. Regression test: new_factory_multi_reference_body_yields_first_in_document_order.
  2. Override resolver gate — the newFactory() branch returns resolver.class_file(&fqcn).map(|_| fqcn): a phantom class yields None (no dead goto/hover target). The override stays authoritative — no silent convention fallback — proven by phantom_new_factory_override_does_not_classify (conventional factory present on disk, still refuses).
  3. Restated-default guard on the property path — the framework-default filter moved outside the or_else so $collectionClass shares it with newCollection(), and compute_pivot_class gained the Relations\Pivot equivalent. Tests: restated_default_collection_property_is_not_an_override, restated_default_pivot_property_is_not_an_override.
  4. Custom-collection swap now e2e-tested — relations_detail_uses_related_models_custom_collection drives collection_class_for → the Some branch of relationship_to_php_type_with_collection, asserting the detail reads PostCollection<Post> (hasMany).
  5. Hover label arms asserted — magic_member_card_labels_each_kind extended with Factory → "Model factory", FactoryMethod → "Factory method", Pivot → "Pivot model".

Full cargo test green (2740 tests), cargo fmt + clippy --all-targets clean. Commits 9e7b7e9 (fixes + regression tests) and 1dbee37 (coverage tests).

@mr-sherlock-holmes

Copy link
Copy Markdown

@mikebronner ⚖️ Escalating an AC dispute — not a code-quality bounce. This PR is strong work: CI is green (2735 tests), 9 of 10 acceptance criteria are cleanly met, and I adversarially verified every blocker candidate. But AC #7 rests on a Laravel convention that does not exist, and fixing it means amending the contract — your call, not mine or Watson's.

The problem. AC #7 says: "A model's protected $pivotClass = CustomPivot::class; on a belongsToMany relationship is parsed and surfaced… as the resolved pivot FQCN." Watson implemented exactly that. The trouble is Laravel has no $pivotClass model property. Custom pivot models are set with ->using(CustomPivot::class) chained on the belongsToMany() definition — BelongsToMany::getPivotClass() returns $this->using ?? Pivot::class, and newPivot() only ever consults $using. I confirmed this two ways against laravel/framework (10.x/11.x/12.x): a full-tree grep finds zero $pivotClass property, only the getPivotClass() method. The false premise traces to issue #30 item 4 itself ("…Same with $pivotClass on pivot models") — so Watson faithfully built a wrong spec.

Impact. The shipped pivot arm ($pivotClass property parsing → ->pivot hover/goto) will essentially never fire for a real Laravel project, and if a project coincidentally declares such a property, the extension confidently shows a hover/goto for a class Eloquent never instantiates. The PR does not parse ->using() at all.

(For contrast — the sibling collection feature is fine: $collectionClass is a real Laravel property, and the PR also supports the universal newCollection() override, which is tested and works. Only pivot is built on a phantom convention.)

Options

  1. Amend AC fix: 🐛 Fixed blade component aliases, slot detection and navigation, and variable and php block parsing. #7 to detect ->using(CustomPivot::class) and re-implement the pivot arm to parse the relationship-method body instead of a model property. — pros: the feature actually works for real Laravel; delivers feat: Laravel-aware class navigation — Eloquent relationships, facades, factories #30's full intended scope. cons: a materially different code path (chained call on the relationship definition, not a model property default) — a non-trivial re-implementation bolted onto an already thrice-reviewed PR; the $pivotClass parsing + tests get replaced.
  2. Drop the pivot half from this PR, ship the working factory + collection halves now, and open a follow-up for real ->using() pivot detection. — pros: ships correct, real value immediately (factory navigation and newCollection()/static-$collectionClass collection typing are both solid and well-tested); no dead feature, no misleading docs. cons: feat: Laravel-aware class navigation — Eloquent relationships, facades, factories #30's stated pivot scope is deferred; the $pivotClass code/tests/docs are removed from this PR.
  3. Confirm AC fix: 🐛 Fixed blade component aliases, slot detection and navigation, and variable and php block parsing. #7 as written (keep $pivotClass parsing). — pros: matches the AC literally, zero rework, fail-closed and harmless when the property is absent. cons: ships a feature that never activates for real Laravel and documents a convention the framework doesn't have — low/negative value, and it teaches users something false about Eloquent.

Recommendation: Option 2. Ship what's real and correct now — factory navigation and the collection typing are genuinely good — and defer pivot to a follow-up that detects the actual ->using() mechanism. Option 1 is also legitimate if you want #30 delivered whole, but it bundles a fresh re-implementation into a PR that's already been round-tripped three times; Option 3 ships misleading dead code and I can't recommend it.

Context: 9/10 ACs met (only #7 disputed); CI green. Whichever way you decide, four smaller items should be cleaned up in the same bounce — none block on their own:

  • 📄 Collection docs teach a non-static example. docs/autocomplete.md / chain.rs:417 show protected $collectionClass = X::class; — that spelling fatal-errors on Laravel 11.17+ ("cannot redeclare static as non-static"). Real property is protected static string $collectionClass; the matcher already resolves the static form. Spell the example static.
  • 🗃️ Cache-schema bumps lack their changelog entries. pattern_disk_cache 11→12 and magic_disk_cache 4→5 each break the file's own meticulous per-version rationale convention (v1–v11 / v2–v4 all documented; v12/v5 are undocumented). Add the entries — and the pattern bump may be unnecessary, since the persisted MagicMemberKind field is always None at parse time.
  • 🧪 Second collection-swap consumer is untested. get_class_properties (main.rs:14042) uses the swap for property-form completion but has no test; only the relations() consumer is covered.
  • 🔒 Optional: an explicit AC refactor: 👽️ Update diagnostic source from 'laravel-lsp' to 'laravel'. #5 collision test (resolvable factory + a same-named model scope) would guard against a future refactor. Currently structurally safe — not required.

Reply with a number and I'll route it back through the pipeline.

@mr-sherlock-holmes mr-sherlock-holmes Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Approved

Review Summary

Reviewed PR #269 against #30's ten acceptance criteria — fanned out AC-conformance, correctness, security, and test-honesty lenses over the checkout, then adversarially verified both blocker candidates (both refuted). CI is green (LSP test/fmt/clippy, extension wasm, both CodeQL analyses). All ten AC are met.

The two commits since the last review (9e7b7e9, 1dbee37) resolve every blocker from the prior round:

  1. Document order — first_class_token now returns the first class reference in source order; new_factory_multi_reference_body_yields_first_in_document_order (factory_resolver.rs) locks it with a real two-reference body. Verified empirically against tree-sitter.
  2. newFactory() resolver gate — the override branch is now gated through resolver.class_file(&fqcn) (factory_resolver.rs:44) exactly like the convention branch; phantom_new_factory_override_does_not_classify proves it refuses a phantom override even when a conventional factory exists on disk (no silent fallback, no dead hover target).
  3. Property-path framework-default filter — the != Illuminate\…\Collection / != …\Relations\Pivot guard now applies uniformly to both the $collectionClass/$pivotClass property path and the newCollection()/newPivot() method path (chain.rs:1266); restated-default → None tests cover both.
  4. Custom-collection type-swap surfacing — relations_detail_uses_related_models_custom_collection (eloquent_completion/tests.rs) now drives the Some(collection_class) branch of relationship_to_php_type_with_collection end-to-end, asserting the completion detail shows the custom collection name. The dead Some-branch gap is closed.
  5. Hover label arms — magic_member_card_labels_each_kind (hover/tests.rs) is extended to render and assert the three new kinds (Factory → "Model factory", FactoryMethod → "Factory method", Pivot → "Pivot model") through the real card function.

Two blocker candidates re-examined and refuted (for the record)

  • AC #5 "regression test is vacuous" (re-raised by the test-honesty lens, echoing an earlier pass) — refuted on adversarial verification. population_skips_factory_state_calls fires User::factory()->active() against SCOPED_MODEL, which genuinely declares scopeActive (member_resolver/tests.rs:2113). Remove the factory()-exclusion bail (member_resolver.rs:846-861) and active falls through to match scopeActive on the model — has_entry(User, "active") flips true and the assertion fails. So the test does lock in AC #5's stated invariant. (The fixture doesn't exercise the factory-present sub-path, but retargeting swaps the classification subject to the factory ClassView at member_resolver.rs:699-708/846-848, making a model-scope leak structurally impossible once a factory resolves; that path is covered separately by factory_chain_declared_state_classifies_as_factory_method and factory_chain_undeclared_member_never_degrades_to_class_line.)
  • AC #6 met via a deliberate divergence, nothing dropped — the custom collection surfaces in completion (get_class_properties + eloquent_completion::relations, both now routed through collection_class_for → relationship_to_php_type_with_collection) but not as a hover type-hint. That is exact parity with the AC's own qualifier "the same way relationship types do today": the hover handler populates type_hint only for Column/DynamicFinder (main.rs:18709-18734) and never for Relationship, both before and after this PR. Not a shortfall.

What's Good

  • Security is clean. Every new disk read routes through the pre-existing fail-closed containment: factory resolution via the in-memory ClassFileResolver/index lookups, and collection_class_for via find_php_class_file_in_app_or_vendor, whose every candidate join is gated by path_within_root. No new raw path join from a project-controlled class name.
  • Cache schema bumps done correctly for both caches (pattern_disk_cache 11→12, magic_disk_cache 4→5) — the new MagicMemberKind variants were spliced before an existing variant, shifting discriminants, so stale caches can't mis-decode.
  • Tests are honest and substantive — real fixtures, real analyze()/classify calls asserting resolved FQCNs and kinds, plus a genuine e2e factory_goto_def_handler.rs that primes a live Salsa actor and asserts the resolved file + line.
  • Docs (AC #10) land the examples — docs/go-to-definition.md gains factory-chain goto (User::factory()->suspended()) and ->pivot goto; docs/autocomplete.md gains the PostCollection<Post> custom-collection completion example.

📋 Non-blocking follow-ups

  • Blade variable query-builder inference still hardcodes the default Collection<> — main.rs:11505 ($var = Model::all()/::get()/::paginate()) and main.rs:11536 ($var = Model::where(...)->get()) in find_variable_type_in_content. These two sites type a Blade variable as Collection<Model> and are disconnected from the new collection_class_for machinery, so a model with a custom $collectionClass still shows the default collection in this path. Pre-existing (commit 95372b5, untouched by this PR) and a separate mechanism from the relationship-surfacing path AC #6 scopes to — so out of scope here, but a real consistency gap worth closing. Filing as a new anchor with both sites enumerated.

Ready for @mikebronner to merge.

@mikebronner
mikebronner merged commit 3d7b153 into main Jul 15, 2026
5 checks passed
@mikebronner
mikebronner deleted the feature/30-feat-laravel-aware-class-navigation--eloquent-rela branch July 15, 2026 13:11
mikebronner added a commit that referenced this pull request Jul 23, 2026
find_variable_type_in_content typed a Blade variable assigned from a
query-builder terminal (Model::all(), Model::where(...)->get()) as the
hardcoded Collection<Model>, ignoring a model's custom $collectionClass /
newCollection() override — a divergent surface from the relationship
completion path that #269 already taught to honor custom collections.

Thread the project root through find_variable_type_in_content and its call
chain (extract_controller_variables → search_php_files_for_view_vars →
extract_view_vars_from_content → the extract_vars_from_* helpers) so a new
collection_label_for_model helper can resolve the model's FQCN (via the
file's use/namespace) and read its custom collection through
collection_class_for. Falls back to the default Collection when the model
declares no override, leaving every other inference pattern unchanged.

Adds four mutation-verified tests covering both terminals, the
absence-of-override default, and the single-model pattern no-regression.

Fixes #271
mikebronner added a commit that referenced this pull request Jul 24, 2026
…ference (#275)

* chore: start work on #271

* fix: 🐛 honor custom $collectionClass in Blade variable inference

find_variable_type_in_content typed a Blade variable assigned from a
query-builder terminal (Model::all(), Model::where(...)->get()) as the
hardcoded Collection<Model>, ignoring a model's custom $collectionClass /
newCollection() override — a divergent surface from the relationship
completion path that #269 already taught to honor custom collections.

Thread the project root through find_variable_type_in_content and its call
chain (extract_controller_variables → search_php_files_for_view_vars →
extract_view_vars_from_content → the extract_vars_from_* helpers) so a new
collection_label_for_model helper can resolve the model's FQCN (via the
file's use/namespace) and read its custom collection through
collection_class_for. Falls back to the default Collection when the model
declares no override, leaving every other inference pattern unchanged.

Adds four mutation-verified tests covering both terminals, the
absence-of-override default, and the single-model pattern no-regression.

Fixes #271
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.

feat: Laravel-aware class navigation — Eloquent relationships, facades, factories

1 participant