mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-17 05:07:37 +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 { 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(
|
||||
|
||||
Reference in New Issue
Block a user