Kaynağa Gözat

fix(resolution): read a quoted Python annotation as a receiver type (#1684) (#1770)

`def f(o: "Alpha")` is the same annotation as `def f(o: Alpha)` — a
forward reference, and what every file under `from __future__ import
annotations` writes — but the receiver-type pattern stopped at the quote,
read no type, and `o.render()` produced no edge. Admit the quoted form.

Co-authored-by: Colby McHenry <colbymchenry@users.noreply.github.com>
Colby Mchenry 6 saat önce
ebeveyn
işleme
3adf06772b

+ 1 - 0
CHANGELOG.md

@@ -213,6 +213,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 
 #### Symbols, tests and the viewer
 #### Symbols, tests and the viewer
 
 
+- Python parameters annotated with a quoted forward reference — `def f(o: "Alpha")`, or anything under `from __future__ import annotations` — now resolve the methods called on them, the same as the unquoted annotation. Re-index Python projects after upgrading. (#1684)
 - **A C macro call written with designated initializers no longer swallows every function after it.** Betaflight resets each config struct with `RESET_CONFIG(type, dst, .field = value, …)`, a shape the C grammar cannot parse; past a hundred or so fields its error recovery ran the enclosing function to the end of the file, the next function vanished from the index and every later one was filed under the first, where name matching then treated it as an unreachable closure. The argument list of such a call is now blanked before parsing, offsets kept, so the file's functions come out with their real extents. On that tree 45 functions in `pid.c` alone moved back to top level and their 117 callers resolve at exact-match confidence. Re-index after upgrading. (#1729)
 - **A C macro call written with designated initializers no longer swallows every function after it.** Betaflight resets each config struct with `RESET_CONFIG(type, dst, .field = value, …)`, a shape the C grammar cannot parse; past a hundred or so fields its error recovery ran the enclosing function to the end of the file, the next function vanished from the index and every later one was filed under the first, where name matching then treated it as an unreachable closure. The argument list of such a call is now blanked before parsing, offsets kept, so the file's functions come out with their real extents. On that tree 45 functions in `pid.c` alone moved back to top level and their 117 callers resolve at exact-match confidence. Re-index after upgrading. (#1729)
 - A method called on the result of another call — `d.setdefault(k, []).append(v)`, `make().run()` — no longer produces a call edge to an unrelated top-level function that merely shares the name, in Python and JavaScript/TypeScript. The receiver is kept so the inner call still resolves; the outer method stays unresolved rather than guessed. Re-index after upgrading. (#1683, #1681)
 - A method called on the result of another call — `d.setdefault(k, []).append(v)`, `make().run()` — no longer produces a call edge to an unrelated top-level function that merely shares the name, in Python and JavaScript/TypeScript. The receiver is kept so the inner call still resolves; the outer method stays unresolved rather than guessed. Re-index after upgrading. (#1683, #1681)
 - A Python call through an imported project module whose name collides with a builtin collection method — `ledger.append(row)` after `from . import ledger` — is no longer dropped as `list.append`. The builtin-method filter now lets the receiver through when it is an imported module that resolves to a file in the project, so `resolveViaImport` can attach the real edge; a stdlib/PyPI receiver (`os.remove`) still produces none. Re-index after upgrading. (#1681, via #1704)
 - A Python call through an imported project module whose name collides with a builtin collection method — `ledger.append(row)` after `from . import ledger` — is no longer dropped as `list.append`. The builtin-method filter now lets the receiver through when it is an imported module that resolves to a file in the project, so `resolveViaImport` can attach the real edge; a stdlib/PyPI receiver (`os.remove`) still produces none. Re-index after upgrading. (#1681, via #1704)

+ 55 - 0
__tests__/python-quoted-annotation.test.ts

@@ -0,0 +1,55 @@
+/**
+ * A quoted (forward-reference) parameter annotation names a receiver type too
+ * (#1684): `def f(o: "Alpha")` resolves `o.render()` exactly like `def f(o:
+ * Alpha)`. Quoted annotations are ordinary Python — forward references, and
+ * everything under `from __future__ import annotations`.
+ */
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import { CodeGraph } from '../src';
+import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
+
+let dir: string;
+let cg: CodeGraph;
+
+beforeAll(async () => {
+  await initGrammars();
+  await loadAllGrammars();
+  dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1684-'));
+  fs.mkdirSync(path.join(dir, 'pkg'));
+  fs.writeFileSync(path.join(dir, 'pkg', '__init__.py'), '');
+  fs.writeFileSync(
+    path.join(dir, 'pkg', 'a.py'),
+    'def render(x):\n    return x\n\nclass Alpha:\n    def render(self):\n        return "a"\n\nclass Beta:\n    def render(self):\n        return "b"\n'
+  );
+  fs.writeFileSync(
+    path.join(dir, 'pkg', 'b.py'),
+    'from __future__ import annotations\nfrom pkg.a import Alpha, Beta\n\n' +
+      'def quoted(o: "Alpha"):\n    return o.render()\n\n' +
+      "def single_quoted(o: 'Beta'):\n    return o.render()\n\n" +
+      'def unquoted(o: Alpha):\n    return o.render()\n'
+  );
+  cg = CodeGraph.initSync(dir);
+  await cg.indexAll();
+});
+
+afterAll(() => {
+  cg.destroy();
+  fs.rmSync(dir, { recursive: true, force: true });
+});
+
+const calleeOf = (fn: string): string[] =>
+  cg
+    .getCallees(cg.getNodesByName(fn).find((n) => n.kind === 'function')!.id)
+    .map(({ node }) => node.qualifiedName)
+    .sort();
+
+describe('quoted forward-reference annotations (#1684)', () => {
+  it('resolves the method on the quoted type, the same as the unquoted annotation', () => {
+    expect(calleeOf('unquoted')).toEqual(['Alpha::render']);
+    expect(calleeOf('quoted')).toEqual(['Alpha::render']);
+    expect(calleeOf('single_quoted')).toEqual(['Beta::render']);
+  });
+});

+ 6 - 0
src/resolution/name-matcher.ts

@@ -1697,6 +1697,12 @@ function buildLocalReceiverTypePatterns(language: Language, r: string): RegExp[]
     case 'python':
     case 'python':
       return [
       return [
         new RegExp(`\\b${r}\\b\\s*=\\s*([A-Z][\\w.]*)\\s*\\(`), // lg = Logger(...)
         new RegExp(`\\b${r}\\b\\s*=\\s*([A-Z][\\w.]*)\\s*\\(`), // lg = Logger(...)
+        // A quoted forward reference (`lg: "Logger"`, `lg: 'pkg.Logger'`) is the
+        // same annotation — and what every file under `from __future__ import
+        // annotations` or with a not-yet-defined class writes. The unquoted
+        // pattern below stopped at the quote and read no type at all, so the
+        // call produced no edge (#1684). Tried first: it is the stricter shape.
+        new RegExp(`\\b${r}\\b\\s*:\\s*["']([A-Z][\\w.]*)["']`), // lg: "Logger"
         new RegExp(`\\b${r}\\b\\s*:\\s*([A-Z][\\w.]*)`), // lg: Logger  (PEP 526)
         new RegExp(`\\b${r}\\b\\s*:\\s*([A-Z][\\w.]*)`), // lg: Logger  (PEP 526)
       ];
       ];
     case 'java':
     case 'java':