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
+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(