fix(python): bare class references produce references edges to classes (#1478) (#1493)

Python's class-as-value idioms (return SomeClass, x = SomeClass, registry
dicts, classes passed as arguments) produced no references edges, so
callers/impact on a Django/DRF serializer missed the views that consume it.
Three gates dropped them:

- return_statement was never dispatched by PYTHON_SPEC (kernel mirrored)
- the extraction gate (definedHere) collected function/method names only
- resolution accepted function/method targets only (matchFunctionRef +
  the function_ref import fast path)

Capture return_statement for Python (single expression; tuple returns not
descended), admit same-file CLASS names to the gate, and accept class
targets for Python bare identifiers — scoped to Python so the TS/JS KIND
FILTER contract is untouched. The docopt false-positive mechanism behind
the function-only rule (lowercase locals vs same-named methods) doesn't
transfer: methods stay excluded for bare ids, and the same-file/import
gate + unique-or-drop rules still apply.

Probed on django-rest-framework (~250 files): 559 new references→class
edges, 10/10 sampled genuine (serializer_class = AuthTokenSerializer, the
ModelSerializer field-mapping registry, aliases, ctor args, isinstance).
EXTRACTION_VERSION 24 → 25.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-08-01 01:36:07 -05:00
committed by GitHub
co-authored by Claude Fable 5
parent f2a5df34de
commit 38580e0b04
9 changed files with 171 additions and 11 deletions
+1
View File
@@ -15,6 +15,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- The background server's watchdog no longer kills a healthy server that is just waiting on a slow disk: like indexing already does, it now checks whether the database files are still making progress before concluding the process is stuck. Fewer spurious kills also means fewer leftover write-ahead logs. (#1431)
- `codegraph status` now shows the write-ahead log's size next to the database size and warns when killed sessions have left it oversized, and every line in the background server's log now carries a timestamp so kills and restarts can be placed in time. (#1431)
- On Windows, the Claude Code prompt hook written by `codegraph install` failed with "command not found" when hooks run through Git Bash, which needs the `.cmd` extension to find the launcher. The installer now writes the platform-correct command, and re-running `codegraph install` (or `codegraph upgrade`) repairs an existing install in place. (#1466)
- Python classes used as values — `return SomeSerializer` from a factory method, `handler = SomeClass` aliases, registry dicts and lists, and classes passed as arguments — now produce reference edges in the graph. Previously these idioms were invisible, so on Django and Django REST Framework projects, asking for a serializer's callers or the impact of editing it missed the views that actually use it. Re-index after upgrading to pick up the new edges. (#1478)
- When a file changed on disk after its last index sync, `codegraph_node` and `codegraph_explore` could return a different symbol's code under the requested name — current file bytes cut at outdated line positions — while presenting it as verbatim, trustworthy source. This hit hardest on projects queried through `projectPath` (for example, sub-projects of a monorepo), which have no live file watcher to flag pending edits. Both tools now verify each file against the index before showing sliced code: an out-of-date file is either shown whole with its full current source, or its code is withheld with a clear "changed on disk" notice — never served as a wrong slice. A fresh re-index restores normal output automatically. Thanks @inth3shadows for the thorough report and verification passes. (#1474)
## [1.5.0] - 2026-07-21
+114 -1
View File
@@ -11,7 +11,9 @@
* - decoy: an ambiguous cross-file name (no import, 2 definitions) NO edge
* - same-file priority: a same-file definition beats a same-named decoy
* - kind filter: a class/variable passed as a value never gets a
* function-ref edge
* function-ref edge except Python, where class-as-value is a core
* idiom and bare ids ALSO resolve to classes (#1478); methods stay
* excluded for bare ids everywhere
* - self: a function passing itself no self-loop
* - drain: all resolvable function_ref rows leave unresolved_refs (no
* batched-resolver runaway), and re-index is idempotent
@@ -744,6 +746,117 @@ describe('Function-as-value capture (#756)', () => {
}
});
it('PYTHON CLASSES: return / alias / registry dict / arg positions produce references edges (#1478)', async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-pycls-'));
fs.writeFileSync(
path.join(tmpDir, 'serializers.py'),
[
'class OrgSerializerFull:',
' pass',
'',
'class OrgSerializerBrief:',
' pass',
].join('\n')
);
fs.writeFileSync(
path.join(tmpDir, 'views.py'),
[
'from serializers import OrgSerializerFull, OrgSerializerBrief',
'',
'def register(cls):',
' pass',
'',
'class OrgViewSet:',
' def get_serializer_class(self):',
' if True:',
' return OrgSerializerFull',
' return OrgSerializerBrief',
'',
'SERIALIZER_REGISTRY = {"org": OrgSerializerFull}',
'register(OrgSerializerBrief)',
].join('\n')
);
fs.writeFileSync(
path.join(tmpDir, 'models.py'),
[
'class Config:',
' pass',
'',
'def make_config_cls():',
' return Config',
'',
'ActiveConfig = Config',
].join('\n')
);
const cg = CodeGraph.initSync(tmpDir);
try {
await cg.indexAll();
// The DRF wiring: get_serializer_class → the imported serializer class,
// via `return` — the issue's headline gap. The module-level registry
// dict rides the file node.
expect(sourceNames(cg, fnRefEdgesInto(cg, 'OrgSerializerFull'))).toEqual([
'get_serializer_class',
'views.py',
]);
// Second branch return + a module-level call argument.
expect(sourceNames(cg, fnRefEdgesInto(cg, 'OrgSerializerBrief'))).toEqual([
'get_serializer_class',
'views.py',
]);
// Same-file: factory return + module-level alias assignment.
expect(sourceNames(cg, fnRefEdgesInto(cg, 'Config'))).toEqual([
'make_config_cls',
'models.py',
]);
// callers() must now surface the view as a consumer of the serializer.
const serializer = cg
.getNodesByName('OrgSerializerFull')
.find((n) => n.kind === 'class')!;
const callers = cg.getCallers(serializer.id);
expect(callers.some((c) => c.node.name === 'get_serializer_class')).toBe(true);
} finally {
cg.destroy();
tmpDir = undefined;
}
});
it('PYTHON KIND FILTER: bare ids still never resolve to methods; unknown names stay silent', async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-pyneg-'));
fs.writeFileSync(
path.join(tmpDir, 'svc.py'),
[
'class Svc:',
' def refresh(self):',
' pass',
'',
'def wire(cb):',
' pass',
'',
'def setup(refresh):',
// A local/parameter sharing a same-file METHOD name: the gate lets it
// through (methods are in definedHere) but resolution must refuse —
// a bare id can never be a method value in Python.
' wire(refresh)',
// A name with no matching class/function anywhere: no edge, silently.
' return unknown_thing',
].join('\n')
);
const cg = CodeGraph.initSync(tmpDir);
try {
await cg.indexAll();
expect(fnRefEdgesInto(cg, 'refresh')).toHaveLength(0);
expect(fnRefEdgesInto(cg, 'unknown_thing')).toHaveLength(0);
} finally {
cg.destroy();
tmpDir = undefined;
}
});
it('DRAIN: resolvable function_ref rows leave unresolved_refs; re-index is stable', async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-drain-'));
fs.writeFileSync(
+8 -1
View File
@@ -250,7 +250,9 @@ impl<'t> Walker<'t> {
target_id_str: NONE_STR,
});
if kind == "function" || kind == "method" {
// Classes join the fn-ref gate for Python (#1478): class-as-value is
// a first-class idiom (mirrors flushFnRefCandidates' python branch).
if kind == "function" || kind == "method" || kind == "class" {
self.defined_fn_names.insert(name.to_string());
}
// captureValueRefScope
@@ -711,6 +713,11 @@ impl<'t> Walker<'t> {
"keyword_argument" => ("value", "value"),
"pair" => ("value", "value"),
"list" => ("list", ""),
// `return SomeClass` / `return handler` (#1478) — a single
// returned expression is a direct named child ('list' shape);
// tuple returns sit under expression_list and are not descended
// (mirrors PYTHON_SPEC).
"return_statement" => ("list", ""),
_ => return,
};
if self.stack.is_empty() {
+13 -6
View File
@@ -45,7 +45,7 @@ custom `visitNode` hooks like Scala's val/var handler) get a candidates-only
| C / ObjC | `argument_list` | `assignment_expression.right` | `initializer_pair.value` | `initializer_list`, `init_declarator.value` | `&fn` (`pointer_expression`), `@selector(...)` (ObjC) |
| C++ | **`&` forms only** in args/rhs/varinit | (same — explicit `&` only) | bare ids at FILE scope only | bare ids at FILE scope only | `&fn`, `&Cls::method` (resolved scoped to the class) |
| TS / JS (tsx/jsx) | `arguments` | `assignment_expression.right` | `pair.value` | `array`, `variable_declarator.value` | `this.method` (`member_expression`, class-scoped — see rule 3) |
| Python | `argument_list`, `keyword_argument.value` | `assignment.right` | `pair.value` | `list` | `self.method` (`attribute`) |
| Python | `argument_list`, `keyword_argument.value`, `return_statement` (#1478 — single expression only; tuple returns not descended) | `assignment.right` | `pair.value` | `list` | `self.method` (`attribute`) |
| Go | `argument_list` | `assignment_statement` / `short_var_declaration` (`expression_list`) | `keyed_element` | `literal_value`, `var_spec.value` | — |
| Rust | `arguments` | `assignment_expression.right` | `field_initializer.value` | `array_expression`, `static_item` / `let_declaration.value` | — |
| Java | `argument_list` | `assignment_expression.right` | — | `variable_declarator.value` | `method_reference` (`Cls::m`, `this::m`) — the only form |
@@ -76,11 +76,18 @@ custom `visitNode` hooks like Scala's val/var handler) get a candidates-only
`arena_ind_prev = arena_ind` (redis/jemalloc) each matched a unique
same-named function somewhere and produced wrong edges when `rhs`/`varinit`
were ungated.
3. **TS/JS/Python: bare ids resolve to `function` kind only.** A bare
identifier can never be a method value in these languages (methods need a
receiver — `this.m` / `self.m`), so allowing method targets soaked up
locals passed as arguments (`new Set(selectedPointsIndices)`;
docopt.py's `name`/`match` params — excalidraw/fmt A/B findings).
3. **TS/JS/Python: bare ids resolve to `function` kind only — plus `class`
for Python (#1478).** A bare identifier can never be a method value in
these languages (methods need a receiver — `this.m` / `self.m`), so
allowing method targets soaked up locals passed as arguments
(`new Set(selectedPointsIndices)`; docopt.py's `name`/`match` params —
excalidraw/fmt A/B findings). Python bare ids ALSO accept CLASS targets:
class-as-value is a core Python idiom (`return SomeSerializer`, registry
dicts, `admin.site.register(Model, Admin)`) with no type-annotation
recovery path, the gate additionally admits same-file CLASS names for
Python, and the docopt false-positive mechanism (lowercase locals vs
same-named methods) doesn't transfer to exact-name class matches. TS/JS
keep the class exclusion (the KIND FILTER contract).
TS/JS `this.X` values are captured as `this.`-PREFIXED candidates and
resolved CLASS-SCOPED (`resolveThisMemberFnRef` in
`src/resolution/index.ts`): the target must be a function/method whose
+1 -1
View File
@@ -21,4 +21,4 @@
* turns the re-index hint into noise keep it honest (see CLAUDE.md, "Honesty
* in the product is load-bearing").
*/
export const EXTRACTION_VERSION = 24;
export const EXTRACTION_VERSION = 25;
+6
View File
@@ -194,6 +194,12 @@ const PYTHON_SPEC: FnRefSpec = {
['keyword_argument', { mode: 'value', field: 'value' }], // Thread(target=worker)
['pair', { mode: 'value', field: 'value' }],
['list', { mode: 'list' }],
// `return SomeClass` / `return handler` — factory returns are how DRF
// wires views to serializers (get_serializer_class) and how Python
// factories hand back callables (#1478). A single returned expression is
// a direct named child, so 'list' covers it; tuple returns (`return A, B`)
// sit under an expression_list child and are deliberately not descended.
['return_statement', { mode: 'list' }],
]),
special: new Set(['attribute']),
};
+6
View File
@@ -649,6 +649,12 @@ export class TreeSitterExtractor {
const definedHere = new Set<string>();
for (const n of this.nodes) {
if (n.kind === 'function' || n.kind === 'method') definedHere.add(n.name);
// Python only (#1478): class-as-value is a first-class idiom (DRF
// get_serializer_class, Meta.model, registry dicts), so same-file CLASS
// names pass the gate too. Other languages keep the function/method
// gate — TS/JS recover class references through type annotations, and
// resolution's kind filter would drop their class candidates anyway.
else if (this.language === 'python' && n.kind === 'class') definedHere.add(n.name);
}
// Import-binding names only (all binding emitters push kind 'imports').
+9 -1
View File
@@ -915,7 +915,15 @@ export class ReferenceResolver {
const viaImport = this.gateLanguage(resolveViaImport(ref, this.context), ref);
if (viaImport) {
const target = this.queries.getNodeById(viaImport.targetNodeId);
if (target && (target.kind === 'function' || target.kind === 'method')) {
if (
target &&
(target.kind === 'function' ||
target.kind === 'method' ||
// Python (#1478): an imported class used as a value (`return
// OrgSerializerFull`) resolves through its import like any
// callback — mirrors matchFunctionRef's bareClassOk.
(ref.language === 'python' && target.kind === 'class'))
) {
return viaImport;
}
}
+13 -1
View File
@@ -232,6 +232,16 @@ export function matchFunctionRef(
ref.language === 'cpp' || ref.language === 'python' ||
ref.language === 'php';
// Python additionally accepts CLASS targets for bare identifiers (#1478):
// class-as-value is a core Python idiom (`return SomeSerializer`,
// `Meta.model = Org`, registry dicts, `admin.site.register(Model, Admin)`)
// and, unlike TS, Python has no type-annotation recovery path. The
// false-positive mechanism behind the function-only rule was lowercase
// locals colliding with same-named METHODS (docopt.py) — a candidate must
// be an exact-name CLASS node here, and the extraction gate (same-file
// class imports) plus unique-or-drop still apply. Methods stay excluded.
const bareClassOk = ref.language === 'python';
// Qualified member-pointer (`&Widget::on_click` → "Widget::on_click"):
// resolve the member ON THAT SCOPE — exempt from bareFnOnly (the `&Cls::m`
// shape is an explicit member reference). Unique-or-drop like everything else.
@@ -264,7 +274,9 @@ export function matchFunctionRef(
.getNodesByName(ref.referenceName)
.filter(
(n) =>
(n.kind === 'function' || (!bareFnOnly && n.kind === 'method')) &&
(n.kind === 'function' ||
(!bareFnOnly && n.kind === 'method') ||
(bareClassOk && n.kind === 'class')) &&
sameLanguageFamily(n.language, ref.language) &&
n.id !== ref.fromNodeId // a function registering itself is not a dependency edge
);