Bläddra i källkod

feat(extraction): add Solidity language support (.sol) (#374, #648) (#1170)

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>
Colby Mchenry 2 månader sedan
förälder
incheckning
1441933a26

+ 23 - 0
.claude/skills/agent-eval/corpus.json

@@ -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."
+    }
   ]
 }

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 1 - 0
CHANGELOG.md


+ 2 - 1
README.md

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

+ 210 - 0
__tests__/extraction.test.ts

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

+ 3 - 0
src/extraction/grammars.ts

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

+ 2 - 0
src/extraction/languages/index.ts

@@ -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,
 };

+ 282 - 0
src/extraction/languages/solidity.ts

@@ -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 };
+  },
+};

+ 22 - 0
src/extraction/tree-sitter.ts

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

+ 1 - 0
src/types.ts

@@ -91,6 +91,7 @@ export const LANGUAGES = [
   'luau',
   'objc',
   'r',
+  'solidity',
   'yaml',
   'twig',
   'xml',

Vissa filer visades inte eftersom för många filer har ändrats