Skip to content
This repository was archived by the owner on Feb 17, 2026. It is now read-only.

Commit 4dfb02a

Browse files
committed
feat(domain-hints): implement DomainHints extraction for Java/Kotlin test files
Add domain classification metadata extraction support for JVM language test files. Java: - Import extraction (standard, static, wildcard support) - Method invocation extraction (2-segment normalization) - JUnit/Mockito/Hamcrest test framework call filtering Kotlin: - Import extraction - Call expression extraction (2-segment normalization) - Kotest/Mockk test framework call filtering fix #108
1 parent 7007f0d commit 4dfb02a

6 files changed

Lines changed: 856 additions & 0 deletions

File tree

pkg/parser/domain_hints/extractor.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ func GetExtractor(lang domain.Language) Extractor {
2323
return &JavaScriptExtractor{lang: lang}
2424
case domain.LanguagePython:
2525
return &PythonExtractor{}
26+
case domain.LanguageJava:
27+
return &JavaExtractor{}
28+
case domain.LanguageKotlin:
29+
return &KotlinExtractor{}
2630
default:
2731
return nil
2832
}

pkg/parser/domain_hints/go_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,8 @@ func TestGetExtractor(t *testing.T) {
113113
{domain.LanguageTypeScript, false},
114114
{domain.LanguageTSX, false},
115115
{domain.LanguagePython, false},
116+
{domain.LanguageJava, false},
117+
{domain.LanguageKotlin, false},
116118
}
117119

118120
for _, tt := range tests {

pkg/parser/domain_hints/java.go

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
package domain_hints
2+
3+
import (
4+
"context"
5+
"strings"
6+
7+
sitter "github.com/smacker/go-tree-sitter"
8+
9+
"github.com/specvital/core/pkg/domain"
10+
"github.com/specvital/core/pkg/parser/strategies/shared/javaast"
11+
"github.com/specvital/core/pkg/parser/tspool"
12+
)
13+
14+
// JavaExtractor extracts domain hints from Java source code.
15+
type JavaExtractor struct{}
16+
17+
const (
18+
// import x.y.z; import x.y.*; import static x.y.z;
19+
javaImportQuery = `
20+
(import_declaration) @import
21+
`
22+
23+
// Function calls: obj.method(), ClassName.staticMethod()
24+
javaCallQuery = `
25+
(method_invocation) @call
26+
`
27+
)
28+
29+
func (e *JavaExtractor) Extract(ctx context.Context, source []byte) *domain.DomainHints {
30+
// Sanitize source to handle NULL bytes
31+
source = javaast.SanitizeSource(source)
32+
33+
tree, err := tspool.Parse(ctx, domain.LanguageJava, source)
34+
if err != nil {
35+
return nil
36+
}
37+
defer tree.Close()
38+
39+
root := tree.RootNode()
40+
41+
hints := &domain.DomainHints{
42+
Imports: e.extractImports(root, source),
43+
Calls: e.extractCalls(root, source),
44+
}
45+
46+
if len(hints.Imports) == 0 && len(hints.Calls) == 0 {
47+
return nil
48+
}
49+
50+
return hints
51+
}
52+
53+
func (e *JavaExtractor) extractImports(root *sitter.Node, source []byte) []string {
54+
results, err := tspool.QueryWithCache(root, source, domain.LanguageJava, javaImportQuery)
55+
if err != nil {
56+
return nil
57+
}
58+
59+
seen := make(map[string]struct{})
60+
var imports []string
61+
62+
for _, r := range results {
63+
if node, ok := r.Captures["import"]; ok {
64+
importPath := extractJavaImportPath(node, source)
65+
if importPath != "" {
66+
if _, exists := seen[importPath]; !exists {
67+
seen[importPath] = struct{}{}
68+
imports = append(imports, importPath)
69+
}
70+
}
71+
}
72+
}
73+
74+
return imports
75+
}
76+
77+
// extractJavaImportPath extracts the import path from an import_declaration node.
78+
// Handles: import x.y.z; import x.y.*; import static x.y.z;
79+
func extractJavaImportPath(node *sitter.Node, source []byte) string {
80+
var scopedIdentifier string
81+
hasAsterisk := false
82+
83+
// Skip "import", "static", and ";" - extract the scoped_identifier
84+
for i := 0; i < int(node.ChildCount()); i++ {
85+
child := node.Child(i)
86+
childType := child.Type()
87+
88+
// scoped_identifier: x.y.z
89+
if childType == "scoped_identifier" {
90+
scopedIdentifier = child.Content(source)
91+
}
92+
// asterisk: for wildcard imports (x.y.*)
93+
if childType == "asterisk" {
94+
hasAsterisk = true
95+
}
96+
// identifier: single-word import (rare, but possible)
97+
if childType == "identifier" && scopedIdentifier == "" {
98+
scopedIdentifier = child.Content(source)
99+
}
100+
}
101+
102+
if scopedIdentifier == "" {
103+
return ""
104+
}
105+
106+
if hasAsterisk {
107+
return scopedIdentifier + ".*"
108+
}
109+
return scopedIdentifier
110+
}
111+
112+
func (e *JavaExtractor) extractCalls(root *sitter.Node, source []byte) []string {
113+
results, err := tspool.QueryWithCache(root, source, domain.LanguageJava, javaCallQuery)
114+
if err != nil {
115+
return nil
116+
}
117+
118+
seen := make(map[string]struct{})
119+
calls := make([]string, 0, len(results))
120+
121+
for _, r := range results {
122+
if node, ok := r.Captures["call"]; ok {
123+
call := extractJavaMethodCall(node, source)
124+
if call == "" {
125+
continue
126+
}
127+
// Normalize to 2 segments
128+
call = normalizeCall(call)
129+
if call == "" {
130+
continue
131+
}
132+
// Skip test framework calls
133+
if isJavaTestFrameworkCall(call) {
134+
continue
135+
}
136+
if _, exists := seen[call]; exists {
137+
continue
138+
}
139+
seen[call] = struct{}{}
140+
calls = append(calls, call)
141+
}
142+
}
143+
144+
return calls
145+
}
146+
147+
// extractJavaMethodCall extracts the method call expression.
148+
// Handles: obj.method(), ClassName.staticMethod(), method()
149+
func extractJavaMethodCall(node *sitter.Node, source []byte) string {
150+
// method_invocation structure: object.name(arguments)
151+
// or: name(arguments)
152+
153+
var parts []string
154+
155+
// Extract object (if exists)
156+
objectNode := node.ChildByFieldName("object")
157+
if objectNode != nil {
158+
parts = append(parts, objectNode.Content(source))
159+
}
160+
161+
// Extract method name
162+
nameNode := node.ChildByFieldName("name")
163+
if nameNode != nil {
164+
parts = append(parts, nameNode.Content(source))
165+
}
166+
167+
if len(parts) == 0 {
168+
return ""
169+
}
170+
171+
return strings.Join(parts, ".")
172+
}
173+
174+
// javaTestFrameworkCalls contains base names from Java test frameworks
175+
// that should be excluded from domain hints.
176+
var javaTestFrameworkCalls = map[string]struct{}{
177+
// JUnit assertions
178+
"assertEquals": {}, "assertNotEquals": {}, "assertTrue": {}, "assertFalse": {},
179+
"assertNull": {}, "assertNotNull": {}, "assertSame": {}, "assertNotSame": {},
180+
"assertArrayEquals": {}, "assertThrows": {}, "assertDoesNotThrow": {},
181+
"assertAll": {}, "assertTimeout": {}, "assertTimeoutPreemptively": {},
182+
"fail": {}, "assumeTrue": {}, "assumeFalse": {},
183+
// Assertions class prefix
184+
"Assertions": {},
185+
// Hamcrest
186+
"assertThat": {}, "is": {}, "equalTo": {}, "hasSize": {}, "contains": {},
187+
"containsString": {}, "startsWith": {}, "endsWith": {},
188+
"MatcherAssert": {},
189+
// Mockito
190+
"mock": {}, "spy": {}, "when": {}, "verify": {}, "doReturn": {},
191+
"doThrow": {}, "doNothing": {}, "times": {}, "never": {}, "any": {},
192+
"eq": {}, "anyString": {}, "anyInt": {}, "anyLong": {},
193+
"Mockito": {},
194+
// AssertJ
195+
"isEqualTo": {}, "isNotNull": {},
196+
}
197+
198+
func isJavaTestFrameworkCall(call string) bool {
199+
baseName := call
200+
if idx := strings.Index(call, "."); idx > 0 {
201+
baseName = call[:idx]
202+
}
203+
_, exists := javaTestFrameworkCalls[baseName]
204+
return exists
205+
}

0 commit comments

Comments
 (0)