diff --git a/.claude/skills/agent-eval/corpus.json b/.claude/skills/agent-eval/corpus.json index 2cfedac4f..e463be14f 100644 --- a/.claude/skills/agent-eval/corpus.json +++ b/.claude/skills/agent-eval/corpus.json @@ -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 `` 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 `` 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 `` 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." } ] } diff --git a/CHANGELOG.md b/CHANGELOG.md index 54ef5f5aa..38ffd0ae9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 1a9800ee3..93956d014 100644 --- a/README.md +++ b/README.md @@ -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 | @@ -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 diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index d29fa11b3..b7ab3b052 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -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'); @@ -129,6 +133,7 @@ describe('Language Support', () => { expect(languages).toContain('swift'); expect(languages).toContain('kotlin'); expect(languages).toContain('dart'); + expect(languages).toContain('solidity'); }); }); @@ -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 = ` diff --git a/src/extraction/grammars.ts b/src/extraction/grammars.ts index 576845e20..776a29b28 100644 --- a/src/extraction/grammars.ts +++ b/src/extraction/grammars.ts @@ -38,6 +38,7 @@ const WASM_GRAMMAR_FILES: Record = { lua: 'tree-sitter-lua.wasm', luau: 'tree-sitter-luau.wasm', objc: 'tree-sitter-objc.wasm', + solidity: 'tree-sitter-solidity.wasm', }; /** @@ -101,6 +102,7 @@ export const EXTENSION_MAP: Record = { '.luau': 'luau', '.m': 'objc', '.mm': 'objc', + '.sol': 'solidity', // XML: file-level tracking; the MyBatis extractor matches `` // shape and emits SQL-statement nodes (other XML returns empty). '.xml': 'xml', @@ -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', diff --git a/src/extraction/languages/index.ts b/src/extraction/languages/index.ts index 543598b8e..f573b9ec5 100644 --- a/src/extraction/languages/index.ts +++ b/src/extraction/languages/index.ts @@ -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> = { typescript: typescriptExtractor, @@ -49,4 +50,5 @@ export const EXTRACTORS: Partial> = { lua: luaExtractor, luau: luauExtractor, objc: objcExtractor, + solidity: solidityExtractor, }; diff --git a/src/extraction/languages/solidity.ts b/src/extraction/languages/solidity.ts new file mode 100644 index 000000000..c6317c8f5 --- /dev/null +++ b/src/extraction/languages/solidity.ts @@ -0,0 +1,296 @@ +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' + ) { + // Defer to the generic dispatch for node creation; we just need a + // post-creation hook to attach extends refs. Find the most recently + // created node for THIS AST position once it exists. The simplest + // correct way is to do it here BEFORE generic creation by walking + // ancestors and queuing references against the soon-to-be-created + // node id — but we don't know that id yet. Instead, register a + // pre-emit step: emit the references later from the resolver layer. + // + // Practical compromise: mirror the generic class path by creating + // the node here AND emitting the extends refs AND walking the body — + // exactly what extractClass/extractInterface do, minus the inheritance + // walk we want to add. Returning `true` then short-circuits the + // generic dispatch. + 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 }; + }, +}; + +// Make the inheritance helper accessible to the resolver layer if it ever needs +// it (kept exported in case the framework resolver wants ancestor metadata). +export { getInheritanceAncestors }; diff --git a/src/types.ts b/src/types.ts index e710e31a1..fb108d74c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -88,6 +88,7 @@ export const LANGUAGES = [ 'lua', 'luau', 'objc', + 'solidity', 'yaml', 'twig', 'xml',