Cross-file `ClassName.staticMethod()` calls resolved to the class, not the method: the import resolver matched the receiver `Foo` to the named class import but dropped the `.bar` member, and createEdges then mis-promoted the `calls` edge to `instantiates`. So callers/impact for the static method came back empty. Descend from the resolved class into its `Container::member` so the call links to the method; fall back to the class when no such member exists (non-`::` languages and genuine class references are unaffected). Also normalize `codegraph affected` inputs to the project-relative, forward-slash form the index stores, so `./src/x.ts`, an absolute path, and a Windows back-slash path all match (previously silently returned 0). Validated on luxon (24 files): node/edge totals identical (no explosion), 69 mis-promoted `instantiates` edges become `calls`, and real static factories (DateTime.fromISO, etc.) resolve their callers. Full suite: 1534 passed. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
fb974552b0
commit
f7441f2124
@@ -1198,6 +1198,23 @@ program
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Normalize a user-supplied file path to the project-relative, forward-slash
|
||||
* form CodeGraph stores in the index. Accepts an absolute path, a `./`-prefixed
|
||||
* path, or Windows back-slashes; an empty string when the input is blank. Used
|
||||
* by `codegraph affected` so `./src/x.ts`, `/abs/repo/src/x.ts`, and
|
||||
* `src/x.ts` all match the same indexed file. (#825)
|
||||
*/
|
||||
function normalizeIndexPath(filePath: string, projectPath: string): string {
|
||||
let f = filePath.trim();
|
||||
if (!f) return '';
|
||||
if (path.isAbsolute(f)) f = path.relative(projectPath, f);
|
||||
// Collapse `.`/`..` segments, then force forward slashes and drop a leading
|
||||
// `./` (path.normalize already strips it on POSIX; explicit for Windows).
|
||||
f = path.normalize(f).replace(/\\/g, '/').replace(/^\.\//, '');
|
||||
return f;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert glob pattern to regex
|
||||
*/
|
||||
@@ -1710,6 +1727,14 @@ program
|
||||
changedFiles.push(...stdinFiles);
|
||||
}
|
||||
|
||||
// Normalize inputs to the project-relative, forward-slash form the index
|
||||
// stores. Without this, `affected ./src/x.ts`, an absolute path (what a
|
||||
// wrapping script often passes), or a Windows back-slash path silently
|
||||
// matches nothing and reports 0 affected tests. (#825)
|
||||
changedFiles = changedFiles
|
||||
.map((f) => normalizeIndexPath(f, projectPath))
|
||||
.filter(Boolean);
|
||||
|
||||
if (changedFiles.length === 0) {
|
||||
if (!options.quiet) info('No files provided. Use file arguments or --stdin.');
|
||||
process.exit(0);
|
||||
|
||||
@@ -1296,6 +1296,25 @@ export function resolveViaImport(
|
||||
);
|
||||
|
||||
if (targetNode) {
|
||||
// `Foo.bar()` / `Foo.CONST` — a NAMED (non-namespace) class import
|
||||
// accessed through a member. `findExportedSymbol` resolved `Foo` to
|
||||
// the class itself; descend into it so the reference links to the
|
||||
// member `bar`, not the class. Without this the edge points at the
|
||||
// class and `createEdges` then mis-promotes the call to an
|
||||
// `instantiates` edge, so the static method shows zero callers and a
|
||||
// hollow impact radius. (#825)
|
||||
if (!imp.isNamespace && ref.referenceName.startsWith(imp.localName + '.')) {
|
||||
const memberNode = resolveStaticMember(targetNode, ref, imp.localName, context);
|
||||
if (memberNode) {
|
||||
return {
|
||||
original: ref,
|
||||
targetNodeId: memberNode.id,
|
||||
confidence: 0.9,
|
||||
resolvedBy: 'import',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
original: ref,
|
||||
targetNodeId: targetNode.id,
|
||||
@@ -1896,3 +1915,46 @@ function findExportedSymbol(
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Node kinds that own static members reachable as `Container.member`. */
|
||||
const STATIC_MEMBER_CONTAINERS = new Set<Node['kind']>([
|
||||
'class', 'struct', 'interface', 'enum', 'trait', 'protocol',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Resolve `Container.member` — a static method/property access on a NAMED class
|
||||
* import (`import { Foo } …; Foo.bar()`) — to the member node, given the
|
||||
* already-resolved container class.
|
||||
*
|
||||
* Members carry a `Container::member` qualifiedName, so we look up
|
||||
* `${container.qualifiedName}::${member}` within the container's own file (the
|
||||
* file filter disambiguates same-named classes in other modules). Returns
|
||||
* undefined when the container isn't a member-owning kind or the member isn't
|
||||
* found, so the caller falls back to the container itself (prior behavior) —
|
||||
* languages whose members aren't `::`-qualified, and genuine class references,
|
||||
* are unaffected. See #825.
|
||||
*/
|
||||
function resolveStaticMember(
|
||||
container: Node,
|
||||
ref: UnresolvedRef,
|
||||
localName: string,
|
||||
context: ResolutionContext
|
||||
): Node | undefined {
|
||||
if (!STATIC_MEMBER_CONTAINERS.has(container.kind)) return undefined;
|
||||
// First segment after the receiver: `Foo.bar.baz` → `bar`.
|
||||
const member = ref.referenceName.slice(localName.length + 1).split('.')[0];
|
||||
if (!member) return undefined;
|
||||
|
||||
const candidates = context
|
||||
.getNodesByQualifiedName(`${container.qualifiedName}::${member}`)
|
||||
.filter((n) => n.filePath === container.filePath);
|
||||
if (candidates.length === 0) return undefined;
|
||||
|
||||
// When the reference is a call, prefer a callable member if several nodes
|
||||
// share the qualifiedName (e.g. a static property and a method).
|
||||
if (ref.referenceKind === 'calls') {
|
||||
const callable = candidates.find((n) => n.kind === 'method' || n.kind === 'function');
|
||||
if (callable) return callable;
|
||||
}
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user