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
@@ -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 = `
|
||||
|
||||
Reference in New Issue
Block a user