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
+63
View File
@@ -0,0 +1,63 @@
/**
* `codegraph affected` input-path normalization (#825).
*
* The index stores project-relative, forward-slash paths. A user (or a wrapping
* script) may pass a `./`-prefixed path or an absolute path; before #825 those
* silently matched nothing and reported 0 affected tests. All three spellings
* must now resolve the same affected test file.
*
* Exercised end-to-end against the built binary.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { execFileSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');
function affected(cwd: string, arg: string): string[] {
const out = execFileSync(process.execPath, [BIN, 'affected', arg, '--quiet', '-p', cwd], {
encoding: 'utf-8',
env: { ...process.env, CODEGRAPH_NO_DAEMON: '1', CODEGRAPH_WASM_RELAUNCHED: '1' },
stdio: ['ignore', 'pipe', 'pipe'],
});
return out.split('\n').map((s) => s.trim()).filter(Boolean);
}
describe('codegraph affected — input path normalization (#825)', () => {
let tempDir: string;
beforeEach(async () => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-affected-paths-'));
fs.mkdirSync(path.join(tempDir, 'src'));
// util.ts <- helper.ts <- helper.test.ts (transitive test dependency)
fs.writeFileSync(path.join(tempDir, 'src/util.ts'), 'export function util(x: number){ return x + 1; }\n');
fs.writeFileSync(
path.join(tempDir, 'src/helper.ts'),
"import { util } from './util';\nexport function helper(){ return util(1); }\n",
);
fs.writeFileSync(
path.join(tempDir, 'src/helper.test.ts'),
"import { helper } from './helper';\ntest('t', () => helper());\n",
);
const cg = CodeGraph.initSync(tempDir);
await cg.indexAll();
cg.close();
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it('bare-relative, ./-prefixed, and absolute paths all resolve the same affected test', () => {
const expected = ['src/helper.test.ts'];
// Baseline that always worked.
expect(affected(tempDir, 'src/util.ts')).toEqual(expected);
// Both of these returned [] before the normalization fix.
expect(affected(tempDir, './src/util.ts')).toEqual(expected);
expect(affected(tempDir, path.join(tempDir, 'src/util.ts'))).toEqual(expected);
});
});
+40
View File
@@ -768,6 +768,46 @@ def bootstrap():
expect(callsToUserService).toHaveLength(0);
});
it('resolves a cross-file static method call to the method, not the class (#825)', async () => {
// `Foo.bar()` where `Foo` is an imported class must link to the static
// method `Foo::bar`, NOT to the class `Foo`. Previously the import
// resolver dropped the `.bar` member and resolved to `Foo`, which the
// calls→instantiates promotion then turned into `run instantiates Foo`,
// leaving the static method with zero callers and a hollow impact radius.
fs.writeFileSync(
path.join(tempDir, 'helpers.ts'),
`export class Foo {\n static bar(x: number) { return x + 1; }\n}\n`
);
fs.writeFileSync(
path.join(tempDir, 'caller.ts'),
`import { Foo } from './helpers';\nexport function run() { return Foo.bar(41); }\n`
);
cg = await CodeGraph.init(tempDir, { index: true });
cg.resolveReferences();
const bar = cg.getNodesByKind('method').find((n) => n.name === 'bar');
const foo = cg.getNodesByKind('class').find((n) => n.name === 'Foo');
const run = cg.getNodesByKind('function').find((n) => n.name === 'run');
expect(bar).toBeDefined();
expect(foo).toBeDefined();
expect(run).toBeDefined();
// `run` is reported as a caller of the static method `Foo.bar`.
const barCallers = cg.getCallers(bar!.id).map((c) => c.node.name);
expect(barCallers).toContain('run');
// And the call is NOT mis-promoted to `run instantiates Foo`.
const outgoing = cg.getOutgoingEdges(run!.id);
expect(
outgoing.filter((e) => e.kind === 'instantiates' && e.target === foo!.id)
).toHaveLength(0);
// The real edge is a `calls` edge to the method.
expect(
outgoing.some((e) => e.kind === 'calls' && e.target === bar!.id)
).toBe(true);
});
it('resolves Go cross-package qualified calls via go.mod module path (#388)', async () => {
// Pre-#388, every `pkga.FuncX(...)` call in a Go monorepo was flagged
// external (isExternalImport returned true for any non-`/internal/`