Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
69786b1
feat(php): migrate PHP to scope-based resolution model (#938)
magyargergo May 11, 2026
4594f5d
Merge remote-tracking branch 'origin/main' into lang/php-migration-v2
magyargergo May 11, 2026
a8201cd
feat(scope-resolution): add emitUnresolvedReceiverEdges hook for dyna…
magyargergo May 11, 2026
8733809
Merge branch 'main' into lang/php-migration-v2
magyargergo May 11, 2026
af9af4a
fix(php): four PHP semantic defects flagged by PR #1497 production re…
magyargergo May 11, 2026
97093f9
Merge remote-tracking branch 'magyargergo/lang/php-migration-v2' into…
magyargergo May 11, 2026
ebccccf
Merge branch 'main' into lang/php-migration-v2
magyargergo May 11, 2026
f81e084
Merge branch 'main' into lang/php-migration-v2
magyargergo May 11, 2026
bc8b6a6
fix(php): restore entryPointPatterns and astFrameworkPatterns
magyargergo May 11, 2026
b1f0cd8
Merge remote-tracking branch 'magyargergo/lang/php-migration-v2' into…
magyargergo May 11, 2026
1796afb
Merge branch 'main' into lang/php-migration-v2
magyargergo May 11, 2026
fd30b61
test(php): gate legacy-DAG-divergent assertions via parity helper
magyargergo May 11, 2026
f81c675
Merge branch 'lang/php-migration-v2' of https://github.com/magyargerg…
magyargergo May 11, 2026
88a269f
Merge branch 'main' into lang/php-migration-v2
magyargergo May 11, 2026
1326d7a
Merge branch 'main' of https://github.com/abhigyanpatwari/GitNexus in…
magyargergo May 11, 2026
d89ec0a
Merge branch 'main' into lang/php-migration-v2
magyargergo May 11, 2026
0e5e54f
test(php): land failing FQN cross-namespace regression (Codex #1497)
magyargergo May 11, 2026
1d69b3f
fix(php): preserve qualified form on TypeRef.rawName in normalizePhpType
magyargergo May 11, 2026
4ffe544
fix(php): inject FQN-keyed module-scope bindings (Codex #1497 finding 1)
magyargergo May 11, 2026
53d1b7b
fix(scope-resolution): require unique narrowing in pickImplicitThisOv…
magyargergo May 11, 2026
d776ca6
test(php): register FQN test in legacy-parity skip list (Codex #1497)
magyargergo May 11, 2026
92e5fbe
Merge branch 'lang/php-migration-v2' of https://github.com/magyargerg…
magyargergo May 11, 2026
6ff0a4b
Merge branch 'main' into lang/php-migration-v2
magyargergo May 12, 2026
e7ed4df
Merge branch 'main' into lang/php-migration-v2
magyargergo May 12, 2026
1d84164
Merge branch 'main' into lang/php-migration-v2
magyargergo May 12, 2026
55b8790
Merge branch 'main' into lang/php-migration-v2
magyargergo May 12, 2026
29e45f1
fix(php): stop MRO walk on arity-incompatible most-derived (U1)
magyargergo May 12, 2026
c82fa83
fix(php): dedup typed-property catch-all double-match in captures (U2)
magyargergo May 12, 2026
14a57b5
test(php): lock dynamic-dispatch suppression in regression coverage (U3)
magyargergo May 12, 2026
e90f77c
fix(php): tighten unresolved-receiver fallback to exact-required arit…
magyargergo May 12, 2026
ea04c5e
docs(php): document '...' vs 'params' variadic-marker asymmetry (U5)
magyargergo May 12, 2026
00d4cc0
test(php): skip U1/U4 scope-resolver-only assertions in legacy DAG mode
magyargergo May 12, 2026
82b5150
Merge branch 'main' into lang/php-migration-v2
magyargergo May 12, 2026
fb093f7
Merge branch 'main' into lang/php-migration-v2
magyargergo May 12, 2026
fcb0e30
fix(php): PSR-4-compliant fixture + pre-existing test debt cleanup
magyargergo May 12, 2026
713aa92
Merge branch 'main' into lang/php-migration-v2
magyargergo May 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 50 additions & 2 deletions gitnexus-shared/src/scope-resolution/method-dispatch-index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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>>();

Expand All @@ -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);
Expand All @@ -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 ───────────────────────────────────────────────────────────────
Expand All @@ -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[] {
Expand All @@ -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;
}
19 changes: 18 additions & 1 deletion gitnexus-shared/src/scope-resolution/registries/lookup-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@
const walk: DefId[] = [ownerDefId, ...ctx.methodDispatch.mroFor(ownerDefId)];

for (let mroDepth = 0; mroDepth < walk.length; mroDepth++) {
const currentOwnerId = walk[mroDepth]!;

Check warning on line 267 in gitnexus-shared/src/scope-resolution/registries/lookup-core.ts

View workflow job for this annotation

GitHub Actions / quality / lint

Forbidden non-null assertion
const members = collectOwnedMembers(currentOwnerId, name, ctx);
for (const def of members) {
if (!acceptedKinds.has(def.type)) continue;
Expand Down Expand Up @@ -423,13 +423,30 @@
}

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) {
Expand Down
28 changes: 26 additions & 2 deletions gitnexus/src/core/ingestion/languages/php.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
});
73 changes: 73 additions & 0 deletions gitnexus/src/core/ingestion/languages/php/arity-metadata.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
47 changes: 47 additions & 0 deletions gitnexus/src/core/ingestion/languages/php/arity.ts
Original file line number Diff line number Diff line change
@@ -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';
}
30 changes: 30 additions & 0 deletions gitnexus/src/core/ingestion/languages/php/cache-stats.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading