mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-11-14 19:41:47 +08:00
PR #3109 stopped a base imported from a package linking to a project class of its name, but only for the extends edge. The reader behind class-name calls follows the base list as written: declaredSupertypes gives the names and findInheritedClassMethod looks each up by name. So with class Color(Enum) after from enum import Enum, Color.name_of() still reached a project Enum.name_of, as did color.name_of() on a receiver named after the class. On sqlalchemy, Query(..., Generic[_T]) with Generic from typing walked to a test's local class Generic, then to that test's Base and on to FunctionElement, so query.select() linked to FunctionElement.select. The walk now skips a project class whose name the head binds outside the project, with the check PR #3109 added, moved to name-matcher.ts so both can call it (the import resolver already imports from there, not the reverse). A dotted base is bound through its first word: enum.Enum through import enum, e.Enum through import enum as e, collections.abc.Mapping through import collections.abc, which the check now counts as binding collections. Validation: - python-outside-bases.test.ts gains the calls: named import, receiver, dotted, aliased and deep bases reach nothing, and a project base and a class in a file the import path names still reach their methods. Without the gate 2 of the 4 new tests fail, and taking out the dotted word or the import a.b binding fails the dotted one. 11 pass here. - A/B on 11 Python repos plus express and gin, 496,746 edges: 10 calls removed, 0 added, 0 moved, 0 extends touched. All 10 are in sqlalchemy and all were wrong: 6 query.select(), query.alias() and query.compile() and 4 value.self_group(), each through typing's Generic into a test's own class Generic and its Base. - Controls express and gin unchanged. - Index cost on sqlalchemy, median of 3 interleaved: 27.8 s on main, 26.4 s here, which is noise. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
290 lines
6.5 KiB
TypeScript
290 lines
6.5 KiB
TypeScript
/**
|
|
* A Python base the file imports from outside the project is that package's
|
|
* class, plain or parameterized: `class Color(Enum)` after `from enum import
|
|
* Enum` extends no project class that happens to be named `Enum`. Names the
|
|
* file binds to the project, or doesn't bind at all, keep linking.
|
|
*/
|
|
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';
|
|
|
|
const FILES: Record<string, string> = {
|
|
'app/__init__.py': '',
|
|
'app/schema.py': `class Enum:
|
|
def name_of(self):
|
|
pass
|
|
|
|
|
|
class Sequence:
|
|
pass
|
|
|
|
|
|
class Generic:
|
|
pass
|
|
|
|
|
|
class Protocol:
|
|
pass
|
|
|
|
|
|
class BaseModel:
|
|
pass
|
|
|
|
|
|
class Thing:
|
|
pass
|
|
|
|
|
|
class Mapping:
|
|
def name_of(self):
|
|
pass
|
|
|
|
|
|
class Other:
|
|
def name_of(self):
|
|
pass
|
|
`,
|
|
'app/outside.py': `from enum import Enum
|
|
from typing import Generic, Protocol, Sequence, TypeVar
|
|
|
|
from pydantic import BaseModel
|
|
|
|
T = TypeVar("T")
|
|
|
|
|
|
class Color(Enum):
|
|
pass
|
|
|
|
|
|
class Plain(Sequence):
|
|
pass
|
|
|
|
|
|
class Items(Sequence[int]):
|
|
pass
|
|
|
|
|
|
class Box(Generic[T]):
|
|
pass
|
|
|
|
|
|
class Greeter(Protocol[T]):
|
|
pass
|
|
|
|
|
|
class Item(BaseModel):
|
|
pass
|
|
|
|
|
|
class Row(BaseModel, Enum):
|
|
pass
|
|
`,
|
|
'app/inside.py': `from app.schema import Sequence
|
|
|
|
from .schema import Enum
|
|
|
|
|
|
class Mine(Enum):
|
|
pass
|
|
|
|
|
|
class Seq(Sequence[int]):
|
|
pass
|
|
`,
|
|
'app/shadow.py': `from enum import Enum
|
|
|
|
|
|
class Enum(Enum):
|
|
pass
|
|
|
|
|
|
class Own(Enum):
|
|
pass
|
|
`,
|
|
'app/fallback.py': `try:
|
|
from pydantic import BaseModel
|
|
except ImportError:
|
|
from .schema import BaseModel
|
|
|
|
|
|
class Either(BaseModel):
|
|
pass
|
|
`,
|
|
'app/starred.py': `from .schema import *
|
|
|
|
|
|
class Starred(Enum):
|
|
pass
|
|
`,
|
|
'app/relative.py': `from .moved import Thing
|
|
|
|
|
|
class Keep(Thing):
|
|
pass
|
|
`,
|
|
'lib/support/helpers/base.py': `class Base:
|
|
def helper(self):
|
|
pass
|
|
|
|
|
|
class Unrelated:
|
|
def helper(self):
|
|
pass
|
|
`,
|
|
'tests/test_base.py': `from helpers.base import Base
|
|
|
|
|
|
class TestBase(Base):
|
|
pass
|
|
|
|
|
|
def call_helper():
|
|
TestBase.helper()
|
|
`,
|
|
'app/calls.py': `import collections.abc
|
|
import enum
|
|
import enum as e
|
|
from enum import Enum
|
|
|
|
from .inside import Mine
|
|
|
|
|
|
class Color(Enum):
|
|
pass
|
|
|
|
|
|
class Dotted(enum.Enum):
|
|
pass
|
|
|
|
|
|
class Aliased(e.Enum):
|
|
pass
|
|
|
|
|
|
class Deep(collections.abc.Mapping):
|
|
pass
|
|
|
|
|
|
def named_import():
|
|
Color.name_of()
|
|
|
|
|
|
def on_a_receiver(color):
|
|
color.name_of()
|
|
|
|
|
|
def dotted():
|
|
Dotted.name_of()
|
|
|
|
|
|
def aliased():
|
|
Aliased.name_of()
|
|
|
|
|
|
def deep():
|
|
Deep.name_of()
|
|
|
|
|
|
def from_the_project():
|
|
Mine.name_of()
|
|
`,
|
|
};
|
|
|
|
let root = '';
|
|
let cg: CodeGraph;
|
|
|
|
beforeAll(async () => {
|
|
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-py-outside-bases-'));
|
|
for (const [rel, content] of Object.entries(FILES)) {
|
|
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
|
|
fs.writeFileSync(path.join(root, rel), content);
|
|
}
|
|
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` in `file` calls. */
|
|
const calls = (file: string, fn: string): string[] => {
|
|
const node = cg.getNodesInFile(file).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);
|
|
};
|
|
|
|
/** `file:class` of what the class `name` in `file` extends. */
|
|
const bases = (file: string, name: string): string[] => {
|
|
const node = cg.getNodesInFile(file).find((n) => n.name === name && n.kind === 'class')!;
|
|
expect(node, name).toBeDefined();
|
|
return cg.getOutgoingEdges(node.id).filter((e) => e.kind === 'extends').map((e) => {
|
|
const target = cg.getNode(e.target)!;
|
|
return `${target.filePath}:${target.name}`;
|
|
});
|
|
};
|
|
|
|
describe('a Python base imported from outside the project', () => {
|
|
it('links no project class of that name, plain or parameterized', () => {
|
|
expect(bases('app/outside.py', 'Color')).toEqual([]);
|
|
expect(bases('app/outside.py', 'Plain')).toEqual([]);
|
|
expect(bases('app/outside.py', 'Items')).toEqual([]);
|
|
expect(bases('app/outside.py', 'Box')).toEqual([]);
|
|
expect(bases('app/outside.py', 'Greeter')).toEqual([]);
|
|
expect(bases('app/outside.py', 'Item')).toEqual([]);
|
|
expect(bases('app/outside.py', 'Row')).toEqual([]);
|
|
});
|
|
|
|
it('keeps a base the file imports from the project', () => {
|
|
expect(bases('app/inside.py', 'Mine')).toEqual(['app/schema.py:Enum']);
|
|
expect(bases('app/inside.py', 'Seq')).toEqual(['app/schema.py:Sequence']);
|
|
});
|
|
|
|
it('keeps a class the file declares itself under the imported name', () => {
|
|
expect(bases('app/shadow.py', 'Own')).toEqual(['app/shadow.py:Enum']);
|
|
});
|
|
|
|
it('keeps a base one of two imports of the name finds in the project', () => {
|
|
expect(bases('app/fallback.py', 'Either')).toEqual(['app/schema.py:BaseModel']);
|
|
});
|
|
|
|
it('keeps a base no named import binds', () => {
|
|
expect(bases('app/starred.py', 'Starred')).toEqual(['app/schema.py:Enum']);
|
|
});
|
|
|
|
it('keeps a base a relative import names, though that module is gone', () => {
|
|
expect(bases('app/relative.py', 'Keep')).toEqual(['app/schema.py:Thing']);
|
|
});
|
|
|
|
it('keeps a class in a file the import path names, though no search root finds it', () => {
|
|
expect(bases('tests/test_base.py', 'TestBase')).toEqual(['lib/support/helpers/base.py:Base']);
|
|
});
|
|
});
|
|
|
|
// The reader behind `Color.name_of()` follows the base list as written, not the
|
|
// `extends` edge. `Enum` and `Other` both declare `name_of`, so no lone
|
|
// candidate picks one.
|
|
describe('a call through a Python base imported from outside the project', () => {
|
|
it('reaches no method of a project class of that name', () => {
|
|
expect(calls('app/calls.py', 'named_import')).toEqual([]);
|
|
expect(calls('app/calls.py', 'on_a_receiver')).toEqual([]);
|
|
});
|
|
|
|
it('holds for a dotted base, through what its first word binds', () => {
|
|
expect(calls('app/calls.py', 'dotted')).toEqual([]);
|
|
expect(calls('app/calls.py', 'aliased')).toEqual([]);
|
|
expect(calls('app/calls.py', 'deep')).toEqual([]);
|
|
});
|
|
|
|
it('still reaches a method of a base the project declares', () => {
|
|
expect(calls('app/calls.py', 'from_the_project')).toEqual(['Enum::name_of']);
|
|
});
|
|
|
|
it('still reaches a method in a file the import path names', () => {
|
|
expect(calls('tests/test_base.py', 'call_helper')).toEqual(['Base::helper']);
|
|
});
|
|
});
|