diff --git a/__tests__/browser-host-globals.test.ts b/__tests__/browser-host-globals.test.ts new file mode 100644 index 000000000..23399c05a --- /dev/null +++ b/__tests__/browser-host-globals.test.ts @@ -0,0 +1,207 @@ +/** + * Calls on the browser's own objects link to no project method (#2907). + * + * `window.open('/help')`, `localStorage.getItem('token')`, `location.reload()` + * and `history.back()` are DOM calls, yet each linked to a project class's + * method of the same name — an app's `WindowService.open`, `StorageService. + * getItem`, `PageService.back` — so `callers` and impact on such a wrapper + * listed every direct DOM call in the app. Three readings did it: a receiver + * with no type was guessed by its word or a lone method name; a chain from + * `window` (`window.localStorage.getItem()`), recorded by its bare method name + * so a project global hung on `window` can be found (#1707), fell to the + * exact-name match; and a JS `self.close()` was read as `this.close()`, + * though an unbound `self` is the global object. + * + * A name the file binds or imports is still its own, and `this` is still the + * instance. + */ +import { describe, it, expect, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; + +let dirs: string[] = []; +let graphs: CodeGraph[] = []; +afterEach(() => { + for (const graph of graphs) graph.close(); + for (const dir of dirs) fs.rmSync(dir, { recursive: true, force: true }); + graphs = []; + dirs = []; +}); + +async function index(files: Record): Promise { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-host-globals-')); + dirs.push(dir); + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true }); + fs.writeFileSync(path.join(dir, rel), content); + } + const cg = await CodeGraph.init(dir, { index: true }); + graphs.push(cg); + return cg; +} + +/** `caller -> target` for every call edge out of `fromFile`, by qualified name. */ +function callsFrom(cg: CodeGraph, fromFile: string): string[] { + return cg + .getNodesInFile(fromFile) + .flatMap((n) => + cg + .getOutgoingEdges(n.id) + .filter((e) => e.kind === 'calls') + .map((e) => `${n.qualifiedName} -> ${cg.getNode(e.target)?.qualifiedName}`) + ) + .sort(); +} + +const SERVICES = [ + 'export class WindowService {', + ' open(url: string): void {}', + ' close(): void {}', + ' postMessage(m: unknown): void {}', + '}', + 'export class StorageService {', + ' getItem(key: string): string | null { return null; }', + '}', + 'export class PageService {', + ' reload(): void {}', + ' back(): void {}', + '}', + '', +].join('\n'); + +describe('calls on browser host objects', () => { + it('link no project method, written directly or through window or self', async () => { + const cg = await index({ + 'src/services.ts': SERVICES, + 'src/browser.ts': [ + "export function openHelp(): void { window.open('/help'); }", + "export function readToken(): string | null { return localStorage.getItem('token'); }", + 'export function refresh(): void { location.reload(); }', + 'export function goBack(): void { history.back(); }', + "export function readTokenChained(): string | null { return window.localStorage.getItem('token'); }", + 'export function goBackChained(): void { window.history.back(); }', + 'export function closeSelf(): void { self.close(); }', + 'export class Popup {', + ' dismiss(): void { self.close(); }', + ' close(): void {}', + '}', + '', + ].join('\n'), + 'src/worker.js': "self.postMessage('ready');\nexport function reply(x) { self.postMessage(x); }\n", + }); + expect(callsFrom(cg, 'src/browser.ts')).toEqual([]); + expect(callsFrom(cg, 'src/worker.js')).toEqual([]); + }); + + it('still link a name the file binds, imports or takes as a parameter', async () => { + const cg = await index({ + 'src/router.ts': [ + 'export class Router { forward(): void {} }', + 'export function createRouter(): Router { return new Router(); }', + '', + ].join('\n'), + 'src/nav.ts': 'export const history = { push(to: string): void {} };\n', + 'src/loc.ts': 'export class Loc { reload(): void {} }\n', + 'src/uses.ts': [ + "import { createRouter } from './router';", + "import { history } from './nav';", + "import { Loc } from './loc';", + 'export function local(): void {', + ' const navigator = createRouter();', + ' navigator.forward();', + '}', + "export function imported(): void { history.push('/x'); }", + 'export function param(location: Loc): void { location.reload(); }', + '', + ].join('\n'), + }); + expect(callsFrom(cg, 'src/uses.ts')).toEqual([ + 'imported -> history::push', + 'local -> Router::forward', + 'local -> createRouter', + 'param -> Loc::reload', + ]); + }); + + it('still link a destructured parameter named like one, and decline after a `typeof` guard', async () => { + // outline's commands and scenes take their Document model as `({ document }: Props)`. + const cg = await index({ + 'src/models/Model.ts': 'export class Model {\n save(): void {}\n}\n', + 'src/models/Document.ts': "import Model from './Model';\nexport default class Document extends Model {\n save(): void {}\n archive(): void {}\n}\n", + 'src/services.ts': SERVICES, + 'src/DocumentPublish.tsx': [ + "import type Document from './models/Document';", + '', + 'type Props = {', + ' /** The document to publish. */', + ' document: Document;', + '};', + '', + 'export function DocumentPublish({ document }: Props) {', + ' const publish = async () => {', + ' await document.save();', + ' };', + ' return publish;', + '}', + '', + ].join('\n'), + 'src/help.ts': [ + 'export function openHelp(): void {', + " if (typeof window !== 'undefined') window.open('/help');", + '}', + 'export function goBack(nav: { canGoBack: boolean }): void {', + ' if (nav.canGoBack && window.history.length > 1) history.back();', + '}', + '', + ].join('\n'), + }); + expect(callsFrom(cg, 'src/DocumentPublish.tsx')).toEqual(['DocumentPublish::publish -> Document::save']); + expect(callsFrom(cg, 'src/help.ts')).toEqual([]); + }); + + it('still read `this`, a `self` the file binds to it, and a project object hung on window', async () => { + const cg = await index({ + 'src/dialog.js': [ + 'export class Dialog {', + ' close() {}', + ' open() { this.close(); }', + ' later() { var self = this; setTimeout(function () { self.close(); }); }', + '}', + 'export const store = {', + ' close() {},', + ' open() { this.close(); },', + '};', + '', + ].join('\n'), + 'src/app.js': 'window.App = {\n init() {},\n};\nexport function boot() { window.App.init(); }\n', + }); + expect(callsFrom(cg, 'src/dialog.js')).toEqual([ + 'Dialog::later -> Dialog::close', + 'Dialog::open -> Dialog::close', + 'store::open -> store::close', + ]); + expect(callsFrom(cg, 'src/app.js')).toEqual(['boot -> window.App::init']); + }); + + it('reads a name after non-ASCII text without panicking, and a parameter inside a template', async () => { + const cg = await index({ + 'src/services.ts': [ + 'export class WindowService { open(url: string): void {} }', + 'export class Model { save(): void {} }', + ].join('\n'), + 'src/wide.ts': [ + "export function wide(π: number): void { if (π > 0) { window.open('/help'); } }", + ].join('\n'), + 'src/template.ts': [ + 'import { Model } from "./services";', + 'export function run(model: unknown): unknown {', + ' return `${((document: Model) => document.save())(model)}`;', + '}', + ].join('\n'), + }); + expect(callsFrom(cg, 'src/wide.ts')).toEqual([]); + expect(callsFrom(cg, 'src/template.ts')).toEqual(['run -> Model::save']); + }); +}); diff --git a/__tests__/enclosing-class-member-call.test.ts b/__tests__/enclosing-class-member-call.test.ts new file mode 100644 index 000000000..543d35269 --- /dev/null +++ b/__tests__/enclosing-class-member-call.test.ts @@ -0,0 +1,211 @@ +/** + * A call that means a member of the class it is written in — a receiver-less + * `render()` in Java / Scala, or `this.render()` / `self.render()` in + * TypeScript / Python — binds to that class's + * member (then what it inherits, then an enclosing class), not to whichever + * same-named method is declared nearest the call. Each fixture puts the + * caller's own `render` far above the call and a sibling class's `render` + * right below it, which is what the same-file line-distance term used to pick. + * C++ has its own class lookup (matchCppImplicitThisCall); its cases here pin + * that it keeps the same answer. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import CodeGraph from '../src/index'; + +let tempDir: string; +let cg: CodeGraph | null = null; + +afterEach(() => { + cg?.close(); + cg = null; + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +const filler = (prefix: string): string => + Array.from({ length: 40 }, (_, i) => `${prefix} filler ${i}`).join('\n'); + +/** qualified names of every `calls` target of the method/function `from`. */ +async function callees(file: string, source: string, from: string): Promise { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-enclosing-')); + fs.writeFileSync(path.join(tempDir, file), source); + cg = await CodeGraph.init(tempDir, { index: true }); + cg.resolveReferences(); + const caller = [...cg.getNodesByKind('method'), ...cg.getNodesByKind('function')].find( + (n) => n.qualifiedName === from + ); + expect(caller).toBeDefined(); + return cg + .getOutgoingEdges(caller!.id) + .filter((e) => e.kind === 'calls') + .map((e) => cg!.getNode(e.target)?.qualifiedName ?? '?'); +} + +const OWN_CLASS: Array<[string, string, string, string]> = [ + ['Scala', 'A.scala', `class A { + def render(): String = "a" +${filler(' //')} + def show(): String = render() +} +class B { + def render(): String = "b" +} +`, 'A::show'], + ['C++ (inline members)', 'a.cpp', `class A { +public: + int render() { return 1; } +${filler(' //')} + int show() { return render(); } +}; +class B { +public: + int render() { return 2; } +}; +`, 'A::show'], + ['C++ (out-of-line members)', 'b.cpp', `class A { public: int render(); int show(); }; +class B { public: int render(); }; +int A::render() { return 1; } +${filler('//')} +int A::show() { return render(); } +int B::render() { return 2; } +`, 'A::show'], + ['Python (self.)', 'a.py', `class A: + def render(self): + return "a" +${filler(' #')} + def show(self): + return self.render() +class B: + def render(self): + return "b" +`, 'A::show'], + ['TypeScript (this.)', 'a.ts', `export class A { + render() { return "a"; } +${filler(' //')} + show() { return this.render(); } +} +export class B { + render() { return "b"; } +} +`, 'A::show'], +]; + +describe('a call meaning a member of the enclosing class', () => { + it.each(OWN_CLASS)('%s: binds to the caller\'s own class, not the nearer sibling', async (_lang, file, source, from) => { + const out = await callees(file, source, from); + expect(out.map((q) => q.toLowerCase())).toContain('a::render'); + expect(out.map((q) => q.toLowerCase())).not.toContain('b::render'); + }); + + it('Java: `super.render()` skips the overriding method itself', async () => { + const out = await callees('S.java', `class Base { + String render() { return "base"; } +} +${filler('//')} +class Kid extends Base { + String render() { return super.render(); } +} +`, 'Kid::render'); + expect(out).toEqual(['Base::render']); + }); + + it('Java: same-named nested types only inherit from what their own header names', async () => { + const out = await callees('Msg.java', `interface AOrBuilder { String describe(); } +interface BOrBuilder { String describe(); } +class A { + static class Builder implements AOrBuilder { + public String describe() { return "a"; } + } +} +class B { + static class Builder implements BOrBuilder { + String show() { return describe(); } + public String describe() { return "b"; } + } +} +class C { + static abstract class Builder implements AOrBuilder { +${filler(' //')} + String show() { return describe(); } + } +} +`, 'C::Builder::show'); + expect(out).toEqual(['AOrBuilder::describe']); + }); +}); + +/** `qualifiedName@file` of every `calls` target of `from`, in a project of several files. */ +async function calleesIn(files: Record, from: string): Promise { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-enclosing-')); + for (const [rel, text] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(tempDir, rel)), { recursive: true }); + fs.writeFileSync(path.join(tempDir, rel), text); + } + cg = await CodeGraph.init(tempDir, { index: true }); + cg.resolveReferences(); + const caller = [...cg.getNodesByKind('method'), ...cg.getNodesByKind('function')].find((n) => n.qualifiedName === from); + expect(caller).toBeDefined(); + return cg + .getOutgoingEdges(caller!.id) + .filter((e) => e.kind === 'calls') + .map((e) => { + const target = cg!.getNode(e.target); + return target ? `${target.qualifiedName}@${target.filePath}` : '?'; + }); +} + +describe('which inherited member a call reaches', () => { + it('Python: a base named like types in other files is not taken for the one another file declares', async () => { + const out = await calleesIn({ + 'a/base.py': 'class BaseEditor:\n def execute(self, sql):\n return sql\n', + 'b/pg.py': 'from a.base import BaseEditor\n\nclass DatabaseSchemaEditor(BaseEditor):\n def execute(self, sql):\n return super().execute(sql)\n', + 'c/lite.py': 'from a.base import BaseEditor\n\nclass DatabaseSchemaEditor(BaseEditor):\n sql_create = "x"\n', + 'd/gis.py': 'from c.lite import DatabaseSchemaEditor\n\nclass GisEditor(DatabaseSchemaEditor):\n def drop(self):\n return self.execute("drop")\n', + }, 'GisEditor::drop'); + expect(out).not.toContain('DatabaseSchemaEditor::execute@b/pg.py'); + }); + + it('Python: the first base listed that has the member is the one `self.` reaches', async () => { + const files = { + 'mixins.py': 'class Second:\n def describe(self):\n return "second"\n\nclass First:\n def describe(self):\n return "first"\n\n' + + 'class Both(First, Second):\n def show(self):\n return self.describe()\n\n' + + 'class Other(Second, First):\n def show(self):\n return self.describe()\n', + }; + expect(await calleesIn(files, 'Both::show')).toEqual(['First::describe@mixins.py']); + cg?.close(); + cg = null; + fs.rmSync(tempDir, { recursive: true, force: true }); + expect(await calleesIn(files, 'Other::show')).toEqual(['Second::describe@mixins.py']); + }); + + it('Java: a class further up that implements the member wins over an interface that only declares it', async () => { + const out = await calleesIn({ + 'p/Rotation.java': 'package p;\ninterface Rotation {\n String current();\n}\n', + 'p/Dispatcher.java': 'package p;\nclass Dispatcher {\n String current() { return "d"; }\n}\n', + 'p/Contextual.java': 'package p;\nclass Contextual extends Dispatcher {\n}\n', + 'p/RotationButton.java': 'package p;\nclass RotationButton extends Contextual implements Rotation {\n String show() { return current(); }\n}\n', + }, 'p::RotationButton::show'); + expect(out).toEqual(['p::Dispatcher::current@p/Dispatcher.java']); + }); + + it('Java: a variable named `parent` is not `super`', async () => { + // The extractor drops the `parent.` receiver, so the call arrives as a + // bare `getName()`; it must not be read as `super.getName()`. + const out = await calleesIn({ + 'p/Base.java': 'package p;\nclass Base {\n String getName() { return "base"; }\n}\n', + 'p/Child.java': 'package p;\nclass Node {\n String getName() { return "node"; }\n}\nclass Child extends Base {\n Node parent;\n String getName() { return "child"; }\n String show() { return parent.getName(); }\n}\n', + }, 'p::Child::show'); + expect(out).not.toContain('p::Base::getName@p/Base.java'); + }); + + it('C++: a call in a namespace takes the overload its arguments fit, not the one in its own file', async () => { + const out = await calleesIn({ + 'core.h': '#pragma once\nnamespace detail {\ntemplate Out copy(In b, In e, Out out) { return out; }\n}\n', + 'format.h': '#pragma once\n#include "core.h"\nnamespace detail {\ntemplate Out copy(V s, Out out) { return out; }\ntemplate Out write_it(Out out, const Char* b, const Char* e) {\n return copy(b, e, out);\n}\n}\n', + }, 'detail::write_it'); + expect(out).toEqual(['detail::copy@core.h']); + }); +}); diff --git a/__tests__/evaluation/results/precision-flask-d73fa1c-3fa7ac1f.json b/__tests__/evaluation/results/precision-flask-d73fa1c-3fa7ac1f.json new file mode 100644 index 000000000..e53d8b0a1 --- /dev/null +++ b/__tests__/evaluation/results/precision-flask-d73fa1c-3fa7ac1f.json @@ -0,0 +1,60 @@ +{ + "timestamp": "2026-10-30T01:58:05.235Z", + "corpus": "flask", + "repo": "https://github.com/pallets/flask.git", + "commit": "d73fa1c", + "codegraphSha": "3fa7ac1f", + "summary": { + "absentCases": 0, + "absentHeld": 0, + "presentCases": 2, + "presentHeld": 2, + "unjudgeable": 0 + }, + "resolvedBy": { + "(none)": 2474, + "import": 1180, + "instance-method": 730, + "exact-match": 633, + "function-ref": 191 + }, + "edges": 5208, + "results": [ + { + "caseId": "flask-package-reexport-receiver", + "expect": "present", + "pass": true, + "found": [ + { + "from": "file:examples/tutorial/flaskr/auth.py", + "to": "method:31833b8026746e7977b4c2736d71c63e", + "resolvedBy": "instance-method" + }, + { + "from": "file:examples/tutorial/flaskr/auth.py", + "to": "method:31833b8026746e7977b4c2736d71c63e", + "resolvedBy": "instance-method" + }, + { + "from": "file:examples/tutorial/flaskr/auth.py", + "to": "method:31833b8026746e7977b4c2736d71c63e", + "resolvedBy": "instance-method" + } + ], + "missingEndpoints": [] + }, + { + "caseId": "flask-control-relative-from-import", + "expect": "present", + "pass": true, + "found": [ + { + "from": "file:src/flask/app.py", + "to": "function:00d6010e49ed947bc5858174234b23e4", + "resolvedBy": "import" + } + ], + "missingEndpoints": [] + } + ] +} \ No newline at end of file diff --git a/__tests__/evaluation/results/precision-gin-dcaa429-3fa7ac1f.json b/__tests__/evaluation/results/precision-gin-dcaa429-3fa7ac1f.json new file mode 100644 index 000000000..b846ba870 --- /dev/null +++ b/__tests__/evaluation/results/precision-gin-dcaa429-3fa7ac1f.json @@ -0,0 +1,59 @@ +{ + "timestamp": "2026-10-30T01:58:26.422Z", + "corpus": "gin", + "repo": "https://github.com/gin-gonic/gin.git", + "commit": "dcaa429", + "codegraphSha": "3fa7ac1f", + "summary": { + "absentCases": 1, + "absentHeld": 1, + "presentCases": 2, + "presentHeld": 2, + "unjudgeable": 0 + }, + "resolvedBy": { + "(none)": 3593, + "exact-match": 2568, + "instance-method": 1814, + "framework": 228, + "file-path": 104, + "import": 96, + "function-ref": 14 + }, + "edges": 8417, + "results": [ + { + "caseId": "gin-declared-factory-receiver", + "expect": "present", + "pass": true, + "found": [ + { + "from": "function:285dcfdab6ec00ce5d1c87eddc233f0d", + "to": "method:08a6ca18de8a7f08f33790ca286e0a20", + "resolvedBy": "instance-method" + } + ], + "missingEndpoints": [] + }, + { + "caseId": "gin-composite-receiver-json-control", + "expect": "present", + "pass": true, + "found": [ + { + "from": "function:e6a071cb9b3ce2eeba6fb82f941cef3d", + "to": "method:3ab506d0531aac0489e0c51fcc2128aa", + "resolvedBy": "instance-method" + } + ], + "missingEndpoints": [] + }, + { + "caseId": "gin-composite-receiver-not-bson", + "expect": "absent", + "pass": true, + "found": [], + "missingEndpoints": [] + } + ] +} \ No newline at end of file diff --git a/__tests__/evaluation/results/precision-json-aa391dc-3fa7ac1f.json b/__tests__/evaluation/results/precision-json-aa391dc-3fa7ac1f.json new file mode 100644 index 000000000..370c73e77 --- /dev/null +++ b/__tests__/evaluation/results/precision-json-aa391dc-3fa7ac1f.json @@ -0,0 +1,200 @@ +{ + "timestamp": "2026-10-30T01:58:03.754Z", + "corpus": "json", + "repo": "https://github.com/nlohmann/json.git", + "commit": "aa391dc", + "codegraphSha": "3fa7ac1f", + "summary": { + "absentCases": 11, + "absentHeld": 11, + "presentCases": 7, + "presentHeld": 7, + "unjudgeable": 0 + }, + "resolvedBy": { + "(none)": 13070, + "instance-method": 6643, + "qualified-name": 3946, + "exact-match": 2641, + "file-path": 1742, + "import": 670, + "function-ref": 49, + "field-call": 1 + }, + "edges": 28762, + "results": [ + { + "caseId": "json-value-not-internal-serializer", + "expect": "absent", + "pass": true, + "found": [], + "missingEndpoints": [] + }, + { + "caseId": "json-begin-fuzzer-contains-word", + "expect": "present", + "pass": true, + "found": [ + { + "from": "method:511ae2bab26a12794fe5b8e38095ef6f", + "to": "method:c7bd7bd104f933f1ecf2fcd44528462f", + "resolvedBy": "instance-method" + } + ], + "missingEndpoints": [] + }, + { + "caseId": "json-begin-fuzzer-end-calls-begin", + "expect": "present", + "pass": true, + "found": [ + { + "from": "method:a583c5de8fb77fcf2e47f72d5f052f20", + "to": "method:c7bd7bd104f933f1ecf2fcd44528462f", + "resolvedBy": "instance-method" + } + ], + "missingEndpoints": [] + }, + { + "caseId": "json-begin-front-member", + "expect": "present", + "pass": true, + "found": [ + { + "from": "method:8654732301dd561330bd7d76aaaac333", + "to": "method:3e3f4b2356976a63aaa31053c5cb2161", + "resolvedBy": "instance-method" + } + ], + "missingEndpoints": [] + }, + { + "caseId": "json-begin-rend-member", + "expect": "present", + "pass": true, + "found": [ + { + "from": "method:0dda1746e6adfea526da71f9367b169b", + "to": "method:3e3f4b2356976a63aaa31053c5cb2161", + "resolvedBy": "instance-method" + } + ], + "missingEndpoints": [] + }, + { + "caseId": "json-begin-subscript-member", + "expect": "present", + "pass": true, + "found": [ + { + "from": "method:77ad649124c3bf33008dde65773f52e6", + "to": "method:3e3f4b2356976a63aaa31053c5cb2161", + "resolvedBy": "instance-method" + } + ], + "missingEndpoints": [] + }, + { + "caseId": "json-begin-emplace-member", + "expect": "present", + "pass": true, + "found": [ + { + "from": "method:c3902fb60811ce9452300172d8c55ee0", + "to": "method:3e3f4b2356976a63aaa31053c5cb2161", + "resolvedBy": "instance-method" + } + ], + "missingEndpoints": [] + }, + { + "caseId": "json-begin-no-cross-test-member", + "expect": "absent", + "pass": true, + "found": [], + "missingEndpoints": [] + }, + { + "caseId": "json-begin-no-eigen-member", + "expect": "absent", + "pass": true, + "found": [], + "missingEndpoints": [] + }, + { + "caseId": "json-begin-no-udt-member", + "expect": "absent", + "pass": true, + "found": [], + "missingEndpoints": [] + }, + { + "caseId": "json-begin-no-fifo-member", + "expect": "absent", + "pass": true, + "found": [], + "missingEndpoints": [] + }, + { + "caseId": "json-begin-no-array-member", + "expect": "absent", + "pass": true, + "found": [], + "missingEndpoints": [] + }, + { + "caseId": "json-begin-no-ordered-map-this", + "expect": "absent", + "pass": true, + "found": [], + "missingEndpoints": [] + }, + { + "caseId": "json-begin-no-adl-to-helper", + "expect": "absent", + "pass": true, + "found": [], + "missingEndpoints": [] + }, + { + "caseId": "json-begin-no-using-std-to-proxy", + "expect": "absent", + "pass": true, + "found": [], + "missingEndpoints": [] + }, + { + "caseId": "json-begin-no-doctest-to-dictionary", + "expect": "absent", + "pass": true, + "found": [], + "missingEndpoints": [] + }, + { + "caseId": "json-begin-no-abi-adl-to-member", + "expect": "absent", + "pass": true, + "found": [], + "missingEndpoints": [] + }, + { + "caseId": "json-items-calls-proxy-ctor", + "expect": "present", + "pass": true, + "found": [ + { + "from": "method:43fd87584a379054069e29e9f2000a50", + "to": "method:2c30311abef74670c4da1607ca5259fb", + "resolvedBy": "import" + }, + { + "from": "method:b768fbb5951dfeae8fcc4e6af4e53c84", + "to": "method:2c30311abef74670c4da1607ca5259fb", + "resolvedBy": "import" + } + ], + "missingEndpoints": [] + } + ] +} \ No newline at end of file diff --git a/__tests__/evaluation/results/precision-svelte-5895c63-3fa7ac1f.json b/__tests__/evaluation/results/precision-svelte-5895c63-3fa7ac1f.json new file mode 100644 index 000000000..7d918ce3a --- /dev/null +++ b/__tests__/evaluation/results/precision-svelte-5895c63-3fa7ac1f.json @@ -0,0 +1,35 @@ +{ + "timestamp": "2026-10-30T01:58:24.599Z", + "corpus": "svelte", + "repo": "https://github.com/sveltejs/svelte.git", + "commit": "5895c63", + "codegraphSha": "3fa7ac1f", + "summary": { + "absentCases": 1, + "absentHeld": 1, + "presentCases": 0, + "presentHeld": 0, + "unjudgeable": 0 + }, + "resolvedBy": { + "(none)": 30920, + "import": 22673, + "exact-match": 9038, + "qualified-name": 1832, + "instance-method": 814, + "framework": 220, + "function-ref": 195, + "file-path": 58, + "fuzzy": 13 + }, + "edges": 65763, + "results": [ + { + "caseId": "svelte-bare-import-bundle", + "expect": "absent", + "pass": true, + "found": [], + "missingEndpoints": [] + } + ] +} \ No newline at end of file diff --git a/__tests__/evaluation/results/precision-vite-8492422-3fa7ac1f.json b/__tests__/evaluation/results/precision-vite-8492422-3fa7ac1f.json new file mode 100644 index 000000000..3b48d2aa8 --- /dev/null +++ b/__tests__/evaluation/results/precision-vite-8492422-3fa7ac1f.json @@ -0,0 +1,95 @@ +{ + "timestamp": "2026-10-30T01:58:16.222Z", + "corpus": "vite", + "repo": "https://github.com/vitejs/vite.git", + "commit": "8492422", + "codegraphSha": "3fa7ac1f", + "summary": { + "absentCases": 6, + "absentHeld": 6, + "presentCases": 2, + "presentHeld": 2, + "unjudgeable": 0 + }, + "resolvedBy": { + "(none)": 13392, + "import": 11144, + "exact-match": 3386, + "qualified-name": 1009, + "instance-method": 547, + "file-path": 197, + "function-ref": 163, + "framework": 1 + }, + "edges": 29839, + "results": [ + { + "caseId": "vite-factory-receiver-control", + "expect": "present", + "pass": true, + "found": [ + { + "from": "function:d321dd128d90578bc69867835ec73ef8", + "to": "method:db73e5f5d9c7af61d0f22c5b9971ced3", + "resolvedBy": "instance-method" + } + ], + "missingEndpoints": [] + }, + { + "caseId": "vite-external-receiver-not-project-method", + "expect": "absent", + "pass": true, + "found": [], + "missingEndpoints": [] + }, + { + "caseId": "vite-bare-import-self-edge", + "expect": "absent", + "pass": true, + "found": [], + "missingEndpoints": [] + }, + { + "caseId": "vite-bare-import-getEnv", + "expect": "absent", + "pass": true, + "found": [], + "missingEndpoints": [] + }, + { + "caseId": "vite-fuzzy-nested-resolveConfig-getEnv", + "expect": "absent", + "pass": true, + "found": [], + "missingEndpoints": [] + }, + { + "caseId": "vite-fuzzy-nested-build-scan", + "expect": "absent", + "pass": true, + "found": [], + "missingEndpoints": [] + }, + { + "caseId": "vite-sealed-module-defineConfig", + "expect": "absent", + "pass": true, + "found": [], + "missingEndpoints": [] + }, + { + "caseId": "vite-control-relative-import", + "expect": "present", + "pass": true, + "found": [ + { + "from": "file:packages/vite/src/node/config.ts", + "to": "function:0c2f36c32c5742aee412978220fd5b23", + "resolvedBy": "import" + } + ], + "missingEndpoints": [] + } + ] +} \ No newline at end of file diff --git a/__tests__/fixtures/golden/torture-multilang.dump b/__tests__/fixtures/golden/torture-multilang.dump index 53b8bf008..3ca5cdffa 100644 --- a/__tests__/fixtures/golden/torture-multilang.dump +++ b/__tests__/fixtures/golden/torture-multilang.dump @@ -4288,7 +4288,7 @@ {"source":"method:5f23af2b373950fe2a4c01962e426926","target":"method:35091377684dd9ce1422bc199925f095","kind":"calls","metadata":"{\"synthesizedBy\":\"interface-impl\",\"via\":\"read\",\"registeredAt\":\"torture.rs:131\"}","line":123,"col":null,"provenance":"heuristic"} {"source":"method:5f23af2b373950fe2a4c01962e426926","target":"method:ebf2f082376e23dff7ba2531c9a9d71f","kind":"calls","metadata":"{\"synthesizedBy\":\"interface-impl\",\"via\":\"read\",\"registeredAt\":\"torture.rs:141\"}","line":123,"col":null,"provenance":"heuristic"} {"source":"method:6003fbc095ba093d811a4f6dcdb8ce8e","target":"class:c70093f83659a119eaf917e29e426544","kind":"references","metadata":"{\"framework\":\"spring\",\"confidence\":0.7,\"resolvedBy\":\"framework\",\"refName\":\"Point\"}","line":24,"col":25,"provenance":null} -{"source":"method:6003fbc095ba093d811a4f6dcdb8ce8e","target":"method:5a848545dc28c551fa3193ddb28d9b17","kind":"calls","metadata":"{\"confidence\":0.4,\"resolvedBy\":\"exact-match\",\"refName\":\"norm\"}","line":24,"col":58,"provenance":null} +{"source":"method:6003fbc095ba093d811a4f6dcdb8ce8e","target":"method:5a848545dc28c551fa3193ddb28d9b17","kind":"calls","metadata":"{\"confidence\":0.9,\"resolvedBy\":\"exact-match\",\"refName\":\"norm\"}","line":24,"col":58,"provenance":null} {"source":"method:60108b3d293a4763b06368be872be2d7","target":"variable:7b3f83bd083d272c56d09652de471592","kind":"references","metadata":"{\"valueRef\":true}","line":null,"col":null,"provenance":null} {"source":"method:60108b3d293a4763b06368be872be2d7","target":"variable:a46e14ba9ba0147ac6e492fcfc75896b","kind":"calls","metadata":"{\"confidence\":0.9,\"resolvedBy\":\"exact-match\",\"refName\":\"lower_conf\"}","line":205,"col":4,"provenance":null} {"source":"method:60108b3d293a4763b06368be872be2d7","target":"variable:bd3123c2d8b795f8be0ffe79b04b4849","kind":"references","metadata":"{\"valueRef\":true}","line":null,"col":null,"provenance":null} diff --git a/__tests__/go-chained-receivers.test.ts b/__tests__/go-chained-receivers.test.ts index 9f097df0a..ca83ac4c4 100644 --- a/__tests__/go-chained-receivers.test.ts +++ b/__tests__/go-chained-receivers.test.ts @@ -364,7 +364,8 @@ func useLiteral() { func main() { use(holder{}); useLiteral() } `, }); - expect(callees(graph, 'use')).toEqual([]); + // A func-typed field's call gives the result its type declares: `h.get()` is an A. + expect(callees(graph, 'use')).toEqual(['calls method A::Run']); expect(callees(graph, 'useLiteral')).toEqual([]); }); }); diff --git a/__tests__/go-receiver-type.test.ts b/__tests__/go-receiver-type.test.ts new file mode 100644 index 000000000..4173bf17a --- /dev/null +++ b/__tests__/go-receiver-type.test.ts @@ -0,0 +1,476 @@ +/** + * Go method calls through a receiver whose type is written somewhere other + * than a plain parameter of the caller's own package: + * + * - a package-qualified parameter (`s *store.Store`); + * - the result of a function or a conversion (`r := newRing()`, `s := + * store.NewStore()`, `list := web.Users(names)`). + * + * A receiver typed outside the project (`conn net.Conn`, `ctx + * context.Context`, `c, _ := net.Dial(…)`, an alias `type Ctx = + * context.Context`) gets no edge rather than a project method that happens + * to share the name, and so does a project function's result declared as an + * outside type (`*os.File`, `http.RoundTripper`). + * + * Cases first written for PR #1954 by @danusha2345. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { CodeGraph } from '../src'; + +async function indexProject(files: Record): Promise<{ dir: string; cg: CodeGraph }> { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-go-recv-')); + fs.writeFileSync(path.join(dir, 'go.mod'), 'module example.com/app\n\ngo 1.22\n'); + for (const [file, source] of Object.entries(files)) { + fs.mkdirSync(path.join(dir, path.dirname(file)), { recursive: true }); + fs.writeFileSync(path.join(dir, file), source); + } + const cg = CodeGraph.initSync(dir); + await cg.indexAll(); + return { dir, cg }; +} + +/** `file::Qualified::name` of every method `caller` (a function name) calls. */ +async function methodCallees(cg: CodeGraph, caller: string): Promise { + const node = (await cg.searchNodes(caller, { limit: 20 })).find( + (r) => r.node.name === caller && r.node.kind === 'function', + ); + expect(node, caller).toBeDefined(); + return (await cg.getCallees(node!.node.id)) + .filter((c) => c.node.kind === 'method') + .map((c) => `${c.node.filePath}::${c.node.qualifiedName}`) + .sort(); +} + +describe('Go receiver typing', () => { + let dir: string; + let cg: CodeGraph; + + beforeAll(async () => { + ({ dir, cg } = await indexProject({ + 'main.go': `package main + +import ( + "bytes" + "net" + "net/http" + "os" + + "example.com/app/other" + "example.com/app/store" +) + +type ringLog struct{ buf []byte } + +func (r *ringLog) Write(b []byte) {} +func (r *ringLog) Reset() {} +func (r *ringLog) Reader() *reader { return &reader{} } + +type reader struct{} + +func (r *reader) Reset() {} + +type Engine struct{} + +func (e *Engine) Run() *Engine { return e } +func (e *Engine) Stop() {} + +func newRing() *ringLog { return &ringLog{} } +func NewEngine(n int) (*Engine, error) { return &Engine{}, nil } +func open() (*os.File, error) { return nil, nil } +func wrap(e *Engine) *ringLog { return nil } + +type limiter struct{} + +func (l *limiter) RoundTrip(r *http.Request) (*http.Response, error) { return nil, nil } + +func transport() http.RoundTripper { return &limiter{} } + +func lowerParam(ring *ringLog, b []byte) { ring.Write(b) } +func lowerValueParam(ring ringLog) { ring.Reset() } +func groupedParam(a, ring *ringLog) { ring.Reset() } +func lowerCtor() { r := newRing(); r.Reset() } +func exportedCtor() { e, _ := NewEngine(1); e.Stop() } +func qualifiedParam(s *store.Store) { s.Put("a") } +func qualifiedCtor() { s := store.NewStore(); s.Put("b") } + +func multiLineCtor() { + e, err := NewEngine( + 1, + ) + _ = err + e.Stop() +} + +func ifCtor() { + if r := newRing(); r != nil { + r.Write(nil) + } +} + +// \`r\` is what Reader returns: a reader, not a ringLog. +func chainedCtor() { r := newRing().Reader(); r.Reset() } + +// On the binding's own line \`e\` is still the parameter; inside the +// \`if\`, \`e\` is what wrap returns. +func selfArg(e *Engine) { + if e := wrap(e.Run()); e != nil { + e.Write(nil) + } +} + +func stdlibParam(buf *bytes.Buffer) { buf.Truncate(0) } +func stdlibCtor() { c, _ := net.Dial("tcp", ""); c.RemoteAddr() } +func projectFuncReturningStdlib() { f, _ := open(); f.Truncate(0) } +func interfaceResult() { rt := transport(); rt.RoundTrip(nil) } +func netParam(conn net.Conn) { conn.LocalAddr() } + +// The \`r\` of the block, or of the function literal, is gone by the call. +func blockShadow() { + r := newRing() + if true { + r := other.New() + _ = r + } + r.Write(nil) +} + +func closureShadow() { + r := newRing() + go func() { r := other.New(); _ = r }() + r.Write(nil) +} + +type Holder struct{} + +func (h *Holder) New() *Item { return &Item{} } + +type Item struct{} + +func (i *Item) Save() {} + +// \`store\` is the parameter, not the imported package. +func shadowPkg(store *Holder) { x := store.New(); x.Save() } + +func main() {} +`, + 'store/store.go': `package store + +type Store struct{} + +func NewStore() *Store { return &Store{} } + +func (s *Store) Put(k string) {} + +type Thing struct{} + +func New() *Thing { return &Thing{} } + +func (t *Thing) Save() {} +`, + 'other/other.go': `package other + +type Other struct{} + +func New() *Other { return &Other{} } + +func (o *Other) Write(b []byte) {} +`, + // Same-named methods elsewhere, so a guess by name has somewhere wrong to go. + 'decoy/decoy.go': `package decoy + +type Decoy struct{} + +func (d *Decoy) Write(b []byte) {} +func (d *Decoy) Reset() {} +func (d *Decoy) Run() {} +func (d *Decoy) Stop() {} +func (d *Decoy) Put(k string) {} +func (d *Decoy) Truncate(n int) {} +func (d *Decoy) RemoteAddr() {} +func (d *Decoy) LocalAddr() {} + +type Store struct{} + +func (s *Store) Put(k string) {} +`, + })); + }); + + afterAll(() => { + cg?.close(); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it.each([ + ['lowerParam', ['main.go::ringLog::Write']], + ['lowerValueParam', ['main.go::ringLog::Reset']], + ['groupedParam', ['main.go::ringLog::Reset']], + ])('%s: a receiver named like a standard-library package resolves on its type', async (caller, expected) => { + expect(await methodCallees(cg, caller)).toEqual(expected); + }); + + it.each([ + ['lowerCtor', ['main.go::ringLog::Reset']], + ['exportedCtor', ['main.go::Engine::Stop']], + ['multiLineCtor', ['main.go::Engine::Stop']], + ['ifCtor', ['main.go::ringLog::Write']], + ['qualifiedCtor', ['store/store.go::Store::Put']], + ])('%s: a receiver bound to a call resolves on what the callee returns', async (caller, expected) => { + expect(await methodCallees(cg, caller)).toEqual(expected); + }); + + it('a package-qualified parameter resolves in that package', async () => { + expect(await methodCallees(cg, 'qualifiedParam')).toEqual(['store/store.go::Store::Put']); + }); + + it('a binding is typed by the whole chain, and from the end of its own line', async () => { + expect(await methodCallees(cg, 'chainedCtor')).toEqual(['main.go::reader::Reset', 'main.go::ringLog::Reader']); + expect(await methodCallees(cg, 'selfArg')).toEqual(['main.go::Engine::Run', 'main.go::ringLog::Write']); + }); + + it.each(['stdlibParam', 'stdlibCtor', 'projectFuncReturningStdlib', 'netParam'])( + '%s: a receiver typed outside the project gets no edge', + async (caller) => { + expect(await methodCallees(cg, caller)).toEqual([]); + }, + ); + + // Real code's by-name guesses here mostly took a namesake: prometheus's + // `handler := NewWriteHandler(…)` went to `readHandler::ServeHTTP`. + it('an outside interface a project function returns links nothing', async () => { + expect(await methodCallees(cg, 'interfaceResult')).toEqual([]); + }); + + it.each(['blockShadow', 'closureShadow'])( + '%s: a `:=` in a block that closed before the call is another variable', + async (caller) => { + expect(await methodCallees(cg, caller)).toEqual(['main.go::ringLog::Write']); + }, + ); + + it('a parameter named like an imported package is the parameter, and types what its method returns', async () => { + expect(await methodCallees(cg, 'shadowPkg')).toEqual(['main.go::Holder::New', 'main.go::Item::Save']); + }); +}); + +describe('Go func-typed fields', () => { + it('takes the func type only, not the next field or a struct tag', async () => { + const { cg } = await indexProject({ + 'main.go': `package main + +type A struct{} +func (A) Run() {} + +type B struct{} + +type holder struct { + get func() *A; other *B \`json:"other"\` +} + +func use(h holder) { h.get().Run() } +`, + }); + expect(await methodCallees(cg, 'use')).toEqual(['main.go::A::Run']); + cg.destroy(); + }); +}); + +describe('Go receiver typing through conversions and aliases', () => { + let dir: string; + let cg: CodeGraph; + + beforeAll(async () => { + ({ dir, cg } = await indexProject({ + 'web/context.go': `package web + +type Context struct{} + +func (c *Context) MakeAuditRecord() {} + +type Users []string + +func (u Users) Usernames() []string { return nil } +`, + 'api4/handlers.go': `package api4 + +import ( + "context" + + "example.com/app/web" +) + +type Context = web.Context + +type ( + Handler = *web.Context + Ctx = context.Context +) + +type Defined web.Context + +func projectAlias(h Handler) { h.MakeAuditRecord() } +func conversion(names []string) { list := web.Users(names); list.Usernames() } +func definedType(d *Defined) { d.MakeAuditRecord() } +func stdlibReceiver(ctx context.Context) { ctx.Done() } +func aliasedStdlib(ctx Ctx) { ctx.Done() } +`, + // Same-named types and methods elsewhere, so neither a lookup by type + // name nor a guess by method name has a single answer to fall back on. + 'decoy/decoy.go': `package decoy + +type Context struct{} + +func (c *Context) MakeAuditRecord() {} + +// Named like the alias of an outside type in api4. +type Ctx struct{} + +func (c *Ctx) Done() {} + +type Decoy struct{} + +func (d *Decoy) Done() {} +func (d *Decoy) Usernames() {} +`, + })); + }); + + afterAll(() => { + cg?.close(); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('a receiver bound to a conversion resolves on the converted type', async () => { + expect(await methodCallees(cg, 'conversion')).toEqual(['web/context.go::Users::Usernames']); + }); + + it('an alias of a project type still resolves on that type', async () => { + expect(await methodCallees(cg, 'projectAlias')).toEqual(['web/context.go::Context::MakeAuditRecord']); + }); + + it.each(['definedType', 'stdlibReceiver', 'aliasedStdlib'])( + '%s: a defined type, or a receiver typed outside the project, gets no edge', + async (caller) => { + expect(await methodCallees(cg, caller)).toEqual([]); + }, + ); +}); + +describe('Go receiver typing through what a call hands back', () => { + let dir: string; + let cg: CodeGraph; + + beforeAll(async () => { + ({ dir, cg } = await indexProject({ + 'svc/svc.go': `package svc + +import ( + "sync" + + "google.golang.org/grpc" +) + +type Client struct{} + +func (c *Client) Do() {} + +type monitor struct { + client func() (*Client, error) +} + +func (m *monitor) run() { + c, _ := m.client() + c.Do() +} + +type Result struct{} + +func (r *Result) Done() {} + +type Converter struct{} + +func (c *Converter) Convert() (*Result, error) { return &Result{}, nil } + +var pool sync.Pool + +func pooled() { + conv := pool.Get().(*Converter) + out, _ := conv.Convert() + out.Done() +} + +type Resp struct{} + +func (r *Resp) GetKind() string { return "" } + +func stream(s grpc.ServerStreamingClient[Resp]) { + chunk, _ := s.Recv() + chunk.GetKind() +} +`, + 'watch/watch.go': `package watch + +type Watcher struct{} + +func NewWatcher() *Watcher { return &Watcher{} } + +func (w *Watcher) Stop() {} +`, + 'watch/watch_test.go': `package watch_test + +import ( + "testing" + + . "example.com/app/watch" +) + +func TestStop(t *testing.T) { + w := NewWatcher() + w.Stop() +} +`, + // Namesakes, so a guess by the method's name has somewhere wrong to go. + 'decoy/decoy.go': `package decoy + +type Decoy struct{} + +func (d *Decoy) Do() {} +func (d *Decoy) Done() {} +func (d *Decoy) Stop() {} +`, + })); + }); + + afterAll(() => { + cg?.close(); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + const callees = async (caller: string, kind: 'function' | 'method' = 'function'): Promise => { + const node = (await cg.searchNodes(caller, { limit: 20 })).find((r) => r.node.name === caller && r.node.kind === kind); + expect(node, caller).toBeDefined(); + return (await cg.getCallees(node!.node.id)) + .filter((c) => c.node.kind === 'method') + .map((c) => `${c.node.filePath}::${c.node.qualifiedName}`) + .sort(); + }; + + it('a func-typed field is called for the result its type declares', async () => { + expect(await callees('run', 'method')).toEqual(['svc/svc.go::Client::Do']); + }); + + it('an assertion types the value, whatever hands it out', async () => { + expect(await callees('pooled')).toEqual(['svc/svc.go::Converter::Convert', 'svc/svc.go::Result::Done']); + }); + + it('a function a dot import brings in types its result', async () => { + expect(await callees('TestStop')).toEqual(['watch/watch.go::Watcher::Stop']); + }); + + it('what an outside generic type hands back is left to name matching, as before', async () => { + expect(await callees('stream')).toEqual(['svc/svc.go::Resp::GetKind']); + }); +}); diff --git a/__tests__/go-std-method-receivers.test.ts b/__tests__/go-std-method-receivers.test.ts index d5a882042..696684b39 100644 --- a/__tests__/go-std-method-receivers.test.ts +++ b/__tests__/go-std-method-receivers.test.ts @@ -307,28 +307,27 @@ describe('Go standard method names on a receiver nothing types', () => { expect(calls(fn)).toEqual([target]); }); - // Fork: the binding rows type both receivers (a range variable over a - // `[]types.ID`, the result of `types.NewURLsMap()`), so these link by their - // declared type at 0.9 where upstream's text guess lands at 0.65. The - // targets are upstream's. it.each([ // `for _, id := range ids` + // The fork types a range element from its slice's element type + // (8201401a), so `id` of `[]types.ID` is an ID and the call is typed + // rather than guessed: 0.9, where upstream still guesses at 0.65. ['idsText', 'types/id.go::ID::String instance-method@0.9'], - // `urlsMap := types.NewURLsMap()` - ['urls', 'types/id.go::URLsMap::String instance-method@0.9'], - ])('links %s through the type its receiver is bound to', (fn, target) => { + ])('still guesses for %s through a receiver named after the owner type', (fn, target) => { expect(calls(fn)).toEqual([target]); }); + // `urlsMap := types.NewURLsMap()`: the type the package's function returns. + it('links urls through the type the call it is assigned from returns', () => { + expect(calls('urls')).toEqual(['types/id.go::URLsMap::String instance-method@0.9']); + }); + it.each([ // `var ( query promql.Query )`, read past the table's `query string`; - // Fork: `Exec` links through the declared type at 0.9, where upstream - // guesses it at 0.8. + // `Exec`, no standard name, is typed by that declaration too. ['runQueries', ['promql/engine.go::Query::Exec instance-method@0.9', 'promql/engine.go::Query::String instance-method@0.9']], - // `version, _ := ParseVersion(…)` says no type, and the table's - // `version string` is another declaration: nothing is ruled out. Fork: - // the fork's bindings read the `Version` result, so both link at 0.9 - // (upstream: 0.8). + // `version, _ := ParseVersion(…)` is what ParseVersion returns; the + // table's `version string` is another declaration. ['versionText', ['server/version.go::Version::Major instance-method@0.9', 'server/version.go::Version::String instance-method@0.9']], ])('reads %s by the declaration in scope, not a test table field of the name', (fn, targets) => { expect(calls(fn)).toEqual(targets); diff --git a/__tests__/library-method-calls.test.ts b/__tests__/library-method-calls.test.ts new file mode 100644 index 000000000..13b0a7d46 --- /dev/null +++ b/__tests__/library-method-calls.test.ts @@ -0,0 +1,133 @@ +/** + * A standard-library method called on a receiver of unknown type — Python + * `options.setdefault(...)`, Scala `m.getOrElse(...)`, Java `m.put(...)` on a + * `var` — does not bind by name to the one project method that shares the + * name: the receiver's own words must name the method's type. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; +import { initGrammars, loadAllGrammars } from '../src/extraction/grammars'; + +let dir: string; +let cg: CodeGraph; + +const files: Record = { + 'py/globals.py': + 'class Globals:\n' + + ' def setdefault(self, key, value):\n' + + ' return value\n' + + ' def reset(self):\n' + + ' return self.setdefault("a", 1)\n' + + '\n' + + 'def build(options):\n' + + ' options.setdefault("x", 1)\n' + + ' return options.copy().setdefault("y", 2)\n', + 'java/com/app/Registry.java': + 'package com.app;\n' + + 'public class Registry {\n' + + ' public static final String KEY = "k";\n' + + ' public Object get(String k) { return null; }\n' + + ' public int size() { return 0; }\n' + + ' public int hash() { return 0; }\n' + + ' public boolean equals(Object o) { return false; }\n' + + ' public String toString() { return "r"; }\n' + + ' int count() { return size(); }\n' + + ' Object own(String k) { return this.get(k); }\n' + + '}\n', + 'sc/Registry.scala': + 'package app\n' + + 'class Registry {\n' + + ' def getOrElse(d: Int): Int = d\n' + + '}\n', + 'sc/Use.scala': + 'package app\n' + + 'object Use {\n' + + ' def go(): Int = {\n' + + ' val m = compute()\n' + + ' m.getOrElse(0)\n' + + ' }\n' + + ' def compute() = Option(1)\n' + + ' def viaName(): Int = { val registry = make(); registry.getOrElse(0) }\n' + + ' def make() = new Registry\n' + + '}\n', + 'java/com/app/Use.java': + 'package com.app;\n' + + 'import java.util.*;\n' + + 'public class Use {\n' + + ' private Registry registry;\n' + + ' Object untyped(String a) {\n' + + ' var data = load();\n' + + ' data.put(a, 1);\n' + + ' return data.get(a);\n' + + ' }\n' + + ' Map load() { return null; }\n' + + ' Object typed(String k) { return registry.get(k); }\n' + + ' int named() { var userRegistry = make(); return userRegistry.size(); }\n' + + ' Registry make() { return null; }\n' + + '}\n', + 'cs/App/Money.cs': + 'namespace App {\n' + + ' public class Money {\n' + + ' public override string ToString() { return "m"; }\n' + + ' }\n' + + ' public class Use {\n' + + ' object Make() { return null; }\n' + + ' public string Look() { var cls = Make(); return cls.ToString(); }\n' + + ' }\n' + + '}\n', +}; + +beforeAll(async () => { + await initGrammars(); + await loadAllGrammars(); + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-libmethods-')); + for (const [rel, text] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true }); + fs.writeFileSync(path.join(dir, rel), text); + } + cg = CodeGraph.initSync(dir); + await cg.indexAll(); +}); + +afterAll(() => { + cg.destroy(); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +const node = (name: string, file: string) => + cg.getNodesByName(name).find((n) => n.filePath.endsWith(file) && (n.kind === 'function' || n.kind === 'method'))!; +const calleesOf = (name: string, file: string) => + cg.getCallees(node(name, file).id) + .filter(({ edge }) => edge.kind === 'calls') + .map(({ node: n }) => n.qualifiedName.split('::').slice(-2).join('::')) + .sort(); + +describe('library-method calls on untyped receivers', () => { + it('Python: `options.setdefault()` declines; `self.setdefault()` keeps its own method', () => { + expect(calleesOf('build', 'globals.py')).toEqual([]); + expect(calleesOf('reset', 'globals.py')).toEqual(['Globals::setdefault']); + }); + + it('Scala: `m.getOrElse()` on an untyped value declines; a receiver named after the type keeps its method', () => { + expect(calleesOf('go', 'Use.scala')).toEqual(['Use::compute']); + expect(calleesOf('viaName', 'Use.scala')).toEqual(['Registry::getOrElse', 'Use::make']); + }); + + it('Java: `data.put()` / `data.get()` on an untyped `var` do not bind to a lone project method', () => { + expect(calleesOf('untyped', 'Use.java')).toEqual(['Use::load']); + }); + + it('Java: a typed field, an own-class call and a receiver named after the type keep their method', () => { + expect(calleesOf('typed', 'Use.java')).toEqual(['Registry::get']); + expect(calleesOf('count', 'Registry.java')).toEqual(['Registry::size']); + expect(calleesOf('own', 'Registry.java')).toEqual(['Registry::get']); + expect(calleesOf('named', 'Use.java')).toEqual(['Registry::size', 'Use::make']); + }); + + it('C#: a receiver named `cls` is a value like any other, not the class itself', () => { + expect(calleesOf('Look', 'Money.cs')).toEqual(['Use::Make']); + }); +}); diff --git a/__tests__/python-self-call-not-import.test.ts b/__tests__/python-self-call-not-import.test.ts index f5d02ec7e..faf1ecedb 100644 --- a/__tests__/python-self-call-not-import.test.ts +++ b/__tests__/python-self-call-not-import.test.ts @@ -175,6 +175,56 @@ describe('Python self-calls do not resolve through a same-named import', () => { }); expect(calls).toEqual(['Adapter::notify -> Root::get_ip (pkg/base.py)']); }); + it('orders bases by their written expression, not by the class name', async () => { + const calls = await callsIn({ + 'pkg/__init__.py': '', + 'pkg/mixins.py': 'class Mixin:\n def render(self):\n return "mixin"\n', + 'pkg/parent.py': 'class Model:\n def render(self):\n return "parent"\n', + 'pkg/child.py': [ + 'from pkg.mixins import Mixin', + 'import pkg.parent as parent', + '', + '', + 'class Model(Mixin, parent.Model):', + ' def notify(self):', + ' return self.render()', + '', + ].join('\n'), + }); + expect(calls).toEqual(['Model::notify -> Mixin::render (pkg/mixins.py)']); + }); + + it('takes the leftmost C3 class of a diamond', async () => { + const calls = await callsIn({ + 'pkg/__init__.py': '', + 'pkg/base.py': [ + 'class Root:', + ' def render(self):', + ' return "root"', + '', + '', + 'class Right(Root):', + ' def render(self):', + ' return "right"', + '', + '', + 'class Left(Root):', + ' pass', + '', + ].join('\n'), + 'pkg/adapter.py': [ + 'from pkg.base import Left, Right', + '', + '', + 'class Adapter(Left, Right):', + ' def notify(self):', + ' return self.render()', + '', + ].join('\n'), + }); + expect(calls).toEqual(['Adapter::notify -> Right::render (pkg/base.py)']); + }); + // A class with more than one base leaves the choice to node order; one // that unpacks its bases (`*bases`) has more than its header shows. it('a method behind unpacked bases', async () => { diff --git a/__tests__/same-module-copy-resolution.test.ts b/__tests__/same-module-copy-resolution.test.ts new file mode 100644 index 000000000..7d88744fe --- /dev/null +++ b/__tests__/same-module-copy-resolution.test.ts @@ -0,0 +1,150 @@ +/** + * A module duplicated under another source root (`rc2/…` vs `rcpro2/…`) gives + * every bare name two same-named candidates, one per copy. Directory proximity + * is capped, so on a deep tree both copies score the same and the first-indexed + * one used to win — a call in module B could land on module A's copy. The + * caller's own copy, when it is uniquely closest, must win; when no copy is + * closer (the caller sits above both), behavior is unchanged. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { CodeGraph } from '../src'; + +// Six shared segments already reach the proximity cap. +const ROOT = 'a/b/c/d/e/f'; + +function write(dir: string, rel: string, lines: string[]): void { + const full = path.join(dir, rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, lines.join('\n') + '\n'); +} + +describe('exact-name resolution prefers the caller\'s own copy of a duplicated module', () => { + let tempDir: string; + let cg: CodeGraph | null = null; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-same-module-')); + for (const mod of ['rc2', 'rcpro2']) { + write(tempDir, `${ROOT}/${mod}/util.py`, ['def compute():', ' return 1']); + write(tempDir, `${ROOT}/${mod}/main.py`, ['def run():', ' return compute()']); + } + write(tempDir, `${ROOT}/rc2/only.py`, ['def solo():', ' return 2']); + write(tempDir, `${ROOT}/rc2/solo_caller.py`, ['def use_solo():', ' return solo()']); + // Equidistant from both copies. + write(tempDir, `${ROOT}/top.py`, ['def top():', ' return compute()']); + }); + + afterEach(() => { + cg?.destroy(); + cg = null; + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { + // Windows can still hold the SQLite handle for a moment; the OS temp dir is swept anyway. + } + }); + + async function callsFrom(fnName: string, filePath: string) { + const caller = cg!.getNodesByKind('function').find((n) => n.name === fnName && n.filePath === filePath); + expect(caller).toBeDefined(); + return cg!.getOutgoingEdges(caller!.id).filter((e) => e.kind === 'calls'); + } + + function fn(name: string, filePath: string) { + const node = cg!.getNodesByKind('function').find((n) => n.name === name && n.filePath === filePath); + expect(node).toBeDefined(); + return node!; + } + + it('resolves each module\'s call to its own copy', async () => { + cg = await CodeGraph.init(tempDir, { index: true }); + cg.resolveReferences(); + + const calls = await Promise.all( + ['rc2', 'rcpro2'].map((mod) => callsFrom('run', `${ROOT}/${mod}/main.py`)), + ); + expect(calls.map((c) => c.map((e) => e.target))).toEqual( + ['rc2', 'rcpro2'].map((mod) => [fn('compute', `${ROOT}/${mod}/util.py`).id]), + ); + expect(calls.map((c) => c[0]!.metadata?.confidence)).toEqual([0.8, 0.8]); + }); + + it('keeps the prior choice and confidence when the caller is equidistant', async () => { + cg = await CodeGraph.init(tempDir, { index: true }); + cg.resolveReferences(); + + const calls = await callsFrom('top', `${ROOT}/top.py`); + expect(calls).toHaveLength(1); + const copies = ['rc2', 'rcpro2'].map((m) => fn('compute', `${ROOT}/${m}/util.py`).id); + expect(copies).toContain(calls[0]!.target); + expect(calls[0]!.metadata?.confidence).toBe(0.7); + }); + + // Android build flavors keep one package in two source sets that are never + // compiled together, so a name the package declares needs no import and + // means the caller's own flavor's copy. + const FLAVORS = 'platform/apps/mobile/android/app/src'; + + it("resolves a Kotlin flavor's call to its own copy of the package", async () => { + for (const flavor of ['rc2', 'rcpro2']) { + const dir = `${FLAVORS}/${flavor}/kotlin/com/app/util`; + write(tempDir, `${dir}/Util.kt`, ['package com.app.util', '', 'fun compute(): Int = 1']); + write(tempDir, `${dir}/Main.kt`, ['package com.app.util', '', 'fun run(): Int = compute()']); + } + cg = await CodeGraph.init(tempDir, { index: true }); + cg.resolveReferences(); + + const flavors = ['rc2', 'rcpro2']; + const calls = await Promise.all(flavors.map((f) => callsFrom('run', `${FLAVORS}/${f}/kotlin/com/app/util/Main.kt`))); + expect(calls.map((c) => c.map((e) => e.target))).toEqual( + flavors.map((f) => [fn('compute', `${FLAVORS}/${f}/kotlin/com/app/util/Util.kt`).id]), + ); + expect(calls.map((c) => c[0]!.metadata?.confidence)).toEqual([0.8, 0.8]); + }); + + it("resolves a Java flavor's `new Util()` to its own copy of the class", async () => { + for (const flavor of ['rc2', 'rcpro2']) { + const dir = `${FLAVORS}/${flavor}/java/com/app/util`; + write(tempDir, `${dir}/Util.java`, ['package com.app.util;', '', 'public class Util {', ' public int compute() { return 1; }', '}']); + write(tempDir, `${dir}/Main.java`, ['package com.app.util;', '', 'public class Main {', ' int run() { return new Util().compute(); }', '}']); + } + cg = await CodeGraph.init(tempDir, { index: true }); + cg.resolveReferences(); + + for (const flavor of ['rc2', 'rcpro2']) { + const dir = `${FLAVORS}/${flavor}/java/com/app/util`; + const run = cg.getNodesByKind('method').find((n) => n.name === 'run' && n.filePath === `${dir}/Main.java`); + const util = cg.getNodesByKind('class').find((n) => n.name === 'Util' && n.filePath === `${dir}/Util.java`); + const made = cg.getOutgoingEdges(run!.id).filter((e) => e.kind === 'instantiates').map((e) => e.target); + expect(made).toEqual([util!.id]); + } + }); + + it('leaves a match of the caller itself at the proximity score', async () => { + // Recursion, or a receiver the call lost (`json.dumps` inside `dumps`): + // the caller's own copy being the caller says nothing of which copy. + for (const mod of ['rc2', 'rcpro2']) { + write(tempDir, `${ROOT}/${mod}/walk.py`, ['def walk(n):', ' return walk(n - 1)']); + } + cg = await CodeGraph.init(tempDir, { index: true }); + cg.resolveReferences(); + + const calls = await callsFrom('walk', `${ROOT}/rcpro2/walk.py`); + expect(calls.map((e) => e.target)).toEqual([fn('walk', `${ROOT}/rcpro2/walk.py`).id]); + expect(calls[0]!.metadata?.confidence).toBe(0.7); + }); + + it('leaves a single-candidate name unaffected', async () => { + cg = await CodeGraph.init(tempDir, { index: true }); + cg.resolveReferences(); + + const calls = await callsFrom('use_solo', `${ROOT}/rc2/solo_caller.py`); + expect(calls).toHaveLength(1); + expect(calls[0]!.target).toBe(fn('solo', `${ROOT}/rc2/only.py`).id); + expect(calls[0]!.metadata?.confidence).toBe(0.9); + }); +}); diff --git a/codegraph-kernel/src/resolve/bound.rs b/codegraph-kernel/src/resolve/bound.rs index e6f05b674..4f95df7b7 100644 --- a/codegraph-kernel/src/resolve/bound.rs +++ b/codegraph-kernel/src/resolve/bound.rs @@ -794,7 +794,7 @@ if site.language == "dart" && site.reference_kind == "calls" { /// qualified names, and `qualified` looks `type_name` up as one: a /// class's parent is the one its declaration resolved to, never every /// same-named class in other namespaces. - fn supertype_names(&mut self, type_name: &str, r: &ResolveRefIn, from_site: bool, qualified: bool) -> Res> { + pub(super) fn supertype_names(&mut self, type_name: &str, r: &ResolveRefIn, from_site: bool, qualified: bool) -> Res> { let mut names: Vec = Vec::new(); for tn in self.supertype_type_nodes(type_name, r, from_site, qualified)? { // Ruby and PHP look in mixins before the parent class. diff --git a/codegraph-kernel/src/resolve/call_shape.rs b/codegraph-kernel/src/resolve/call_shape.rs index bfab5c500..8db2deb22 100644 --- a/codegraph-kernel/src/resolve/call_shape.rs +++ b/codegraph-kernel/src/resolve/call_shape.rs @@ -52,6 +52,25 @@ const GO_STD_METHODS: &[&str] = &[ "NumField", "Interface", "Kind", ]; +/// A Scala collection/Option method: a bare call of one on a receiver of +/// unknown type is the standard library's, unless the receiver's own words +/// name the project type (#2915's rule for Python's `setdefault`). +const SCALA_STD_METHODS: &[&str] = &[ + "getOrElse", "getOrNull", "getOrPut", "getOrDefault", "isDefined", "isEmpty", + "nonEmpty", "map", "flatMap", "filter", "filterNot", "foreach", "fold", + "foldLeft", "foldRight", "collect", "toList", "toSet", "toMap", "head", + "headOption", "tail", "size", "length", "exists", "forall", +]; + +/// A Java standard-library method: `size`, `put`, `get`, `toString` on a +/// receiver of unknown type is the JDK's, unless its words name the project +/// type. +const JAVA_STD_METHODS: &[&str] = &[ + "size", "put", "putAll", "get", "getOrDefault", "contains", "containsKey", + "containsValue", "add", "addAll", "remove", "toString", "equals", "hashCode", + "length", "isEmpty", "iterator", "stream", "forEach", +]; + const KOTLIN_STD_METHODS: &[&str] = &[ "apply", "also", "let", "run", "takeIf", "takeUnless", "toString", "equals", "hashCode", "map", "mapNotNull", "mapIndexed", @@ -164,6 +183,7 @@ pub(super) fn is_std_method(language: &str, name: &str) -> bool { match language { "rust" => RUST_STD_METHODS, "go" => GO_STD_METHODS, "kotlin" => KOTLIN_STD_METHODS, "csharp" => CSHARP_STD_METHODS, "dart" => DART_STD_METHODS, + "scala" => SCALA_STD_METHODS, "java" => JAVA_STD_METHODS, _ => &[], }.contains(&name) } @@ -651,6 +671,14 @@ impl KernelResolver { return Ok(n.file_path == r.file_path && n.start_line >= start && n.end_line <= end); } if !member || n.file_path == r.file_path { return Ok(true); } + // A standard-library method on a receiver of unknown type is the + // library's; only a receiver whose words name the type keeps it. + if is_std_method("scala", &n.name) + && !site.receiver.is_empty() + && !shares_receiver_word(&site.receiver, n) + { + return Ok(false); + } let Some(owner) = owner(n) else { return Ok(true) }; if self.scala_imported_member(owner, &r.reference_name, &r.file_path)? { return Ok(true); } let mut queue: VecDeque = nodes.iter().filter(|t| type_kind(&t.kind) && t.start_line <= r.line && t.end_line >= r.line).map(|t| t.name.clone()).collect(); diff --git a/codegraph-kernel/src/resolve/esm_scope.rs b/codegraph-kernel/src/resolve/esm_scope.rs index f73e1b06a..4cf56fd33 100644 --- a/codegraph-kernel/src/resolve/esm_scope.rs +++ b/codegraph-kernel/src/resolve/esm_scope.rs @@ -4,6 +4,63 @@ use super::*; use super::awaited::{blank_string_contents, strip_ts_comments}; impl KernelResolver { + /// Whether `name` at this call is one of the browser's own objects rather + /// than a project value: a JS-family name in JS_BROWSER_HOST_GLOBALS that + /// the file writes only as a receiver (`document.x`, `history.back()`, + /// `self?.close()`) or after `typeof`. A file that binds the name — a + /// parameter, plain or destructured, a declaration, `var self = this`, an + /// import — keeps it its own (#2907). + pub(super) fn is_browser_host_global(&mut self, name: &str, r: &ResolveRefIn) -> Res { + if !is_js_family(&r.language) || !JS_BROWSER_HOST_GLOBALS.contains(name) { + return Ok(false); + } + // A lexical binding of the name at the call (`document` as a + // parameter, a `const history`) is the file's own, whatever the + // template or comment blanking below can see. + if self.receiver_binding(name, r)?.is_some_and(|b| b.kind != "import") { + return Ok(false); + } + let key = format!("{}\0{}", r.file_path, name); + if let Some(&hit) = self.js_host_receiver_only.get(&key) { + return Ok(hit); + } + let only = self.uses_name_only_as_receiver(name, &r.file_path)?; + self.js_host_receiver_only.insert(key, only); + Ok(only) + } + + /// Whether the JS/TS file writes `name` only as a receiver or after + /// `typeof`. Any other mention may bind or hand over a value of the file's + /// own. Read with comments and strings blanked; `x.document` is no mention. + fn uses_name_only_as_receiver(&mut self, name: &str, file_path: &str) -> Res { + let Some(src) = self.read_file(file_path) else { return Ok(true) }; + let code = blank_string_contents(&strip_ts_comments(src.text())); + let mut only = true; + let mut from = 0usize; + while let Some(rel) = code[from..].find(name) { + let at = from + rel; + let end = at + name.len(); + from = at + 1; + let previous = code[..at].chars().next_back(); + if previous.is_some_and(|c| is_word_char(c) || c == '.' || c == '#') { continue; } + if code[end..].chars().next().is_some_and(is_word_char) { continue; } + let rest = code[end..].trim_start(); + let receiver = (rest.starts_with('.') && !rest.starts_with("..")) + || rest.starts_with('[') + || rest.starts_with("?."); + // A byte 16 back need not be a character boundary (`π`). + let mut window_start = at.saturating_sub(16); + while !code.is_char_boundary(window_start) { window_start -= 1; } + let window = &code[window_start..at]; + let after_typeof = window.rfind("typeof").is_some_and(|i| { + window[..i].chars().next_back().is_none_or(|c| !is_word_char(c)) + && window[i + 6..].chars().all(char::is_whitespace) + }); + if !receiver && !after_typeof { only = false; break; } + } + Ok(only) + } + pub(super) fn is_unexported_module_binding(&mut self, n: &KNode) -> Res { if !is_esm_family(&n.language) || n.is_exported || n.qualified_name.contains("::") || !matches!(n.kind.as_str(), "function" | "variable" | "constant" | "class" | "interface" | "type_alias" | "enum" | "component") @@ -47,3 +104,8 @@ impl KernelResolver { Ok(re!(r"^\s*(?:declare\s+)?(?:async\s+)?(?:function\*?|const|let|var|(?:abstract\s+)?class|interface|type|enum)\s").is_match(line)) } } + +/// A JS identifier character. +fn is_word_char(c: char) -> bool { + c.is_alphanumeric() || c == '_' || c == '$' +} diff --git a/codegraph-kernel/src/resolve/go_value.rs b/codegraph-kernel/src/resolve/go_value.rs index 6b34926fb..b99cad9fb 100644 --- a/codegraph-kernel/src/resolve/go_value.rs +++ b/codegraph-kernel/src/resolve/go_value.rs @@ -304,7 +304,21 @@ impl KernelResolver { for raw in source.get(start..end).unwrap_or_default() { let line = strip_line_comments(raw); if let Some(t) = FIELD_OF.capture(&line, field) { - return Ok(Some((t.trim().to_string(), s.file_path.clone()))); + let mut ty = t.trim().to_string(); + // A func type's results follow its parameter list, past + // spaces (`get func() A`), which the first token cuts off. + if ty.starts_with("func") { + let toks: Vec<&str> = line.split_whitespace().collect(); + if let Some(i) = toks.iter().position(|w| *w == field) { + let joined = toks[i + 1..].join(" "); + // `get func() *A; other *B` — the next field is + // no part of this type, nor is a struct tag. + let joined = joined.split(';').next().unwrap_or(&joined); + let joined = joined.split('`').next().unwrap_or(joined); + ty = joined.trim_end_matches(['}', ';']).trim().to_string(); + } + } + return Ok(Some((ty, s.file_path.clone()))); } // An embedded field spells only its type — `KV` of `struct // { KV; name string }` answers `KV` for the field `KV`. @@ -319,6 +333,7 @@ impl KernelResolver { Ok(None) } + /// The `index`th result of a Go call's callee — `*Session` of `func /// New() (*Session, error)` at 0, `*Txn` of `func Begin() (int, *Txn)` /// at 1. A result that names the callee's own type parameter reduces to @@ -786,25 +801,35 @@ impl KernelResolver { if self.go_is_outside_package(q, &file)? { return Ok(None); } - if let Some(d) = self.go_imported_package_dir(&file, q)? { - if !self.go_package_types(callee, &d)?.is_empty() { - return Ok(Some((format!("{q}.{callee}"), file))); - } - // `pkg.F(…)` — F's result. - for f in self.nodes_by_name(callee)?.iter().filter(|n| { - n.language == "go" && n.kind == "function" && pos_dirname(&n.file_path) == d - }) { - if let Some(t) = self.go_call_result_at(f, result_idx) { - return Ok(Some((t, f.file_path.clone()))); + // A local or parameter of the package's name is the + // value, not the package: `shadowPkg(store *Holder)` + // makes `store.New()` the method, whose result types + // the call (#2543). + if !self.is_shadowed_import(q, r)? { + if let Some(d) = self.go_imported_package_dir(&file, q)? { + if !self.go_package_types(callee, &d)?.is_empty() { + return Ok(Some((format!("{q}.{callee}"), file))); } + // `pkg.F(…)` — F's result. + for f in self.nodes_by_name(callee)?.iter().filter(|n| { + n.language == "go" && n.kind == "function" && pos_dirname(&n.file_path) == d + }) { + if let Some(t) = self.go_call_result_at(f, result_idx) { + return Ok(Some((t, f.file_path.clone()))); + } + } + return Ok(None); } - return Ok(None); } // `m.F(…)` — F's first result, on m's type. let Some((base_ty, base_at)) = self.go_expr_written(operand, file_text, r)? else { return Ok(None); }; let Some((tn, dir)) = self.go_type_package(&base_ty, &base_at)? else { + // A qualified type of a package the project does not + // index names nothing the call can be read from; a + // literal type's text is the answer (Outside). + if base_ty.contains('.') { return Ok(None); } return Ok(Some((base_ty, base_at))); }; let want = format!("{tn}::{callee}"); @@ -821,8 +846,12 @@ impl KernelResolver { } } // `h.get()` — a field of func type called as a - // callee: its type is no named method holder's. + // callee: the call's type is the func type's + // `result_idx`th result, not the func value. if let Some((ft, at)) = self.go_field_type(&tn, callee, &dir)? { + if let Some(result) = go_func_type_result(&ft, result_idx) { + return Ok(Some((result, at))); + } return Ok(Some((ft, at))); } return Ok(None); @@ -834,6 +863,7 @@ impl KernelResolver { return Ok(None); }; let Some((tn, dir)) = self.go_type_package(&w, &at)? else { + if w.contains('.') { return Ok(None); } return Ok(Some((w, at))); }; let want = format!("{tn}::{callee}"); @@ -1018,3 +1048,41 @@ impl KernelResolver { } } } + +/// The `index`th result a written Go func type declares: `func() A` is `A`, +/// `func(int) (A, error)` at 0 is `A` and at 1 `error` (none). A func value's +/// call has the type its declaration spells, which is what a func-typed +/// field's call gives (`h.get().Run()` on `get func() A`). +fn go_func_type_result(ft: &str, index: usize) -> Option { + let tail = ft.trim().strip_prefix("func")?; + let mut depth = 0usize; + let mut params_end = None; + for (i, c) in tail.char_indices() { + match c { + '(' => depth += 1, + ')' => { + depth = depth.saturating_sub(1); + if depth == 0 { params_end = Some(i + 1); break; } + } + _ => {} + } + } + let raw = tail[params_end?..].trim().trim_end_matches('{').trim(); + let inner = raw.strip_prefix('(').and_then(|s| s.strip_suffix(')')).unwrap_or(raw); + let mut depth = 0usize; + let mut starts = vec![0usize]; + let mut commas: Vec = Vec::new(); + for (i, c) in inner.char_indices() { + match c { + '[' | '(' | '{' => depth += 1, + ']' | ')' | '}' => depth = depth.saturating_sub(1), + ',' if depth == 0 => { commas.push(i); starts.push(i + 1); } + _ => {} + } + } + if index >= starts.len() { return None; } + let end = commas.get(index).copied().unwrap_or(inner.len()); + let nth = inner[starts[index]..end].trim(); + let nth = nth.rsplit(' ').next().unwrap_or(nth).trim(); + if nth.is_empty() || nth == "error" { None } else { Some(nth.to_string()) } +} diff --git a/codegraph-kernel/src/resolve/js_objects_upstream.rs b/codegraph-kernel/src/resolve/js_objects_upstream.rs index 60114eb03..6915a815b 100644 --- a/codegraph-kernel/src/resolve/js_objects_upstream.rs +++ b/codegraph-kernel/src/resolve/js_objects_upstream.rs @@ -56,6 +56,15 @@ fn position_within(r: &ResolveRefIn,n: &KNode) -> bool { && (r.line,r.column) < (n.end_line,n.end_column) } +/// What a collapsed-object call (`window.App.init()`, `this.render()`, a +/// bare name recorded from a chain) is: not one of ours, the browser's own +/// object (no edge), or a resolved project member (#2907). +pub(super) enum CollapsedCall { + NotOurs, + Declined, + Hit(KCand), +} + impl KernelResolver { fn js_object_parent(&mut self,n: &KNode) -> Res>> { let Some(cut) = n.qualified_name.rfind("::").filter(|cut| *cut > 0) else { return Ok(None) }; @@ -273,28 +282,40 @@ impl KernelResolver { }) } - pub(super) fn match_collapsed_object_call(&mut self,r: &ResolveRefIn) -> Res> { + pub(super) fn match_collapsed_object_call(&mut self,r: &ResolveRefIn) -> Res { if r.reference_kind != "calls" || !is_js_family(&r.language) - || !re!(r"^[A-Za-z_$][\w$]*$").is_match(&r.reference_name) { return Ok(None); } + || !re!(r"^[A-Za-z_$][\w$]*$").is_match(&r.reference_name) { return Ok(CollapsedCall::NotOurs); } if let Some(src) = self.read_file(&r.file_path) { if let Some(line) = src.get((r.line - 1).max(0) as usize) { if js_slice(line,r.column.max(0) as usize).strip_prefix(r.reference_name.as_str()) - .is_some_and(|rest| re!(r"^\s*\(").is_match(rest)) { return Ok(None); } + .is_some_and(|rest| re!(r"^\s*\(").is_match(rest)) { return Ok(CollapsedCall::NotOurs); } } } - let Some((_,receiver)) = self.bare_call_receiver(r)? else { return Ok(None) }; + let Some((_,receiver)) = self.bare_call_receiver(r)? else { return Ok(CollapsedCall::NotOurs) }; if receiver.trim() == "this" { if let Some(caller) = self.node_by_id(&r.from_node_id)? { if let Some(owner) = self.this_object(caller)? { - return self.resolve_object_literal_member(&owner,&r.reference_name,r,0.85,"instance-method"); + return Ok(match self.resolve_object_literal_member(&owner,&r.reference_name,r,0.85,"instance-method")? { + Some(hit) => CollapsedCall::Hit(hit), + None => CollapsedCall::NotOurs, + }); } } - return Ok(None); + return Ok(CollapsedCall::NotOurs); + } + // A JS `self` the file doesn't bind is the global object's own `self` — + // a worker's scope, or the page's `window` — never `this` (#2907). + if receiver.trim() == "self" && self.is_browser_host_global("self", r)? { + return Ok(CollapsedCall::Declined); } - let Some(caps) = re!(r"(?:^|[^\w$.])(window|globalThis)\s*\??\.\s*([A-Za-z_$][\w$]*(?:\s*\??\.\s*[A-Za-z_$][\w$]*)*)$").captures(&receiver) else { return Ok(None) }; + let Some(caps) = re!(r"(?:^|[^\w$.])(window|globalThis)\s*\??\.\s*([A-Za-z_$][\w$]*(?:\s*\??\.\s*[A-Za-z_$][\w$]*)*)$").captures(&receiver) else { return Ok(CollapsedCall::NotOurs) }; let path: String = caps[2].chars().filter(|ch| !ch.is_whitespace() && *ch != '?').collect(); Ok(match self.resolve_object_path_member(&path,&r.reference_name,r,Some(&caps[1]))? { - ObjectPathMatch::Found(hit) => Some(hit), _ => None, + ObjectPathMatch::Found(hit) => CollapsedCall::Hit(hit), + // A chain that starts at a browser host object is the browser's, + // however deep it goes: `window.localStorage.getItem()`. + _ if JS_BROWSER_HOST_GLOBALS.contains(path.split('.').next().unwrap_or(&path)) => CollapsedCall::Declined, + _ => CollapsedCall::NotOurs, }) } @@ -393,7 +414,7 @@ impl KernelResolver { /// isJsSuperCallSite: the call is written `super.name(…)` where it /// starts (the member on the next line or two included). - fn is_js_super_call_site(&mut self, r: &ResolveRefIn) -> Res { + pub(super) fn is_js_super_call_site(&mut self, r: &ResolveRefIn) -> Res { if r.reference_kind != "calls" || !JS_SUPER_LANGS.contains(&r.language.as_str()) || !re!(r"^[A-Za-z_$][\w$]*$").is_match(&r.reference_name) { return Ok(false); } let Some(lines) = self.read_file(&r.file_path) else { return Ok(false) }; diff --git a/codegraph-kernel/src/resolve/member_fn_ref.rs b/codegraph-kernel/src/resolve/member_fn_ref.rs index 87550101c..bb42b5c3d 100644 --- a/codegraph-kernel/src/resolve/member_fn_ref.rs +++ b/codegraph-kernel/src/resolve/member_fn_ref.rs @@ -253,6 +253,16 @@ impl KernelResolver { let site = r.clone().at(cls); let mut out = Vec::new(); for name in python_base_names(&lines, (cls.start_line - 1).max(0) as usize) { + // `class Adapter(*bases, Right)` with `bases = (Left,)`: the tuple's + // classes are bases too, in the order it writes them. + if let Some(variable) = name.trim().strip_prefix('*') { + for element in python_unpacked_bases(&lines, variable, (cls.start_line - 1).max(0) as usize) { + if let Some(base) = self.python_ref_class(&element, &site)? { + out.push(base); + } + } + continue; + } if let Some(base) = self.python_ref_class(&name, &site)? { out.push(base); } @@ -956,7 +966,8 @@ fn python_base_names(lines: &[String], at: usize) -> Vec { names .into_iter() .map(|n| n.trim().to_string()) - .filter(|n| !n.is_empty() && !n.contains('=') && !n.starts_with('*')) + // A splat base (`*bases`) is kept: python_bases expands its tuple. + .filter(|n| !n.is_empty() && !n.contains('=') && !n.starts_with("**")) .collect() } @@ -1064,3 +1075,27 @@ mod tests { assert_eq!(code[1].1, lines[1]); } } + +/// The class names a module-level tuple variable writes: `bases = (Left, Right)` +/// for a `class Adapter(*bases)` header. Empty for any other shape. +fn python_unpacked_bases(lines: &[String], variable: &str, before: usize) -> Vec { + let Ok(pattern) = regex::Regex::new(&format!(r"(?m)^{}\s*=\s*\(([^)]*)\)", regex::escape(variable.trim()))) else { + return Vec::new(); + }; + let text = lines.join("\n"); + // The module-level binding written before the class: an earlier function's + // local `bases = (Wrong,)` is no base list. + let last = pattern + .captures_iter(&text) + .filter(|caps| { + let at = caps.get(0).map(|m| m.start()).unwrap_or(0); + text[..at].matches('\n').count() < before + }) + .last(); + let Some(caps) = last else { return Vec::new() }; + caps[1] + .split(',') + .map(|item| item.trim().to_string()) + .filter(|item| !item.is_empty() && item.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '.')) + .collect() +} diff --git a/codegraph-kernel/src/resolve/method_call.rs b/codegraph-kernel/src/resolve/method_call.rs index 12b86d6dc..54886f6b1 100644 --- a/codegraph-kernel/src/resolve/method_call.rs +++ b/codegraph-kernel/src/resolve/method_call.rs @@ -693,6 +693,17 @@ impl KernelResolver { McShape::Done(res) => return Ok(res), }; + // A call on one of the browser's own objects — `window.open()`, + // `localStorage.getItem()`, `location.reload()`, `history.back()` — + // that the file neither binds nor imports is the browser's: no project + // method is it, whatever the method's name or a class's resemblance to + // the receiver (#2907). Ahead of the receiver-type reader, whose + // backward scan has no scope and would take a `history: X` member line + // for this name. + if r.reference_kind == "calls" && dotted && is_js_family(&r.language) + && self.is_browser_host_global(&object_or_class, r)? { + return Ok(None); + } if r.language == "cpp" && self.cpp_external_receiver(&object_or_class, r)? { return Ok(None); } if matches!(r.language.as_str(), "java" | "kotlin") { if let Some(hit) = self.enum_constant_call(&object_or_class, &method_name, r)? { return Ok(Some(hit)); } @@ -802,6 +813,12 @@ impl KernelResolver { None => self.infer_local_receiver_type(&object_or_class, &site, true), }?) }; + // A JVM `var name = Method()` takes the method's return type + // (`var userRegistry = make()` is a Registry), which no text + // pattern in local_receiver_type_patterns reads. + if inferred.is_none() && matches!(r.language.as_str(), "java" | "csharp" | "kotlin") { + inferred = self.jvm_var_initializer_type(&object_or_class, r)?; + } if r.language == "objc" && inferred.is_none() { inferred = self.objc_member_receiver_type(&object_or_class, r)?; } @@ -933,9 +950,74 @@ if inferred.is_none() && r.language == "dart" { } return self.match_bound_type_member(&object_or_class, &method_name, r); } + // `chunk, _ := s.Recv()` where `s` is a parameter of a generic type + // from a package the project does not index: the method's result is + // that type argument, which the written type names (`[Resp]`). The + // free strategies then answer the call from the method's name, as + // upstream leaves it (#2543). A value an outside package hands out + // (`http.NewRequest`, `ctx.Err()`) stays refused. + if r.language == "go" && !object_or_class.contains('.') + && self + .go_local_declaration_at(object_or_class.clone(), r.file_path.clone(), r.line, r.column)? + .is_some_and(|d| d.declared_type.is_none()) + && self.go_generic_parameter_initializer(&object_or_class, r)? + { + self.go_mc_untyped_local = true; + } Ok(None) } + /// Whether `name`'s short-var initializer calls a method on a parameter + /// whose written type is a generic of an outside package + /// (`chunk, _ := s.Recv()` on `s grpc.ServerStreamingClient[Resp]`). + fn go_generic_parameter_initializer(&mut self, name: &str, r: &ResolveRefIn) -> Res { + let Some(binding) = self.receiver_binding(name, r)? else { return Ok(false) }; + let Some(lines) = self.read_file(&r.file_path) else { return Ok(false) }; + let Some(line) = lines.get((binding.line - 1).max(0) as usize) else { return Ok(false) }; + let pattern = Self::cached_regex(&format!( + r"\b{}\s*:?[^=]*=\s*([A-Za-z_]\w*)\.([A-Za-z_]\w*)\s*\(", + regex::escape(name) + ))?; + let Some(caps) = pattern.captures(line) else { return Ok(false) }; + // Only the streaming read the rule exists for — `Recv`/`RecvMsg` hand + // back the type argument; `Header` and its like do not. + if !matches!(&caps[2], "Recv" | "RecvMsg") { + return Ok(false); + } + let root = caps[1].to_string(); + // The receiver must still be the parameter at the initializer. + if !self.receiver_binding(&root, r)?.is_some_and(|b| b.kind == "param") { + return Ok(false); + } + let Some(caller) = self.node_by_id(&r.from_node_id)? else { return Ok(false) }; + let signature = caller.signature.clone().unwrap_or_default(); + let parameter = Self::cached_regex(&format!(r"\b{}\s+([A-Za-z_][\w.\[\]]*)", regex::escape(&root)))?; + Ok(parameter.captures(&signature).is_some_and(|m| m[1].contains('[') && m[1].contains('.'))) + } + + /// The type a JVM `var name = Method()` local holds: the return type the + /// one project method named `Method` declares. None for any other shape. + fn jvm_var_initializer_type(&mut self, name: &str, r: &ResolveRefIn) -> Res> { + let Some(binding) = self.receiver_binding(name, r)? else { return Ok(None) }; + let Some(lines) = self.read_file(&r.file_path) else { return Ok(None) }; + let Some(line) = lines.get((binding.line - 1).max(0) as usize) else { return Ok(None) }; + let pattern = Self::cached_regex(&format!( + r"\bvar\s+{}\s*=\s*([A-Za-z_]\w*)\s*\(", + regex::escape(name) + ))?; + let Some(caps) = pattern.captures(line) else { return Ok(None) }; + let callee = caps[1].to_string(); + let hits: Vec<_> = self + .nodes_by_name(&callee)? + .iter() + .filter(|n| matches!(n.kind.as_str(), "method" | "function") && n.return_type.is_some()) + .cloned() + .collect(); + let same_file: Vec<_> = hits.iter().filter(|n| n.file_path == r.file_path).cloned().collect(); + let pick = if same_file.len() == 1 { same_file.into_iter().next() } else if hits.len() == 1 { hits.into_iter().next() } else { None }; + Ok(pick.and_then(|n| n.return_type.clone())) + } + /// isReactLifecycleMethod (#2589): a lifecycle method (`render`, /// `componentDidMount`, …) of a class whose head extends React's /// `Component`/`PureComponent` — such calls are React's, never a @@ -1757,6 +1839,16 @@ if inferred.is_none() && r.language == "dart" { if r.language == "go" && self.take_go_mc_untyped() { return probe!(r, "brc:mc-free", self.match_method_call_free(r)); } + // A Java/Scala/C# local whose `var` or inferred declaration says + // nothing of the value is like an unannotated JS binding: the free + // strategies answer it, and their standard-method gate keeps a + // library name from binding by name alone (#2915). + if matches!(r.language.as_str(), "java" | "scala" | "csharp" | "kotlin") + && binding.kind == "local" + && self.infer_local_receiver_type(root, r, true)?.is_none() + { + return probe!(r, "brc:untyped-jvm", self.match_method_call_free(r)); + } if binding.kind == "param" { // An unannotated parameter bound the name but says nothing of // its value — matchMethodCall (#2589) lets the name strategies diff --git a/codegraph-kernel/src/resolve/mod.rs b/codegraph-kernel/src/resolve/mod.rs index df26225b6..60a12eb92 100644 --- a/codegraph-kernel/src/resolve/mod.rs +++ b/codegraph-kernel/src/resolve/mod.rs @@ -734,6 +734,8 @@ pub struct KernelResolver { exported_symbol_memo: HashMap>>, sealed_memo: HashMap, esm_exports_memo: HashMap)>, + /// `file\0name` -> the JS file uses the name only as a receiver (#2907). + js_host_receiver_only: HashMap, c_static_memo: HashMap, /// cppMemberMacroArguments, by class declaration id. cpp_macro_arguments_memo: HashMap>>, @@ -1072,6 +1074,7 @@ impl KernelResolver { exported_symbol_memo: HashMap::new(), sealed_memo: HashMap::new(), esm_exports_memo: HashMap::new(), + js_host_receiver_only: HashMap::new(), c_static_memo: HashMap::new(), cpp_macro_arguments_memo: HashMap::new(), cpp_scope_names: None, diff --git a/codegraph-kernel/src/resolve/names.rs b/codegraph-kernel/src/resolve/names.rs index c74dadade..1414b0fa5 100644 --- a/codegraph-kernel/src/resolve/names.rs +++ b/codegraph-kernel/src/resolve/names.rs @@ -1,6 +1,7 @@ //! Name machinery (name-matcher.ts): visibility, exact and fuzzy matching, best-match scoring. use super::*; +use super::js_objects_upstream::CollapsedCall; use super::name_scope::{can_name_in_type_position, is_bare_rust_name}; /// The most a PHP `$this`/`self`/`parent` call settled on a repository trait @@ -47,6 +48,22 @@ fn prefer_adjacent_implementations(candidates: &mut Vec>, r: &Resolve }); } +/// Byte offsets of `name` in `text` where it stands as a whole word: not +/// preceded or followed by a name character (`$` included, as JavaScript +/// allows it in identifiers). The regex crate has no look-around. +pub(super) fn name_offsets<'t>(text: &'t str, name: &'t str) -> impl Iterator + 't { + text.match_indices(name).map(|(at, _)| at).filter(move |at| { + let before = text[..*at].chars().next_back().is_none_or(|c| !is_name_char(c)); + let after = at + name.len(); + let after = text[after..].chars().next().is_none_or(|c| !is_name_char(c)); + before && after + }) +} + +fn is_name_char(c: char) -> bool { + c.is_alphanumeric() || c == '_' || c == '$' +} + impl KernelResolver { // ----------------------------------------------------------------------- // Name machinery (name-matcher.ts) @@ -740,6 +757,7 @@ impl KernelResolver { pub(super) fn find_best_match(&mut self, r: &ResolveRefIn, candidates: &[Arc]) -> Option> { let mut best_score = -1f64; let mut best: Option> = None; + let mut tied: Vec> = Vec::new(); let mut ref_dirs: Vec = r.file_path.split('/').map(|s| s.to_string()).collect(); ref_dirs.pop(); @@ -786,6 +804,18 @@ impl KernelResolver { if score > best_score { best_score = score; best = Some(candidate.clone()); + tied.clear(); + tied.push(candidate.clone()); + } else if score == best_score { + tied.push(candidate.clone()); + } + } + // Proximity is capped, so on deep trees two copies of the same symbol + // in sibling modules can tie; the caller's own copy, if uniquely + // closest, wins (upstream 81559dcc). + if tied.len() > 1 { + if let Some(own) = closest_copy(r, &tied) { + return Some(own); } } best @@ -1103,20 +1133,59 @@ impl KernelResolver { resolved_by: "exact-match", })); } - if candidates.len() as i64 > self.ambiguous_ceiling { + // A member of the caller's own class (or what it inherits / is + // nested in) is what the name means there, however far away it is + // declared. Not for a call written on `super` in JS (upstream + // 25776014 / #2497). + let member = if self.is_js_super_call_site(r)? { + None + } else { + self.match_enclosing_scope_member(r, &candidates)? + }; + // The scope names the member, not the overload: when the class + // declares the name more than once, the one the arguments fit, else + // the nearest, is chosen below — among those overloads only. + let mut pool: Vec> = candidates.to_vec(); + if let Some(member) = member { + let overloads: Vec> = candidates + .iter() + .filter(|c| c.qualified_name == member.qualified_name && c.file_path == member.file_path) + .cloned() + .collect(); + if overloads.len() <= 1 { + return Ok(Some(KCand { + node: member, + confidence: 0.9, + resolved_by: "exact-match", + })); + } + pool = overloads; + } + if pool.len() as i64 > self.ambiguous_ceiling { return Ok(None); } - let Some(best) = self.find_best_match(r, &candidates) else { + let Some(best) = self.find_best_match(r, &pool) else { return Ok(None); }; - let best = self.cpp_function_overload(best, &candidates, r)?; + let best = self.cpp_function_overload(best, &pool, r)?; if bare_js && !is_bare_call_target_kind(&best.kind) { return Ok(None); } if self.is_cross_file_reachable(&best, r)? { let proximity = compute_path_proximity(&r.file_path, &best.file_path); + // Every candidate is a copy of one symbol and this is the caller's + // own copy: the name itself is not ambiguous, only which copy — + // so rank it above a proximity guess, below a unique name or the + // file's own definition (upstream 81559dcc). + let weighed: Vec> = if candidates.iter().any(|c| c.language == r.language) { + candidates.iter().filter(|c| c.language == r.language).cloned().collect() + } else { + candidates.to_vec() + }; let confidence = if self.is_decisive_same_file_match(&best, r, &candidates)? { 0.9 + } else if best.id != r.from_node_id && closest_copy(r, &weighed).is_some_and(|c| c.id == best.id) { + 0.8 } else if proximity >= 30 { 0.7 } else { @@ -1131,6 +1200,512 @@ impl KernelResolver { Ok(None) } + /// matchEnclosingScopeMember (name-matcher.ts, upstream 25776014): a call + /// that means a member of the class it is written in — a receiver-less + /// `render()` in Java / Scala, or `this.render()` / `self.render()` in + /// TypeScript / JavaScript / Python — binds by the language's scoping, not + /// by which same-named method sits nearest: the caller's own class first, + /// then what it inherits, then (bare only) enclosing classes. + fn match_enclosing_scope_member(&mut self, r: &ResolveRefIn, candidates: &[Arc]) -> Res>> { + const IMPLICIT: [&str; 2] = ["java", "scala"]; + const EXPLICIT: [&str; 5] = ["python", "typescript", "tsx", "javascript", "jsx"]; + if r.reference_kind != "calls" || r.reference_name.contains(['.', ':']) { + return Ok(None); + } + let implicit = IMPLICIT.contains(&r.language.as_str()); + if !implicit && !EXPLICIT.contains(&r.language.as_str()) { + return Ok(None); + } + let Some(caller) = self.node_by_id(&r.from_node_id)? else { + return Ok(None); + }; + if caller.qualified_name.is_empty() { + return Ok(None); + } + let Some(receiver) = self.call_receiver_kind(r)? else { + return Ok(None); + }; + if receiver == ReceiverKind::Bare && !implicit { + return Ok(None); + } + + let name = r.reference_name.as_str(); + // Candidates keyed by declaring type, qualified and simple. + let mut by_owner: HashMap>> = HashMap::new(); + let mut by_owner_name: HashMap>> = HashMap::new(); + for c in candidates { + let Some(sep) = c.qualified_name.rfind("::") else { continue }; + if sep == 0 || &c.qualified_name[sep + 2..] != name { + continue; + } + let owner = c.qualified_name[..sep].to_string(); + let simple = owner.rsplit("::").next().unwrap_or(&owner).to_string(); + by_owner.entry(owner).or_default().push(c.clone()); + by_owner.entry(simple.clone()).or_default(); + by_owner_name.entry(simple).or_default().push(c.clone()); + } + let callable_first = |found: &Vec>| -> Vec> { + let mut v = found.clone(); + v.sort_by_key(|c| i32::from(!(c.kind == "method" || c.kind == "function"))); + v + }; + // `Owner::Owner` is a constructor: a bare `Owner(...)` builds a new + // instance, it does not call an inherited or enclosing member. + let members = |owner: &str| -> Vec> { + if owner == name || owner.ends_with(&format!("::{name}")) { + Vec::new() + } else { + callable_first(by_owner.get(owner).unwrap_or(&Vec::new())) + } + }; + + let segments: Vec<&str> = caller.qualified_name.split("::").collect(); + let start = if MEMBER_OWNER_KINDS.contains(&caller.kind.as_str()) { segments.len() } else { segments.len() - 1 }; + for i in (1..=start).rev() { + let scope = segments[..i].join("::"); + let type_decls = self.types_named(&scope, &r.language)?; + if type_decls.is_empty() { + // `this.`/`super.` name a class member; only a bare call sees locals. + if receiver != ReceiverKind::Bare { + continue; + } + if let Some(local_fn) = members(&scope).into_iter().find(|c| { + c.file_path == r.file_path && (c.kind == "method" || c.kind == "function") + }) { + return Ok(Some(local_fn)); + } + continue; + } + if receiver != ReceiverKind::Super { + // pick(scope, true): the caller's file first; another file + // only when a single type carries the qualified name. + let found = members(&scope); + if !found.is_empty() { + if let Some(local) = found.iter().find(|c| c.file_path == r.file_path) { + return Ok(Some(local.clone())); + } + if type_decls.len() == 1 { + return Ok(Some(found[0].clone())); + } + } + } + // The type's own supertypes first; by name when they reach nothing. + let decl = self.decl_of(&type_decls, r); + let simple = segments[i - 1]; + let supers = self.direct_supertypes(&type_decls, simple, r)?; + let via_super = match &decl { + Some(d) => self.inherited_through(d, &by_owner, name, receiver, r)?, + None => None, + }; + let via_super = match via_super { + Some(v) => Some(v), + None => self.inherited_by_name(&supers, simple, &by_owner_name, name, receiver, r)?, + }; + if let Some(v) = via_super { + return Ok(Some(v)); + } + // `this`/`self`/`super` is the innermost instance; outer classes + // are reachable only by a bare call. + if receiver != ReceiverKind::Bare { + return Ok(None); + } + // A supertype outside the index may declare the name: stop rather + // than guess past it. + if supers.is_empty() && self.declares_supertype(&type_decls, simple, r)? { + return Ok(None); + } + } + Ok(None) + } + + /// The receiver a call site's line names: `render()`, `this.render()`, + /// `self.render()`, `super.render()` all extract as `render` (upstream + /// callReceiverKind). None when the receiver is something else. + fn call_receiver_kind(&mut self, r: &ResolveRefIn) -> Res> { + let Some(src) = self.read_file(&r.file_path) else { + return Ok(None); + }; + let Some(line) = src.line((r.line - 1) as usize) else { + return Ok(None); + }; + let mut at: Option = None; + for start in name_offsets(line, &r.reference_name) { + at = Some(start); + if start >= r.column as usize { + break; + } + } + let Some(at) = at else { + return Ok(None); + }; + let before = &line[..at]; + if re!(r"(?:^|[^\w$.])super(?:\s*\([^()]*\)|<[^<>]*>)?\s*\.\s*$").is_match(before) { + return Ok(Some(ReceiverKind::Super)); + } + if re!(r"(?:^|[^\w$.])this\s*\??\.\s*$").is_match(before) { + return Ok(Some(ReceiverKind::Slf)); + } + // A JS `self` the file doesn't bind is the global object, not the instance. + if re!(r"(?:^|[^\w$.])self\s*\??\.\s*$").is_match(before) { + if is_js_family(&r.language) && self.is_browser_host_global("self", r)? { + return Ok(None); + } + return Ok(Some(ReceiverKind::Slf)); + } + if r.language == "python" && re!(r"(?:^|[^\w$.])cls\s*\.\s*$").is_match(before) { + return Ok(Some(ReceiverKind::Slf)); + } + if re!(r"(?:\.|->|::)\s*$").is_match(before) { + return Ok(None); + } + Ok(Some(ReceiverKind::Bare)) + } + + /// typesNamed: owner-kind nodes carrying the qualified name, same language + /// family as the ref. + fn types_named(&mut self, qn: &str, lang: &str) -> Res>> { + Ok(self + .nodes_by_qualified_name(qn)? + .iter() + .filter(|n| MEMBER_OWNER_KINDS.contains(&n.kind.as_str()) && same_language_family(&n.language, lang)) + .cloned() + .collect()) + } + + /// declOf: the declaration of `scope` the call sits in, else the only one. + fn decl_of(&mut self, decls: &[Arc], r: &ResolveRefIn) -> Option> { + let enclosing: Vec<&Arc> = decls + .iter() + .filter(|n| n.file_path == r.file_path && n.start_line <= r.line && n.end_line >= r.line) + .collect(); + if let Some(last) = enclosing.last() { + return Some((*last).clone()); + } + if decls.len() == 1 { + return Some(decls[0].clone()); + } + None + } + + /// A type declaration's header: its text up to the body's `{`, every + /// declaration of that name joined (upstream typeHeader/headerOf). + fn type_header(&mut self, decls: &[Arc], r: &ResolveRefIn) -> Res> { + let enclosing: Vec<&Arc> = decls + .iter() + .filter(|n| n.file_path == r.file_path && n.start_line <= r.line && n.end_line >= r.line) + .collect(); + let chosen: Vec> = if let Some(last) = enclosing.last() { + vec![(*last).clone()] + } else { + decls.to_vec() + }; + if decls.is_empty() { + return Ok(None); + } + let mut header = String::new(); + for decl in chosen { + let Some(src) = self.read_file(&decl.file_path) else { + return Ok(None); + }; + let start = (decl.start_line - 1) as usize; + let stop = (decl.start_line as usize + 30).min(src.lines.len()); + for l in start..stop { + let Some(text) = src.line(l) else { break }; + match text.find('{') { + Some(b) => { + header.push(' '); + header.push_str(&text[..b]); + break; + } + None => { + header.push(' '); + header.push_str(text); + } + } + } + } + Ok(Some(header)) + } + + /// directSupertypes: of the simple name's resolved supertypes, the ones + /// the declaration's header names, in header order. + fn direct_supertypes(&mut self, decls: &[Arc], simple: &str, r: &ResolveRefIn) -> Res> { + let all = self.supertype_names(simple, r, false, false)?; + if all.is_empty() { + return Ok(all); + } + let Some(header) = self.type_header(decls, r)? else { + return Ok(all); + }; + let mut keyed: Vec<(i64, String)> = all + .into_iter() + .map(|t| { + let pos = name_offsets(&header, &t).next().map(|p| p as i64).unwrap_or(-1); + (pos, t) + }) + .filter(|(p, _)| *p >= 0) + .collect(); + keyed.sort_by_key(|(p, _)| *p); + Ok(keyed.into_iter().map(|(_, t)| t).collect()) + } + + /// Python's C3 linearization over the header's base list, with + /// `*bases` expanded. Falls back to appending the remaining heads when a + /// hierarchy is inconsistent, so a cycle cannot borrow a member. + fn python_c3(&mut self, class: &Arc, r: &ResolveRefIn, depth: u32) -> Res>> { + if depth > 8 { + return Ok(vec![class.clone()]); + } + let bases = self.python_bases(class, r)?; + let mut seqs: Vec>> = Vec::with_capacity(bases.len() + 1); + for base in &bases { + seqs.push(self.python_c3(base, r, depth + 1)?); + } + seqs.push(bases); + let mut out = vec![class.clone()]; + loop { + seqs.retain(|q| !q.is_empty()); + if seqs.is_empty() { + return Ok(out); + } + let head = seqs + .iter() + .map(|q| q[0].clone()) + .find(|h| !seqs.iter().any(|q| q[1..].iter().any(|n| n.id == h.id))); + let Some(head) = head else { + for q in &seqs { + if !out.iter().any(|c| c.id == q[0].id) { + out.push(q[0].clone()); + } + } + return Ok(out); + }; + if !out.iter().any(|c| c.id == head.id) { + out.push(head.clone()); + } + for q in seqs.iter_mut() { + if !q.is_empty() && q[0].id == head.id { + q.remove(0); + } + } + } + } + + /// inheritedThrough: members reached through the declaration's own + /// resolved extends/implements edges, breadth-first, in header order; + /// an interface/abstract declaration answers only when nothing concrete + /// does (upstream 25776014). + fn inherited_through( + &mut self, + ty: &Arc, + by_owner: &HashMap>>, + name: &str, + receiver: ReceiverKind, + r: &ResolveRefIn, + ) -> Res>> { + // Python reads the member along its C3 order, and each class's *own* + // member decides: `Adapter(Left, Right)` with `Left(Root)` and + // `Right(Root)` reaches Right's, though Root is written first in Left's + // subtree. `python_bases` also expands a header's `*bases`. + if r.language == "python" { + for class in self.python_c3(ty, r, 0)?.iter().skip(1) { + if class.name == name { + continue; + } + if self.python_class_assigns(class, name)? { + return Ok(Some(class.clone())); + } + let mut own: Vec<_> = self + .nodes_by_qualified_name(&format!("{}::{}", class.qualified_name, name))? + .iter() + .filter(|n| n.file_path == class.file_path) + .cloned() + .collect(); + own.retain(|c| { + !(receiver == ReceiverKind::Super + && (c.is_abstract || class.kind == "interface" || class.kind == "protocol")) + }); + if let Some(first) = own.first() { + return Ok(Some(first.clone())); + } + } + return Ok(None); + } + let mut seen: HashSet = HashSet::from([ty.id.clone()]); + let mut frontier = vec![ty.clone()]; + let mut declared: Option> = None; + for _depth in 0..4 { + if frontier.is_empty() { + break; + } + let mut next = Vec::new(); + for t in &frontier { + // Supertypes in the order the header names them. + let mut supers = self.supertype_nodes(&t.id)?; + if supers.len() >= 2 { + let header = self.header_of(t)?; + let at = |s: &Arc| -> i64 { + name_offsets(&header, &s.name).next().map(|p| p as i64).unwrap_or(i64::MAX) + }; + supers.sort_by_key(|s| at(s)); + } + for s in supers { + if seen.contains(&s.id) { + continue; + } + seen.insert(s.id.clone()); + next.push(s.clone()); + if s.name == name { + continue; + } + let abstract_only = s.kind == "interface" || s.kind == "protocol"; + let mut found: Vec> = by_owner + .get(&s.qualified_name) + .cloned() + .unwrap_or_default() + .into_iter() + .filter(|c| { + c.file_path == s.file_path + && !(receiver == ReceiverKind::Super && (c.is_abstract || abstract_only)) + }) + .collect(); + found.sort_by_key(|c| i32::from(!(c.kind == "method" || c.kind == "function"))); + if found.is_empty() { + continue; + } + if !abstract_only && !found[0].is_abstract { + return Ok(Some(found[0].clone())); + } + if declared.is_none() { + declared = Some(found[0].clone()); + } + } + } + frontier = next; + } + let _ = r; + Ok(declared) + } + + /// inherited (by name): breadth-first over resolved extends/implements + /// names from the caller's own type's supertypes. + fn inherited_by_name( + &mut self, + first_supers: &[String], + type_name: &str, + by_owner_name: &HashMap>>, + name: &str, + receiver: ReceiverKind, + r: &ResolveRefIn, + ) -> Res>> { + let mut frontier = vec![type_name.to_string()]; + let mut seen: HashSet = HashSet::from([type_name.to_string()]); + for depth in 0..4 { + if frontier.is_empty() { + break; + } + let mut next = Vec::new(); + for t in &frontier { + let supers: Vec = if depth == 0 { + first_supers.to_vec() + } else { + self.supertype_names(t, r, false, false)? + }; + for s in supers { + if seen.contains(&s) { + continue; + } + seen.insert(s.clone()); + next.push(s.clone()); + if s == name { + continue; + } + let Some(cands) = by_owner_name.get(&s) else { + continue; + }; + // Supertypes come back as bare names: a same-named type + // of another language is not it. + let own_language = self.nodes_by_name(&s)?.iter().any(|n| { + MEMBER_OWNER_KINDS.contains(&n.kind.as_str()) && n.language == r.language + }); + let mut kept: Vec> = Vec::new(); + for c in cands { + if own_language && c.language != r.language { + continue; + } + if receiver == ReceiverKind::Super + && (c.is_abstract || self.member_in_interface(c)?) + { + continue; + } + kept.push(c.clone()); + } + let mut found = kept; + found.sort_by_key(|c| i32::from(!(c.kind == "method" || c.kind == "function"))); + // Overloads share a file; copies across files are a guess + // unless one is the caller's. + if !found.is_empty() && found.iter().all(|c| c.file_path == found[0].file_path) { + return Ok(Some(found[0].clone())); + } + if !found.is_empty() { + return Ok(found.iter().find(|c| c.file_path == r.file_path).cloned()); + } + } + } + frontier = next; + } + Ok(None) + } + + /// isInterfaceMember: the member's owner resolves to an interface/protocol. + fn member_in_interface(&mut self, member: &Arc) -> Res { + let Some(sep) = member.qualified_name.rfind("::") else { + return Ok(false); + }; + Ok(self + .nodes_by_qualified_name(&member.qualified_name[..sep])? + .iter() + .any(|n| n.kind == "interface" || n.kind == "protocol")) + } + + /// A declaration's header — its text up to the body's `{` (upstream + /// headerOf). + pub(super) fn header_of(&mut self, decl: &Arc) -> Res { + let mut header = String::new(); + let Some(src) = self.read_file(&decl.file_path) else { + return Ok(header); + }; + let start = (decl.start_line - 1) as usize; + let stop = (decl.start_line as usize + 30).min(src.lines.len()); + for l in start..stop { + let Some(text) = src.line(l) else { break }; + match text.find('{') { + Some(b) => { + header.push(' '); + header.push_str(&text[..b]); + break; + } + None => { + header.push(' '); + header.push_str(text); + } + } + } + Ok(header) + } + + /// declaresSupertype: the declaration's header names an + /// extends/implements/with or a C++ `:` base list. + fn declares_supertype(&mut self, decls: &[Arc], simple: &str, r: &ResolveRefIn) -> Res { + if simple.starts_with('<') { + return Ok(true); // an anonymous class always has one + } + let Some(header) = self.type_header(decls, r)? else { + return Ok(true); + }; + Ok(re!(r"\b(?:extends|implements|with)\b|[\w>)\]]\s*:(?:[^:]|$)").is_match(&header)) + } + /// Whether `findBestMatch` picked `best` because it is THE definition in /// the call site's own file, so the edge deserves a same-file confidence /// rather than the directory-proximity one. Proximity measures shared @@ -1559,7 +2134,11 @@ impl KernelResolver { && super::lua_alias::is_global(&r.reference_name) && !self.nodes_in_file(&r.file_path)?.iter().any(|n| n.name == r.reference_name && n.kind == "function") { return Ok(None); } if let Some(alias) = self.csharp_alias_type_target(r)? { return Ok(Some(alias)); } - if let Some(c) = self.match_collapsed_object_call(r)? { return Ok(Some(c)); } + match self.match_collapsed_object_call(r)? { + CollapsedCall::Hit(c) => return Ok(Some(c)), + CollapsedCall::Declined => return Ok(None), + CollapsedCall::NotOurs => {} + } if let Some(c) = self.match_by_exact_name(r)? { return Ok(Some(c)); } @@ -2155,6 +2734,71 @@ pub(super) fn compute_path_proximity(file_path1: &str, file_path2: &str) -> i64 path_proximity_from_dirs(&dir1, file_path2) } +/// MEMBER_OWNER_KINDS (name-matcher.ts): node kinds a member scope can be. +const MEMBER_OWNER_KINDS: [&str; 8] = + ["class", "struct", "interface", "trait", "protocol", "enum", "union", "module"]; + +/// callReceiverKind's answer: how the call site names its receiver. +#[derive(Clone, Copy, PartialEq, Eq)] +enum ReceiverKind { + Bare, + Slf, + Super, +} + +/// sharedDirDepth (name-matcher.ts): leading directory segments two file +/// paths share, uncapped. +fn shared_dir_depth(file_path1: &str, file_path2: &str) -> usize { + let mut a: Vec<&str> = file_path1.split('/').collect(); + let mut b: Vec<&str> = file_path2.split('/').collect(); + a.pop(); + b.pop(); + let mut shared = 0; + while shared < a.len() && shared < b.len() && a[shared] == b[shared] { + shared += 1; + } + shared +} + +/// closestCopy (name-matcher.ts, upstream 81559dcc): when every candidate is +/// a copy of the same symbol — same qualified name, kind and file basename, +/// as when a module is duplicated under another source root — return the copy +/// whose file shares the longest directory prefix with the reference. None +/// when the candidates are not all copies or no copy is uniquely closest. +fn closest_copy(r: &ResolveRefIn, candidates: &[Arc]) -> Option> { + if candidates.len() < 2 { + return None; + } + let first = &candidates[0]; + let base = first.file_path.rsplit('/').next().unwrap_or(""); + let mut best: Option> = None; + let mut best_depth = 0usize; + let mut have = false; + let mut unique = false; + for c in candidates { + if c.qualified_name != first.qualified_name + || c.kind != first.kind + || c.file_path.rsplit('/').next().unwrap_or("") != base + { + return None; + } + let depth = shared_dir_depth(&r.file_path, &c.file_path); + if !have || depth > best_depth { + best_depth = depth; + best = Some(c.clone()); + have = true; + unique = true; + } else if depth == best_depth { + unique = false; + } + } + if unique { + best + } else { + None + } +} + /// The file's lines with `//` and one-line `/* */` comments removed, rejoined. fn stripped_text(src: &SourceFile) -> String { src.iter().map(|l| strip_line_comments(l)).collect::>().join("\n") diff --git a/codegraph-kernel/src/resolve/node_table.rs b/codegraph-kernel/src/resolve/node_table.rs index 1f54f6eaa..f2d73bed0 100644 --- a/codegraph-kernel/src/resolve/node_table.rs +++ b/codegraph-kernel/src/resolve/node_table.rs @@ -30,6 +30,8 @@ pub(super) struct KNode { /// `async` function or method: its call yields a Promise. pub(super) is_async: bool, pub(super) is_static: bool, + /// Abstract method/class body — a `super.m()` call never lands on it. + pub(super) is_abstract: bool, } impl KNode { @@ -57,6 +59,7 @@ impl KNode { decorators: raw_decs.as_deref().and_then(parse_json_string_array), is_async: row.get::<_, Option>(16)?.unwrap_or(0) != 0, is_static: row.get::<_, Option>(17)?.unwrap_or(0) != 0, + is_abstract: row.get::<_, Option>(18)?.unwrap_or(0) != 0, }) } } @@ -576,4 +579,4 @@ pub(super) struct WorkspaceK { pub(super) const NODE_COLS: &str = "id, kind, name, qualified_name, file_path, language, \ start_line, end_line, start_column, end_column, \ signature, visibility, is_exported, return_type, \ - type_parameters, decorators, is_async, is_static"; + type_parameters, decorators, is_async, is_static, is_abstract"; diff --git a/codegraph-kernel/src/resolve/python_types.rs b/codegraph-kernel/src/resolve/python_types.rs index 3fd5719d8..371a4b84f 100644 --- a/codegraph-kernel/src/resolve/python_types.rs +++ b/codegraph-kernel/src/resolve/python_types.rs @@ -995,7 +995,7 @@ impl KernelResolver { let trimmed = line.trim_start(); if trimmed.is_empty() || trimmed.starts_with('#') { continue; } if line.len() - trimmed.len() >= binding_indent { continue; } - if re!(r"^(?:if|elif|else|for|while|try|except|finally|with)").is_match(trimmed) { + if re!(r"^(?:if|elif|else|for|while|try|except|finally|with)\b").is_match(trimmed) { return true; } } @@ -1004,9 +1004,6 @@ impl KernelResolver { // `PyBinding` lines are zero-based; the ref's line is one-based. let at = r.line.max(0) as usize; let eligible = |line: &usize, rebinds: &bool| *rebinds && *line < at; - if lines.iter().any(|(_, line, rebinds)| eligible(line, rebinds) && control_bound(*line)) { - return Ok(Some(Answer::Unknown)); - } if let Some((i, line, _)) = lines .iter() .filter(|(_, line, rebinds)| eligible(line, rebinds)) diff --git a/codegraph-kernel/src/resolve/tables.rs b/codegraph-kernel/src/resolve/tables.rs index a2c57238f..873d6abde 100644 --- a/codegraph-kernel/src/resolve/tables.rs +++ b/codegraph-kernel/src/resolve/tables.rs @@ -998,6 +998,19 @@ pub(super) static JS_BUILT_IN_METHODS: LazyLock> = LazyLoc .collect() }); +/// The browser's host objects a script reaches by name (JS_BROWSER_HOST_GLOBALS, +/// js-builtins.ts): the global object under its names and its members that +/// code calls straight on. A call on one is the browser's, not a project +/// method, unless the file binds or imports the name (#2907). +pub(super) static JS_BROWSER_HOST_GLOBALS: LazyLock> = LazyLock::new(|| { + [ + "window", "self", "globalThis", "document", "localStorage", "sessionStorage", + "location", "history", "navigator", "screen", "performance", + ] + .into_iter() + .collect() +}); + pub(super) static PYTHON_BUILT_IN_METHODS: LazyLock> = LazyLock::new(|| { [ "append", "extend", "insert", "remove", "pop", "clear", "sort", "reverse", "copy", "update",