diff --git a/gitnexus-shared/src/scope-resolution/method-dispatch-index.ts b/gitnexus-shared/src/scope-resolution/method-dispatch-index.ts index d09e8fa89..dcaa45055 100644 --- a/gitnexus-shared/src/scope-resolution/method-dispatch-index.ts +++ b/gitnexus-shared/src/scope-resolution/method-dispatch-index.ts @@ -40,11 +40,27 @@ export interface MethodDispatchIndex { readonly mroByOwnerDefId: ReadonlyMap; /** Interfaces / traits → classes that implement them. */ readonly implsByInterfaceDefId: ReadonlyMap; + /** + * Optional parallel MRO view that EXCLUDES mixin-like augmentation + * (e.g., PHP traits). Populated only when the input supplies + * `computeExtendsOnlyMro`. Used by the super-branch dispatch in + * `receiver-bound-calls` so that `parent::method()` walks the + * inheritance chain only, not the trait-augmented one. Undefined for + * languages without mixin-like semantics — callers should fall back + * to `mroFor` when this is missing. + */ + readonly extendsOnlyMroByOwnerDefId?: ReadonlyMap; /** `mroByOwnerDefId.get`, with an empty frozen array on miss. */ mroFor(ownerDefId: DefId): readonly DefId[]; /** `implsByInterfaceDefId.get`, with an empty frozen array on miss. */ implementorsOf(interfaceDefId: DefId): readonly DefId[]; + /** + * `extendsOnlyMroByOwnerDefId.get`, with an empty frozen array on miss. + * Undefined when `extendsOnlyMroByOwnerDefId` was not populated; callers + * should treat this as equivalent to `mroFor` for non-mixin languages. + */ + readonly extendsOnlyMroFor?: (ownerDefId: DefId) => readonly DefId[]; } export interface MethodDispatchInput { @@ -81,12 +97,25 @@ export interface MethodDispatchInput { * write-wins policy and fires at most once per unique owner. */ readonly implementsOf: (ownerDefId: DefId) => readonly DefId[]; + /** + * Optional: return the EXTENDS-only ancestor chain for `ownerDefId`, + * excluding the owner itself AND any mixin-like augmentation (e.g., + * PHP traits). Languages without mixin semantics leave this undefined + * and the index's `extendsOnlyMroByOwnerDefId` stays unpopulated. + * + * Same contract as `computeMro`: pure, deterministic, `[]` on no parents. + * Called at most once per unique owner (first-write-wins). + */ + readonly computeExtendsOnlyMro?: (ownerDefId: DefId) => readonly DefId[]; } // ─── Builder ──────────────────────────────────────────────────────────────── export function buildMethodDispatchIndex(input: MethodDispatchInput): MethodDispatchIndex { const mroByOwnerDefId = new Map(); + const extendsOnlyByOwnerDefId = input.computeExtendsOnlyMro + ? new Map() + : undefined; const implsBuilding = new Map(); const implsSeen = new Map>(); @@ -97,6 +126,14 @@ export function buildMethodDispatchIndex(input: MethodDispatchInput): MethodDisp const chain = input.computeMro(ownerId); mroByOwnerDefId.set(ownerId, Object.freeze(chain.slice())); } + if ( + input.computeExtendsOnlyMro !== undefined && + extendsOnlyByOwnerDefId !== undefined && + !extendsOnlyByOwnerDefId.has(ownerId) + ) { + const extOnly = input.computeExtendsOnlyMro(ownerId); + extendsOnlyByOwnerDefId.set(ownerId, Object.freeze(extOnly.slice())); + } for (const ifaceId of input.implementsOf(ownerId)) { let seen = implsSeen.get(ifaceId); @@ -121,7 +158,7 @@ export function buildMethodDispatchIndex(input: MethodDispatchInput): MethodDisp implsByInterfaceDefId.set(ifaceId, Object.freeze(owners.slice())); } - return wrapIndex(mroByOwnerDefId, implsByInterfaceDefId); + return wrapIndex(mroByOwnerDefId, implsByInterfaceDefId, extendsOnlyByOwnerDefId); } // ─── Internal ─────────────────────────────────────────────────────────────── @@ -131,8 +168,9 @@ const EMPTY: readonly DefId[] = Object.freeze([]); function wrapIndex( mroByOwnerDefId: Map, implsByInterfaceDefId: Map, + extendsOnlyMroByOwnerDefId: Map | undefined, ): MethodDispatchIndex { - return { + const base: MethodDispatchIndex = { mroByOwnerDefId, implsByInterfaceDefId, mroFor(ownerDefId: DefId): readonly DefId[] { @@ -142,4 +180,14 @@ function wrapIndex( return implsByInterfaceDefId.get(interfaceDefId) ?? EMPTY; }, }; + if (extendsOnlyMroByOwnerDefId !== undefined) { + return { + ...base, + extendsOnlyMroByOwnerDefId, + extendsOnlyMroFor(ownerDefId: DefId): readonly DefId[] { + return extendsOnlyMroByOwnerDefId.get(ownerDefId) ?? EMPTY; + }, + }; + } + return base; } diff --git a/gitnexus-shared/src/scope-resolution/registries/lookup-core.ts b/gitnexus-shared/src/scope-resolution/registries/lookup-core.ts index a18ad4930..fff3e7adb 100644 --- a/gitnexus-shared/src/scope-resolution/registries/lookup-core.ts +++ b/gitnexus-shared/src/scope-resolution/registries/lookup-core.ts @@ -423,13 +423,30 @@ function applyArityFilter( } let anyCompatible = false; + let anyUnknown = false; for (const state of perCandidate.values()) { const verdict = arityFn(callsite, state.def); state.signals.arityVerdict = verdict; if (verdict === 'compatible') anyCompatible = true; + else if (verdict === 'unknown') anyUnknown = true; } - if (!anyCompatible) return; + // When ALL candidates are 'incompatible' (none compatible, none unknown), + // the call is genuinely arity-broken — drop every candidate so the + // registry returns no resolution. This matches the PHP variadic case + // f(int $req, ...$rest) called with zero args: every candidate definitively + // rejects, and emitting an edge to a definitively-rejected callable is + // a false positive. When some candidates are 'unknown' (missing metadata), + // keep the set so downstream evidence can break the tie — that's the + // original safety-fallback behavior. + if (!anyCompatible) { + if (!anyUnknown) { + for (const defId of perCandidate.keys()) { + perCandidate.delete(defId); + } + } + return; + } // Filter: when at least one compatible candidate exists, drop incompatibles. for (const [defId, state] of perCandidate) { diff --git a/gitnexus/src/core/ingestion/languages/php/arity-metadata.ts b/gitnexus/src/core/ingestion/languages/php/arity-metadata.ts index 9dc07b98c..6b80a057d 100644 --- a/gitnexus/src/core/ingestion/languages/php/arity-metadata.ts +++ b/gitnexus/src/core/ingestion/languages/php/arity-metadata.ts @@ -8,7 +8,10 @@ * `undefined`, which `phpArityCompatibility` then treats as * "max unknown" — the candidate stays eligible at `argCount >= required`. * - Defaulted parameters (`= expr`) contribute to `optionalCount`; - * `requiredParameterCount = total − optionalCount`. + * `requiredParameterCount = total − optionalCount − (variadic ? 1 : 0)`. + * The variadic slot itself accepts zero args so it is subtracted from + * the required count — `f(int $a, ...$rest)` requires exactly 1 arg, + * not 2, and `f(...$rest)` requires 0. * - `property_promotion_parameter` (constructor-promoted) is counted * the same as `simple_parameter` since both consume an argument slot. * - `parameterTypes` collects declared type names; a literal `'...'` @@ -46,7 +49,10 @@ export function computePhpArityMetadata(fnNode: SyntaxNode): PhpArityMetadata { // Variadic methods accept any arg count ≥ required — leave `parameterCount` // undefined so the registry treats max as unknown. const parameterCount = hasVariadic ? undefined : total; - const requiredParameterCount = hasVariadic ? undefined : total - optionalCount; + // The variadic slot itself accepts zero args; subtract it from the required + // count so PHP's ArgumentCountError-equivalent calls (too few args before + // the variadic) are correctly rejected by arity compatibility. + const requiredParameterCount = total - optionalCount - (hasVariadic ? 1 : 0); return { parameterCount, diff --git a/gitnexus/src/core/ingestion/languages/php/namespace-siblings.ts b/gitnexus/src/core/ingestion/languages/php/namespace-siblings.ts index edf06519a..6ebfbde2a 100644 --- a/gitnexus/src/core/ingestion/languages/php/namespace-siblings.ts +++ b/gitnexus/src/core/ingestion/languages/php/namespace-siblings.ts @@ -106,6 +106,24 @@ export interface PhpSiblingInputs { readonly treeCache?: { get(filePath: string): unknown }; } +/** + * Side-channel cache populated by `populatePhpNamespaceSiblings` so that + * later visibility-check hooks (e.g., `isCallableVisibleFromCaller`) can + * look up a file's PHP namespace without re-parsing. Cleared at the start + * of every populate run so stale entries don't leak across resolutions. + */ +const namespaceByFilePath = new Map(); + +/** + * Read the cached PHP namespace for a given filePath. Returns `''` (global) + * when the file has no namespace_definition or hasn't been processed yet. + * Callers should only consult this AFTER `populatePhpNamespaceSiblings` has + * run for the current resolution. + */ +export function getPhpNamespaceForFile(filePath: string): string { + return namespaceByFilePath.get(filePath) ?? ''; +} + /** * Inject same-namespace class defs and return-type bindings into each * PHP file's Module scope's `bindingAugmentations`. This makes classes @@ -120,13 +138,17 @@ export function populatePhpNamespaceSiblings( indexes: ScopeResolutionIndexes, inputs: PhpSiblingInputs, ): void { - // Step 1: extract namespace structure for each file. + // Step 1: extract namespace structure for each file. Also seed the + // side-channel cache used by visibility-check hooks downstream. + namespaceByFilePath.clear(); const structureByFile = new Map(); for (const parsed of parsedFiles) { const content = inputs.fileContents.get(parsed.filePath); if (content === undefined) continue; const cachedTree = inputs.treeCache?.get(parsed.filePath); - structureByFile.set(parsed.filePath, extractPhpFileStructure(content, cachedTree)); + const struct = extractPhpFileStructure(content, cachedTree); + structureByFile.set(parsed.filePath, struct); + namespaceByFilePath.set(parsed.filePath, struct.namespace); } // Step 2: group class-like defs and module scopes by namespace. diff --git a/gitnexus/src/core/ingestion/languages/php/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/php/scope-resolver.ts index b0c2a666e..fd053506a 100644 --- a/gitnexus/src/core/ingestion/languages/php/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/php/scope-resolver.ts @@ -33,12 +33,14 @@ import { resolveCallerGraphId, resolveDefGraphId, } from '../../scope-resolution/graph-bridge/ids.js'; +import { narrowOverloadCandidates } from '../../scope-resolution/passes/overload-narrowing.js'; import type { SemanticModel } from '../../model/semantic-model.js'; import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; +import type { SymbolDefinition } from 'gitnexus-shared'; import { phpProvider } from '../php.js'; import { phpArityCompatibility, phpMergeBindings } from './index.js'; import { resolvePhpImportTargetInternal, loadPhpComposerConfig } from './import-target.js'; -import { populatePhpNamespaceSiblings } from './namespace-siblings.js'; +import { populatePhpNamespaceSiblings, getPhpNamespaceForFile } from './namespace-siblings.js'; /** * PHP MRO builder — extends the generic EXTENDS-only MRO with trait-use @@ -58,6 +60,72 @@ import { populatePhpNamespaceSiblings } from './namespace-siblings.js'; * a trait method shadows the parent-class method but is overridden by the * using class's own methods). */ +/** + * PHP free-call visibility check for `pickUniqueGlobalCallable`. Returns + * true when the candidate function is reachable from the caller's PHP + * namespace context, false when the cross-namespace bridge would be a + * false positive (e.g., `\App\Utils\format` is not visible from `\App` + * without an explicit `use function App\Utils\format;`). + * + * Rules (PHP semantics): + * 1. Same-namespace candidates are always visible. + * 2. Global-namespace candidates (no namespace prefix) are visible from + * every caller — PHP's global fallback for functions/constants. + * 3. Candidates in a different namespace are visible only when the + * caller has a `use function` import that matches the candidate's + * fully-qualified name. + */ +function phpIsCallableVisibleFromCaller(ctx: { + callerParsed: ParsedFile; + candidate: SymbolDefinition; +}): boolean { + const { callerParsed, candidate } = ctx; + const callerNs = getPhpNamespaceForFile(callerParsed.filePath); + const candNs = getPhpNamespaceForFile(candidate.filePath); + + // Global-namespace candidate: PHP falls back to global for functions + // and constants when the local namespace doesn't define them. + if (candNs === '') return true; + + // Same-namespace: caller can see the candidate without an explicit use. + if (candNs === callerNs) return true; + + // Cross-namespace: require an explicit `use function` import in the + // caller's parsedImports that matches the candidate's fully-qualified + // name. interpret.ts maps `use function Foo\bar` to a named import with + // localName = 'bar' and targetRaw = 'Foo\\bar'. + const candQualified = + candidate.qualifiedName === undefined + ? '' + : candNs !== '' && !candidate.qualifiedName.includes('\\') + ? `${candNs}\\${candidate.qualifiedName}` + : candidate.qualifiedName; + if (candQualified === '') return false; + return callerParsed.parsedImports.some( + (imp) => + imp.kind === 'named' && + imp.targetRaw.replace(/^\\+/, '') === candQualified.replace(/^\\+/, ''), + ); +} + +/** + * Compute the EXTENDS-only ancestor chain for every class — no trait + * augmentation. PHP semantics: `parent::method()` walks this view so + * that `parent::` resolves to the parent class's method, even when a + * composed trait shadows the same name. + * + * Returns the same shape as `buildPhpMro` so callers can swap views + * without changing dispatch logic. Just `buildMro` + `defaultLinearize` + * — no trait IMPLEMENTS edge walk. + */ +function buildPhpExtendsOnlyMro( + graph: KnowledgeGraph, + parsedFiles: readonly ParsedFile[], + nodeLookup: GraphNodeLookup, +): Map { + return buildMro(graph, parsedFiles, nodeLookup, defaultLinearize); +} + function buildPhpMro( graph: KnowledgeGraph, parsedFiles: readonly ParsedFile[], @@ -104,34 +172,25 @@ function buildPhpMro( } // Step 4: augment every class's MRO by prepending the traits used by - // any class in its ancestor chain (transitively). PHP semantics: - // a trait used by a parent class is also visible on the child. + // any class in its ancestor chain (transitively closed). PHP semantics: + // a trait used by a parent class is also visible on the child, and a + // trait-using-trait chain is flattened to a single ancestor set. // // For each class, walk its (already-computed) EXTENDS-based MRO and - // collect all transitively-used traits. Prepend them before the - // EXTENDS ancestors so the method dispatch index finds trait methods - // before checking the parent class hierarchy. + // collect all transitively-used traits via BFS — `trait A { use B; } + // trait B { use C; } class X { use A; }` must include C in X's MRO. + // Prepend them before the EXTENDS ancestors so the method dispatch + // index finds trait methods before falling back to the parent class + // hierarchy. for (const [classDefId, extendsMro] of mro) { - const allTraits: string[] = []; - const seen = new Set(); - - // Collect traits from this class itself and from each ancestor. const ancestorChain = [classDefId, ...extendsMro]; + const seeds: string[] = []; for (const ancestorId of ancestorChain) { for (const traitId of directTraitUse.get(ancestorId) ?? []) { - if (!seen.has(traitId)) { - seen.add(traitId); - allTraits.push(traitId); - // Traits can use other traits — include transitively. - for (const transitiveTrait of directTraitUse.get(traitId) ?? []) { - if (!seen.has(transitiveTrait)) { - seen.add(transitiveTrait); - allTraits.push(transitiveTrait); - } - } - } + seeds.push(traitId); } } + const allTraits = collectTransitiveTraits(seeds, directTraitUse); if (allTraits.length > 0) { // Prepend traits before EXTENDS ancestors: own class's traits first, @@ -146,20 +205,7 @@ function buildPhpMro( for (const [classDefId, traits] of directTraitUse) { if (!mro.has(classDefId) && !traitDefIds.has(classDefId)) { // Class with no EXTENDS but with trait-use — add to MRO map. - const allTraits: string[] = []; - const seen = new Set(); - for (const traitId of traits) { - if (!seen.has(traitId)) { - seen.add(traitId); - allTraits.push(traitId); - for (const transitiveTrait of directTraitUse.get(traitId) ?? []) { - if (!seen.has(transitiveTrait)) { - seen.add(transitiveTrait); - allTraits.push(transitiveTrait); - } - } - } - } + const allTraits = collectTransitiveTraits([...traits], directTraitUse); mro.set(classDefId, allTraits); } } @@ -167,6 +213,32 @@ function buildPhpMro( return mro; } +/** + * Collect the transitive closure of traits reachable from the seed set. + * BFS over `directTraitUse` until fixpoint. The `seen` set guards against + * cycles (invalid PHP but defensively handled) and prevents duplicate + * entries when multiple seeds converge on the same trait. Insertion order + * is preserved — first-seen wins for MRO ordering. + */ +function collectTransitiveTraits( + seeds: readonly string[], + directTraitUse: ReadonlyMap, +): string[] { + const out: string[] = []; + const seen = new Set(); + const queue: string[] = [...seeds]; + while (queue.length > 0) { + const t = queue.shift()!; + if (seen.has(t)) continue; + seen.add(t); + out.push(t); + for (const next of directTraitUse.get(t) ?? []) { + if (!seen.has(next)) queue.push(next); + } + } + return out; +} + /** * Emit CALLS edges for PHP member-call sites whose receiver has no type * binding (e.g. `mixed`-typed parameters, untyped variables). @@ -222,6 +294,15 @@ function phpEmitUnresolvedReceiverEdges( const fnDef = candidates[0]; if (fnDef === undefined) continue; + // Apply arity narrowing — a unique method name match is not enough + // when arity says the call is definitively incompatible (e.g., PHP + // f(int $req, ...$rest) called with zero args). This prevents the + // fallback from emitting edges that the receiver-bound pass already + // rejected for arity reasons. + if (narrowOverloadCandidates([fnDef], site.arity, site.argumentTypes).length === 0) { + continue; + } + const callerGraphId = resolveCallerGraphId(site.inScope, scopes, nodeLookup); if (callerGraphId === undefined) continue; const tgtGraphId = resolveDefGraphId(fnDef.filePath, fnDef, nodeLookup); @@ -265,6 +346,19 @@ const phpScopeResolver: ScopeResolver = { buildMro: (graph, parsedFiles, nodeLookup) => buildPhpMro(graph, parsedFiles, nodeLookup), + // PHP-specific: parent::method() must walk inheritance only, skipping + // composed traits. See buildPhpExtendsOnlyMro and the super-branch use + // in `passes/receiver-bound-calls.ts`. + buildExtendsOnlyMro: (graph, parsedFiles, nodeLookup) => + buildPhpExtendsOnlyMro(graph, parsedFiles, nodeLookup), + + // PHP free-call visibility: cross-namespace candidates are blocked + // unless explicitly `use function`-imported by the caller. Prevents + // false-positive CALLS edges between unrelated namespaces sharing a + // function name. Same-namespace and global-namespace candidates pass + // unchanged. + isCallableVisibleFromCaller: phpIsCallableVisibleFromCaller, + populateOwners: (parsed: ParsedFile) => populateClassOwnedMembers(parsed), // PHP same-namespace cross-file visibility — classes in the same diff --git a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts index de3bfbddf..99e7145ae 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -386,6 +386,26 @@ export interface ScopeResolver { nodeLookup: GraphNodeLookup, ): Map; + /** + * Optional parallel MRO that EXCLUDES mixin-like augmentation (e.g., PHP + * traits). Returns the inheritance-only ancestor chain — the same kind + * of map as `buildMro` but built only from inheritance edges (EXTENDS). + * + * Used by the shared super-branch dispatch in `receiver-bound-calls` + * so that `parent::method()` walks the inheritance chain only, not the + * trait-augmented one. PHP semantics: `parent::` explicitly bypasses + * traits, even when a composed trait shadows a same-named parent method. + * + * Languages without mixin-like semantics leave this undefined — callers + * fall back to `buildMro`/`mroFor`, which for those languages is already + * the inheritance chain. + */ + readonly buildExtendsOnlyMro?: ( + graph: KnowledgeGraph, + parsedFiles: readonly ParsedFile[], + nodeLookup: GraphNodeLookup, + ) => Map; + /** * Mutate `parsed.localDefs[i].ownerId` to point at the structural * owner. Python's rule: methods (Function defs whose parent scope @@ -484,6 +504,26 @@ export interface ScopeResolver { */ readonly isFileLocalDef?: (def: SymbolDefinition) => boolean; + /** + * Optional predicate to gate free-call fallback emission by caller-side + * visibility. When provided, `pickUniqueGlobalCallable` rejects candidates + * the caller cannot legally reach — e.g., a PHP function in a different + * namespace with no `use function` import, which PHP runtime would treat + * as `Call to undefined function`. Returning `false` blocks the candidate; + * returning `true` allows it; undefined-default keeps current behavior + * (no visibility filtering, equivalent to "all candidates visible"). + * + * The hook receives the caller's `ParsedFile` (so it can consult + * `parsedImports`, `moduleScope`, etc.) and the candidate `SymbolDefinition`. + * The predicate must be pure: same inputs → same answer. + * + * Languages without namespace-scoped function resolution leave this undefined. + */ + readonly isCallableVisibleFromCaller?: (ctx: { + readonly callerParsed: ParsedFile; + readonly candidate: SymbolDefinition; + }) => boolean; + /** * Optional post-finalize hook to inject cross-file bindings that * aren't modeled via explicit imports. Runs after diff --git a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/method-dispatch.ts b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/method-dispatch.ts index 164147ac6..419ab478f 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/method-dispatch.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/method-dispatch.ts @@ -22,8 +22,9 @@ const EMPTY_DEFS: readonly string[] = Object.freeze([]); export function buildPopulatedMethodDispatch( mroByDefId: ReadonlyMap, + extendsOnlyMroByDefId?: ReadonlyMap, ): MethodDispatchIndex { - return { + const base: MethodDispatchIndex = { mroByOwnerDefId: mroByDefId, implsByInterfaceDefId: new Map(), mroFor(ownerDefId) { @@ -33,4 +34,14 @@ export function buildPopulatedMethodDispatch( return EMPTY_DEFS; }, }; + if (extendsOnlyMroByDefId !== undefined) { + return { + ...base, + extendsOnlyMroByOwnerDefId: extendsOnlyMroByDefId, + extendsOnlyMroFor(ownerDefId) { + return extendsOnlyMroByDefId.get(ownerDefId) ?? EMPTY_DEFS; + }, + }; + } + return base; } diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts index 350eabc3c..401b3fe1c 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts @@ -39,6 +39,10 @@ export function emitFreeCallFallback( options: { readonly allowGlobalFallback?: boolean; readonly isFileLocalDef?: (def: SymbolDefinition) => boolean; + readonly isCallableVisibleFromCaller?: (ctx: { + readonly callerParsed: ParsedFile; + readonly candidate: SymbolDefinition; + }) => boolean; } = {}, ): number { let emitted = 0; @@ -83,6 +87,10 @@ export function emitFreeCallFallback( parsed.filePath, options.isFileLocalDef, site.arity, + options.isCallableVisibleFromCaller !== undefined + ? (candidate) => + options.isCallableVisibleFromCaller!({ callerParsed: parsed, candidate }) + : undefined, ); } if (fnDef === undefined) continue; @@ -120,6 +128,7 @@ function pickUniqueGlobalCallable( callerFilePath: string, isFileLocalDef?: (def: SymbolDefinition) => boolean, callArity?: number, + isCallerVisible?: (candidate: SymbolDefinition) => boolean, ): SymbolDefinition | undefined { const scopeDefs: SymbolDefinition[] = []; const scopeSeen = new Set(); @@ -132,6 +141,13 @@ function pickUniqueGlobalCallable( if (isFileLocalDef !== undefined && def.filePath !== callerFilePath && isFileLocalDef(def)) { continue; } + // Caller-side visibility filter (e.g., PHP namespace + use-function + // import gating). When defined, blocks candidates the caller cannot + // legally reach. Languages without namespace-scoped function resolution + // leave this undefined → no filtering. + if (isCallerVisible !== undefined && !isCallerVisible(def)) { + continue; + } const key = logicalCallableKey(def); if (scopeSeen.has(key)) continue; scopeSeen.add(key); @@ -158,6 +174,10 @@ function pickUniqueGlobalCallable( if (isFileLocalDef !== undefined && def.filePath !== callerFilePath && isFileLocalDef(def)) { continue; } + // Same caller-visibility filter applied to the model-side pool. + if (isCallerVisible !== undefined && !isCallerVisible(def)) { + continue; + } const key = logicalCallableKey(def); if (seen.has(key)) continue; seen.add(key); diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/overload-narrowing.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/overload-narrowing.ts index 922afb36c..ae448e326 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/overload-narrowing.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/overload-narrowing.ts @@ -13,9 +13,13 @@ * 2. Exact-required-match wins over variadic. Variadic is detected * via a `parameterTypes` entry equal to `'params'` or starting * with `'params '` (C# `params` / variadic marker). - * 3. If the arity filter empties the set, fall back to the full - * overload list rather than returning nothing — the caller still - * needs a best-effort candidate. + * 3. If the arity filter empties the set AND any candidate had + * unknown bounds (both `parameterCount` and `requiredParameterCount` + * undefined), fall back to the full overload list — the empty + * result may be due to missing metadata rather than a real mismatch. + * If EVERY rejected candidate had definite arity bounds, trust the + * filter and return empty — the call is genuinely arity-incompatible + * (e.g., PHP `f(int $req, ...$rest)` called with zero args). * 4. If `argTypes` is present, filter further by per-slot type * equality. An empty string in `argTypes[i]` means "unknown" and * counts as a match. Mismatches disqualify. A non-empty typed @@ -48,8 +52,16 @@ export function narrowOverloadCandidates( return true; }); + // When the arity filter empties the set, only fall back to the full + // overload list if some candidate had unknown bounds — otherwise the + // empty result is authoritative (every candidate definitively failed + // arity, e.g., PHP variadic with required-prefix called with too few + // args). + const anyUnknownBounds = overloads.some( + (d) => d.parameterCount === undefined && d.requiredParameterCount === undefined, + ); const candidates: readonly SymbolDefinition[] = - arityMatches.length > 0 ? arityMatches : overloads; + arityMatches.length > 0 ? arityMatches : anyUnknownBounds ? overloads : []; if (argTypes !== undefined && argTypes.length > 0) { const typed = candidates.filter((d) => { diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts index 925151b6c..283f93b51 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts @@ -165,7 +165,16 @@ export function emitReceiverBoundCalls( if (provider.isSuperReceiver(receiverName)) { const enclosingClass = findEnclosingClassDef(site.inScope, scopes); if (enclosingClass !== undefined) { - const ancestors = scopes.methodDispatch.mroFor(enclosingClass.nodeId); + // For super-receiver dispatch (`parent::`, `base.`, `super()`), + // walk the inheritance-only ancestor chain when the language + // exposes it. PHP's `parent::` semantically bypasses composed + // traits; other languages without mixin augmentation have no + // `extendsOnlyMroFor` and fall back to `mroFor`. + const extendsOnly = scopes.methodDispatch.extendsOnlyMroFor; + const ancestors = + extendsOnly !== undefined + ? extendsOnly(enclosingClass.nodeId) + : scopes.methodDispatch.mroFor(enclosingClass.nodeId); let memberDef: SymbolDefinition | undefined; for (const ownerId of ancestors) { memberDef = findOwnedMember(ownerId, memberName, model); @@ -283,7 +292,19 @@ export function emitReceiverBoundCalls( let memberDef: SymbolDefinition | undefined; for (const ownerId of chain) { memberDef = findOwnedMember(ownerId, memberName, model); - if (memberDef !== undefined) break; + if (memberDef !== undefined) { + // Reject when arity is definitively incompatible (e.g., PHP + // f(int $req, ...$rest) called with zero args). Falls through + // to the next owner in the chain — a subclass may shadow with + // a different arity. + if ( + narrowOverloadCandidates([memberDef], site.arity, site.argumentTypes).length === 0 + ) { + memberDef = undefined; + continue; + } + break; + } } if (memberDef !== undefined) { const reason = diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index 933fa43d9..637624232 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -153,6 +153,7 @@ export function runScopeResolution( const allFilePaths = new Set(parsedFiles.map((f) => f.filePath)); const nodeLookup = buildGraphNodeLookup(graph); const mroByClassDefId = provider.buildMro(graph, parsedFiles, nodeLookup); + const extendsOnlyMroByClassDefId = provider.buildExtendsOnlyMro?.(graph, parsedFiles, nodeLookup); const resolutionConfig = input.resolutionConfig; const finalized = finalizeScopeModel(parsedFiles, { @@ -174,7 +175,7 @@ export function runScopeResolution( // the type system. const indexes = { ...finalized, - methodDispatch: buildPopulatedMethodDispatch(mroByClassDefId), + methodDispatch: buildPopulatedMethodDispatch(mroByClassDefId, extendsOnlyMroByClassDefId), }; // Build the workspace resolution index ONCE — scope-valued lookups @@ -275,6 +276,7 @@ export function runScopeResolution( { allowGlobalFallback: provider.allowGlobalFreeCallFallback === true, isFileLocalDef: provider.isFileLocalDef, + isCallableVisibleFromCaller: provider.isCallableVisibleFromCaller, }, ); const { emitted, skipped } = emitReferencesViaLookup( diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index 8e165a837..d65229808 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -1020,6 +1020,16 @@ export const PHP_QUERIES = ` (use_declaration [(name) (qualified_name)] @heritage.trait))) @heritage +; ── Heritage: trait uses another trait (transitive trait composition) ──────── +; PHP allows a trait body to contain "use OtherTrait;". The trait-uses-trait +; IMPLEMENTS edge is required by buildPhpMro to compute the full transitive +; trait closure (depth 3+ chains). +(trait_declaration + name: (name) @heritage.class + body: (declaration_list + (use_declaration + [(name) (qualified_name)] @heritage.trait))) @heritage + ; PHP HTTP consumers: file_get_contents('/path'), curl_init('/path') (function_call_expression function: (name) @_php_http (#match? @_php_http "^(file_get_contents|curl_init)$") diff --git a/gitnexus/test/fixtures/lang-resolution/php-calls/app/Services/UserService.php b/gitnexus/test/fixtures/lang-resolution/php-calls/app/Services/UserService.php index fa0a1b8be..882e12d64 100644 --- a/gitnexus/test/fixtures/lang-resolution/php-calls/app/Services/UserService.php +++ b/gitnexus/test/fixtures/lang-resolution/php-calls/app/Services/UserService.php @@ -2,10 +2,13 @@ namespace App\Services; -use function App\Utils\OneArg\log; -use function App\Utils\ZeroArg\log as zero_log; +use function App\Utils\OneArg\write_audit; +use function App\Utils\ZeroArg\write_audit as zero_write_audit; function create_user(): string { + // Two visible write_audit candidates (different arities). Arity narrowing + // must pick the 1-arg OneArg version. This validates that visibility + + // arity together correctly disambiguate. return write_audit('hello'); } diff --git a/gitnexus/test/fixtures/lang-resolution/php-namespace-fallback-isolation/composer.json b/gitnexus/test/fixtures/lang-resolution/php-namespace-fallback-isolation/composer.json new file mode 100644 index 000000000..3675b0d1c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-namespace-fallback-isolation/composer.json @@ -0,0 +1,8 @@ +{ + "autoload": { + "psr-4": { + "App\\": "src/App/", + "Vendor\\": "src/Vendor/" + } + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-namespace-fallback-isolation/src/App/Caller.php b/gitnexus/test/fixtures/lang-resolution/php-namespace-fallback-isolation/src/App/Caller.php new file mode 100644 index 000000000..6fecabb3c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-namespace-fallback-isolation/src/App/Caller.php @@ -0,0 +1,19 @@ +record(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-parent-vs-trait/composer.json b/gitnexus/test/fixtures/lang-resolution/php-parent-vs-trait/composer.json new file mode 100644 index 000000000..386b0bd2d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-parent-vs-trait/composer.json @@ -0,0 +1,7 @@ +{ + "autoload": { + "psr-4": { + "App\\": "app/" + } + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-transitive-traits/app/Models/Consumer.php b/gitnexus/test/fixtures/lang-resolution/php-transitive-traits/app/Models/Consumer.php new file mode 100644 index 000000000..86d330173 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-transitive-traits/app/Models/Consumer.php @@ -0,0 +1,20 @@ +aMethod(); + } + + public function callDepthTwo(): string { + return $this->bMethod(); + } + + public function callDepthThree(): string { + return $this->deepMethod(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-transitive-traits/app/Traits/TraitA.php b/gitnexus/test/fixtures/lang-resolution/php-transitive-traits/app/Traits/TraitA.php new file mode 100644 index 000000000..4408b8dfb --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-transitive-traits/app/Traits/TraitA.php @@ -0,0 +1,10 @@ + { }); }); +// --------------------------------------------------------------------------- +// Variadic arity minimum: required-arg count must be enforced for variadic +// functions. f(int $req, ...$rest) called as f() is an ArgumentCountError at +// PHP runtime and must NOT emit a CALLS edge from the resolver. +// --------------------------------------------------------------------------- + +describe('PHP variadic arity minimum (U1)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'php-variadic-arity-minimum'), () => {}); + }, 60000); + + const callsFrom = (source: string, target: string) => + getRelationships(result, 'CALLS').filter((c) => c.source === source && c.target === target); + + it('emits CALLS edge for record(level, ...msgs) with arity 4 (happy path)', () => { + expect(callsFrom('callValidRecord', 'record').length).toBe(1); + }); + + it('emits CALLS edge for record(level) with only the required arg (arity 1)', () => { + expect(callsFrom('callValidRecordMin', 'record').length).toBe(1); + }); + + it('does NOT emit CALLS edge for record() with zero args (below required=1)', () => { + expect(callsFrom('callTooFewRecord', 'record').length).toBe(0); + }); + + it('emits CALLS edge for format() — pure variadic, required=0', () => { + expect(callsFrom('callPureVariadic', 'format').length).toBe(1); + }); + + it('emits CALLS edge for pad("x") — required+optional+variadic, only required given', () => { + expect(callsFrom('callPadMin', 'pad').length).toBe(1); + }); + + it('does NOT emit CALLS edge for pad() with zero args (below required=1)', () => { + expect(callsFrom('callPadTooFew', 'pad').length).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// Transitive trait MRO: trait A uses B uses C — Consumer using A must see C's +// methods. Current depth-2 expansion in buildPhpMro silently drops methods +// from 3+ level chains. +// --------------------------------------------------------------------------- + +describe('PHP transitive trait MRO (U2)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'php-transitive-traits'), () => {}); + }, 60000); + + const callsFrom = (source: string, target: string) => + getRelationships(result, 'CALLS').filter((c) => c.source === source && c.target === target); + + it('detects 3 traits and 1 class', () => { + expect(getNodesByLabel(result, 'Trait')).toEqual(['TraitA', 'TraitB', 'TraitC']); + expect(getNodesByLabel(result, 'Class')).toContain('Consumer'); + }); + + it('depth-1: $this->aMethod() resolves to TraitA::aMethod', () => { + expect(callsFrom('callDepthOne', 'aMethod').length).toBeGreaterThanOrEqual(1); + }); + + it('depth-2: $this->bMethod() resolves to TraitB::bMethod (TraitA uses TraitB)', () => { + expect(callsFrom('callDepthTwo', 'bMethod').length).toBeGreaterThanOrEqual(1); + }); + + it('depth-3: $this->deepMethod() resolves to TraitC::deepMethod (TraitA → TraitB → TraitC)', () => { + expect(callsFrom('callDepthThree', 'deepMethod').length).toBeGreaterThanOrEqual(1); + }); +}); + +// --------------------------------------------------------------------------- +// parent:: bypasses traits. When a class composes a trait AND extends a parent +// that both define the same method name, parent::method() must resolve to the +// parent class (PHP semantics), NOT the trait. $this->method() still goes to +// the trait (PHP's own-class > trait > parent precedence). +// --------------------------------------------------------------------------- + +describe('PHP parent:: bypasses traits (U3)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'php-parent-vs-trait'), () => {}); + }, 60000); + + const callsFromTo = (source: string, target: string, file: string) => + getRelationships(result, 'CALLS').filter( + (c) => c.source === source && c.target === target && c.targetFilePath === file, + ); + + it('parent::record() resolves to Base::record, NOT Auditable::record', () => { + expect(callsFromTo('callViaParent', 'record', 'app/Base.php').length).toBeGreaterThanOrEqual(1); + expect(callsFromTo('callViaParent', 'record', 'app/Auditable.php').length).toBe(0); + }); + + it('$this->record() still resolves to Auditable::record (trait shadows parent)', () => { + expect(callsFromTo('callViaThis', 'record', 'app/Auditable.php').length).toBeGreaterThanOrEqual( + 1, + ); + expect(callsFromTo('callViaThis', 'record', 'app/Base.php').length).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// Namespace-aware free-call fallback. PHP's `pickUniqueGlobalCallable` must +// reject cross-namespace candidates that the caller can't reach without an +// explicit `use function` import. Same-namespace and globally-imported calls +// still emit edges. +// --------------------------------------------------------------------------- + +describe('PHP namespace-aware free-call fallback (U4)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'php-namespace-fallback-isolation'), + () => {}, + ); + }, 60000); + + const callsFromTo = (source: string, target: string, file?: string) => + getRelationships(result, 'CALLS').filter( + (c) => + c.source === source && + c.target === target && + (file === undefined || c.targetFilePath === file), + ); + + it('rejects cross-namespace candidate when caller has no use-function import', () => { + // callNoImport (in \App) calls format('x'). Workspace has \App\Utils\format/1 + // and \Vendor\Utils\format/2. Caller is in \App — NOT same namespace as + // either candidate, and no `use function` for `format` is in scope. + // Expected: NO CALLS edge. + expect(callsFromTo('callNoImport', 'format').length).toBe(0); + }); + + it('resolves same-namespace free call (caller in App\\Utils → App\\Utils\\format)', () => { + expect( + callsFromTo('callSameNamespace', 'format', 'src/App/Utils/Format.php').length, + ).toBeGreaterThanOrEqual(1); + }); + + it('resolves use-function-imported alias (vendorFormat → Vendor\\Utils\\format)', () => { + // `use function Vendor\Utils\format as vendorFormat;`. Caller in \App calls + // vendorFormat('x', 80) — the import target is reachable. + expect( + callsFromTo('callImported', 'vendorFormat').length + + callsFromTo('callImported', 'format', 'src/Vendor/Utils/Format.php').length, + ).toBeGreaterThanOrEqual(1); + }); +}); + // --------------------------------------------------------------------------- // Local shadow: same-file definition takes priority over imported name // ---------------------------------------------------------------------------