mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-11-14 19:41:47 +08:00
A Python call through a name the file imports as a module, like other.update(...) after `from pkg import other`, linked to the module's function whatever the calling function bound `other` to: a parameter, a local, a with/except/loop target, a walrus, a case capture, a lambda or comprehension variable. resolveViaImport read the file's import mappings alone (its only scope check, pythonFunctionModuleAlias from PR #2600, covers a function-local `import a.b as X`), and isPythonProjectModule, which lets a builtin-named call like update/get/write past the builtin filter for a project module, asked the same file-wide question. The mappings also list from-imports at any depth ahead of plain imports, so a function-local `from pkg import third as other` became every function's `other`. pythonReceiverBinding reads how the code around a reference binds the name: the reference's own statement (lambda parameters, comprehension targets), then each enclosing function, innermost first, through its parameters and own statements (assignment, annotation, for, as, walrus, del, nested def/class, case captures, imports; global keeps the module's binding), memoized per function. pythonImportsInScope gives resolveViaImport's Python branch and isPythonProjectModule only the receiver's mappings that apply there. Validation: - __tests__/python-receiver-rebinding.test.ts, 80 tests (default and wasm, LF and CRLF): 48 fail on the base main (ten rebinding shapes, the local from-import, the cross-function leak), 32 pass (look-alikes: keyword argument, string, comment, comprehension over the module, lambda beside the call, global, keyword pattern, same-module local import); all pass with the fix. The 26 Python-related test files pass. - A/B, main vs fix: django's own package (930 files, kernel path) changes 2 edges' confidence (0.85 to 0.8, same targets: a closure over a parameter `apps` and a local `apps = model._meta.apps`); flask drops one cli.group() call and lowers 4 to 0.7 with the same targets (a nested `def cli` and a pytest fixture parameter `app` named like imports); requests, click, mealie and fastapi (package plus tests), and the express and gin controls are byte-identical. - django package index time, median of 3 interleaved: 11.3 s main, 10.7 s fix. Reimplements PR #2632 by BGamboa13. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
236 lines
7.2 KiB
TypeScript
236 lines
7.2 KiB
TypeScript
/**
|
|
* A Python call through a name the file imports as a module reads that module
|
|
* only where nothing nearer binds the name:
|
|
*
|
|
* from pkg import other
|
|
*
|
|
* def real():
|
|
* other.update({}) # pkg/other.py's update
|
|
*
|
|
* def by_param(other):
|
|
* other.update({}) # dict.update, or whatever the caller passed
|
|
*
|
|
* The import resolver read the file's imports alone, so a parameter, a local,
|
|
* a `with … as` / `except … as` / loop target, a walrus, a `case` capture or a
|
|
* lambda or comprehension variable named like the module still linked the
|
|
* call to the module's function. A function-local `from pkg import third as
|
|
* other` linked to `pkg.other` too, and so did, from every other function of
|
|
* the file, though it binds the name only where it is written.
|
|
*/
|
|
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/index';
|
|
|
|
const FILES: Record<string, string> = {
|
|
'pyproject.toml': '[project]\nname = "pkg"\n',
|
|
'src/pkg/__init__.py': '',
|
|
'src/pkg/other.py': [
|
|
'def update(x=None):',
|
|
' return x',
|
|
'',
|
|
'',
|
|
'def persist(x=None):',
|
|
' return x',
|
|
'',
|
|
'',
|
|
'def get(key=None):',
|
|
' return key',
|
|
'',
|
|
].join('\n'),
|
|
'src/pkg/third.py': ['def get(key=None):', ' return key', ''].join('\n'),
|
|
'tests/shadow.py': [
|
|
'from pkg import other',
|
|
'',
|
|
'',
|
|
'def real():',
|
|
' other.update({"b": 2})',
|
|
' return other.persist(1)',
|
|
'',
|
|
'',
|
|
'def by_param(other):',
|
|
' other.update({"a": 1})',
|
|
' return other.persist(2)',
|
|
'',
|
|
'',
|
|
'def by_with(path):',
|
|
' with open(path, "w") as other:',
|
|
' return other.persist(3)',
|
|
'',
|
|
'',
|
|
'def by_assign():',
|
|
' other = {}',
|
|
' other.update({"c": 3})',
|
|
'',
|
|
'',
|
|
'def by_for(items):',
|
|
' for other in items:',
|
|
' other.persist(4)',
|
|
'',
|
|
'',
|
|
'def by_except():',
|
|
' try:',
|
|
' pass',
|
|
' except OSError as other:',
|
|
' return other.persist(5)',
|
|
'',
|
|
'',
|
|
'def by_lambda():',
|
|
' return lambda other: other.persist(6)',
|
|
'',
|
|
'',
|
|
'def by_comprehension(items):',
|
|
' return [other.persist(7) for other in items]',
|
|
'',
|
|
'',
|
|
'def by_walrus(load):',
|
|
' if (other := load()):',
|
|
' return other.persist(8)',
|
|
'',
|
|
'',
|
|
'def by_closure(other):',
|
|
' def inner():',
|
|
' return other.persist(9)',
|
|
' return inner',
|
|
'',
|
|
'',
|
|
'def by_capture(v):',
|
|
' match v:',
|
|
' case other:',
|
|
' return other.persist(10)',
|
|
'',
|
|
'',
|
|
'def by_local_import():',
|
|
' from pkg import third as other',
|
|
' return other.get(11)',
|
|
'',
|
|
'',
|
|
// Each of these reads the module the file imports.
|
|
'def keyword_argument(run):',
|
|
' run(',
|
|
' other=1,',
|
|
' )',
|
|
' return other.persist(12)',
|
|
'',
|
|
'',
|
|
'def in_a_string():',
|
|
' print("with x as other", "other = 1") # other = 2',
|
|
' return other.persist(13)',
|
|
'',
|
|
'',
|
|
'def comprehension_of_others(items):',
|
|
' return [x for x in other.persist(items)]',
|
|
'',
|
|
'',
|
|
'def lambda_beside(run):',
|
|
' return run(lambda other: 1, other.persist(14))',
|
|
'',
|
|
'',
|
|
'def declared_global():',
|
|
' global other',
|
|
' return other.persist(15)',
|
|
'',
|
|
'',
|
|
'def keyword_pattern(v):',
|
|
' match v:',
|
|
' case Point(other=1):',
|
|
' return other.persist(16)',
|
|
'',
|
|
'',
|
|
'def same_module_again():',
|
|
' from pkg import other',
|
|
' return other.get(17)',
|
|
'',
|
|
].join('\n'),
|
|
// The module binds `other` with a plain import, which the file's mappings
|
|
// list after every from-import, the function-local one included.
|
|
'tests/leak.py': [
|
|
'import pkg.other as other',
|
|
'',
|
|
'',
|
|
'def local_third():',
|
|
' from pkg import third as other',
|
|
' return other.get(1)',
|
|
'',
|
|
'',
|
|
'def untouched():',
|
|
' return other.get(2)',
|
|
'',
|
|
].join('\n'),
|
|
};
|
|
|
|
describe.each([
|
|
['default', 'LF'],
|
|
['wasm', 'LF'],
|
|
['default', 'CRLF'],
|
|
['wasm', 'CRLF'],
|
|
])('a Python receiver the function binds itself is not the module the file imports (%s, %s)', (backend, eol) => {
|
|
let root = '';
|
|
let cg: CodeGraph | undefined;
|
|
let kernel: string | undefined;
|
|
|
|
beforeAll(async () => {
|
|
kernel = process.env.CODEGRAPH_KERNEL;
|
|
if (backend === 'wasm') process.env.CODEGRAPH_KERNEL = '0';
|
|
else delete process.env.CODEGRAPH_KERNEL;
|
|
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-py-rebind-'));
|
|
for (const [rel, content] of Object.entries(FILES)) {
|
|
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
|
|
fs.writeFileSync(path.join(root, rel), eol === 'CRLF' ? content.replace(/\n/g, '\r\n') : content);
|
|
}
|
|
cg = await CodeGraph.init(root, { index: true });
|
|
}, 60_000);
|
|
|
|
afterAll(() => {
|
|
cg?.destroy();
|
|
if (root) fs.rmSync(root, { recursive: true, force: true });
|
|
if (kernel === undefined) delete process.env.CODEGRAPH_KERNEL;
|
|
else process.env.CODEGRAPH_KERNEL = kernel;
|
|
});
|
|
|
|
/** `file:name` of each project function `name` in `file` calls, sorted. */
|
|
const callsFrom = (file: string, name: string): string[] => {
|
|
const from = cg!.getNodesInFile(file).filter((n) => n.name === name && (n.kind === 'function' || n.kind === 'method'));
|
|
expect(from, `${name} in ${file}`).toHaveLength(1);
|
|
return cg!.getOutgoingEdgesFrom([from[0]!.id])
|
|
.filter((e) => e.kind === 'calls')
|
|
.map((e) => cg!.getNode(e.target)!)
|
|
.filter((t) => t.filePath.startsWith('src/'))
|
|
.map((t) => `${t.filePath}:${t.name}`)
|
|
.sort();
|
|
};
|
|
|
|
it('a call where nothing nearer binds the name reaches the module', () => {
|
|
expect(callsFrom('tests/shadow.py', 'real')).toEqual(['src/pkg/other.py:persist', 'src/pkg/other.py:update']);
|
|
});
|
|
|
|
it.each([
|
|
'by_param', 'by_with', 'by_assign', 'by_for', 'by_except', 'by_lambda',
|
|
'by_comprehension', 'by_walrus', 'inner', 'by_capture',
|
|
])('%s: a name the code around the call binds links no module function', (fn) => {
|
|
expect(callsFrom('tests/shadow.py', fn)).toEqual([]);
|
|
});
|
|
|
|
it('a function-local `from pkg import third as other` is that module', () => {
|
|
expect(callsFrom('tests/shadow.py', 'by_local_import')).toEqual(['src/pkg/third.py:get']);
|
|
expect(callsFrom('tests/leak.py', 'local_third')).toEqual(['src/pkg/third.py:get']);
|
|
});
|
|
|
|
it('a function-local import binds the name in that function only', () => {
|
|
expect(callsFrom('tests/leak.py', 'untouched')).toEqual(['src/pkg/other.py:get']);
|
|
});
|
|
|
|
it.each([
|
|
['keyword_argument', 'src/pkg/other.py:persist'],
|
|
['in_a_string', 'src/pkg/other.py:persist'],
|
|
['comprehension_of_others', 'src/pkg/other.py:persist'],
|
|
['lambda_beside', 'src/pkg/other.py:persist'],
|
|
['declared_global', 'src/pkg/other.py:persist'],
|
|
['keyword_pattern', 'src/pkg/other.py:persist'],
|
|
['same_module_again', 'src/pkg/other.py:get'],
|
|
])('%s binds nothing of that name: the call reaches the module', (fn, target) => {
|
|
expect(callsFrom('tests/shadow.py', fn)).toEqual([target]);
|
|
});
|
|
});
|