mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-17 05:07:37 +08:00
fix(python): a call on self is never resolved through a same-named import (#2290)
The Python extractor records `self.get_ip(request)` by its bare name, so the import strategy matched it to a function the file imports as `get_ip` and the call never reached the class's own (or inherited) method. A bare-named Python call written on `self` now skips import resolution; the method strategies then find it. A plain `get_ip(request)` still binds to the import. Across allauth, DRF, netbox and mealie this retargets 16 calls, all from an imported helper to the class's own method; no edge is added or lost. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -166,6 +166,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
- When an agent asks CodeGraph about a separate git repository nested inside an indexed project, one the project's index leaves out (for example because the parent's `.gitignore` excludes it), it now gets the usual "isn't indexed" guidance instead of answers from the parent project's code. Nested repositories the parent does index, such as submodules, work as before. Thanks @wstczyw for the report. (#2110)
|
||||
- Editing a file that defines the same name more than once, like two classes that each have an `execute` method or a method's overloads, no longer moves every caller from other files onto one of them during `codegraph sync`. A sync interrupted partway through a file also no longer loses those callers until a full re-index. Thanks @ijbranch for the report. (#2276)
|
||||
- Delphi and Free Pascal include files (`.inc`) are now indexed as Pascal. Before, they were read as PHP and came up empty. A `.inc` file with a `<?php` tag is still PHP, and a `codegraph.json` mapping for `.inc` still wins. Thanks @ijbranch for the report. (#2279)
|
||||
- In Python, a call written on the instance, like `self.get_breadcrumbs(...)`, now links to the class's own method even when the file also imports a function of the same name. Before, the import claimed it: Django REST Framework's renderers, netbox's change logging and django-allauth's account adapter all linked their own method calls to an imported helper.
|
||||
|
||||
## [1.6.1] - 2026-09-29
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* A Python call written on the instance, `self.get_ip(request)`, is a method
|
||||
* call even when the file also imports a function named `get_ip`. The
|
||||
* extractor records it by its bare name, and the import strategy claimed it:
|
||||
* django-allauth's `DefaultAccountAdapter.send_notification_mail` calling
|
||||
* `self.get_client_ip(self.request)` was linked to `httpkit.get_client_ip`,
|
||||
* which `adapter.py` imports, instead of the adapter's own method.
|
||||
*/
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { CodeGraph } from '../src';
|
||||
|
||||
describe('Python self-calls do not resolve through a same-named import', () => {
|
||||
const dirs: string[] = [];
|
||||
let cg: CodeGraph | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
cg?.close();
|
||||
cg = undefined;
|
||||
for (const dir of dirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function callsIn(files: Record<string, string>): Promise<string[]> {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-py-self-'));
|
||||
dirs.push(dir);
|
||||
for (const [rel, content] of Object.entries(files)) {
|
||||
fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, rel), content);
|
||||
}
|
||||
cg = await CodeGraph.init(dir, { index: true });
|
||||
const rows = (cg as any).db.db
|
||||
.prepare(
|
||||
`SELECT s.qualified_name s, t.qualified_name t, t.file_path f FROM edges e
|
||||
JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target WHERE e.kind = 'calls'`,
|
||||
)
|
||||
.all() as { s: string; t: string; f: string }[];
|
||||
return rows.map((r) => `${r.s} -> ${r.t} (${r.f})`).sort();
|
||||
}
|
||||
|
||||
const httpkit = 'def get_ip(request):\n return "1.2.3.4"\n';
|
||||
|
||||
it('a method calling its own same-named method through self', async () => {
|
||||
expect(
|
||||
await callsIn({
|
||||
'pkg/__init__.py': '',
|
||||
'pkg/httpkit.py': httpkit,
|
||||
'pkg/adapter.py': [
|
||||
'from pkg.httpkit import get_ip',
|
||||
'',
|
||||
'',
|
||||
'class Adapter:',
|
||||
' def get_ip(self, request):',
|
||||
' return get_ip(request)',
|
||||
'',
|
||||
' def notify(self, request):',
|
||||
' return self.get_ip(request)',
|
||||
'',
|
||||
].join('\n'),
|
||||
}),
|
||||
).toEqual([
|
||||
// The bare call is still the imported function.
|
||||
'Adapter::get_ip -> get_ip (pkg/httpkit.py)',
|
||||
'Adapter::notify -> Adapter::get_ip (pkg/adapter.py)',
|
||||
]);
|
||||
});
|
||||
|
||||
it('a method a base class in another file declares', async () => {
|
||||
expect(
|
||||
await callsIn({
|
||||
'pkg/__init__.py': '',
|
||||
'pkg/httpkit.py': httpkit,
|
||||
'pkg/base.py': 'class Base:\n def get_ip(self, request):\n return "base"\n',
|
||||
'pkg/adapter.py': [
|
||||
'from pkg.base import Base',
|
||||
'from pkg.httpkit import get_ip',
|
||||
'',
|
||||
'',
|
||||
'class Adapter(Base):',
|
||||
' def notify(self, request):',
|
||||
' return self.get_ip(request)',
|
||||
'',
|
||||
' def raw(self, request):',
|
||||
' return get_ip(request)',
|
||||
'',
|
||||
].join('\n'),
|
||||
}),
|
||||
).toEqual([
|
||||
'Adapter::notify -> Base::get_ip (pkg/base.py)',
|
||||
'Adapter::raw -> get_ip (pkg/httpkit.py)',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
isImportableKind,
|
||||
CPP_DEFINE_SIGNATURE,
|
||||
} from './types';
|
||||
import { matchJsStoreBindingCall, isUnresolvedJsMemberCall, isVisibleAcrossFiles, matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos, isRustNameInScope, CASE_INSENSITIVE_LANGUAGES } from './name-matcher';
|
||||
import { isPythonSelfCall, matchJsStoreBindingCall, isUnresolvedJsMemberCall, isVisibleAcrossFiles, matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos, isRustNameInScope, CASE_INSENSITIVE_LANGUAGES } from './name-matcher';
|
||||
import { isVisibleCppMacro, clearCppMacroVisibility } from './cpp-macro-visibility';
|
||||
import { isCppConstructorRef, matchCppConstructor } from './cpp-constructor';
|
||||
import { gateSwiftTypeTarget, clearSwiftTypeVisibility, swiftExtendedConformances } from './swift-type-visibility';
|
||||
@@ -1240,7 +1240,12 @@ export class ReferenceResolver {
|
||||
}
|
||||
|
||||
const tImp = this.profileStages ? process.hrtime.bigint() : 0n;
|
||||
const importResult = this.gateLanguage(resolveViaImport(ref, this.context), ref);
|
||||
// `self.get_ip()` is a method call on the instance even when the file
|
||||
// also imports a function named `get_ip`: the import never names it.
|
||||
const selfCall = ref.language === 'python' && ref.referenceKind === 'calls' &&
|
||||
this.context.getImportMappings(ref.filePath, ref.language).some((m) => m.localName === ref.referenceName) &&
|
||||
isPythonSelfCall(ref, this.context);
|
||||
const importResult = selfCall ? null : this.gateLanguage(resolveViaImport(ref, this.context), ref);
|
||||
if (this.profileStages) this.stageAdd('viaImport', ref, !!importResult, tImp);
|
||||
if (importResult) {
|
||||
if (importResult.confidence >= 0.9) return importResult;
|
||||
|
||||
@@ -1714,6 +1714,15 @@ function bareCallReceiver(ref: UnresolvedRef, context: ResolutionContext): { rec
|
||||
return { receiver: head.slice(-40), links };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a Python call recorded by its bare name was written on the instance,
|
||||
* `self.get_ip(request)`. The extractor drops the `self.`, so a same-named name
|
||||
* the file imports would otherwise claim the method call (#2074 follow-up).
|
||||
*/
|
||||
export function isPythonSelfCall(ref: UnresolvedRef, context: ResolutionContext): boolean {
|
||||
return ref.language === 'python' && bareCallReceiver(ref, context)?.receiver === 'self';
|
||||
}
|
||||
|
||||
/** Whether a call recorded by its bare name is written on something other than the caller's own object. */
|
||||
function isCollapsedNonRecursion(ref: UnresolvedRef, context: ResolutionContext): boolean {
|
||||
const written = bareCallReceiver(ref, context);
|
||||
|
||||
Reference in New Issue
Block a user