Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
53b7e37
fix(python): resolve attribute-receiver calls by evidence, never by b…
inth3shadows Sep 23, 2026
66095f2
fix(python): keep attribute calls on the evidence path, and resolve r…
inth3shadows Oct 1, 2026
537b21e
fix(python): read rebindings by statement, and type an attribute the …
inth3shadows Oct 1, 2026
b9507be
fix(python): drop same-scope attribute inference; a subscript or attr…
inth3shadows Oct 3, 2026
19abd83
fix(python): an assignment inside a nested def is not typed by the en…
inth3shadows Oct 3, 2026
9d3e5e3
fix(python): type attributes set by a factory or a base class (#1704,…
inth3shadows Sep 23, 2026
f0d1925
fix(python): count every return in a factory, and never type a chaine…
inth3shadows Oct 1, 2026
b3c01d4
fix(python): a return or yield inside a string is not a statement
inth3shadows Oct 1, 2026
54cc1f2
Merge origin/main into fix/python-attr-types
colbymchenry Oct 20, 2026
488d086
Merge origin/main into review/1926
colbymchenry Oct 20, 2026
d1cc8e9
fix(python): an untyped attribute call is guessed by its last link's …
colbymchenry Oct 20, 2026
fa72081
Merge origin/main into review/1926
colbymchenry Oct 20, 2026
42798e3
fix(resolution): a receiver that spells a class's whole name guesses …
colbymchenry Oct 20, 2026
11045ba
fix(python): a class whose base the reader cannot find is no evidence…
colbymchenry Oct 20, 2026
cfb9680
fix(resolution): an untyped python attribute chain needs more than on…
colbymchenry Oct 20, 2026
8954371
fix(resolution): an untyped python chain is guessed through its head …
colbymchenry Oct 20, 2026
d77a73e
docs(changelog): the whole-name preference for a receiver named after…
colbymchenry Oct 20, 2026
241b079
perf(python): read an attribute's type once per class, attribute and …
colbymchenry Oct 20, 2026
28bb106
Merge origin/main into review/1926
colbymchenry Oct 20, 2026
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,10 @@ 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 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.
- A call through a class name, like `Logger.log()`, or through a variable named after one, like `logger.log()`, now links to that class's own method or the one it inherits, such as Django's `form.is_valid()` reaching `BaseForm`, instead of the same-named method of a class whose name merely contains it, such as `FileLogger`. These calls also no longer link to a class a test declares inside one of its functions, or, from your own code, to a class in a test suite. Re-index to update an existing project. Thanks @inth3shadows.
- Python imports now resolve the way Python finds modules: from your project's package roots — the repository root, `src/` layouts, each service with its own `pyproject.toml`, `setup.cfg` or `setup.py`, and the package directories those declare — so `from shop.cart import total` reaches `src/shop/cart.py`, `from pkg import name` reaches what the package's `__init__.py` defines or re-exports (respecting `__all__`), and `import json` or `import requests` no longer links to a same-named file nested in a test fixture, an example, a vendored copy or another package. After a module, package or `__init__.py` is added or removed, a package's `__init__.py` is edited, or a build config is edited, the next sync brings Python imports back in line with a fresh index; the file watcher doesn't react to a build-config edit by itself, so run `codegraph sync` after one. Re-index Python projects after upgrading. (#1704)
- An Express route keeps its named handler when a middleware before it contains an arrow function. `rateLimit({ keyGenerator: (req) => req.ip })` or `(req, res, next) => next()` no longer hides the handler at the end of the route (re-index to update an existing project).
Expand Down
13 changes: 13 additions & 0 deletions __tests__/fixtures/kernel-parity/torture.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,16 @@ def bucket_chains(d, k, v):
make().run()
(lambda: make)()().run()
obj.make().run().again()


# --- non-call, non-identifier receivers (#66) ---------------------------------
def fabrication_shapes(rows_by_file, key):
# Attribute chain and subscript. Each must keep its receiver text as a
# qualifier — a bare `append`/`get` exact-matches an unrelated project
# function of that name. (The call-chain shape lives in bucket_chains
# above, which #1748 encodes as `<inner>().<method>` instead.)
self_like = rows_by_file
self_like.rows.append({"x": 1})
rows_by_file[key].append(2)
rows_by_file[key].get(key, None)
return rows_by_file
98 changes: 98 additions & 0 deletions __tests__/kernel-python-call-fabrication.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/**
* 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);
});
});
Loading