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
@@ -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