diff --git a/__tests__/function-ref.test.ts b/__tests__/function-ref.test.ts index 4e156c31..ce813168 100644 --- a/__tests__/function-ref.test.ts +++ b/__tests__/function-ref.test.ts @@ -25,6 +25,7 @@ import * as path from 'path'; import * as os from 'os'; import { CodeGraph } from '../src'; import type { Edge } from '../src/types'; +import { ToolHandler } from '../src/mcp/tools'; import { initGrammars, loadAllGrammars } from '../src/extraction/grammars'; beforeAll(async () => { @@ -859,6 +860,349 @@ describe('Function-as-value capture (#756)', () => { } }); + it('#1820 PYTHON: obj.method passed as a callback is a caller; a unique method resolves', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-1820-py-')); + fs.writeFileSync( + path.join(tmpDir, 'store.py'), + [ + 'class Base:', + ' pass', + '', + 'class Store(Base):', + ' def fetch(self, ids):', + ' return ids', + ].join('\n') + ); + fs.writeFileSync( + path.join(tmpDir, 'consumer.py'), + [ + 'from concurrent.futures import ThreadPoolExecutor', + 'from store import Base', + '', + 'class Consumer:', + ' def __init__(self, store: Base):', + ' self.store = store', + '', + ' def direct(self, ids):', + ' return self.store.fetch(ids)', + '', + ' def via_callback(self, ids, pool: ThreadPoolExecutor):', + ' return pool.submit(self.store.fetch, ids)', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + const fetch = cg.getNodesByName('fetch').find((n) => n.kind === 'method')!; + const callers = cg.getCallers(fetch.id).map((c) => c.node.name).sort(); + expect(callers).toContain('direct'); + expect(callers).toContain('via_callback'); + expect(sourceNames(cg, fnRefEdgesInto(cg, 'fetch'))).toEqual(['via_callback']); + expect([...cg.getImpactRadius(fetch.id).nodes.values()].map(n => n.name)).toContain('via_callback'); + const response = await new ToolHandler(cg).execute('codegraph_explore', { + query: 'Consumer.via_callback Store.fetch', + }); + expect(response.isError).not.toBe(true); + const text = response.content?.[0]?.text ?? ''; + expect(text).toMatch(/`fetch`.*2 callers in `consumer.py`/); + expect(text).toContain('via_callback(method)'); + expect(text).toContain('pool.submit(self.store.fetch, ids)'); + } finally { + cg.destroy(); + } + }); + + it('#1820 PYTHON: a test-file method still makes an unknown receiver ambiguous', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-1820-py-mock-')); + fs.writeFileSync( + path.join(tmpDir, 'store.py'), + 'class Store:\n def fetch(self, ids):\n return ids\n' + ); + fs.mkdirSync(path.join(tmpDir, 'tests')); + fs.writeFileSync( + path.join(tmpDir, 'tests', 'test_store.py'), + 'class FakeStore:\n def fetch(self, ids):\n return ids\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'consumer.py'), + [ + 'class Consumer:', + ' def __init__(self, store):', + ' self.store = store', + ' def via_callback(self, pool, ids):', + ' return pool.submit(self.store.fetch, ids)', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + const edges = fnRefEdgesInto(cg, 'fetch'); + expect(edges).toHaveLength(0); + } finally { + cg.destroy(); + } + }); + + it('#1820 PYTHON: a NotImplementedError base still makes an unknown receiver ambiguous', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-1820-py-base-')); + fs.writeFileSync( + path.join(tmpDir, 'base.py'), + [ + 'class Base:', + ' def fetch(self, ids):', + ' raise NotImplementedError("subclass")', + ].join('\n') + ); + fs.writeFileSync( + path.join(tmpDir, 'store.py'), + [ + 'from base import Base', + 'class Store(Base):', + ' def fetch(self, ids):', + ' return ids', + ].join('\n') + ); + fs.writeFileSync( + path.join(tmpDir, 'consumer.py'), + [ + 'class Consumer:', + ' def __init__(self, store):', + ' self.store = store', + ' def via_callback(self, pool, ids):', + ' return pool.submit(self.store.fetch, ids)', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + const edges = fnRefEdgesInto(cg, 'fetch'); + expect(edges).toHaveLength(0); + } finally { + cg.destroy(); + } + }); + + it('#1820 PYTHON: two methods of the same name produce no callback edge', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-1820-py-decoy-')); + fs.writeFileSync(path.join(tmpDir, 'a.py'), 'class A:\n def fetch(self, ids):\n return ids\n'); + fs.writeFileSync(path.join(tmpDir, 'b.py'), 'class B:\n def fetch(self, ids):\n return ids\n'); + fs.writeFileSync( + path.join(tmpDir, 'consumer.py'), + [ + 'class Consumer:', + ' def __init__(self, store):', + ' self.store = store', + ' def via_callback(self, pool, ids):', + ' return pool.submit(self.store.fetch, ids)', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + expect(fnRefEdgesInto(cg, 'fetch')).toHaveLength(0); + } finally { + cg.destroy(); + } + }); + + it('#1820 GO: method value Submit(c.store.Fetch) is a caller; go Fetch(ids) is a call', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-1820-go-')); + fs.writeFileSync( + path.join(tmpDir, 'store.go'), + [ + 'package demo', + '', + 'type Store struct{}', + '', + 'func (s *Store) Fetch(ids []string) []string { return ids }', + ].join('\n') + ); + fs.writeFileSync( + path.join(tmpDir, 'consumer.go'), + [ + 'package demo', + '', + 'func Submit(fn func([]string) []string, ids []string) []string { return fn(ids) }', + '', + 'type Consumer struct{ store *Store }', + '', + 'func (c *Consumer) Direct(ids []string) []string { return c.store.Fetch(ids) }', + '', + 'func (c *Consumer) ViaGo(ids []string) { go c.store.Fetch(ids) }', + '', + 'func (c *Consumer) ViaSubmit(ids []string) []string { return Submit(c.store.Fetch, ids) }', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + const fetch = cg.getNodesByName('Fetch').find((n) => n.kind === 'method')!; + const callers = cg.getCallers(fetch.id).map((c) => c.node.name).sort(); + expect(callers).toContain('Direct'); + expect(callers).toContain('ViaGo'); + expect(callers).toContain('ViaSubmit'); + expect(sourceNames(cg, fnRefEdgesInto(cg, 'Fetch'))).toEqual(['ViaSubmit']); + } finally { + cg.destroy(); + } + }); + + it('#1820: receiver identity beats same-file and imported-name decoys', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-receivers-')); + fs.writeFileSync(path.join(tmpDir, 'store.py'), `class Store: + def fetch(self, ids): + return ids +`); + fs.writeFileSync(path.join(tmpDir, 'main.py'), `from store import Store as Actual +class Store: + def fetch(self, ids): + return ids +class Consumer: + def __init__(self, store: Actual): + self.store = store + def callback(self, pool, ids): + return pool.submit(self.store.fetch, ids) + def assigned(self): + cb = self.store.fetch + def collected(self): + return [self.store.fetch] +def typed(obj: Actual, pool): + pool.submit(obj.fetch) +def static(pool): + pool.submit(Actual.fetch) +`); + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + const edges = fnRefEdgesInto(cg, 'fetch'); + expect(sourceNames(cg, edges)).toEqual(['assigned', 'callback', 'collected', 'static', 'typed']); + expect(edges.every(e => cg.getNode(e.target)?.filePath === 'store.py')).toBe(true); + } finally { cg.close(); } + }); + + it('#1820: same-file ambiguity and noncallable receivers stay unlinked', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-ambiguity-')); + fs.writeFileSync(path.join(tmpDir, 'main.py'), `class A: + def fetch(self): + return 1 +class B: + def fetch(self): + return 2 +class Data: + fetch = 42 +class Property: + @property + def fetch(self): + return 42 +def unknown(obj, pool): + pool.submit(obj.fetch) +def data(obj: Data, pool): + pool.submit(obj.fetch) +def prop(obj: Property, pool): + pool.submit(obj.fetch) +def bare(fetch, pool): + pool.submit(fetch) +class Own: + def fetch(self): + return 3 + def bound(self, pool): + pool.submit(self.fetch) + @classmethod + def class_bound(cls, pool): + pool.submit(cls.fetch) +`); + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + const edges = fnRefEdgesInto(cg, 'fetch'); + expect(sourceNames(cg, edges)).toEqual(['bound', 'class_bound']); + expect(edges.every(e => cg.getNode(e.target)?.qualifiedName === 'Own::fetch')).toBe(true); + } finally { cg.close(); } + }); + + it('#1820: typed, constructed and inherited Python receivers exclude noncallable values', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-known-')); + fs.writeFileSync(path.join(tmpDir, 'main.py'), `class Store: + def fetch(self): + return 1 +class Child(Store): + def inherited(self, pool): + pool.submit(self.fetch) +class Data: + def __init__(self): + self.fetch = 42 +class Override(Store): + def __init__(self): + self.fetch = 42 +class Consumer: + def __init__(self): + self.store = Store() + def keyword(self, pool): + pool.submit(callback=self.store.fetch) +def constructor(pool): + obj = Store() + pool.submit(obj.fetch) +def partial_ref(obj: Store): + return partial(obj.fetch, 1) +def mapped(obj: Store, xs): + return map(obj.fetch, xs) +def data(obj: Data, pool): + pool.submit(obj.fetch) +def override(obj: Override, pool): + pool.submit(obj.fetch) +def primitive(obj: int, pool): + pool.submit(obj.fetch) +def literal(pool): + obj = 42 + pool.submit(obj.fetch) +def reassigned(obj: Store, pool): + obj = 42 + pool.submit(obj.fetch) +def direct(obj: Store): + obj.fetch() +`); + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + expect(sourceNames(cg, fnRefEdgesInto(cg, 'fetch'))).toEqual([ + 'constructor', 'inherited', 'keyword', 'mapped', 'partial_ref', + ]); + const fetch = cg.getNodesByName('fetch').find(n => n.kind === 'method')!; + expect(sourceNames(cg, cg.getIncomingEdges(fetch.id).filter(e => e.kind === 'calls'))).toContain('direct'); + } finally { cg.close(); } + }); + + it('#1820: Go receiver types disambiguate method values and reject external fields', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-go-scope-')); + fs.writeFileSync(path.join(tmpDir, 'main.go'), `package demo +import "database/sql" +type Store struct{} +func (s *Store) Fetch() {} +type Decoy struct{} +func (d *Decoy) Fetch() {} +type Consumer struct { store *Store; external *sql.DB } +func (c *Consumer) Callback() { Submit(c.store.Fetch) } +func Typed(s *Store) { Submit(s.Fetch) } +func Assigned(s *Store) { cb := s.Fetch } +func Collected(s *Store) { table := []func(){s.Fetch} } +func MethodExpression() { Submit(Store.Fetch) } +func (c *Consumer) External() { Submit(c.external.Fetch) } +func Unknown(obj interface{}) { Submit(obj.Fetch) } +`); + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + const edges = fnRefEdgesInto(cg, 'Fetch'); + expect(sourceNames(cg, edges)).toEqual(['Assigned', 'Callback', 'Collected', 'MethodExpression', 'Typed']); + expect(edges.every(e => cg.getNode(e.target)?.qualifiedName === 'Store::Fetch')).toBe(true); + } finally { cg.close(); } + }); + 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( diff --git a/__tests__/kernel-tsjs-parity.test.ts b/__tests__/kernel-tsjs-parity.test.ts index 1b42e4aa..dd4c8e13 100644 --- a/__tests__/kernel-tsjs-parity.test.ts +++ b/__tests__/kernel-tsjs-parity.test.ts @@ -220,6 +220,43 @@ async function exprReceivers(x, y) { assertParity('fixtures/torture.go', fs.readFileSync(file, 'utf8'), 'go'); }); + it('Python member values preserve receivers across callback, assignment and collection positions (#1820)', () => { + const result = assertParity('members.py', ` +class Store: + def fetch(self, ids): + return ids +class Consumer: + def wire(self, pool, obj): + pool.submit(self.store.fetch, obj.fetch) + cb = self.store.fetch + table = [obj.fetch, Store.fetch, self.fetch, cls.fetch] + keyword(callback=obj.fetch) + obj.fetch([]) + pool.submit(factory().fetch, obj[0].fetch) +`, 'python'); + const names = result.unresolvedReferences.filter(r => r.referenceKind === 'function_ref').map(r => r.referenceName); + expect(names.sort()).toEqual(['Store.fetch', 'cls.fetch', 'obj.fetch', 'self.fetch', 'self.store.fetch']); + expect(result.unresolvedReferences.some(r => r.referenceKind === 'calls' && r.referenceName === 'obj.fetch')).toBe(true); + }); + + it('Go method values preserve receivers and exclude invocation receivers (#1820)', () => { + const result = assertParity('members.go', `package demo + type Store struct{} + func (s *Store) Fetch() {} + func wire(c *Store, pool Pool) { + Submit(c.Fetch) + cb := c.Fetch + table := []func(){c.Fetch, Store.Fetch} + Submit(c.store.Fetch) + go c.Fetch() + Submit(factory().Fetch, items[0].Fetch) + } +`, 'go'); + const names = result.unresolvedReferences.filter(r => r.referenceKind === 'function_ref').map(r => r.referenceName); + expect(names.sort()).toEqual(['Store.Fetch', 'c.Fetch', 'c.store.Fetch']); + expect(result.unresolvedReferences.some(r => r.referenceKind === 'calls' && r.referenceName === 'c.Fetch')).toBe(true); + }); + it.each(REAL_SOURCES)('real source parity: %s', (rel) => { const file = path.join(__dirname, '..', rel); assertParity(rel, fs.readFileSync(file, 'utf8'), 'typescript'); diff --git a/codegraph-kernel/src/go.rs b/codegraph-kernel/src/go.rs index ecdbcadc..239c788a 100644 --- a/codegraph-kernel/src/go.rs +++ b/codegraph-kernel/src/go.rs @@ -1009,6 +1009,31 @@ impl<'t> Walker<'t> { row: p.row, }); } + // #1820: preserve the receiver of a method value. + "selector_expression" => { + let field = v + .child_by_field_name("field") + .or_else(|| v.named_child(v.named_child_count().saturating_sub(1))); + let Some(field) = field else { return }; + let name = self.text(field); + if name.is_empty() || is_stoplisted(name) { + return; + } + let value = self.text(v); + if !value.split('.').all(|part| { + !part.is_empty() && part.chars().enumerate().all(|(i, c)| { + c == '_' || c.is_ascii_alphabetic() || (i > 0 && c.is_ascii_digit()) + }) + }) { return; } + let p = field.start_position(); + self.fn_ref_cands.push(Cand { + from, + name: value.to_string(), + line: p.row as u32 + 1, + column_byte: field.start_byte(), + row: p.row, + }); + } "literal_element" | "expression_list" => { for i in 0..v.named_child_count() { if let Some(c) = v.named_child(i) { @@ -1050,6 +1075,7 @@ impl<'t> Walker<'t> { let mut seen: HashSet<(String, String)> = HashSet::new(); for c in cands { if !c.name.starts_with("this.") + && !c.name.contains('.') && !c.name.contains("::") && !self.defined_fn_names.contains(&c.name) && !self.imported_names.contains(&c.name) diff --git a/codegraph-kernel/src/python.rs b/codegraph-kernel/src/python.rs index 7c628b99..1fe325f0 100644 --- a/codegraph-kernel/src/python.rs +++ b/codegraph-kernel/src/python.rs @@ -6,7 +6,8 @@ //! decorators (`@staticmethod` yes, `@app.route(...)` no — python's `call` //! kind isn't `call_expression`), module-level assignments always extract as //! `variable` (no isConst hook), and `self.method` fn-ref candidates carry the -//! BARE attribute name. Python is not a TYPE_ANNOTATION language — no type +//! full receiver path (#1820). +//! Python is not a TYPE_ANNOTATION language — no type //! refs anywhere. Files with parse errors defer to wasm. use crate::buffers::{ @@ -903,18 +904,16 @@ impl<'t> Walker<'t> { for v in values { let (name, anchor) = match v.kind() { "identifier" => (self.text(v).to_string(), v), - // `self.handle_click` — object EXACTLY `self`; BARE attr name. + // Preserve only statically named member chains, as on WASM. "attribute" => { - let obj = v.child_by_field_name("object"); - let attr = v.child_by_field_name("attribute"); - match (obj, attr) { - (Some(o), Some(a)) - if o.kind() == "identifier" && self.text(o) == "self" => - { - (self.text(a).to_string(), a) - } - _ => continue, - } + let Some(attr) = v.child_by_field_name("attribute") else { continue }; + let name = self.text(v); + if !name.split('.').all(|part| { + !part.is_empty() && part.chars().enumerate().all(|(i, c)| { + c == '_' || c.is_ascii_alphabetic() || (i > 0 && c.is_ascii_digit()) + }) + }) { continue; } + (name.to_string(), attr) } _ => continue, }; @@ -964,6 +963,7 @@ impl<'t> Walker<'t> { let mut seen: HashSet<(String, String)> = HashSet::new(); for c in cands { if !c.name.starts_with("this.") + && !c.name.contains('.') && !c.name.contains("::") && !self.defined_fn_names.contains(&c.name) && !self.imported_names.contains(&c.name) diff --git a/src/extraction/extraction-version.ts b/src/extraction/extraction-version.ts index 8ddab13b..3691e95f 100644 --- a/src/extraction/extraction-version.ts +++ b/src/extraction/extraction-version.ts @@ -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 = 26; +export const EXTRACTION_VERSION = 27; diff --git a/src/extraction/function-ref.ts b/src/extraction/function-ref.ts index 4a3596f0..46d540c2 100644 --- a/src/extraction/function-ref.ts +++ b/src/extraction/function-ref.ts @@ -25,9 +25,10 @@ * * Deliberately NOT covered (resolving the *dispatch* — `o->cb(x)` → the * registered function — needs data-flow through struct fields; a wrong edge - * is worse than none): indirect-call resolution and `obj.method` member - * values where `obj` isn't `this`/`self` (the receiver's type is statically - * unknowable without local data-flow). + * is worse than none): indirect-call resolution. Member values where the + * receiver isn't `this`/`self` (`pool.submit(obj.method)`, `Submit(c.store.Fetch)`) + * retain their receiver (#1820), so resolution can use type/import scope + * before considering a unique method name. */ import type { Node as SyntaxNode } from 'web-tree-sitter'; @@ -51,7 +52,8 @@ export interface FnRefCandidate { * referenced cross-file WITHOUT imports (global namespace), so the gate * can't see them — the strong positional prior (a string argument to * `usort`/`array_map`/…) plus resolution's unique-or-drop rule carry the - * precision instead. + * precision instead. Python/Go member values also skip the name gate, + * retaining their receiver for scoped resolution. */ skipGate?: boolean; } @@ -231,6 +233,7 @@ const GO_SPEC: FnRefSpec = { ['literal_element', null], ['expression_list', null], ]), + special: new Set(['selector_expression']), }; const RUST_SPEC: FnRefSpec = { @@ -699,16 +702,23 @@ function normalizeSpecial( return []; } - // Swift `#selector(Holder.fire)` → fire. ObjC `@selector(storeImage:)` → - // `storeImage:` verbatim (ObjC method nodes keep their selector colons). + // Go `c.store.Fetch` (has a `field` child) vs Swift `#selector(...)` / + // ObjC `@selector(...)` (no `field` — inner identifier / selector text). case 'selector_expression': { + const field = getChildByField(node, 'field'); + if (field) { + const name = getNodeText(field, source); + const receiver = getChildByField(node, 'operand'); + const value = receiver ? `${getNodeText(receiver, source)}.${name}` : ''; + return /^[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)+$/.test(value) + ? [{ name: value, node: field, skipGate: true }] : []; + } const inner = node.namedChild(0); if (!inner) return []; if (inner.type === 'identifier' || inner.type === 'simple_identifier') { return [{ name: getNodeText(inner, source), node: inner }]; } - // Swift dotted form: rightmost simple_identifier. ObjC keyword selector: - // text as-is. + // Swift dotted form: rightmost simple_identifier; ObjC keeps selector text. const last = lastNamedOfType(node, new Set(['simple_identifier'])); if (last) return [{ name: getNodeText(last, source), node: last }]; return [{ name: getNodeText(inner, source).trim(), node: inner }]; @@ -739,14 +749,13 @@ function normalizeSpecial( return []; } - // `self.handle_click` (Python) — object must be EXACTLY `self`. + // Keep the receiver on Python member values; calls/subscripts are not + // statically named receivers and must not collapse to a bare method. case 'attribute': { - const obj = getChildByField(node, 'object'); const attr = getChildByField(node, 'attribute'); - if (obj && attr && obj.type === 'identifier' && getNodeText(obj, source) === 'self') { - return [{ name: getNodeText(attr, source), node: attr }]; - } - return []; + const name = getNodeText(node, source); + return attr && /^[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)+$/.test(name) + ? [{ name, node: attr, skipGate: true }] : []; } // `this.Run0` (C#) — receiver must be EXACTLY `this`. Two grammar shapes: diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index 8c3038dc..ad37a9f8 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -1648,13 +1648,19 @@ export function resolveViaImport( for (const imp of imports) { if (imp.localName === ref.referenceName || ref.referenceName.startsWith(imp.localName + '.')) { // Resolve the import path - const resolvedPath = resolveImportPath( + let resolvedPath = resolveImportPath( imp.source, ref.filePath, ref.language, context ); + // Named Python imports need the same absolute-module lookup as namespace + // imports, including aliases used as receiver types (#1820). + if (!resolvedPath && ref.language === 'python') { + resolvedPath = findPythonModuleFile(imp.source, context, ref.filePath)?.filePath ?? null; + } + if (resolvedPath) { const exportedName = imp.isDefault ? 'default' : imp.exportedName; const memberName = imp.isNamespace @@ -1667,7 +1673,11 @@ export function resolveViaImport( ref.language, context, new Set() - ); + ) ?? (ref.language === 'python' + ? context.getNodesInFile(resolvedPath).find(n => + n.name === (memberName ?? exportedName) && !n.qualifiedName.includes('::') && + (n.kind === 'class' || n.kind === 'function' || n.kind === 'variable' || n.kind === 'constant')) + : undefined); if (targetNode) { // `Foo.bar()` / `Foo.CONST` — a NAMED (non-namespace) class import diff --git a/src/resolution/index.ts b/src/resolution/index.ts index db40e50c..43c2fd26 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -973,6 +973,9 @@ export class ReferenceResolver { if (ref.referenceName.startsWith('this.')) { return this.gateLanguage(this.resolveThisMemberFnRef(ref), ref); } + if ((ref.language === 'python' || ref.language === 'go') && ref.referenceName.includes('.')) { + return this.gateLanguage(matchFunctionRef(ref, this.context), ref); + } const viaImport = this.gateLanguage(resolveViaImport(ref, this.context), ref); if (viaImport) { const target = this.queries.getNodeById(viaImport.targetNodeId); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 7f2c82c0..04f3a370 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -9,7 +9,6 @@ import { Language, Node } from '../types'; import { UnresolvedRef, ResolvedRef, ResolutionContext, isSupertypeTarget, isInheritanceRef, isImportableKind } from './types'; import { blankStringContents, stripCommentsForRegex } from './strip-comments'; import { JS_BUILT_INS, JS_BUILTIN_METHODS, TS_PRIMITIVE_TYPES } from './js-builtins'; - /** * Ceiling on how many same-named definitions a FUZZY name-match strategy will * score. A name defined more times than this is "ubiquitous" — a method/symbol @@ -226,14 +225,191 @@ export function gateLanguageMatch( return result; } +/** Member values retain their receiver; never break ties by file order (#1820). */ +function matchMemberFunctionRef(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { + const dot = ref.referenceName.lastIndexOf('.'); + const receiver = ref.referenceName.slice(0, dot); + const member = ref.referenceName.slice(dot + 1); + const result = (nodes: Node[], confidence = 0.9): ResolvedRef | null => { + const pool = nodes.filter(n => sameLanguageFamily(n.language, ref.language)); + const target = pool.length === 1 ? pool[0] : undefined; + return target && (target.kind === 'function' || target.kind === 'method') && + target.id !== ref.fromNodeId && !isPythonProperty(target, context) + ? { original: ref, targetNodeId: target.id, confidence, resolvedBy: 'function-ref' } + : null; + }; + const imports = context.getImportMappings(ref.filePath, ref.language); + // An import is authoritative even when it points outside the project. + if (imports.some(i => i.localName === receiver.split('.')[0])) { + if (ref.language === 'python') { + const cls = pythonRefClass(receiver, ref, context); + if (cls) return result(pythonMembers(cls, member, ref, context)); + } + const imported = context.resolveImport?.(ref); + const node = imported && context.getNodeById?.(imported.targetNodeId); + return node ? result(context.getNodesByQualifiedName(node.qualifiedName).filter(n => n.filePath === node.filePath)) : null; + } + if (ref.language === 'go') { + if (receiver.includes('.')) return matchGoFieldChainCall(receiver, member, ref, context); + const type = inferLocalReceiverType(receiver, ref, context); + if (type) return resolveMethodOnType(type, member, ref, context, 0.9, 'function-ref'); + const types = context.getNodesByName(receiver).filter(n => n.language === 'go' && (n.kind === 'struct' || n.kind === 'interface')); + if (types.length) return types.length === 1 ? resolveMethodOnType(receiver, member, ref, context, 0.9, 'function-ref') : null; + } else { + const owner = context.getNodesInFile(ref.filePath).filter(n => + n.kind === 'class' && n.startLine <= ref.line && n.endLine >= ref.line) + .sort((a, b) => b.startLine - a.startLine)[0]; + let type: string | null = null; + if (receiver === 'self' || receiver === 'cls') { + return owner ? result(pythonMembers(owner, member, ref, context)) : null; + } + if (/^(self|cls)\.\w+$/.test(receiver)) { + if (!owner) return null; + type = pythonFieldType(receiver, owner, ref, context); + } else { + type = pythonLocalType(receiver, ref, context); + } + // A type name used directly (`Store.fetch`) is scoped just like an annotation. + if (!type && /^[A-Z]\w*$/.test(receiver)) type = receiver; + if (type && type !== 'object' && type !== 'Any') { + const cls = pythonRefClass(type, ref, context); + if (!cls) return null; + const members = pythonMembers(cls, member, ref, context); + if (members.length) return result(members); + // A base-typed field can hold a subclass-only method (the reported case). + // Keep only descendants of THAT base; unrelated same-name methods cannot win. + const candidates = context.getNodesByName(member).filter(n => n.kind === 'method' && n.language === 'python'); + const descendants = candidates.filter(n => { + const parent = context.getNodesInFile(n.filePath).find(c => + c.kind === 'class' && n.qualifiedName === `${c.qualifiedName}::${member}`); + return parent && pythonDerivesFrom(parent, cls, ref, context); + }); + return result(descendants, 0.8); + } + } + // Unknown receivers retain the old unique-or-drop discipline, across ALL + // files. Tests and abstract-looking bodies are candidates too. + return result(context.getNodesByName(member), 0.8); +} + +function pythonRefClass(name: string, ref: UnresolvedRef, context: ResolutionContext): Node | null { + const imports = context.getImportMappings(ref.filePath, 'python'); + if (imports.some(i => i.localName === name.split('.')[0])) { + const hit = context.resolveImport?.({ ...ref, referenceName: name, referenceKind: 'references' }); + const node = hit && context.getNodeById?.(hit.targetNodeId); + return node?.kind === 'class' && context.getNodesByQualifiedName(node.qualifiedName) + .filter(n => n.kind === 'class' && n.filePath === node.filePath).length === 1 ? node : null; + } + const classes = context.getNodesByName(name).filter(n => n.kind === 'class' && n.filePath === ref.filePath); + return classes.length === 1 ? classes[0]! : null; +} + +function pythonBases(cls: Node, ref: UnresolvedRef, context: ResolutionContext): Node[] { + const line = context.getFileLines?.(cls.filePath)?.[cls.startLine - 1] + ?? context.readFile(cls.filePath)?.split('\n')[cls.startLine - 1] ?? ''; + const bases = line.match(/^\s*class\s+\w+\s*\(([^)]*)\)/)?.[1]; + return (bases?.split(',') ?? []).flatMap(name => { + const base = pythonRefClass(name.trim(), { ...ref, filePath: cls.filePath }, context); + return base ? [base] : []; + }); +} + +function pythonDerivesFrom(cls: Node, base: Node, ref: UnresolvedRef, context: ResolutionContext, seen = new Set()): boolean { + if (seen.has(cls.id) || seen.size >= 16) return false; + seen.add(cls.id); + return pythonBases(cls, ref, context).some(p => p.id === base.id || pythonDerivesFrom(p, base, ref, context, seen)); +} + +function pythonMembers(cls: Node, member: string, ref: UnresolvedRef, context: ResolutionContext, seen = new Set()): Node[] { + if (seen.has(cls.id) || seen.size >= 16) return []; + seen.add(cls.id); + // Instance assignments also shadow methods, even though they are not nodes. + const body = pythonMemberLines(cls.filePath, context).slice(cls.startLine - 1, cls.endLine).join('\n'); + if (new RegExp(`^\\s*(?:(?:self|cls)\\.)?${member}\\s*(?:=|:)`, 'm').test(body)) return [cls]; + const own = context.getNodesByQualifiedName(`${cls.qualifiedName}::${member}`).filter(n => n.filePath === cls.filePath); + if (own.length) return own; + return [...new Map(pythonBases(cls, ref, context).flatMap(p => pythonMembers(p, member, ref, context, seen)).map(n => [n.id, n])).values()]; +} + +function isPythonProperty(node: Node, context: ResolutionContext): boolean { + if (node.language !== 'python' || node.kind !== 'method') return false; + const lines = context.getFileLines?.(node.filePath) ?? context.readFile(node.filePath)?.split('\n') ?? []; + for (let i = node.startLine - 2; i >= 0 && lines[i]!.trim().startsWith('@'); i--) { + if (/^\s*@(?:property|(?:functools\.)?cached_property)\s*$/.test(lines[i]!)) return true; + } + return false; +} + +const PYTHON_MEMBER_LINES = new WeakMap>(); +function pythonMemberLines(filePath: string, context: ResolutionContext): string[] { + let files = PYTHON_MEMBER_LINES.get(context); + if (!files) { files = new Map(); PYTHON_MEMBER_LINES.set(context, files); } + let lines = files.get(filePath); + if (!lines) { + lines = stripCommentsForRegex(context.readFile(filePath) ?? '', 'python').split('\n'); + files.set(filePath, lines); + } + return lines; +} + +function pythonLocalType(receiver: string, ref: UnresolvedRef, context: ResolutionContext): string | null { + if (!/^\w+$/.test(receiver)) return null; + const caller = context.getNodeById?.(ref.fromNodeId); + const lines = pythonMemberLines(ref.filePath, context); + const declaration = new RegExp(`^\\s*${receiver}\\s*(?::\\s*["']?([\\w.]+)["']?)?\\s*=\\s*(.*)$`); + const annotation = new RegExp(`^\\s*${receiver}\\s*:\\s*["']?([\\w.]+)`); + for (let i = ref.line - 1; i >= (caller?.startLine ?? 1) - 1; i--) { + const line = lines[i] ?? ''; + const assigned = line.match(declaration); + if (assigned) return assigned[1] ?? assigned[2]!.match(/^([A-Z][\w.]*)\s*\(/)?.[1] ?? ''; + const declared = line.match(annotation)?.[1]; + if (declared) return declared; + } + return caller?.signature?.match(new RegExp(`\\b${receiver}\\s*:\\s*["']?([\\w.]+)`))?.[1] ?? null; +} + +/** Read a field's own annotation/initializer, or a constructor parameter assigned to it. */ +function pythonFieldType(receiver: string, owner: Node, ref: UnresolvedRef, context: ResolutionContext): string | null { + const lines = pythonMemberLines(ref.filePath, context); + const field = receiver.split('.')[1]!; + const assignment = new RegExp(`^\\s*(?:self|cls)\\.${field}\\s*(?::\\s*["']?([\\w.]+)["']?)?\\s*=\\s*(.*)$`); + const annotation = new RegExp(`^\\s*(?:(?:self|cls)\\.)?${field}\\s*:\\s*["']?([\\w.]+)`); + const methods = context.getNodesInFile(ref.filePath).filter(n => n.kind === 'method' && + n.qualifiedName.startsWith(`${owner.qualifiedName}::`)); + const types = new Set(); + for (let i = owner.startLine; i < owner.endLine; i++) { + const method = methods.find(n => n.startLine <= i + 1 && n.endLine >= i + 1); + if (method && method.name !== '__init__' && method.id !== ref.fromNodeId) continue; + if (method?.id === ref.fromNodeId && i + 1 > ref.line) continue; + const line = lines[i] ?? ''; + const declared = !method || /^\s*(?:self|cls)\./.test(line) ? line.match(annotation)?.[1] : undefined; + if (declared) types.add(declared); + const assigned = line.match(assignment); + if (!assigned) continue; + if (assigned[1]) { types.add(assigned[1]); continue; } + const constructor = assigned[2]!.match(/^([A-Z][\w.]*)\s*\(/)?.[1]; + if (constructor) { types.add(constructor); continue; } + const param = assigned[2]!.trim(); + if (method && /^\w+$/.test(param)) { + const signature = method.signature ?? ''; + const type = signature.match(new RegExp(`\\b${param}\\s*:\\s*["']?([\\w.]+)`))?.[1]; + if (type) types.add(type); + } else { + types.add(''); + } + } + // Conflicting assignments are known-but-ambiguous, never a name-only fallback. + return types.size === 1 ? [...types][0]! : types.size > 1 ? '' : null; +} + /** * Resolve a function-as-value reference (#756) — a function name used as a * callback/function-pointer value (`register(handler)`, `o->cb = handler`, * `{ .cb = handler }`, `signal(SIGINT, handler)`). The ONLY strategy allowed * for `function_ref` refs: exact name, function/method targets only, same - * language family, same-file first, and cross-file only when the match is - * UNIQUE. No fuzzy fallback, no qualified-name walking — a wrong callback - * edge is worse than none. + * language family, same-file first for bare names, and unique-only cross-file. + * Member values use receiver/type/import scope before a unique-name fallback. + * A wrong callback edge is worse than none. */ export function matchFunctionRef( ref: UnresolvedRef, @@ -243,6 +419,10 @@ export function matchFunctionRef( // resolveOne (resolveThisMemberFnRef) — never by name matching here. if (ref.referenceName.startsWith('this.')) return null; + if ((ref.language === 'python' || ref.language === 'go') && ref.referenceName.includes('.')) { + return matchMemberFunctionRef(ref, context); + } + // In JS/TS/Python a bare identifier can never be a method value (methods // are only reachable through a receiver — `this.m` / `self.m` / // `Cls.m`), so bare fn-refs match FUNCTIONS only. This also sidesteps the @@ -1319,6 +1499,7 @@ export function resolveMethodOnType( // block, so Java/Kotlin import disambiguation — whose target is intentionally // in ANOTHER file (#314) — is unaffected: that block returns early whenever // an import FQN pins the class. + if (ref.referenceKind === 'function_ref' && matches.length !== 1) return null; const ordered = preferCallSiteFile(matches, ref.filePath); return { original: ref, @@ -1876,6 +2057,7 @@ function getInferScanStates(context: ResolutionContext): Map