Carries #1221 by @w0lan plus a hardening pass: property-receiver typing consults property-shaped declarations only (typed property / promoted ctor param / pseudoconstructor assignment / assignment-followed classic ctor and typed setter), so same-named locals and parameters can never mistype a property. Co-authored-by: Roman Wolan <roman.wolan@morizon-gratka.pl> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Roman Wolan
Claude Fable 5
parent
9d0cd3a7d1
commit
70b1be6a21
@@ -11,6 +11,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
|
||||||
|
- PHP method calls made through a class property — `$this->dep->method()`, the dominant call shape in constructor-injection codebases (Symfony, Laravel) — now resolve to the method on the property's declared type, so callers and impact analysis see production call sites instead of reporting a DI-heavy method as uncalled or test-only. Promoted constructor parameters, typed properties, classic constructor assignment (including multi-line signatures), and typed setter injection all count; interface-typed properties resolve to the interface method, and inherited methods resolve through the type hierarchy. Only property-shaped declarations are consulted — a same-named local variable or parameter elsewhere can never mistype the property — and a property whose type can't be recovered statically stays unlinked rather than guessed. Thanks @w0lan. (#1220)
|
||||||
- `codegraph upgrade` now also refreshes what previous versions installed into your agents — the CodeGraph section in CLAUDE.md / AGENTS.md / GEMINI.md and the MCP entry — so upgrading no longer leaves agents following instructions written for tools that have since been renamed or removed. Refresh-only: agents you never configured are not touched, and your permission and hook choices are preserved. Also available manually as `codegraph install --refresh`, and skippable with `CODEGRAPH_NO_INSTALL_REFRESH=1`. (#1238)
|
- `codegraph upgrade` now also refreshes what previous versions installed into your agents — the CodeGraph section in CLAUDE.md / AGENTS.md / GEMINI.md and the MCP entry — so upgrading no longer leaves agents following instructions written for tools that have since been renamed or removed. Refresh-only: agents you never configured are not touched, and your permission and hook choices are preserved. Also available manually as `codegraph install --refresh`, and skippable with `CODEGRAPH_NO_INSTALL_REFRESH=1`. (#1238)
|
||||||
- `codegraph upgrade` on an npm install now upgrades through npm again instead of quietly creating a second copy that never wins the PATH race — previously `codegraph --version` kept reporting the old version forever, no matter how many times you upgraded. (#1238)
|
- `codegraph upgrade` on an npm install now upgrades through npm again instead of quietly creating a second copy that never wins the PATH race — previously `codegraph --version` kept reporting the old version forever, no matter how many times you upgraded. (#1238)
|
||||||
- After every upgrade, CodeGraph now checks that the `codegraph` command your terminal resolves actually serves the freshly installed version — confirming you don't need a new terminal, or telling you exactly which stale install is shadowing the new one. (#1071)
|
- After every upgrade, CodeGraph now checks that the `codegraph` command your terminal resolves actually serves the freshly installed version — confirming you don't need a new terminal, or telling you exactly which stale install is shadowing the new one. (#1071)
|
||||||
|
|||||||
@@ -0,0 +1,342 @@
|
|||||||
|
/**
|
||||||
|
* PHP property-receiver resolution (#1108 family).
|
||||||
|
*
|
||||||
|
* `$this->prop->method()` reaches the resolver as `this->prop.method` (the
|
||||||
|
* extractor records the receiver's raw text with the leading `$` stripped, and
|
||||||
|
* — unlike a `foo()->bar()` chain — there are no `()` on the receiver). The
|
||||||
|
* property's declaration lives OUTSIDE the calling method: a promoted
|
||||||
|
* constructor parameter (`private readonly Greeter $greeter`), a classic typed
|
||||||
|
* property assigned in `__construct`, or a property typed by an interface. The
|
||||||
|
* resolver recovers the property's declared type from PROPERTY-shaped
|
||||||
|
* declarations only — a modifier-prefixed typed declaration, the
|
||||||
|
* `$this->prop = new X()` pseudoconstructor, or (for a classic untyped
|
||||||
|
* property) the typed variable assigned to it inside its own function. Plain
|
||||||
|
* `$prop` locals and parameters live in a different namespace than
|
||||||
|
* `$this->prop` and can never shadow it, so they must never type it — the
|
||||||
|
* interference tests below pin that. The inferred type is validated through
|
||||||
|
* `resolveMethodOnType`, so a property whose type can't be recovered stays
|
||||||
|
* UNLINKED rather than guessed — a wrong inference produces no edge instead
|
||||||
|
* of a wrong one.
|
||||||
|
*
|
||||||
|
* Method lookup runs EXCLUSIVELY through declared-type inference: the
|
||||||
|
* name-similarity fallbacks never see this shape, which is what makes the
|
||||||
|
* same-name-collision and no-type cases below negative. Inherited methods
|
||||||
|
* resolve only once `extends`/`implements` edges exist, so these refs defer to
|
||||||
|
* the conformance pass; the full `indexAll()` path here exercises that.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import * as fs from 'node:fs';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
import * as os from 'node:os';
|
||||||
|
import { CodeGraph } from '../src';
|
||||||
|
import { Node } from '../src/types';
|
||||||
|
import { ResolutionContext } from '../src/resolution';
|
||||||
|
import { matchMethodCall } from '../src/resolution/name-matcher';
|
||||||
|
import type { UnresolvedRef } from '../src/resolution/types';
|
||||||
|
|
||||||
|
describe('PHP property-receiver resolution', () => {
|
||||||
|
let dir: string;
|
||||||
|
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'php-prop-recv-')); });
|
||||||
|
afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
|
||||||
|
|
||||||
|
const write = (rel: string, body: string) => {
|
||||||
|
const p = path.join(dir, rel);
|
||||||
|
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||||
|
fs.writeFileSync(p, body);
|
||||||
|
};
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
const cg = await CodeGraph.init(dir, { silent: true });
|
||||||
|
await cg.indexAll();
|
||||||
|
const db = (cg as any).db.db;
|
||||||
|
const calls: { src: string; tgt: string; tgtQn: string }[] = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT s.name src, t.name tgt, t.qualified_name tgtQn
|
||||||
|
FROM edges e JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target
|
||||||
|
WHERE e.kind = 'calls' AND t.kind = 'method'`,
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
cg.close?.();
|
||||||
|
return calls;
|
||||||
|
};
|
||||||
|
const hasCall = (calls: any[], src: string, tgtQn: string) =>
|
||||||
|
calls.some((e) => e.src === src && e.tgtQn === tgtQn);
|
||||||
|
// Any resolved method call `src` makes to a method of the given bare name —
|
||||||
|
// used by the negative cases to assert nothing was guessed.
|
||||||
|
const callsMethodNamed = (calls: any[], src: string, tgt: string) =>
|
||||||
|
calls.some((e) => e.src === src && e.tgt === tgt);
|
||||||
|
|
||||||
|
const greeter = `<?php\nclass Greeter { public function greet() { return 1; } }\n`;
|
||||||
|
|
||||||
|
it('resolves a promoted constructor property (`private readonly Greeter $greeter`)', async () => {
|
||||||
|
write('Greeter.php', greeter);
|
||||||
|
write('App.php', `<?php
|
||||||
|
class App {
|
||||||
|
public function __construct(private readonly Greeter $greeter) {}
|
||||||
|
public function run() { return $this->greeter->greet(); }
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
const calls = await load();
|
||||||
|
expect(hasCall(calls, 'run', 'Greeter::greet')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves a classic typed property assigned in the constructor', async () => {
|
||||||
|
write('Greeter.php', greeter);
|
||||||
|
write('App.php', `<?php
|
||||||
|
class App {
|
||||||
|
private Greeter $greeter;
|
||||||
|
public function __construct(Greeter $greeter) { $this->greeter = $greeter; }
|
||||||
|
public function run() { return $this->greeter->greet(); }
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
const calls = await load();
|
||||||
|
expect(hasCall(calls, 'run', 'Greeter::greet')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves a property typed by an interface to the interface method', async () => {
|
||||||
|
write('GreeterInterface.php', `<?php\ninterface GreeterInterface { public function hello(); }\n`);
|
||||||
|
write('App.php', `<?php
|
||||||
|
class App {
|
||||||
|
public function __construct(private GreeterInterface $g) {}
|
||||||
|
public function run() { return $this->g->hello(); }
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
const calls = await load();
|
||||||
|
expect(hasCall(calls, 'run', 'GreeterInterface::hello')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves an inherited method through the conformance pass (property typed by the subclass)', async () => {
|
||||||
|
// `baseMethod` is declared only on Base; the property is typed `Sub`.
|
||||||
|
// The `Sub extends Base` edge is what lets the deferred conformance walk
|
||||||
|
// find the method on the supertype — the whole point of deferring this ref.
|
||||||
|
write('Base.php', `<?php\nclass Base { public function baseMethod() { return 1; } }\n`);
|
||||||
|
write('Sub.php', `<?php\nclass Sub extends Base { public function other() { return 2; } }\n`);
|
||||||
|
write('App.php', `<?php
|
||||||
|
class App {
|
||||||
|
public function __construct(private Sub $s) {}
|
||||||
|
public function run() { return $this->s->baseMethod(); }
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
const calls = await load();
|
||||||
|
expect(hasCall(calls, 'run', 'Base::baseMethod')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disambiguates by declared type when two classes share a method name (negative)', async () => {
|
||||||
|
// Both classes declare `greet`; the property is typed `Greeter`. A
|
||||||
|
// name-similarity fallback would happily link either — this shape must
|
||||||
|
// route to the RIGHT class and ONLY it.
|
||||||
|
write('Greeter.php', greeter);
|
||||||
|
write('OtherGreeter.php', `<?php\nclass OtherGreeter { public function greet() { return 2; } }\n`);
|
||||||
|
write('App.php', `<?php
|
||||||
|
class App {
|
||||||
|
public function __construct(private Greeter $greeter) {}
|
||||||
|
public function run() { return $this->greeter->greet(); }
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
const calls = await load();
|
||||||
|
expect(hasCall(calls, 'run', 'Greeter::greet')).toBe(true);
|
||||||
|
expect(hasCall(calls, 'run', 'OtherGreeter::greet')).toBe(false);
|
||||||
|
// Exactly one method edge from `run` — no double-linking.
|
||||||
|
expect(calls.filter((e) => e.src === 'run')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates no edge for an untyped property with only a docblock type (negative)', async () => {
|
||||||
|
// `@var Greeter` is a comment, not a declared type. Guessing from a
|
||||||
|
// docblock is out of scope — the property stays unlinked.
|
||||||
|
write('Greeter.php', greeter);
|
||||||
|
write('App.php', `<?php
|
||||||
|
class App {
|
||||||
|
/** @var Greeter */
|
||||||
|
private $greeter;
|
||||||
|
public function run() { return $this->greeter->greet(); }
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
const calls = await load();
|
||||||
|
expect(callsMethodNamed(calls, 'run', 'greet')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates no edge for a deep property chain `$this->a->b->method()` (negative)', async () => {
|
||||||
|
// The single-property pattern deliberately does not match a two-hop chain;
|
||||||
|
// the intermediate type is unknown, so nothing is guessed.
|
||||||
|
write('Greeter.php', greeter);
|
||||||
|
write('App.php', `<?php
|
||||||
|
class App {
|
||||||
|
public function __construct(private Wrapper $a) {}
|
||||||
|
public function run() { return $this->a->b->greet(); }
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
const calls = await load();
|
||||||
|
expect(callsMethodNamed(calls, 'run', 'greet')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a local variable shadowing a property routes to the local\'s type, not the property (#1108 regression)', async () => {
|
||||||
|
// `$greeter->greet()` has receiver `greeter` (no `this->`), so it takes the
|
||||||
|
// existing #1108 local-variable path, not the new property path. The local
|
||||||
|
// `new OtherGreeter()` must win by nearest-declaration-backward even though
|
||||||
|
// a property `$greeter` typed `Greeter` exists — the property change must
|
||||||
|
// not hijack a plain-variable receiver.
|
||||||
|
write('Greeter.php', greeter);
|
||||||
|
write('OtherGreeter.php', `<?php\nclass OtherGreeter { public function greet() { return 2; } }\n`);
|
||||||
|
write('App.php', `<?php
|
||||||
|
class App {
|
||||||
|
public function __construct(private Greeter $greeter) {}
|
||||||
|
public function run() { $greeter = new OtherGreeter(); return $greeter->greet(); }
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
const calls = await load();
|
||||||
|
expect(hasCall(calls, 'run', 'OtherGreeter::greet')).toBe(true);
|
||||||
|
expect(hasCall(calls, 'run', 'Greeter::greet')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a same-named local in ANOTHER method never types the property (interference)', async () => {
|
||||||
|
// In PHP `$greeter` (a local) and `$this->greeter` (the property) are
|
||||||
|
// different namespaces — unlike CFML's scopes, no shadowing is possible.
|
||||||
|
// The nearest declaration walking backward from run()'s call is helper()'s
|
||||||
|
// `$greeter = new OtherGreeter()`; the property's promoted type `Greeter`
|
||||||
|
// must still win.
|
||||||
|
write('Greeter.php', greeter);
|
||||||
|
write('OtherGreeter.php', `<?php\nclass OtherGreeter { public function greet() { return 2; } }\n`);
|
||||||
|
write('App.php', `<?php
|
||||||
|
class App {
|
||||||
|
public function __construct(private Greeter $greeter) {}
|
||||||
|
public function helper() { $greeter = new OtherGreeter(); return $greeter->greet(); }
|
||||||
|
public function run() { return $this->greeter->greet(); }
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
const calls = await load();
|
||||||
|
expect(hasCall(calls, 'run', 'Greeter::greet')).toBe(true);
|
||||||
|
expect(hasCall(calls, 'run', 'OtherGreeter::greet')).toBe(false);
|
||||||
|
// helper()'s own local-receiver call still routes to the local's type.
|
||||||
|
expect(hasCall(calls, 'helper', 'OtherGreeter::greet')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a same-named local in the SAME method never types the property (interference)', async () => {
|
||||||
|
write('Greeter.php', greeter);
|
||||||
|
write('OtherGreeter.php', `<?php\nclass OtherGreeter { public function greet() { return 2; } }\n`);
|
||||||
|
write('App.php', `<?php
|
||||||
|
class App {
|
||||||
|
public function __construct(private Greeter $greeter) {}
|
||||||
|
public function run() {
|
||||||
|
$greeter = new OtherGreeter();
|
||||||
|
$greeter->greet();
|
||||||
|
return $this->greeter->greet();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
const calls = await load();
|
||||||
|
// Both calls resolve, each to its own receiver's type.
|
||||||
|
expect(hasCall(calls, 'run', 'OtherGreeter::greet')).toBe(true);
|
||||||
|
expect(hasCall(calls, 'run', 'Greeter::greet')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a same-named parameter of an unrelated method never types the property (interference)', async () => {
|
||||||
|
write('Greeter.php', greeter);
|
||||||
|
write('OtherGreeter.php', `<?php\nclass OtherGreeter { public function greet() { return 2; } }\n`);
|
||||||
|
write('App.php', `<?php
|
||||||
|
class App {
|
||||||
|
public function __construct(private Greeter $greeter) {}
|
||||||
|
public function accept(OtherGreeter $greeter) { return $greeter->greet(); }
|
||||||
|
public function run() { return $this->greeter->greet(); }
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
const calls = await load();
|
||||||
|
expect(hasCall(calls, 'run', 'Greeter::greet')).toBe(true);
|
||||||
|
expect(hasCall(calls, 'run', 'OtherGreeter::greet')).toBe(false);
|
||||||
|
expect(hasCall(calls, 'accept', 'OtherGreeter::greet')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves a classic UNTYPED property through its constructor assignment (multi-line signature)', async () => {
|
||||||
|
// Pre-7.4 style: the property declaration carries no type; the type lives
|
||||||
|
// on the constructor parameter, here across a multi-line signature. The
|
||||||
|
// resolver follows `$this->greeter = $greeter` to the parameter's type.
|
||||||
|
write('Greeter.php', greeter);
|
||||||
|
write('App.php', `<?php
|
||||||
|
class App {
|
||||||
|
private $greeter;
|
||||||
|
public function __construct(
|
||||||
|
Greeter $greeter,
|
||||||
|
$other
|
||||||
|
) {
|
||||||
|
$this->greeter = $greeter;
|
||||||
|
}
|
||||||
|
public function run() { return $this->greeter->greet(); }
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
const calls = await load();
|
||||||
|
expect(hasCall(calls, 'run', 'Greeter::greet')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves a setter-injected untyped property through the setter parameter', async () => {
|
||||||
|
write('Greeter.php', greeter);
|
||||||
|
write('App.php', `<?php
|
||||||
|
class App {
|
||||||
|
private $greeter;
|
||||||
|
public function setGreeter(Greeter $greeter) { $this->greeter = $greeter; }
|
||||||
|
public function run() { return $this->greeter->greet(); }
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
const calls = await load();
|
||||||
|
expect(hasCall(calls, 'run', 'Greeter::greet')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the assignment-following fallback stays inside the assigning function (interference)', async () => {
|
||||||
|
// The untyped property is assigned from an UNTYPED constructor parameter,
|
||||||
|
// and a same-named typed variable exists in the method directly above the
|
||||||
|
// constructor. The backward scan from the assignment must stop at the
|
||||||
|
// constructor's own `function` line — no type is recoverable, no edge.
|
||||||
|
write('Greeter.php', greeter);
|
||||||
|
write('OtherGreeter.php', `<?php\nclass OtherGreeter { public function greet() { return 2; } }\n`);
|
||||||
|
write('App.php', `<?php
|
||||||
|
class App {
|
||||||
|
private $greeter;
|
||||||
|
public function helper() { $greeter = new OtherGreeter(); return $greeter->greet(); }
|
||||||
|
public function __construct($greeter) {
|
||||||
|
$this->greeter = $greeter;
|
||||||
|
}
|
||||||
|
public function run() { return $this->greeter->greet(); }
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
const calls = await load();
|
||||||
|
expect(callsMethodNamed(calls, 'run', 'greet')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Unit-level check of the confidence the integration DB does not expose:
|
||||||
|
// the property-receiver shape resolves through resolveMethodOnType at 0.9.
|
||||||
|
it('matchMethodCall resolves `this->prop.method` at confidence 0.9', () => {
|
||||||
|
const node = (id: string, name: string, qn: string, kind: Node['kind'], file: string): Node => ({
|
||||||
|
id, kind, name, qualifiedName: qn, filePath: file, language: 'php',
|
||||||
|
startLine: 1, endLine: 1, startColumn: 0, endColumn: 0, updatedAt: 0,
|
||||||
|
});
|
||||||
|
const byName: Record<string, Node[]> = {
|
||||||
|
Greeter: [node('c:greeter', 'Greeter', 'Greeter', 'class', 'Greeter.php')],
|
||||||
|
greet: [node('m:greet', 'greet', 'Greeter::greet', 'method', 'Greeter.php')],
|
||||||
|
};
|
||||||
|
const lines = [
|
||||||
|
'<?php',
|
||||||
|
'class App {',
|
||||||
|
' public function __construct(private readonly Greeter $greeter) {}',
|
||||||
|
' public function run() { return $this->greeter->greet(); }',
|
||||||
|
'}',
|
||||||
|
];
|
||||||
|
const ctx: ResolutionContext = {
|
||||||
|
getNodesInFile: () => [],
|
||||||
|
getNodesByName: (name) => byName[name] ?? [],
|
||||||
|
getNodesByQualifiedName: () => [],
|
||||||
|
getNodesByKind: () => [],
|
||||||
|
fileExists: () => false,
|
||||||
|
readFile: () => null,
|
||||||
|
getFileLines: () => lines,
|
||||||
|
getProjectRoot: () => '',
|
||||||
|
getAllFiles: () => [],
|
||||||
|
getImportMappings: () => [],
|
||||||
|
};
|
||||||
|
const ref: UnresolvedRef = {
|
||||||
|
fromNodeId: 'caller', referenceName: 'this->greeter.greet', referenceKind: 'calls',
|
||||||
|
line: 4, column: 0, filePath: 'App.php', language: 'php',
|
||||||
|
};
|
||||||
|
const res = matchMethodCall(ref, ctx);
|
||||||
|
expect(res?.targetNodeId).toBe('m:greet');
|
||||||
|
expect(res?.confidence).toBe(0.9);
|
||||||
|
expect(res?.resolvedBy).toBe('instance-method');
|
||||||
|
});
|
||||||
|
});
|
||||||
+19
-3
@@ -16,7 +16,7 @@ import {
|
|||||||
FrameworkResolver,
|
FrameworkResolver,
|
||||||
ImportMapping,
|
ImportMapping,
|
||||||
} from './types';
|
} from './types';
|
||||||
import { matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, sameLanguageFamily, crossesKnownFamily } from './name-matcher';
|
import { matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesKnownFamily } from './name-matcher';
|
||||||
import { resolveViaImport, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef, isNixPathImportRef } from './import-resolver';
|
import { resolveViaImport, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef, isNixPathImportRef } from './import-resolver';
|
||||||
import { detectFrameworks } from './frameworks';
|
import { detectFrameworks } from './frameworks';
|
||||||
import { synthesizeCallbackEdges } from './callback-synthesizer';
|
import { synthesizeCallbackEdges } from './callback-synthesizer';
|
||||||
@@ -44,6 +44,9 @@ const SCOPED_CHAIN_LANGUAGES = new Set(['rust']);
|
|||||||
/** The extractor's chained-receiver encoding: `<inner>().<method>`. */
|
/** The extractor's chained-receiver encoding: `<inner>().<method>`. */
|
||||||
const CHAIN_SHAPE = /^(.+)\(\)\.(\w+)$/;
|
const CHAIN_SHAPE = /^(.+)\(\)\.(\w+)$/;
|
||||||
|
|
||||||
|
/** PHP `$this->prop->method()` encoded as `this->prop.method` — no `()`, so CHAIN_SHAPE misses it. */
|
||||||
|
const PHP_PROP_SHAPE = /^this->\w+\.\w+$/;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cache size limits. Each per-resolver cache is bounded so memory
|
* Cache size limits. Each per-resolver cache is bounded so memory
|
||||||
* stays flat on large codebases (20k+ files). Sizes were chosen to
|
* stays flat on large codebases (20k+ files). Sizes were chosen to
|
||||||
@@ -919,6 +922,15 @@ export class ReferenceResolver {
|
|||||||
CHAIN_SHAPE.test(ref.referenceName)
|
CHAIN_SHAPE.test(ref.referenceName)
|
||||||
) {
|
) {
|
||||||
this.deferredChainRefs.push(ref);
|
this.deferredChainRefs.push(ref);
|
||||||
|
} else if (
|
||||||
|
// PHP `$this->prop->method()` (encoded `this->prop.method`): its method
|
||||||
|
// may live on the property's declared supertype, resolvable only once
|
||||||
|
// implements/extends edges exist — defer to the same conformance pass.
|
||||||
|
ref.referenceKind === 'calls' &&
|
||||||
|
ref.language === 'php' &&
|
||||||
|
PHP_PROP_SHAPE.test(ref.referenceName)
|
||||||
|
) {
|
||||||
|
this.deferredChainRefs.push(ref);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -1117,9 +1129,13 @@ export class ReferenceResolver {
|
|||||||
const maybeYield = createYielder();
|
const maybeYield = createYielder();
|
||||||
const resolved: ResolvedRef[] = [];
|
const resolved: ResolvedRef[] = [];
|
||||||
for (const ref of deferred) {
|
for (const ref of deferred) {
|
||||||
// `::`-receiver languages (Rust) split on `::` (matchScopedCallChain);
|
// PHP `this->prop.method` resolves via matchMethodCall (declared-type
|
||||||
|
// inference + resolveMethodOnType conformance walk); `::`-receiver
|
||||||
|
// languages (Rust) split on `::` (matchScopedCallChain); other
|
||||||
// dotted-receiver languages on `.` (matchDottedCallChain).
|
// dotted-receiver languages on `.` (matchDottedCallChain).
|
||||||
const chainMatch = SCOPED_CHAIN_LANGUAGES.has(ref.language)
|
const chainMatch = (ref.language === 'php' && PHP_PROP_SHAPE.test(ref.referenceName))
|
||||||
|
? matchMethodCall(ref, this.context)
|
||||||
|
: SCOPED_CHAIN_LANGUAGES.has(ref.language)
|
||||||
? matchScopedCallChain(ref, this.context)
|
? matchScopedCallChain(ref, this.context)
|
||||||
: matchDottedCallChain(ref, this.context);
|
: matchDottedCallChain(ref, this.context);
|
||||||
const match = this.gateLanguage(chainMatch, ref);
|
const match = this.gateLanguage(chainMatch, ref);
|
||||||
|
|||||||
@@ -1280,11 +1280,30 @@ function inferLocalReceiverType(
|
|||||||
componentScoped = scope === 'variables' || scope === 'this';
|
componentScoped = scope === 'variables' || scope === 'this';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// PHP `$this->prop` receiver — the property's declaration lives outside the
|
||||||
|
// calling method (a promoted constructor parameter `private readonly Foo $prop`,
|
||||||
|
// a typed property `private Foo $prop;`, or a classic constructor parameter
|
||||||
|
// `Foo $prop` assigned in __construct). Strip the prefix and widen the scan to
|
||||||
|
// the whole file (the constructor may sit below the calling method), but —
|
||||||
|
// unlike CFML's scopes above — switch to PROPERTY-shaped patterns: a plain
|
||||||
|
// `$prop` local or parameter lives in a different namespace than `$this->prop`
|
||||||
|
// and can never shadow it, so the generic local patterns would type the
|
||||||
|
// property from unrelated same-named variables in other methods (a wrong
|
||||||
|
// 0.9-confidence edge, not a missing one).
|
||||||
|
let phpProperty = false;
|
||||||
|
if (ref.language === 'php') {
|
||||||
|
const scoped = receiverName.match(/^this->(.+)$/);
|
||||||
|
if (scoped) {
|
||||||
|
scanReceiver = scoped[1]!;
|
||||||
|
componentScoped = true;
|
||||||
|
phpProperty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const patterns = localReceiverTypePatterns(
|
const escapedReceiver = scanReceiver.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
ref.language,
|
const patterns = phpProperty
|
||||||
scanReceiver.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'),
|
? phpPropertyTypePatterns(escapedReceiver)
|
||||||
);
|
: localReceiverTypePatterns(ref.language, escapedReceiver);
|
||||||
if (patterns.length === 0) return null;
|
if (patterns.length === 0) return null;
|
||||||
|
|
||||||
// Split through the context's per-file lines cache when available: this runs
|
// Split through the context's per-file lines cache when available: this runs
|
||||||
@@ -1332,6 +1351,95 @@ function inferLocalReceiverType(
|
|||||||
if (type) return type;
|
if (type) return type;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A PHP property with no statically-typed declaration (classic pre-7.4
|
||||||
|
// style) may still be typed by what gets ASSIGNED to it — follow the
|
||||||
|
// `$this->prop = $var` assignment to the assigned variable's own typed
|
||||||
|
// declaration (a classic or multi-line constructor parameter, or a typed
|
||||||
|
// setter's parameter).
|
||||||
|
if (phpProperty) {
|
||||||
|
return inferPhpAssignedPropertyType(escapedReceiver, lines, callIdx);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Patterns that recover a PHP class property's declared type for a
|
||||||
|
* `$this->prop` receiver. Deliberately NOT localReceiverTypePatterns: only
|
||||||
|
* property-shaped declarations qualify —
|
||||||
|
* 1. a modifier-prefixed typed declaration, which covers both a typed
|
||||||
|
* property (`private ?Foo $prop;`) and a promoted constructor parameter
|
||||||
|
* (`private readonly Foo $prop`), and
|
||||||
|
* 2. the pseudoconstructor assignment (`$this->prop = new Foo(...)`).
|
||||||
|
* A bare `X $prop` parameter or `$prop = new X()` local elsewhere in the
|
||||||
|
* file must NOT match: those variables can never alias `$this->prop`.
|
||||||
|
* Union-typed properties (`Foo|Bar $prop`) yield no match and thus no edge —
|
||||||
|
* silent beats wrong. The classic untyped-property-assigned-in-constructor
|
||||||
|
* shape is handled by inferPhpAssignedPropertyType instead.
|
||||||
|
*/
|
||||||
|
function phpPropertyTypePatterns(r: string): RegExp[] {
|
||||||
|
return [
|
||||||
|
new RegExp(
|
||||||
|
`\\b(?:(?:private|protected|public|readonly|static|final)(?:\\(set\\))?\\s+)+\\??([A-Za-z_\\\\][\\w\\\\]*)\\s+&?\\$${r}\\b`,
|
||||||
|
), // private readonly ?Foo $prop (typed property / promoted param)
|
||||||
|
new RegExp(`\\$this->${r}\\b\\s*=\\s*new\\s+([A-Za-z_\\\\][\\w\\\\]*)`), // $this->prop = new Foo()
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Second-chance typing for a PHP `$this->prop` receiver whose property
|
||||||
|
* declaration carries no static type (classic pre-7.4 style): find the
|
||||||
|
* `$this->prop = $var` assignment, then recover `$var`'s type from its own
|
||||||
|
* declaration WITHIN the assignment's function — the constructor's (possibly
|
||||||
|
* multi-line) parameter list, a typed setter's parameter, or a `= new X()`
|
||||||
|
* local. The backward scan stops at the enclosing `function` line (checked
|
||||||
|
* for a match first — a single-line `__construct(Foo $var) { ... }` carries
|
||||||
|
* the typed parameter itself), so a same-named variable in another method
|
||||||
|
* can never type the property.
|
||||||
|
*/
|
||||||
|
function inferPhpAssignedPropertyType(
|
||||||
|
escapedProp: string,
|
||||||
|
lines: string[],
|
||||||
|
callIdx: number,
|
||||||
|
): string | null {
|
||||||
|
const assignRe = new RegExp(`\\$this->${escapedProp}\\b\\s*=\\s*\\$(\\w+)\\b`);
|
||||||
|
const assignAt = (i: number): RegExpMatchArray | null => {
|
||||||
|
const line = lines[i];
|
||||||
|
if (!line || line.length > 10_000) return null;
|
||||||
|
return line.match(assignRe);
|
||||||
|
};
|
||||||
|
// The assignment is position-independent relative to the call — nearest-
|
||||||
|
// backward first, then sweep forward, same order as the componentScoped scan.
|
||||||
|
let assignIdx = -1;
|
||||||
|
let varName: string | null = null;
|
||||||
|
for (let i = callIdx; i >= 0; i--) {
|
||||||
|
const m = assignAt(i);
|
||||||
|
if (m) { assignIdx = i; varName = m[1]!; break; }
|
||||||
|
}
|
||||||
|
if (varName === null) {
|
||||||
|
for (let i = callIdx + 1; i < lines.length; i++) {
|
||||||
|
const m = assignAt(i);
|
||||||
|
if (m) { assignIdx = i; varName = m[1]!; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (varName === null) return null;
|
||||||
|
|
||||||
|
const varPatterns = localReceiverTypePatterns(
|
||||||
|
'php',
|
||||||
|
varName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'),
|
||||||
|
);
|
||||||
|
for (let i = assignIdx; i >= 0; i--) {
|
||||||
|
const line = lines[i];
|
||||||
|
if (line && line.length <= 10_000) {
|
||||||
|
for (const re of varPatterns) {
|
||||||
|
const m = line.match(re);
|
||||||
|
if (m && m[1]) {
|
||||||
|
const type = normalizeInferredTypeName(m[1]);
|
||||||
|
if (type) return type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (line && /\bfunction\b/.test(line)) break;
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1364,6 +1472,32 @@ export function matchMethodCall(
|
|||||||
? ref.referenceName.match(/^([\w.]+)\$(\w+)$/)
|
? ref.referenceName.match(/^([\w.]+)\$(\w+)$/)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
// PHP property receiver: `$this->prop->method()` reaches the resolver as
|
||||||
|
// `this->prop.method` (the extractor records the receiver's raw text with the
|
||||||
|
// leading `$` stripped). Resolve it EXCLUSIVELY through declared-type
|
||||||
|
// inference + resolveMethodOnType validation — the name-similarity strategies
|
||||||
|
// below must never see this shape, so a property whose type can't be
|
||||||
|
// recovered stays unlinked rather than guessed (a wrong inference produces no
|
||||||
|
// edge rather than a wrong one). Deeper chains (`this->a->b.method`) don't
|
||||||
|
// match the single-property pattern and stay unlinked, same as before.
|
||||||
|
const phpThisPropMatch = ref.language === 'php'
|
||||||
|
? ref.referenceName.match(/^(this->\w+)\.(\w+)$/)
|
||||||
|
: null;
|
||||||
|
if (phpThisPropMatch) {
|
||||||
|
const [, receiver, phpMethodName] = phpThisPropMatch;
|
||||||
|
const inferredType = inferLocalReceiverType(receiver!, ref, context);
|
||||||
|
if (!inferredType) return null;
|
||||||
|
return resolveMethodOnType(
|
||||||
|
inferredType,
|
||||||
|
phpMethodName!,
|
||||||
|
ref,
|
||||||
|
context,
|
||||||
|
0.9,
|
||||||
|
'instance-method',
|
||||||
|
importedFqnOf(inferredType, ref, context),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const match = dotMatch || colonMatch || luaColonMatch || rDollarMatch;
|
const match = dotMatch || colonMatch || luaColonMatch || rDollarMatch;
|
||||||
if (!match) {
|
if (!match) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
Reference in New Issue
Block a user