diff --git a/docs/coverage/detail/lang.solidity.core.md b/docs/coverage/detail/lang.solidity.core.md index 0da1e2f85..a67ea176b 100644 --- a/docs/coverage/detail/lang.solidity.core.md +++ b/docs/coverage/detail/lang.solidity.core.md @@ -12,7 +12,7 @@ Auto-generated. Back to [summary](../summary.md). | Capability | Status | Verified at | Issue | Cites | Notes | |------------|--------|-------------|-------|-------|-------| | Call line precision | 🟢 `partial` | `2026-06-24` | 5371 | `internal/extractors/solidity/extractor.go`
`internal/extractors/solidity/extractor_test.go` | collectCallsFromBody emits CALLS edges per dotted (Type.method()) and bare (fn()) invocation in a function/modifier body, with a solidityKeywords denylist dropping control-flow/builtins/type/visibility tokens and self-recursion filtering on undotted leaves (dotted cross-contract calls preserved, #2114). Partial (honest): edges do NOT yet carry a per-call line property; targets are unresolved name strings, not bound to the callee SCOPE.Operation. | -| Core extraction | ✅ `full` | `2026-06-24` | 5371 | `internal/extractors/solidity/extractor.go`
`internal/extractors/solidity/extractor_test.go` | Regex extractor (no bundled tree-sitter Solidity grammar). contractRE emits contract/library/interface/abstract-contract as SCOPE.Component (subtype contract/library/interface); functionRE/eventRE/modifierRE emit members as SCOPE.Operation (subtype function/event/modifier) with CONTAINS edges from the owning contract. Comments and string literals are scrubbed (stripCommentsAndStrings) and brace-matched bodies extracted (extractBracedBody) before member/call scanning. Proven by the extractor_test.go suite (contract/library/interface discovery, member CONTAINS, signature capture). | +| Core extraction | ✅ `full` | `2026-07-28` | 5371 | `internal/extractors/solidity/extractor.go`
`internal/extractors/solidity/extractor_test.go` | Regex extractor (no bundled tree-sitter Solidity grammar). contractRE emits contract/library/interface/abstract-contract as SCOPE.Component (subtype contract/library/interface); functionRE/eventRE/modifierRE emit members as SCOPE.Operation (subtype function/event/modifier) with CONTAINS edges from the owning contract. Comments and string literals are scrubbed (stripCommentsAndStrings) and brace-matched bodies extracted (extractBracedBody) before member/call scanning. The member regexes end at the opening paren, so declSignature scans on from the match with paren depth to the terminating { or ; and Signature carries the full parameter list, mutability/visibility and returns clause; before that it stopped at the paren (5255 truncated signatures across seven production Solidity repos, none complete). Proven by the extractor_test.go suite (contract/library/interface discovery, member CONTAINS, signature capture). | | Import resolution quality | 🟢 `partial` | `2026-06-24` | 5371 | `internal/extractors/solidity/extractor.go`
`internal/extractors/solidity/extractor_test.go` | buildImportEntities parses both import styles (import "./Foo.sol"; and import {Sym} from "pkg/...";), emitting one IMPORTS edge per target path with source_module/imported_name/local_name properties and a SCOPE.Component for the imported unit. Partial (honest): named-symbol imports record only the module path (not per-symbol bindings) and edges point at the raw path string, not a resolved file/contract entity. | ## Provenance diff --git a/docs/coverage/registry.json b/docs/coverage/registry.json index 535e29cbc..dd7f70d0a 100644 --- a/docs/coverage/registry.json +++ b/docs/coverage/registry.json @@ -89133,8 +89133,8 @@ "status": "full", "cites": ["internal/extractors/solidity/extractor.go","internal/extractors/solidity/extractor_test.go"], "issue": "5371", - "notes": "Regex extractor (no bundled tree-sitter Solidity grammar). contractRE emits contract/library/interface/abstract-contract as SCOPE.Component (subtype contract/library/interface); functionRE/eventRE/modifierRE emit members as SCOPE.Operation (subtype function/event/modifier) with CONTAINS edges from the owning contract. Comments and string literals are scrubbed (stripCommentsAndStrings) and brace-matched bodies extracted (extractBracedBody) before member/call scanning. Proven by the extractor_test.go suite (contract/library/interface discovery, member CONTAINS, signature capture).", - "verified_at": "2026-06-24" + "notes": "Regex extractor (no bundled tree-sitter Solidity grammar). contractRE emits contract/library/interface/abstract-contract as SCOPE.Component (subtype contract/library/interface); functionRE/eventRE/modifierRE emit members as SCOPE.Operation (subtype function/event/modifier) with CONTAINS edges from the owning contract. Comments and string literals are scrubbed (stripCommentsAndStrings) and brace-matched bodies extracted (extractBracedBody) before member/call scanning. The member regexes end at the opening paren, so declSignature scans on from the match with paren depth to the terminating { or ; and Signature carries the full parameter list, mutability/visibility and returns clause; before that it stopped at the paren (5255 truncated signatures across seven production Solidity repos, none complete). Proven by the extractor_test.go suite (contract/library/interface discovery, member CONTAINS, signature capture).", + "verified_at": "2026-07-28" }, "import_resolution_quality": { "status": "partial", diff --git a/internal/extractors/solidity/extractor.go b/internal/extractors/solidity/extractor.go index 6dba1e810..385cff6f9 100644 --- a/internal/extractors/solidity/extractor.go +++ b/internal/extractors/solidity/extractor.go @@ -286,8 +286,7 @@ func findContracts(src, filePath string, signals frameworkSignals) []types.Entit if fnEndLine == 0 { fnEndLine = fnStartLine } - sigEnd := fm[1] - rawFnSig := strings.Join(strings.Fields(body[fm[0]:sigEnd]), " ") + rawFnSig := declSignature(body, fm[0], fm[1]) callRels := collectCallsFromBody(fnBody, qualName) fnRec := types.EntityRecord{ @@ -321,7 +320,7 @@ func findContracts(src, filePath string, signals frameworkSignals) []types.Entit evName := body[em[2]:em[3]] qualName := name + "." + evName evStartLine := bodyLineOffset + strings.Count(body[:em[0]], "\n") - rawEvSig := strings.Join(strings.Fields(body[em[0]:em[1]]), " ") + rawEvSig := declSignature(body, em[0], em[1]) evRec := types.EntityRecord{ Name: qualName, @@ -350,7 +349,7 @@ func findContracts(src, filePath string, signals frameworkSignals) []types.Entit modName := body[mm[2]:mm[3]] qualName := name + "." + modName modStartLine := bodyLineOffset + strings.Count(body[:mm[0]], "\n") - rawModSig := strings.Join(strings.Fields(body[mm[0]:mm[1]]), " ") + rawModSig := declSignature(body, mm[0], mm[1]) modBody, _ := extractBracedBody(body, mm[1]) callRels := collectCallsFromBody(modBody, qualName) @@ -548,6 +547,28 @@ func extractBracedBody(src string, openPos int) (string, int) { return "", 0 } +// declSignature returns the declaration starting at declStart up to the first +// '{' or ';' outside parentheses, with runs of whitespace collapsed. src is +// scrubbed (see stripCommentsAndStrings), so a delimiter inside a comment or a +// string literal cannot end the signature early. Falls back to the span up to +// fallbackEnd when the declaration has no terminator. +func declSignature(src string, declStart, fallbackEnd int) string { + depth := 0 + for i := declStart; i < len(src); i++ { + switch src[i] { + case '(': + depth++ + case ')': + depth-- + case '{', ';': + if depth == 0 { + return strings.Join(strings.Fields(src[declStart:i]), " ") + } + } + } + return strings.Join(strings.Fields(src[declStart:fallbackEnd]), " ") +} + // stripCommentsAndStrings replaces Solidity // and /* */ comments and string // literals with spaces so regexes don't match inside them. func stripCommentsAndStrings(src string) string { diff --git a/internal/extractors/solidity/extractor_test.go b/internal/extractors/solidity/extractor_test.go index 66d5ab34e..b2dba3957 100644 --- a/internal/extractors/solidity/extractor_test.go +++ b/internal/extractors/solidity/extractor_test.go @@ -298,6 +298,131 @@ contract StakingVault { } } +// ── Signature extraction ────────────────────────────────────────────────────── + +func TestSolidity_FunctionSignatures(t *testing.T) { + src := `// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +interface IVault { + function totalSupply() external view returns (uint256); +} + +contract Vault is IVault { + function totalSupply() external view returns (uint256) { + return _total; + } + + function transfer( + address to, + uint256 amount + ) public onlyOwner returns (bool ok) { + return true; + } + + function forEach(function(uint256) external returns (bool) cb) public { + cb(1); + } +} +` + ents := runSolidity(t, src, "Vault.sol") + + want := map[string]string{ + "IVault.totalSupply": "function totalSupply() external view returns (uint256)", + "Vault.totalSupply": "function totalSupply() external view returns (uint256)", + "Vault.transfer": "function transfer( address to, uint256 amount ) public onlyOwner returns (bool ok)", + "Vault.forEach": "function forEach(function(uint256) external returns (bool) cb) public", + } + for name, sig := range want { + fn := solFindSubtype(ents, name, "SCOPE.Operation", "function") + if fn == nil { + t.Errorf("expected function %s", name) + continue + } + if fn.Signature != sig { + t.Errorf("%s signature = %q, want %q", name, fn.Signature, sig) + } + } +} + +func TestSolidity_EventAndModifierSignatures(t *testing.T) { + src := `// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +contract Vault { + event Transfer(address indexed from, address indexed to, uint256 value); + + modifier onlyRoleOrOwner(bytes32 role) { + require(hasRole(role, msg.sender) || msg.sender == owner, "denied"); + _; + } +} +` + ents := runSolidity(t, src, "Vault.sol") + + ev := solFindSubtype(ents, "Vault.Transfer", "SCOPE.Operation", "event") + if ev == nil { + t.Fatal("expected event Vault.Transfer") + } + const wantEv = "event Transfer(address indexed from, address indexed to, uint256 value)" + if ev.Signature != wantEv { + t.Errorf("Transfer signature = %q, want %q", ev.Signature, wantEv) + } + + mod := solFindSubtype(ents, "Vault.onlyRoleOrOwner", "SCOPE.Operation", "modifier") + if mod == nil { + t.Fatal("expected modifier Vault.onlyRoleOrOwner") + } + const wantMod = "modifier onlyRoleOrOwner(bytes32 role)" + if mod.Signature != wantMod { + t.Errorf("onlyRoleOrOwner signature = %q, want %q", mod.Signature, wantMod) + } +} + +func TestSolidity_SignatureIgnoresDelimitersInCommentsAndStrings(t *testing.T) { + src := `// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +contract Guarded { + function withdraw( + address, // recipient ) + uint256 amount + ) public onlyRole("ADMIN)ROLE") { + _send(amount); + } + + function upgrade( + uint64, /* newVersion ) */ + bytes calldata /* data */ + ) internal virtual override { + _bump(); + } + + event Logged(uint256 a /* ) */, uint256 b); +} +` + ents := runSolidity(t, src, "Guarded.sol") + + // Blanking leaves the erased comment/string body visible as a gap. + cases := []struct { + name, subtype, want string + }{ + {"Guarded.withdraw", "function", "function withdraw( address, uint256 amount ) public onlyRole( )"}, + {"Guarded.upgrade", "function", "function upgrade( uint64, bytes calldata ) internal virtual override"}, + {"Guarded.Logged", "event", "event Logged(uint256 a , uint256 b)"}, + } + for _, tc := range cases { + e := solFindSubtype(ents, tc.name, "SCOPE.Operation", tc.subtype) + if e == nil { + t.Errorf("expected %s %s", tc.subtype, tc.name) + continue + } + if e.Signature != tc.want { + t.Errorf("%s signature = %q, want %q", tc.name, e.Signature, tc.want) + } + } +} + // ── CONTAINS edges ──────────────────────────────────────────────────────────── func TestSolidity_ContainsEdges(t *testing.T) { diff --git a/tools/coverage/capability-map.yaml b/tools/coverage/capability-map.yaml index 69ff45e80..98dfdd238 100644 --- a/tools/coverage/capability-map.yaml +++ b/tools/coverage/capability-map.yaml @@ -14165,6 +14165,23 @@ records: issues_implemented: ["2773"] verified_at: "2026-05-28" + lang.solidity.core: + capabilities: + core_extraction: + # Regex extractor: no tree-sitter Solidity grammar is bundled. + # stripCommentsAndStrings blanks comments and literals first; + # findContracts walks the declarations; declSignature scans past the + # member regex match with paren depth so Signature is complete. + status: full + symbols: + - file: internal/extractors/solidity/extractor.go + functions: [findContracts, declSignature, extractBracedBody, stripCommentsAndStrings] + tests: + - file: internal/extractors/solidity/extractor_test.go + functions: [TestSolidity_ContractDeclaration, TestSolidity_LibraryDeclaration, TestSolidity_InterfaceDeclaration, TestSolidity_Functions, TestSolidity_Events, TestSolidity_Modifiers, TestSolidity_ContainsEdges, TestSolidity_FunctionSignatures, TestSolidity_EventAndModifierSignatures, TestSolidity_SignatureIgnoresDelimitersInCommentsAndStrings] + issues_implemented: [6028] + verified_at: "2026-07-28" + lang.c-cpp.framework.crow: capabilities: Type System: