Contracts/libraries/interfaces, structs, enums, modifiers, events, errors, state variables; call edges for emit/revert/modifier guards/base-constructor chains/library calls; is-inheritance with implements reclassification; import resolution. Validated on solmate, solady, openzeppelin-contracts. Lands #667. Co-authored-by: naiba <hi@nai.ba> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
naiba
Claude Fable 5
parent
a0208feaac
commit
1441933a26
@@ -492,5 +492,28 @@
|
||||
"files": "~2450",
|
||||
"question": "How does a PUBLISH packet from an MQTT client reach the sessions of matching subscribers? Trace the flow from the connection/channel layer through the broker's routing to session delivery."
|
||||
}
|
||||
],
|
||||
"Solidity": [
|
||||
{
|
||||
"name": "solmate",
|
||||
"repo": "https://github.com/transmissions11/solmate",
|
||||
"size": "Small",
|
||||
"files": "~60",
|
||||
"question": "How does solmate's ERC20 transferFrom enforce allowance and update balances? Trace the flow including the permit() signature path."
|
||||
},
|
||||
{
|
||||
"name": "solady",
|
||||
"repo": "https://github.com/Vectorized/solady",
|
||||
"size": "Medium",
|
||||
"files": "~270",
|
||||
"question": "How does solady's ERC20 implementation handle a permit() call — from signature recovery through nonce update to allowance write?"
|
||||
},
|
||||
{
|
||||
"name": "openzeppelin-contracts",
|
||||
"repo": "https://github.com/OpenZeppelin/openzeppelin-contracts",
|
||||
"size": "Large",
|
||||
"files": "~400",
|
||||
"question": "How does an OpenZeppelin AccessControl-protected function check the caller's role? Trace from the onlyRole modifier through hasRole to the role storage."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
### New Features
|
||||
|
||||
- CodeGraph now indexes **Solidity** (`.sol`) — contracts, libraries, interfaces, structs, enums, modifiers, events, errors, and state variables become first-class symbols, with call edges that follow `emit`, `revert`, modifier guards (`onlyOwner`-style, including base-constructor chains like `constructor() ERC20(...)`), and library/method calls (including `using` directives). `import` directives resolve to the imported file, so cross-contract questions like "trace `transferFrom` through allowance and balance updates" or "how does `onlyRole` reach the role storage?" work out of the box on real Solidity codebases (validated on solmate, solady, and OpenZeppelin Contracts). Thanks @naiba. (#374, #648)
|
||||
- Erlang behaviour dispatch is now followed through the graph: a framework call through a variable module — cowboy's `Handler:init`/`Middleware:execute` folds, a plugin manager's `Mod:callback(...)` — links to the repo's implementations of the behaviour that declares that callback, so flow traces and impact cross the OTP callback boundary instead of stopping at it. The links are precision-gated: the callback arity must match, exactly one behaviour may own that callback shape (a collision stays unlinked rather than guessed), the implementer must actually export the callback, and the fan-out is bounded — a behaviour with hundreds of implementers stays a visibly dynamic boundary. Every bridged hop is labeled as dynamic dispatch with its wiring site, never shown as a plain static call.
|
||||
- CodeGraph now indexes **Erlang** (`.erl`, `.hrl`) — functions, with clauses and arities of the same name grouped as one symbol spanning all of them, plus records with their fields, `-type`/`-opaque` aliases, `-define` macros, and `-spec` signatures attached to every function. Cross-module `mod:fn(...)` calls resolve to the target module's function, `fun name/arity` values are captured as references (so callback registrations like `lists:foreach(fun submit/1, ...)` link up), `-include`/`-include_lib` connect to the header files they pull in, `-behaviour` declarations link a callback module to its behaviour (and only ever to a module — a same-named macro or function elsewhere in the repo is never mistaken for one), and `-export` lists (plus `-compile(export_all)`) drive each function's public/private flag. OTP's indirection idioms are followed where the target is static: `spawn`/`apply`/`proc_lib`/`timer`/`rpc` calls that name their target as `(Module, Function, Args)` arguments produce call edges, and `gen_server:call`/`cast` connects to the target module's `handle_call`/`handle_cast` — its own when targeting `?MODULE` (including the `-define(SERVER, ?MODULE)` idiom), and the named module when a registered name follows OTP's name-the-server-after-its-module convention (`gen_server:call(other_mod, ...)`, directly or through a `-define(STORE, other_mod)` macro); a registered name that matches no module stays unlinked. Macros participate in the graph too: a `-define` body's calls belong to the macro, each `?MACRO(...)` use site links into the call chain (and bare `?CONSTANT` reads are tracked as references), so a call path hidden behind a macro — `set_password → ?SQL_UPSERT_T → sql_query_t` — traces end-to-end and "where is this macro used" is answerable. escripts index like any module (the shebang line is understood), and OTP application resource files (`.app.src`, `.app`) join the graph: `{mod, ...}` links an app to its callback module and `{applications, [...]}` connects umbrella sibling apps — resolving only ever to modules, so an OTP app name like `ssl` is never mistaken for a same-named function. Truly dynamic dispatch (`Mod:handle(...)`, message sends, var-module spawns) is deliberately left unlinked rather than guessed. `codegraph_explore` also understands Erlang-native symbol spelling in queries — `mod:fn/3` and `init/2` find the symbols they name. (#635, #648)
|
||||
- CodeGraph now indexes **Visual Basic .NET** (`.vb`) — classes, Modules, interfaces, structures, enums, properties, events, `MustOverride` abstract members, and `Declare` P/Invoke signatures, with `Inherits`/`Implements` hierarchy edges, call edges (resolved through VB's ambiguous call-vs-index parentheses), and `New`/`As New` instantiation links. Real-world VB styles parse cleanly: WinForms designer files, interpolated and multi-line strings, XML literals (embedded `<%= %>` expressions included), single-line and multi-line LINQ queries, multi-line lambdas, `Handles`/`WithEvents` event wiring, Custom Events, date literals, classic type-character identifiers (`i%`, `name$`), and non-English (Unicode) identifiers. (#648, #639, #170)
|
||||
|
||||
@@ -244,7 +244,7 @@ The reliable, universal payoff is **surgical context and speed**: CodeGraph coll
|
||||
| **Full-Text Search** | Find code by name instantly across your entire codebase, powered by FTS5 |
|
||||
| **Impact Analysis** | Trace callers, callees, and the full impact radius of any symbol before making changes |
|
||||
| **Always Fresh** | File watcher uses native OS events (FSEvents/inotify/ReadDirectoryChangesW) with debounced auto-sync — the graph stays current as you code, zero config |
|
||||
| **20+ Languages** | TypeScript, JavaScript, Python, Go, Rust, Java, C#, VB.NET, PHP, Ruby, C, C++, Objective-C, Metal, Swift, Kotlin, Scala, Dart, Lua, Luau, R, Erlang, CFML, COBOL, Svelte, Vue, Astro, Liquid, Pascal/Delphi |
|
||||
| **20+ Languages** | TypeScript, JavaScript, Python, Go, Rust, Java, C#, VB.NET, PHP, Ruby, C, C++, Objective-C, Metal, Swift, Kotlin, Scala, Dart, Lua, Luau, R, Erlang, CFML, COBOL, Solidity, Svelte, Vue, Astro, Liquid, Pascal/Delphi |
|
||||
| **Framework-aware Routes** | Recognizes web-framework routing files and links URL patterns to their handlers across 17 frameworks |
|
||||
| **Mixed iOS / React Native / Expo** | Closes cross-language flows that static parsing misses: Swift ↔ ObjC bridging, React Native legacy bridge + TurboModules + Fabric view components, native → JS event emitters, Expo Modules |
|
||||
| **100% Local** | No data leaves your machine. No API keys. No external services. SQLite database only |
|
||||
@@ -719,6 +719,7 @@ is written):
|
||||
| COBOL | `.cbl`, `.cob`, `.cpy` | Full support (programs, sections/paragraphs with PERFORM/GO TO call edges, CALL 'literal' cross-program calls, COPY copybook imports — including standalone `.cpy` files — DATA DIVISION records/fields/88-levels, EXEC CICS LINK/XCTL and EXEC SQL INCLUDE targets; fixed and free format) |
|
||||
| Visual Basic .NET | `.vb` | Full support (classes, Modules, interfaces, structures, enums, properties, events, `Declare` P/Invoke, `Handles`/`WithEvents`, `Inherits`/`Implements` edges, call edges through VB's call/index paren ambiguity, `As New` instantiation, interpolated strings, LINQ, Unicode identifiers) |
|
||||
| Erlang | `.erl`, `.hrl`, `.escript`, `.app.src`, `.app` | Full support (functions with multi-clause/multi-arity grouping, `-spec` signatures, records with fields, `-type`/`-opaque` aliases, `-define` macros, `-include`/`-include_lib`/`-import` edges, local and `mod:fn` remote call edges, `fun name/arity` references, `spawn`/`apply`/`proc_lib`/`timer`/`rpc` MFA-argument call edges, `gen_server:call/cast(?MODULE)` → own `handle_call`/`handle_cast` links, `-behaviour` links, `-export`-based visibility) |
|
||||
| Solidity | `.sol` | Full support (contracts, libraries, interfaces, structs, enums, modifiers, events, errors, state variables, `import`/`using` directives, `emit`/`revert` calls) |
|
||||
|
||||
## Measured cross-file coverage
|
||||
|
||||
|
||||
@@ -120,6 +120,10 @@ describe('Language Detection', () => {
|
||||
expect(isSourceFile('legacy/module.src')).toBe(false);
|
||||
});
|
||||
|
||||
it('should detect Solidity files', () => {
|
||||
expect(detectLanguage('contracts/Vault.sol')).toBe('solidity');
|
||||
});
|
||||
|
||||
it('should return unknown for unsupported extensions', () => {
|
||||
expect(detectLanguage('styles.css')).toBe('unknown');
|
||||
expect(detectLanguage('data.json')).toBe('unknown');
|
||||
@@ -148,6 +152,7 @@ describe('Language Support', () => {
|
||||
expect(languages).toContain('swift');
|
||||
expect(languages).toContain('kotlin');
|
||||
expect(languages).toContain('dart');
|
||||
expect(languages).toContain('solidity');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7415,6 +7420,211 @@ void helperFunction(int count) {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Solidity Extraction', () => {
|
||||
const code = `// SPDX-License-Identifier: MIT
|
||||
pragma solidity ^0.8.20;
|
||||
|
||||
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
|
||||
|
||||
interface IVault {
|
||||
function deposit(uint256 amount) external returns (bool);
|
||||
event Deposited(address indexed user, uint256 amount);
|
||||
}
|
||||
|
||||
library SafeMath {
|
||||
function add(uint256 a, uint256 b) internal pure returns (uint256) {
|
||||
return a + b;
|
||||
}
|
||||
}
|
||||
|
||||
contract Vault is IVault {
|
||||
using SafeMath for uint256;
|
||||
|
||||
enum Status { Active, Frozen, Closed }
|
||||
|
||||
struct UserInfo {
|
||||
uint256 balance;
|
||||
uint256 lastDeposit;
|
||||
}
|
||||
|
||||
IERC20 public immutable token;
|
||||
mapping(address => UserInfo) public users;
|
||||
address public owner;
|
||||
|
||||
event Withdrawn(address indexed user, uint256 amount);
|
||||
error NotOwner();
|
||||
|
||||
modifier onlyOwner() {
|
||||
if (msg.sender != owner) revert NotOwner();
|
||||
_;
|
||||
}
|
||||
|
||||
constructor(address _token) {
|
||||
token = IERC20(_token);
|
||||
owner = msg.sender;
|
||||
}
|
||||
|
||||
function deposit(uint256 amount) external override returns (bool) {
|
||||
users[msg.sender].balance = users[msg.sender].balance.add(amount);
|
||||
emit Deposited(msg.sender, amount);
|
||||
return true;
|
||||
}
|
||||
|
||||
function withdraw(uint256 amount) external onlyOwner {
|
||||
emit Withdrawn(msg.sender, amount);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
describe('Language detection', () => {
|
||||
it('should detect Solidity files', () => {
|
||||
expect(detectLanguage('contracts/Vault.sol')).toBe('solidity');
|
||||
});
|
||||
|
||||
it('should report Solidity as supported', () => {
|
||||
expect(isLanguageSupported('solidity')).toBe(true);
|
||||
expect(getSupportedLanguages()).toContain('solidity');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Container extraction', () => {
|
||||
it('should extract contract / interface / library as class-likes', () => {
|
||||
const result = extractFromSource('Vault.sol', code);
|
||||
// interface_declaration → interface
|
||||
const iface = result.nodes.find((n) => n.kind === 'interface' && n.name === 'IVault');
|
||||
expect(iface).toBeDefined();
|
||||
expect(iface?.language).toBe('solidity');
|
||||
// contract and library both map to 'class' (library has no special semantics
|
||||
// a class node doesn't already cover — they share methodTypes/inheritance).
|
||||
expect(result.nodes.find((n) => n.kind === 'class' && n.name === 'Vault')).toBeDefined();
|
||||
expect(result.nodes.find((n) => n.kind === 'class' && n.name === 'SafeMath')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should emit extends references for `is X, Y` inheritance', () => {
|
||||
// `Vault is IVault` — Solidity uses one keyword (`is`) for both class
|
||||
// extension and interface implementation, so the extractor emits `extends`
|
||||
// and the resolver's interface-impl synthesizer reclassifies to
|
||||
// `implements` based on the target node kind.
|
||||
const result = extractFromSource('Vault.sol', code);
|
||||
const extendsRefs = result.unresolvedReferences.filter(
|
||||
(r) => r.referenceKind === 'extends' && r.referenceName === 'IVault'
|
||||
);
|
||||
expect(extendsRefs).toHaveLength(1);
|
||||
const vaultNode = result.nodes.find((n) => n.kind === 'class' && n.name === 'Vault');
|
||||
expect(extendsRefs[0]?.fromNodeId).toBe(vaultNode?.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Method extraction', () => {
|
||||
it('should extract methods, modifiers, and constructor with signatures', () => {
|
||||
const result = extractFromSource('Vault.sol', code);
|
||||
const methods = result.nodes.filter((n) => n.kind === 'method');
|
||||
const names = methods.map((n) => n.name);
|
||||
expect(names).toContain('deposit');
|
||||
expect(names).toContain('withdraw');
|
||||
expect(names).toContain('add');
|
||||
expect(names).toContain('onlyOwner'); // modifier_definition
|
||||
expect(names).toContain('constructor'); // constructor_definition (synthetic name)
|
||||
|
||||
// Signature should capture parameters + visibility + state mutability + return type.
|
||||
const add = methods.find((m) => m.name === 'add');
|
||||
expect(add?.signature).toContain('uint256 a');
|
||||
expect(add?.signature).toContain('internal');
|
||||
expect(add?.signature).toContain('pure');
|
||||
expect(add?.signature).toContain('returns (uint256)');
|
||||
|
||||
// `external` visibility should map to 'public' (callable from outside the contract).
|
||||
const deposit = methods.find((m) => m.name === 'deposit');
|
||||
expect(deposit?.visibility).toBe('public');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Struct, enum, and field extraction', () => {
|
||||
it('should extract struct, enum, and enum members', () => {
|
||||
const result = extractFromSource('Vault.sol', code);
|
||||
expect(result.nodes.find((n) => n.kind === 'struct' && n.name === 'UserInfo')).toBeDefined();
|
||||
expect(result.nodes.find((n) => n.kind === 'enum' && n.name === 'Status')).toBeDefined();
|
||||
const enumMembers = result.nodes.filter((n) => n.kind === 'enum_member').map((n) => n.name);
|
||||
expect(enumMembers).toEqual(expect.arrayContaining(['Active', 'Frozen', 'Closed']));
|
||||
});
|
||||
|
||||
it('should extract state variables, struct members, events, errors as fields', () => {
|
||||
const result = extractFromSource('Vault.sol', code);
|
||||
const fieldNames = result.nodes.filter((n) => n.kind === 'field').map((n) => n.name);
|
||||
// state variables
|
||||
expect(fieldNames).toEqual(expect.arrayContaining(['token', 'users', 'owner']));
|
||||
// struct members
|
||||
expect(fieldNames).toEqual(expect.arrayContaining(['balance', 'lastDeposit']));
|
||||
// event + error
|
||||
expect(fieldNames).toEqual(expect.arrayContaining(['Deposited', 'Withdrawn', 'NotOwner']));
|
||||
});
|
||||
|
||||
it('should treat `constant_variable_declaration` as a constant, not a variable', () => {
|
||||
const constCode = `// SPDX-License-Identifier: MIT
|
||||
pragma solidity ^0.8.20;
|
||||
uint256 constant FILE_CONST = 42;
|
||||
`;
|
||||
const result = extractFromSource('consts.sol', constCode);
|
||||
const node = result.nodes.find((n) => n.name === 'FILE_CONST');
|
||||
expect(node?.kind).toBe('constant');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Import and call extraction', () => {
|
||||
it('should extract import directives with the source path as the module name', () => {
|
||||
const result = extractFromSource('Vault.sol', code);
|
||||
const imp = result.nodes.find((n) => n.kind === 'import');
|
||||
expect(imp).toBeDefined();
|
||||
expect(imp?.name).toBe('@openzeppelin/contracts/token/ERC20/IERC20.sol');
|
||||
});
|
||||
|
||||
it('should produce calls refs for emit, revert, and library/method calls', () => {
|
||||
const result = extractFromSource('Vault.sol', code);
|
||||
const calls = result.unresolvedReferences
|
||||
.filter((r) => r.referenceKind === 'calls')
|
||||
.map((r) => r.referenceName);
|
||||
// emit Deposited(...)
|
||||
expect(calls).toContain('Deposited');
|
||||
// revert NotOwner()
|
||||
expect(calls).toContain('NotOwner');
|
||||
// library call: balance.add(amount) — receiver-qualified
|
||||
expect(calls.some((c) => c === 'add' || c === 'balance.add')).toBe(true);
|
||||
});
|
||||
|
||||
it('should produce calls refs for modifier invocations and base-constructor invocations', () => {
|
||||
// `withdraw(...) external onlyOwner` — the modifier sits in the function
|
||||
// header, outside the body: field the call walker descends, so it goes
|
||||
// through the decorator-position walk. It must emit `calls` (not
|
||||
// `decorates`) so flow traversal rides the withdraw → onlyOwner →
|
||||
// NotOwner audit path.
|
||||
const result = extractFromSource('Vault.sol', code);
|
||||
const withdrawNode = result.nodes.find((n) => n.kind === 'method' && n.name === 'withdraw');
|
||||
const modifierCall = result.unresolvedReferences.find(
|
||||
(r) => r.referenceKind === 'calls' && r.referenceName === 'onlyOwner'
|
||||
);
|
||||
expect(modifierCall).toBeDefined();
|
||||
expect(modifierCall?.fromNodeId).toBe(withdrawNode?.id);
|
||||
|
||||
// Base-constructor invocation parses as the same modifier_invocation
|
||||
// node: `constructor(address o) ERC20("T", "TOK") Ownable(o)` — the
|
||||
// constructor-chain hop.
|
||||
const ctorCode = `pragma solidity ^0.8.20;
|
||||
contract MyToken is ERC20, Ownable {
|
||||
constructor(address o) ERC20("Tok", "TOK") Ownable(o) {}
|
||||
function grab(bytes32 r) external onlyRole(ADMIN_ROLE) returns (uint256) { return 1; }
|
||||
}
|
||||
`;
|
||||
const ctorResult = extractFromSource('MyToken.sol', ctorCode);
|
||||
const ctorCalls = ctorResult.unresolvedReferences
|
||||
.filter((r) => r.referenceKind === 'calls')
|
||||
.map((r) => r.referenceName);
|
||||
expect(ctorCalls).toEqual(expect.arrayContaining(['ERC20', 'Ownable']));
|
||||
// modifier WITH arguments still resolves to the bare modifier name
|
||||
expect(ctorCalls).toContain('onlyRole');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Regression: issue-specific extraction fixes', () => {
|
||||
it('indexes inner functions of an anonymous AMD/CommonJS module wrapper (#528)', () => {
|
||||
const code = `
|
||||
|
||||
@@ -45,6 +45,7 @@ const WASM_GRAMMAR_FILES: Record<GrammarLanguage, string> = {
|
||||
cobol: 'tree-sitter-cobol.wasm',
|
||||
vbnet: 'tree-sitter-vbnet.wasm',
|
||||
erlang: 'tree-sitter-erlang.wasm',
|
||||
solidity: 'tree-sitter-solidity.wasm',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -114,6 +115,7 @@ export const EXTENSION_MAP: Record<string, Language> = {
|
||||
'.luau': 'luau',
|
||||
'.m': 'objc',
|
||||
'.mm': 'objc',
|
||||
'.sol': 'solidity',
|
||||
// CFML: .cfc/.cfm parse with the tag-aware `cfml` grammar (custom CfmlExtractor
|
||||
// dialect-switches to cfscript for bare-script content); .cfs is pure CFScript.
|
||||
'.cfc': 'cfml',
|
||||
@@ -489,6 +491,7 @@ export function getLanguageDisplayName(language: Language): string {
|
||||
lua: 'Lua',
|
||||
luau: 'Luau',
|
||||
objc: 'Objective-C',
|
||||
solidity: 'Solidity',
|
||||
yaml: 'YAML',
|
||||
twig: 'Twig',
|
||||
xml: 'XML',
|
||||
|
||||
@@ -32,6 +32,7 @@ import { cfqueryExtractor } from './cfquery';
|
||||
import { cobolExtractor } from './cobol';
|
||||
import { vbnetExtractor } from './vbnet';
|
||||
import { erlangExtractor } from './erlang';
|
||||
import { solidityExtractor } from './solidity';
|
||||
|
||||
export const EXTRACTORS: Partial<Record<Language, LanguageExtractor>> = {
|
||||
typescript: typescriptExtractor,
|
||||
@@ -61,4 +62,5 @@ export const EXTRACTORS: Partial<Record<Language, LanguageExtractor>> = {
|
||||
cobol: cobolExtractor,
|
||||
vbnet: vbnetExtractor,
|
||||
erlang: erlangExtractor,
|
||||
solidity: solidityExtractor,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
import type { Node as SyntaxNode } from 'web-tree-sitter';
|
||||
import { getNodeText, getChildByField } from '../tree-sitter-helpers';
|
||||
import type { LanguageExtractor } from '../tree-sitter-types';
|
||||
|
||||
/**
|
||||
* Solidity extractor — tree-sitter-solidity (ABI 14).
|
||||
*
|
||||
* Solidity has multiple top-level "contract-like" containers (contract /
|
||||
* interface / library) and several callable forms that don't have a `name:`
|
||||
* field (constructor, fallback, receive). We map:
|
||||
* - contract_declaration → class (also library_declaration)
|
||||
* - interface_declaration → interface
|
||||
* - struct_declaration → struct
|
||||
* - enum_declaration → enum (enum_value is the bare ident — no
|
||||
* name field, so handled in visitNode)
|
||||
* - function_definition / modifier_definition → function|method
|
||||
* - constructor_definition / fallback_receive_definition → method (synthetic
|
||||
* name: "constructor" / "fallback" / "receive" — these are nameless in AST)
|
||||
* - state_variable_declaration / struct_member → field (inside contract/struct)
|
||||
* - event_definition / error_declaration → field-shaped node carrying
|
||||
* the event/error name so callers/refs can resolve emit X / revert X
|
||||
* - import_directive → import
|
||||
* - call_expression / emit_statement / revert_statement / modifier_invocation
|
||||
* → calls (the latter three are call-shaped but use distinct AST nodes)
|
||||
*/
|
||||
|
||||
function getInheritanceAncestors(node: SyntaxNode, source: string): string[] {
|
||||
const ancestors: string[] = [];
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (!child || child.type !== 'inheritance_specifier') continue;
|
||||
const ancestor = getChildByField(child, 'ancestor');
|
||||
if (!ancestor) continue;
|
||||
// ancestor is user_defined_type → contains identifier (or scoped path)
|
||||
const id = ancestor.descendantsOfType('identifier');
|
||||
if (id.length > 0) {
|
||||
const last = id[id.length - 1]!;
|
||||
ancestors.push(getNodeText(last, source));
|
||||
}
|
||||
}
|
||||
return ancestors;
|
||||
}
|
||||
|
||||
function fallbackReceiveName(node: SyntaxNode): string {
|
||||
// tree-sitter-solidity reuses one node type for both `fallback() ...` and
|
||||
// `receive() ...` — the keyword is an unnamed/anonymous child. Walk all
|
||||
// children (named + unnamed) and pick the first whose text is one of these.
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const child = node.child(i);
|
||||
if (!child) continue;
|
||||
const t = child.text;
|
||||
if (t === 'fallback' || t === 'receive') return t;
|
||||
}
|
||||
return 'fallback';
|
||||
}
|
||||
|
||||
export const solidityExtractor: LanguageExtractor = {
|
||||
// Free functions (file-level) AND methods inside contracts use the same
|
||||
// function_definition node — the dispatcher routes by isInsideClassLikeNode.
|
||||
functionTypes: ['function_definition', 'modifier_definition'],
|
||||
classTypes: ['contract_declaration', 'library_declaration'],
|
||||
methodTypes: [
|
||||
'function_definition',
|
||||
'modifier_definition',
|
||||
'constructor_definition',
|
||||
'fallback_receive_definition',
|
||||
],
|
||||
interfaceTypes: ['interface_declaration'],
|
||||
structTypes: ['struct_declaration'],
|
||||
enumTypes: ['enum_declaration'],
|
||||
enumMemberTypes: [], // enum_value has no name field; handled in visitNode
|
||||
typeAliasTypes: ['user_defined_type_definition'],
|
||||
importTypes: ['import_directive'],
|
||||
// emit / revert / modifier_invocation are call-shaped but distinct AST nodes
|
||||
callTypes: ['call_expression', 'emit_statement', 'revert_statement', 'modifier_invocation'],
|
||||
// top-level state vars are file-scope constants/variables; struct_member
|
||||
// and state_variable_declaration inside a contract are fields (handled via
|
||||
// fieldTypes + isInsideClassLikeNode).
|
||||
variableTypes: ['state_variable_declaration', 'constant_variable_declaration'],
|
||||
fieldTypes: ['state_variable_declaration', 'struct_member'],
|
||||
|
||||
nameField: 'name',
|
||||
bodyField: 'body',
|
||||
paramsField: 'parameters',
|
||||
returnField: 'return_type',
|
||||
|
||||
// constructor / fallback / receive have no `name:` field — synthesize one.
|
||||
resolveName: (node, _source) => {
|
||||
if (node.type === 'constructor_definition') return 'constructor';
|
||||
if (node.type === 'fallback_receive_definition') return fallbackReceiveName(node);
|
||||
return undefined;
|
||||
},
|
||||
|
||||
getSignature: (node, source) => {
|
||||
// tree-sitter-solidity does NOT wrap params in a `parameters:` field — each
|
||||
// `parameter` node is a direct child of function/modifier/constructor. We
|
||||
// reconstruct `(t1 a, t2 b)` by walking those siblings; getChildByField
|
||||
// would return null and lose the entire param list.
|
||||
const params: string[] = [];
|
||||
let returnType: SyntaxNode | undefined;
|
||||
let visibility: SyntaxNode | undefined;
|
||||
let mutability: SyntaxNode | undefined;
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (!child) continue;
|
||||
const fieldName = node.fieldNameForNamedChild(i);
|
||||
if (child.type === 'parameter' && fieldName !== 'return_type') {
|
||||
params.push(getNodeText(child, source));
|
||||
} else if (child.type === 'return_type_definition' || fieldName === 'return_type') {
|
||||
returnType = child;
|
||||
} else if (child.type === 'visibility') {
|
||||
visibility = child;
|
||||
} else if (child.type === 'state_mutability') {
|
||||
mutability = child;
|
||||
}
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
parts.push(`(${params.join(', ')})`);
|
||||
if (visibility) parts.push(getNodeText(visibility, source));
|
||||
if (mutability) parts.push(getNodeText(mutability, source));
|
||||
if (returnType) parts.push(getNodeText(returnType, source));
|
||||
return parts.join(' ');
|
||||
},
|
||||
|
||||
getVisibility: (node) => {
|
||||
// Solidity functions: public/private/internal/external — `external` maps
|
||||
// to 'public' for our purposes (callable from outside the contract).
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (child?.type !== 'visibility') continue;
|
||||
const t = child.text.trim();
|
||||
if (t === 'public' || t === 'external') return 'public';
|
||||
if (t === 'private') return 'private';
|
||||
if (t === 'internal') return 'internal';
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
|
||||
// `constant_variable_declaration` is by definition a constant; the generic
|
||||
// variable extractor defaults to kind:'variable' otherwise.
|
||||
isConst: (node) => node.type === 'constant_variable_declaration',
|
||||
|
||||
visitNode: (node, ctx) => {
|
||||
const t = node.type;
|
||||
|
||||
// Solidity inheritance: `contract MyToken is Token, IERC20 { ... }`. The
|
||||
// core's extractInheritance walks for `extends_clause`/`base_class_clause`
|
||||
// shaped children, which Solidity doesn't have — its `inheritance_specifier`
|
||||
// children are direct siblings of the `body:` field. We piggyback on the
|
||||
// standard contract/library/interface dispatch (which fires AFTER this
|
||||
// hook returns false) by emitting the extends references here, then
|
||||
// returning false so the generic class extractor still creates the node.
|
||||
// Each ancestor → one `extends` reference; the resolver then upgrades it
|
||||
// to a real edge. Without these refs, "what inherits from Ownable" /
|
||||
// "trace inherited onlyOwner" can't traverse the contract graph and the
|
||||
// agent has to Read each file to reconstruct the hierarchy.
|
||||
if (
|
||||
t === 'contract_declaration' ||
|
||||
t === 'library_declaration' ||
|
||||
t === 'interface_declaration'
|
||||
) {
|
||||
// Mirror the generic class path — create the node (the extends refs
|
||||
// need its id), emit the refs, walk the body — then return true to
|
||||
// short-circuit the generic dispatch so nothing is doubled.
|
||||
const ancestors = getInheritanceAncestors(node, ctx.source);
|
||||
const nameNode = getChildByField(node, 'name');
|
||||
const body = getChildByField(node, 'body');
|
||||
if (!nameNode) return false;
|
||||
const name = getNodeText(nameNode, ctx.source);
|
||||
const kind = t === 'interface_declaration' ? 'interface' : 'class';
|
||||
const created = ctx.createNode(kind, name, node);
|
||||
if (!created) return true;
|
||||
// Solidity uses one keyword (`is`) for both class-extends-class and
|
||||
// class-implements-interface, indistinguishable at parse time. Emit
|
||||
// `extends` for every ancestor — the resolver's interface-impl synthesizer
|
||||
// (Phase 5.5) reclassifies a class→interface edge as `implements` based
|
||||
// on the target node kind, matching how Java/C# extractors do it.
|
||||
for (const ancestor of ancestors) {
|
||||
ctx.addUnresolvedReference({
|
||||
fromNodeId: created.id,
|
||||
referenceName: ancestor,
|
||||
referenceKind: 'extends',
|
||||
line: node.startPosition.row + 1,
|
||||
column: node.startPosition.column,
|
||||
});
|
||||
}
|
||||
ctx.pushScope(created.id);
|
||||
if (body) {
|
||||
for (let i = 0; i < body.namedChildCount; i++) {
|
||||
const child = body.namedChild(i);
|
||||
if (child) ctx.visitNode(child);
|
||||
}
|
||||
}
|
||||
ctx.popScope();
|
||||
return true;
|
||||
}
|
||||
|
||||
// tree-sitter-solidity puts struct_member / enum_value as DIRECT children
|
||||
// of struct_declaration / enum_declaration — there is no `body:` field, so
|
||||
// the core's extractStruct/extractEnum (which require a body field) bails.
|
||||
// We extract these here, push the parent on the scope stack, walk the
|
||||
// direct children, and emit one struct/enum node + its members.
|
||||
if (t === 'struct_declaration' || t === 'enum_declaration') {
|
||||
const nameNode = getChildByField(node, 'name');
|
||||
if (!nameNode) return true;
|
||||
const name = getNodeText(nameNode, ctx.source);
|
||||
const kind = t === 'struct_declaration' ? 'struct' : 'enum';
|
||||
const created = ctx.createNode(kind, name, node);
|
||||
if (!created) return true;
|
||||
ctx.pushScope(created.id);
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (!child) continue;
|
||||
if (child === nameNode) continue;
|
||||
ctx.visitNode(child);
|
||||
}
|
||||
ctx.popScope();
|
||||
return true;
|
||||
}
|
||||
|
||||
// enum_value is the bare identifier of an enum case — no `name:` field, so
|
||||
// the generic enum-member dispatch can't find it. Use the node's own text.
|
||||
if (t === 'enum_value') {
|
||||
ctx.createNode('enum_member', getNodeText(node, ctx.source), node);
|
||||
return true;
|
||||
}
|
||||
|
||||
// event SomeEvent(...) — preserve event name as a field-shaped node so
|
||||
// `emit SomeEvent(...)` (an emit_statement) can resolve to it. We use
|
||||
// `field` kind because Solidity events are member declarations of a
|
||||
// contract, similar in spirit to fields, and `field` reuses the FTS index
|
||||
// without adding a new NodeKind.
|
||||
if (t === 'event_definition') {
|
||||
const nameNode = getChildByField(node, 'name');
|
||||
if (!nameNode) return true;
|
||||
const name = getNodeText(nameNode, ctx.source);
|
||||
ctx.createNode('field', name, node, {
|
||||
signature: getNodeText(node, ctx.source).trim().slice(0, 200),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// error MyError(...) — same reasoning as event_definition. revert MyError()
|
||||
// (a revert_statement) is captured via callTypes and resolves by name.
|
||||
if (t === 'error_declaration') {
|
||||
const nameNode = getChildByField(node, 'name');
|
||||
if (!nameNode) return true;
|
||||
const name = getNodeText(nameNode, ctx.source);
|
||||
ctx.createNode('field', name, node, {
|
||||
signature: getNodeText(node, ctx.source).trim().slice(0, 200),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// struct_member: named field inside a struct. It has `name:` + `type:` —
|
||||
// the generic field dispatch handles it via fieldTypes, so no custom code.
|
||||
return false;
|
||||
},
|
||||
|
||||
// import "X"; / import {A, B} from "X"; / import * as X from "Y";
|
||||
// We surface the SOURCE path as the moduleName — that's what
|
||||
// import-resolver matches against on disk. The `import_name:` field (if
|
||||
// present, for the symbolic-import form) is intentionally ignored here; the
|
||||
// SOURCE is the file being imported from.
|
||||
extractImport: (node, source) => {
|
||||
const importText = source.substring(node.startIndex, node.endIndex).trim();
|
||||
const sourceField = getChildByField(node, 'source');
|
||||
if (!sourceField) return null;
|
||||
// source is a `string` node — strip quotes via descendantsOfType lookup.
|
||||
const stringContent = sourceField.descendantsOfType('string_literal');
|
||||
let moduleName: string;
|
||||
if (stringContent.length > 0) {
|
||||
moduleName = getNodeText(stringContent[0]!, source);
|
||||
} else {
|
||||
moduleName = getNodeText(sourceField, source);
|
||||
}
|
||||
moduleName = moduleName.replace(/^["']|["']$/g, '').trim();
|
||||
if (!moduleName) return null;
|
||||
return { moduleName, signature: importText };
|
||||
},
|
||||
};
|
||||
@@ -4511,6 +4511,28 @@ export class TreeSitterExtractor {
|
||||
private extractDecoratorsFor(declNode: SyntaxNode, decoratedId: string): void {
|
||||
const consider = (n: SyntaxNode | null): void => {
|
||||
if (!n) return;
|
||||
// Solidity `modifier_invocation` (unique to that grammar) sits
|
||||
// decorator-position in the function header — OUTSIDE the `body:` field
|
||||
// the call walker descends — but its body executes around the function
|
||||
// via `_;`, so it is a real call-flow hop (`withdraw → onlyOwner →
|
||||
// _checkRole` is the canonical audit trace). The same node type carries
|
||||
// base-constructor invocations (`constructor() ERC20("T","TOK")`), the
|
||||
// constructor-chain hop. Emit `calls`, not `decorates`, so flow
|
||||
// traversal rides it.
|
||||
if (n.type === 'modifier_invocation') {
|
||||
const target = n.namedChild(0);
|
||||
const name = target?.type === 'identifier' ? getNodeText(target, this.source) : undefined;
|
||||
if (name) {
|
||||
this.unresolvedReferences.push({
|
||||
fromNodeId: decoratedId,
|
||||
referenceName: name,
|
||||
referenceKind: 'calls',
|
||||
line: n.startPosition.row + 1,
|
||||
column: n.startPosition.column,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
// `marker_annotation` is Java's grammar for arg-less annotations
|
||||
// (`@Override`, `@Deprecated`); `attribute` is Swift's grammar for
|
||||
// attributes and PROPERTY WRAPPERS (`@objc`, `@Argument`, `@Published`,
|
||||
|
||||
@@ -91,6 +91,7 @@ export const LANGUAGES = [
|
||||
'luau',
|
||||
'objc',
|
||||
'r',
|
||||
'solidity',
|
||||
'yaml',
|
||||
'twig',
|
||||
'xml',
|
||||
|
||||
Reference in New Issue
Block a user