|
| 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 | +} |
0 commit comments