Skip to content

Commit 5f20213

Browse files
authored
Discount bounded loops in Kotlin; split scaling from repeating (#88)
The Kotlin extractor emitted a raw loop_depth and no scaling_loop_depth, so perf.scalingDepth() silently substituted the raw value and a constant-count loop inflated the Big-O exponent at full confidence. Kotlin now classifies each loop and emits scaling_loop_depth and calls_in_scaling_loop, joining the Go/Python/TypeScript convention. Fixing that surfaced a second defect in Go, Python and TypeScript. A single "bounded" predicate drove two different questions: whether a loop adds a factor of n, and whether its body repeats. An infinite loop answers those differently — `for {}` adds no exponent, but a chain walk inside one issues a query per level. Both are now tracked: scaling = grows with the input -> scaling_loop_depth repeats = non-constant trip count -> calls_in_scaling_loop calls_in_scaling_loop is also emitted whenever calls_in_loop is, even when empty, because the consumer keys off the prop's presence; an omitted key read as "never computed" and fell back to the unfiltered call set. The two defects masked each other — fixing only the emptiness would have dropped true-positive N+1 findings on infinite loops, with green tests and no golden diff, since no fixture covered it. perf.go needs no logic change; two of its comments described the bug as if it were the design and are corrected. cacheVersion v97 -> v99, with cachecov entries and golden fixtures pinning all three loop classes in four languages (Ruby and Swift as inline-discount controls).
1 parent 4e5b061 commit 5f20213

19 files changed

Lines changed: 1035 additions & 77 deletions

File tree

internal/cachecov/coverage_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,8 @@ var versionCoverage = map[int][]string{
129129
95: {"TestResolveImports_CouplingKindTagged", "TestResolveImports_ReferenceBeatsAssociation"}, // Ruby synthetic-edge coupling_kind prop + framework-const ignore list
130130
96: {"TestAST_AssignedAndReturnedCallback", "TestAST_AssignedCallback_ShadowGuarded", "TestAST_ReturnedPlainVariable_NoPhantomRef", "TestAST_ReturnedForwardReference_Resolves", "TestAST_ShadowedLoopVarNotResolvedAsCall"}, // Python assignment/return value-refs + scope-wide (not just param) shadow guard
131131
97: {"TestMatchAnyGlob_MidPatternDoublestar", "TestGolden"}, // Directory-scoped Ruby test globs: production *_ab_test.rb no longer deleted from the graph
132+
98: {"TestKtComplexity_ScalingLoopDepth_ConstantRangeDiscounted", "TestKtComplexity_ScalingLoopDepth_VariableRangeNotDiscounted", "TestKtComplexity_ScalingLoopDepth_ConstantIteratorReceiverDiscounted", "TestKtComplexity_ScalingLoopDepth_VariableIteratorReceiverNotDiscounted", "TestKtComplexity_ScalingLoopDepth_ConstantOuterScalingInner", "TestKtComplexity_ScalingLoopDepth_ConstantInnerScalingOuter", "TestKtComplexity_ScalingLoopDepth_InfiniteLoopDiscounted", "TestKtComplexity_ScalingLoopDepth_ConditionalWhileNotDiscounted", "TestKtComplexity_ScalingLoopDepth_AbsentWithoutLoops", "TestKtComplexity_LoopCountAndCyclomaticUnchangedByBounding", "TestGolden"}, // Kotlin bounded-loop discounting: scaling_loop_depth joins the Go/Python/TS convention
133+
99: {"TestKtComplexity_CallsInScalingLoop_ConstantExcluded", "TestKtComplexity_CallsInScalingLoop_InfiniteLoopCallsRetained", "TestKtComplexity_CallsInScalingLoop_ScalingRetained", "TestKtComplexity_CallsInScalingLoop_MixedLoops", "TestKtComplexity_CallsInScalingLoop_AbsentWithoutLoopCalls", "TestExtract_CallsInScalingLoop_InfiniteLoopCallsRetained", "TestExtract_CallsInScalingLoop_PresentButEmptyWhenAllBounded", "TestExtract_CallsInScalingLoop_AbsentWithoutLoopCalls", "TestPyComplexity_CallsInScalingLoop_InfiniteLoopCallsRetained", "TestPyComplexity_CallsInScalingLoop_PresentButEmptyWhenAllBounded", "TestPyComplexity_CallsInScalingLoop_AbsentWithoutLoopCalls", "TestTsComplexity_CallsInScalingLoop_InfiniteLoopCallsRetained", "TestTsComplexity_CallsInScalingLoop_PresentButEmptyWhenAllBounded", "TestTsComplexity_CallsInScalingLoop_AbsentWithoutLoopCalls", "TestGolden"}, // calls_in_scaling_loop = repeated (not merely scaling) loops, emitted even when empty
132134
}
133135

134136
func TestCacheVersionCoverage(t *testing.T) {

internal/engine/cache.go

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,26 @@ import (
266266
// misrouted to reference-only test-ref extraction. The glob matcher gained the
267267
// "<prefix>/**/<fileglob>" form to express it. Cached Ruby snapshots must re-extract: the
268268
// file set reaching the extractor changes.
269-
const cacheVersion = "v97"
269+
// v98: the Kotlin extractor learned bounded-loop discounting and now emits
270+
// scaling_loop_depth (and calls_in_scaling_loop) alongside loop_depth, joining the
271+
// Go/Python/TypeScript convention. A constant-count loop — a literal integer range
272+
// (0..2, 0 until 3, 2 downTo 0, 0..10 step 2), a collection-literal receiver
273+
// (listOf(a, b).forEach), an ALL_CAPS data constant, or a size-preserving chain over
274+
// either — no longer inflates the Big-O exponent, and neither does an infinite
275+
// while (true) / do-while (true). Previously perf.scalingDepth() silently substituted the
276+
// raw loop_depth for Kotlin, so `for (ring in 1..6) { for (i in 0 until sides) }` reported
277+
// O(n2) at full confidence. Cached Kotlin snapshots must re-extract.
278+
// v99: calls_in_scaling_loop now means "calls inside a loop that repeats a NON-CONSTANT
279+
// number of times", not "calls inside an input-scaling loop", and is emitted by the Go,
280+
// Python, TypeScript and Kotlin extractors whenever calls_in_loop is — even when empty.
281+
// Two bugs, one cause: `for {}` / `while (true)` were classed bounded, which is right for
282+
// the Big-O exponent (they add no factor of n) but wrong for N+1 detection (a parent-chain
283+
// walk runs one query per level), and an omitted empty subset was read by
284+
// perf.scalingLoopCalls() as "extractor never computed it", falling back to the unfiltered
285+
// calls_in_loop. The second bug masked the first: fixing only the emptiness would have
286+
// deleted true-positive N+1 findings on infinite loops. Cached Go/Python/TypeScript/Kotlin
287+
// snapshots must re-extract.
288+
const cacheVersion = "v99"
270289

271290
// extractorCache holds per-extractor facts keyed by a content hash of the files
272291
// the extractor depends on. It is loaded from disk at the start of a snapshot and

internal/engine/testdata/golden/go_sample.facts.jsonl

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,8 @@
77
{"kind":"module","name":"pkg/b","file":"pkg/b","repo":"go_sample","props":{"language":"go","package":"b"}}
88
{"kind":"symbol","name":"..main","file":"main.go","line":10,"repo":"go_sample","props":{"cyclomatic":1,"exported":false,"language":"go","symbol_kind":"function"},"relations":[{"kind":"calls","target":"pkg/a.Alpha"},{"kind":"calls","target":"pkg/b.Beta"},{"kind":"declares","target":"."}]}
99
{"kind":"symbol","name":"pkg/a.Alpha","file":"pkg/a/a.go","line":7,"repo":"go_sample","props":{"cyclomatic":1,"exported":true,"language":"go","symbol_kind":"function"},"relations":[{"kind":"calls","target":"pkg/b.Beta"},{"kind":"declares","target":"pkg/a"}]}
10+
{"kind":"symbol","name":"pkg/a.GetPath","file":"pkg/a/a.go","line":15,"repo":"go_sample","props":{"calls_in_loop":["pkg/a.getByID"],"calls_in_scaling_loop":["pkg/a.getByID"],"cyclomatic":2,"exported":true,"language":"go","loop_count":1,"loop_depth":1,"scaling_loop_depth":0,"symbol_kind":"function"},"relations":[{"kind":"calls","target":"pkg/a.getByID"},{"kind":"declares","target":"pkg/a"}]}
11+
{"kind":"symbol","name":"pkg/a.Seed","file":"pkg/a/a.go","line":24,"repo":"go_sample","props":{"calls_in_loop":["pkg/a.setup"],"calls_in_scaling_loop":[],"cyclomatic":2,"exported":true,"language":"go","loop_count":1,"loop_depth":1,"scaling_loop_depth":0,"symbol_kind":"function"},"relations":[{"kind":"calls","target":"pkg/a.setup"},{"kind":"declares","target":"pkg/a"}]}
12+
{"kind":"symbol","name":"pkg/a.getByID","file":"pkg/a/a.go","line":30,"repo":"go_sample","props":{"cyclomatic":1,"exported":false,"language":"go","symbol_kind":"function"},"relations":[{"kind":"declares","target":"pkg/a"}]}
13+
{"kind":"symbol","name":"pkg/a.setup","file":"pkg/a/a.go","line":31,"repo":"go_sample","props":{"cyclomatic":1,"exported":false,"language":"go","symbol_kind":"function"},"relations":[{"kind":"declares","target":"pkg/a"}]}
1014
{"kind":"symbol","name":"pkg/b.Beta","file":"pkg/b/b.go","line":6,"repo":"go_sample","props":{"cyclomatic":1,"exported":true,"language":"go","symbol_kind":"function"},"relations":[{"kind":"calls","target":"pkg/a.Alpha"},{"kind":"declares","target":"pkg/b"}]}

internal/engine/testdata/golden/kotlin_sample.facts.jsonl

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,12 @@
4949
{"kind":"symbol","name":"app/src/main/kotlin/de/foo/app.HomeViewModel.onChangeEnded","file":"app/src/main/kotlin/de/foo/app/HomeViewModel.kt","line":45,"repo":"kotlin_sample","props":{"cyclomatic":1,"exported":true,"language":"kotlin","override":true,"receiver":"HomeViewModel","symbol_kind":"method"},"relations":[{"kind":"calls","target":"app/src/main/kotlin/de/foo/app.HomeViewModel.onChangeEnded"},{"kind":"calls","target":"app/src/main/kotlin/de/foo/app.fallbackHandler"},{"kind":"calls","target":"onChangeEnded"},{"kind":"declares","target":"app/src/main/kotlin/de/foo/app"}]}
5050
{"kind":"symbol","name":"app/src/main/kotlin/de/foo/app.HomeViewModel.render","file":"app/src/main/kotlin/de/foo/app/HomeViewModel.kt","line":15,"repo":"kotlin_sample","props":{"cyclomatic":1,"exported":true,"language":"kotlin","receiver":"HomeViewModel","symbol_kind":"method"},"relations":[{"kind":"calls","target":"api/src/main/kotlin/de/foo/api.formatUser"},{"kind":"declares","target":"app/src/main/kotlin/de/foo/app"}]}
5151
{"kind":"symbol","name":"app/src/main/kotlin/de/foo/app.HomeViewModel.square","file":"app/src/main/kotlin/de/foo/app/HomeViewModel.kt","line":29,"repo":"kotlin_sample","props":{"cyclomatic":1,"exported":true,"language":"kotlin","receiver":"HomeViewModel","symbol_kind":"method"},"relations":[{"kind":"declares","target":"app/src/main/kotlin/de/foo/app"}]}
52-
{"kind":"symbol","name":"app/src/main/kotlin/de/foo/app.HomeViewModel.updateItem","file":"app/src/main/kotlin/de/foo/app/HomeViewModel.kt","line":34,"repo":"kotlin_sample","props":{"calls_in_loop":["app/src/main/kotlin/de/foo/app.HomeViewModel.updateItem"],"cyclomatic":2,"exported":true,"language":"kotlin","loop_count":1,"loop_depth":1,"receiver":"HomeViewModel","symbol_kind":"method"},"relations":[{"kind":"calls","target":"app/src/main/kotlin/de/foo/app.HomeViewModel.updateItem"},{"kind":"declares","target":"app/src/main/kotlin/de/foo/app"}]}
52+
{"kind":"symbol","name":"app/src/main/kotlin/de/foo/app.HomeViewModel.updateItem","file":"app/src/main/kotlin/de/foo/app/HomeViewModel.kt","line":34,"repo":"kotlin_sample","props":{"calls_in_loop":["app/src/main/kotlin/de/foo/app.HomeViewModel.updateItem"],"calls_in_scaling_loop":[],"cyclomatic":2,"exported":true,"language":"kotlin","loop_count":1,"loop_depth":1,"receiver":"HomeViewModel","scaling_loop_depth":0,"symbol_kind":"method"},"relations":[{"kind":"calls","target":"app/src/main/kotlin/de/foo/app.HomeViewModel.updateItem"},{"kind":"declares","target":"app/src/main/kotlin/de/foo/app"}]}
5353
{"kind":"symbol","name":"app/src/main/kotlin/de/foo/app.HomeViewModel.updateItem","file":"app/src/main/kotlin/de/foo/app/HomeViewModel.kt","line":40,"repo":"kotlin_sample","props":{"cyclomatic":1,"exported":true,"language":"kotlin","receiver":"HomeViewModel","symbol_kind":"method"},"relations":[{"kind":"declares","target":"app/src/main/kotlin/de/foo/app"}]}
5454
{"kind":"symbol","name":"app/src/main/kotlin/de/foo/app.HomeViewModel.wire","file":"app/src/main/kotlin/de/foo/app/HomeViewModel.kt","line":22,"repo":"kotlin_sample","props":{"cyclomatic":1,"exported":true,"language":"kotlin","receiver":"HomeViewModel","symbol_kind":"method"},"relations":[{"kind":"calls","target":"app/src/main/kotlin/de/foo/app.register"},{"kind":"calls","target":"doNothing"},{"kind":"calls","target":"map"},{"kind":"calls","target":"square"},{"kind":"declares","target":"app/src/main/kotlin/de/foo/app"}]}
55+
{"kind":"symbol","name":"app/src/main/kotlin/de/foo/app.LoopShapes","file":"app/src/main/kotlin/de/foo/app/HomeViewModel.kt","line":70,"repo":"kotlin_sample","props":{"exported":true,"language":"kotlin","symbol_kind":"class"},"relations":[{"kind":"declares","target":"app/src/main/kotlin/de/foo/app"}]}
56+
{"kind":"symbol","name":"app/src/main/kotlin/de/foo/app.LoopShapes.bootstrap","file":"app/src/main/kotlin/de/foo/app/HomeViewModel.kt","line":77,"repo":"kotlin_sample","props":{"calls_in_loop":["repo.getById"],"calls_in_scaling_loop":[],"cyclomatic":2,"exported":true,"language":"kotlin","loop_count":1,"loop_depth":1,"receiver":"LoopShapes","scaling_loop_depth":0,"symbol_kind":"method"},"relations":[{"kind":"calls","target":"forEach"},{"kind":"calls","target":"getById"},{"kind":"declares","target":"app/src/main/kotlin/de/foo/app"}]}
57+
{"kind":"symbol","name":"app/src/main/kotlin/de/foo/app.LoopShapes.loadPath","file":"app/src/main/kotlin/de/foo/app/HomeViewModel.kt","line":71,"repo":"kotlin_sample","props":{"calls_in_loop":["repo.getById"],"calls_in_scaling_loop":["repo.getById"],"cyclomatic":2,"exported":true,"language":"kotlin","loop_count":1,"loop_depth":1,"receiver":"LoopShapes","scaling_loop_depth":0,"symbol_kind":"method"},"relations":[{"kind":"calls","target":"getById"},{"kind":"declares","target":"app/src/main/kotlin/de/foo/app"}]}
5558
{"kind":"symbol","name":"app/src/main/kotlin/de/foo/app.fib","file":"app/src/main/kotlin/de/foo/app/HomeViewModel.kt","line":62,"repo":"kotlin_sample","props":{"cyclomatic":2,"exported":true,"language":"kotlin","recursive_self":true,"symbol_kind":"function"},"relations":[{"kind":"calls","target":"app/src/main/kotlin/de/foo/app.fib"},{"kind":"calls","target":"app/src/main/kotlin/de/foo/app.fib"},{"kind":"declares","target":"app/src/main/kotlin/de/foo/app"}]}
5659
{"kind":"symbol","name":"app/src/test/kotlin/de/foo/app.HomeViewModelTest","file":"app/src/test/kotlin/de/foo/app/HomeViewModelTest.kt","line":10,"repo":"kotlin_sample","props":{"exported":true,"language":"kotlin","symbol_kind":"class"},"relations":[{"kind":"declares","target":"app/src/test/kotlin/de/foo/app"}]}
5760
{"kind":"symbol","name":"app/src/test/kotlin/de/foo/app.HomeViewModelTest.fibonacci","file":"app/src/test/kotlin/de/foo/app/HomeViewModelTest.kt","line":11,"repo":"kotlin_sample","props":{"cyclomatic":1,"exported":true,"language":"kotlin","receiver":"HomeViewModelTest","symbol_kind":"method"},"relations":[{"kind":"calls","target":"app/src/test/kotlin/de/foo/app.fib"},{"kind":"declares","target":"app/src/test/kotlin/de/foo/app"}]}

internal/engine/testdata/golden/kotlin_swift_multirepo.facts.jsonl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
{"kind":"symbol","name":"app/src/main/kotlin/de/golf/app.FullAnalysisDataBuilder.lastMonth","file":"android/app/src/main/kotlin/de/golf/app/Analytics.kt","line":30,"repo":"android","props":{"cyclomatic":1,"exported":true,"language":"kotlin","receiver":"FullAnalysisDataBuilder","symbol_kind":"method"},"relations":[{"kind":"declares","target":"app/src/main/kotlin/de/golf/app"},{"kind":"instantiates","target":"TimeWindow"}]}
2525
{"kind":"symbol","name":"app/src/main/kotlin/de/golf/app.HandicapAnalytics","file":"android/app/src/main/kotlin/de/golf/app/Analytics.kt","line":19,"repo":"android","props":{"exported":true,"language":"kotlin","symbol_kind":"class"},"relations":[{"kind":"declares","target":"app/src/main/kotlin/de/golf/app"}]}
2626
{"kind":"symbol","name":"app/src/main/kotlin/de/golf/app.HandicapAnalytics.DifferentialEntry","file":"android/app/src/main/kotlin/de/golf/app/Analytics.kt","line":20,"repo":"android","props":{"data_class":true,"exported":true,"language":"kotlin","symbol_kind":"class"},"relations":[{"kind":"declares","target":"app/src/main/kotlin/de/golf/app"}]}
27-
{"kind":"symbol","name":"app/src/main/kotlin/de/golf/app.HandicapAnalytics.differentials","file":"android/app/src/main/kotlin/de/golf/app/Analytics.kt","line":22,"repo":"android","props":{"cyclomatic":2,"exported":true,"language":"kotlin","loop_count":1,"loop_depth":1,"receiver":"HandicapAnalytics","symbol_kind":"method"},"relations":[{"kind":"calls","target":"map"},{"kind":"declares","target":"app/src/main/kotlin/de/golf/app"},{"kind":"instantiates","target":"DifferentialEntry"}]}
27+
{"kind":"symbol","name":"app/src/main/kotlin/de/golf/app.HandicapAnalytics.differentials","file":"android/app/src/main/kotlin/de/golf/app/Analytics.kt","line":22,"repo":"android","props":{"cyclomatic":2,"exported":true,"language":"kotlin","loop_count":1,"loop_depth":1,"receiver":"HandicapAnalytics","scaling_loop_depth":1,"symbol_kind":"method"},"relations":[{"kind":"calls","target":"map"},{"kind":"declares","target":"app/src/main/kotlin/de/golf/app"},{"kind":"instantiates","target":"DifferentialEntry"}]}
2828
{"kind":"symbol","name":"app/src/main/kotlin/de/golf/app.RegisterUseCase","file":"android/app/src/main/kotlin/de/golf/app/Analytics.kt","line":8,"repo":"android","props":{"exported":true,"language":"kotlin","symbol_kind":"class"},"relations":[{"kind":"declares","target":"app/src/main/kotlin/de/golf/app"}]}
2929
{"kind":"symbol","name":"app/src/main/kotlin/de/golf/app.RegisterUseCase.ValidationError","file":"android/app/src/main/kotlin/de/golf/app/Analytics.kt","line":9,"repo":"android","props":{"data_class":true,"exported":true,"language":"kotlin","symbol_kind":"class"},"relations":[{"kind":"declares","target":"app/src/main/kotlin/de/golf/app"}]}
3030
{"kind":"symbol","name":"app/src/main/kotlin/de/golf/app.RegisterUseCase.validate","file":"android/app/src/main/kotlin/de/golf/app/Analytics.kt","line":11,"repo":"android","props":{"cyclomatic":2,"exported":true,"language":"kotlin","receiver":"RegisterUseCase","symbol_kind":"method"},"relations":[{"kind":"calls","target":"contains"},{"kind":"declares","target":"app/src/main/kotlin/de/golf/app"},{"kind":"instantiates","target":"ValidationError"}]}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
{"kind":"dependency","name":"app/api -\u003e app.db","file":"app/api.py","line":3,"repo":"python_sample","props":{"from":true,"language":"python","source":"external"},"relations":[{"kind":"imports","target":"app.db"}]}
22
{"kind":"module","name":"app","file":"app","repo":"python_sample","props":{"language":"python"}}
33
{"kind":"symbol","name":"app/api.handler","file":"app/api.py","line":6,"repo":"python_sample","props":{"cyclomatic":1,"exported":true,"language":"python","symbol_kind":"function"},"relations":[{"kind":"calls","target":"app/db.get_user"},{"kind":"declares","target":"app"}]}
4+
{"kind":"symbol","name":"app/db.get_path","file":"app/db.py","line":9,"repo":"python_sample","props":{"calls_in_loop":["app/db.get_user"],"calls_in_scaling_loop":["app/db.get_user"],"cyclomatic":2,"exported":true,"language":"python","loop_count":1,"loop_depth":1,"scaling_loop_depth":0,"symbol_kind":"function"},"relations":[{"kind":"calls","target":"app/db.get_user"},{"kind":"declares","target":"app"}]}
45
{"kind":"symbol","name":"app/db.get_user","file":"app/db.py","line":4,"repo":"python_sample","props":{"cyclomatic":1,"exported":true,"language":"python","symbol_kind":"function"},"relations":[{"kind":"declares","target":"app"}]}
6+
{"kind":"symbol","name":"app/db.seed","file":"app/db.py","line":15,"repo":"python_sample","props":{"calls_in_loop":["app/db.get_user"],"calls_in_scaling_loop":[],"cyclomatic":2,"exported":true,"language":"python","loop_count":1,"loop_depth":1,"scaling_loop_depth":0,"symbol_kind":"function"},"relations":[{"kind":"calls","target":"app/db.get_user"},{"kind":"declares","target":"app"}]}

0 commit comments

Comments
 (0)