fix(resolution): calls through an imported singleton resolve to the method (#1315)
reproStore.notifyJoinGuildStatus() after `import { reproStore }`
resolved its calls edge to the exported CONSTANT (resolvedBy:'import'),
while the identical same-file call resolved to the method via
local-variable receiver inference (#1108) — so `callers <method>`
missed every cross-file use and a widely-used method could look
unused (#1292).
resolveViaImport's member-descend now handles imported VALUES alongside
the #825 static-member case: when the base resolves to a
constant/variable, the value's type is inferred from ITS OWN
declaration lines in the exporting file (the shared #1108 pattern
table: `= new T(...)` initializers and type annotations) and the member
is resolved AND VALIDATED on that type via resolveMethodOnType. A
failed inference or validation keeps the existing constant edge —
never a fabricated one. Calls only; plain member reads still reference
the value.
excalidraw control: byte-identical graph (10,653 nodes / 19,483 calls
edges before and after).
Fixes #1292
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
ce983a08fe
commit
2ec877b08c
@@ -18,6 +18,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
|
||||||
|
- TypeScript/JavaScript method calls through an imported singleton (`import { store } from './store'; store.notify()`) now resolve to the class method instead of the exported constant, so `codegraph callers` sees cross-file callers of the method — previously only same-file calls were attributed and a method used everywhere could look unused. The same declaration-based type inference applies across the languages that share it (Python, Java, Kotlin, Go, and more), and a failed inference keeps the old edge rather than guessing. (#1292)
|
||||||
- `codegraph node <symbol> -f <file>` now prints the symbol's source body. Pinning an ambiguous name to a specific file (the whole point of `-f` when many files define the same function) returned only the location and caller trail with no code. (#1284)
|
- `codegraph node <symbol> -f <file>` now prints the symbol's source body. Pinning an ambiguous name to a specific file (the whole point of `-f` when many files define the same function) returned only the location and caller trail with no code. (#1284)
|
||||||
- Deleting a whole directory is now picked up by watch mode: the files inside it are removed from the index on the next auto-sync instead of lingering as stale records until an unrelated edit happened to trigger one. Operating systems often report a directory deletion as a single event on the directory itself (with no per-file events for its contents), which the watcher previously discarded. (#1285)
|
- Deleting a whole directory is now picked up by watch mode: the files inside it are removed from the index on the next auto-sync instead of lingering as stale records until an unrelated edit happened to trigger one. Operating systems often report a directory deletion as a single event on the directory itself (with no per-file events for its contents), which the watcher previously discarded. (#1285)
|
||||||
- `codegraph sync` now gets the same slow-disk fix that made full indexing fast in 1.4.0: database checkpointing is deferred for the whole incremental run instead of firing every few megabytes of writes. On mechanical drives and other high-latency storage, a small sync on a large index no longer stalls for minutes at near-zero CPU — the cost of a sync scales with what changed, not with the size of the existing index. The same `CODEGRAPH_NO_WAL_DEFER=1` switch turns it off. (#1248)
|
- `codegraph sync` now gets the same slow-disk fix that made full indexing fast in 1.4.0: database checkpointing is deferred for the whole incremental run instead of firing every few megabytes of writes. On mechanical drives and other high-latency storage, a small sync on a large index no longer stalls for minutes at near-zero CPU — the cost of a sync scales with what changed, not with the size of the existing index. The same `CODEGRAPH_NO_WAL_DEFER=1` switch turns it off. (#1248)
|
||||||
|
|||||||
@@ -2541,6 +2541,61 @@ func main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Imported singleton instance-method calls (#1292)', () => {
|
||||||
|
// `reproStore.notifyJoinGuildStatus()` after `import { reproStore }` used
|
||||||
|
// to emit its calls edge to the CONSTANT (resolvedBy:'import'), while the
|
||||||
|
// identical call in the defining file resolved to the method — so callers
|
||||||
|
// of the method missed every cross-file use. The import path now infers
|
||||||
|
// the value's type from its own declaration and resolves the member on it.
|
||||||
|
it('cross-file call through an imported singleton resolves to the class method', async () => {
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-1292-'));
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.join(tmpDir, 'src'));
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(tmpDir, 'src', 'store.ts'),
|
||||||
|
`export class ReproStore {
|
||||||
|
notifyJoinGuildStatus(): void {
|
||||||
|
console.log('notified');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const reproStore = new ReproStore();
|
||||||
|
|
||||||
|
export function callInDefinitionFile(): void {
|
||||||
|
reproStore.notifyJoinGuildStatus();
|
||||||
|
}
|
||||||
|
`
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(tmpDir, 'src', 'caller.ts'),
|
||||||
|
`import { reproStore } from './store';
|
||||||
|
|
||||||
|
export function callFromImportedFile(): void {
|
||||||
|
reproStore.notifyJoinGuildStatus();
|
||||||
|
}
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
const cg = CodeGraph.initSync(tmpDir);
|
||||||
|
await cg.indexAll();
|
||||||
|
|
||||||
|
const method = (await cg.searchNodes('notifyJoinGuildStatus', { limit: 5 })).find(
|
||||||
|
(r) => r.node.kind === 'method'
|
||||||
|
);
|
||||||
|
expect(method).toBeDefined();
|
||||||
|
|
||||||
|
// BOTH functions call the method — the cross-file one included.
|
||||||
|
const callers = await cg.getCallers(method!.node.id);
|
||||||
|
const callerNames = callers.map((c) => c.node.name).sort();
|
||||||
|
expect(callerNames).toContain('callInDefinitionFile');
|
||||||
|
expect(callerNames).toContain('callFromImportedFile');
|
||||||
|
cg.close();
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
});
|
||||||
|
|
||||||
describe('C++ namespace-qualified static method calls to out-of-line definitions (#1291)', () => {
|
describe('C++ namespace-qualified static method calls to out-of-line definitions (#1291)', () => {
|
||||||
// The issue's exact shape: nested types + out-of-line static method
|
// The issue's exact shape: nested types + out-of-line static method
|
||||||
// definition inside `namespace simulator { }` in the .cpp, called via the
|
// definition inside `namespace simulator { }` in the .cpp, called via the
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ import { Language, Node } from '../types';
|
|||||||
import { UnresolvedRef, ResolvedRef, ResolutionContext, ImportMapping, ReExport } from './types';
|
import { UnresolvedRef, ResolvedRef, ResolutionContext, ImportMapping, ReExport } from './types';
|
||||||
import { applyAliases } from './path-aliases';
|
import { applyAliases } from './path-aliases';
|
||||||
import { resolveWorkspaceImport } from './workspace-packages';
|
import { resolveWorkspaceImport } from './workspace-packages';
|
||||||
|
import {
|
||||||
|
resolveMethodOnType,
|
||||||
|
localReceiverTypePatterns,
|
||||||
|
normalizeInferredTypeName,
|
||||||
|
} from './name-matcher';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extension resolution order by language
|
* Extension resolution order by language
|
||||||
@@ -1512,6 +1517,18 @@ export function resolveViaImport(
|
|||||||
resolvedBy: 'import',
|
resolvedBy: 'import',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
// An imported VALUE (singleton constant / shared instance) called
|
||||||
|
// through a member: `reproStore.notifyJoinGuildStatus()` after
|
||||||
|
// `import { reproStore } from './store'`. findExportedSymbol
|
||||||
|
// resolved the CONSTANT itself; linking the CALL there hides the
|
||||||
|
// real callee — callers of the method miss every cross-file use
|
||||||
|
// and the method can look unused (#1292). Infer the value's type
|
||||||
|
// from its own declaration in the exporting file and resolve the
|
||||||
|
// member on that type. resolveMethodOnType VALIDATES the type
|
||||||
|
// declares the method, so a mis-inference falls through to the
|
||||||
|
// constant edge below rather than fabricating a wrong one.
|
||||||
|
const instanceMember = resolveImportedInstanceMember(targetNode, ref, imp.localName, context);
|
||||||
|
if (instanceMember) return instanceMember;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -2158,6 +2175,48 @@ const STATIC_MEMBER_CONTAINERS = new Set<Node['kind']>([
|
|||||||
* languages whose members aren't `::`-qualified, and genuine class references,
|
* languages whose members aren't `::`-qualified, and genuine class references,
|
||||||
* are unaffected. See #825.
|
* are unaffected. See #825.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Resolve a CALL through an imported value to the method on the value's own
|
||||||
|
* type: `reproStore.notifyJoinGuildStatus()` where `reproStore` is
|
||||||
|
* `export const reproStore = new ReproStore()` in the imported file (#1292).
|
||||||
|
* The same-file form of this call already resolves via local-variable
|
||||||
|
* receiver inference (#1108); this is the cross-file/import half. The type is
|
||||||
|
* recovered from the VALUE'S OWN declaration lines in the exporting file
|
||||||
|
* (initializer `= new T(...)` or a type annotation, per the shared #1108
|
||||||
|
* pattern table), then the member is resolved AND VALIDATED on that type by
|
||||||
|
* resolveMethodOnType — a failed inference or validation returns null so the
|
||||||
|
* caller keeps its existing constant-edge behavior.
|
||||||
|
*/
|
||||||
|
function resolveImportedInstanceMember(
|
||||||
|
value: Node,
|
||||||
|
ref: UnresolvedRef,
|
||||||
|
localName: string,
|
||||||
|
context: ResolutionContext
|
||||||
|
): ResolvedRef | null {
|
||||||
|
if (ref.referenceKind !== 'calls') return null;
|
||||||
|
if (value.kind !== 'constant' && value.kind !== 'variable') return null;
|
||||||
|
const member = ref.referenceName.slice(localName.length + 1).split('.')[0];
|
||||||
|
if (!member) return null;
|
||||||
|
|
||||||
|
const source = context.readFile(value.filePath);
|
||||||
|
if (!source) return null;
|
||||||
|
// Only the value's own declaration lines — never the whole file, so a
|
||||||
|
// same-named identifier elsewhere can't donate a type.
|
||||||
|
const lines = source.split('\n');
|
||||||
|
const declSlice = lines.slice(Math.max(0, value.startLine - 1), value.endLine).join('\n');
|
||||||
|
|
||||||
|
const receiver = value.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
for (const pattern of localReceiverTypePatterns(value.language as Language, receiver)) {
|
||||||
|
const m = declSlice.match(pattern);
|
||||||
|
if (!m || !m[1]) continue;
|
||||||
|
const typeName = normalizeInferredTypeName(m[1]);
|
||||||
|
if (!typeName) continue;
|
||||||
|
const resolved = resolveMethodOnType(typeName, member, ref, context, 0.85, 'instance-method');
|
||||||
|
if (resolved) return resolved;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function resolveStaticMember(
|
function resolveStaticMember(
|
||||||
container: Node,
|
container: Node,
|
||||||
ref: UnresolvedRef,
|
ref: UnresolvedRef,
|
||||||
|
|||||||
@@ -1075,7 +1075,7 @@ const NON_TYPE_RECEIVER_TOKENS = new Set([
|
|||||||
* args and pointer/ref markers, take the last `.`/`::`-qualified segment, and
|
* args and pointer/ref markers, take the last `.`/`::`-qualified segment, and
|
||||||
* reject obvious non-types.
|
* reject obvious non-types.
|
||||||
*/
|
*/
|
||||||
function normalizeInferredTypeName(raw: string): string | null {
|
export function normalizeInferredTypeName(raw: string): string | null {
|
||||||
const cleaned = raw.replace(/<[^>]*>/g, '').replace(/[&*]/g, '').trim();
|
const cleaned = raw.replace(/<[^>]*>/g, '').replace(/[&*]/g, '').trim();
|
||||||
const seg = cleaned.split(/[.:]+/).filter(Boolean).pop();
|
const seg = cleaned.split(/[.:]+/).filter(Boolean).pop();
|
||||||
if (!seg) return null;
|
if (!seg) return null;
|
||||||
@@ -1090,7 +1090,7 @@ function normalizeInferredTypeName(raw: string): string | null {
|
|||||||
* PascalCase is required in the capture where the language convention allows,
|
* PascalCase is required in the capture where the language convention allows,
|
||||||
* as a cheap false-positive guard on top of resolveMethodOnType's validation.
|
* as a cheap false-positive guard on top of resolveMethodOnType's validation.
|
||||||
*/
|
*/
|
||||||
function localReceiverTypePatterns(language: Language, r: string): RegExp[] {
|
export function localReceiverTypePatterns(language: Language, r: string): RegExp[] {
|
||||||
switch (language) {
|
switch (language) {
|
||||||
case 'typescript':
|
case 'typescript':
|
||||||
case 'javascript':
|
case 'javascript':
|
||||||
|
|||||||
Reference in New Issue
Block a user