mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-20 07:22:06 +08:00
* fix(extraction): record callers for methods passed as values (#1820) pool.submit(obj.method), thread_pool_exec(self.store.fetch), and Go Submit(c.store.Fetch) produced no callers/impact edge because only obj.method(...) call expressions were captured. Member values are now emitted as *.method function-refs and resolve unique-or-drop (same-file first). Two methods of the same name still produce no edge. Go go c.store.Fetch(ids) remains a calls edge (it is a call expression). EXTRACTION_VERSION 27: MCP daemon catch-up rebuilds a stale index on start so the watcher does not keep serving the hole after upgrade. * fix(resolution): do not let test mocks veto #1820 method-value callers A unique production method plus a unit-test double of the same name was treated as ambiguous, so retrieve_vision_vectors still looked callback-invisible on a real repo. Prefer the non-test file when exactly one production target remains. * fix(resolution): prefer a real override over a NotImplementedError base #1820 unique-or-drop treated DocStoreConnection.delete_payload_fields (the ABC stub) and QdrantConnection.delete_payload_fields as ambiguous, so gc_stale_member_stamps stayed invisible. Skip short not-implemented stubs when exactly one real implementation remains. * fix(extraction): preserve receivers for method values (#1820) Python attributes and Go selectors used as callback values were dropped or reduced to bare method names. Preserve receiver paths in native and WASM extraction, resolve through type/import scope, and reject ambiguous or noncallable targets. Cover callback, assignment, collection, and query behavior, with extraction version 27 signaling the required reindex. Co-authored-by: Josh66 <info@aska-systems.de> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Josh66 <info@aska-systems.de> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,7 @@ import * as path from 'path';
|
|||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import { CodeGraph } from '../src';
|
import { CodeGraph } from '../src';
|
||||||
import type { Edge } from '../src/types';
|
import type { Edge } from '../src/types';
|
||||||
|
import { ToolHandler } from '../src/mcp/tools';
|
||||||
import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
|
import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
|
||||||
|
|
||||||
beforeAll(async () => {
|
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 () => {
|
it('DRAIN: resolvable function_ref rows leave unresolved_refs; re-index is stable', async () => {
|
||||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-drain-'));
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-drain-'));
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
|
|||||||
@@ -220,6 +220,43 @@ async function exprReceivers(x, y) {
|
|||||||
assertParity('fixtures/torture.go', fs.readFileSync(file, 'utf8'), 'go');
|
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) => {
|
it.each(REAL_SOURCES)('real source parity: %s', (rel) => {
|
||||||
const file = path.join(__dirname, '..', rel);
|
const file = path.join(__dirname, '..', rel);
|
||||||
assertParity(rel, fs.readFileSync(file, 'utf8'), 'typescript');
|
assertParity(rel, fs.readFileSync(file, 'utf8'), 'typescript');
|
||||||
|
|||||||
@@ -1009,6 +1009,31 @@ impl<'t> Walker<'t> {
|
|||||||
row: p.row,
|
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" => {
|
"literal_element" | "expression_list" => {
|
||||||
for i in 0..v.named_child_count() {
|
for i in 0..v.named_child_count() {
|
||||||
if let Some(c) = v.named_child(i) {
|
if let Some(c) = v.named_child(i) {
|
||||||
@@ -1050,6 +1075,7 @@ impl<'t> Walker<'t> {
|
|||||||
let mut seen: HashSet<(String, String)> = HashSet::new();
|
let mut seen: HashSet<(String, String)> = HashSet::new();
|
||||||
for c in cands {
|
for c in cands {
|
||||||
if !c.name.starts_with("this.")
|
if !c.name.starts_with("this.")
|
||||||
|
&& !c.name.contains('.')
|
||||||
&& !c.name.contains("::")
|
&& !c.name.contains("::")
|
||||||
&& !self.defined_fn_names.contains(&c.name)
|
&& !self.defined_fn_names.contains(&c.name)
|
||||||
&& !self.imported_names.contains(&c.name)
|
&& !self.imported_names.contains(&c.name)
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
//! decorators (`@staticmethod` yes, `@app.route(...)` no — python's `call`
|
//! decorators (`@staticmethod` yes, `@app.route(...)` no — python's `call`
|
||||||
//! kind isn't `call_expression`), module-level assignments always extract as
|
//! kind isn't `call_expression`), module-level assignments always extract as
|
||||||
//! `variable` (no isConst hook), and `self.method` fn-ref candidates carry the
|
//! `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.
|
//! refs anywhere. Files with parse errors defer to wasm.
|
||||||
|
|
||||||
use crate::buffers::{
|
use crate::buffers::{
|
||||||
@@ -903,18 +904,16 @@ impl<'t> Walker<'t> {
|
|||||||
for v in values {
|
for v in values {
|
||||||
let (name, anchor) = match v.kind() {
|
let (name, anchor) = match v.kind() {
|
||||||
"identifier" => (self.text(v).to_string(), v),
|
"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" => {
|
"attribute" => {
|
||||||
let obj = v.child_by_field_name("object");
|
let Some(attr) = v.child_by_field_name("attribute") else { continue };
|
||||||
let attr = v.child_by_field_name("attribute");
|
let name = self.text(v);
|
||||||
match (obj, attr) {
|
if !name.split('.').all(|part| {
|
||||||
(Some(o), Some(a))
|
!part.is_empty() && part.chars().enumerate().all(|(i, c)| {
|
||||||
if o.kind() == "identifier" && self.text(o) == "self" =>
|
c == '_' || c.is_ascii_alphabetic() || (i > 0 && c.is_ascii_digit())
|
||||||
{
|
})
|
||||||
(self.text(a).to_string(), a)
|
}) { continue; }
|
||||||
}
|
(name.to_string(), attr)
|
||||||
_ => continue,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
_ => continue,
|
_ => continue,
|
||||||
};
|
};
|
||||||
@@ -964,6 +963,7 @@ impl<'t> Walker<'t> {
|
|||||||
let mut seen: HashSet<(String, String)> = HashSet::new();
|
let mut seen: HashSet<(String, String)> = HashSet::new();
|
||||||
for c in cands {
|
for c in cands {
|
||||||
if !c.name.starts_with("this.")
|
if !c.name.starts_with("this.")
|
||||||
|
&& !c.name.contains('.')
|
||||||
&& !c.name.contains("::")
|
&& !c.name.contains("::")
|
||||||
&& !self.defined_fn_names.contains(&c.name)
|
&& !self.defined_fn_names.contains(&c.name)
|
||||||
&& !self.imported_names.contains(&c.name)
|
&& !self.imported_names.contains(&c.name)
|
||||||
|
|||||||
@@ -21,4 +21,4 @@
|
|||||||
* turns the re-index hint into noise — keep it honest (see CLAUDE.md, "Honesty
|
* turns the re-index hint into noise — keep it honest (see CLAUDE.md, "Honesty
|
||||||
* in the product is load-bearing").
|
* in the product is load-bearing").
|
||||||
*/
|
*/
|
||||||
export const EXTRACTION_VERSION = 26;
|
export const EXTRACTION_VERSION = 27;
|
||||||
|
|||||||
@@ -25,9 +25,10 @@
|
|||||||
*
|
*
|
||||||
* Deliberately NOT covered (resolving the *dispatch* — `o->cb(x)` → the
|
* Deliberately NOT covered (resolving the *dispatch* — `o->cb(x)` → the
|
||||||
* registered function — needs data-flow through struct fields; a wrong edge
|
* registered function — needs data-flow through struct fields; a wrong edge
|
||||||
* is worse than none): indirect-call resolution and `obj.method` member
|
* is worse than none): indirect-call resolution. Member values where the
|
||||||
* values where `obj` isn't `this`/`self` (the receiver's type is statically
|
* receiver isn't `this`/`self` (`pool.submit(obj.method)`, `Submit(c.store.Fetch)`)
|
||||||
* unknowable without local data-flow).
|
* 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';
|
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
|
* referenced cross-file WITHOUT imports (global namespace), so the gate
|
||||||
* can't see them — the strong positional prior (a string argument to
|
* can't see them — the strong positional prior (a string argument to
|
||||||
* `usort`/`array_map`/…) plus resolution's unique-or-drop rule carry the
|
* `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;
|
skipGate?: boolean;
|
||||||
}
|
}
|
||||||
@@ -231,6 +233,7 @@ const GO_SPEC: FnRefSpec = {
|
|||||||
['literal_element', null],
|
['literal_element', null],
|
||||||
['expression_list', null],
|
['expression_list', null],
|
||||||
]),
|
]),
|
||||||
|
special: new Set(['selector_expression']),
|
||||||
};
|
};
|
||||||
|
|
||||||
const RUST_SPEC: FnRefSpec = {
|
const RUST_SPEC: FnRefSpec = {
|
||||||
@@ -699,16 +702,23 @@ function normalizeSpecial(
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Swift `#selector(Holder.fire)` → fire. ObjC `@selector(storeImage:)` →
|
// Go `c.store.Fetch` (has a `field` child) vs Swift `#selector(...)` /
|
||||||
// `storeImage:` verbatim (ObjC method nodes keep their selector colons).
|
// ObjC `@selector(...)` (no `field` — inner identifier / selector text).
|
||||||
case 'selector_expression': {
|
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);
|
const inner = node.namedChild(0);
|
||||||
if (!inner) return [];
|
if (!inner) return [];
|
||||||
if (inner.type === 'identifier' || inner.type === 'simple_identifier') {
|
if (inner.type === 'identifier' || inner.type === 'simple_identifier') {
|
||||||
return [{ name: getNodeText(inner, source), node: inner }];
|
return [{ name: getNodeText(inner, source), node: inner }];
|
||||||
}
|
}
|
||||||
// Swift dotted form: rightmost simple_identifier. ObjC keyword selector:
|
// Swift dotted form: rightmost simple_identifier; ObjC keeps selector text.
|
||||||
// text as-is.
|
|
||||||
const last = lastNamedOfType(node, new Set(['simple_identifier']));
|
const last = lastNamedOfType(node, new Set(['simple_identifier']));
|
||||||
if (last) return [{ name: getNodeText(last, source), node: last }];
|
if (last) return [{ name: getNodeText(last, source), node: last }];
|
||||||
return [{ name: getNodeText(inner, source).trim(), node: inner }];
|
return [{ name: getNodeText(inner, source).trim(), node: inner }];
|
||||||
@@ -739,14 +749,13 @@ function normalizeSpecial(
|
|||||||
return [];
|
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': {
|
case 'attribute': {
|
||||||
const obj = getChildByField(node, 'object');
|
|
||||||
const attr = getChildByField(node, 'attribute');
|
const attr = getChildByField(node, 'attribute');
|
||||||
if (obj && attr && obj.type === 'identifier' && getNodeText(obj, source) === 'self') {
|
const name = getNodeText(node, source);
|
||||||
return [{ name: getNodeText(attr, source), node: attr }];
|
return attr && /^[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)+$/.test(name)
|
||||||
}
|
? [{ name, node: attr, skipGate: true }] : [];
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// `this.Run0` (C#) — receiver must be EXACTLY `this`. Two grammar shapes:
|
// `this.Run0` (C#) — receiver must be EXACTLY `this`. Two grammar shapes:
|
||||||
|
|||||||
@@ -1648,13 +1648,19 @@ export function resolveViaImport(
|
|||||||
for (const imp of imports) {
|
for (const imp of imports) {
|
||||||
if (imp.localName === ref.referenceName || ref.referenceName.startsWith(imp.localName + '.')) {
|
if (imp.localName === ref.referenceName || ref.referenceName.startsWith(imp.localName + '.')) {
|
||||||
// Resolve the import path
|
// Resolve the import path
|
||||||
const resolvedPath = resolveImportPath(
|
let resolvedPath = resolveImportPath(
|
||||||
imp.source,
|
imp.source,
|
||||||
ref.filePath,
|
ref.filePath,
|
||||||
ref.language,
|
ref.language,
|
||||||
context
|
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) {
|
if (resolvedPath) {
|
||||||
const exportedName = imp.isDefault ? 'default' : imp.exportedName;
|
const exportedName = imp.isDefault ? 'default' : imp.exportedName;
|
||||||
const memberName = imp.isNamespace
|
const memberName = imp.isNamespace
|
||||||
@@ -1667,7 +1673,11 @@ export function resolveViaImport(
|
|||||||
ref.language,
|
ref.language,
|
||||||
context,
|
context,
|
||||||
new Set()
|
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) {
|
if (targetNode) {
|
||||||
// `Foo.bar()` / `Foo.CONST` — a NAMED (non-namespace) class import
|
// `Foo.bar()` / `Foo.CONST` — a NAMED (non-namespace) class import
|
||||||
|
|||||||
@@ -973,6 +973,9 @@ export class ReferenceResolver {
|
|||||||
if (ref.referenceName.startsWith('this.')) {
|
if (ref.referenceName.startsWith('this.')) {
|
||||||
return this.gateLanguage(this.resolveThisMemberFnRef(ref), ref);
|
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);
|
const viaImport = this.gateLanguage(resolveViaImport(ref, this.context), ref);
|
||||||
if (viaImport) {
|
if (viaImport) {
|
||||||
const target = this.queries.getNodeById(viaImport.targetNodeId);
|
const target = this.queries.getNodeById(viaImport.targetNodeId);
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { Language, Node } from '../types';
|
|||||||
import { UnresolvedRef, ResolvedRef, ResolutionContext, isSupertypeTarget, isInheritanceRef, isImportableKind } from './types';
|
import { UnresolvedRef, ResolvedRef, ResolutionContext, isSupertypeTarget, isInheritanceRef, isImportableKind } from './types';
|
||||||
import { blankStringContents, stripCommentsForRegex } from './strip-comments';
|
import { blankStringContents, stripCommentsForRegex } from './strip-comments';
|
||||||
import { JS_BUILT_INS, JS_BUILTIN_METHODS, TS_PRIMITIVE_TYPES } from './js-builtins';
|
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
|
* 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
|
* score. A name defined more times than this is "ubiquitous" — a method/symbol
|
||||||
@@ -226,14 +225,191 @@ export function gateLanguageMatch(
|
|||||||
return result;
|
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<string>()): 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<string>()): 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<ResolutionContext, Map<string, string[]>>();
|
||||||
|
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] ?? '<unknown>';
|
||||||
|
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<string>();
|
||||||
|
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('<unknown>');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Conflicting assignments are known-but-ambiguous, never a name-only fallback.
|
||||||
|
return types.size === 1 ? [...types][0]! : types.size > 1 ? '<ambiguous>' : null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve a function-as-value reference (#756) — a function name used as a
|
* Resolve a function-as-value reference (#756) — a function name used as a
|
||||||
* callback/function-pointer value (`register(handler)`, `o->cb = handler`,
|
* callback/function-pointer value (`register(handler)`, `o->cb = handler`,
|
||||||
* `{ .cb = handler }`, `signal(SIGINT, handler)`). The ONLY strategy allowed
|
* `{ .cb = handler }`, `signal(SIGINT, handler)`). The ONLY strategy allowed
|
||||||
* for `function_ref` refs: exact name, function/method targets only, same
|
* for `function_ref` refs: exact name, function/method targets only, same
|
||||||
* language family, same-file first, and cross-file only when the match is
|
* language family, same-file first for bare names, and unique-only cross-file.
|
||||||
* UNIQUE. No fuzzy fallback, no qualified-name walking — a wrong callback
|
* Member values use receiver/type/import scope before a unique-name fallback.
|
||||||
* edge is worse than none.
|
* A wrong callback edge is worse than none.
|
||||||
*/
|
*/
|
||||||
export function matchFunctionRef(
|
export function matchFunctionRef(
|
||||||
ref: UnresolvedRef,
|
ref: UnresolvedRef,
|
||||||
@@ -243,6 +419,10 @@ export function matchFunctionRef(
|
|||||||
// resolveOne (resolveThisMemberFnRef) — never by name matching here.
|
// resolveOne (resolveThisMemberFnRef) — never by name matching here.
|
||||||
if (ref.referenceName.startsWith('this.')) return null;
|
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
|
// In JS/TS/Python a bare identifier can never be a method value (methods
|
||||||
// are only reachable through a receiver — `this.m` / `self.m` /
|
// are only reachable through a receiver — `this.m` / `self.m` /
|
||||||
// `Cls.m`), so bare fn-refs match FUNCTIONS only. This also sidesteps the
|
// `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
|
// block, so Java/Kotlin import disambiguation — whose target is intentionally
|
||||||
// in ANOTHER file (#314) — is unaffected: that block returns early whenever
|
// in ANOTHER file (#314) — is unaffected: that block returns early whenever
|
||||||
// an import FQN pins the class.
|
// an import FQN pins the class.
|
||||||
|
if (ref.referenceKind === 'function_ref' && matches.length !== 1) return null;
|
||||||
const ordered = preferCallSiteFile(matches, ref.filePath);
|
const ordered = preferCallSiteFile(matches, ref.filePath);
|
||||||
return {
|
return {
|
||||||
original: ref,
|
original: ref,
|
||||||
@@ -1876,6 +2057,7 @@ function getInferScanStates(context: ResolutionContext): Map<string, InferScanSt
|
|||||||
/** Drop the per-context scan states (see ReferenceResolver.clearCaches). */
|
/** Drop the per-context scan states (see ReferenceResolver.clearCaches). */
|
||||||
export function clearNameMatcherMemos(context: ResolutionContext): void {
|
export function clearNameMatcherMemos(context: ResolutionContext): void {
|
||||||
INFER_SCAN_STATES.delete(context);
|
INFER_SCAN_STATES.delete(context);
|
||||||
|
PYTHON_MEMBER_LINES.delete(context);
|
||||||
AWAITED_TYPE_MEMO.delete(context);
|
AWAITED_TYPE_MEMO.delete(context);
|
||||||
AWAITED_FILES.delete(context);
|
AWAITED_FILES.delete(context);
|
||||||
C_STATIC_MEMO.delete(context);
|
C_STATIC_MEMO.delete(context);
|
||||||
|
|||||||
Reference in New Issue
Block a user