Merge branch 'main' into fix/issue-1486-hook-concurrency-guard

This commit is contained in:
abhigyantrumio 2026-05-12 23:03:28 +05:30
commit bff714dbd1
71 changed files with 4846 additions and 33 deletions

View file

@ -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;
}

View file

@ -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) {

View file

@ -5,12 +5,22 @@
* and standard export/import resolution. PHP files can use a variety of
* extensions from legacy versions through modern PHP 8.
*/
import {
emitPhpScopeCaptures,
interpretPhpImport,
interpretPhpTypeBinding,
phpArityCompatibility,
phpMergeBindings,
resolvePhpImportTarget,
phpBindingScopeFor,
phpImportOwningScope,
phpReceiverBinding,
} from './php/index.js';
import { SupportedLanguages } from 'gitnexus-shared';
import { createClassExtractor } from '../class-extractors/generic.js';
import { phpClassConfig } from '../class-extractors/configs/php.js';
import { defineLanguage } from '../language-provider.js';
import type { AstFrameworkPatternConfig } from '../language-provider.js';
import { defineLanguage, type AstFrameworkPatternConfig } from '../language-provider.js';
import { typeConfig as phpConfig } from '../type-extractors/php.js';
import { phpExportChecker } from '../export-detection.js';
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
@ -289,4 +299,18 @@ export const phpProvider = defineLanguage({
descriptionExtractor: phpDescriptionExtractor,
isRouteFile: isPhpRouteFile,
builtInNames: BUILT_INS,
// ── RFC #909 Ring 3: scope-based resolution hooks ──────────────────────
emitScopeCaptures: emitPhpScopeCaptures,
interpretImport: interpretPhpImport,
interpretTypeBinding: interpretPhpTypeBinding,
// LanguageProvider uses (def, callsite); phpArityCompatibility uses (def, callsite) — same.
arityCompatibility: phpArityCompatibility,
// LanguageProvider adapter: (parsedImport, workspaceIndex) → string | null
resolveImportTarget: resolvePhpImportTarget,
// mergeBindings on LanguageProvider: (scope, bindings) — ignore scope id,
// delegate to phpMergeBindings which uses binding origin tiers.
mergeBindings: (_scope, bindings) => [...phpMergeBindings(bindings)],
bindingScopeFor: phpBindingScopeFor,
importOwningScope: phpImportOwningScope,
receiverBinding: phpReceiverBinding,
});

View file

@ -0,0 +1,73 @@
/**
* Extract PHP arity metadata from a method-like tree-sitter node
* `method_declaration` or `function_definition`.
*
* Reuses `phpMethodConfig.extractParameters` so scope-extracted defs
* carry the same arity semantics as the legacy parse-worker path:
* - `variadic_parameter` (`...$args`) collapses `parameterCount` to
* `undefined`, which `phpArityCompatibility` then treats as
* "max unknown" the candidate stays eligible at `argCount >= required`.
* - Defaulted parameters (`= expr`) contribute to `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 `'...'`
* marker is appended for variadic methods so `phpArityCompatibility`
* can detect them without re-reading the AST.
*/
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import { phpMethodConfig } from '../../method-extractors/configs/php.js';
interface PhpArityMetadata {
readonly parameterCount: number | undefined;
readonly requiredParameterCount: number | undefined;
readonly parameterTypes: readonly string[] | undefined;
}
export function computePhpArityMetadata(fnNode: SyntaxNode): PhpArityMetadata {
const params = phpMethodConfig.extractParameters?.(fnNode) ?? [];
let hasVariadic = false;
let optionalCount = 0;
const types: string[] = [];
for (const p of params) {
if (p.isVariadic) {
hasVariadic = true;
} else if (p.isOptional) {
optionalCount++;
}
if (p.type !== null) types.push(p.type);
}
// PHP variadic marker convention: append the literal '...' string to
// `parameterTypes`. This is intentionally DIFFERENT from C#, which uses
// the literal 'params' (its source-language keyword). The shared
// `narrowOverloadCandidates` pass in `scope-resolution/passes/overload-
// narrowing.ts` checks for the C# 'params' marker — that branch is
// dead code for PHP because PHP variadic methods set `parameterCount
// = undefined` (see line below), which skips the `max !== undefined`
// gate that hosts the 'params' check. PHP's actual variadic-aware
// arity logic lives in `phpArityCompatibility` (arity.ts) and now
// also in `phpEmitUnresolvedReceiverEdges` (scope-resolver.ts), both
// of which check `'...'`. Finding 9 of PR #1497 adversarial review.
if (hasVariadic) types.push('...');
const total = params.length;
// Variadic methods accept any arg count ≥ required — leave `parameterCount`
// undefined so the registry treats max as unknown.
const parameterCount = hasVariadic ? undefined : total;
// 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,
requiredParameterCount,
parameterTypes: types.length > 0 ? types : undefined,
};
}

View file

@ -0,0 +1,47 @@
/**
* PHP arity check, accommodating variadic (`...$args`) and default parameters.
*
* The `def` metadata synthesized by `arity-metadata.ts`:
* - `parameterCount` total formal parameters; `undefined` when
* the method has a variadic `...$param`.
* - `requiredParameterCount` min required (excludes defaulted params
* and the variadic itself).
* - `parameterTypes` declared type strings; contains the
* literal `'...'` when the method is variadic.
*
* Verdicts:
* - `'compatible'` `required <= argCount <= max`, OR the def has
* variadic (any `argCount >= required`).
* - `'incompatible'` argCount below required, or above max with no variadic.
* - `'unknown'` metadata absent / incomplete; named-args can satisfy
* any arity so we return unknown when we detect them.
*
* PHP supports named arguments (PHP 8.0+): `save(force: true)`. Named-arg
* call sites cannot be arity-checked statically without parsing arg names,
* so we return `'unknown'` when the callsite carries named args (signalled
* by a negative `arity` value per the shared Callsite contract).
*/
import type { Callsite, SymbolDefinition } from 'gitnexus-shared';
export function phpArityCompatibility(
def: SymbolDefinition,
callsite: Callsite,
): 'compatible' | 'unknown' | 'incompatible' {
const max = def.parameterCount;
const min = def.requiredParameterCount;
if (max === undefined && min === undefined) return 'unknown';
const argCount = callsite.arity;
// Negative arity signals named-argument call sites — can't narrow statically.
if (!Number.isFinite(argCount) || argCount < 0) return 'unknown';
const hasVarArgs =
def.parameterTypes !== undefined &&
def.parameterTypes.some((t) => t === '...' || t.startsWith('...'));
if (min !== undefined && argCount < min) return 'incompatible';
if (max !== undefined && argCount > max && !hasVarArgs) return 'incompatible';
return 'compatible';
}

View file

@ -0,0 +1,30 @@
/**
* Dev-mode counters for the cross-phase scope-captures parse cache
* (PHP mirror of `languages/csharp/cache-stats.ts`).
*
* Gated by `PROF_SCOPE_RESOLUTION=1`. Production builds fold every
* increment into dead code via the module-level `PROF` constant, so
* the hot path in `captures.ts` stays branch-free.
*/
const PROF = process.env.PROF_SCOPE_RESOLUTION === '1';
let CACHE_HITS = 0;
let CACHE_MISSES = 0;
export function recordCacheHit(): void {
if (PROF) CACHE_HITS++;
}
export function recordCacheMiss(): void {
if (PROF) CACHE_MISSES++;
}
export function getPhpCaptureCacheStats(): { hits: number; misses: number } {
return { hits: CACHE_HITS, misses: CACHE_MISSES };
}
export function resetPhpCaptureCacheStats(): void {
CACHE_HITS = 0;
CACHE_MISSES = 0;
}

View file

@ -0,0 +1,806 @@
/**
* `emitScopeCaptures` for PHP (RFC #909 Ring 3 LANG-php).
*
* Drives the PHP scope query against tree-sitter-php and groups raw
* matches into `CaptureMatch[]` for the central extractor. Layers two
* synthesized streams on top:
*
* 1. **Decomposed use declarations** each `namespace_use_declaration`
* is re-emitted with `@import.kind/source/name/alias` markers so
* `interpretPhpImport` can recover the ParsedImport shape without
* re-parsing raw text. Grouped uses fan out to one match per clause.
*
* 2. **Receiver-binding synthesis** `$this` and `parent` type-bindings
* are synthesized on every non-static method entry. PHP's grammar
* does not express "implicit receiver of a non-static class method"
* via a clean `.scm` pattern, so we walk up the AST in code.
*
* 3. **Arity metadata synthesis** `@declaration.parameter-count` /
* `@declaration.required-parameter-count` / `@declaration.parameter-types`
* are synthesized on function-like declarations so the registry can
* narrow overloads.
*
* 4. **PHPDoc synthesis** @param and @return annotations in comment
* nodes preceding method/function declarations are extracted and emitted
* as `@type-binding.parameter` and `@type-binding.return` matches.
*
* 5. **Foreach loop synthesis** `foreach ($users as $user)` emits
* a `@type-binding.alias` match binding the loop variable to the
* element type of the iterable (resolved from PHPDoc or scopeEnv).
*
* Pure given the input source text. No I/O, no globals consulted.
*/
import type { Capture, CaptureMatch } from 'gitnexus-shared';
import { findNodeAtRange, nodeToCapture, syntheticCapture } from '../../utils/ast-helpers.js';
import { splitNamespaceUseDeclaration } from './import-decomposer.js';
import { computePhpArityMetadata } from './arity-metadata.js';
import { synthesizePhpReceiverBinding } from './receiver-binding.js';
import { getPhpParser, getPhpScopeQuery } from './query.js';
import { recordCacheHit, recordCacheMiss } from './cache-stats.js';
import { getTreeSitterBufferSize } from '../../constants.js';
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
type SyntaxNode = ReturnType<ReturnType<typeof getPhpParser>['parse']>['rootNode'];
/** Declaration anchors that carry function-like arity metadata. */
const FUNCTION_DECL_TAGS = ['@declaration.method', '@declaration.function'] as const;
/** tree-sitter-php node types that the method extractor accepts. */
const FUNCTION_NODE_TYPES = [
'method_declaration',
'function_definition',
'anonymous_function',
'arrow_function',
] as const;
export function emitPhpScopeCaptures(
sourceText: string,
_filePath: string,
cachedTree?: unknown,
): readonly CaptureMatch[] {
// Skip the parse when the caller already produced a Tree for this source.
// The cachedTree parameter is typed as `unknown` at the LanguageProvider
// contract layer; cast here at the use site.
let tree = cachedTree as ReturnType<ReturnType<typeof getPhpParser>['parse']> | undefined;
if (tree === undefined) {
tree = parseSourceSafe(getPhpParser(), sourceText, undefined, {
bufferSize: getTreeSitterBufferSize(sourceText),
});
recordCacheMiss();
} else {
recordCacheHit();
}
const rawMatches = getPhpScopeQuery().matches(tree.rootNode);
const out: CaptureMatch[] = [];
// Pre-scan: collect anchor node IDs of property_declaration nodes already
// matched by the typed @declaration.property pattern (query.ts ~lines 9598).
// The untyped @declaration.variable catch-all (query.ts ~lines 101103) is
// intentionally loose — it has no `type:` constraint, so tree-sitter also
// matches it against typed property declarations and emits a second capture
// for the same property_declaration anchor. Graph-level def-id collision
// currently masks the duplicate at the node-emit layer, but the catch-all
// capture still flows through scope-binding / name-keyed registries with a
// `$`-prefixed name that the typed branch's `$`-strip never normalizes —
// a known vector for receiver-binding lookup pollution. The two patterns
// produce separate rawMatches entries with separate `grouped` maps, so the
// dedup has to be cross-match: build the set here, then skip
// @declaration.variable matches whose anchor is in it (loop below).
const typedPropertyAnchorIds = new Set<number>();
for (const m of rawMatches) {
for (const c of m.captures) {
if (c.name === 'declaration.property') {
typedPropertyAnchorIds.add(c.node.id);
break;
}
}
}
for (const m of rawMatches) {
// Group captures by their tag name. Tree-sitter strips the leading
// `@`; we put it back so the central extractor's prefix lookups work.
const grouped: Record<string, Capture> = {};
for (const c of m.captures) {
const tag = '@' + c.name;
grouped[tag] = nodeToCapture(tag, c.node);
}
if (Object.keys(grouped).length === 0) continue;
// Cross-match dedup for the typed-property double-match described above:
// skip @declaration.variable matches whose anchor was already captured as
// @declaration.property in an earlier match.
if (grouped['@declaration.variable'] !== undefined) {
const varCap = m.captures.find((c) => c.name === 'declaration.variable');
if (varCap !== undefined && typedPropertyAnchorIds.has(varCap.node.id)) continue;
}
// Normalize PHP property declarations: strip leading `$` from
// `@declaration.name` for @declaration.property matches. PHP stores
// field names WITHOUT the `$` sigil in the graph so that member access
// lookups like `$user->address` can find the property named `address`
// (not `$address`). `@type-binding.annotation` already strips `$` in
// `interpretPhpTypeBinding`; this mirrors that for the declaration side.
//
// Only applies to `@declaration.property` — typed class properties and
// constructor-promoted parameters. Untyped `@declaration.variable` keeps
// its `$` prefix (those defs are Variable type and not in the field
// registry, so their name doesn't affect member lookup).
if (
grouped['@declaration.property'] !== undefined &&
grouped['@declaration.name'] !== undefined
) {
const nameCap = grouped['@declaration.name'];
if (nameCap.text.startsWith('$')) {
grouped['@declaration.name'] = { ...nameCap, text: nameCap.text.slice(1) };
}
}
// Normalize PHP receiver expressions so the compound-receiver resolver
// can walk chains expressed with `->` (PHP) as if they used `.` (the
// resolver's canonical separator). Without this, `$user->address->save()`
// has receiver text `$user->address` — the resolver sees no `.` separator,
// treats it as a bare identifier, and cannot walk field types.
//
// Transformation applied to `@reference.receiver` captures:
// 1. Replace `->` with `.` ($user->address → $user.address)
// 2. Strip leading `$` from each segment ($user.address → user.address)
// 3. Strip trailing `?` on null-safe receivers ($user? → user)
//
// This is a PHP-local normalization — no shared pipeline code is changed.
if (grouped['@reference.receiver'] !== undefined) {
const recvCap = grouped['@reference.receiver']!;
const normalized = normalizePhpReceiver(recvCap.text);
if (normalized !== recvCap.text) {
grouped['@reference.receiver'] = { ...recvCap, text: normalized };
}
}
// Normalize static property write: strip leading `$` from `@reference.name`
// so `User::$count` resolves to property `count` (stored without `$` in graph).
if (grouped['@reference.write.static'] !== undefined) {
const nameCap = grouped['@reference.name'];
if (nameCap !== undefined && nameCap.text.startsWith('$')) {
grouped['@reference.name'] = {
...nameCap,
text: nameCap.text.slice(1),
};
}
// Re-tag as @reference.write.member so downstream passes see a uniform write kind.
grouped['@reference.write.member'] = grouped['@reference.write.static']!;
delete grouped['@reference.write.static'];
}
// Decompose each `namespace_use_declaration` so `interpretPhpImport`
// sees the kind/source/name/alias markers it consumes.
if (grouped['@import.statement'] !== undefined) {
const stmtCapture = grouped['@import.statement'];
const stmtNode = findNodeAtRange(
tree.rootNode,
stmtCapture.range,
'namespace_use_declaration',
);
if (stmtNode !== null) {
const decomposed = splitNamespaceUseDeclaration(stmtNode);
if (decomposed.length > 0) {
for (const d of decomposed) out.push(d);
continue;
}
}
// Defensive fallback: emit the raw match.
out.push(grouped);
continue;
}
// Synthesize `$this` / `parent` receiver type-bindings on every
// non-static method-like. Mirrors C#'s `this` / `base` synthesis.
if (grouped['@scope.function'] !== undefined) {
out.push(grouped);
const anchor = grouped['@scope.function']!;
const fnNode = findFunctionNode(tree.rootNode, anchor.range);
if (fnNode !== null) {
for (const synth of synthesizePhpReceiverBinding(fnNode)) {
out.push(synth);
}
// Synthesize PHPDoc @param and @return type bindings for this fn.
for (const synth of synthesizePhpDocBindings(fnNode)) {
out.push(synth);
}
// Synthesize foreach loop variable bindings inside this fn body.
for (const synth of synthesizeForeachBindings(fnNode)) {
out.push(synth);
}
}
continue;
}
// Synthesize arity metadata on function-like declarations so the
// registry can narrow overloads.
const declTag = FUNCTION_DECL_TAGS.find((t) => grouped[t] !== undefined);
if (declTag !== undefined) {
const anchor = grouped[declTag]!;
const fnNode = findFunctionNode(tree.rootNode, anchor.range);
if (fnNode !== null) {
const arity = computePhpArityMetadata(fnNode);
if (arity.parameterCount !== undefined) {
grouped['@declaration.parameter-count'] = syntheticCapture(
'@declaration.parameter-count',
fnNode,
String(arity.parameterCount),
);
}
if (arity.requiredParameterCount !== undefined) {
grouped['@declaration.required-parameter-count'] = syntheticCapture(
'@declaration.required-parameter-count',
fnNode,
String(arity.requiredParameterCount),
);
}
if (arity.parameterTypes !== undefined) {
grouped['@declaration.parameter-types'] = syntheticCapture(
'@declaration.parameter-types',
fnNode,
JSON.stringify(arity.parameterTypes),
);
}
}
}
// Synthesize `@reference.arity` on every call site so the registry's
// arity filter can narrow overloads. Count the `argument` children of
// the backing `arguments` node. Mirrors C#'s pattern (csharp/captures.ts
// lines 149-186). PHP needs this for arity-based dispatch (Cluster H).
const callTag = (
['@reference.call.free', '@reference.call.member', '@reference.call.constructor'] as const
).find((t) => grouped[t] !== undefined);
if (callTag !== undefined && grouped['@reference.arity'] === undefined) {
const anchor = grouped[callTag]!;
const callNode =
findNodeAtRange(tree.rootNode, anchor.range, 'function_call_expression') ??
findNodeAtRange(tree.rootNode, anchor.range, 'member_call_expression') ??
findNodeAtRange(tree.rootNode, anchor.range, 'nullsafe_member_call_expression') ??
findNodeAtRange(tree.rootNode, anchor.range, 'scoped_call_expression') ??
findNodeAtRange(tree.rootNode, anchor.range, 'object_creation_expression');
if (callNode !== null) {
const argList = callNode.childForFieldName('arguments');
const args: SyntaxNode[] = [];
if (argList !== null) {
for (let i = 0; i < argList.namedChildCount; i++) {
const child = argList.namedChild(i);
if (child !== null && child.type === 'argument') args.push(child);
}
}
grouped['@reference.arity'] = syntheticCapture(
'@reference.arity',
callNode,
String(args.length),
);
// Infer argument types from literal nodes for type-based narrowing.
// Non-literal arguments emit empty string ("unknown" = any-match).
const argTypes = args.map((arg) => inferPhpArgType(arg));
grouped['@reference.parameter-types'] = syntheticCapture(
'@reference.parameter-types',
callNode,
JSON.stringify(argTypes),
);
}
}
out.push(grouped);
}
return out;
}
/** Find the first PHP function-like node at the given range. */
function findFunctionNode(rootNode: SyntaxNode, range: Capture['range']): SyntaxNode | null {
for (const nodeType of FUNCTION_NODE_TYPES) {
const n = findNodeAtRange(rootNode, range, nodeType);
if (n !== null) return n as SyntaxNode;
}
return null;
}
// ─── PHP receiver normalization ──────────────────────────────────────────────
/**
* Normalize a PHP receiver expression so the language-agnostic
* compound-receiver resolver (which splits on `.`) can walk field-type chains.
*
* The compound-receiver resolver:
* - splits on `.` to get chain segments
* - looks up the first segment in `typeBindings` (keyed with `$` for variables)
* - walks subsequent segments as field names (stored without `$` in the graph)
*
* Transformation:
* 1. Replace `->` and `?->` with `.` so the resolver's splitter works
* 2. Strip any bare `?` fragment left by null-safe chain ends
* 3. Strip `$` from all segments EXCEPT the first (which is a variable
* and must keep `$` for typeBindings lookup e.g. `$user → User`)
*
* Examples:
* `$user` `$user` (bare variable unchanged)
* `$user->address` `$user.address`
* `$user->address->city` `$user.address.city`
* `$user?` `$user` (null-safe trailing `?` stripped)
* `$this` `$this` (receiverBinding uses `$this`)
* `parent` `parent` (super-receiver check)
*/
function normalizePhpReceiver(raw: string): string {
// Keep `$this`, `parent`, and `self` as-is.
if (raw === '$this' || raw === 'parent' || raw === 'self') return raw;
// Replace `?->` (null-safe) and plain `->` with `.`.
let text = raw.replace(/\?->/g, '.').replace(/->/g, '.');
// Strip a trailing `?` (null-safe fragment on the last object node).
text = text.replace(/\?$/, '');
// Collapse any doubled dots from `?->` where `?` was on its own.
text = text.replace(/\.{2,}/g, '.');
// Strip trailing dot.
text = text.replace(/\.$/, '');
// Split on `.` and strip `$` from all segments EXCEPT the first.
// The first segment is a PHP variable (typeBinding key includes `$`).
// Subsequent segments are property/method names (stored without `$`).
const segments = text.split('.');
for (let i = 1; i < segments.length; i++) {
const s = segments[i];
if (s !== undefined && s.startsWith('$')) segments[i] = s.slice(1);
}
return segments.join('.');
}
// ─── PHP argument type inference ─────────────────────────────────────────────
/**
* Infer the PHP type of a call argument from its literal shape.
* Returns an empty string for non-literals (treated as "unknown" = any-match).
* Mirrors C#'s `inferArgType` helper.
*/
function inferPhpArgType(argNode: SyntaxNode): string {
// argument node wraps the actual expression
const expr = argNode.firstNamedChild ?? argNode;
switch (expr.type) {
case 'integer':
return 'int';
case 'float':
return 'float';
case 'string':
case 'encapsed_string':
case 'heredoc':
case 'nowdoc':
return 'string';
case 'boolean':
case 'true':
case 'false':
return 'bool';
case 'null':
return 'null';
default:
return '';
}
}
// ─── PHPDoc synthesis ─────────────────────────────────────────────────────────
/** PHP 8+ attribute_list nodes that appear between PHPDoc and method. */
const SKIP_SIBLING_TYPES = new Set(['attribute_list', 'attribute', 'comment']);
/** Regex for PHPDoc @param: standard `@param Type $name` */
const PHPDOC_PARAM_RE = /@param\s+(\S+)\s+\$(\w+)/g;
/** Regex for PHPDoc @param: alternate `@param $name Type` */
const PHPDOC_PARAM_ALT_RE = /@param\s+\$(\w+)\s+(\S+)/g;
/** Regex for PHPDoc @return: `@return Type` */
const PHPDOC_RETURN_RE = /@return\s+(\S+)/;
/**
* Normalize a PHP type string to a simple class name for binding purposes.
* Returns null for primitives or uninformative types.
* Mirrors `normalizePhpType` in `interpret.ts` but operates on raw PHPDoc strings.
*/
function normalizePhpDocType(raw: string): string | null {
let type = raw.trim();
// Strip nullable prefix
if (type.startsWith('?')) type = type.slice(1).trim();
// Strip array suffix: User[] → User
if (type.endsWith('[]')) type = type.slice(0, -2).trim();
// Strip union with null/false/void
if (type.includes('|')) {
const parts = type
.split('|')
.map((p) => p.trim())
.filter((p) => p !== 'null' && p !== 'false' && p !== 'void' && p !== 'mixed' && p !== '');
if (parts.length !== 1) return null;
type = parts[0];
}
// Strip intersection: take first part
if (type.includes('&')) {
const first = type.split('&')[0].trim();
if (first === '') return null;
type = first;
}
// Strip generic wrapper: Collection<User> → User
const genericMatch = type.match(/^\w[\w\\]*\s*<([^,<>]+)>$/);
if (genericMatch) {
type = genericMatch[1].trim();
// Strip array suffix again inside generic
if (type.endsWith('[]')) type = type.slice(0, -2).trim();
}
// Strip namespace qualifier: \App\Models\User → User
if (type.includes('\\')) {
const segs = type.split('\\').filter(Boolean);
type = segs[segs.length - 1] ?? type;
}
// Reject primitives
if (PHP_PRIMITIVES.has(type.toLowerCase())) return null;
// Must be a simple identifier
if (!/^\w+$/.test(type)) return null;
return type;
}
const PHP_PRIMITIVES = new Set([
'int',
'integer',
'float',
'double',
'string',
'bool',
'boolean',
'array',
'object',
'callable',
'iterable',
'null',
'void',
'never',
'mixed',
'false',
'true',
'self',
'static',
'parent',
]);
/**
* Collect comment text from siblings immediately before `fnNode`.
* Skips PHP 8+ attribute_list nodes.
*/
function collectPrecedingComments(fnNode: SyntaxNode): string {
const texts: string[] = [];
let sibling = fnNode.previousSibling;
while (sibling !== null) {
if (sibling.type === 'comment') {
texts.unshift(sibling.text);
} else if (sibling.isNamed && !SKIP_SIBLING_TYPES.has(sibling.type)) {
break;
}
sibling = sibling.previousSibling;
}
return texts.join('\n');
}
/**
* Synthesize PHPDoc @param and @return type-binding captures for a
* method_declaration or function_definition node.
*
* PHPDoc @param Type $name `@type-binding.parameter` match (anchored at fn body/return_type).
* PHPDoc @return Type `@type-binding.return` match (anchored at fn name).
*/
function synthesizePhpDocBindings(fnNode: SyntaxNode): CaptureMatch[] {
if (fnNode.type !== 'method_declaration' && fnNode.type !== 'function_definition') return [];
const commentBlock = collectPrecedingComments(fnNode);
if (commentBlock === '') return [];
const out: CaptureMatch[] = [];
// Anchor for parameter type-bindings: the function body (or return_type as fallback).
// The binding must be inside the function scope so it's visible to body statements.
const bodyNode = fnNode.childForFieldName('body');
const anchorNode = bodyNode ?? fnNode;
// ── @param annotations ────────────────────────────────────────────────────
PHPDOC_PARAM_RE.lastIndex = 0;
let m: RegExpExecArray | null;
const seenParams = new Set<string>();
while ((m = PHPDOC_PARAM_RE.exec(commentBlock)) !== null) {
const rawType = m[1];
const paramName = '$' + m[2];
const typeName = normalizePhpDocType(rawType);
if (typeName === null) continue;
seenParams.add(paramName);
out.push({
'@type-binding.parameter': nodeToCapture('@type-binding.parameter', anchorNode),
'@type-binding.name': syntheticCapture('@type-binding.name', anchorNode, paramName),
'@type-binding.type': syntheticCapture('@type-binding.type', anchorNode, typeName),
});
}
// Also check alternate PHPDoc order: @param $name Type
PHPDOC_PARAM_ALT_RE.lastIndex = 0;
while ((m = PHPDOC_PARAM_ALT_RE.exec(commentBlock)) !== null) {
const paramName = '$' + m[1];
if (seenParams.has(paramName)) continue; // standard format takes priority
const rawType = m[2];
const typeName = normalizePhpDocType(rawType);
if (typeName === null) continue;
out.push({
'@type-binding.parameter': nodeToCapture('@type-binding.parameter', anchorNode),
'@type-binding.name': syntheticCapture('@type-binding.name', anchorNode, paramName),
'@type-binding.type': syntheticCapture('@type-binding.type', anchorNode, typeName),
});
}
// ── @return annotation ────────────────────────────────────────────────────
const returnMatch = PHPDOC_RETURN_RE.exec(commentBlock);
if (returnMatch !== null) {
const rawType = returnMatch[1];
const typeName = normalizePhpDocType(rawType);
if (typeName !== null) {
// @return bindings must be anchored at the method name and hoisted to Module scope
// by phpBindingScopeFor (which checks for @type-binding.return presence).
// Use the function_definition/method_declaration node itself as the anchor — it
// coincides with the innermost scope's range, so auto-hoist kicks in.
const nameNode = fnNode.childForFieldName('name') ?? fnNode;
out.push({
'@type-binding.return': nodeToCapture('@type-binding.return', fnNode),
'@type-binding.name': syntheticCapture('@type-binding.name', nameNode, nameNode.text),
'@type-binding.type': syntheticCapture('@type-binding.type', nameNode, typeName),
});
}
}
return out;
}
// ─── Foreach synthesis ───────────────────────────────────────────────────────
/**
* Walk all `foreach_statement` nodes inside `fnNode` and synthesize
* `@type-binding.alias` captures binding the loop variable to the
* element type of the iterable.
*
* Supports:
* - `foreach ($users as $user)` simple iterable variable
* - `foreach ($users as $k => $user)` keyvalue pair
* - `foreach ($this->users as $user)` member access iterable
* - `foreach (getUsers() as $user)` NOT yet supported (needs return type)
*
* The element type is resolved by:
* 1. Looking up the iterable name in PHPDoc @param bindings already
* collected for this function (passed via typeBindingsByName).
* 2. Direct resolution when iterable's env type IS the element type
* (because PHPDoc normalizes `User[]` `User` already).
*/
function synthesizeForeachBindings(fnNode: SyntaxNode): CaptureMatch[] {
if (
fnNode.type !== 'method_declaration' &&
fnNode.type !== 'function_definition' &&
fnNode.type !== 'anonymous_function' &&
fnNode.type !== 'arrow_function'
) {
return [];
}
const out: CaptureMatch[] = [];
// Build a mini type map from the function's PHPDoc @param annotations.
// This is re-parsed here (not cached from synthesizePhpDocBindings) for simplicity;
// the cost is negligible given the small comment sizes.
const commentBlock = collectPrecedingComments(fnNode);
const paramTypeMap = buildParamTypeMap(commentBlock);
// Walk the function body for foreach_statement nodes.
const bodyNode = fnNode.childForFieldName('body');
if (bodyNode === null) return [];
collectForeachBindings(bodyNode, fnNode, paramTypeMap, out);
return out;
}
/** Build a map of `$paramName → elementTypeName` from PHPDoc @param in a comment block. */
function buildParamTypeMap(commentBlock: string): Map<string, string> {
const map = new Map<string, string>();
if (commentBlock === '') return map;
PHPDOC_PARAM_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = PHPDOC_PARAM_RE.exec(commentBlock)) !== null) {
const rawType = m[1];
const paramName = '$' + m[2];
const typeName = normalizePhpDocType(rawType);
if (typeName !== null) map.set(paramName, typeName);
}
PHPDOC_PARAM_ALT_RE.lastIndex = 0;
while ((m = PHPDOC_PARAM_ALT_RE.exec(commentBlock)) !== null) {
const paramName = '$' + m[1];
if (map.has(paramName)) continue;
const rawType = m[2];
const typeName = normalizePhpDocType(rawType);
if (typeName !== null) map.set(paramName, typeName);
}
return map;
}
/**
* Walk a subtree and collect foreach_statement bindings.
* Recursively descends into all child nodes.
*/
function collectForeachBindings(
node: SyntaxNode,
fnNode: SyntaxNode,
paramTypeMap: Map<string, string>,
out: CaptureMatch[],
): void {
if (node.type === 'foreach_statement') {
const synth = synthesizeSingleForeach(node, fnNode, paramTypeMap);
if (synth !== null) out.push(synth);
}
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child !== null) {
collectForeachBindings(child, fnNode, paramTypeMap, out);
}
}
}
/**
* Synthesize a single `@type-binding.alias` match for a `foreach_statement`.
*
* AST structure for foreach_statement (tree-sitter-php):
* foreach ( <iterable> as <value_or_pair> ) <body>
* Named children (excluding body): first = iterable, second = value or pair.
*/
function synthesizeSingleForeach(
foreachNode: SyntaxNode,
fnNode: SyntaxNode,
paramTypeMap: Map<string, string>,
): CaptureMatch | null {
// Collect non-body named children: [iterable, value_or_pair]
const bodyNode = foreachNode.childForFieldName('body');
const children: SyntaxNode[] = [];
for (let i = 0; i < foreachNode.namedChildCount; i++) {
const child = foreachNode.namedChild(i);
if (child !== null && child !== bodyNode) children.push(child);
}
if (children.length < 2) return null;
const iterableNode = children[0];
const valueOrPair = children[1];
// Determine the loop variable node
let loopVarNode: SyntaxNode;
if (valueOrPair.type === 'pair') {
// $key => $value — use the last named child of the pair
const lastChild = valueOrPair.namedChild(valueOrPair.namedChildCount - 1);
if (lastChild === null) return null;
loopVarNode =
lastChild.type === 'by_ref' ? (lastChild.firstNamedChild ?? lastChild) : lastChild;
} else {
loopVarNode =
valueOrPair.type === 'by_ref' ? (valueOrPair.firstNamedChild ?? valueOrPair) : valueOrPair;
}
// Loop variable must be a variable_name
if (loopVarNode.type !== 'variable_name') return null;
const loopVarName = loopVarNode.text; // e.g. '$user'
// Resolve the element type from the iterable
let elementType: string | null = null;
if (iterableNode.type === 'variable_name') {
// foreach ($users as $user) — look up $users in param map
const iterableName = iterableNode.text; // e.g. '$users'
elementType = paramTypeMap.get(iterableName) ?? null;
} else if (iterableNode.type === 'member_access_expression') {
// foreach ($this->users as $user) — property name is the field
const propNameNode = iterableNode.childForFieldName('name');
if (propNameNode !== null) {
// Property stored with $ prefix in paramTypeMap (rare for $this->prop patterns)
// Try both with and without $ prefix
const propKey = '$' + propNameNode.text;
elementType = paramTypeMap.get(propKey) ?? null;
if (elementType === null) {
// Try to find the property type from the enclosing class
elementType = findClassPropertyElementType(iterableNode, fnNode);
}
}
} else if (iterableNode.type === 'function_call_expression') {
// foreach (getUsers() as $user) — use the function name as a type alias.
// The function's @return annotation produces a @type-binding.return binding
// in the Module scope (e.g. getUsers → User). The scope-extractor's
// followChainedRef will resolve $user → getUsers → User.
const funcNode = iterableNode.childForFieldName('function');
if (funcNode !== null && funcNode.type === 'name') {
elementType = funcNode.text; // e.g. 'getUsers' — chain will be resolved later
}
} else if (iterableNode.type === 'member_call_expression') {
// foreach ($this->getUsers() as $user) — use the method name as a type alias.
const methodNameNode = iterableNode.childForFieldName('name');
if (methodNameNode !== null) {
elementType = methodNameNode.text; // e.g. 'getUsers'
}
}
if (elementType === null) return null;
// Anchor the binding inside the foreach body so it's scoped to the loop.
const anchorNode = bodyNode ?? foreachNode;
return {
'@type-binding.alias': nodeToCapture('@type-binding.alias', anchorNode),
'@type-binding.name': syntheticCapture('@type-binding.name', anchorNode, loopVarName),
'@type-binding.type': syntheticCapture('@type-binding.type', anchorNode, elementType),
};
}
/**
* Try to find the element type for `$this->property` member access by walking
* up from the foreach to the enclosing class and scanning the property declaration.
*/
function findClassPropertyElementType(
memberAccessNode: SyntaxNode,
fnNode: SyntaxNode,
): string | null {
const propNameNode = memberAccessNode.childForFieldName('name');
if (propNameNode === null) return null;
const propName = propNameNode.text;
// Walk up from fnNode to find the enclosing class declaration
let cur: SyntaxNode | null = fnNode.parent;
while (cur !== null) {
if (cur.type === 'class_declaration' || cur.type === 'trait_declaration') {
break;
}
cur = cur.parent;
}
if (cur === null) return null;
// Find the property_declaration with matching variable_name '$propName'
const declList = cur.childForFieldName('body');
if (declList === null) return null;
for (let i = 0; i < declList.namedChildCount; i++) {
const child = declList.namedChild(i);
if (child === null || child.type !== 'property_declaration') continue;
for (let j = 0; j < child.namedChildCount; j++) {
const elem = child.namedChild(j);
if (elem === null || elem.type !== 'property_element') continue;
const varNameNode = elem.firstNamedChild;
if (varNameNode === null || varNameNode.text !== '$' + propName) continue;
// Found the property — get its element type from @var PHPDoc or native type
return extractPropertyElementType(child);
}
}
return null;
}
/** Regex for PHPDoc @var: `@var Type` */
const PHPDOC_VAR_RE = /@var\s+(\S+)/;
/**
* Extract element type from a property_declaration node:
* 1. PHPDoc @var annotation on a preceding comment sibling
* 2. PHP 7.4+ native type field (non-array)
*/
function extractPropertyElementType(propDecl: SyntaxNode): string | null {
// Strategy 1: PHPDoc @var on a preceding comment sibling
let sibling = propDecl.previousSibling;
while (sibling !== null) {
if (sibling.type === 'comment') {
const m = PHPDOC_VAR_RE.exec(sibling.text);
if (m !== null) return normalizePhpDocType(m[1]);
} else if (sibling.isNamed && !SKIP_SIBLING_TYPES.has(sibling.type)) {
break;
}
sibling = sibling.previousSibling;
}
// Strategy 2: native type field — skip generic 'array'
const typeNode = propDecl.childForFieldName('type');
if (typeNode === null) return null;
const typeName = typeNode.text.trim();
if (typeName === 'array' || typeName === '') return null;
return normalizePhpDocType(typeName);
}

View file

@ -0,0 +1,304 @@
/**
* Decompose a PHP `namespace_use_declaration` into one or more
* `CaptureMatch` objects carrying the synthesized markers
* `@import.kind` / `@import.source` / `@import.name` / `@import.alias`
* that `interpretPhpImport` consumes.
*
* PHP import forms handled:
*
* use Foo\Bar; namespace, localName=Bar
* use Foo\Bar as Baz; alias, localName=Baz
* use function Foo\bar; function, localName=bar
* use const Foo\BAR; const, localName=BAR
* use Foo\{A, B as C}; grouped: one match per clause
* use function Foo\{f, g as h}; grouped function variants
* use const Foo\{X, Y as Z}; grouped const variants
*
* Unlike C#'s decomposer this is 1:N each grouped use_declaration
* fans out to one CaptureMatch per inner clause.
*/
import type { Capture, CaptureMatch } from 'gitnexus-shared';
import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
export type PhpImportKind = 'namespace' | 'alias' | 'function' | 'const';
interface PhpImportSpec {
readonly kind: PhpImportKind;
/** Full backslash-separated path (backslashes intact): `Foo\Bar\Baz`. */
readonly source: string;
/** Local binding name last source segment for plain imports, the
* alias identifier for aliased imports. */
readonly name: string;
/** Present iff kind === 'alias'. */
readonly alias?: string;
/** Anchor node for synthesized captures (range-wise). */
readonly atNode: SyntaxNode;
}
/**
* Decompose a `namespace_use_declaration` node into one `CaptureMatch`
* per logical import. Returns `[]` when the node is unrecognized or
* carries no resolvable clauses.
*/
export function splitNamespaceUseDeclaration(stmtNode: SyntaxNode): CaptureMatch[] {
if (stmtNode.type !== 'namespace_use_declaration') return [];
// Detect qualifier keyword: `use function` / `use const`
// tree-sitter-php uses a `use_type` or `function`/`const` keyword
// child to distinguish them. We scan the raw text before the first
// backslash-path child.
const qualifier = detectQualifier(stmtNode);
// Grouped use: `use Foo\{A, B as C}` — find namespace_use_group child.
const groupNode = findNamedChild(stmtNode, 'namespace_use_group');
if (groupNode !== null) {
return decomposeGrouped(stmtNode, groupNode, qualifier);
}
// Single use clause (possibly aliased).
const spec = parseSingleUseClause(stmtNode, qualifier);
if (spec === null) return [];
return [buildImportMatch(stmtNode, spec)];
}
// ── Qualifier detection ────────────────────────────────────────────────────
/**
* Return the qualifier keyword appearing after `use`:
* `'function'`, `'const'`, or `null` for plain namespace use.
*
* tree-sitter-php emits the qualifier as a `name` node with text
* "function" or "const" (not a keyword token in recent grammars),
* or as a dedicated `use_type` node. We inspect the node's raw text
* to be grammar-version-agnostic.
*/
function detectQualifier(node: SyntaxNode): PhpImportKind {
const raw = node.text;
// Match `use function` or `use const` at the start (after optional whitespace)
if (/^\s*use\s+function\s/i.test(raw)) return 'function';
if (/^\s*use\s+const\s/i.test(raw)) return 'const';
return 'namespace';
}
// ── Single clause parsing ──────────────────────────────────────────────────
function parseSingleUseClause(node: SyntaxNode, qualifier: PhpImportKind): PhpImportSpec | null {
// A plain `namespace_use_declaration` has one or more
// `namespace_use_clause` named children (each clause is one import,
// comma-separated for multiple). For the single case there is one.
const clause = findNamedChild(node, 'namespace_use_clause');
if (clause !== null) return parseUseClause(clause, qualifier);
// Older grammar versions may put the qualified_name directly under
// the declaration node. Check for a qualified_name or name child.
const qualName = findNamedChild(node, 'qualified_name') ?? findNamedChild(node, 'name');
if (qualName === null) return null;
const source = qualName.text.trim();
if (source === '') return null;
return {
kind: qualifier,
source,
name: lastSegment(source),
atNode: node,
};
}
function parseUseClause(clause: SyntaxNode, qualifier: PhpImportKind): PhpImportSpec | null {
// namespace_use_clause:
// qualified_name (or name)
// optional: alias_clause → "as" name (some grammar versions)
// optional: bare name node (tree-sitter-php ≥ 0.22 emits the
// alias as a sibling `name` node
// directly, not inside alias_clause)
const qualName = findNamedChild(clause, 'qualified_name') ?? findNamedChild(clause, 'name');
if (qualName === null) return null;
const source = qualName.text.trim();
if (source === '') return null;
// Strategy 1: explicit alias_clause wrapper (older grammar versions).
const aliasClause = findNamedChild(clause, 'alias_clause');
if (aliasClause !== null) {
// alias_clause: "as" name
const aliasName = findNamedChild(aliasClause, 'name') ?? aliasClause.firstNamedChild;
const alias = aliasName?.text.trim() ?? '';
if (alias === '') return null;
return {
kind: 'alias',
source,
name: alias,
alias,
atNode: clause,
};
}
// Strategy 2: bare sibling `name` node after the qualified_name.
// tree-sitter-php (≥ 0.22) emits `use Foo\Bar as Baz` as:
// namespace_use_clause
// qualified_name "Foo\Bar"
// name "Baz" ← alias, no alias_clause wrapper
// Detect by: clause has ≥2 named children AND the last named child is
// a `name` node that differs from the qualName node.
if (clause.namedChildCount >= 2) {
const lastChild = clause.namedChild(clause.namedChildCount - 1);
if (lastChild !== null && lastChild !== qualName && lastChild.type === 'name') {
const alias = lastChild.text.trim();
if (alias !== '') {
return {
kind: 'alias',
source,
name: alias,
alias,
atNode: clause,
};
}
}
}
return {
kind: qualifier,
source,
name: lastSegment(source),
atNode: clause,
};
}
// ── Grouped use decomposition ──────────────────────────────────────────────
/**
* Decompose `use Foo\Bar\{A, B as C, function f, const X}` into one
* `CaptureMatch` per inner clause.
*
* The leading prefix (`Foo\Bar`) is prepended to each inner path.
* Inner clauses can override the qualifier with their own `function` /
* `const` keyword inside the group.
*/
function decomposeGrouped(
stmtNode: SyntaxNode,
groupNode: SyntaxNode,
outerQualifier: PhpImportKind,
): CaptureMatch[] {
// The prefix is the qualified_name that precedes the `{...}` group.
const prefixNode = findNamedChild(stmtNode, 'qualified_name') ?? findNamedChild(stmtNode, 'name');
const prefix = prefixNode?.text.trim() ?? '';
const out: CaptureMatch[] = [];
for (let i = 0; i < groupNode.namedChildCount; i++) {
const child = groupNode.namedChild(i);
if (child === null) continue;
// Each child in a group may be:
// namespace_use_clause — plain or aliased
// namespace_use_type — `function` or `const` qualifier inside group
// We detect an inline qualifier by checking the raw text of the clause.
if (child.type !== 'namespace_use_clause') continue;
const innerQualifier = detectInnerQualifier(child) ?? outerQualifier;
const spec = parseInnerClause(child, prefix, innerQualifier);
if (spec !== null) {
out.push(buildImportMatch(stmtNode, spec));
}
}
return out;
}
/**
* Detect an inline qualifier keyword inside a grouped clause.
* e.g. `use Foo\{function bar, const BAZ}` each clause may start with
* `function` or `const`.
*/
function detectInnerQualifier(clause: SyntaxNode): PhpImportKind | null {
const raw = clause.text.trim();
if (/^function\s/i.test(raw)) return 'function';
if (/^const\s/i.test(raw)) return 'const';
return null;
}
function parseInnerClause(
clause: SyntaxNode,
prefix: string,
qualifier: PhpImportKind,
): PhpImportSpec | null {
const qualName = findNamedChild(clause, 'qualified_name') ?? findNamedChild(clause, 'name');
if (qualName === null) return null;
// Strip inline `function` / `const` text prefix if present in the text.
let innerPath = qualName.text.trim();
innerPath = innerPath.replace(/^(?:function|const)\s+/i, '').trim();
if (innerPath === '') return null;
const source = prefix !== '' ? `${prefix}\\${innerPath}` : innerPath;
// Strategy 1: explicit alias_clause wrapper (older grammar versions).
const aliasClause = findNamedChild(clause, 'alias_clause');
if (aliasClause !== null) {
const aliasName = findNamedChild(aliasClause, 'name') ?? aliasClause.firstNamedChild;
const alias = aliasName?.text.trim() ?? '';
if (alias === '') return null;
return {
kind: 'alias',
source,
name: alias,
alias,
atNode: clause,
};
}
// Strategy 2: bare sibling `name` node after the qualified_name (tree-sitter-php ≥ 0.22).
if (clause.namedChildCount >= 2) {
const lastChild = clause.namedChild(clause.namedChildCount - 1);
if (lastChild !== null && lastChild !== qualName && lastChild.type === 'name') {
const alias = lastChild.text.trim();
if (alias !== '') {
return {
kind: 'alias',
source,
name: alias,
alias,
atNode: clause,
};
}
}
}
return {
kind: qualifier,
source,
name: lastSegment(innerPath),
atNode: clause,
};
}
// ── CaptureMatch builder ───────────────────────────────────────────────────
function buildImportMatch(stmtNode: SyntaxNode, spec: PhpImportSpec): CaptureMatch {
const m: Record<string, Capture> = {
'@import.statement': nodeToCapture('@import.statement', stmtNode),
'@import.kind': syntheticCapture('@import.kind', spec.atNode, spec.kind),
'@import.source': syntheticCapture('@import.source', spec.atNode, spec.source),
'@import.name': syntheticCapture('@import.name', spec.atNode, spec.name),
};
if (spec.alias !== undefined) {
m['@import.alias'] = syntheticCapture('@import.alias', spec.atNode, spec.alias);
}
return m;
}
// ── Helpers ────────────────────────────────────────────────────────────────
/** Last backslash-separated segment: `Foo\Bar\Baz` → `Baz`. */
function lastSegment(path: string): string {
const parts = path.split('\\').filter(Boolean);
return parts[parts.length - 1] ?? path;
}
/** Find the first named child with a given node type. */
function findNamedChild(node: SyntaxNode, type: string): SyntaxNode | null {
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child !== null && child.type === type) return child;
}
return null;
}

View file

@ -0,0 +1,140 @@
/**
* Adapter from `(ParsedImport, WorkspaceIndex)` concrete file path.
*
* Delegates to the existing `resolvePhpImportInternal` (PSR-4 via
* composer.json + suffix matching fallback). The `WorkspaceIndex` is
* opaque at this layer; consumers wire a `PhpResolveContext` shape
* carrying `fromFile` + `allFilePaths`.
*
* `loadPhpComposerConfig` is the `ScopeResolver.loadResolutionConfig`
* implementation it loads `composer.json` once per workspace pass and
* threads the parsed config into every subsequent `resolveImportTarget`
* call via the opaque `resolutionConfig` parameter.
*
* Returning `null` lets the finalize algorithm mark the edge as
* `linkStatus: 'unresolved'`.
*/
import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared';
import { resolvePhpImportInternal } from '../../import-resolvers/php.js';
import type { ComposerConfig } from '../../language-config.js';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
export interface PhpResolveContext {
readonly fromFile: string;
readonly allFilePaths: ReadonlySet<string>;
}
// ─── loadResolutionConfig ──────────────────────────────────────────────────
/**
* Load and parse `composer.json` from the repo root. Returns a
* `ComposerConfig` object (PSR-4 namespace directory mappings) or
* `null` when no `composer.json` is present or it cannot be parsed.
*
* The result is threaded into each `resolvePhpImportInternal` call as
* the `composerConfig` argument.
*/
export function loadPhpComposerConfig(repoPath: string): ComposerConfig | null {
try {
const composerPath = join(repoPath, 'composer.json');
const raw = readFileSync(composerPath, 'utf8');
const parsed = JSON.parse(raw) as unknown;
if (typeof parsed !== 'object' || parsed === null) return null;
const composer = parsed as Record<string, unknown>;
const autoload = composer['autoload'] as Record<string, unknown> | undefined;
if (autoload === undefined) return null;
const psr4Raw = (autoload['psr-4'] ?? {}) as Record<string, string | string[]>;
const psr4 = new Map<string, string>();
for (const [ns, dirs] of Object.entries(psr4Raw)) {
// namespace prefix ends with `\` — keep as-is; resolver strips it
const normalizedNs = ns.replace(/\\$/, '');
const dir = Array.isArray(dirs) ? dirs[0] : dirs;
if (typeof dir === 'string') {
// Normalize directory path (strip trailing slash)
const normalizedDir = dir.replace(/\/+$/, '');
psr4.set(normalizedNs, normalizedDir);
}
}
return { psr4 };
} catch {
return null;
}
}
// ─── resolvePhpImportTarget ────────────────────────────────────────────────
/**
* LanguageProvider-shaped adapter: `(ParsedImport, WorkspaceIndex) → string | null`.
*
* The `WorkspaceIndex` is `unknown` in the shared contract. The scope-resolution
* orchestrator hands us a `PhpResolveContext`-shaped object; narrow structurally
* rather than via a cast chain so unexpected shapes return `null` cleanly.
*/
export function resolvePhpImportTarget(
parsedImport: ParsedImport,
workspaceIndex: WorkspaceIndex,
): string | null {
const ctx = workspaceIndex as PhpResolveContext | undefined;
if (
ctx === undefined ||
typeof (ctx as { fromFile?: unknown }).fromFile !== 'string' ||
!((ctx as { allFilePaths?: unknown }).allFilePaths instanceof Set)
) {
return null;
}
if (parsedImport.kind === 'dynamic-unresolved') return null;
if (parsedImport.targetRaw === null || parsedImport.targetRaw === '') return null;
const allFiles = ctx.allFilePaths as Set<string>;
const normalizedFileList = [...allFiles].map((f) => f.replace(/\\/g, '/'));
const allFileList = [...allFiles];
return resolvePhpImportInternal(
parsedImport.targetRaw,
null, // composerConfig not available through LanguageProvider path
allFiles,
normalizedFileList,
allFileList,
undefined,
);
}
/**
* ScopeResolver-shaped adapter: `(targetRaw, fromFile, allFilePaths, resolutionConfig?) → string | null`.
*
* Used inside `scope-resolver.ts`. Accepts the optional `resolutionConfig`
* (a `ComposerConfig | null` loaded once per workspace by
* `loadPhpComposerConfig`) and threads it into `resolvePhpImportInternal`.
*/
export function resolvePhpImportTargetInternal(
targetRaw: string,
_fromFile: string,
allFilePaths: ReadonlySet<string>,
resolutionConfig?: unknown,
): string | null {
if (targetRaw === '') return null;
const composerConfig =
resolutionConfig !== undefined && resolutionConfig !== null
? (resolutionConfig as ComposerConfig)
: null;
const allFiles = allFilePaths as Set<string>;
const normalizedFileList = [...allFiles].map((f) => f.replace(/\\/g, '/'));
const allFileList = [...allFiles];
return resolvePhpImportInternal(
targetRaw,
composerConfig,
allFiles,
normalizedFileList,
allFileList,
undefined,
);
}

View file

@ -0,0 +1,73 @@
/**
* PHP scope-resolution hooks (RFC #909 Ring 3 LANG-php, #938).
*
* Public API barrel. Consumers should import from this file rather than
* the individual modules.
*
* Module layout (each file is a single concern):
*
* - `query.ts` tree-sitter query + lazy parser/query singletons
* - `captures.ts` `emitPhpScopeCaptures` orchestrator
* - `import-decomposer.ts` each `namespace_use_declaration` ParsedImport captures
* - `interpret.ts` capture-match `ParsedImport` / `ParsedTypeBinding`
* - `simple-hooks.ts` small/no-op hooks made explicit
* - `receiver-binding.ts` synthesize `$this` / `parent` type-bindings on
* instance-method entry
* - `merge-bindings.ts` PHP `use` precedence (local > import > wildcard)
* - `arity.ts` PHP arity compatibility (variadic, defaults)
* - `arity-metadata.ts` synthesize arity metadata from declarations
* - `import-target.ts` `(ParsedImport, WorkspaceIndex) → file path` adapter
* wrapping `resolvePhpImportInternal` (PSR-4 + composer.json)
* - `scope-resolver.ts` `ScopeResolver` registered in `SCOPE_RESOLVERS`
* - `cache-stats.ts` PROF_SCOPE_RESOLUTION cache hit/miss counters
*
* ## Known limitations
*
* The PHP registry-primary path intentionally does NOT resolve the following.
* Each is a conscious trade-off at migration time.
*
* 1. **Trait `$this` using-class binding** for methods defined in a
* trait, `$this` is synthesized as a binding to the trait itself.
* Resolving `$this` to the actual using-class type requires cross-file
* analysis of all `use TraitName;` declarations in class bodies.
* Deferred to a follow-up; trait method resolution falls back to the
* trait scope.
*
* 2. **Anonymous classes** `new class extends Foo { }` have no stable
* class name and are skipped by receiver-binding synthesis. The class
* body is still scoped; member lookups inside it will fall back to
* free-call resolution.
*
* 3. **Dynamic property/method access** `$obj->{$name}()` and
* `$$varName` are not followed. The dynamic receiver is ignored and
* the call falls through to the shared free-call resolver.
*
* 4. **Magic methods** `__get`, `__set`, `__call`, `__callStatic` are
* not modeled as virtual dispatch; they appear as regular method
* declarations in the graph but calls that would route through them
* at runtime are not distinguished.
*
* 5. **Laravel facade magic** `App::make(...)`, `Cache::get(...)` etc.
* resolve statically to the Facade class rather than the underlying
* bound implementation. Deferred to a Laravel-specific plugin.
*
* 6. **Intersection types in parameters** `T&U $param` takes the first
* named part (`T`). This matches the legacy type-extractor's behavior.
*
* Shadow-harness corpus parity is the authoritative signal for which of
* these matter in practice. The CI parity gate blocks any PR that regresses
* either the legacy or registry-primary run of
* `test/integration/resolvers/php.test.ts`.
*/
export { emitPhpScopeCaptures } from './captures.js';
export { getPhpCaptureCacheStats, resetPhpCaptureCacheStats } from './cache-stats.js';
export { interpretPhpImport, interpretPhpTypeBinding } from './interpret.js';
export { phpMergeBindings } from './merge-bindings.js';
export { phpArityCompatibility } from './arity.js';
export { resolvePhpImportTarget, type PhpResolveContext } from './import-target.js';
export { phpBindingScopeFor, phpImportOwningScope, phpReceiverBinding } from './simple-hooks.js';
// NOTE: phpScopeResolver is intentionally NOT re-exported from this barrel.
// Importing it here would create a circular dependency:
// php.ts → php/index.js → php/scope-resolver.js → ../php.js
// Registry and other consumers must import directly from './php/scope-resolver.js'.

View file

@ -0,0 +1,250 @@
/**
* Capture-match semantic-shape interpreters for PHP.
*
* - `interpretPhpImport` `ParsedImport`
* - `interpretPhpTypeBinding` `ParsedTypeBinding`
*
* Import matches arrive pre-decomposed by `emitPhpScopeCaptures` (one
* CaptureMatch per logical import, with synthesized `@import.kind /
* source / name / alias` markers). Type-binding matches arrive from
* the raw query captures each `@type-binding.*` anchor carries
* `@type-binding.name` + `@type-binding.type`.
*/
import type { CaptureMatch, ParsedImport, ParsedTypeBinding, TypeRef } from 'gitnexus-shared';
// ─── interpretImport ──────────────────────────────────────────────────────
export function interpretPhpImport(captures: CaptureMatch): ParsedImport | null {
const kindCap = captures['@import.kind'];
const sourceCap = captures['@import.source'];
const nameCap = captures['@import.name'];
const aliasCap = captures['@import.alias'];
const kind = kindCap?.text;
if (kind === undefined || sourceCap === undefined) return null;
const source = sourceCap.text.trim();
if (source === '') return null;
switch (kind) {
case 'namespace': {
// `use Foo\Bar;` — PHP `use` is a NAMED import (binds the class
// `Bar`, not the namespace `Foo`). This differs from C# `using`,
// which is a true namespace import. Producing 'named' here makes
// `new Bar()` resolve to the imported class def.
const localName = nameCap?.text.trim() ?? lastSegment(source);
return {
kind: 'named',
localName,
importedName: localName,
targetRaw: source,
};
}
case 'alias': {
// `use Foo\Bar as Baz;`
if (aliasCap === undefined) return null;
const alias = aliasCap.text.trim();
if (alias === '') return null;
const importedName = lastSegment(source);
return {
kind: 'alias',
localName: alias,
importedName,
alias,
targetRaw: source,
};
}
case 'function': {
// `use function Foo\bar;` — treat as named import; importedName is
// the function name (last segment). targetRaw is the full path.
const localName = nameCap?.text.trim() ?? lastSegment(source);
return {
kind: 'named',
localName,
importedName: localName,
targetRaw: source,
};
}
case 'const': {
// `use const Foo\BAR;` — same shape as function.
const localName = nameCap?.text.trim() ?? lastSegment(source);
return {
kind: 'named',
localName,
importedName: localName,
targetRaw: source,
};
}
default:
return null;
}
}
// ─── interpretTypeBinding ─────────────────────────────────────────────────
export function interpretPhpTypeBinding(captures: CaptureMatch): ParsedTypeBinding | null {
const nameCap = captures['@type-binding.name'];
const typeCap = captures['@type-binding.type'];
if (nameCap === undefined || typeCap === undefined) return null;
// Determine source from anchor captures. Order: most-specific first.
let source: TypeRef['source'] = 'parameter-annotation';
if (captures['@type-binding.self'] !== undefined) source = 'self';
else if (captures['@type-binding.constructor'] !== undefined) source = 'constructor-inferred';
else if (captures['@type-binding.annotation'] !== undefined) source = 'annotation';
else if (captures['@type-binding.alias'] !== undefined) source = 'assignment-inferred';
else if (captures['@type-binding.return'] !== undefined) source = 'return-annotation';
let rawType: string | null;
if (source === 'assignment-inferred') {
// `@type-binding.alias` captures cover several assignment RHS shapes:
// - `$alias = $u` → rawType = '$u' (variable alias)
// - `$u = getUser()` → rawType = 'getUser' (callable alias)
// - `$u = new User()` → rawType = 'User' (constructor — via @type-binding.constructor; handled below)
// - `$role = UserRole::Viewer` → rawType = 'UserRole' (enum/class constant)
//
// For variable aliases (`$u`), `normalizePhpType` returns null because
// `$` is not a word character. We must preserve the raw `$`-prefixed name
// so `followChainedRef` can walk the chain `$alias → $u → User`.
// For callable/class names, `normalizePhpType` strips qualifiers correctly.
const rawText = typeCap.text.trim();
if (rawText.startsWith('$')) {
// Variable alias: keep as-is for chain-following.
rawType = rawText;
} else {
rawType = normalizePhpType(rawText);
}
} else {
// All other sources: strip PHP type decoration to get the simple class name:
// ?User → User (nullable prefix)
// User|null → User (union with null/false/void)
// User&Loggable → User (intersection — take first meaningful)
// Collection<User> → User (PHPDoc generic wrapper)
// User[] → User (array suffix)
// \App\Models\User → User (backslash qualifier)
rawType = normalizePhpType(typeCap.text.trim());
}
if (rawType === null) return null;
// PHP variable names include the `$` sigil (e.g. `$user`). Most
// bindings keep it because they are looked up via the variable
// (`$user->method()` finds binding `$user`). Property field bindings
// are different: `$user->address` looks up `address` (no sigil) on
// the User class. Property declarations carry source `'annotation'`,
// so we strip the leading `$` for that source only.
let boundName = nameCap.text.trim();
if (source === 'annotation' && boundName.startsWith('$')) {
boundName = boundName.slice(1);
}
return { boundName, rawTypeName: rawType, source };
}
// ─── Type normalization ───────────────────────────────────────────────────
/**
* Normalize a PHP type string to a simple class identifier, or `null`
* when the type is uninformative (primitive, void, mixed, self, etc.).
*
* Rules applied in order:
* 1. Strip nullable prefix `?`
* 2. Split on `|` (union) keep only if exactly one non-null part
* 3. Take first part of `&` intersection
* 4. Strip array suffix `[]`
* 5. Strip generic wrapper `Collection<User>` `User`
* 6. Canonicalize leading backslash off: `\App\Models\User` `App\Models\User`
* 7. Reject PHP primitive / pseudo types
*
* The qualified form is preserved on `TypeRef.rawName` so downstream PHP
* receiver resolution can distinguish `\App\Other\User` from a same-simple-name
* `User` reachable via `use`. Without this, fully-qualified type hints collapse
* to ambiguous simple names and resolve against the caller's scope chain
* instead of the explicit target the source named (Codex PR #1497 review,
* finding 1).
*/
export function normalizePhpType(raw: string): string | null {
// 1. Strip nullable prefix
let type = raw.startsWith('?') ? raw.slice(1).trim() : raw;
// 2. Union type — keep only if one non-null/false/void part remains
if (type.includes('|')) {
const parts = type
.split('|')
.map((p) => p.trim())
.filter((p) => p !== 'null' && p !== 'false' && p !== 'void' && p !== 'mixed' && p !== '');
if (parts.length !== 1) return null;
type = parts[0];
}
// 3. Intersection type — take the first part
if (type.includes('&')) {
const first = type.split('&')[0].trim();
if (first === '') return null;
type = first;
}
// 4. Strip array suffix
if (type.endsWith('[]')) type = type.slice(0, -2).trim();
// 5. Strip single-arg generic wrapper: Collection<User> → User
// Qualified inner types (Collection<\App\Models\User>) survive — the
// capture group preserves whatever the writer named.
const genericMatch = type.match(/^\w[\w\\]*\s*<([^,<>]+)>$/);
if (genericMatch) {
type = genericMatch[1].trim();
}
// 6. Canonicalize leading backslash off — keep the qualified path intact.
// `\App\Models\User` → `App\Models\User`. `App\Models\User` → unchanged.
// Unqualified `User` stays as `User`. The qualified form is the lookup
// key into the workspace QualifiedNameIndex (PHP defs are indexed by
// namespace-joined qualifiedName); the leading-backslash distinction in
// source is only an "absolute path" anchor, not part of the canonical key.
if (type.startsWith('\\')) type = type.replace(/^\\+/, '');
// 7. Reject primitives / pseudo-types
if (isPrimitiveOrPseudo(type)) return null;
// Must be a (possibly qualified) PHP identifier — segments of word chars
// separated by single backslashes. Empty segments (consecutive backslashes,
// trailing backslash) are rejected.
if (!/^\w+(?:\\\w+)*$/.test(type)) return null;
return type;
}
const PHP_PRIMITIVE_TYPES = new Set([
'int',
'integer',
'float',
'double',
'string',
'bool',
'boolean',
'array',
'object',
'callable',
'iterable',
'null',
'void',
'never',
'mixed',
'false',
'true',
'self',
'static',
'parent',
]);
function isPrimitiveOrPseudo(type: string): boolean {
return PHP_PRIMITIVE_TYPES.has(type.toLowerCase());
}
/** Last backslash-separated segment: `Foo\Bar\Baz` → `Baz`. */
function lastSegment(path: string): string {
const parts = path.split('\\').filter(Boolean);
return parts[parts.length - 1] ?? path;
}

View file

@ -0,0 +1,51 @@
/**
* PHP shadowing precedence for the `mergeBindings` hook.
*
* Tier ranking (lower wins in shadowing):
*
* - 0: `local` a class member, method, local variable, or parameter
* declared in this scope.
* - 1: `import` / `namespace` / `reexport` `use Foo\Bar;`,
* `use Foo\Bar as Baz;`, `use function`, `use const`.
* All use-statement flavors that introduce a name sit at this tier.
* - 2: `wildcard` grouped uses / wildcard imports (deferred; mapped
* here for completeness).
*
* Within a surviving tier we de-dup by `DefId`, last-write-wins so a
* `use` re-declared further down the file cleanly replaces the earlier
* binding.
*/
import type { BindingRef } from 'gitnexus-shared';
const TIER_LOCAL = 0;
const TIER_IMPORT = 1;
const TIER_WILDCARD = 2;
const TIER_UNKNOWN = 3;
function tierOf(b: BindingRef): number {
switch (b.origin) {
case 'local':
return TIER_LOCAL;
case 'reexport':
case 'import':
case 'namespace':
return TIER_IMPORT;
case 'wildcard':
return TIER_WILDCARD;
default:
return TIER_UNKNOWN;
}
}
export function phpMergeBindings(bindings: readonly BindingRef[]): readonly BindingRef[] {
if (bindings.length === 0) return bindings;
let bestTier = Number.POSITIVE_INFINITY;
for (const b of bindings) bestTier = Math.min(bestTier, tierOf(b));
const survivors = bindings.filter((b) => tierOf(b) === bestTier);
const seen = new Map<string, BindingRef>();
for (const b of survivors) seen.set(b.def.nodeId, b);
return [...seen.values()];
}

View file

@ -0,0 +1,335 @@
/**
* PHP same-namespace cross-file visibility.
*
* In PHP, every class declared in `namespace Foo\Bar` is visible to all
* other files in the same namespace WITHOUT an explicit `use` statement.
* Without this pass, `Service.php` (namespace `App\Services`) can't see
* `User` declared in `Models.php` (namespace `App\Models`) unless
* `UserService.php` has an explicit `use App\Models\User` statement.
*
* More importantly, A.php (namespace `App\Models`) can return `Greeting`
* (same namespace `App\Models`) without importing it, and the compound-
* receiver resolver needs to find `Greeting` as a class binding in the
* scope chain.
*
* Implementation mirrors C#'s `namespace-siblings.ts`:
* 1. Extract the declared namespace from each PHP file's source.
* 2. Group class-like defs by namespace.
* 3. Inject sibling class defs into each file's Module scope's
* `bindingAugmentations` with `origin: 'namespace'`.
* 4. Also mirror return-type bindings from same-namespace siblings
* so cross-file chain-follow finds return types without explicit imports.
*
* Uses the PHP tree-sitter parser (via the lazy singleton in `query.ts`)
* to extract namespace declarations same AST that `extractParsedFile`
* already parsed, reused via `treeCache` to avoid double-parsing.
*/
import type { BindingRef, ParsedFile, Scope, ScopeId, SymbolDefinition } from 'gitnexus-shared';
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import { getPhpParser } from './query.js';
import { getTreeSitterBufferSize } from '../../constants.js';
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
// ─── PHP file structure extraction ──────────────────────────────────────────
interface PhpFileStructure {
/** The declared namespace (backslash-separated), or '' for global namespace. */
readonly namespace: string;
}
type PhpTree = ReturnType<ReturnType<typeof getPhpParser>['parse']>;
/**
* Extract the declared namespace from a PHP file's source.
* Uses the cached AST tree when available to avoid re-parsing.
*/
function extractPhpFileStructure(content: string, cachedTree: unknown): PhpFileStructure {
const tree =
(cachedTree as PhpTree | undefined) ??
parseSourceSafe(getPhpParser(), content, undefined, {
bufferSize: getTreeSitterBufferSize(content),
});
// Walk top-level nodes looking for namespace_definition.
// PHP files have at most one namespace declaration (PSR-4 convention).
// `namespace_definition` has a `name:` field of type `namespace_name`.
const root = tree.rootNode;
for (let i = 0; i < root.namedChildCount; i++) {
const child = root.namedChild(i);
if (child === null) continue;
if (child.type === 'namespace_definition') {
const nameNode = child.childForFieldName('name');
if (nameNode !== null) {
return { namespace: nameNode.text };
}
}
}
return { namespace: '' };
}
// ─── Augmentation bucket helper ─────────────────────────────────────────────
function getAugmentationBucket(
augmentations: Map<ScopeId, Map<string, BindingRef[]>>,
scopeId: ScopeId,
name: string,
): BindingRef[] {
let scopeBindings = augmentations.get(scopeId);
if (scopeBindings === undefined) {
scopeBindings = new Map<string, BindingRef[]>();
augmentations.set(scopeId, scopeBindings);
}
let bucket = scopeBindings.get(name);
if (bucket === undefined) {
bucket = [];
scopeBindings.set(name, bucket);
}
return bucket;
}
function isClassLikeDef(def: SymbolDefinition): boolean {
return (
def.type === 'Class' ||
def.type === 'Interface' ||
def.type === 'Struct' ||
def.type === 'Enum' ||
def.type === 'Trait'
);
}
// ─── Public entry point ──────────────────────────────────────────────────────
export interface PhpSiblingInputs {
readonly fileContents: ReadonlyMap<string, string>;
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 either `populatePhpClassQualifiedNames`
* or `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
* in the same PHP namespace visible to each other without explicit `use`
* statements, mirroring PHP's actual runtime behavior.
*
* Uses `origin: 'namespace'` so `phpMergeBindings` tiers it below
* explicit `use` imports (`origin: 'import'`) and local declarations.
*/
export function populatePhpNamespaceSiblings(
parsedFiles: readonly ParsedFile[],
indexes: ScopeResolutionIndexes,
inputs: PhpSiblingInputs,
): void {
// 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);
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.
interface NamespaceBucket {
readonly scopes: { filePath: string; scopeId: ScopeId; scope: Scope }[];
readonly classDefs: SymbolDefinition[];
}
const buckets = new Map<string, NamespaceBucket>();
const getBucket = (ns: string): NamespaceBucket => {
let b = buckets.get(ns);
if (b === undefined) {
b = { scopes: [], classDefs: [] };
buckets.set(ns, b);
}
return b;
};
for (const parsed of parsedFiles) {
const struct = structureByFile.get(parsed.filePath);
if (struct === undefined) continue;
const ns = struct.namespace;
const bucket = getBucket(ns);
// Register the file's module scope in the bucket.
const moduleScope = parsed.scopes.find((s) => s.kind === 'Module');
if (moduleScope !== undefined) {
bucket.scopes.push({
filePath: parsed.filePath,
scopeId: moduleScope.id,
scope: moduleScope,
});
}
// Collect class-like defs declared at the top-level of this file
// (defs in Class or Module scopes, excluding nested inner classes).
for (const scope of parsed.scopes) {
if (scope.kind !== 'Class') continue;
// Only top-level class scopes (parent is Module or Namespace scope).
if (scope.parent === null) continue;
const parentScope = parsed.scopes.find((s) => s.id === scope.parent);
if (
parentScope === undefined ||
(parentScope.kind !== 'Module' && parentScope.kind !== 'Namespace')
) {
continue;
}
for (const def of scope.ownedDefs) {
if (isClassLikeDef(def)) {
bucket.classDefs.push(def);
break; // one class-like per scope
}
}
}
}
const augmentations = indexes.bindingAugmentations as Map<ScopeId, Map<string, BindingRef[]>>;
// Step 3: For each namespace bucket, inject sibling class bindings
// into every file's Module scope (that is NOT the declaring file).
for (const [, bucket] of buckets) {
// Build name → def map (simple name of qualifiedName).
const defsByName = new Map<string, SymbolDefinition[]>();
for (const def of bucket.classDefs) {
const q = def.qualifiedName ?? '';
const simpleName = q.includes('.')
? q.slice(q.lastIndexOf('.') + 1)
: q.includes('\\')
? q.slice(q.lastIndexOf('\\') + 1)
: q;
if (simpleName === '') continue;
const arr = defsByName.get(simpleName) ?? [];
arr.push(def);
defsByName.set(simpleName, arr);
}
for (const { filePath, scopeId, scope } of bucket.scopes) {
for (const [name, defs] of defsByName) {
// Skip if already locally declared (origin: 'local' wins).
const local = scope.bindings.get(name);
if (local !== undefined && local.some((b) => b.origin === 'local')) continue;
for (const def of defs) {
if (def.filePath === filePath) continue; // don't self-inject
const arr = getAugmentationBucket(augmentations, scopeId, name);
if (arr.some((b) => b.def.nodeId === def.nodeId)) continue;
arr.push({ def, origin: 'namespace' });
}
}
}
}
// Step 3b: Inject fully-qualified-name bindings into every PHP file's
// Module scope. PHP `\App\Models\User` (leading-backslash FQN) and
// `App\Models\User` (already-qualified relative) on a parameter or
// typed receiver must resolve to the exact namespace-qualified class
// regardless of which simple-name `User` the caller's `use` imports
// shadowed. The shared `findClassBindingInScope` scope-chain walk
// consumes these augmentations via `lookupBindingsAt`, so adding the
// qualified key on every file's module scope routes FQN-receivers to
// the right def. Codex PR #1497 review, finding 1.
//
// Cost: O(PHP files × class-like defs in the workspace) augmentation
// entries. Bounded and acceptable in practice — typical PHP projects
// have hundreds of files and classes, not tens of thousands.
for (const parsed of parsedFiles) {
const moduleScope = parsed.scopes.find((s) => s.kind === 'Module');
if (moduleScope === undefined) continue;
const moduleScopeId = moduleScope.id;
for (const [ns, bucket] of buckets) {
if (ns === '') continue; // global-namespace classes have no qualified form to register
for (const def of bucket.classDefs) {
const q = def.qualifiedName ?? '';
const simpleName = q.includes('\\') ? q.slice(q.lastIndexOf('\\') + 1) : q;
if (simpleName === '') continue;
const fqn = `${ns}\\${simpleName}`;
const arr = getAugmentationBucket(augmentations, moduleScopeId, fqn);
if (arr.some((b) => b.def.nodeId === def.nodeId)) continue;
arr.push({ def, origin: 'namespace' });
}
}
}
// Step 4: Mirror return-type bindings from same-namespace sibling files.
// This enables chain-follow like `$c->greet()->save()` where `greet()`
// returns `Greeting` (declared in A.php, same namespace) and `Greeting`
// isn't imported in the calling file. Without this, the compound-receiver
// resolver can't resolve `Greeting` as a class binding in the importer's
// scope chain.
//
// Additionally, mirror from files that are imported via `use` (different
// namespace) so return types from dependencies are chain-followable too.
for (const parsed of parsedFiles) {
const moduleScope = parsed.scopes.find((s) => s.kind === 'Module');
if (moduleScope === undefined) continue;
const moduleTypeBindings = moduleScope.typeBindings as Map<
string,
import('gitnexus-shared').TypeRef
>;
const struct = structureByFile.get(parsed.filePath);
const ownNs = struct?.namespace ?? '';
// Collect namespaces accessible from this file:
// 1. Own namespace (same-ns siblings)
// 2. Namespaces of directly imported files (via parsedImports → targetRaw → PSR-4 namespace)
const accessibleFiles = new Set<string>();
// Same-namespace siblings.
const sameBucket = buckets.get(ownNs);
if (sameBucket !== undefined) {
for (const { filePath } of sameBucket.scopes) {
if (filePath !== parsed.filePath) accessibleFiles.add(filePath);
}
}
// Files directly imported by this file (finalized import edges).
const ownModuleScopeBindings = indexes.bindings.get(moduleScope.id);
if (ownModuleScopeBindings !== undefined) {
for (const [, refs] of ownModuleScopeBindings) {
for (const ref of refs) {
if (ref.origin === 'import' || ref.origin === 'namespace') {
const importFilePath = ref.def.filePath;
if (importFilePath !== parsed.filePath) {
accessibleFiles.add(importFilePath);
}
}
}
}
}
// Mirror return-type bindings from accessible files.
for (const srcFilePath of accessibleFiles) {
const srcParsed = parsedFiles.find((p) => p.filePath === srcFilePath);
if (srcParsed === undefined) continue;
const srcModuleScope = srcParsed.scopes.find((s) => s.kind === 'Module');
if (srcModuleScope === undefined) continue;
for (const [boundName, typeRef] of srcModuleScope.typeBindings) {
if (moduleTypeBindings.has(boundName)) continue;
moduleTypeBindings.set(boundName, typeRef);
}
}
}
}

View file

@ -0,0 +1,332 @@
/**
* Tree-sitter query for PHP scope captures (RFC #909 Ring 3 LANG-php).
*
* Captures the structural skeleton the generic scope-resolution pipeline
* consumes: scopes (program/namespace/class/function), declarations
* (class-likes, method-likes, properties, variables), imports
* (namespace_use_declaration), type bindings (parameter annotations,
* property types, constructor-inferred locals, return types), and
* references (call sites, member writes).
*
* PHP specifics that shape this query:
*
* - `namespace_use_declaration` is an import only at top level / inside
* namespace blocks. Class-body `use_declaration` (trait-use) is a
* different node type and is NOT captured here.
*
* - `object_creation_expression` has `name` and `qualified_name` as
* direct children (no wrapping node).
*
* - `method_declaration` exposes a `return_type:` named field containing
* a `type` node, which may be `named_type`, `optional_type`, etc.
*
* - `property_element` has a `name:` field of type `variable_name`.
*
* - `variable_name` nodes always include the `$` sigil in their text.
*
* Exposes lazy `Parser` and `Query` singletons so callers don't pay
* tree-sitter init cost per file.
*/
import Parser from 'tree-sitter';
import Php from 'tree-sitter-php';
// tree-sitter-php exports `{ php, php_only, html }` in recent versions, or the
// language directly in older versions.
//
// IMPORTANT: must match the grammar used by the central parse phase
// (`src/core/tree-sitter/parser-loader.ts` line: `[SupportedLanguages.PHP]: PHP.php_only`).
// Using a different grammar variant causes tree-sitter to throw when running
// a query built against grammar A on a tree parsed by grammar B — this error
// is swallowed by `scope-extractor-bridge.ts`, producing silent empty results.
const Php_typed = Php as unknown as { php_only?: unknown; php?: unknown };
const PHP_LANG = Php_typed.php_only ?? Php_typed.php ?? Php;
const PHP_SCOPE_QUERY = `
;; Scopes
(program) @scope.module
;; Both block-scoped and statement-scoped namespace declarations.
(namespace_definition) @scope.namespace
(class_declaration) @scope.class
(interface_declaration) @scope.class
(trait_declaration) @scope.class
(enum_declaration) @scope.class
(method_declaration) @scope.function
(function_definition) @scope.function
(anonymous_function) @scope.function
(arrow_function) @scope.function
;; Declarations types
(class_declaration
name: (name) @declaration.name) @declaration.class
(interface_declaration
name: (name) @declaration.name) @declaration.interface
(trait_declaration
name: (name) @declaration.name) @declaration.trait
(enum_declaration
name: (name) @declaration.name) @declaration.enum
;; Declarations methods / functions / constructors
(method_declaration
name: (name) @declaration.name) @declaration.method
(function_definition
name: (name) @declaration.name) @declaration.function
;; Declarations properties
;; PHP 7.4+ typed property: private UserRepo $repo;
;; property_element has name: (variable_name) field.
;; Emits BOTH a declaration (so SemanticModel registers the property) AND a type-binding.
(property_declaration
type: (_) @type-binding.type
(property_element
name: (variable_name) @type-binding.name)) @type-binding.annotation
(property_declaration
type: (_)
(property_element
name: (variable_name) @declaration.name)) @declaration.property
;; Untyped property: public $id; capture as plain declaration.
(property_declaration
(property_element
name: (variable_name) @declaration.name)) @declaration.variable
;; Imports namespace_use_declaration
;;
;; Captures ALL forms: plain, alias, function/const qualifiers, and grouped.
;; The import-decomposer in captures.ts fans out grouped uses.
;;
;; NOTE: class-body use_declaration = trait-use, NOT an import.
;; Only namespace_use_declaration (top-level / namespace scope) is an import.
(namespace_use_declaration) @import.statement
;; Type bindings parameters
;; simple_parameter with a type hint: function f(User $u)
;; type field is a 'type' supertype (named_type, optional_type, union_type, etc.)
(simple_parameter
type: (_) @type-binding.type
name: (variable_name) @type-binding.name) @type-binding.parameter
;; property_promotion_parameter: function __construct(private User $u)
;; Emits type-binding so the constructor body can resolve $u as the typed param.
(property_promotion_parameter
type: (_) @type-binding.type
name: (variable_name) @type-binding.name) @type-binding.parameter
;; Also emit a @type-binding.annotation for the promoted parameter so that
;; phpBindingScopeFor can hoist it to the Class scope (stripping the $ sigil).
;; This enables compound-receiver resolution: $user->address->save() resolves
;; address Address via the Class scope's typeBindings.
;; The @type-binding.parameter above stays for constructor-body resolution ($address).
(property_promotion_parameter
type: (_) @type-binding.type
name: (variable_name) @type-binding.name) @type-binding.annotation
;; Also emit a @declaration.property so SemanticModel registers the promoted
;; parameter as a class-owned property (enabling $obj->propName lookups).
(property_promotion_parameter
name: (variable_name) @declaration.name) @declaration.property
;; Type bindings local assignment: $u = new User()
;; new ClassName() name is a direct child of object_creation_expression
(assignment_expression
left: (variable_name) @type-binding.name
right: (object_creation_expression
(name) @type-binding.type)) @type-binding.constructor
;; new Foo\Bar\ClassName() qualified_name wraps name
(assignment_expression
left: (variable_name) @type-binding.name
right: (object_creation_expression
(qualified_name
(name) @type-binding.type))) @type-binding.constructor
;; Type bindings $alias = $u (identifier alias)
(assignment_expression
left: (variable_name) @type-binding.name
right: (variable_name) @type-binding.type) @type-binding.alias
;; Type bindings $u = factory() (free call return alias)
(assignment_expression
left: (variable_name) @type-binding.name
right: (function_call_expression
function: (name) @type-binding.type)) @type-binding.alias
;; Type bindings $u = $svc->getUser() (method call return alias)
(assignment_expression
left: (variable_name) @type-binding.name
right: (member_call_expression
name: (name) @type-binding.type)) @type-binding.alias
;; Type bindings method return type
;; method_declaration exposes return_type: field (type node supertype).
;; named_type wraps the class name: function getUser(): User
(method_declaration
name: (name) @type-binding.name
return_type: (named_type
(name) @type-binding.type)) @type-binding.return
;; nullable return type via optional_type: function getUser(): ?User
(method_declaration
name: (name) @type-binding.name
return_type: (optional_type
(named_type
(name) @type-binding.type))) @type-binding.return
;; function_definition (top-level or namespace-level) return type: User
;; Enables cross-file return-type propagation for free functions.
(function_definition
name: (name) @type-binding.name
return_type: (named_type
(name) @type-binding.type)) @type-binding.return
;; nullable return type for function_definition: ?User
(function_definition
name: (name) @type-binding.name
return_type: (optional_type
(named_type
(name) @type-binding.type))) @type-binding.return
;; References free calls: foo()
(function_call_expression
function: (name) @reference.name) @reference.call.free
;; References member calls: $obj->method()
;;
;; SAFETY-INVARIANT (Finding 1 of PR #1497 adversarial review): the name:
;; field is constrained to (name), NOT (_) tree-sitter-php emits
;; variable_name nodes for dynamic method names ($obj->$method(),
;; $obj->{$method}()). Keeping the pattern at (name) is what suppresses
;; capture of those dynamic shapes. The resolver is structural-only and
;; cannot infer the bound method name from runtime values; relaxing this
;; pattern to (_) would silently emit zero-confidence false-positive
;; edges. Regression: test/fixtures/lang-resolution/php-dynamic-calls/.
(member_call_expression
object: (_) @reference.receiver
name: (name) @reference.name) @reference.call.member
;; References null-safe member calls: $obj?->method() (PHP 8+)
(nullsafe_member_call_expression
object: (_) @reference.receiver
name: (name) @reference.name) @reference.call.member
;; References static calls: X::method()
;;
;; Same SAFETY-INVARIANT as member_call_expression above: name: (name)
;; deliberately excludes variable_name so Class::$method() and
;; $className::$method() shapes do not capture. The receiver field uses
;; (_) because static dispatch on a variable receiver
;; ($className::method()) IS captured but resolution falls through
;; harmlessly when $className has no class type binding. See
;; php-dynamic-calls/ regression suite.
(scoped_call_expression
scope: (_) @reference.receiver
name: (name) @reference.name) @reference.call.member
;; Type bindings $x = X::Constant or $x = X::CASE (enum case)
;; Binds the variable to the class name X so member calls on $x dispatch
;; to X's methods (e.g. UserRole::Viewer label()).
;;
;; tree-sitter-php emits class_constant_access_expression with two name
;; children: [0]=class/enum name, [1]=constant/case name. The dot-anchor
;; before (name) matches only the FIRST name child (the class).
(assignment_expression
left: (variable_name) @type-binding.name
right: (class_constant_access_expression
. (name) @type-binding.type)) @type-binding.alias
(assignment_expression
left: (variable_name) @type-binding.name
right: (class_constant_access_expression
(qualified_name
(name) @type-binding.type))) @type-binding.alias
;; Type bindings $x = SomeClass::staticFactory()
;; Binds $x to the type returned by the static factory method, anchored on
;; the method name (chain-follow resolves the actual return type later).
(assignment_expression
left: (variable_name) @type-binding.name
right: (scoped_call_expression
name: (name) @type-binding.type)) @type-binding.alias
;; Type bindings null-safe member-call result: $x = $a?->getY()
(assignment_expression
left: (variable_name) @type-binding.name
right: (nullsafe_member_call_expression
name: (name) @type-binding.type)) @type-binding.alias
;; References constructor calls: new User()
(object_creation_expression
(name) @reference.name) @reference.call.constructor
(object_creation_expression
(qualified_name
(name) @reference.name)) @reference.call.constructor
;; References member writes: $obj->prop = $x
(assignment_expression
left: (member_access_expression
object: (_) @reference.receiver
name: (name) @reference.name)) @reference.write.member
;; References static property writes: User::$count = $x
;; Uses @reference.write.static anchor so captures.ts can strip the leading
;; $ from the variable_name capture (static props are stored without $ in graph).
;;
;; SAFETY-INVARIANT (Finding 2 of PR #1497 adversarial review): no
;; read-access property capture exists in this query dynamic property
;; reads ($obj->$prop, $obj->{$prop}) produce no captures, which is the
;; desired behavior for a structural-only resolver. Adding a read pattern
;; in the future MUST keep name: (name) (not (_)) to preserve the
;; suppression. Regression: php-dynamic-calls/ fixture dynamicPropertyRead.
(assignment_expression
left: (scoped_property_access_expression
scope: (_) @reference.receiver
name: (variable_name) @reference.name)) @reference.write.static
`;
let _parser: Parser | null = null;
let _query: Parser.Query | null = null;
export function getPhpParser(): Parser {
if (_parser === null) {
_parser = new Parser();
_parser.setLanguage(PHP_LANG as Parameters<Parser['setLanguage']>[0]);
}
return _parser;
}
export function getPhpScopeQuery(): Parser.Query {
if (_query === null) {
_query = new Parser.Query(PHP_LANG as Parameters<Parser['setLanguage']>[0], PHP_SCOPE_QUERY);
}
return _query;
}

View file

@ -0,0 +1,136 @@
/**
* Synthesize `@type-binding.self` captures for PHP instance methods
* one for `$this` (always on non-static methods inside a type
* declaration) and optionally one for `parent` (only on class methods
* when the enclosing class has an explicit `base_clause`).
*
* Mirrors `languages/csharp/receiver-binding.ts` in structure. PHP's
* grammar doesn't give us a clean `.scm` pattern for "implicit receiver
* on every instance method inside an enclosing type" because `$this` is
* not a parameter it's an implicit receiver. Synthesis in code is the
* same approach C# uses for `this` / `base`.
*
* ## Known limitations
*
* - **Trait `$this`**: for methods defined in a trait, `$this` is
* synthesized as a binding to the trait itself. The actual using-class
* type is not known at single-file parse time. V1 limitation
* documented in `index.ts`.
* - **Anonymous classes**: skipped (no stable enclosing class name).
*/
import type { Capture, CaptureMatch } from 'gitnexus-shared';
import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
const TYPE_DECL_NODE_TYPES = new Set([
'class_declaration',
'interface_declaration',
'trait_declaration',
'enum_declaration',
]);
const FUNCTION_NODE_TYPES = new Set([
'method_declaration',
'function_definition',
'anonymous_function',
'arrow_function',
]);
/** Walk up to find the enclosing type declaration. */
function findEnclosingTypeDeclaration(node: SyntaxNode): SyntaxNode | null {
let cur: SyntaxNode | null = node.parent;
while (cur !== null) {
if (TYPE_DECL_NODE_TYPES.has(cur.type)) return cur;
cur = cur.parent;
}
return null;
}
function typeName(typeNode: SyntaxNode): string | null {
return typeNode.childForFieldName('name')?.text ?? null;
}
/**
* Return the base class name from a `base_clause` child of the class node.
* `base_clause` contains a `qualified_name` or `name` child.
*/
function baseClauseText(typeNode: SyntaxNode): string | null {
for (let i = 0; i < typeNode.namedChildCount; i++) {
const child = typeNode.namedChild(i);
if (child === null || child.type !== 'base_clause') continue;
const nameNode = child.firstNamedChild;
if (nameNode === null) return null;
// Take last segment of qualified name (e.g. \App\Models\BaseModel → BaseModel)
const text = nameNode.text.trim();
const segments = text.split('\\').filter(Boolean);
return segments[segments.length - 1] ?? text;
}
return null;
}
/** Check whether this method has a `static_modifier` child. */
function isStaticMethod(fnNode: SyntaxNode): boolean {
for (let i = 0; i < fnNode.namedChildCount; i++) {
const child = fnNode.namedChild(i);
if (child !== null && child.type === 'static_modifier') return true;
}
return false;
}
/**
* Build zero, one, or two `@type-binding.self` matches for `fnNode`:
*
* - Returns `[]` if the function is free (no enclosing type), static,
* or the enclosing type has no resolvable name.
* - Returns one match (`$this`) for non-static methods inside a
* class / trait / interface / enum body.
* - Returns two matches (`$this` + `parent`) only when the function
* lives in a `class_declaration` that has an explicit `base_clause`.
*
* The caller is responsible for guaranteeing
* `FUNCTION_NODE_TYPES.has(fnNode.type)`.
*/
export function synthesizePhpReceiverBinding(fnNode: SyntaxNode): CaptureMatch[] {
if (!FUNCTION_NODE_TYPES.has(fnNode.type)) return [];
if (isStaticMethod(fnNode)) return [];
const enclosingType = findEnclosingTypeDeclaration(fnNode);
if (enclosingType === null) return [];
// Anonymous class — skip (no stable name).
if (enclosingType.type === 'anonymous_class_declaration') return [];
const enclosingName = typeName(enclosingType);
if (enclosingName === null) return [];
// Anchor the synthesized captures to the method body (compound_statement)
// so they land inside the function scope, not at the class scope.
// For interface/abstract methods that have no body, skip.
const bodyNode =
fnNode.childForFieldName('body') ??
// arrow_function: body is the expression after `=>`
fnNode.childForFieldName('return_value');
if (bodyNode === null) return [];
const out: CaptureMatch[] = [];
out.push(buildReceiverMatch(bodyNode, '$this', enclosingName));
// `parent` applies only to class methods with an explicit base_clause.
if (enclosingType.type === 'class_declaration') {
const baseText = baseClauseText(enclosingType);
if (baseText !== null) {
out.push(buildReceiverMatch(bodyNode, 'parent', baseText));
}
}
return out;
}
function buildReceiverMatch(anchorNode: SyntaxNode, name: string, typeText: string): CaptureMatch {
const m: Record<string, Capture> = {
'@type-binding.self': nodeToCapture('@type-binding.self', anchorNode),
'@type-binding.name': syntheticCapture('@type-binding.name', anchorNode, name),
'@type-binding.type': syntheticCapture('@type-binding.type', anchorNode, typeText),
};
return m;
}

View file

@ -0,0 +1,421 @@
/**
* PHP `ScopeResolver` registered in `SCOPE_RESOLVERS` and consumed by
* the generic `runScopeResolution` orchestrator (RFC #909 Ring 3 LANG-php).
*
* Third migration after Python and C#. See `pythonScopeResolver` for the
* canonical shape.
*
* ## Circular-import avoidance
*
* The old PR had `php/scope-resolver.ts` importing `phpProvider` from
* `../php.js` while `php.ts` imported `phpScopeResolver` from `./php/index.js`
* undefined at module load. The canonical fix (mirroring C#):
*
* - `scope-resolver.ts` imports `phpProvider` from `../php.js`
* - `php.ts` imports individual hook FUNCTIONS from `./php/index.js`
*
* Node's ESM handles the cycle correctly because `phpProvider` is a named
* export that is live-binding by the time `phpScopeResolver` is first
* read (lazily, at resolution time), `phpProvider` is fully initialized.
*/
import type { ParsedFile } from 'gitnexus-shared';
import { SupportedLanguages } from 'gitnexus-shared';
import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js';
import {
findReceiverTypeBinding,
populateClassOwnedMembers,
} from '../../scope-resolution/scope/walkers.js';
import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js';
import type { KnowledgeGraph } from '../../../graph/types.js';
import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js';
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, getPhpNamespaceForFile } from './namespace-siblings.js';
/**
* PHP MRO builder extends the generic EXTENDS-only MRO with trait-use
* relationships encoded as IMPLEMENTS edges.
*
* PHP trait-use (`use TraitName;` inside a class body) is recorded in the
* graph as an IMPLEMENTS edge from the using class to the Trait node. The
* generic `buildMro` only walks EXTENDS edges, so trait methods are invisible
* to the MRO-based dispatch index. This variant:
*
* 1. Runs the generic `buildMro` (EXTENDS edges, Class defs only).
* 2. Indexes Trait defs from `parsedFiles` alongside Class defs.
* 3. Walks IMPLEMENTS edges; for each edge whose target resolves to a
* Trait DefId, prepends that Trait DefId to the source class's MRO.
*
* Trait methods are searched BEFORE parent-class methods (PHP semantics:
* 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[],
nodeLookup: GraphNodeLookup,
): Map<string, string[]> {
// Step 1: run generic MRO (Class-only, EXTENDS-only).
const mro = buildMro(graph, parsedFiles, nodeLookup, defaultLinearize);
// Step 2: build a graphId → defId map for ALL class-like defs including Traits.
// After the `isLinkableLabel` fix, Trait nodes are now indexed in nodeLookup.
const defIdByGraphId = new Map<string, string>();
for (const parsed of parsedFiles) {
for (const def of parsed.localDefs) {
if (def.type !== 'Class' && def.type !== 'Trait') continue;
const graphId = resolveDefGraphId(parsed.filePath, def, nodeLookup);
if (graphId !== undefined) defIdByGraphId.set(graphId, def.nodeId);
}
}
// Step 2b: build a Set of Trait defIds for O(1) trait-vs-interface checks.
const traitDefIds = new Set<string>();
for (const parsed of parsedFiles) {
for (const def of parsed.localDefs) {
if (def.type === 'Trait') traitDefIds.add(def.nodeId);
}
}
// Step 3: collect direct trait-use edges (IMPLEMENTS where target is a Trait).
// Maps class/trait defId → [traitDefId, ...] for direct `use TraitName;`.
const directTraitUse = new Map<string, string[]>();
for (const rel of graph.iterRelationshipsByType('IMPLEMENTS')) {
const sourceDefId = defIdByGraphId.get(rel.sourceId);
if (sourceDefId === undefined) continue;
const targetDefId = defIdByGraphId.get(rel.targetId);
if (targetDefId === undefined) continue;
if (!traitDefIds.has(targetDefId)) continue;
let list = directTraitUse.get(sourceDefId);
if (list === undefined) {
list = [];
directTraitUse.set(sourceDefId, list);
}
if (!list.includes(targetDefId)) list.push(targetDefId);
}
// Step 4: augment every class's MRO by prepending the traits used by
// 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 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 ancestorChain = [classDefId, ...extendsMro];
const seeds: string[] = [];
for (const ancestorId of ancestorChain) {
for (const traitId of directTraitUse.get(ancestorId) ?? []) {
seeds.push(traitId);
}
}
const allTraits = collectTransitiveTraits(seeds, directTraitUse);
if (allTraits.length > 0) {
// Prepend traits before EXTENDS ancestors: own class's traits first,
// then parent traits (in ancestor order). This ensures trait methods
// are found before falling back to the inheritance chain.
mro.set(classDefId, [...allTraits, ...extendsMro]);
}
}
// Step 5: also insert Trait-only entries for classes that use traits
// directly but have no EXTENDS parents (not in `mro` yet).
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 = collectTransitiveTraits([...traits], directTraitUse);
mro.set(classDefId, allTraits);
}
}
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).
*
* PHP is dynamically typed: a parameter declared as `mixed` (or with no
* type hint) cannot be resolved by the generic receiver-bound pass, which
* requires a `TypeRef` in scope. This hook does a workspace-wide method
* name lookup: when exactly one def in the workspace matches the called
* method name, emit the CALLS edge.
*
* Only fires for sites that are NOT already in `handledSites` and whose
* receiver has no type binding in the scope chain. Unique-name-match
* constraint avoids false positives for common method names.
*/
function phpEmitUnresolvedReceiverEdges(
graph: KnowledgeGraph,
scopes: ScopeResolutionIndexes,
parsedFiles: readonly ParsedFile[],
nodeLookup: GraphNodeLookup,
handledSites: Set<string>,
model: SemanticModel,
): number {
let emitted = 0;
const seen = new Set<string>();
for (const parsed of parsedFiles) {
for (const site of parsed.referenceSites) {
if (site.kind !== 'call') continue;
if (site.explicitReceiver === undefined) continue;
const siteKey = `${parsed.filePath}:${site.atRange.startLine}:${site.atRange.startCol}`;
if (handledSites.has(siteKey)) continue;
// Only proceed when the receiver has NO type binding — it's unresolvable
// by the generic pass. This is the `mixed` / unannotated case.
const typeRef = findReceiverTypeBinding(site.inScope, site.explicitReceiver.name, scopes);
if (typeRef !== undefined) continue;
// Workspace-wide lookup: collect all methods matching the called name.
// Filter out defs with no qualifiedName (legacy parse stubs without full
// metadata) and deduplicate by nodeId so reconcileOwnership double-registration
// doesn't inflate the count.
const allCandidates = model.methods.lookupMethodByName(site.name);
const seen2 = new Set<string>();
const candidates = allCandidates.filter((c) => {
if (c.qualifiedName === undefined) return false;
if (seen2.has(c.nodeId)) return false;
seen2.add(c.nodeId);
return true;
});
if (candidates.length !== 1) continue; // ambiguous or missing — skip
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;
}
// Tighten the fallback further with an EXACT-required-arity gate
// (Finding 8 / U4): the first-stage `narrowOverloadCandidates`
// accepts any argCount in `min..max` (or `>= min` when variadic),
// which over-emits 0.6-confidence edges for common method names
// whose only workspace candidate has optional / defaulted params.
// For the fallback path only, require argCount === required for
// fixed-arity candidates. Variadic candidates keep the relaxed
// `argCount >= required` semantics (already enforced by the first-
// stage check, so no extra work here).
const min = fnDef.requiredParameterCount;
const hasVarArgs =
fnDef.parameterTypes !== undefined &&
fnDef.parameterTypes.some((t) => t === '...' || t.startsWith('...'));
if (
min !== undefined &&
Number.isFinite(site.arity) &&
site.arity >= 0 &&
!hasVarArgs &&
site.arity !== min
) {
continue;
}
const callerGraphId = resolveCallerGraphId(site.inScope, scopes, nodeLookup);
if (callerGraphId === undefined) continue;
const tgtGraphId = resolveDefGraphId(fnDef.filePath, fnDef, nodeLookup);
if (tgtGraphId === undefined) continue;
handledSites.add(siteKey);
const relId = `rel:CALLS:${callerGraphId}->${tgtGraphId}`;
if (seen.has(relId)) continue;
seen.add(relId);
graph.addRelationship({
id: relId,
sourceId: callerGraphId,
targetId: tgtGraphId,
type: 'CALLS',
confidence: 0.6,
reason: 'php-unresolved-receiver-fallback',
});
emitted++;
}
}
return emitted;
}
const phpScopeResolver: ScopeResolver = {
language: SupportedLanguages.PHP,
languageProvider: phpProvider,
importEdgeReason: 'php-scope: use',
resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig) =>
resolvePhpImportTargetInternal(targetRaw, fromFile, allFilePaths, resolutionConfig),
loadResolutionConfig: (repoPath) => loadPhpComposerConfig(repoPath),
// PHP LEGB-like precedence: local > import/namespace/reexport > wildcard.
// The per-scope id is unused by phpMergeBindings (tier ordering computed
// purely from BindingRef.origin), so we don't synthesize a Scope.
mergeBindings: (existing, incoming) => [...phpMergeBindings([...existing, ...incoming])],
// Adapter: phpArityCompatibility uses (def, callsite); the contract is (callsite, def).
arityCompatibility: (callsite, def) => phpArityCompatibility(def, callsite),
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
// PHP namespace are visible without explicit `use` statements.
// Mirrors C#'s `populateNamespaceSiblings`.
populateNamespaceSiblings: populatePhpNamespaceSiblings,
// PHP uses `parent` for super-class dispatch (not `super()`).
isSuperReceiver: (text) => text.trim() === 'parent',
// PHP is dynamically typed — field-fallback heuristic on so that
// method calls on `mixed`-typed receivers (no annotation) fall back
// to a workspace-wide name search rather than silently dropping the edge.
fieldFallbackOnMethodLookup: true,
// PHP: allow free-call fallback to unique workspace-wide callable when
// lexical/import bindings miss. Needed for two cases:
// 1. `use function` imports where PSR-4 directory resolution is
// non-deterministic (multiple .php files in same namespace dir).
// 2. Unimported free calls within the same namespace (same-namespace
// visibility without an explicit use statement, e.g. test fixtures).
allowGlobalFreeCallFallback: true,
// Return-type propagation on — PHP method signatures are authoritative
// enough for cross-file chain-follow.
propagatesReturnTypesAcrossImports: true,
// PHP hoists method return-type bindings to the Module scope so
// `propagateImportedReturnTypes` can pick them up across files.
hoistTypeBindingsToModule: true,
// PHP recovers member calls on `mixed`/untyped receivers via a
// workspace-wide unique-method-name lookup, mirroring the legacy DAG.
emitUnresolvedReceiverEdges: phpEmitUnresolvedReceiverEdges,
};
export { phpScopeResolver };

View file

@ -0,0 +1,134 @@
/**
* Trivial / no-op-ish hooks for the PHP provider. Made explicit so
* reviewers don't have to re-derive the analysis from "absence == default".
*/
import type {
CaptureMatch,
ParsedImport,
Scope,
ScopeId,
ScopeTree,
TypeRef,
} from 'gitnexus-shared';
// ─── bindingScopeFor ──────────────────────────────────────────────────────
/**
* PHP method return-type bindings (`@type-binding.return`) must hoist
* to the enclosing Module scope so `propagateImportedReturnTypes` can
* mirror them across files. Without this hoist, the return binding gets
* stuck at the Class scope and is invisible to the cross-file propagation
* pass that reads only `sourceModule.typeBindings`.
*
* All other bindings delegate to the default "innermost scope" rule.
*/
export function phpBindingScopeFor(
decl: CaptureMatch,
innermost: Scope,
tree: ScopeTree,
): ScopeId | null {
if (decl['@type-binding.return'] !== undefined) {
let cur: Scope | undefined = innermost;
while (cur !== undefined && cur.kind !== 'Module') {
const parentId: ScopeId | null = cur.parent ?? null;
if (parentId === null) break;
cur = tree.getScope(parentId);
}
if (cur !== undefined && cur.kind === 'Module') return cur.id;
}
// Constructor-promoted properties (`function __construct(public User $u)`)
// are declared inside the constructor's Function scope in the AST, but they
// are class-owned fields. Hoist the @declaration.property binding to the
// enclosing Class scope so `populateClassOwnedMembers` assigns the correct
// ownerId and `findOwnedMember` can resolve `$obj->u`.
if (decl['@declaration.property'] !== undefined && innermost.kind === 'Function') {
let cur: Scope | undefined = innermost;
while (cur !== undefined && cur.kind !== 'Class') {
const parentId: ScopeId | null = cur.parent ?? null;
if (parentId === null) break;
cur = tree.getScope(parentId);
}
if (cur !== undefined && cur.kind === 'Class') return cur.id;
}
// Constructor-promoted property TYPE BINDING (`function __construct(public Address $address)`)
// produces both a @type-binding.parameter (stays in Function scope for `$address` lookups
// inside the constructor body) AND a @type-binding.annotation (query.ts). The annotation
// capture is emitted so this hoist branch can place `address → Address` in the CLASS scope.
//
// The compound-receiver resolver (`resolveCompoundReceiverClass`) reads typeBindings from
// the class scope: `cs.typeBindings.get('address')`. Without hoisting, `$user->address->save()`
// fails to resolve `address` because the type binding is in the constructor's Function scope.
//
// `@type-binding.annotation` for a promoted param appears with innermost = Function scope
// (the constructor). Regular typed class properties (`private Address $addr;`) have their
// annotation already in the Class scope, so this branch only fires for promoted params.
if (decl['@type-binding.annotation'] !== undefined && innermost.kind === 'Function') {
let cur: Scope | undefined = innermost;
while (cur !== undefined && cur.kind !== 'Class') {
const parentId: ScopeId | null = cur.parent ?? null;
if (parentId === null) break;
cur = tree.getScope(parentId);
}
if (cur !== undefined && cur.kind === 'Class') return cur.id;
}
return null;
}
// ─── importOwningScope ────────────────────────────────────────────────────
/**
* Determine which scope owns a `use` import declaration.
*
* - `use` inside `namespace Foo { }` attach to that Namespace scope.
* - Top-level `use` (no enclosing namespace) innermost (Module).
* - `use TraitName;` inside a class body this is a trait-use
* (heritage), NOT a namespace import. The grammar emits
* `use_declaration` for trait-use (distinct from
* `namespace_use_declaration`). Our query only captures
* `namespace_use_declaration`, so trait-use never reaches this hook
* in practice. Returning `null` here is a safety fallback.
*/
export function phpImportOwningScope(
_imp: ParsedImport,
innermost: Scope,
_tree: ScopeTree,
): ScopeId | null {
// Namespace-scoped or module-scoped imports attach to the innermost scope
// (either Namespace or Module). Class-scoped imports should not occur for
// namespace_use_declaration; if they do, attach to the class scope.
if (
innermost.kind === 'Namespace' ||
innermost.kind === 'Module' ||
innermost.kind === 'Class' ||
innermost.kind === 'Function'
) {
return innermost.id;
}
return null;
}
// ─── receiverBinding ──────────────────────────────────────────────────────
/**
* Look up `$this` or `parent` in the function scope's type bindings.
*
* Both are synthesized as `@type-binding.self` captures during capture
* emission (`receiver-binding.ts`) `$this` for every non-static
* method inside a class/trait/interface/enum body, `parent` additionally
* for class methods with an explicit `base_clause`.
*
* Returns `null` for:
* - static methods (no `$this` synthesized)
* - free functions (no enclosing class)
* - non-Function scopes
*/
export function phpReceiverBinding(functionScope: Scope): TypeRef | null {
if (functionScope.kind !== 'Function') return null;
return (
functionScope.typeBindings.get('$this') ?? functionScope.typeBindings.get('parent') ?? null
);
}

View file

@ -72,6 +72,7 @@ export const MIGRATED_LANGUAGES: ReadonlySet<SupportedLanguages> = new Set<Suppo
SupportedLanguages.TypeScript,
SupportedLanguages.Go,
SupportedLanguages.C,
SupportedLanguages.PHP,
]);
/**

View file

@ -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
@ -576,4 +616,32 @@ export interface ScopeResolver {
readonly treeCache?: { get(filePath: string): unknown };
},
) => void;
/**
* Optional post-resolution pass: emit CALLS edges for member-call sites
* whose receiver cannot be typed by the scope chain (no `TypeRef`).
* Dynamically-typed languages with untyped/`mixed`/`Any` parameters use
* this hook to recover the call edge via workspace-wide method-name
* lookup, mirroring what their legacy resolvers did.
*
* Runs AFTER `emitReceiverBoundCalls` and BEFORE `emitFreeCallFallback`.
* Implementations MUST:
* - Skip sites already in `handledSites` (Invariant I2).
* - Add resolved site keys to `handledSites` before returning.
* - Stay narrow: a unique workspace-wide match is the safe baseline.
* Multi-candidate fallbacks should narrow by arity / argument types
* before emitting to keep false-positive rate bounded.
*
* Returns the number of edges emitted (for telemetry).
*
* Default: undefined (no unresolved-receiver fallback).
*/
readonly emitUnresolvedReceiverEdges?: (
graph: KnowledgeGraph,
scopes: ScopeResolutionIndexes,
parsedFiles: readonly ParsedFile[],
nodeLookup: GraphNodeLookup,
handledSites: Set<string>,
model: SemanticModel,
) => number;
}

View file

@ -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;
}

View file

@ -117,6 +117,11 @@ export function isLinkableLabel(label: NodeLabel): boolean {
label === 'Interface' ||
label === 'Struct' ||
label === 'Enum' ||
// Trait nodes are linkable so MRO builders can bridge PHP/Rust trait
// defs between scope-resolution DefIds and the graph's node ids.
// IMPLEMENTS edges from classes to traits are otherwise invisible to
// the scope-resolution MRO pass.
label === 'Trait' ||
// Variable / Property are linkable too — receiver-bound write/read
// ACCESSES edges target field nodes (e.g. `user.name = "x"` →
// ACCESSES edge to User's `name` Variable/Property node).

View file

@ -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;
@ -82,6 +86,11 @@ export function emitFreeCallFallback(
scopes,
parsed.filePath,
options.isFileLocalDef,
site.arity,
options.isCallableVisibleFromCaller !== undefined
? (candidate) =>
options.isCallableVisibleFromCaller!({ callerParsed: parsed, candidate })
: undefined,
);
}
if (fnDef === undefined) continue;
@ -118,6 +127,8 @@ function pickUniqueGlobalCallable(
scopes: ScopeResolutionIndexes,
callerFilePath: string,
isFileLocalDef?: (def: SymbolDefinition) => boolean,
callArity?: number,
isCallerVisible?: (candidate: SymbolDefinition) => boolean,
): SymbolDefinition | undefined {
const scopeDefs: SymbolDefinition[] = [];
const scopeSeen = new Set<string>();
@ -130,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);
@ -137,6 +155,15 @@ function pickUniqueGlobalCallable(
}
if (scopeDefs.length === 1) return scopeDefs[0];
// When multiple scope-index candidates exist, attempt arity narrowing
// before falling back to the semantic-model lookup. This handles
// registry-primary languages where the model is not populated for the
// migrated language's files (call-processor skips them).
if (scopeDefs.length > 1 && callArity !== undefined) {
const arityMatch = narrowByArity(scopeDefs, callArity);
if (arityMatch !== undefined) return arityMatch;
}
const defs: SymbolDefinition[] = [];
const seen = new Set<string>();
const push = (pool: readonly SymbolDefinition[]): void => {
@ -147,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);
@ -157,7 +188,35 @@ function pickUniqueGlobalCallable(
push(model.symbols.lookupCallableByName(name));
push(model.methods.lookupMethodByName(name));
return defs.length === 1 ? defs[0] : undefined;
if (defs.length === 1) return defs[0];
// When multiple candidates exist and the call site has a known arity,
// narrow by parameter count.
if (defs.length > 1 && callArity !== undefined) {
const arityMatch = narrowByArity(defs, callArity);
if (arityMatch !== undefined) return arityMatch;
}
return undefined;
}
/**
* Narrow a list of callable candidates by call-site arity.
* A def is compatible when `requiredParameterCount <= arity <= parameterCount`.
* Defs with `parameterCount === undefined` (variadic/unknown) are always kept.
* Returns the single compatible def, or `undefined` when zero or multiple match.
*/
function narrowByArity(
defs: readonly SymbolDefinition[],
callArity: number,
): SymbolDefinition | undefined {
const compatible = defs.filter((d) => {
const total = d.parameterCount;
if (total === undefined) return true; // unknown arity — keep
const required = d.requiredParameterCount ?? total;
return required <= callArity && callArity <= total;
});
return compatible.length === 1 ? compatible[0] : undefined;
}
function logicalCallableKey(def: SymbolDefinition): string {
@ -189,10 +248,18 @@ function pickConstructorOrClass(
/** Walk up from the call-site scope to the enclosing class scope,
* pick a method member by name with overload narrowing on arity +
* argument types. Returns undefined if there's no enclosing class
* or no matching method. Used for implicit-this calls inside a
* class body where multiple overloads share the call name. */
function pickImplicitThisOverload(
* argument types. Returns undefined if there's no enclosing class,
* no matching method, OR narrowing leaves multiple compatible
* candidates in the multi-candidate case, picking
* `candidates[0]` would emit a high-confidence CALLS edge whose
* target depends on registration order rather than a defensible
* resolution. Mirrors `pickUniqueGlobalCallable`'s uniqueness check
* in the same file (Codex PR #1497 review, finding 2).
*
* Exported for unit testing language-agnostic logic, exercised
* via synthetic stubs in `pick-implicit-this-overload.test.ts`. The
* production call site is `applyFreeCallFallback` immediately above. */
export function pickImplicitThisOverload(
site: {
readonly inScope: ScopeId;
readonly name: string;
@ -225,6 +292,11 @@ function pickImplicitThisOverload(
if (overloads.length === 0) return undefined;
if (overloads.length === 1) return overloads[0];
// Narrow on arity + argument types. Require a UNIQUE survivor —
// ambiguous narrowing (multiple compatible candidates with no
// disambiguating signal) leaves the call unresolved rather than
// routing to an arbitrary first overload by registration order.
const candidates = narrowOverloadCandidates(overloads, site.arity, site.argumentTypes);
if (candidates.length !== 1) return undefined;
return candidates[0];
}

View file

@ -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
@ -39,6 +43,16 @@ export function narrowOverloadCandidates(
const max = d.parameterCount;
const min = d.requiredParameterCount;
if (max !== undefined && argCount > max) {
// Variadic marker check is C#-specific (the 'params' keyword).
// Other languages use their own marker — PHP uses '...' (see
// `languages/php/arity-metadata.ts:46`), Python uses '*args'-
// shaped metadata that lives outside `parameterTypes` entirely.
// This branch is dead code for those languages because they
// set `parameterCount = undefined` for variadic functions,
// which keeps `max` undefined and skips this check entirely.
// Adding new variadic markers here changes behavior for those
// other languages too — don't extend without auditing each
// adapter's `arity-metadata.ts`. Finding 9 of PR #1497.
const variadic =
d.parameterTypes !== undefined &&
d.parameterTypes.some((t) => t === 'params' || t.startsWith('params '));
@ -48,8 +62,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) => {

View file

@ -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,21 @@ export function emitReceiverBoundCalls(
let memberDef: SymbolDefinition | undefined;
for (const ownerId of chain) {
memberDef = findOwnedMember(ownerId, memberName, model);
if (memberDef !== undefined) break;
if (memberDef !== undefined) {
// The MRO chain is most-derived-first ([classDef, ...ancestors]).
// If the most-derived definition is arity-incompatible with the
// call site, PHP throws ArgumentCountError at runtime — it does
// NOT silently dispatch to an ancestor. Terminate the chain walk
// so no edge is emitted, rather than falling through to an
// arity-compatible ancestor (which would be a false positive).
if (
narrowOverloadCandidates([memberDef], site.arity, site.argumentTypes).length === 0
) {
memberDef = undefined;
break;
}
break;
}
}
if (memberDef !== undefined) {
const reason =

View file

@ -17,6 +17,7 @@ import { typescriptScopeResolver } from '../../languages/typescript/scope-resolv
import { goScopeResolver } from '../../languages/go/scope-resolver.js';
import { javaScopeResolver } from '../../languages/java/scope-resolver.js';
import { cScopeResolver } from '../../languages/c/scope-resolver.js';
import { phpScopeResolver } from '../../languages/php/scope-resolver.js';
/** Map of `SupportedLanguages` `ScopeResolver`. The phase iterates
* this map intersected with `MIGRATED_LANGUAGES` (the per-language
@ -32,4 +33,5 @@ export const SCOPE_RESOLVERS: ReadonlyMap<SupportedLanguages, ScopeResolver> = n
[SupportedLanguages.Go, goScopeResolver],
[SupportedLanguages.Java, javaScopeResolver],
[SupportedLanguages.C, cScopeResolver],
[SupportedLanguages.PHP, phpScopeResolver],
]);

View file

@ -184,6 +184,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, {
@ -205,7 +206,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
@ -283,6 +284,17 @@ export function runScopeResolution(
workspaceIndex,
readonlyModel,
);
const unresolvedReceiverExtras =
provider.emitUnresolvedReceiverEdges !== undefined
? provider.emitUnresolvedReceiverEdges(
graph,
indexes,
parsedFiles,
nodeLookup,
handledSites,
readonlyModel,
)
: 0;
const freeCallExtras = emitFreeCallFallback(
graph,
indexes,
@ -295,6 +307,7 @@ export function runScopeResolution(
{
allowGlobalFallback: provider.allowGlobalFreeCallFallback === true,
isFileLocalDef: provider.isFileLocalDef,
isCallableVisibleFromCaller: provider.isCallableVisibleFromCaller,
},
);
const { emitted, skipped } = emitReferencesViaLookup(
@ -330,7 +343,7 @@ export function runScopeResolution(
filesSkipped,
importsEmitted,
resolve: resolveStats,
referenceEdgesEmitted: emitted + receiverExtras + freeCallExtras,
referenceEdgesEmitted: emitted + receiverExtras + unresolvedReceiverExtras + freeCallExtras,
referenceSkipped: skipped,
};
}

View file

@ -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)$")

View file

@ -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');
}

View file

@ -0,0 +1,113 @@
<?php
namespace App\Services;
/**
* Exercises every dynamic PHP call/access shape that the tree-sitter
* grammar should NOT capture as a resolvable reference. The negative
* regression suite asserts zero CALLS edges from `Dynamic::*` to any
* `Targets::*` method whose name only appears in dynamic position.
*
* Findings 1-7 of the adversarial review of PR #1497 confirmed via
* grammar inspection that these patterns produce zero captures; this
* fixture + test pair locks that invariant in regression coverage so
* a future query.ts edit cannot silently break it.
*/
class Dynamic
{
public function memberCallDynamicName(Targets $obj): void
{
// $obj->$method() — dynamic method name via variable_name node.
// Query pattern requires `name: (name)` so this is not captured.
$method = 'dynamicProcess';
$obj->$method();
}
public function memberCallBraceDynamicName(Targets $obj): void
{
// $obj->{$method}() — brace-syntax variant of the above.
$method = 'dynamicBrace';
$obj->{$method}();
}
public function scopedCallDynamicMethodName(): void
{
// ClassName::$method() — dynamic method name on static dispatch.
$method = 'dynamicHandle';
Targets::$method();
}
public function scopedCallVariableClassNameStaticMethod($className): void
{
// $className::method() — class-name is an untyped parameter (no
// type hint, no string-literal assignment that could be picked up
// by a future type-binding heuristic). Receiver IS captured but
// resolution falls through because $className has no class type
// binding in scope. The unresolved-receiver fallback also doesn't
// fire because `dynamicStaticMethod` is unique workspace-wide AND
// exact-arity narrowing in U4 would still match — meaning the
// ONLY thing keeping the edge count at zero today is the absence
// of any type binding for the receiver.
$className::dynamicStaticMethod();
}
public function scopedCallDynamicClassAndMethodName(): void
{
// $className::$method() — both dynamic.
$className = 'App\\Services\\Targets';
$method = 'dynamicScopedDynName';
$className::$method();
}
public function callUserFuncVariableCallable($callable): void
{
// call_user_func($callable, ...) — resolver is structural-only
// and never inspects argument values to infer the callable.
// The literal `call_user_func` itself is an unresolved built-in.
call_user_func($callable);
}
public function callUserFuncArrayVariable($callable, $args): void
{
// call_user_func_array($callable, $args) — unknown-arity variant.
call_user_func_array($callable, $args);
}
public function callUserFuncStringCallable(): void
{
// 'Class::method' string-callable form — argument is a string
// literal, never reaches the function: child of function_call_expression.
call_user_func('App\\Services\\Targets::dynamicCallableMethod');
}
public function callUserFuncArrayObjectCallable(Targets $obj): void
{
// [$obj, 'method'] array-callable form — array is an argument
// value, not the function: child.
call_user_func([$obj, 'dynamicArrayCallableMethod']);
}
public function callUserFuncArrayClassNameCallable(): void
{
// ['Class', 'method'] array-callable with class-name string.
call_user_func(['App\\Services\\Targets', 'dynamicArrayClassCallableMethod']);
}
public function dynamicPropertyRead(Targets $obj): string
{
// $obj->$prop — dynamic property read. No read-access property
// capture pattern exists in query.ts at all (Finding 2).
$prop = 'dynamicProp';
return $obj->$prop;
}
public function sanityStaticCall(Targets $obj): void
{
// The fixture's deliberate sanity-check call. THIS one DOES emit
// a CALLS edge — if the assertion that this edge exists ever
// fails, the test infra is broken, not the dynamic-dispatch
// suppression. Without this, every zero-edge assertion above
// would pass even if the pipeline never emitted any edges at all.
$obj->sanityStaticallyNamedTarget();
}
}

View file

@ -0,0 +1,16 @@
<?php
namespace App\Services;
/**
* Second attractor class. Its purpose is to provide a NON-UNIQUE
* workspace-wide name for `dynamicStaticMethod`, so that the
* phpEmitUnresolvedReceiverEdges 0.6-confidence fallback cannot fire
* for the `$className::dynamicStaticMethod()` site in Dynamic.php.
* That isolates the dynamic-receiver test from the U4 concern
* (Finding 8 unresolved-receiver fallback tightening).
*/
class OtherTargets
{
public static function dynamicStaticMethod(): void {}
}

View file

@ -0,0 +1,36 @@
<?php
namespace App\Services;
/**
* Attractors: every method name here is unique workspace-wide so that if
* the dynamic-dispatch suppression in the query / resolver layer ever
* regresses, the false-positive CALLS edges would surface against these
* targets.
*/
class Targets
{
public function dynamicProcess(): void {}
public function dynamicHandle(): void {}
public function dynamicBrace(): void {}
public static function dynamicStaticMethod(): void {}
public static function dynamicScopedDynName(): void {}
public function dynamicCallableMethod(): void {}
public function dynamicArrayCallableMethod(): void {}
public function dynamicArrayClassCallableMethod(): void {}
public string $dynamicProp = '';
/**
* Sanity-check target: the fixture's one non-dynamic call DOES reach
* this method, proving the test infra emits CALLS edges normally.
*/
public function sanityStaticallyNamedTarget(): void {}
}

View file

@ -0,0 +1,5 @@
{
"autoload": {
"psr-4": { "App\\": "app/" }
}
}

View file

@ -0,0 +1,8 @@
<?php
namespace App\Models;
class User {
public function record(): string {
return 'App\\Models\\User::record';
}
}

View file

@ -0,0 +1,8 @@
<?php
namespace App\Other;
class User {
public function record(): string {
return 'App\\Other\\User::record';
}
}

View file

@ -0,0 +1,25 @@
<?php
namespace App\Services;
use App\Models\User;
// Two `User` classes exist in the workspace: App\Models\User and App\Other\User.
// The `use App\Models\User` import binds the simple name `User` to App\Models\User.
//
// The `save` method uses a fully-qualified type hint `\App\Other\User` — PHP
// runtime semantics: the leading backslash means "absolute namespace path",
// so this parameter is always App\Other\User, even though the simple `User`
// elsewhere in this file is App\Models\User.
//
// CALLS edges from `$u->record()` MUST resolve to app/Other/User.php::record,
// NOT app/Models/User.php::record. The `saveLocal` method exercises the
// simple-name path as a control — `User` here is the imported App\Models\User.
class Service {
public function save(\App\Other\User $u): void {
$u->record();
}
public function saveLocal(User $u): void {
$u->record();
}
}

View file

@ -0,0 +1,7 @@
{
"autoload": {
"psr-4": {
"App\\": "app/"
}
}
}

View file

@ -0,0 +1,10 @@
<?php
namespace App\Models;
class ChildModel extends ParentModel
{
public function method(int $a, int $b): bool { return true; }
public function compat(int $a): bool { return true; }
}

View file

@ -0,0 +1,8 @@
<?php
namespace App\Models;
class Orphan
{
public function method(int $a, int $b): bool { return true; }
}

View file

@ -0,0 +1,10 @@
<?php
namespace App\Models;
class ParentModel
{
public function method(int $a): bool { return true; }
public function compat(int $a): bool { return true; }
}

View file

@ -0,0 +1,42 @@
<?php
namespace App\Services;
use App\Models\ChildModel;
use App\Models\Orphan;
class Caller
{
public function callIncompatible(): void
{
// ChildModel::method takes 2 args; ParentModel::method takes 1.
// Class-name receiver -> hits Case 2 (findClassBindingInScope) in
// receiver-bound-calls.ts.
// Pre-fix bug: MRO walk emits a false CALLS edge to ParentModel::method
// because Case 2 used `continue` on arity mismatch and fell through.
// Post-fix: zero edges (PHP throws ArgumentCountError at runtime).
ChildModel::method(1);
}
public function callCompatible(): void
{
// Happy path: ChildModel::compat takes 1 arg; matches call site.
ChildModel::compat(1);
}
public function callNoParent(): void
{
// Orphan::method takes 2 args; called with 1; no parent class exists.
// Pre-fix: same Case 2 bug — the loop exhausts with memberDef cleared,
// BUT with `continue` the loop simply ends after one iteration since
// the chain has only one entry; no edge would have been emitted here
// even pre-fix. Post-fix: same — zero edges. Documents the boundary.
Orphan::method(1);
}
public function callMostDerivedHappy(): void
{
// Happy path: ChildModel::method takes 2 args; matches call site.
ChildModel::method(1, 2);
}
}

View file

@ -0,0 +1,5 @@
{
"autoload": {
"psr-4": { "App\\": "app/" }
}
}

View file

@ -0,0 +1,8 @@
{
"autoload": {
"psr-4": {
"App\\": "src/App/",
"Vendor\\": "src/Vendor/"
}
}
}

View 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);
}
}

View 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');
}
}

View file

@ -0,0 +1,6 @@
<?php
namespace App\Utils;
function format(string $s): string {
return $s;
}

View file

@ -0,0 +1,6 @@
<?php
namespace Vendor\Utils;
function format(string $s, int $width): string {
return str_pad($s, $width);
}

View file

@ -0,0 +1,8 @@
<?php
namespace App;
trait Auditable {
public function record(): string {
return 'trait';
}
}

View file

@ -0,0 +1,8 @@
<?php
namespace App;
class Base {
public function record(): string {
return 'base';
}
}

View 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();
}
}

View file

@ -0,0 +1,7 @@
{
"autoload": {
"psr-4": {
"App\\": "app/"
}
}
}

View 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();
}
}

View file

@ -0,0 +1,10 @@
<?php
namespace App\Traits;
trait TraitA {
use TraitB;
public function aMethod(): string {
return 'from A';
}
}

View file

@ -0,0 +1,10 @@
<?php
namespace App\Traits;
trait TraitB {
use TraitC;
public function bMethod(): string {
return 'from B';
}
}

View file

@ -0,0 +1,8 @@
<?php
namespace App\Traits;
trait TraitC {
public function deepMethod(): string {
return 'from C';
}
}

View file

@ -0,0 +1,7 @@
{
"autoload": {
"psr-4": {
"App\\": "app/"
}
}
}

View file

@ -0,0 +1,8 @@
<?php
namespace App\Models;
class UserRepo
{
public function save(): void {}
}

View file

@ -0,0 +1,23 @@
<?php
namespace App\Services;
use App\Models\UserRepo;
class Mixed
{
// Typed property: must emit exactly one Property def named `repo`,
// zero stray Variable defs.
private UserRepo $repo;
// Untyped property: must emit exactly one (legitimate) catch-all def
// for `$id`, and zero Property defs for it.
public $id;
// Constructor-promoted typed parameter: tree-sitter routes these
// through the same `property_element` shape, so the dedup must also
// suppress the stray Variable here.
public function __construct(private UserRepo $promotedRepo)
{
}
}

View file

@ -0,0 +1,5 @@
{
"autoload": {
"psr-4": { "App\\": "app/" }
}
}

View file

@ -0,0 +1,26 @@
<?php
namespace App\Models;
/**
* Single workspace-unique candidate for each fallback-method name so the
* unresolved-receiver fallback in phpEmitUnresolvedReceiverEdges fires
* for untyped receivers. The names are deliberately chosen to NOT collide
* with other fixtures so cross-fixture test interference cannot occur.
*
* Method-arity matrix:
* - happyPath(): min=0, max=0
* - withDefault(string $a, int $b = 0): min=1, max=2
* - variadicLog(string $level, ...$args): min=1, max=undefined, hasVarArgs
* - variadicLogTwoRequired(string $a, string $b, ...$rest): min=2, max=undefined, hasVarArgs
*/
class Handler
{
public function happyPath(): void {}
public function withDefault(string $a, int $b = 0): void {}
public function variadicLog(string $level, ...$args): void {}
public function variadicLogTwoRequired(string $a, string $b, ...$rest): void {}
}

View file

@ -0,0 +1,58 @@
<?php
namespace App\Services;
/**
* Each method's receiver is an untyped parameter so the high-confidence
* receiver-bound passes drop the site, leaving it for
* phpEmitUnresolvedReceiverEdges (the 0.6-confidence fallback).
*
* The fallback's gate is EXACT-required-arity post-fix:
* - call argCount === fnDef.requiredParameterCount edge emitted
* - call argCount !== required, non-variadic NO edge (post-fix; pre-fix may have emitted)
* - variadic candidate, argCount >= required edge emitted
* - variadic candidate, argCount < required NO edge
*/
class Caller
{
public function callHappyPath($h): void
{
// happyPath(): min=0. argCount=0 → exact match. Edge.
$h->happyPath();
}
public function callDefaultExactRequired($h): void
{
// withDefault($a, $b=0): min=1. argCount=1 === min → exact match. Edge.
$h->withDefault('a');
}
public function callDefaultBeyondRequired($h): void
{
// withDefault($a, $b=0): min=1, max=2. argCount=2 > min.
// Pre-fix: first-stage narrow accepts (2 <= 2), edge emitted.
// Post-fix: exact-required gate rejects (2 !== 1), no edge.
$h->withDefault('a', 99);
}
public function callVariadicAtRequired($h): void
{
// variadicLog($level, ...$args): min=1, hasVarArgs.
// argCount=1 === min → edge emitted (variadic relaxed path).
$h->variadicLog('info');
}
public function callVariadicBeyondRequired($h): void
{
// variadicLog($level, ...$args): min=1, hasVarArgs.
// argCount=2 > min, variadic → edge emitted.
$h->variadicLog('info', 'arg1');
}
public function callVariadicBelowRequired($h): void
{
// variadicLogTwoRequired($a, $b, ...$rest): min=2, hasVarArgs.
// argCount=1 < min → no edge (first-stage rejects). Both pre/post-fix.
$h->variadicLogTwoRequired('only-one');
}
}

View file

@ -0,0 +1,5 @@
{
"autoload": {
"psr-4": { "App\\": "app/" }
}
}

View 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();
}
}

View 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);
}
}

View file

@ -0,0 +1,7 @@
{
"autoload": {
"psr-4": {
"App\\": "app/"
}
}
}

View file

@ -34,6 +34,52 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly<Record<string, Readonly
// which is only available in the registry-primary path.
'resolves user.Save() to the method whose receiver type is declared in another package file',
]),
php: new Set([
// Arity-narrowing in `pickUniqueGlobalCallable` rejects free-call
// candidates that are definitively below required-parameter-count. The
// legacy DAG path does not narrow on arity, so it emits over-broad CALLS
// edges for variadic functions invoked with too few args even though
// the only candidate's required count is non-zero. Scope-resolver-only
// correctness win (commit af9af4a9 U1); backporting to legacy is out
// of scope.
'does NOT emit CALLS edge for record() with zero args (below required=1)',
'does NOT emit CALLS edge for pad() with zero args (below required=1)',
// `$this->method()` precedence inside a class that composes a trait AND
// extends a parent both defining the same method requires the augmented
// trait-aware MRO (trait shadows parent). The legacy DAG has no
// trait-aware MRO, so it fails to bind the call to the trait. Scope-
// resolver-only correctness win (commit af9af4a9 U3).
'$this->record() still resolves to Auditable::record (trait shadows parent)',
// Fully-qualified type-hint resolution (`\App\Other\User $u` parameter)
// routes through the scope-resolver's bindingAugmentations channel
// populated by `populatePhpNamespaceSiblings` Step 3b. The legacy DAG
// resolves receiver types via simple-name workspace lookup and has no
// namespace-prefixed binding channel, so it cannot distinguish the FQN
// target from a same-simple-name class reachable via `use`. Scope-
// resolver-only correctness win (Codex PR #1497 review, finding 1).
'\\App\\Other\\User parameter resolves $u->record() to app/Other/User.php (NOT app/Models/User.php)',
// MRO arity-mismatch on class-name receivers (`Child::method(1)` where
// Child::method takes 2 args and Parent::method takes 1): the legacy
// DAG has no arity narrowing on Case 2 (class-name) MRO walk, so it
// emits a false CALLS edge to Parent::method on fallthrough. Scope-
// resolver-only correctness win (PR #1497 review Image 1 / U1).
'arity-incompatible most-derived override does NOT fall through to ParentModel::method',
// Class-name receiver with single-class arity mismatch (no parent in
// the MRO chain): legacy resolves the method by name without arity
// gating, so it emits a CALLS edge even when arity is definitively
// incompatible. The scope-resolver's `narrowOverloadCandidates` check
// in `receiver-bound-calls.ts` Case 2 rejects this post-fix. Scope-
// resolver-only correctness win (PR #1497 / U1).
'arity-incompatible class with no parent emits zero CALLS edges (regression check)',
// `phpEmitUnresolvedReceiverEdges` exact-required-arity gate (PR
// #1497 / U4): the legacy DAG has no equivalent unresolved-receiver
// fallback hook, so it resolves these untyped-receiver sites via a
// different code path that over-emits for default-parameter and
// variadic-required-mismatch shapes. Scope-resolver-only correctness
// wins; backporting to legacy is out of scope.
'argCount > required (2>1) on candidate with default param emits NO edge post-fix',
'variadic candidate, argCount < required (1<2) emits NO edge',
]),
python: new Set([
// Suffix-fallback lex tiebreak depends on the registry-primary
// resolver's deterministic sort. The legacy resolver returns the

View file

@ -1,11 +1,12 @@
/**
* PHP: PSR-4 imports, extends, implements, trait use, enums, calls + ambiguous disambiguation
*/
import { describe, it, expect, beforeAll } from 'vitest';
import { describe, expect, beforeAll } from 'vitest';
import path from 'path';
import {
FIXTURES,
CROSS_FILE_FIXTURES,
createResolverParityIt,
getRelationships,
getNodesByLabel,
getNodesByLabelFull,
@ -14,6 +15,12 @@ import {
type PipelineResult,
} from './helpers.js';
// Wrap vitest's `it` so legacy-DAG-only divergences (commit af9af4a9 U1/U3)
// are skipped under REGISTRY_PRIMARY_PHP=0. The skip list lives in
// helpers.ts:LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES.php — sibling pattern
// to csharp/typescript/python.
const it = createResolverParityIt('php');
// ---------------------------------------------------------------------------
// Heritage: PSR-4 imports, extends, implements, trait use, enums, calls
// ---------------------------------------------------------------------------
@ -91,6 +98,10 @@ describe('PHP heritage & import resolution', () => {
expect(targets).toContain('label');
});
// save($entity: mixed) calls $entity->getId() — the receiver is typed `mixed`
// so there is no TypeRef in scope. The scope-resolver `emitUnresolvedReceiverEdges`
// hook (PHP-wired) recovers this case via workspace-wide unique-name lookup,
// matching the legacy DAG behavior.
it('emits CALLS edge: save → getId', () => {
const calls = getRelationships(result, 'CALLS').filter(
(e) => e.source === 'save' && e.target === 'getId',
@ -439,6 +450,161 @@ 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).toBe(1);
});
it('depth-2: $this->bMethod() resolves to TraitB::bMethod (TraitA uses TraitB)', () => {
expect(callsFrom('callDepthTwo', 'bMethod').length).toBe(1);
});
it('depth-3: $this->deepMethod() resolves to TraitC::deepMethod (TraitA → TraitB → TraitC)', () => {
expect(callsFrom('callDepthThree', 'deepMethod').length).toBe(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).toBe(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).toBe(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).toBe(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. The CALLS edge
// may surface against either the alias name (`vendorFormat`) or the
// canonical function name (`format` in the vendor file) depending on
// dedup ordering; either way, exactly one edge total.
expect(
callsFromTo('callImported', 'vendorFormat').length +
callsFromTo('callImported', 'format', 'src/Vendor/Utils/Format.php').length,
).toBe(1);
});
});
// ---------------------------------------------------------------------------
// Local shadow: same-file definition takes priority over imported name
// ---------------------------------------------------------------------------
@ -1807,3 +1973,325 @@ describe('PHP Child extends ParentClass — inherited method resolution (SM-9)',
expect(parentMethodCall!.source).toBe('run');
});
});
// ---------------------------------------------------------------------------
// Fully-qualified type-hint resolution (Codex PR #1497 review, finding 1).
//
// Two `User` classes coexist in the workspace: `App\Models\User` and
// `App\Other\User`. A service file imports the simple-name `User` from
// App\Models, but uses a fully-qualified `\App\Other\User` in a parameter
// annotation. PHP runtime semantics: the leading `\` is an absolute namespace
// path; the parameter is always `App\Other\User`, even when the simple
// `User` is bound to a different class by `use`.
//
// Pre-fix: `normalizePhpType` strips the qualifier so the TypeRef carries
// only `User`, then `findClassBindingInScope` walks the scope chain and
// resolves to the imported `App\Models\User` — emitting a CALLS edge to the
// wrong class. Post-fix: qualified form survives on `rawName`, the
// QualifiedNameIndex fallback (or a PHP-specific qualified lookup) routes
// the call to App\Other\User::record.
// ---------------------------------------------------------------------------
describe('PHP fully-qualified type-hint resolution (Codex #1497)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'php-fqn-cross-namespace'), () => {});
}, 60000);
const callsFromTo = (source: string, target: string, file: string) =>
getRelationships(result, 'CALLS').filter(
(c) => c.source === source && c.target === target && c.targetFilePath === file,
);
it('detects both User classes in distinct namespaces', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
// Exactly two User entries — one per namespace.
const userClasses = getNodesByLabelFull(result, 'Class').filter((n) => n.name === 'User');
expect(userClasses.length).toBe(2);
const userFiles = userClasses.map((c) => c.properties.filePath as string).sort();
expect(
userFiles.some((f) => f.includes('Models/User.php') || f.includes('Models\\User.php')),
).toBe(true);
expect(
userFiles.some((f) => f.includes('Other/User.php') || f.includes('Other\\User.php')),
).toBe(true);
});
it('\\App\\Other\\User parameter resolves $u->record() to app/Other/User.php (NOT app/Models/User.php)', () => {
// The bug Codex flagged: FQN parameter collapses to simple `User`, then
// resolves to the imported `App\Models\User` instead of the explicit
// `\App\Other\User` named in the annotation. Post-fix: exactly one edge,
// pointing to the FQN target.
expect(callsFromTo('save', 'record', 'app/Other/User.php').length).toBe(1);
expect(callsFromTo('save', 'record', 'app/Models/User.php').length).toBe(0);
});
it('simple-name `User $u` parameter resolves to the imported App\\Models\\User (control case)', () => {
// Sanity check that unqualified type-hint resolution still works via the
// `use App\Models\User;` import. Without this control, U2's normalizer
// change could regress the simple-name path and we'd miss it.
expect(callsFromTo('saveLocal', 'record', 'app/Models/User.php').length).toBe(1);
expect(callsFromTo('saveLocal', 'record', 'app/Other/User.php').length).toBe(0);
});
});
// ---------------------------------------------------------------------------
// MRO arity-mismatch: most-derived override with incompatible arity must NOT
// fall through to an arity-compatible ancestor (PHP throws ArgumentCountError
// at runtime). See receiver-bound-calls.ts Case 2.
// ---------------------------------------------------------------------------
describe('PHP MRO arity-mismatch fallthrough', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'php-mro-arity-mismatch'), () => {});
}, 60000);
const callsFromTo = (source: string, target: string, targetFilePath: string) =>
getRelationships(result, 'CALLS').filter(
(c) => c.source === source && c.target === target && c.targetFilePath === targetFilePath,
);
it('detects ParentModel, ChildModel, Orphan, and Caller classes', () => {
expect(getNodesByLabel(result, 'Class')).toEqual([
'Caller',
'ChildModel',
'Orphan',
'ParentModel',
]);
});
it('arity-incompatible most-derived override does NOT fall through to ParentModel::method', () => {
// Pre-fix bug: `$child->method(1)` with ChildModel::method(int,int) and
// ParentModel::method(int) would emit a false CALLS edge to ParentModel::method.
// Post-fix: zero CALLS edges from callIncompatible for this site.
expect(callsFromTo('callIncompatible', 'method', 'app/Models/ParentModel.php').length).toBe(0);
expect(callsFromTo('callIncompatible', 'method', 'app/Models/ChildModel.php').length).toBe(0);
});
it('arity-compatible most-derived override emits exactly one CALLS edge to ChildModel::compat', () => {
// Happy path: ChildModel::compat(int) matches the call site $child->compat(1).
expect(callsFromTo('callCompatible', 'compat', 'app/Models/ChildModel.php').length).toBe(1);
expect(callsFromTo('callCompatible', 'compat', 'app/Models/ParentModel.php').length).toBe(0);
});
it('arity-incompatible class with no parent emits zero CALLS edges (regression check)', () => {
// Orphan::method(int,int) called with one arg, no parent class — must remain
// unresolved both before and after the fix.
expect(callsFromTo('callNoParent', 'method', 'app/Models/Orphan.php').length).toBe(0);
});
it('arity-compatible most-derived call still resolves to ChildModel::method (happy path)', () => {
// Ensure the fix did not break compatible-arity resolution.
expect(callsFromTo('callMostDerivedHappy', 'method', 'app/Models/ChildModel.php').length).toBe(
1,
);
expect(callsFromTo('callMostDerivedHappy', 'method', 'app/Models/ParentModel.php').length).toBe(
0,
);
});
});
// ---------------------------------------------------------------------------
// @declaration.variable double-match dedup on typed properties.
// Pre-fix, the catch-all property pattern in query.ts (no `type:` constraint)
// also matched typed property declarations and emitted a stray Variable def
// alongside the legitimate Property def. captures.ts now pre-scans rawMatches
// for @declaration.property anchors and suppresses the duplicate.
// ---------------------------------------------------------------------------
describe('PHP typed-property double-match dedup', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'php-typed-property-dedup'), () => {});
}, 60000);
it('detects the Mixed class', () => {
expect(getNodesByLabel(result, 'Class')).toContain('Mixed');
});
it('emits exactly one Property def for the typed property `$repo`', () => {
const properties = getNodesByLabel(result, 'Property');
expect(properties.filter((n) => n === 'repo').length).toBe(1);
});
it('emits exactly one Property def for the constructor-promoted typed `$promotedRepo`', () => {
const properties = getNodesByLabel(result, 'Property');
expect(properties.filter((n) => n === 'promotedRepo').length).toBe(1);
});
it('emits zero stray Variable defs for typed property and promoted typed parameter', () => {
// Pre-fix: a Variable def named `$repo` and `$promotedRepo` (no `$` strip)
// would slip through the catch-all pattern. Post-fix: zero.
const variables = getNodesByLabel(result, 'Variable');
expect(variables.filter((n) => n === '$repo' || n === 'repo').length).toBe(0);
expect(variables.filter((n) => n === '$promotedRepo' || n === 'promotedRepo').length).toBe(0);
});
it('untyped property `$id` still emits its catch-all Property def (regression check)', () => {
// The untyped catch-all @declaration.variable pattern is the legitimate
// path for `public $id;`. Make sure the cross-match dedup does not
// over-suppress untyped declarations — they have no @declaration.property
// sibling, so their anchor is not in the typedPropertyAnchorIds set.
const properties = getNodesByLabel(result, 'Property');
expect(properties.filter((n) => n === 'id').length).toBe(1);
});
it('no `$`-prefixed Property or Variable defs leak from typed declarations', () => {
// The catch-all branch does NOT run the `$`-strip normalization, so any
// def it produces for a typed property carries a `$`-prefixed name —
// a known receiver-binding lookup pollution vector. Post-fix the
// catch-all is suppressed for typed property_declaration anchors, so
// no `$repo` / `$promotedRepo` def should appear at any label.
for (const n of result.graph.iterNodes()) {
const name = String(n.properties.name);
if (name === '$repo' || name === '$promotedRepo') {
throw new Error(`leaked $-prefixed def: ${n.label}|${name}|${n.id}`);
}
}
});
});
// ---------------------------------------------------------------------------
// Dynamic PHP constructs MUST NOT capture as resolvable references.
// Findings 1-7 of the PR #1497 adversarial review confirmed via grammar
// inspection that $obj->$method(), call_user_func(...), array/string
// callables, and dynamic property reads produce zero captures. This suite
// locks that invariant in regression so a future query.ts edit cannot
// silently relax `name: (name)` to `name: (_)` and reintroduce false-
// positive edges.
// ---------------------------------------------------------------------------
describe('PHP dynamic dispatch — negative regression suite', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'php-dynamic-calls'), () => {});
}, 60000);
const callsFromDynamicTo = (target: string) =>
getRelationships(result, 'CALLS').filter(
(c) =>
c.target === target &&
// Source is some method on `Dynamic` (the file under test).
c.sourceFilePath === 'app/Services/Dynamic.php',
);
it('detects the Dynamic and Targets classes', () => {
expect(getNodesByLabel(result, 'Class')).toContain('Dynamic');
expect(getNodesByLabel(result, 'Class')).toContain('Targets');
});
it('sanity check: non-dynamic call DOES emit an edge', () => {
// Without this, every zero-edge assertion below would pass even if the
// pipeline emitted no CALLS edges at all.
expect(callsFromDynamicTo('sanityStaticallyNamedTarget').length).toBe(1);
});
it('$obj->$method() emits no CALLS edge to dynamicProcess', () => {
expect(callsFromDynamicTo('dynamicProcess').length).toBe(0);
});
it('$obj->{$method}() emits no CALLS edge to dynamicBrace', () => {
expect(callsFromDynamicTo('dynamicBrace').length).toBe(0);
});
it('Class::$method() emits no CALLS edge to dynamicHandle', () => {
expect(callsFromDynamicTo('dynamicHandle').length).toBe(0);
});
it('$className::method() with untyped variable receiver emits no CALLS edge', () => {
// Two attractor classes (Targets and OtherTargets) both expose
// dynamicStaticMethod so the unresolved-receiver fallback (Finding 8 /
// U4) cannot fire — that isolates this assertion to the dynamic-
// dispatch suppression at the query / receiver-bound-calls layer.
expect(callsFromDynamicTo('dynamicStaticMethod').length).toBe(0);
});
it('$className::$method() with dynamic class and method names emits no CALLS edge', () => {
expect(callsFromDynamicTo('dynamicScopedDynName').length).toBe(0);
});
it('call_user_func / call_user_func_array string and array callables emit no CALLS edges', () => {
// call_user_func itself is a built-in with no workspace def, so the
// free-call to it is unresolved — no edge to `call_user_func`.
expect(callsFromDynamicTo('call_user_func').length).toBe(0);
expect(callsFromDynamicTo('call_user_func_array').length).toBe(0);
// None of the named targets reachable only via the callable argument
// should pick up a false-positive edge.
expect(callsFromDynamicTo('dynamicCallableMethod').length).toBe(0);
expect(callsFromDynamicTo('dynamicArrayCallableMethod').length).toBe(0);
expect(callsFromDynamicTo('dynamicArrayClassCallableMethod').length).toBe(0);
});
it('dynamic property read ($obj->$prop) emits no read-edge to dynamicProp', () => {
// No read-access property capture pattern exists in query.ts at all
// (Finding 2). Verify no CALLS / READS / write edge targets `dynamicProp`.
expect(callsFromDynamicTo('dynamicProp').length).toBe(0);
const reads = getRelationships(result, 'READS').filter(
(r) => r.target === 'dynamicProp' && r.sourceFilePath === 'app/Services/Dynamic.php',
);
expect(reads.length).toBe(0);
});
});
// ---------------------------------------------------------------------------
// phpEmitUnresolvedReceiverEdges exact-required-arity gate (Finding 8 / U4).
// The 0.6-confidence fallback for untyped receivers now requires argCount
// to exactly match the candidate's required parameter count for fixed-
// arity candidates. Variadic candidates keep the relaxed argCount >=
// required semantics.
// ---------------------------------------------------------------------------
describe('PHP unresolved-receiver fallback exact-required-arity gate', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'php-unresolved-receiver-arity'),
() => {},
);
}, 60000);
const fallbackEdgeFromTo = (source: string, target: string) =>
getRelationships(result, 'CALLS').filter(
(c) =>
c.source === source && c.target === target && c.targetFilePath === 'app/Models/Handler.php',
);
it('detects Handler and Caller classes', () => {
expect(getNodesByLabel(result, 'Class')).toContain('Handler');
expect(getNodesByLabel(result, 'Class')).toContain('Caller');
});
it('happy path: argCount === required (0===0) emits 0.6 fallback edge', () => {
expect(fallbackEdgeFromTo('callHappyPath', 'happyPath').length).toBe(1);
});
it('argCount === required (1===1) on candidate with default param still emits edge', () => {
expect(fallbackEdgeFromTo('callDefaultExactRequired', 'withDefault').length).toBe(1);
});
it('argCount > required (2>1) on candidate with default param emits NO edge post-fix', () => {
// Pre-fix: first-stage narrowOverloadCandidates accepted (1 <= 2 <= 2).
// Post-fix: exact-required gate rejects (2 !== 1).
expect(fallbackEdgeFromTo('callDefaultBeyondRequired', 'withDefault').length).toBe(0);
});
it('variadic candidate, argCount === required (1===1) emits edge', () => {
expect(fallbackEdgeFromTo('callVariadicAtRequired', 'variadicLog').length).toBe(1);
});
it('variadic candidate, argCount > required (2>1) emits edge (relaxed)', () => {
expect(fallbackEdgeFromTo('callVariadicBeyondRequired', 'variadicLog').length).toBe(1);
});
it('variadic candidate, argCount < required (1<2) emits NO edge', () => {
expect(fallbackEdgeFromTo('callVariadicBelowRequired', 'variadicLogTwoRequired').length).toBe(
0,
);
});
});

View file

@ -148,17 +148,20 @@ describe('primaryLanguages', () => {
it('returns exactly the flipped languages (env opts in unmigrated, opts out migrated)', () => {
// Migrated languages are default-on; each must be opted out here when
// testing explicit env overrides. Java (unmigrated) opts in; Go stays off.
process.env['REGISTRY_PRIMARY_PYTHON'] = 'false';
process.env['REGISTRY_PRIMARY_CSHARP'] = 'false';
process.env['REGISTRY_PRIMARY_TYPESCRIPT'] = 'false';
process.env['REGISTRY_PRIMARY_GO'] = 'false';
process.env['REGISTRY_PRIMARY_C'] = 'false';
// testing explicit env overrides. Java (unmigrated) opts in.
// Opt out every member of MIGRATED_LANGUAGES dynamically so this test
// does not have to be updated each time a new language ships its
// Ring 3 migration (PHP joined the set in commit 69786b16; future
// Ring 3 additions land here without test churn).
for (const lang of MIGRATED_LANGUAGES) {
process.env[envVarNameFor(lang)] = 'false';
}
process.env['REGISTRY_PRIMARY_JAVA'] = '1';
const enabled = primaryLanguages();
expect(enabled.has(SupportedLanguages.Python)).toBe(false);
expect(enabled.has(SupportedLanguages.CSharp)).toBe(false);
expect(enabled.has(SupportedLanguages.Go)).toBe(false);
expect(enabled.has(SupportedLanguages.PHP)).toBe(false);
expect(enabled.has(SupportedLanguages.Java)).toBe(true);
// Only Java is on: migrated defaults overridden off, Java explicitly on.
expect(enabled.size).toBe(1);

View file

@ -69,11 +69,26 @@ describe('narrowOverloadCandidates — arity filtering', () => {
expect(result.map((d) => d.nodeId)).toEqual(['v:1']);
});
it('falls back to the full overload list when arity filter empties it', () => {
it('returns empty when arity filter empties the set AND every candidate had definite bounds', () => {
// argCount=5 doesn't match any overload (none variadic, all have max < 5).
// Post-commit af9af4a9 (PR #1497 / U1): the empty result is now authoritative
// because every rejected candidate had defined `parameterCount` /
// `requiredParameterCount`. The old "always fall back to full list" rescue
// was deliberately removed so resolvers actually drop calls that are
// definitively arity-incompatible (e.g., PHP `f(int $req, ...$rest)`
// called with zero args).
const result = narrowOverloadCandidates([add1, add2, add3], 5, undefined);
expect(result.map((d) => d.nodeId)).toEqual(['add:1', 'add:2', 'add:3']);
expect(result.map((d) => d.nodeId)).toEqual([]);
});
// Note: the `anyUnknownBounds ? overloads : []` branch in
// narrowOverloadCandidates is structurally unreachable in this caller's
// shape — a candidate with both `parameterCount` and `requiredParameterCount`
// undefined always passes the arity filter (neither `argCount > max` nor
// `argCount < min` can fire), so `arityMatches.length` is always > 0
// whenever `anyUnknownBounds` is true. The branch is preserved in the
// source as a defensive guard for future refactors that might add
// additional rejection criteria in the filter.
});
describe('narrowOverloadCandidates — type narrowing', () => {

View file

@ -0,0 +1,156 @@
/**
* Unit tests for `pickImplicitThisOverload` the implicit-`this` free-call
* resolver in `free-call-fallback.ts`.
*
* Codex PR #1497 review, finding 2: the previous implementation returned
* `candidates[0]` after `narrowOverloadCandidates` regardless of how many
* candidates survived narrowing. When two same-name methods on the same
* class had identical arity and unknown argument types, narrowing left both
* compatible and the resolver emitted a high-confidence CALLS edge whose
* target depended on registration order. The fix tightens the picker to
* require a UNIQUE post-narrowing candidate; otherwise the call is left
* unresolved.
*
* These tests exercise the function via synthetic stubs no fixtures, no
* pipeline because the failure shape (two same-arity overloads with
* indistinguishable types) cannot be produced by a PHP integration fixture
* (PHP forbids method overloading) and any C# fixture would entangle this
* unit's contract with the wider C# resolver.
*/
import { describe, it, expect } from 'vitest';
import type { Scope, ScopeId, SymbolDefinition } from 'gitnexus-shared';
import { pickImplicitThisOverload } from '../../../src/core/ingestion/scope-resolution/passes/free-call-fallback.js';
import type { ScopeResolutionIndexes } from '../../../src/core/ingestion/model/scope-resolution-indexes.js';
import type { SemanticModel } from '../../../src/core/ingestion/model/semantic-model.js';
import type { WorkspaceResolutionIndex } from '../../../src/core/ingestion/scope-resolution/workspace-index.js';
const CLASS_SCOPE_ID = 'scope:test.cs#1:1-100:1:Class' as ScopeId;
const CLASS_DEF_ID = 'def:test.cs:Foo';
const mkMethod = (overrides: Partial<SymbolDefinition> & { nodeId: string }): SymbolDefinition => ({
nodeId: overrides.nodeId,
filePath: 'x.cs',
type: 'Method',
...overrides,
});
const mkClassScope = (): Scope =>
({
id: CLASS_SCOPE_ID,
parent: null,
kind: 'Class',
range: { startLine: 1, startCol: 1, endLine: 100, endCol: 1 },
filePath: 'test.cs',
bindings: new Map(),
typeBindings: new Map(),
ownedDefs: [],
}) as unknown as Scope;
const mkScopes = (scope: Scope): ScopeResolutionIndexes =>
({
scopeTree: {
getScope: (id: ScopeId) => (id === scope.id ? scope : undefined),
},
}) as unknown as ScopeResolutionIndexes;
const mkWorkspaceIndex = (mapping: ReadonlyMap<ScopeId, string>): WorkspaceResolutionIndex =>
({
classScopeIdToDefId: mapping,
}) as unknown as WorkspaceResolutionIndex;
const mkModel = (
overloadsByName: ReadonlyMap<string, readonly SymbolDefinition[]>,
): SemanticModel =>
({
methods: {
lookupAllByOwner: (_classDefId: string, name: string) =>
overloadsByName.get(name) ?? ([] as readonly SymbolDefinition[]),
},
}) as unknown as SemanticModel;
describe('pickImplicitThisOverload — uniqueness guard (Codex #1497 finding 2)', () => {
const site = {
inScope: CLASS_SCOPE_ID,
name: 'save',
arity: 1,
argumentTypes: undefined,
};
it('returns the sole overload when only one method exists on the owner', () => {
const sole = mkMethod({ nodeId: 'm:1', parameterCount: 1, requiredParameterCount: 1 });
const scopes = mkScopes(mkClassScope());
const workspace = mkWorkspaceIndex(new Map([[CLASS_SCOPE_ID, CLASS_DEF_ID]]));
const model = mkModel(new Map([['save', [sole]]]));
const result = pickImplicitThisOverload(site, scopes, workspace, model);
expect(result?.nodeId).toBe('m:1');
});
it('returns the single survivor when narrowing disambiguates by arity', () => {
const save1 = mkMethod({ nodeId: 'm:1', parameterCount: 1, requiredParameterCount: 1 });
const save2 = mkMethod({ nodeId: 'm:2', parameterCount: 2, requiredParameterCount: 2 });
const scopes = mkScopes(mkClassScope());
const workspace = mkWorkspaceIndex(new Map([[CLASS_SCOPE_ID, CLASS_DEF_ID]]));
const model = mkModel(new Map([['save', [save1, save2]]]));
// site.arity = 1 → only save1 survives narrowing.
const result = pickImplicitThisOverload(site, scopes, workspace, model);
expect(result?.nodeId).toBe('m:1');
});
it('returns undefined when narrowing leaves two compatible candidates (the bug)', () => {
// Two same-arity, same-required-count overloads with no disambiguating
// parameter-type info on either def. `narrowOverloadCandidates` keeps
// both; pre-fix code returned `candidates[0]` (registration order);
// post-fix code returns undefined.
const save1 = mkMethod({ nodeId: 'm:1', parameterCount: 1, requiredParameterCount: 1 });
const save2 = mkMethod({ nodeId: 'm:2', parameterCount: 1, requiredParameterCount: 1 });
const scopes = mkScopes(mkClassScope());
const workspace = mkWorkspaceIndex(new Map([[CLASS_SCOPE_ID, CLASS_DEF_ID]]));
const model = mkModel(new Map([['save', [save1, save2]]]));
const result = pickImplicitThisOverload(site, scopes, workspace, model);
expect(result).toBeUndefined();
});
it('returns undefined when no method on the owner matches the call name', () => {
const scopes = mkScopes(mkClassScope());
const workspace = mkWorkspaceIndex(new Map([[CLASS_SCOPE_ID, CLASS_DEF_ID]]));
const model = mkModel(new Map());
const result = pickImplicitThisOverload(site, scopes, workspace, model);
expect(result).toBeUndefined();
});
it('returns undefined when the call site is not inside a Class scope', () => {
// Module-scope sites: no enclosing class, so the implicit-this picker
// has nothing to pick from. Different from an empty-narrowing miss.
const moduleScope = {
id: 'scope:test.cs#1:1-100:1:Module' as ScopeId,
parent: null,
kind: 'Module',
range: { startLine: 1, startCol: 1, endLine: 100, endCol: 1 },
filePath: 'test.cs',
bindings: new Map(),
typeBindings: new Map(),
ownedDefs: [],
} as unknown as Scope;
const scopes = mkScopes(moduleScope);
const workspace = mkWorkspaceIndex(new Map());
const model = mkModel(new Map());
const result = pickImplicitThisOverload(
{ ...site, inScope: moduleScope.id },
scopes,
workspace,
model,
);
expect(result).toBeUndefined();
});
});

View file

@ -250,7 +250,13 @@ describe('Step 5: arity filter', () => {
);
});
it('keeps incompatible candidates when no compatible candidate exists (soft penalty)', () => {
it('drops every candidate when ALL are incompatible AND none unknown (hard rejection)', () => {
// Post-commit af9af4a9 (PR #1497 / U1): the old soft-penalty fallback
// that kept incompatible candidates with `arityMatchIncompatible`
// weight was deliberately removed at this layer too. When every
// candidate is definitively arity-incompatible, the registry returns
// no resolution — matching the PHP variadic case `f(int $req, ...$rest)`
// called with zero args.
const save3 = mkDef({
nodeId: 'def:save-three',
type: 'Method',
@ -268,8 +274,41 @@ describe('Step 5: arity filter', () => {
const results = buildMethodRegistry(ctx).lookup('save', 'scope:m', {
callsite: { arity: 1 },
});
expect(results).toHaveLength(1);
expect(evidenceOfKind(results[0]!, 'arity-match')?.weight).toBe(
expect(results).toHaveLength(0);
});
it('keeps incompatible candidates when at least one verdict is unknown (soft penalty)', () => {
// The soft-rescue path is still active when at least one candidate's
// arity verdict is 'unknown' — that signals missing metadata rather
// than a definitive mismatch, so all candidates (including incompatible
// ones) are preserved with their evidence weights for downstream
// tie-breaking.
const save3 = mkDef({
nodeId: 'def:save-three',
type: 'Method',
qualifiedName: 'User.save',
parameterCount: 3,
});
const saveUnknown = mkDef({
nodeId: 'def:save-unknown',
type: 'Method',
qualifiedName: 'User.save',
});
const mod = mkScope({
id: 'scope:m',
parent: null,
bindings: { save: [mkBinding(save3, 'local'), mkBinding(saveUnknown, 'local')] },
});
const ctx = makeCtx([mod], [save3, saveUnknown], {
arity: (_callsite, def) => (def.nodeId === 'def:save-unknown' ? 'unknown' : 'incompatible'),
});
const results = buildMethodRegistry(ctx).lookup('save', 'scope:m', {
callsite: { arity: 1 },
});
expect(results).toHaveLength(2);
const incompat = results.find((r) => r.def.nodeId === 'def:save-three');
expect(incompat).toBeDefined();
expect(evidenceOfKind(incompat!, 'arity-match')?.weight).toBe(
EvidenceWeights.arityMatchIncompatible,
);
});