feat(php): migrate PHP to scope-based resolution model (#938) [supersedes #1124]#1497
Conversation
…ri#938) RFC abhigyanpatwari#909 Ring 3 LANG-php. Third language after Python and C# to be migrated to the scope-based resolver pipeline. PHP module (gitnexus/src/core/ingestion/languages/php/): - scope-resolver.ts: ScopeResolver implementation with PHP-specific MRO builder (traits via IMPLEMENTS edges), namespace-sibling population, arity compatibility, and merge-binding strategy - query.ts: tree-sitter-php .scm patterns for all PHP constructs - captures.ts: emitPhpScopeCaptures with import decomposition, receiver-binding synthesis, arity metadata, and PHPDoc extraction - interpret.ts: interpretPhpImport / interpretPhpTypeBinding - import-decomposer.ts: 1:N decomposer for use/use function/use const including grouped use declarations - import-target.ts: PSR-4 + composer.json resolution adapter - namespace-siblings.ts: cross-file same-namespace visibility - arity-metadata.ts, arity.ts: PHP arity computation and compatibility - merge-bindings.ts: PHP binding precedence (local > import > wildcard) - receiver-binding.ts: $this / parent type-binding synthesis - simple-hooks.ts: bindingScopeFor hoisting for return types and constructor-promoted properties - cache-stats.ts: tree-sitter parse cache metrics Registry wiring: - pipeline/registry.ts: add phpScopeResolver alongside existing Go entry - registry-primary-flag.ts: add PHP to MIGRATED_LANGUAGES Shared pipeline improvements (PHP-motivated, safe for all languages): - graph-bridge/node-lookup.ts: add Trait to isLinkableLabel so PHP and Rust MRO builders can bridge trait defs to graph node ids - passes/free-call-fallback.ts: add arity-narrowing to pickUniqueGlobalCallable; when multiple global candidates exist, narrow by parameterCount/requiredParameterCount before giving up; fixes registry-primary languages where the semantic model is empty Test results: - 159/160 PHP tests pass with REGISTRY_PRIMARY_PHP=1 (scope-resolver) - 160/160 PHP tests pass with REGISTRY_PRIMARY_PHP=0 (legacy path) - 788/788 C#/Python/TypeScript/Go tests green - Typecheck clean - 1 test skipped in registry-primary mode: save->getId on mixed-typed receiver; requires a postResolutionFallback hook in the contract (follow-up to RFC abhigyanpatwari#909 Ring 3)
|
@magyargergo is attempting to deploy a commit to the NexusCore Team on Vercel. A member of the Team first needs to authorize it. |
# Conflicts: # gitnexus/src/core/ingestion/registry-primary-flag.ts # gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts # gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts
…mic languages Adds an optional post-resolution pass on the ScopeResolver contract: when a member-call receiver cannot be typed by the scope chain (no TypeRef), the language may emit CALLS edges via a workspace-wide unique-name lookup. Runs after emitReceiverBoundCalls and before emitFreeCallFallback, gated per-language. PHP wires the hook to recover member calls on mixed/untyped parameters (e.g. save(mixed $entity) calling $entity->getId()), restoring parity with the legacy DAG. Re-enables the previously skipped save → getId test; PHP suite is now 160/160 with no skips in both flag states.
CI Report✅ All checks passed Pipeline Status
Test Results
✅ All 8807 tests passed 1 test(s) skipped — expand for details
Code CoverageTests
📋 View full run · Generated by CI |
…production review
Fixes four PHP-semantic defects identified by the production-readiness
review. The bar is "graph edges must reflect what PHP actually does",
not "matches legacy DAG" — parity with the legacy DAG is not a
correctness criterion when the legacy DAG itself has the same defect.
U1: Variadic requiredParameterCount
arity-metadata.ts:49 now subtracts the variadic slot from required
count: total - optionalCount - (hasVariadic ? 1 : 0). f(int $req,
...$rest) requires 1 arg, not undefined. Adds overload-narrowing and
lookup-core arity-filter changes so resolvers actually drop candidates
that are definitively arity-incompatible (was silently rescuing
empty filter sets even when bounds were known).
U2: True transitive trait MRO
buildPhpMro now uses a BFS worklist (collectTransitiveTraits) to
flatten the trait-of-trait DAG to fixpoint instead of expanding one
level. Adds (trait_declaration body (use_declaration ...)) heritage
query so trait-uses-trait IMPLEMENTS edges are emitted at all. Fixes
3+ level trait chains silently dropping methods.
U3: parent:: bypasses composed traits
ScopeResolver gains optional buildExtendsOnlyMro hook; PHP returns
the unaugmented EXTENDS-only chain via buildPhpExtendsOnlyMro.
MethodDispatchIndex gains optional extendsOnlyMroFor accessor wired
through buildPopulatedMethodDispatch. Super-branch dispatch in
receiver-bound-calls now walks extendsOnlyMroFor when present, so
parent::method() routes to the parent class even when a composed
trait shadows the same name. Other languages leave the hook
undefined and fall back to mroFor unchanged.
U4: Namespace-aware free-call fallback
ScopeResolver gains optional isCallableVisibleFromCaller predicate;
pickUniqueGlobalCallable applies it to filter cross-namespace
candidates the caller can't reach without a use-function import.
PHP impl checks same-namespace OR explicit use-function presence,
using a side-channel namespace cache populated by
populatePhpNamespaceSiblings. Fixes the legacy DAG's namespace-
blind false-positive emissions. Existing php-calls fixture
updated: write_audit now correctly imports its targets via
use-function rather than relying on the false-positive name-only
match.
Tests:
- 175/175 PHP both flag states (REGISTRY_PRIMARY_PHP=0 and =1)
- 794/794 C#/Python/TypeScript/Go/C (no cross-language regression)
- typecheck clean
- 4 new fixtures: php-variadic-arity-minimum, php-transitive-traits,
php-parent-vs-trait, php-namespace-fallback-isolation
Plan: docs/plans/2026-05-11-001-fix-php-resolver-semantic-defects-plan.md
… lang/php-migration-v2
These were stripped by the PHP migration commit (69786b1) as collateral damage during the rebase, but they have nothing to do with scope resolution. They feed cluster/community detection and Laravel route-attribute analysis at the higher analysis layers. Restored verbatim from 69786b1~1: - entryPointPatterns: 17 PHP idioms (Controller, handle/execute/ boot/register, REST verbs, Service/Repository, find/save/delete) - astFrameworkPatterns: Laravel routing block with 3.0x multiplier for Route::get / Route::post / #[Route(...)] attribute, etc. PHP 175/175 still green; typecheck clean.
… lang/php-migration-v2
CI scope-parity / php parity was failing under REGISTRY_PRIMARY_PHP=0 on three assertions added by commit af9af4a (U1 arity-narrowing, U3 trait shadows parent). Per that commit's stance — "parity with the legacy DAG is not a correctness criterion when the legacy DAG itself has the same defect" — backporting these fixes to the legacy resolver is out of scope. Adopt the existing sibling pattern (csharp/typescript/python use the same helper): - php.test.ts: switch to `const it = createResolverParityIt('php')` so expected-failure assertions skip under legacy mode. - helpers.ts: register three test names in LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES.php with the rationale. Verified locally: 172 passed + 3 skipped under REGISTRY_PRIMARY_PHP=0, 175 passed under REGISTRY_PRIMARY_PHP=1, tsc clean.
…o/GitNexus into lang/php-migration-v2
…to lang/php-migration-v2
…npatwari#1497) Fixture php-fqn-cross-namespace declares two `User` classes — `App\Models\User` and `App\Other\User` — and a Service.php that imports the Models simple-name via `use App\Models\User` but uses a fully-qualified `\App\Other\User` in a parameter annotation. Adds three assertions: - Both User classes are detected as Class nodes in distinct files. - The FQN-parameter method's `$u->record()` resolves to app/Other/User.php (currently FAILS — Codex review finding 1). - The simple-name-parameter method's `$u->record()` resolves to app/Models/User.php (control; still passes pre-fix). This is the test-first gate Codex required: "Block merge until the PHP FQN fixture fails before the fix and passes after it." Verified failing on HEAD before U2-U3 land the fix. Also sweeps every `toBeGreaterThanOrEqual` in php.test.ts to exact `.toBe(N)` per DoD.md §2.7 ("avoid bounds-only assertions that mask regressions"). Touched 10 assertions across the U2/U3 trait MRO blocks, the namespace-aware free-call fallback block, and the new FQN block. All previously-green tests stay green with exact counts — no latent over-emission bugs were hidden by `>= 1`. The ambiguous-overload finding (Codex finding 2) cannot be exercised by a PHP integration fixture — PHP does not support method overloading, so `model.methods.lookupAllByOwner` returns at most 1 entry per (class, name) pair. That regression test ships as a unit test against `pickImplicitThisOverload` in U4 once the function is exported. Plan: docs/plans/2026-05-11-002-fix-php-fqn-and-overload-codex-findings-plan.md (U1)
Stop collapsing `\App\Models\User` to `User` in normalizePhpType (step 6). Canonicalize the leading backslash off and preserve the qualified path on TypeRef.rawName so downstream PHP receiver resolution can distinguish the FQN target from a same-simple-name class reachable via `use`. - Step 6 rewritten: `\App\Models\User` → `App\Models\User`, `App\Models\User` → unchanged, `User` → unchanged. - Final validation regex relaxed from /^\w+$/ to /^\w+(?:\\w+)*$/ to accept qualified PHP identifiers while still rejecting empty segments, trailing backslashes, and non-identifier characters. - Step 5 (single-arg generic strip) already passes qualified inner types through via its existing /^\w[\w\]*/ pattern — no change needed. U3 will wire the qualified-name lookup path; this commit alone is a no-op for resolution (the lookup chain still keys on simple names). PHP suite remains green at 177 passed; the only failing test is the intentional FQN regression from U1. Plan: docs/plans/2026-05-11-002-fix-php-fqn-and-overload-codex-findings-plan.md (U2)
…ri#1497 finding 1) Extend populatePhpNamespaceSiblings with Step 3b: for every PHP file's Module scope, inject a binding entry keyed by the fully-qualified class name (`App\Models\User`) for every class-like def in the workspace. This routes FQN-receivers like `\App\Other\User` to the exact namespace-qualified class regardless of which simple-name `User` the caller's `use` imports shadowed. Why module-scope bindingAugmentations instead of mutating def.qualifiedName: the shared QualifiedNameIndex consumes def.qualifiedName at finalize time, but PHP class defs need to remain keyed by simple name throughout the rest of the pipeline (MRO, method-dispatch-index, namespace-siblings step 3). An earlier attempt to rewrite def.qualifiedName to namespace-prefixed form cascaded into 32 unrelated test failures across receiver-binding, MRO, and heritage. The bindingAugmentations channel is purpose-built for adding post-finalize visibility without mutating shared semantic state, and `findClassBindingInScope`'s scope-chain walk already consumes it via `lookupBindingsAt` — wiring is zero-touch. Cost: O(PHP files × class-like defs) augmentation entries. Typical PHP project: hundreds × hundreds = bounded. Verified locally: - Registry-primary: 178/178 PHP tests pass (including the new FQN regression). - Legacy DAG: 174 passed, 3 existing skips, 1 failure (the FQN test, expected — legacy DAG has no namespace augmentation channel; U5 registers it in LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES.php). - Cross-language: 794/794 (C#, Python, TypeScript, Go, C) pass. The PHP-only augmentation does not touch shared resolution code. - tsc clean. Plan: docs/plans/2026-05-11-002-fix-php-fqn-and-overload-codex-findings-plan.md (U3)
…erload Codex PR abhigyanpatwari#1497 review, finding 2: pickImplicitThisOverload returned candidates[0] after narrowOverloadCandidates without checking uniqueness. When two same-name methods on the same class shared identical arity and the call site lacked disambiguating argument-type info, narrowing left both compatible and the resolver emitted a high-confidence CALLS edge whose target depended on registration order rather than a defensible resolution. Tighten the picker: - candidates.length === 1 -> return candidates[0] (unchanged for the unambiguous case) - candidates.length !== 1 (zero or multiple) -> return undefined (call left unresolved; no edge emitted) This mirrors pickUniqueGlobalCallable's existing pattern in the same file. Export pickImplicitThisOverload so a unit test can exercise it with synthetic stubs — PHP cannot produce the multi-overload failure shape (no method overloading in PHP), and C# integration coverage would entangle the unit's contract with the broader C# resolver. The unit test pins five cases: sole overload, narrowing-disambiguated, the ambiguous multi-candidate case (the bug regression), no-match, and no-enclosing-class. Verified: 972/972 across PHP + C# + Python + TypeScript + Go + C resolver suites; no regression in any language. tsc clean. Plan: docs/plans/2026-05-11-002-fix-php-fqn-and-overload-codex-findings-plan.md (U4)
…anpatwari#1497) After U3 (FQN-keyed bindingAugmentations) lands, the FQN regression passes under REGISTRY_PRIMARY_PHP=1 but still fails under =0. The legacy DAG resolves receiver types via simple-name workspace lookup and has no namespace-prefixed binding channel, so it cannot distinguish `\App\Other\User` from a same-simple-name class reachable via `use`. Per the established convention from commit af9af4a ("parity with the legacy DAG is not a correctness criterion when the legacy DAG itself has the same defect"), the test registers in LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES.php rather than blocking the migration. The other two assertions in the new describe block (class detection and the saveLocal control) pass in both modes and stay green. Verified: - REGISTRY_PRIMARY_PHP=0: 174 passed, 4 skipped (3 existing + 1 new). - REGISTRY_PRIMARY_PHP=1: 178 passed. - C# + Python + TypeScript + Go + C resolver matrix: 794 passed. - pickImplicitThisOverload unit tests: 5 passed. - resolver-parity-expected-failures unit test: 3 passed. Plan: docs/plans/2026-05-11-002-fix-php-fqn-and-overload-codex-findings-plan.md (U5)
…o/GitNexus into lang/php-migration-v2
| if ( | ||
| narrowOverloadCandidates([memberDef], site.arity, site.argumentTypes).length === 0 | ||
| ) { | ||
| memberDef = undefined; | ||
| continue; |
There was a problem hiding this comment.
Bug: MRO chain incorrectly falls through to parent on arity mismatch
The MRO chain for Case 2 is ordered most-derived-first: [Child, Parent, Grandparent, ...]. When Child::method() is found but arity-incompatible with the call site, this code clears memberDef and continues — walking to Parent::method() and potentially emitting a CALLS edge there if that ancestor happens to be arity-compatible.
This is incorrect. PHP always dispatches to the most-derived override. If the call is arity-incompatible with Child::method, PHP throws ArgumentCountError at runtime — it never silently redirects to an ancestor method. The resolver should emit no edge for this call.
The comment "a subclass may shadow with a different arity" is also inverted: the chain walks up toward ancestors (less-derived), not down toward subclasses.
The fix is to break instead of continue, so that discovering an arity-incompatible most-derived definition terminates the chain walk and emits no edge:
| if ( | |
| narrowOverloadCandidates([memberDef], site.arity, site.argumentTypes).length === 0 | |
| ) { | |
| memberDef = undefined; | |
| continue; | |
| if ( | |
| narrowOverloadCandidates([memberDef], site.arity, site.argumentTypes).length === 0 | |
| ) { | |
| memberDef = undefined; | |
| break; |
| type: (_) | ||
| (property_element | ||
| name: (variable_name) @declaration.name)) @declaration.property | ||
|
|
There was a problem hiding this comment.
Bug: @declaration.variable pattern double-matches typed properties
In tree-sitter, a field constraint in one pattern does not exclude that field in another. The @declaration.property pattern (lines 91–95) requires a type: field. But this catch-all pattern has no guard against the presence of a type: field, so tree-sitter matches it against all property_declaration nodes — including typed ones already captured above.
For a typed property such as private UserRepo $repo;, tree-sitter emits two matches:
@declaration.property→Propertydef namedrepo(after$-strip incaptures.ts)@declaration.variable→Variabledef named$repo(no strip, different type key)
These are registered with different DefIds and different names, so both survive into the graph. The spurious Variable node can conflict with type-binding lookups that key on $-prefixed names, and captures.ts itself acknowledges the expected mutual exclusivity (lines 95–98: "Only applies to @declaration.property") — but the query does not enforce it.
Fix options:
- Use a tree-sitter
#not-has?predicate on this pattern (if supported by the tree-sitter-php version in use):(#not-has? @declaration.variable type) - Or filter in
captures.ts: skip the@declaration.variableprocessing branch whengrouped['@declaration.property']is also present for the same anchor node.
|
Claude finished @magyargergo's task in 5m 1s —— View job Dynamic PHP Resolution Review — PR #1497Tasks
Dynamic PHP Resolution ReviewCore Safety MechanismAll dynamic-call safety in this PR is enforced by a single tree-sitter constraint: every call and write reference capture in This is a structural guarantee, not a runtime check. FindingsFinding 1 —
|
| Test | Currently exists |
|---|---|
$obj->$method() with unknown $method emits no edges |
❌ |
call_user_func($unknown) does not bind to arbitrary function |
❌ |
call_user_func([$obj, 'known']) — no edge when $obj untyped |
❌ |
$obj->$prop does not create a static edge to property prop |
❌ |
$className::method() — no edge when $className is untyped variable |
❌ |
The guarantees exist in the grammar (Finding 1–2) but are not regression-tested. A future refactor that inadvertently adds a variable_name match to the query would break the safety invariant silently.
Recommended fix: Add a PHP fixture with dynamic call patterns and a test asserting the edge set does NOT contain certain calls. Fix this →
Blocks merge: No — but strongly recommended before a second language consumer relies on these guarantees.
Tests/Coverage Assessment
The positive test suite is thorough: 159/160 passed (1 documented skip for mixed-typed receiver). The fixture coverage for normal PHP resolution (PSR-4, traits, enums, arity, namespaces) is solid.
The critical gap is entirely in negative coverage: there is no test that confirms the tree-sitter name: (name) safety net actually prevents dynamic-pattern capture. The guarantee is structurally sound today, but untested safety nets erode.
Final Verdict
production-ready with minor follow-ups for this dynamic-call slice
The core mechanism is sound: tree-sitter grammar node-type constraints (name: (name)) prevent dynamic PHP expressions from ever entering the reference capture pipeline. call_user_func and array/string callables are handled correctly by inaction — the pipeline never inspects argument values for callable dispatch, so no false edges are created. The phpEmitUnresolvedReceiverEdges hook is the only source of non-zero-confidence edges from dynamic-looking sites, and it is bounded, low-confidence, and documented.
The one action item before depending on these guarantees in production is adding negative regression tests (Finding 10) so the grammar-level safety net does not silently regress.
When the class-name receiver pass (Case 2 in receiver-bound-calls.ts) found a most-derived definition that was arity-incompatible with the call site, the previous code used `continue` to fall through to the next ancestor in the MRO chain. If an ancestor happened to be arity- compatible, the resolver emitted a false CALLS edge to it. This is incorrect: PHP dispatches to the most-derived override at runtime and throws `ArgumentCountError` when arities don't match - it never silently redirects to an ancestor. Replace the inner `continue` with `break` so the chain walk terminates and no edge is emitted for the site. Adds php-mro-arity-mismatch fixture and 5 regression tests covering: - the bug scenario (Child::method/2 + Parent::method/1, call with 1 arg) - arity-compatible happy path (Child::compat/1) - no-parent case (Orphan with arity mismatch) - happy path most-derived call - class detection sanity check
The untyped @declaration.variable catch-all pattern in query.ts (lines 101-103) has no `type:` constraint and tree-sitter therefore also matches it against typed property declarations - emitting a second capture for the same property_declaration anchor. Graph-level def-id collision currently masks the duplicate at the node-emit layer, but the catch-all capture still flows through scope-binding and name-keyed registries with a `$`-prefixed name that the typed branch's `$`-strip never normalizes - a known vector for receiver-binding lookup pollution. The two tree-sitter patterns produce separate rawMatches entries with separate `grouped` maps, so the dedup must be cross-match. Pre-scan rawMatches once to collect anchor node IDs already covered by @declaration.property, then skip @declaration.variable matches whose anchor is in that set. Adds php-typed-property-dedup fixture covering typed property, constructor-promoted typed parameter, and a mixed-untyped declaration to verify the catch-all path still fires for untyped properties.
The PR abhigyanpatwari#1497 adversarial review confirmed via grammar inspection that dynamic PHP call shapes ($obj->$method(), $obj->{$method}(), Class::$method(), $className::method(), call_user_func with variable / string / array callables, dynamic property reads) currently produce zero captures and zero CALLS edges - because the member_call_expression and scoped_call_expression query patterns constrain `name:` to `(name)` rather than `(_)`, deliberately excluding variable_name nodes. That safety invariant was not regression-tested. A future query.ts edit relaxing `name:` to `(_)` would silently emit false-positive edges. Add a php-dynamic-calls fixture covering all 11 dynamic shapes from Findings 1-7 plus a sanity-check static call that DOES emit an edge - without the sanity check every zero-edge assertion would pass even if the pipeline emitted no edges at all. Adds inline SAFETY-INVARIANT comments above the three load-bearing query patterns (member_call_expression, scoped_call_expression, and the static-write property pattern) referencing this fixture as the regression net.
…y (U4)
phpEmitUnresolvedReceiverEdges emits 0.6-confidence CALLS edges for
member-call sites whose receiver has no type binding (PHP 'mixed'-typed
parameters, untyped variables) when the called method name has a single
workspace-wide candidate. The existing first-stage gate accepted any
argCount in 'min..max' (or '>= min' with variadic), which over-emits
when the lone candidate has optional / defaulted parameters - the
fallback fires for calls that happen to fit the wider range but
weren't meant for that target.
Add an EXACT-required-arity gate for the 0.6-confidence path only:
for fixed-arity candidates, require argCount === requiredParameterCount.
Variadic candidates ('...' marker in parameterTypes) keep the relaxed
'>= required' semantics already enforced by the first-stage check.
Adds php-unresolved-receiver-arity fixture with seven scenarios covering
happy path, default-param exact match, default-param over-arity (the
narrowed case), variadic at/above/below required, and the class-
detection sanity check. Pre-fix the over-arity scenario emits a stray
0.6 edge; post-fix it does not. All existing PHP integration tests
continue to pass.
The shared narrowOverloadCandidates pass in scope-resolution/passes/ overload-narrowing.ts checks for 'params'/'params ' as the variadic marker - the C# 'params' keyword convention. PHP uses '...' instead, matching its source-language syntax. The discrepancy is harmless in practice because PHP variadic methods set parameterCount=undefined, which skips the 'max !== undefined && argCount > max' gate that hosts the 'params' check entirely - so the C# branch is dead code for PHP. Document the asymmetry at both sites with cross-references so a future contributor reading either file lands on the full picture: - overload-narrowing.ts:48 area: explains the 'params' branch is C#- specific and warns that adding new markers requires auditing each adapter's arity-metadata.ts. - languages/php/arity-metadata.ts:46 area: explains PHP uses '...' intentionally and that the shared pass's 'params' branch is dead code for PHP because of the parameterCount=undefined setting. No behavior change. Existing PHP variadic-resolution tests already exercise the live '...' path.
Four tests added by U1 and U4 assert correctness wins that only the scope-resolution pipeline delivers. The legacy DAG path has no equivalent narrowing, so it emits the over-broad edges these tests forbid: - U1: MRO arity narrowing on class-name receivers (Case 2 in receiver-bound-calls.ts) — legacy has no arity check between MRO iterations, so 'Child::method(1)' falls through to Parent::method. - U1: single-class arity gating — legacy resolves by name without arity, so an arity-incompatible call on a class with no parent still emits an edge. - U4: 'phpEmitUnresolvedReceiverEdges' exact-required-arity gate — the legacy DAG has no equivalent unresolved-receiver fallback hook, so default-param over-arity and variadic-below-required shapes resolve via a different code path that over-emits. Backporting these narrowing checks to the legacy DAG is out of scope, matching the existing pattern in LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES for record/pad zero-args (commit af9af4a U1) and the FQN cross- namespace test (Codex PR abhigyanpatwari#1497 finding 1).
Four tests were failing on lang/php-migration-v2 in the tests/ubuntu/coverage
job before this commit. Root causes and fixes:
1. cross-file-binding.test.ts: Consumer-Before-Provider PHP
The fixture had BProvider.php at app/BProvider.php while declaring
namespace App\Models — a PSR-4 violation that caused PHP's import-
target resolver to return null for 'use function App\Models\getUser'.
Without a resolved import target, no IMPORTS edge was emitted, the
SCC graph had no edge between consumer and provider, and the shared
propagateImportedReturnTypes pass never mirrored getUser's return
type into AConsumer's scope. Result: $u (bound to 'getUser' by
query.ts:167-169 type-binding.alias) never collapsed to User, so
$u->save() went unresolved.
Move BProvider.php to its PSR-4-compliant location at
app/Models/User.php. The class+function were already in namespace
App\Models; the rename just aligns the file path with the
namespace prefix mapped by composer.json. After the move:
- PSR-4 directory scan finds the file for 'use function
App\Models\getUser' (lines 65-86 of php import-resolver).
- IMPORTS edge AConsumer.php → app/Models/User.php emits.
- SCC reverse-topological walk in propagateImportedReturnTypes
mirrors getUser→User from User.php's module-scope typeBindings
into AConsumer's, then chain-follows $u → getUser → User.
- $u->save() resolves to User#save.
2. registry-primary-flag.test.ts: 'flipped languages' size === 1
When PHP was added to MIGRATED_LANGUAGES at commit 69786b1 (the
PHP scope-based resolution migration), the hard-coded opt-out list
in this test did not include REGISTRY_PRIMARY_PHP. With PHP
default-on and not opted out, enabled.size returned 2 (Java + PHP)
instead of 1. Rewrite the opt-out loop to iterate
MIGRATED_LANGUAGES dynamically so future Ring 3 additions land
without test churn.
3. overload-narrowing.test.ts: 'falls back to the full overload list'
Commit af9af4a (PR abhigyanpatwari#1497 production review fixes) deliberately
tightened narrowOverloadCandidates to stop silently rescuing
empty filter sets when every candidate had definite bounds — the
call is genuinely arity-incompatible (e.g. PHP variadic with
required-prefix called with too few args). The unit test still
asserted the old soft-rescue behavior. Rename and update the
assertion to match the intentional new semantics; document that
the 'anyUnknownBounds' branch in the source is structurally
unreachable from this caller shape (an unknown-bounds candidate
always passes the arity filter, so arityMatches is always
non-empty when anyUnknownBounds is true).
4. registries.test.ts: 'keeps incompatible candidates (soft penalty)'
Same root cause as abhigyanpatwari#3 at the registries layer: lookup-core.ts now
drops every candidate when all are 'incompatible' and none are
'unknown'. The 'soft penalty' kept-with-evidence behavior survives
only when at least one candidate's arity verdict is 'unknown' —
that signal differentiates definite mismatch from missing metadata.
Update the existing test to assert the new hard-rejection behavior
and add a new test exercising the surviving soft-penalty path with
one 'unknown' + one 'incompatible' candidate.
Verification:
- Originally-failing tests: 4 of 4 now pass (no skips).
- PHP integration suite: 205/205 in primary mode, 197+8 skipped in
legacy mode (unchanged).
- Cross-language unit + integration: no regressions.
|
Claude finished @magyargergo's task in 6m 12s —— View job Production Readiness Review — PHP Scope-Based Resolution (PR #1497)
|
| Risk area | Fixture | Positive | Negative |
|---|---|---|---|
| MRO arity mismatch (U1) | php-mro-arity-mismatch |
✅ callCompatible → ChildModel |
✅ callIncompatible → no edge |
| Typed property dedup (U2) | php-typed-property-dedup |
✅ exactly 1 repo Property |
✅ zero $repo Variable |
| Dynamic calls (U3) | php-dynamic-calls |
✅ sanity static edge | ✅ zero edges for all dynamic shapes |
| Fallback arity gate (U4) | php-unresolved-receiver-arity |
✅ exact-required matches | ✅ beyond-required rejected |
| FQN cross-namespace | php-fqn-cross-namespace |
✅ FQN → Other/User.php | ✅ not Models/User.php |
| parent:: bypass traits | php-parent-vs-trait |
✅ parent → Base.php | ✅ parent not Auditable.php |
| Transitive traits | php-transitive-traits |
✅ depth-3 deepMethod |
N/A (pure positive) |
| Namespace isolation | php-namespace-fallback-isolation |
✅ same-ns matches | ✅ cross-ns no edge |
Hidden Unicode / Hygiene — ✅ Clean
No bidi control characters (U+202A–U+202E, U+2066–U+2069) found in any PHP source, test, shared-pass, or fixture file. Non-ASCII characters present are em dashes (—) in doc comments — acceptable visible punctuation.
Final Verdict
production-ready with minor follow-ups
All issues identified in prior reviews (U1 MRO fallthrough, U2 typed-property dedup, U3 dynamic-dispatch suppression, U4 unresolved-receiver fallback tightening, U5 variadic marker documentation, FQN preservation, transitive traits, parent:: bypass, namespace isolation) have been addressed with targeted fixes and regression tests. The shared pipeline changes are backward-compatible for all existing languages. The legacy parity skip list is narrowly scoped with precise justifications. CI passes cleanly across all platforms.
Two minor follow-ups are recommended but not merge-blocking: (1) expand PSR-4 array-directory support (Finding B), and (2) dismiss the CodeQL false positive for phpEmitUnresolvedReceiverEdges (Finding A). The undocumented as/insteadof trait conflict limitation (Finding C) should be tracked as a follow-up issue — it does not affect correct PHP code (which must declare conflicts explicitly).
Resolve conflicts from PR abhigyanpatwari#1497 (PHP migration to scope-based resolution): - registry-primary-flag.ts: include both CPlusPlus and PHP in MIGRATED_LANGUAGES - scope-resolution/pipeline/registry.ts: register both cppScopeResolver and phpScopeResolver - registry-primary-flag.test.ts: keep dynamic opt-out loop from main; assert both CPlusPlus and PHP are off - call-processor.ts: keep HEAD's deferred-write 'line' field (used for unique ACCESSES edge IDs)

Summary
This PR supersedes #1124 (stale, conflicting with main). Rebased cleanly onto current
origin/main(10+ commits ahead of the original merge-base), preserving all new hooks added by Go (#1302), range-binding, and structural-interface rings.RFC #909 Ring 3 LANG-php — third language migrated to the scope-based resolver pipeline after Python and C#.
What's in this PR
PHP module (
gitnexus/src/core/ingestion/languages/php/): 13 new filesscope-resolver.ts—ScopeResolverimplementation: PHP MRO with trait-use via IMPLEMENTS edges, namespace-sibling population, arity compatibility, merge-binding strategy,allowGlobalFreeCallFallbackfor unimported same-namespace callsquery.ts— tree-sitter-php patterns for all PHP constructs (classes, traits, interfaces, enums, namespaces, use/use function/use const, member/free/nullsafe/static calls, PHPDoc)captures.ts—emitPhpScopeCaptures: import decomposition, receiver-binding synthesis, arity metadata, PHPDoc extractioninterpret.ts—interpretPhpImport/interpretPhpTypeBindingimport-decomposer.ts— 1:N decomposer for allusedeclaration forms including groupeduse Foo\{A, B as C}import-target.ts— PSR-4 +composer.jsonresolution adapternamespace-siblings.ts— cross-file same-namespace visibility (mirrors C# implementation)arity-metadata.ts,arity.ts— PHP arity computation andarityCompatibilitymerge-bindings.ts— binding precedence: local > import/namespace > wildcardreceiver-binding.ts—$this/parenttype-binding synthesissimple-hooks.ts—bindingScopeForhoisting for return types and constructor-promoted propertiescache-stats.ts— tree-sitter parse cache metricsRegistry wiring (2 files, minimal):
pipeline/registry.ts— addsphpScopeResolveralongside existing Go/Python/C#/TS entriesregistry-primary-flag.ts— adds PHP toMIGRATED_LANGUAGESShared pipeline improvements (PHP-motivated, safe for all languages):
graph-bridge/node-lookup.ts— adds'Trait'toisLinkableLabelso PHP (and Rust) MRO builders can bridge trait defs to graph node IDs via IMPLEMENTS edges; Go is unaffectedpasses/free-call-fallback.ts— adds arity-narrowing topickUniqueGlobalCallable: when multiple global candidates share a name, narrows byparameterCount/requiredParameterCountbefore giving up; fixes registry-primary languages where the semantic model is not populated by the legacy call-processorP0 staleness fix (Kieran review finding)
The original PR #1124 deleted 6 hooks from
ScopeResolverthat were added on main by Go (#1302) and other rings (expandsWildcardTo,populateWorkspaceOwners,detectInterfaceImplementations,mirrorNamespaceTypeBindings,populateRangeBindings,allowGlobalFreeCallFallback). This PR restores all shared-pipeline files toorigin/mainand applies PHP changes on top.Known limitation (follow-up)
save($entity: mixed)->getId()— member calls onmixed-typed parameters have no TypeRef in scope and cannot be resolved by the current contract. The stale PR used a non-contractemitUnresolvedReceiverEdgeshook. The 1 affected test is skipped in registry-primary mode with a comment; apostResolutionFallbackcontract hook is tracked as a follow-up to RFC #909 Ring 3.Test results
npx tsc --noEmit)Go resolver tests are fully green — no regressions from the shared pipeline changes.
Files changed
20 files: 13 new PHP module files,
php.ts(modified),registry.ts,registry-primary-flag.ts,node-lookup.ts,free-call-fallback.ts,php.test.ts.Closes #938 (RFC #909 Ring 3 LANG-php)