php-property-receiver-resolution.test.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342
  1. /**
  2. * PHP property-receiver resolution (#1108 family).
  3. *
  4. * `$this->prop->method()` reaches the resolver as `this->prop.method` (the
  5. * extractor records the receiver's raw text with the leading `$` stripped, and
  6. * — unlike a `foo()->bar()` chain — there are no `()` on the receiver). The
  7. * property's declaration lives OUTSIDE the calling method: a promoted
  8. * constructor parameter (`private readonly Greeter $greeter`), a classic typed
  9. * property assigned in `__construct`, or a property typed by an interface. The
  10. * resolver recovers the property's declared type from PROPERTY-shaped
  11. * declarations only — a modifier-prefixed typed declaration, the
  12. * `$this->prop = new X()` pseudoconstructor, or (for a classic untyped
  13. * property) the typed variable assigned to it inside its own function. Plain
  14. * `$prop` locals and parameters live in a different namespace than
  15. * `$this->prop` and can never shadow it, so they must never type it — the
  16. * interference tests below pin that. The inferred type is validated through
  17. * `resolveMethodOnType`, so a property whose type can't be recovered stays
  18. * UNLINKED rather than guessed — a wrong inference produces no edge instead
  19. * of a wrong one.
  20. *
  21. * Method lookup runs EXCLUSIVELY through declared-type inference: the
  22. * name-similarity fallbacks never see this shape, which is what makes the
  23. * same-name-collision and no-type cases below negative. Inherited methods
  24. * resolve only once `extends`/`implements` edges exist, so these refs defer to
  25. * the conformance pass; the full `indexAll()` path here exercises that.
  26. */
  27. import { describe, it, expect, beforeEach, afterEach } from 'vitest';
  28. import * as fs from 'node:fs';
  29. import * as path from 'node:path';
  30. import * as os from 'node:os';
  31. import { CodeGraph } from '../src';
  32. import { Node } from '../src/types';
  33. import { ResolutionContext } from '../src/resolution';
  34. import { matchMethodCall } from '../src/resolution/name-matcher';
  35. import type { UnresolvedRef } from '../src/resolution/types';
  36. describe('PHP property-receiver resolution', () => {
  37. let dir: string;
  38. beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'php-prop-recv-')); });
  39. afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
  40. const write = (rel: string, body: string) => {
  41. const p = path.join(dir, rel);
  42. fs.mkdirSync(path.dirname(p), { recursive: true });
  43. fs.writeFileSync(p, body);
  44. };
  45. const load = async () => {
  46. const cg = await CodeGraph.init(dir, { silent: true });
  47. await cg.indexAll();
  48. const db = (cg as any).db.db;
  49. const calls: { src: string; tgt: string; tgtQn: string }[] = db
  50. .prepare(
  51. `SELECT s.name src, t.name tgt, t.qualified_name tgtQn
  52. FROM edges e JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target
  53. WHERE e.kind = 'calls' AND t.kind = 'method'`,
  54. )
  55. .all();
  56. cg.close?.();
  57. return calls;
  58. };
  59. const hasCall = (calls: any[], src: string, tgtQn: string) =>
  60. calls.some((e) => e.src === src && e.tgtQn === tgtQn);
  61. // Any resolved method call `src` makes to a method of the given bare name —
  62. // used by the negative cases to assert nothing was guessed.
  63. const callsMethodNamed = (calls: any[], src: string, tgt: string) =>
  64. calls.some((e) => e.src === src && e.tgt === tgt);
  65. const greeter = `<?php\nclass Greeter { public function greet() { return 1; } }\n`;
  66. it('resolves a promoted constructor property (`private readonly Greeter $greeter`)', async () => {
  67. write('Greeter.php', greeter);
  68. write('App.php', `<?php
  69. class App {
  70. public function __construct(private readonly Greeter $greeter) {}
  71. public function run() { return $this->greeter->greet(); }
  72. }
  73. `);
  74. const calls = await load();
  75. expect(hasCall(calls, 'run', 'Greeter::greet')).toBe(true);
  76. });
  77. it('resolves a classic typed property assigned in the constructor', async () => {
  78. write('Greeter.php', greeter);
  79. write('App.php', `<?php
  80. class App {
  81. private Greeter $greeter;
  82. public function __construct(Greeter $greeter) { $this->greeter = $greeter; }
  83. public function run() { return $this->greeter->greet(); }
  84. }
  85. `);
  86. const calls = await load();
  87. expect(hasCall(calls, 'run', 'Greeter::greet')).toBe(true);
  88. });
  89. it('resolves a property typed by an interface to the interface method', async () => {
  90. write('GreeterInterface.php', `<?php\ninterface GreeterInterface { public function hello(); }\n`);
  91. write('App.php', `<?php
  92. class App {
  93. public function __construct(private GreeterInterface $g) {}
  94. public function run() { return $this->g->hello(); }
  95. }
  96. `);
  97. const calls = await load();
  98. expect(hasCall(calls, 'run', 'GreeterInterface::hello')).toBe(true);
  99. });
  100. it('resolves an inherited method through the conformance pass (property typed by the subclass)', async () => {
  101. // `baseMethod` is declared only on Base; the property is typed `Sub`.
  102. // The `Sub extends Base` edge is what lets the deferred conformance walk
  103. // find the method on the supertype — the whole point of deferring this ref.
  104. write('Base.php', `<?php\nclass Base { public function baseMethod() { return 1; } }\n`);
  105. write('Sub.php', `<?php\nclass Sub extends Base { public function other() { return 2; } }\n`);
  106. write('App.php', `<?php
  107. class App {
  108. public function __construct(private Sub $s) {}
  109. public function run() { return $this->s->baseMethod(); }
  110. }
  111. `);
  112. const calls = await load();
  113. expect(hasCall(calls, 'run', 'Base::baseMethod')).toBe(true);
  114. });
  115. it('disambiguates by declared type when two classes share a method name (negative)', async () => {
  116. // Both classes declare `greet`; the property is typed `Greeter`. A
  117. // name-similarity fallback would happily link either — this shape must
  118. // route to the RIGHT class and ONLY it.
  119. write('Greeter.php', greeter);
  120. write('OtherGreeter.php', `<?php\nclass OtherGreeter { public function greet() { return 2; } }\n`);
  121. write('App.php', `<?php
  122. class App {
  123. public function __construct(private Greeter $greeter) {}
  124. public function run() { return $this->greeter->greet(); }
  125. }
  126. `);
  127. const calls = await load();
  128. expect(hasCall(calls, 'run', 'Greeter::greet')).toBe(true);
  129. expect(hasCall(calls, 'run', 'OtherGreeter::greet')).toBe(false);
  130. // Exactly one method edge from `run` — no double-linking.
  131. expect(calls.filter((e) => e.src === 'run')).toHaveLength(1);
  132. });
  133. it('creates no edge for an untyped property with only a docblock type (negative)', async () => {
  134. // `@var Greeter` is a comment, not a declared type. Guessing from a
  135. // docblock is out of scope — the property stays unlinked.
  136. write('Greeter.php', greeter);
  137. write('App.php', `<?php
  138. class App {
  139. /** @var Greeter */
  140. private $greeter;
  141. public function run() { return $this->greeter->greet(); }
  142. }
  143. `);
  144. const calls = await load();
  145. expect(callsMethodNamed(calls, 'run', 'greet')).toBe(false);
  146. });
  147. it('creates no edge for a deep property chain `$this->a->b->method()` (negative)', async () => {
  148. // The single-property pattern deliberately does not match a two-hop chain;
  149. // the intermediate type is unknown, so nothing is guessed.
  150. write('Greeter.php', greeter);
  151. write('App.php', `<?php
  152. class App {
  153. public function __construct(private Wrapper $a) {}
  154. public function run() { return $this->a->b->greet(); }
  155. }
  156. `);
  157. const calls = await load();
  158. expect(callsMethodNamed(calls, 'run', 'greet')).toBe(false);
  159. });
  160. it('a local variable shadowing a property routes to the local\'s type, not the property (#1108 regression)', async () => {
  161. // `$greeter->greet()` has receiver `greeter` (no `this->`), so it takes the
  162. // existing #1108 local-variable path, not the new property path. The local
  163. // `new OtherGreeter()` must win by nearest-declaration-backward even though
  164. // a property `$greeter` typed `Greeter` exists — the property change must
  165. // not hijack a plain-variable receiver.
  166. write('Greeter.php', greeter);
  167. write('OtherGreeter.php', `<?php\nclass OtherGreeter { public function greet() { return 2; } }\n`);
  168. write('App.php', `<?php
  169. class App {
  170. public function __construct(private Greeter $greeter) {}
  171. public function run() { $greeter = new OtherGreeter(); return $greeter->greet(); }
  172. }
  173. `);
  174. const calls = await load();
  175. expect(hasCall(calls, 'run', 'OtherGreeter::greet')).toBe(true);
  176. expect(hasCall(calls, 'run', 'Greeter::greet')).toBe(false);
  177. });
  178. it('a same-named local in ANOTHER method never types the property (interference)', async () => {
  179. // In PHP `$greeter` (a local) and `$this->greeter` (the property) are
  180. // different namespaces — unlike CFML's scopes, no shadowing is possible.
  181. // The nearest declaration walking backward from run()'s call is helper()'s
  182. // `$greeter = new OtherGreeter()`; the property's promoted type `Greeter`
  183. // must still win.
  184. write('Greeter.php', greeter);
  185. write('OtherGreeter.php', `<?php\nclass OtherGreeter { public function greet() { return 2; } }\n`);
  186. write('App.php', `<?php
  187. class App {
  188. public function __construct(private Greeter $greeter) {}
  189. public function helper() { $greeter = new OtherGreeter(); return $greeter->greet(); }
  190. public function run() { return $this->greeter->greet(); }
  191. }
  192. `);
  193. const calls = await load();
  194. expect(hasCall(calls, 'run', 'Greeter::greet')).toBe(true);
  195. expect(hasCall(calls, 'run', 'OtherGreeter::greet')).toBe(false);
  196. // helper()'s own local-receiver call still routes to the local's type.
  197. expect(hasCall(calls, 'helper', 'OtherGreeter::greet')).toBe(true);
  198. });
  199. it('a same-named local in the SAME method never types the property (interference)', async () => {
  200. write('Greeter.php', greeter);
  201. write('OtherGreeter.php', `<?php\nclass OtherGreeter { public function greet() { return 2; } }\n`);
  202. write('App.php', `<?php
  203. class App {
  204. public function __construct(private Greeter $greeter) {}
  205. public function run() {
  206. $greeter = new OtherGreeter();
  207. $greeter->greet();
  208. return $this->greeter->greet();
  209. }
  210. }
  211. `);
  212. const calls = await load();
  213. // Both calls resolve, each to its own receiver's type.
  214. expect(hasCall(calls, 'run', 'OtherGreeter::greet')).toBe(true);
  215. expect(hasCall(calls, 'run', 'Greeter::greet')).toBe(true);
  216. });
  217. it('a same-named parameter of an unrelated method never types the property (interference)', async () => {
  218. write('Greeter.php', greeter);
  219. write('OtherGreeter.php', `<?php\nclass OtherGreeter { public function greet() { return 2; } }\n`);
  220. write('App.php', `<?php
  221. class App {
  222. public function __construct(private Greeter $greeter) {}
  223. public function accept(OtherGreeter $greeter) { return $greeter->greet(); }
  224. public function run() { return $this->greeter->greet(); }
  225. }
  226. `);
  227. const calls = await load();
  228. expect(hasCall(calls, 'run', 'Greeter::greet')).toBe(true);
  229. expect(hasCall(calls, 'run', 'OtherGreeter::greet')).toBe(false);
  230. expect(hasCall(calls, 'accept', 'OtherGreeter::greet')).toBe(true);
  231. });
  232. it('resolves a classic UNTYPED property through its constructor assignment (multi-line signature)', async () => {
  233. // Pre-7.4 style: the property declaration carries no type; the type lives
  234. // on the constructor parameter, here across a multi-line signature. The
  235. // resolver follows `$this->greeter = $greeter` to the parameter's type.
  236. write('Greeter.php', greeter);
  237. write('App.php', `<?php
  238. class App {
  239. private $greeter;
  240. public function __construct(
  241. Greeter $greeter,
  242. $other
  243. ) {
  244. $this->greeter = $greeter;
  245. }
  246. public function run() { return $this->greeter->greet(); }
  247. }
  248. `);
  249. const calls = await load();
  250. expect(hasCall(calls, 'run', 'Greeter::greet')).toBe(true);
  251. });
  252. it('resolves a setter-injected untyped property through the setter parameter', async () => {
  253. write('Greeter.php', greeter);
  254. write('App.php', `<?php
  255. class App {
  256. private $greeter;
  257. public function setGreeter(Greeter $greeter) { $this->greeter = $greeter; }
  258. public function run() { return $this->greeter->greet(); }
  259. }
  260. `);
  261. const calls = await load();
  262. expect(hasCall(calls, 'run', 'Greeter::greet')).toBe(true);
  263. });
  264. it('the assignment-following fallback stays inside the assigning function (interference)', async () => {
  265. // The untyped property is assigned from an UNTYPED constructor parameter,
  266. // and a same-named typed variable exists in the method directly above the
  267. // constructor. The backward scan from the assignment must stop at the
  268. // constructor's own `function` line — no type is recoverable, no edge.
  269. write('Greeter.php', greeter);
  270. write('OtherGreeter.php', `<?php\nclass OtherGreeter { public function greet() { return 2; } }\n`);
  271. write('App.php', `<?php
  272. class App {
  273. private $greeter;
  274. public function helper() { $greeter = new OtherGreeter(); return $greeter->greet(); }
  275. public function __construct($greeter) {
  276. $this->greeter = $greeter;
  277. }
  278. public function run() { return $this->greeter->greet(); }
  279. }
  280. `);
  281. const calls = await load();
  282. expect(callsMethodNamed(calls, 'run', 'greet')).toBe(false);
  283. });
  284. // Unit-level check of the confidence the integration DB does not expose:
  285. // the property-receiver shape resolves through resolveMethodOnType at 0.9.
  286. it('matchMethodCall resolves `this->prop.method` at confidence 0.9', () => {
  287. const node = (id: string, name: string, qn: string, kind: Node['kind'], file: string): Node => ({
  288. id, kind, name, qualifiedName: qn, filePath: file, language: 'php',
  289. startLine: 1, endLine: 1, startColumn: 0, endColumn: 0, updatedAt: 0,
  290. });
  291. const byName: Record<string, Node[]> = {
  292. Greeter: [node('c:greeter', 'Greeter', 'Greeter', 'class', 'Greeter.php')],
  293. greet: [node('m:greet', 'greet', 'Greeter::greet', 'method', 'Greeter.php')],
  294. };
  295. const lines = [
  296. '<?php',
  297. 'class App {',
  298. ' public function __construct(private readonly Greeter $greeter) {}',
  299. ' public function run() { return $this->greeter->greet(); }',
  300. '}',
  301. ];
  302. const ctx: ResolutionContext = {
  303. getNodesInFile: () => [],
  304. getNodesByName: (name) => byName[name] ?? [],
  305. getNodesByQualifiedName: () => [],
  306. getNodesByKind: () => [],
  307. fileExists: () => false,
  308. readFile: () => null,
  309. getFileLines: () => lines,
  310. getProjectRoot: () => '',
  311. getAllFiles: () => [],
  312. getImportMappings: () => [],
  313. };
  314. const ref: UnresolvedRef = {
  315. fromNodeId: 'caller', referenceName: 'this->greeter.greet', referenceKind: 'calls',
  316. line: 4, column: 0, filePath: 'App.php', language: 'php',
  317. };
  318. const res = matchMethodCall(ref, ctx);
  319. expect(res?.targetNodeId).toBe('m:greet');
  320. expect(res?.confidence).toBe(0.9);
  321. expect(res?.resolvedBy).toBe('instance-method');
  322. });
  323. });