diff --git a/CHANGELOG.md b/CHANGELOG.md index 02ecfabd5..3072db16f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` items={…} />` or excalidraw's ` …>`, 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, `` 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 `` 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. diff --git a/__tests__/python-subscript-receivers.test.ts b/__tests__/python-subscript-receivers.test.ts new file mode 100644 index 000000000..8a4442eb8 --- /dev/null +++ b/__tests__/python-subscript-receivers.test.ts @@ -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> { + 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 = { + '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']); + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index be194e55e..f1f46ee39 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -23419,6 +23419,26 @@ function inferPhpAssignedPropertyType( /** Python attribute-chain refs being guessed by name after the type reader found nothing. */ const PY_CHAIN_GUESSING = new WeakSet(); +/** + * 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 */ @@ -23426,6 +23446,9 @@ 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