Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -418,7 +418,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- In React with TypeScript, a component that renders a generic component and passes it type arguments, like outline's `<PaginatedList<Document> items={…} />` or excalidraw's `<DropdownMenuItemContentRadio<"contain" | "overlap"> …>`, now links to the component it renders. Before, a tag followed by type arguments was skipped, so the generic component looked unused by the screens that render it, and `codegraph_explore`'s call paths, callers and impact stopped short of it. Re-index React projects after upgrading.
- In React, a component that a file declares itself as a value is now what that file's JSX renders, whatever else in the repository shares its name. A lazily loaded page, like `const RegisterPage = Loadable(lazy(() => import('./pages/Register')))`, a `dynamic(() => import(…))` component or one loaded only in development (`IS_DEV ? lazy(…) : null`), links to the component its module exports, including the one its loader picks and one an `export default observer(Login)` wraps; a wrapped component like `const Settings = observer(function Settings() {…})`, `React.memo(Rows, areEqual)` or `forwardRef(ComboboxInner) as {…}` links to the function it wraps; and an alias like `const DialogPortal = DialogPrimitive.Portal` links to its own declaration. Before, `<RegisterPage />` was linked to any lone component of that name elsewhere, often in another app of the same repository, so `codegraph_explore`, callers and impact followed it into the wrong app while the page it really renders had no caller. React Router layouts declared this way follow the same rule, and a TSX type named like a value the file declares now stays in that file. Re-index React projects after upgrading.
- In React, a JSX tag imported from one of your own modules now links to the component that module exports under that name, read the way a lazily loaded page's module already is. That includes going through an `index` file or barrel that passes the component on, like outline's `Sidebar/index.ts` with `import Sidebar from './App'; export default Sidebar` or its `Avatar/index.ts` with `const Avatar = AvatarWithHoverCard`, and past a wrapper such as `export default observer(AppSidebar)`, `React.memo(StaticCanvas, areEqual)` or mastodon's `const MemoizedBlurhash = memo(Blurhash)`. Before, a tag was matched by its name and the import only broke ties, so outline's `<Sidebar>` linked to an unrelated styled `Sidebar` in a sibling file, a default import under another name linked to whatever other component its module exported, and an import through a barrel could land on another app's component of the same name. A tag whose module exports an unnamed component, like `export default () => …`, now links to nothing instead of to a namesake elsewhere. A lazily loaded React Router page exported as such a value or wrapper links to the component it holds too, the one with the markup. `codegraph sync` and the file watcher redraw these links when only the file that passes the component on changes. Re-index React projects after upgrading.
- Python calls through an attribute now reach the method your code gives that attribute. `self.sink.stop()` follows `self.sink = Sink()`, a `sink: Sink` annotation, or a typed `__init__` parameter to `Sink`'s `stop`, including a `stop` that `Sink` inherits, and an attribute of a typed local or parameter resolves the same way. An attribute holding a list, a dict or a class from another package, like `requests.Session()`, no longer links its calls to a same-named method of your own, and one whose type the code never names is matched by the attribute's own name, like `self.client` to a `Client` class, but no longer by the method's name alone. Re-index Python projects after upgrading. Thanks @inth3shadows. (#1704)
- Python calls through an attribute now reach the method your code gives that attribute. `self.sink.stop()` follows `self.sink = Sink()`, a `sink: Sink` annotation, or a typed `__init__` parameter to `Sink`'s `stop`, including a `stop` that `Sink` inherits, and an attribute of a typed local or parameter resolves the same way. An attribute holding a list, a dict or a class from another package, like `requests.Session()`, no longer links its calls to a same-named method of your own, and one whose type the code never names is matched by the attribute's own name, like `self.client` to a `Client` class, but no longer by the method's name alone. A call on an element of a collection, like `stats[i].write_timeline()`, links to your one method of that name when the file's own module or one it imports has it. Re-index Python projects after upgrading. Thanks @inth3shadows. (#1704)
- Python attributes set by a factory or a base class now lead to the right method. `self.client.send()` resolves when the class gets `self.client` from `Client.from_env()` or `make_client()` — read from the factory's return type, or from what every `return` in it builds — and when a base class's `__init__` in another file sets it. Calls straight on a factory's result, like `Client.from_env().send()` or `Truncator(value).chars(n)`, resolve the same way. A factory whose returns build different classes, a generator, and a name rebound inside the function give no type, and an attribute only a base class from another package sets, like `self.client` in a Django app's tests, no longer links to a method of your own. `codegraph sync` re-checks these calls when you edit the factory or base class they depend on; on large projects, an edit to a module much of the project imports can leave a call that only now becomes resolvable for the next full index. Re-index to pick up the change. Thanks @inth3shadows. (#1704, #750)
- A call on a variable or attribute named after a class, like `client` or `nodelist`, now prefers the class it spells whole, `Client` or `NodeList`, over another that only shares a word with it, like `RedisCacheClient`.
- A call through an attribute of an imported value, like flask's `app.jinja_env.filters.keys()`, no longer links to the value's own `jinja_env`: the method called is `keys`. Re-index to update an existing project.
Expand Down
155 changes: 155 additions & 0 deletions __tests__/python-subscript-receivers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
/**
* A Python call on an element of a collection, like `stats[i].write_timeline()`,
* links to the project's one method of the name, in a module the file has in
* hand, unless a list or a dict has that method too. The receiver's text kept
* the subscript, so no strategy could read it, and these calls linked to nothing.
*
* The collection's name says nothing of the element's class: netbox's
* `context['object'].get_absolute_url()` is no `ConfigContext`.
*/
import { describe, it, expect, afterEach } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import CodeGraph from '../src/index';

describe('a Python call on an element of a collection', () => {
let root = '';
let cg: CodeGraph | undefined;

afterEach(() => {
cg?.destroy();
cg = undefined;
if (root) fs.rmSync(root, { recursive: true, force: true });
root = '';
});

/** Index one module and return the qualified names a function's call lines reach, by line. */
async function calls(source: string, fn: string): Promise<Array<[number, string]>> {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-py-subscript-'));
fs.writeFileSync(path.join(root, 'a.py'), source);
const graph = (cg = await CodeGraph.init(root, { index: true }));
const node = graph.getNodesByName(fn).find((n) => n.kind === 'function' || n.kind === 'method')!;
return graph
.getOutgoingEdges(node.id)
.filter((e) => e.kind === 'calls')
.map((e): [number, string] => [e.line ?? 0, graph.getNode(e.target)!.qualifiedName])
.sort((a, b) => a[0] - b[0]);
}

const classes = [
'class Log:',
' def get_message(self):',
' return 1',
' def pop(self):',
' return 1',
' def search(self):',
' return 1',
'',
'class Timeline:',
' def replay(self):',
' return 1',
'',
'class Other:',
' def replay(self):',
' return 1',
'',
];
const at = (offset: number): number => classes.length + 1 + offset;

it('links the one method of the name, through any subscript', async () => {
const reached = await calls([
...classes,
'def f(trigger, stats, key):',
' trigger["a"][0].get_message()',
' stats[key].get_message()',
' stats[key["k"]].get_message()',
' stats["a]b"].get_message()',
'',
].join('\n'), 'f');
expect(reached).toEqual([
[at(1), 'Log::get_message'],
[at(2), 'Log::get_message'],
[at(3), 'Log::get_message'],
[at(4), 'Log::get_message'],
]);
});

it('never takes a method a list or a dict has, or one of several', async () => {
const reached = await calls([
...classes,
'def f(trigger):',
' trigger["a"].pop(1)',
' trigger["a"].search()',
' trigger["a"].replay()',
'',
].join('\n'), 'f');
expect(reached).toEqual([]);
});

it('stops at an attribute after the subscript: nothing says what it holds', async () => {
// django's `connections[db].settings_dict.get(…)` is a dict's, whatever its name says.
const reached = await calls([
...classes,
'def f(caches):',
' caches["opt"].timeline.replay()',
' caches["opt"].anything.get_message()',
'',
].join('\n'), 'f');
expect(reached).toEqual([]);
});

it('never goes by the name of the collection, or by a dunder a container answers', async () => {
const reached = await calls([
'class ConfigContext:',
' def url(self):',
' return 1',
'',
'class Other:',
' def url(self):',
' return 1',
'',
'class Registry:',
' def __setitem__(self, k, v):',
' pass',
'',
'def f(context, registry, a):',
' context["object"].url()',
' registry["a"].__setitem__(1, a)',
'',
].join('\n'), 'f');
expect(reached).toEqual([]);
});

it('reads an element of what a call returns the same way', async () => {
const reached = await calls([
...classes,
'def g():',
' return []',
'def f(x):',
' g()[0].get_message()',
'',
].join('\n'), 'f');
expect(reached.map(([, name]) => name).sort()).toEqual(['Log::get_message', 'g']);
});

it('wants the method in a module the file has in hand', async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-py-subscript-'));
const files: Record<string, string> = {
'lookups.py': 'class CollatedMixin:\n def process_rhs(self):\n return 1\n',
'models.py': 'class Log:\n def get_message(self):\n return 1\n',
'use.py': [
'from models import Log',
'def f(children, logs):',
' children[0].process_rhs()',
' logs[0].get_message()',
'',
].join('\n'),
};
for (const [rel, content] of Object.entries(files)) fs.writeFileSync(path.join(root, rel), content);
const graph = (cg = await CodeGraph.init(root, { index: true }));
const f = graph.getNodesByName('f').find((n) => n.kind === 'function')!;
expect(graph.getOutgoingEdges(f.id).filter((e) => e.kind === 'calls').map((e) => graph.getNode(e.target)!.qualifiedName))
.toEqual(['Log::get_message']);
});
});
23 changes: 23 additions & 0 deletions src/resolution/name-matcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23419,13 +23419,36 @@ function inferPhpAssignedPropertyType(
/** Python attribute-chain refs being guessed by name after the type reader found nothing. */
const PY_CHAIN_GUESSING = new WeakSet<UnresolvedRef>();

/**
* A Python call on an element of a collection, like `stats[i].write_timeline()`.
* The collection's name says nothing of the element's class (netbox's
* `context['object']` is no `ConfigContext`), so only the project's one method
* of the name will do, in a module the file has in hand: its own, or one it
* imports. netbox's `children[0].process_rhs()` is a Django lookup's, not the
* mixin's in a module its file never names. Nor a name a list or a dict has,
* or a dunder a container answers itself.
*/
function matchPythonElementCall(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
const name = ref.referenceName.slice(ref.referenceName.lastIndexOf('.') + 1);
if (!/^\w+$/.test(name) || /^__\w+__$/.test(name) || isStdMethodName('python', name)) return null;
const methods = context.getNodesByName(name).filter((n) => n.kind === 'method' && n.language === 'python');
const method = methods.length === 1 ? methods[0]! : null;
if (!method || isUnnamedTestDouble(method, '', ref, context)) return null;
const inHand = method.filePath === ref.filePath || context.getImportMappings(ref.filePath, 'python')
.some((m) => context.resolveModuleFile?.(m.source, ref.filePath, 'python') === method.filePath);
return inHand ? { original: ref, targetNodeId: method.id, confidence: 0.7, resolvedBy: 'instance-method', byName: true } : null;
}

/**
* Try to resolve by method name on a class/object
*/
export function matchMethodCall(
ref: UnresolvedRef,
context: ResolutionContext
): ResolvedRef | null {
if (ref.language === 'python' && ref.referenceKind === 'calls' && /\]\.\w+$/.test(ref.referenceName)) {
return matchPythonElementCall(ref, context);
}
// Parse method call patterns like "obj.method" or "Class::method". The method
// part allows trailing `:` keywords so Objective-C selectors resolve
// (`SDImageCache.storeImage:`, `obj.setX:y:`); colons never appear in other
Expand Down