From 51f99d848b08007810d093b916840e483a68a9d0 Mon Sep 17 00:00:00 2001 From: Josh66 Date: Thu, 29 Oct 2026 09:03:30 +0000 Subject: [PATCH] fix(python): a base's type argument is no supertype, so Repo.save() is not User.save declaredSupertypes' Python branch took every word of the base list, so `class Repo(Base[User])` declared `Base` and `User`. A call on the class or on a receiver named after it (classStrategyMethod -> inheritedClassMethod), `Repo.save()` / `repo.save()`, then reached `User.save` at 0.85 / 0.8 when `Base` declares no `save`. Bracketed type arguments, nested too, are now dropped before the names are read. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018hJjBUPFiLSFBssshFwHzq --- CHANGELOG.md | 1 + .../python-supertype-type-arguments.test.ts | 110 ++++++++++++++++++ src/resolution/name-matcher.ts | 8 +- 3 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 __tests__/python-supertype-type-arguments.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f58218b4c..927e3adcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- In Python, a call on a class or on a receiver named after it, like `Repo.save()` or `repo.save()` with `class Repo(Base[User])`, no longer links to a method of the base's type argument, like `User.save`. Before, every name in the base list counted as a supertype, so `User` was searched as if `Repo` inherited from it. The base itself, `Base`, is still searched. Re-index Python projects after upgrading. - In TypeScript, an awaited call with type arguments like `await fetchApi("/me")` or `await api.get(url)` now links to the function it calls. Before, the call was dropped, so typed fetch wrappers looked like they had no callers. (#2586) - In JavaScript and TypeScript, a call or `new` in a parameter's default value, like `function renderWithNote(note = buildNote())`, a destructured `{ size = defaultSize() }` or `constructor(private svc = new Service())`, now counts as the function's own call. Before, it was dropped, so `buildNote` was missing that caller and impact on it came back incomplete. Re-index JavaScript and TypeScript projects after upgrading. Thanks @inth3shadows for the report and the pull request. (#2588) - In React, a component rendered at module scope, in a top-level constant or inside a test's `it(…)` callback, like `createRoot(el).render()` or `render()`, now counts as rendered there, so an app's entry reaches its root component and a component's tests show among its callers. (#2587) diff --git a/__tests__/python-supertype-type-arguments.test.ts b/__tests__/python-supertype-type-arguments.test.ts new file mode 100644 index 000000000..86b86d911 --- /dev/null +++ b/__tests__/python-supertype-type-arguments.test.ts @@ -0,0 +1,110 @@ +/** + * A Python class's supertypes, as its head declares them, are the classes its + * bases name — not their type arguments. `class Repo(Base[User])` extends + * `Base`; reading every word of the base list also counted `User`, so a call on + * the class or on a receiver named after it, `Repo.save()` / `repo.save()`, + * reached `User.save` when `Base` declares no `save`. + */ +import { describe, it, expect, afterAll, beforeAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; + +let root = ''; +let cg: CodeGraph; + +beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-py-supertype-type-args-')); + fs.mkdirSync(path.join(root, 'app')); + fs.writeFileSync(path.join(root, 'app/__init__.py'), ''); + fs.writeFileSync(path.join(root, 'app/m.py'), `from typing import Generic, TypeVar + +T = TypeVar("T") +U = TypeVar("U") + + +class Base(Generic[T]): + def load(self): + pass + + +class Pair(Generic[T, U]): + def load(self): + pass + + +class User: + def save(self): + pass + + +class Other: + def save(self): + pass + + +class Repo(Base[User]): + pass + + +class Store(Pair[str, dict[str, User]]): + pass + + +class Keyed(Base[User], metaclass=type): + pass + + +def save_by_class(): + Repo.save() + + +def save_by_receiver(repo): + repo.save() + + +def save_nested(): + Store.save() + + +def save_keyed(): + Keyed.save() + + +def load_by_class(): + Repo.load() + + +def load_nested(): + Store.load() +`); + cg = await CodeGraph.init(root, { index: true }); +}); + +afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); +}); + +/** Qualified names of the methods `fn` calls. */ +const calls = (fn: string): string[] => { + const node = cg.getNodesInFile('app/m.py').find((n) => n.name === fn && n.kind === 'function')!; + expect(node, fn).toBeDefined(); + return cg.getOutgoingEdges(node.id).filter((e) => e.kind === 'calls') + .map((e) => cg.getNode(e.target)!).filter((n) => n.kind === 'method').map((n) => n.qualifiedName); +}; + +describe('a Python type argument is no supertype', () => { + it('does not reach a method of the type argument', () => { + expect(calls('save_by_class')).not.toContain('User::save'); + expect(calls('save_by_receiver')).not.toContain('User::save'); + expect(calls('save_nested')).not.toContain('User::save'); + expect(calls('save_keyed')).not.toContain('User::save'); + }); + + it('still reaches the method the base declares', () => { + expect(calls('load_by_class')).toEqual(['Base::load']); + expect(calls('load_nested')).toEqual(['Pair::load']); + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 9debc1ec2..273b5a71d 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -19865,8 +19865,12 @@ function readDeclaredSupertypes(cls: Node, context: ResolutionContext): string[] // A base built by a class's factory — Django's `class Manager( // BaseManager.from_queryset(QuerySet))` — subclasses that class: the // qualifier is the supertype, not the method called on it. - case 'python': return names(/\bclass\s+\w+\s*\(([^)]*)\)/.exec(head)?.[1]?.replace(/\w+\s*=\s*[\w.]+/g, '') - .replace(/([A-Za-z_][\w.]*)\.\w+\s*\(/g, '$1 (')); + // A parameterized base, `Base[User]`, is `Base`: its type arguments are no supertypes. + case 'python': { + let bases = /\bclass\s+\w+\s*\(([^)]*)\)/.exec(head)?.[1]; + while (bases && /\[[^[\]]*\]/.test(bases)) bases = bases.replace(/\[[^[\]]*\]/g, ''); + return names(bases?.replace(/\w+\s*=\s*[\w.]+/g, '').replace(/([A-Za-z_][\w.]*)\.\w+\s*\(/g, '$1 (')); + } case 'ruby': return names(/\bclass\s+[\w:]+\s*<\s*([\w:]+)/.exec(head)?.[1]); case 'php': return names(/\bextends\s+([\w.\\]+)/.exec(head)?.[1]); case 'typescript': case 'tsx': case 'javascript': case 'jsx':