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: