mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-17 05:07:37 +08:00
fix(resolution): Solidity bare calls stay in the inheritance chain; Erlang follows -import (#2178)
- Solidity: a receiver-less call reaches a function of the contract around it or one it inherits (heads read from source, `is` clause with constructor args dropped); OpenZeppelin's _msgSender() in Context-based contracts went to ERC2771Context's override 83 times. - Erlang: another module's function is called bare only through -import(Mod, [f/N]) (or from an included .hrl): cowboy's -import(req_SUITE, [do_get/3]) went to compress_SUITE's do_get/3; with no import, no cross-module guess. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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<string, string> = {
|
||||
'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']);
|
||||
});
|
||||
});
|
||||
@@ -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<ResolutionContext, Map<string, string[]>>();
|
||||
const SOLIDITY_HIERARCHIES = new WeakMap<ResolutionContext, WeakMap<UnresolvedRef, Set<string>>>();
|
||||
const SOLIDITY_TYPE_KINDS: ReadonlySet<string> = 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<string>();
|
||||
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(`(?<![\\w$])${ref.referenceName}\\s*\\(`).exec(line);
|
||||
return !!m && !/\.\s*$/.test(line.slice(0, m.index));
|
||||
}
|
||||
|
||||
/** The module an Erlang file `-import`s `name/arity` from, or undefined. */
|
||||
function erlangImportedModule(name: string, arity: string, ref: UnresolvedRef, context: ResolutionContext): string | undefined {
|
||||
const source = context.readFile(ref.filePath);
|
||||
if (!source || !source.includes('-import')) return undefined;
|
||||
const fn = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
for (const m of source.matchAll(/^-import\(\s*'?([A-Za-z_][\w@]*)'?\s*,\s*\[([^\]]*)\]\s*\)\s*\./gm)) {
|
||||
if (new RegExp(`(?:^|[\\s,])'?${fn}'?\\s*/\\s*${arity}\\b`).test(m[2]!)) return m[1]!;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const CPP_NS_MACROS = new WeakMap<ResolutionContext, { openers: Map<string, string[]>; openerFns: Set<string>; closers: Map<string, number>; aliases: Map<string, string> }>();
|
||||
const CPP_NS_FRAMES = new WeakMap<ResolutionContext, Map<string, Array<{ start: number; end: number; path: string[] }>>>();
|
||||
/** A closing macro's body: `}` / `} }`, maybe beside a pragma macro (`PYBIND11_WARNING_POP }`). */
|
||||
|
||||
Reference in New Issue
Block a user