Files
codegraph/__tests__/kernel-python-call-fabrication.test.ts
T
Eric MinishandClaude Opus 5.5 642c7bb3a5 fix(python): attribute calls follow the type the class body, a factory or a base gives the attribute (#1926)
Python calls through an attribute reach the method the code gives that
attribute: `self.sink.stop()` follows `self.sink = Sink()`, a `sink: Sink`
annotation, a typed `__init__` parameter, a factory's return type or what
every `return` in it builds, and a base class's `__init__` in another file.
Calls straight on a factory's result (`Client.from_env().send()`) resolve
the same way, and a sync re-opens these answers when the factory or base
they were read from changes. The native Python extractor now keeps a call's
receiver text as the wasm one does, so `self.data.append(1)` is never a bare
`append` that matches an unrelated project function. This includes all of
PR 1704.

Reworked from evidence-or-silence, which dropped about 3,000 correct Django
links (`self.client.get()`, `self.engine.render_to_string()`). The reader
now has three answers. A project class that has the method: that method.
Evidence that rules project methods out: no edge, for a builtin, a class
from outside the project built in a method or named by an annotation, an
attribute only an outside base can give (Django's `TestCase` in an app), or
a class without the method whose bases are all known. Nothing typed: the
call is guessed by the name of its last link, read as written, as any
receiver is, but only for an owner whose name holds the link's head noun
(`self.session` for `SessionBase`, not `database.group_shopping_lists` for
`GroupRepositoryAIProvider`) and never as the one method of a name; the
guess records the files the reader looked at, so a sync re-opens it too. A
class-body field an outside class builds (`author = models.ForeignKey(Author)`)
may be a descriptor, so it is no evidence. A chain through an imported
module (`os.path.exists()`) is the module's. In the shared scoring, a
receiver that spells an owner's whole name (`client` for `Client`)
outscores one that only shares its word (`RedisCacheClient`). A call
through an imported value's attribute chain (`app.jinja_env.filters.keys()`)
no longer links to the value's `jinja_env`.

A/B against main with fresh indexes, every changed edge triaged, sites
matched by position since the extractor now names these calls by their
whole chain. Django: 3,317 calls linked that were not
(`self.client.force_login()` to `ClientMixin`, `User.objects.create_user()`
to `UserManager`, `self.stdout.write()` to `OutputWrapper`), 127 moved,
nearly all to a more precise target (`self.site.register()` to
`AdminSite.register` rather than the `register` decorator), and 134
removed: main's bare-name links on builtin values (`value.lower()` to the
template filter, `.encode()` to a test password hasher) and calls on
expressions such as `(q1 | q2).order_by()`, which now go unresolved as they
do in the wasm engine. flask: 32 linked, 4 moved to `JSONProvider`.
fastapi: 58 linked. mealie: 740 linked. Guesses still wrong, as main makes
them for a plain receiver of the same name: fastapi's
`request.headers.get()` to a telemetry `_HeadersGetter`, and about 20 of
mealie's repository attributes to a controller. Go, C#, Ruby, Swift,
Kotlin, Java and TS repositories are byte-identical, except excalidraw's
5 `props.onChange()` calls, which move between two unrelated `Props`
interfaces. Indexing CPU: Django +7%, mealie +5%, fastapi flat; wall time
unchanged within noise. Full suite on the merged tree: 7,999 passed, 0 failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 21:00:13 +00:00

99 lines
3.8 KiB
TypeScript

/**
* Python non-identifier call receivers must not fabricate edges — ON THE ARM
* THAT SHIPS.
*
* `resolution.test.ts` already covers this end-to-end, but python is in the
* kernel's DEFAULT_ROUTED set, so in a from-source checkout (no staged .node)
* that test exercises the *wasm* TreeSitterExtractor while every published
* bundle extracts python with codegraph-kernel/src/python.rs. A fix applied to
* only one arm is a green test over code that never runs.
*
* This suite forces the kernel arm and asserts the same two properties:
* - `ledger.append(row)`, where `ledger` is an imported project module that
* exports `append`, RESOLVES (the false-negative half, #66).
* - `self.data.append(...)` / `rows[k].append(...)` — attribute-chain and
* subscript receivers — do NOT fabricate an edge to that same `append`
* (the false-positive half; `python.rs` used to emit a BARE `append`,
* which exact-matched the only project-wide symbol of that name).
*
* Skips when no kernel binary is staged, like the parity suites.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src/index';
const KERNEL_PATH = path.join(
__dirname,
'..',
'codegraph-kernel',
'prebuilds',
`${process.platform}-${process.arch}`,
'codegraph-kernel.node'
);
const kernelBuilt = fs.existsSync(KERNEL_PATH);
describe.skipIf(!kernelBuilt)('python call-receiver fabrication (kernel arm)', () => {
let tempDir: string;
let cg: CodeGraph | undefined;
let savedLangs: string | undefined;
beforeEach(() => {
savedLangs = process.env.CODEGRAPH_KERNEL_LANGS;
process.env.CODEGRAPH_KERNEL_LANGS = 'python';
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-py-fab-'));
});
afterEach(() => {
cg?.close();
cg = undefined;
if (savedLangs === undefined) delete process.env.CODEGRAPH_KERNEL_LANGS;
else process.env.CODEGRAPH_KERNEL_LANGS = savedLangs;
fs.rmSync(tempDir, { recursive: true, force: true });
});
it('resolves a module-qualified call and fabricates none from chained receivers (#66)', async () => {
fs.writeFileSync(
path.join(tempDir, 'ledger.py'),
'def append(row):\n return True\n\n\ndef path():\n return "ledger.jsonl"\n'
);
fs.writeFileSync(
path.join(tempDir, 'record.py'),
'from . import ledger\n\n\ndef add_outcome(row):\n if not ledger.append(row):\n return None\n return ledger.path()\n'
);
fs.writeFileSync(
path.join(tempDir, 'unrelated.py'),
'class Box:\n def __init__(self):\n self.data = []\n\n def build_map(self):\n self.data.append({"x": 1})\n rows = {}\n rows["k"] = []\n rows["k"].append(2)\n return rows\n'
);
cg = await CodeGraph.init(tempDir, { index: true });
const ledgerAppend = cg
.getNodesByKind('function')
.find((n) => n.name === 'append' && n.filePath.replace(/\\/g, '/').endsWith('ledger.py'));
expect(ledgerAppend).toBeDefined();
const addOutcome = cg.getNodesByKind('function').find((n) => n.name === 'add_outcome');
expect(addOutcome).toBeDefined();
expect(
cg
.getOutgoingEdges(addOutcome!.id)
.filter((e) => e.kind === 'calls')
.map((e) => e.target)
).toContain(ledgerAppend!.id);
// Both non-identifier receivers live in build_map; neither may reach
// ledger.py's append.
const buildMap = cg.getNodesByKind('method').find((n) => n.name === 'build_map');
expect(buildMap).toBeDefined();
expect(
cg
.getOutgoingEdges(buildMap!.id)
.filter((e) => e.kind === 'calls')
.map((e) => e.target)
).not.toContain(ledgerAppend!.id);
});
});