fix(resolution,cli): cross-file static method calls + affected path normalization (#825) (#865)

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:
Colby Mchenry
2026-06-13 14:48:52 -05:00
committed by GitHub
co-authored by Claude Opus 4.8
parent fb974552b0
commit f7441f2124
5 changed files with 192 additions and 0 deletions
+25
View File
@@ -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);
+62
View File
@@ -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];
}