diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f93060b9..3e2fad425 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- In Solidity, a call with no receiver now reaches a function of the contract around it or of one it inherits. On OpenZeppelin, `_msgSender()` in contracts built on `Context` used to land on `ERC2771Context`'s override 83 times. In Erlang, a call to another module's function now follows the file's `-import(Mod, [f/N])`. On cowboy, `-import(req_SUITE, [do_get/3])` calls used to land on another suite's `do_get/3`. A bare call with no such import no longer links to some other module. Re-index after upgrading. - Calls on a library's own types and modules no longer land on a project method that happens to share the name. In Rust, `Vec::new()`, `String::from(…)` and module paths like `task::spawn(…)` stay the standard library's. A later link of a multi-line Rust or Go chain now counts as a chained call, named after the chain's first line. In Go, `w.Header().Get(…)` is net/http's. In R, a plain call like `range(x)` is a function, not an object's method. In JavaScript, `response.text()` / `.json()` is fetch's. On tokio, `task::spawn(…)` used to land on `TaskTracker::spawn` 300 times. On ggplot2, `range(…)` used to land on a coordinate system's `range` method. Re-index after upgrading. - In C and C++, a qualified name like `fmt::format` now reaches a declaration inside a namespace the library opens with a macro, like fmt's `FMT_BEGIN_NAMESPACE`, pybind11's `PYBIND11_NAMESPACE_BEGIN(…)` or rapidjson's and spdlog's `…_NAMESPACE_BEGIN`. It also works through a namespace alias like `namespace py = pybind11;`. On fmt, 1,728 `fmt::format(…)` calls used to link to nothing, and so did about 1,400 `py::…` calls on pybind11. - A method guessed from a receiver's name no longer lands on a request handler the web framework dispatches to, such as a Django or DRF view's `post` / `get` or a controller's `update` / `destroy`. It also no longer lands on a test double the calling file never mentions. On django-allauth, tests' `client.post(…)` used to land on a `ClientRegistrationView` 425 times. On mealie, `response.json()` used to land on a test's `_FakeHTTPResponse` 424 times. In Python, `site = Site.objects.create(…)` now makes `site` a `Site`, so `site.save()` reaches the model's own `save`. Re-index after upgrading. diff --git a/__tests__/solidity-erlang-call-scope.test.ts b/__tests__/solidity-erlang-call-scope.test.ts new file mode 100644 index 000000000..2ced668f5 --- /dev/null +++ b/__tests__/solidity-erlang-call-scope.test.ts @@ -0,0 +1,98 @@ +/** + * A call written without a receiver reaches what its language lets it: + * + * - Solidity: a function of the contract around the call or of one it + * inherits (OpenZeppelin's `_msgSender()` in a Context-derived Governor + * went to ERC2771Context's override 83 times); + * - Erlang: another module's function only through `-import(Mod, [f/N])` + * (cowboy's `-import(req_SUITE, [do_get/3])` went to compress_SUITE's + * `do_get/3`). + */ +import { describe, it, expect, afterAll, beforeAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; + +let root = ''; +let cg: CodeGraph; + +beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-sol-erl-scope-')); + const files: Record = { + 'contracts/Context.sol': `pragma solidity ^0.8.20; + +abstract contract Context { + function _msgSender() internal view virtual returns (address) { + return msg.sender; + } +} +`, + 'contracts/ERC2771Context.sol': `pragma solidity ^0.8.20; + +import {Context} from "./Context.sol"; + +abstract contract ERC2771Context is Context { + function _msgSender() internal view virtual override returns (address) { + return msg.sender; + } +} +`, + 'contracts/Governor.sol': `pragma solidity ^0.8.20; + +import {Context} from "./Context.sol"; + +abstract contract Governor is Context { + function castVote() public returns (address) { + return _msgSender(); + } +} +`, + 'test/req_SUITE.erl': `-module(req_SUITE). +-export([do_get/3]). + +do_get(Path, Headers, Config) -> {Path, Headers, Config}. +`, + 'test/compress_SUITE.erl': `-module(compress_SUITE). +-export([do_get/3]). + +do_get(Path, Headers, Config) -> {Path, Headers, Config}. +`, + 'test/static_handler_SUITE.erl': `-module(static_handler_SUITE). +-export([etag/1]). +-import(req_SUITE, [do_get/3]). + +etag(Config) -> do_get("/etag", [], Config). +`, + }; + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + cg = await CodeGraph.init(root, { index: true }); +}); + +afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); +}); + +/** `Owner::member` of every call edge out of a file. */ +function callsFrom(file: string): string[] { + const ids = cg.getNodesInFile(file).map((n) => n.id); + return cg + .getOutgoingEdgesFrom(ids) + .filter((e) => e.kind === 'calls') + .map((e) => cg.getNode(e.target)!.qualifiedName) + .sort(); +} + +describe('receiver-less calls', () => { + it('Solidity: reach the inherited function, not a sibling override', () => { + expect(callsFrom('contracts/Governor.sol')).toEqual(['Context::_msgSender']); + }); + + it('Erlang: reach the module the file imports the function from', () => { + expect(callsFrom('test/static_handler_SUITE.erl')).toEqual(['req_SUITE::do_get/3']); + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index c6a316ffb..817abebee 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -3389,6 +3389,7 @@ export function matchByExactName( // large import-heavy (front-end + back-end) repos (#915). const bareJs = isBareJsCall(ref, context); const bareNoMembers = isBareGoCall(ref, context) || isBareRCall(ref, context); + const solidityBare = isReceiverLessSolidityCall(ref, context); const barePhp = isBarePhpCall(ref, context); // A type, a value or an import the file binds from a package outside the // repository names nothing in it, whatever kind of reference it is. @@ -3457,6 +3458,7 @@ export function matchByExactName( !(kotlinCall && !isKotlinTopLevelVisible(n, ref, context)) && !(kotlinBare && !isKotlinMemberReachable(n, ref, context)) && !isKotlinNumberBitwise(n, ref) && + !(solidityBare && !isSolidityMemberInScope(n, ref, context)) && !(dartBare && isDartMember(n) && !isDartMethodInScope(n, ref, context)) && !(phpSelf && (n.kind !== 'method' || !isPhpMethodInScope(n, ref, phpSelf, context))) && !(pythonShape && !fitsPythonCallShape(n, pythonShape, ref, context)) && @@ -4584,6 +4586,8 @@ export function clearNameMatcherMemos(context: ResolutionContext): void { CPP_NS_MACROS.delete(context); CPP_NS_FRAMES.delete(context); CPP_NS_ALIASES.delete(context); + SOLIDITY_SUPERS.delete(context); + SOLIDITY_HIERARCHIES.delete(context); MEMBER_TYPE_MEMO.delete(context); CSHARP_ALIASES.delete(context); SWIFT_HIERARCHIES.delete(context); @@ -7038,8 +7042,10 @@ export function matchFuzzy( // resolve a name without regard to case, which is what this fallback's // lowercase index is for. const bareR = isBareRCall(ref, context); + const solidityBare = isReceiverLessSolidityCall(ref, context); const callableCandidates = candidates.filter((n) => callableKinds.has(n.kind) && !(typeRef && !canNameInTypePosition(n)) && !(bareR && n.kind === 'method') && + !(solidityBare && !isSolidityMemberInScope(n, ref, context)) && // `new …MockData()` makes an instance of a type; a method is never what it names. !(ref.referenceKind === 'instantiates' && n.kind === 'method') && !(rustBare && !isRustNameInScope(n, ref, context)) && @@ -7261,6 +7267,19 @@ function matchReferenceInner( if (sameFile) { return { original: ref, targetNodeId: sameFile.id, confidence: 0.95, resolvedBy: 'exact-match' }; } + // Another module's function is called bare only through `-import(Mod, + // [f/N])` (or from a `.hrl` a module includes): cowboy's + // `-import(req_SUITE, [do_get/3])` went to compress_SUITE's `do_get/3`. + const imported = erlangImportedModule(am[1]!, am[2]!, ref, context); + if (imported !== undefined) { + const chosen = candidates.find((n) => n.qualifiedName.startsWith(`${imported}::`)); + return chosen ? { original: ref, targetNodeId: chosen.id, confidence: 0.9, resolvedBy: 'exact-match' } : null; + } + if (!/\.hrl$/.test(ref.filePath)) { + const included = candidates.filter((n) => /\.hrl$/.test(n.filePath)); + if (included.length === 0) return null; + candidates.splice(0, candidates.length, ...included); + } if (candidates.length === 1) { return { original: ref, targetNodeId: candidates[0]!.id, confidence: 0.8, resolvedBy: 'exact-match' }; } @@ -7372,6 +7391,85 @@ function matchReferenceInner( return null; } +const SOLIDITY_SUPERS = new WeakMap>(); +const SOLIDITY_HIERARCHIES = new WeakMap>>(); +const SOLIDITY_TYPE_KINDS: ReadonlySet = new Set(['class', 'interface', 'struct', 'module', 'trait']); + +/** What a Solidity contract, interface or library inherits: `contract Governor is Context, ERC165(…), IGovernor {`. */ +function soliditySupertypesOf(name: string, context: ResolutionContext): string[] { + let memo = SOLIDITY_SUPERS.get(context); + if (!memo) SOLIDITY_SUPERS.set(context, (memo = new Map())); + const hit = memo.get(name); + if (hit) return hit; + const supers: string[] = []; + for (const decl of context.getNodesByName(name)) { + if (decl.language !== 'solidity' || !SOLIDITY_TYPE_KINDS.has(decl.kind)) continue; + const lines = context.getFileLines?.(decl.filePath) ?? context.readFile(decl.filePath)?.split(/\r?\n/) ?? []; + let depth = 0; + let head = ''; + for (const ch of lines.slice(decl.startLine - 1, decl.startLine + 10).join(' ').replace(/\/\/[^\n]*|\/\*.*?\*\//g, ' ')) { + if (ch === '{' && depth === 0) break; + if (ch === '(') depth++; + else if (ch === ')') depth = Math.max(0, depth - 1); + else if (depth === 0) head += ch; + } + const clause = /\bis\b([\s\S]*)$/.exec(head)?.[1] ?? ''; + for (const m of clause.matchAll(/([A-Za-z_]\w*)\s*(?=,|$)/g)) supers.push(m[1]!); + } + memo.set(name, supers); + return supers; +} + +/** + * Whether a bare Solidity call can reach method `n`: a function of the + * contract around the call or of one it inherits, or a free function. + * OpenZeppelin's `_msgSender()` in Governor (a Context) went to + * ERC2771Context's override 83 times. + */ +function isSolidityMemberInScope(n: Node, ref: UnresolvedRef, context: ResolutionContext): boolean { + if (n.kind !== 'method' || n.language !== 'solidity') return true; + const cut = n.qualifiedName.lastIndexOf('::'); + if (cut <= 0) return true; + const owner = n.qualifiedName.slice(0, cut).split('::').pop()!; + let memo = SOLIDITY_HIERARCHIES.get(context); + if (!memo) SOLIDITY_HIERARCHIES.set(context, (memo = new WeakMap())); + let names = memo.get(ref); + if (!names) { + names = new Set(); + const queue = context.getNodesInFile(ref.filePath) + .filter((c) => SOLIDITY_TYPE_KINDS.has(c.kind) && c.startLine <= ref.line && c.endLine >= ref.line) + .map((c) => c.name); + while (queue.length > 0 && names.size < 60) { + const name = queue.shift()!; + if (names.has(name)) continue; + names.add(name); + queue.push(...soliditySupertypesOf(name, context)); + } + memo.set(ref, names); + } + return names.has(owner); +} + +/** Whether a Solidity call is written with no receiver (`_msgSender()`, not `token._msgSender()`). */ +function isReceiverLessSolidityCall(ref: UnresolvedRef, context: ResolutionContext): boolean { + if (ref.language !== 'solidity' || ref.referenceKind !== 'calls' || !/^[A-Za-z_]\w*$/.test(ref.referenceName)) return false; + const line = context.getFileLines?.(ref.filePath)?.[ref.line - 1] ?? context.readFile(ref.filePath)?.split('\n')[ref.line - 1]; + if (!line) return false; + const m = new RegExp(`(?; openerFns: Set; closers: Map; aliases: Map }>(); const CPP_NS_FRAMES = new WeakMap>>(); /** A closing macro's body: `}` / `} }`, maybe beside a pragma macro (`PYBIND11_WARNING_POP }`). */