Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .claude/skills/agent-eval/corpus.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,5 +94,10 @@
{ "name": "react-native-segmented-control", "repo": "https://github.com/react-native-segmented-control/segmented-control", "size": "Small", "files": "~25", "question": "How does JSX `<SegmentedControl onChange={cb}/>` reach the native onChange handler on iOS/Android?" },
{ "name": "react-native-screens", "repo": "https://github.com/software-mansion/react-native-screens", "size": "Medium", "files": "~1200", "question": "How does JSX `<ScreenStack>` reach the native RNSScreenStackView component?" },
{ "name": "react-native-skia", "repo": "https://github.com/Shopify/react-native-skia", "size": "Large", "files": "~1000", "question": "How does a `<SkiaPictureView/>` JSX usage reach the iOS / Android native renderer?" }
],
"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." }
]
}
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`, 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).
- `codegraph status --json` now also reports the running CLI `version`, the index directory (`indexPath`), and a `lastIndexed` timestamp (ISO-8601, or null when nothing's indexed yet), so CI and scripts can pin the CLI version and check index freshness from a single command. A matching `CodeGraph.getLastIndexedAt()` library method exposes the same freshness check without shelling out. Thanks @12122J and @eddieran. (#329)

### Fixes
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ CodeGraph cuts **tokens, tool calls, and wall-clock time on every repo** — acr
| **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#, PHP, Ruby, C, C++, Objective-C, Swift, Kotlin, Dart, Lua, Luau, Svelte, Liquid, Pascal/Delphi |
| **20+ Languages** | TypeScript, JavaScript, Python, Go, Rust, Java, C#, PHP, Ruby, C, C++, Objective-C, Swift, Kotlin, Dart, Lua, Luau, Solidity, Svelte, Liquid, Pascal/Delphi |
| **Framework-aware Routes** | Recognizes web-framework routing files and links URL patterns to their handlers across 14 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 |
Expand Down Expand Up @@ -627,6 +627,7 @@ is written):
| Pascal / Delphi | `.pas`, `.dpr`, `.dpk`, `.lpr` | Full support (classes, records, interfaces, enums, DFM/FMX form files) |
| Lua | `.lua` | Full support (functions, methods with receivers, local variables, `require` imports, call edges) |
| Luau | `.luau` | Full support (everything in Lua, plus `type`/`export type` aliases, typed signatures, and Roblox instance-path `require`) |
| Solidity | `.sol` | Full support (contracts, libraries, interfaces, structs, enums, modifiers, events, errors, state variables, `import`/`using` directives, `emit`/`revert` calls) |

## Troubleshooting

Expand Down
178 changes: 178 additions & 0 deletions __tests__/extraction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,10 @@ describe('Language Detection', () => {
expect(detectLanguage('stdio.h', '#ifndef STDIO_H\nvoid printf();\n#endif\n')).toBe('c');
});

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');
Expand Down Expand Up @@ -129,6 +133,7 @@ describe('Language Support', () => {
expect(languages).toContain('swift');
expect(languages).toContain('kotlin');
expect(languages).toContain('dart');
expect(languages).toContain('solidity');
});
});

Expand Down Expand Up @@ -4414,6 +4419,179 @@ 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);
});
});
});

describe('Regression: issue-specific extraction fixes', () => {
it('indexes inner functions of an anonymous AMD/CommonJS module wrapper (#528)', () => {
const code = `
Expand Down
3 changes: 3 additions & 0 deletions src/extraction/grammars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ const WASM_GRAMMAR_FILES: Record<GrammarLanguage, string> = {
lua: 'tree-sitter-lua.wasm',
luau: 'tree-sitter-luau.wasm',
objc: 'tree-sitter-objc.wasm',
solidity: 'tree-sitter-solidity.wasm',
};

/**
Expand Down Expand Up @@ -101,6 +102,7 @@ export const EXTENSION_MAP: Record<string, Language> = {
'.luau': 'luau',
'.m': 'objc',
'.mm': 'objc',
'.sol': 'solidity',
// XML: file-level tracking; the MyBatis extractor matches `<mapper namespace="...">`
// shape and emits SQL-statement nodes (other XML returns empty).
'.xml': 'xml',
Expand Down Expand Up @@ -384,6 +386,7 @@ export function getLanguageDisplayName(language: Language): string {
lua: 'Lua',
luau: 'Luau',
objc: 'Objective-C',
solidity: 'Solidity',
yaml: 'YAML',
twig: 'Twig',
xml: 'XML',
Expand Down
2 changes: 2 additions & 0 deletions src/extraction/languages/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { scalaExtractor } from './scala';
import { luaExtractor } from './lua';
import { luauExtractor } from './luau';
import { objcExtractor } from './objc';
import { solidityExtractor } from './solidity';

export const EXTRACTORS: Partial<Record<Language, LanguageExtractor>> = {
typescript: typescriptExtractor,
Expand All @@ -49,4 +50,5 @@ export const EXTRACTORS: Partial<Record<Language, LanguageExtractor>> = {
lua: luaExtractor,
luau: luauExtractor,
objc: objcExtractor,
solidity: solidityExtractor,
};
Loading