mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-02 02:11:29 +00:00
fix(php): four PHP semantic defects flagged by PR #1497 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
This commit is contained in:
parent
a8201cd9c9
commit
af9af4a958
31 changed files with 711 additions and 52 deletions
|
|
@ -40,11 +40,27 @@ export interface MethodDispatchIndex {
|
|||
readonly mroByOwnerDefId: ReadonlyMap<DefId, readonly DefId[]>;
|
||||
/** Interfaces / traits → classes that implement them. */
|
||||
readonly implsByInterfaceDefId: ReadonlyMap<DefId, readonly DefId[]>;
|
||||
/**
|
||||
* 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<DefId, readonly DefId[]>;
|
||||
|
||||
/** `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<DefId, readonly DefId[]>();
|
||||
const extendsOnlyByOwnerDefId = input.computeExtendsOnlyMro
|
||||
? new Map<DefId, readonly DefId[]>()
|
||||
: undefined;
|
||||
const implsBuilding = new Map<DefId, DefId[]>();
|
||||
const implsSeen = new Map<DefId, Set<DefId>>();
|
||||
|
||||
|
|
@ -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<DefId, readonly DefId[]>,
|
||||
implsByInterfaceDefId: Map<DefId, readonly DefId[]>,
|
||||
extendsOnlyMroByOwnerDefId: Map<DefId, readonly DefId[]> | 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<string, string>();
|
||||
|
||||
/**
|
||||
* 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<string, PhpFileStructure>();
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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<string, string[]> {
|
||||
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<string>();
|
||||
|
||||
// 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<string>();
|
||||
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, readonly string[]>,
|
||||
): string[] {
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
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
|
||||
|
|
|
|||
|
|
@ -386,6 +386,26 @@ export interface ScopeResolver {
|
|||
nodeLookup: GraphNodeLookup,
|
||||
): Map<string /* DefId */, string[] /* ancestor DefIds */>;
|
||||
|
||||
/**
|
||||
* 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<string /* DefId */, string[] /* ancestor DefIds */>;
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
|
|
|||
|
|
@ -22,8 +22,9 @@ const EMPTY_DEFS: readonly string[] = Object.freeze([]);
|
|||
|
||||
export function buildPopulatedMethodDispatch(
|
||||
mroByDefId: ReadonlyMap<string, readonly string[]>,
|
||||
extendsOnlyMroByDefId?: ReadonlyMap<string, readonly string[]>,
|
||||
): 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string>();
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)$")
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
8
gitnexus/test/fixtures/lang-resolution/php-namespace-fallback-isolation/composer.json
vendored
Normal file
8
gitnexus/test/fixtures/lang-resolution/php-namespace-fallback-isolation/composer.json
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"App\\": "src/App/",
|
||||
"Vendor\\": "src/Vendor/"
|
||||
}
|
||||
}
|
||||
}
|
||||
19
gitnexus/test/fixtures/lang-resolution/php-namespace-fallback-isolation/src/App/Caller.php
vendored
Normal file
19
gitnexus/test/fixtures/lang-resolution/php-namespace-fallback-isolation/src/App/Caller.php
vendored
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
namespace App;
|
||||
|
||||
use function Vendor\Utils\format as vendorFormat;
|
||||
|
||||
class Caller {
|
||||
public function callNoImport(): string {
|
||||
// No use function for `format`. Caller is in \App, candidates live
|
||||
// in \App\Utils and \Vendor\Utils. PHP runtime: Call to undefined
|
||||
// function App\format. Resolver must emit NO edge.
|
||||
return format('x');
|
||||
}
|
||||
|
||||
public function callImported(): string {
|
||||
// Imported via `use function Vendor\Utils\format as vendorFormat`.
|
||||
// Resolver must emit an edge to Vendor\Utils\format.
|
||||
return vendorFormat('x', 80);
|
||||
}
|
||||
}
|
||||
10
gitnexus/test/fixtures/lang-resolution/php-namespace-fallback-isolation/src/App/Utils/Caller.php
vendored
Normal file
10
gitnexus/test/fixtures/lang-resolution/php-namespace-fallback-isolation/src/App/Utils/Caller.php
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?php
|
||||
namespace App\Utils;
|
||||
|
||||
class Caller {
|
||||
public function callSameNamespace(): string {
|
||||
// Caller is in \App\Utils, calls `format('x')`. Same-namespace
|
||||
// resolution: emit edge to \App\Utils\format.
|
||||
return format('x');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
<?php
|
||||
namespace App\Utils;
|
||||
|
||||
function format(string $s): string {
|
||||
return $s;
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
<?php
|
||||
namespace Vendor\Utils;
|
||||
|
||||
function format(string $s, int $width): string {
|
||||
return str_pad($s, $width);
|
||||
}
|
||||
8
gitnexus/test/fixtures/lang-resolution/php-parent-vs-trait/app/Auditable.php
vendored
Normal file
8
gitnexus/test/fixtures/lang-resolution/php-parent-vs-trait/app/Auditable.php
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<?php
|
||||
namespace App;
|
||||
|
||||
trait Auditable {
|
||||
public function record(): string {
|
||||
return 'trait';
|
||||
}
|
||||
}
|
||||
8
gitnexus/test/fixtures/lang-resolution/php-parent-vs-trait/app/Base.php
vendored
Normal file
8
gitnexus/test/fixtures/lang-resolution/php-parent-vs-trait/app/Base.php
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<?php
|
||||
namespace App;
|
||||
|
||||
class Base {
|
||||
public function record(): string {
|
||||
return 'base';
|
||||
}
|
||||
}
|
||||
14
gitnexus/test/fixtures/lang-resolution/php-parent-vs-trait/app/Child.php
vendored
Normal file
14
gitnexus/test/fixtures/lang-resolution/php-parent-vs-trait/app/Child.php
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<?php
|
||||
namespace App;
|
||||
|
||||
class Child extends Base {
|
||||
use Auditable;
|
||||
|
||||
public function callViaParent(): string {
|
||||
return parent::record();
|
||||
}
|
||||
|
||||
public function callViaThis(): string {
|
||||
return $this->record();
|
||||
}
|
||||
}
|
||||
7
gitnexus/test/fixtures/lang-resolution/php-parent-vs-trait/composer.json
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/php-parent-vs-trait/composer.json
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"App\\": "app/"
|
||||
}
|
||||
}
|
||||
}
|
||||
20
gitnexus/test/fixtures/lang-resolution/php-transitive-traits/app/Models/Consumer.php
vendored
Normal file
20
gitnexus/test/fixtures/lang-resolution/php-transitive-traits/app/Models/Consumer.php
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<?php
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\TraitA;
|
||||
|
||||
class Consumer {
|
||||
use TraitA;
|
||||
|
||||
public function callDepthOne(): string {
|
||||
return $this->aMethod();
|
||||
}
|
||||
|
||||
public function callDepthTwo(): string {
|
||||
return $this->bMethod();
|
||||
}
|
||||
|
||||
public function callDepthThree(): string {
|
||||
return $this->deepMethod();
|
||||
}
|
||||
}
|
||||
10
gitnexus/test/fixtures/lang-resolution/php-transitive-traits/app/Traits/TraitA.php
vendored
Normal file
10
gitnexus/test/fixtures/lang-resolution/php-transitive-traits/app/Traits/TraitA.php
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?php
|
||||
namespace App\Traits;
|
||||
|
||||
trait TraitA {
|
||||
use TraitB;
|
||||
|
||||
public function aMethod(): string {
|
||||
return 'from A';
|
||||
}
|
||||
}
|
||||
10
gitnexus/test/fixtures/lang-resolution/php-transitive-traits/app/Traits/TraitB.php
vendored
Normal file
10
gitnexus/test/fixtures/lang-resolution/php-transitive-traits/app/Traits/TraitB.php
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?php
|
||||
namespace App\Traits;
|
||||
|
||||
trait TraitB {
|
||||
use TraitC;
|
||||
|
||||
public function bMethod(): string {
|
||||
return 'from B';
|
||||
}
|
||||
}
|
||||
8
gitnexus/test/fixtures/lang-resolution/php-transitive-traits/app/Traits/TraitC.php
vendored
Normal file
8
gitnexus/test/fixtures/lang-resolution/php-transitive-traits/app/Traits/TraitC.php
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<?php
|
||||
namespace App\Traits;
|
||||
|
||||
trait TraitC {
|
||||
public function deepMethod(): string {
|
||||
return 'from C';
|
||||
}
|
||||
}
|
||||
7
gitnexus/test/fixtures/lang-resolution/php-transitive-traits/composer.json
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/php-transitive-traits/composer.json
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"App\\": "app/"
|
||||
}
|
||||
}
|
||||
}
|
||||
30
gitnexus/test/fixtures/lang-resolution/php-variadic-arity-minimum/app/Services/Caller.php
vendored
Normal file
30
gitnexus/test/fixtures/lang-resolution/php-variadic-arity-minimum/app/Services/Caller.php
vendored
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
namespace App\Services;
|
||||
|
||||
use App\Utils\Logger;
|
||||
|
||||
class Caller {
|
||||
public function callValidRecord(): void {
|
||||
Logger::record('info', 'started', 'processing', 'done');
|
||||
}
|
||||
|
||||
public function callValidRecordMin(): void {
|
||||
Logger::record('info');
|
||||
}
|
||||
|
||||
public function callTooFewRecord(): void {
|
||||
Logger::record();
|
||||
}
|
||||
|
||||
public function callPureVariadic(): string {
|
||||
return Logger::format();
|
||||
}
|
||||
|
||||
public function callPadMin(): string {
|
||||
return Logger::pad('x');
|
||||
}
|
||||
|
||||
public function callPadTooFew(): string {
|
||||
return Logger::pad();
|
||||
}
|
||||
}
|
||||
19
gitnexus/test/fixtures/lang-resolution/php-variadic-arity-minimum/app/Utils/Logger.php
vendored
Normal file
19
gitnexus/test/fixtures/lang-resolution/php-variadic-arity-minimum/app/Utils/Logger.php
vendored
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
namespace App\Utils;
|
||||
|
||||
class Logger {
|
||||
public static function record(string $level, string ...$messages): void {
|
||||
foreach ($messages as $msg) {
|
||||
echo "[$level] $msg\n";
|
||||
}
|
||||
}
|
||||
|
||||
public static function format(...$parts): string {
|
||||
return implode('', array_map('strval', $parts));
|
||||
}
|
||||
|
||||
public static function pad(string $s, int $w = 80, string ...$chars): string {
|
||||
$pad = empty($chars) ? ' ' : implode('', $chars);
|
||||
return str_pad($s, $w, $pad);
|
||||
}
|
||||
}
|
||||
7
gitnexus/test/fixtures/lang-resolution/php-variadic-arity-minimum/composer.json
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/php-variadic-arity-minimum/composer.json
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"App\\": "app/"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -443,6 +443,162 @@ describe('PHP variadic call resolution', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue