Skip to content

Commit 3a58baa

Browse files
authored
test(extractors): cover every cacheVersion case with unit + golden te… (#69)
* test(extractors): cover every cacheVersion case with unit + golden tests and an enforced coverage guard * Fixing lint
1 parent 59be6a8 commit 3a58baa

52 files changed

Lines changed: 1498 additions & 0 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.githooks/pre-push

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
#!/bin/sh
2+
# Enola pre-push guard — runs the core-feature regression nets locally, before any
3+
# code reaches CI. Enable once per clone with:
4+
#
5+
# git config core.hooksPath .githooks
6+
#
7+
# It runs two checks:
8+
# 1. cacheVersion coverage guard (fast, no CGO): fails if a `// vN:` changelog
9+
# entry in internal/engine/cache.go has no registered test in
10+
# internal/cachecov/coverage_test.go — i.e. an extractor behavior was bumped
11+
# without a covering test.
12+
# 2. golden + determinism: the extractor fact graph for the fixture repos is
13+
# byte-identical to the committed goldens and reproducible across runs.
14+
#
15+
# To skip in an emergency: `git push --no-verify` (CI still enforces both).
16+
set -e
17+
18+
echo "pre-push: cacheVersion coverage guard…"
19+
go test -count=1 -run TestCacheVersionCoverage ./internal/cachecov/
20+
21+
echo "pre-push: golden + determinism…"
22+
go test -count=1 -run 'TestGolden|TestDeterminism' ./internal/engine/...
23+
24+
echo "pre-push: OK"

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ jobs:
4747
go-version-file: go.mod
4848
cache: true
4949

50+
- name: cacheVersion coverage guard
51+
run: go test -count=1 -run TestCacheVersionCoverage ./internal/cachecov/
52+
5053
- name: Golden + determinism
5154
run: go test -count=1 -run 'TestGolden|TestDeterminism' ./internal/engine/...
5255

internal/cachecov/coverage_test.go

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
package cachecov
2+
3+
// TestCacheVersionCoverage is the regression guard for Enola's core promise: every
4+
// cacheVersion bump documented in internal/engine/cache.go must be backed by a real
5+
// test. cache.go's changelog (the `// vN:` lines) is the spec for the extractors'
6+
// deterministic, multi-language behavior; this guard makes it impossible to add a
7+
// new vN — and therefore ship an extractor behavior change — without registering a
8+
// covering test here.
9+
//
10+
// The guard fails (before CI, if wired into a git pre-push hook) when any of:
11+
// - a `// vN:` line exists in cache.go with no entry in versionCoverage,
12+
// - versionCoverage names a test function that does not exist in the tree,
13+
// - the changelog is not the contiguous range v2..cacheVersion,
14+
// - versionCoverage has a stale entry for a vN cache.go no longer documents.
15+
//
16+
// When you bump cacheVersion and add a `// vN+1:` line, add a matching entry below
17+
// pointing at the test(s) that assert the new behavior. If you rename a referenced
18+
// test, update its name here — that coupling is intentional: it keeps the map honest.
19+
//
20+
// This package is deliberately dependency-free (no engine/extractor imports, no
21+
// tree-sitter), so it builds and runs in milliseconds.
22+
23+
import (
24+
"os"
25+
"path/filepath"
26+
"regexp"
27+
"strconv"
28+
"strings"
29+
"testing"
30+
)
31+
32+
// versionCoverage maps each cacheVersion (the integer N in "vN") to one or more
33+
// test functions that assert the behavior that version introduced. v1 is the
34+
// implicit baseline (no changelog line), so coverage starts at v2.
35+
var versionCoverage = map[int][]string{
36+
2: {"TestExtractURLSessionFacts"}, // Swift URLSession precision
37+
3: {"TestExtractFile_FastAPIRoute_Get"}, // Python route Name shape
38+
4: {"TestRestTemplate_ClientCalls", "TestFeignClient_AndControllerDiscriminated"}, // Java RestTemplate/@FeignClient
39+
5: {"TestLaravelRoutes_Resource", "TestPHPHTTPClient_GuzzleRequest"}, // PHP HTTP client + route DSLs
40+
6: {"TestExtractFile_BareConstantReferences"}, // Ruby bare-constant RelCalls
41+
7: {"TestExtractFile_BuiltinConstantsSkipped"}, // Ruby builtin-constant skip + serializer fold
42+
8: {"TestCRegistrationMacro", "TestCRegistrationMacroMultiArg"}, // C/C++ file-scope registration macros
43+
9: {"TestCFuncPtrFieldAssignment", "TestCMacroBodyCall"}, // C/C++ func-ptr fields + macro-body refs
44+
10: {"TestCStaticRegistrationMacro"}, // C/C++ qualifier-prefixed reg macro
45+
11: {"TestCCompoundLiteralAssignment"}, // C/C++ in-body compound-literal init
46+
12: {"TestCMacroBodyValuePosition"}, // C/C++ macro-body value-position func ptrs
47+
13: {"TestCMacroExpansionTokenPaste"}, // C/C++ token-paste macro expansion
48+
14: {"TestCStaticSingleArgAttr", "TestCDefineShowAttribute"}, // C/C++ single-arg DEVICE_ATTR, all-ident scan
49+
15: {"TestCMachineDescCleanErrorRegion"}, // C/C++ clean ERROR-node machine_desc salvage
50+
16: {"TestCMachineDescErrorRegion"}, // C/C++ assignment/field_expression fragment salvage
51+
17: {"TestCMachineDescSalvageSkipsFunctionBodies"}, // C/C++ full-tree salvage skips function bodies
52+
18: {"TestExtractTestRefsAST", "TestExtractFile_CustomClassMacroRecorded"}, // Ruby custom macros + KindTestRef
53+
19: {"TestExtractFile_ClassBodyQualifiedCall"}, // Ruby class-body calls + KindFileRef
54+
20: {"TestExtractFile_TopLevelAssignmentRHS"}, // Ruby per-scope pass (assignment RHS)
55+
21: {"TestExtractFile_InterpolatedSymbolPrefix"}, // Ruby interpolated-symbol prefix
56+
22: {"TestExtractFile_SuperReferencesAncestor", "TestExtractFile_LiteralSymbolDispatch"},// Ruby super + literal-symbol dispatch
57+
23: {"TestExtractFile_ChainedNoArgCall"}, // Ruby chained-receiver no-arg call
58+
24: {"TestExtractFile_DefaultParamCall", "TestExtractFile_PredicateBangSingleLevelCall"},// Ruby default-param + predicate/bang calls
59+
25: {"TestExtractFile_DelegateFold"}, // Ruby delegate :a, to: X fold
60+
26: {"TestExtractFile_LocalRelationScopeCall"}, // Ruby scope-like call on identifier receiver
61+
27: {"TestExtractFile_IvarUnderscoredCall", "TestExtractFile_GvarUnderscoredCall"}, // Ruby @ivar/@@cvar/$gvar receivers
62+
28: {"TestExtractFile_KlassReceiverDispatch", "TestExtractFile_ClazzKlazzReceiverDispatch"}, // Ruby klass/clazz/klazz dispatch
63+
29: {"TestExtractFile_InterpolatedStringPrefix"}, // Ruby interpolated-string prefix
64+
30: {"TestExtractFile_StringPrefixGatedOnDispatcher"}, // Ruby string-prefix dispatcher gating
65+
31: {"TestRbComplexity_BlockParamNotCall", "TestRbComplexity_InBatchesNotElementLoop"}, // Ruby block params + find/in_batches
66+
32: {"TestRbComplexity_SuperIsNotRecursion", "TestRbComplexity_DelegationIsNotRecursion"}, // Ruby super/decorator not recursion
67+
33: {"TestRbComplexity_SelfClassSiblingIsNotRecursion", "TestRbComplexity_ConstSelfClassMethodIsRecursion"}, // Ruby same-object recursion gating
68+
34: {"TestRbComplexity_ConstantBoundLoopNoDepth"}, // Ruby constant-bounded loops
69+
35: {"TestRbComplexity_LiteralChainLoopIsBounded"}, // Ruby bounded-loop chain unwrap
70+
36: {"TestAST_ModuleAbstractness", "TestAST_ConcernDetection"}, // Ruby module abstract prop
71+
37: {"TestParseXcodeGenProject", "TestManifest_TargetDependencyEdges"}, // Swift SPM/XcodeGen target modules
72+
38: {"TestModuleResolver_SharedSourceRootCollapses"}, // Swift shared-source-root collapse
73+
39: {"TestSymbolKind_MethodVsFunc", "TestRelCalls_SelfOptionalChaining"}, // Swift SymbolMethod + member-call edges
74+
40: {"TestFileScope_TopLevelCallsEmitFileRef", "TestOperator_CustomInfixTracked"}, // Swift file-scope refs + custom operators
75+
41: {"TestOperator_StandardMulticharNotTracked"}, // Swift stdlib-operator exclusion
76+
42: {"TestExtract_FlattenedTypeMethodResolves", "TestExtensionPropertyGetterCalls"}, // Swift funcIndex fallback + extension property owner
77+
43: {"TestClassPropertyInitAttachesToType"}, // Swift class-property init attaches to type
78+
44: {"TestSwComplexity_BoundedForRange", "TestSwComplexity_ComputedPropertyGetter"}, // Swift bounded loops + property metrics
79+
45: {"TestSwComplexity_SubscriptOnSameNameLocalNotRecursion"}, // Swift subscript-not-call
80+
46: {"TestSwComplexity_OverloadDelegationNotRecursion"}, // Swift label-aware recursion
81+
47: {"TestSwIO_DirectNetworkPrimitiveSetsIODirect", "TestComputePerformsIO_TransitiveThroughAmbiguousEdge"}, // Swift io_direct/performs_io
82+
48: {"TestResolveInheritedCalls_SubclassBaseMethod"}, // Swift inherited-method resolution
83+
49: {"TestTargetPriorityAndRoles"}, // Swift test-bundle module + module_role
84+
50: {"TestTargetPriorityAndRoles"}, // shared ModuleRoleForPath heuristic
85+
51: {"TestExtract_NoFalseCrossTargetCycle"}, // Swift no false module cycle
86+
52: {"TestAST_MemberFunctionIsMethodKind", "TestAST_OverrideAndDIProviderProps"}, // Kotlin SymbolMethod + nav edges + di/override
87+
53: {"TestDetectBasePackage_GroovyAndKotlinDSL"}, // Kotlin Groovy namespace base package
88+
54: {"TestAST_CallsOutsideFunctionBody"}, // Kotlin default-param + ctor-delegation calls
89+
55: {"TestAST_CallableReference", "TestAST_QualifiedCallableReference"}, // Kotlin ::foo / Type::foo callable refs
90+
56: {"TestKtComplexity_OverloadDelegationNotRecursion", "TestKtComplexity_ReactiveChainNotLoop"}, // Kotlin arity recursion + RxJava/Flow
91+
57: {"TestKtComplexity_SuperDelegationNotRecursion", "TestKtComplexity_RetrofitMethodPerformsIO"}, // Kotlin super-delegation + Retrofit/Room io
92+
58: {"TestBuildPackageIndex_MultiModuleSamePrefix", "TestAST_SealedClassIsAbstract"}, // Kotlin/Java multi-module import + sealed abstract
93+
59: {"TestBuildPackageIndex_MainWinsOverVariant"}, // Kotlin main-source-set preference
94+
60: {"TestModuleRole", "TestDagger_DIvsSpringComponent"}, // compound test module + Dagger di_component/module
95+
61: {"TestExtract_FileRef_JSXComponentUsage", "TestExtract_DynamicAndRequireDependencyEdges"}, // TS JSX/require file refs + dep edges
96+
62: {"TestExtract_FileRef_SameModuleUsePositions"}, // TS same-module use positions
97+
63: {"TestExtract_AnonymousDefaultExport_NamedByFile"}, // TS default import -> default export
98+
64: {"TestExtract_ThisMemberReference_EventHandler"}, // TS this.member reference
99+
65: {"TestExtract_SkipsMinifiedBundle", "TestIsMinifiedSource"}, // TS minified-file skip
100+
66: {"TestTsIO_DirectPrimitiveSetsIODirect", "TestTsIO_PerformsIOPropagatesToCaller"}, // TS io_direct/performs_io
101+
67: {"TestTsIO_NamedImportsFromNetworkModuleNotIO"}, // TS tightened io_direct
102+
68: {"TestExtract_AbstractClass"}, // TS abstract class prop
103+
69: {"TestExtractURLSessionFacts_TestSourcesSkipped", "TestExtractEndpointFacts"}, // Swift endpoint-enum + test-source skip
104+
70: {"TestExtractEndpointFacts_DefaultPrefix"}, // Swift endpoint version-prefix
105+
71: {"TestExtractStoredMethodEndpointFacts"}, // Swift stored-method endpoints
106+
72: {"TestWrapperEndpoint_PathAndVerbFromCallSite", "TestRoutes_NestedSingularResource"},// Swift request-wrapper + Ruby nested resources
107+
}
108+
109+
func TestCacheVersionCoverage(t *testing.T) {
110+
root := repoRoot(t)
111+
112+
current, changelog := parseCacheVersions(t, filepath.Join(root, "internal", "engine", "cache.go"))
113+
114+
// 1. Structural: the changelog must be exactly the contiguous range v2..current.
115+
if current < 2 {
116+
t.Fatalf("cacheVersion = v%d, want >= v2", current)
117+
}
118+
for v := 2; v <= current; v++ {
119+
if !changelog[v] {
120+
t.Errorf("cache.go has no `// v%d:` changelog line, but cacheVersion is v%d — every version must be documented", v, current)
121+
}
122+
}
123+
for v := range changelog {
124+
if v > current {
125+
t.Errorf("cache.go documents v%d but cacheVersion is only v%d — bump cacheVersion or remove the changelog line", v, current)
126+
}
127+
}
128+
129+
// 2. Every documented version must have a coverage entry.
130+
for v := range changelog {
131+
tests, ok := versionCoverage[v]
132+
if !ok || len(tests) == 0 {
133+
t.Errorf("v%d is documented in cache.go but has no entry in versionCoverage — register the test(s) that assert it", v)
134+
}
135+
}
136+
137+
// 3. No stale coverage entries for versions cache.go no longer documents.
138+
for v := range versionCoverage {
139+
if !changelog[v] {
140+
t.Errorf("versionCoverage has a stale entry for v%d, which cache.go does not document", v)
141+
}
142+
}
143+
144+
// 4. Every referenced test must actually exist in the tree.
145+
existing := testFuncNames(t, root)
146+
for v, tests := range versionCoverage {
147+
for _, name := range tests {
148+
if !existing[name] {
149+
t.Errorf("v%d references test %q, which does not exist — fix the name in versionCoverage (renamed?) or add the test", v, name)
150+
}
151+
}
152+
}
153+
}
154+
155+
// repoRoot walks up from the test's working directory until it finds go.mod.
156+
func repoRoot(t *testing.T) string {
157+
t.Helper()
158+
dir, err := os.Getwd()
159+
if err != nil {
160+
t.Fatalf("getwd: %v", err)
161+
}
162+
for {
163+
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
164+
return dir
165+
}
166+
parent := filepath.Dir(dir)
167+
if parent == dir {
168+
t.Fatal("could not locate go.mod above the test working directory")
169+
}
170+
dir = parent
171+
}
172+
}
173+
174+
var (
175+
cacheVersionConst = regexp.MustCompile(`cacheVersion\s*=\s*"v(\d+)"`)
176+
changelogLine = regexp.MustCompile(`^//\s*v(\d+):`)
177+
)
178+
179+
// parseCacheVersions returns the current cacheVersion integer and the set of
180+
// version integers documented by `// vN:` lines in cache.go.
181+
func parseCacheVersions(t *testing.T, path string) (current int, changelog map[int]bool) {
182+
t.Helper()
183+
data, err := os.ReadFile(path)
184+
if err != nil {
185+
t.Fatalf("read cache.go: %v", err)
186+
}
187+
188+
changelog = map[int]bool{}
189+
for _, raw := range strings.Split(string(data), "\n") {
190+
line := strings.TrimSpace(raw)
191+
if m := changelogLine.FindStringSubmatch(line); m != nil {
192+
n, _ := strconv.Atoi(m[1])
193+
changelog[n] = true
194+
continue
195+
}
196+
if m := cacheVersionConst.FindStringSubmatch(line); m != nil {
197+
current, _ = strconv.Atoi(m[1])
198+
}
199+
}
200+
if current == 0 {
201+
t.Fatal("could not find the cacheVersion constant in cache.go")
202+
}
203+
return current, changelog
204+
}
205+
206+
// testFuncNames scans every *_test.go under internal/ and pkg/ and returns the set
207+
// of top-level `func TestXxx(` names.
208+
func testFuncNames(t *testing.T, root string) map[string]bool {
209+
t.Helper()
210+
funcRe := regexp.MustCompile(`^func (Test[A-Za-z0-9_]+)\(`)
211+
names := map[string]bool{}
212+
for _, sub := range []string{"internal", "pkg"} {
213+
base := filepath.Join(root, sub)
214+
err := filepath.WalkDir(base, func(path string, d os.DirEntry, err error) error {
215+
if err != nil {
216+
return err
217+
}
218+
if d.IsDir() || !strings.HasSuffix(path, "_test.go") {
219+
return nil
220+
}
221+
data, err := os.ReadFile(path)
222+
if err != nil {
223+
return err
224+
}
225+
for _, line := range strings.Split(string(data), "\n") {
226+
if m := funcRe.FindStringSubmatch(line); m != nil {
227+
names[m[1]] = true
228+
}
229+
}
230+
return nil
231+
})
232+
if err != nil {
233+
t.Fatalf("walk %s: %v", base, err)
234+
}
235+
}
236+
if len(names) == 0 {
237+
t.Fatal("found no test functions — scan path is wrong")
238+
}
239+
return names
240+
}

internal/cachecov/doc.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// Package cachecov holds the enforced coverage guard for the extractor
2+
// cacheVersion changelog in internal/engine/cache.go.
3+
//
4+
// It intentionally has no production code and no heavy (CGO/tree-sitter)
5+
// dependencies, so the guard test builds and runs in well under a second — cheap
6+
// enough to run from a git pre-push hook, before CI.
7+
package cachecov

internal/engine/golden_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ var fixtures = []fixture{
4646
{name: "go_sample", subRepos: []string{"."}},
4747
{name: "ts_sample", subRepos: []string{"."}},
4848
{name: "python_sample", subRepos: []string{"."}},
49+
{name: "ruby_sample", subRepos: []string{"."}},
50+
{name: "swift_sample", subRepos: []string{"."}},
51+
{name: "kotlin_sample", subRepos: []string{"."}},
52+
{name: "java_sample", subRepos: []string{"."}},
53+
{name: "cpp_sample", subRepos: []string{"."}},
4954
{name: "php_sample", subRepos: []string{"."}},
5055
{name: "php_laravel_sample", subRepos: []string{"."}},
5156
{name: "php_symfony_sample", subRepos: []string{"."}},

0 commit comments

Comments
 (0)