Skip to content

Commit 972a724

Browse files
authored
Fix/python dead code false positives (#72)
* fix(python): emit call edges for absolute imports and value references The extractor only resolved relative imports, so functions reached via an absolute intra-project import had no incoming edge and were mis-reported as dead code. - Resolve absolute intra-project imports to call edges; add a post-pass that rewrites dotted targets to canonical slash symbol names and drops stdlib/third-party edges - Emit file-scope edges for module-level calls - Record decorator applications, decorator-call arguments, and parameter defaults as uses - Register function-local (lazy) imports so calls through them resolve - Emit reference edges for functions passed by name as call arguments - Tag click/Typer command and group functions - Bump cacheVersion (with coverage and tests) * fix(python): track more reference mechanisms in dead-code analysis The extractor missed several ways Python code references symbols, so many used functions had no incoming edge and were mis-reported as dead. - Resolve absolute and function-local imports to call edges; add a post-pass that rewrites dotted targets to canonical symbol names and drops external ones - Emit file-scope edges for module-level and class-body wiring - Track value references: call arguments, parameter defaults, decorator arguments, attributes, and collection/dict literals - Credit same-module references safely, guarding against parameter shadowing - Resolve string-literal symbol paths and pyproject entry points - Detect route decorators in more forms and tag framework-dispatched handlers, CLI commands, and registration-decorated functions as entry points - Bump cacheVersion (with coverage and tests) * Add cardinality and I/O signals to complexity facts; widen generated-path filter The performance analyzer consumes per-function complexity facts from the language extractors. Two gaps led to systematic false positives: loop-nesting depth was treated as the Big-O exponent even when a loop iterates a constant/bounded range, and generated/vendored source was analyzed as if it were hand-written. Extractors (Python, TypeScript, Go): - Emit `scaling_loop_depth` alongside `loop_depth` — loop nesting that counts only input-scaling loops. Loops over literal/constant collections, range(<const>), fixed varargs, and infinite while(true)/for{} event or retry loops are discounted, so a structurally deep but bounded body no longer reads as O(n^k). Emitted whenever loops exist (even as 0) so consumers can tell "all loops bounded" from "signal absent"; languages that don't emit it fall back to loop_depth, unchanged. Python extractor: - Tag bodies that directly invoke a DB/network/file primitive (io_direct) and propagate it transitively across the call graph into `performs_io` via a monotone fixpoint, mirroring the TypeScript pass. This lets a consumer distinguish a real per-iteration I/O call from a name that merely collides with a DB verb. mcputil: - Widen IsGeneratedPath to also exclude vendored and machine-generated sources: the vendor/openapi-gen/third_party/__generated__ path segments and unambiguous codegen/bundle suffixes (.gen.ts, .pb.go, _pb2.py, .min.js, ...). Kept to exact segments and explicit suffixes so legitimately named packages are never hidden. Bump cacheVersion so cached snapshots re-extract with the new signals. * Emit calls_in_scaling_loop so bounded-loop calls aren't N+1 candidates The performance analyzer flags a call inside a loop as a likely N+1. But a call that only ever runs inside a bounded loop — an iteration over a literal/constant collection, range(<const>), a composite literal, or an infinite while(true)/for{} event loop — runs a fixed number of times, not a pattern that scales with input, so it should not be treated as an N+1. Extractors (Python, TypeScript, Go): - Emit a new prop calls_in_scaling_loop — the subset of calls_in_loop made while the scaling depth (loops that scale with input) is >= 1, reusing the per-call-site scaling counter already tracked for scaling_loop_depth. calls_in_loop is unchanged (still every in-loop call), so the compounding call graph is unaffected; the new list is the precise input for N+1 detection. Extractors that don't emit it let the consumer fall back to calls_in_loop, behavior unchanged. Bump cacheVersion so cached snapshots re-extract with the new signal. * feat(python): classify enums, data holders, and duck-typed abstract classes The Python extractor emitted `abstract` only for formal ABC/Protocol/ @AbstractMethod, and never emitted `enum` or `data_class`. As a result package-metrics could not distinguish Python value-carrier and enum packages from logic packages, and abstractness (A) was ~0 for the many idiomatic base classes that signal "abstract" by raising NotImplementedError rather than subclassing ABC. handleClass now sets, in addition to the existing `abstract` detection: - `enum` for Enum/IntEnum/StrEnum/Flag/IntFlag subclasses, so a pure-enum package is excluded from N (parity with the Kotlin enum handling). - `data_class` for DTO/schema/record classes: @DataClass and @attrs (define/frozen/mutable/attrs/attr.s) decorated classes, and subclasses of Pydantic BaseModel/RootModel/GenericModel/BaseSettings (plus any `*BaseModel` name, covering project-local bases like StrictBaseModel), typing.NamedTuple, and TypedDict. - `abstract` for the duck-typed abstract pattern: a class with a method whose whole body is `raise NotImplementedError` (optionally after a docstring). Conservative — bare `pass`/`...` stub bodies are not treated as abstract. Bump cacheVersion to v86 (v85 introduced the props; v86 broadens data_class to RootModel/*BaseModel subclasses) so cached Python snapshots re-extract. * fix(walk): never index Python virtualenvs and installed dependencies A repo-local .venv/venv holds the entire third-party dependency tree (thousands of .py files). The Python extractor doesn't walk files itself — it consumes the engine's file list, which prunes only paths matched by the config `ignore` globs. Neither mcp-arch.yaml nor config.Default() listed .venv/site-packages, so the whole dependency tree was walked, parsed, and cached, dominating snapshot time. Add Python virtualenv / dependency / tool-cache directories to every ignore source, using the any-depth `**/<dir>/**` form so nested venvs in monorepos are pruned too. walkRepo already SkipDir's an ignored directory, so the tree is skipped without descending into it: - config.Default() (compiled fallback) and mcp-arch.yaml (shipped config): .venv, venv, site-packages, .tox, .nox, .eggs, __pycache__, and the mypy/ pytest/ruff caches. site-packages is the definitive catch for any oddly named env (conda/direnv/.tox all nest one). - mcputil.IsGeneratedPath: add .venv/venv/site-packages as a hardcoded defense-in-depth guard, so even a custom config that omits them cannot surface dependency symbols. - examples/python.yaml and examples/full.yaml: add the site-packages catch. Bare `env` is intentionally NOT excluded (too common a legitimate directory name); a test asserts app/env/settings.py stays indexed. No cacheVersion bump — this changes file discovery, not extractor output. * Fixing lint
1 parent 651dc7a commit 972a724

22 files changed

Lines changed: 2436 additions & 139 deletions

examples/full.yaml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,17 @@ ignore:
2222
- ".git/**"
2323
- ".enola/**"
2424

25+
# Python virtual environments, installed dependencies, and tool caches
26+
- "**/.venv/**"
27+
- "**/venv/**"
28+
- "**/site-packages/**"
29+
- "**/.tox/**"
30+
- "**/.nox/**"
31+
- "**/__pycache__/**"
32+
- "**/.mypy_cache/**"
33+
- "**/.pytest_cache/**"
34+
- "**/.ruff_cache/**"
35+
2536
# Go tests
2637
- "**/*_test.go"
2738

examples/python.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,11 @@ ignore:
2222
- "**/.venv/**"
2323
- "venv/**"
2424
- "**/venv/**"
25+
# Definitive catch for any oddly-named env (conda/direnv/.tox/.nox all nest one).
26+
- "**/site-packages/**"
2527
- ".tox/**"
2628
- "**/.tox/**"
29+
- "**/.nox/**"
2730
- ".mypy_cache/**"
2831
- "**/.mypy_cache/**"
2932
- ".pytest_cache/**"

internal/cachecov/coverage_test.go

Lines changed: 85 additions & 76 deletions
Large diffs are not rendered by default.

internal/config/config.go

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,22 @@ func Default() *Config {
6565
"coverage/**",
6666
".nuxt/**",
6767
".svelte-kit/**",
68-
"__pycache__/**",
68+
"**/__pycache__/**",
69+
// Python virtual environments, installed dependencies, and tool caches.
70+
// A repo-local .venv/venv holds the entire dependency tree (thousands of
71+
// third-party .py files); indexing it is never wanted and dominates
72+
// snapshot time. site-packages is the definitive catch for any oddly-named
73+
// env (.tox/.nox/conda/direnv all nest one). Any-depth (**/x/**) form so
74+
// monorepo sub-project venvs are pruned too.
75+
"**/.venv/**",
76+
"**/venv/**",
77+
"**/site-packages/**",
78+
"**/.tox/**",
79+
"**/.nox/**",
80+
"**/.eggs/**",
81+
"**/.mypy_cache/**",
82+
"**/.pytest_cache/**",
83+
"**/.ruff_cache/**",
6984
"**/Pods/**",
7085
"**/.gradle/**",
7186
// Minified / bundled JS by name. The extractor also detects minified

internal/config/config_test.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,22 @@ func TestDefaultIgnoresNestedBuildAndPods(t *testing.T) {
2323
t.Error("Default().Ignore still has top-level-only \"build/**\"; want \"**/build/**\"")
2424
}
2525
}
26+
27+
// TestDefaultIgnoresPythonEnvs locks in the .venv fix: Python virtual
28+
// environments and installed dependencies must be ignored at any depth so a
29+
// repo-local .venv (whole dependency tree) is never indexed.
30+
func TestDefaultIgnoresPythonEnvs(t *testing.T) {
31+
has := func(want string) bool {
32+
for _, p := range Default().Ignore {
33+
if p == want {
34+
return true
35+
}
36+
}
37+
return false
38+
}
39+
for _, want := range []string{"**/.venv/**", "**/venv/**", "**/site-packages/**"} {
40+
if !has(want) {
41+
t.Errorf("Default().Ignore missing %q", want)
42+
}
43+
}
44+
}

internal/engine/cache.go

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,67 @@ import (
162162
// v77: Ruby extractor detects Gemfile-less repos via a loose .rb/shebang scan and
163163
// indexes extensionless Ruby executables. Bump so snapshots that cached an empty
164164
// (undetected) Ruby result re-extract instead of serving stale zero facts.
165-
const cacheVersion = "v77"
165+
// v78: Python extractor now emits call edges for ABSOLUTE intra-project imports
166+
// (previously only relative imports resolved, so functions reached via
167+
// `from pkg.mod import fn` had no incoming edge and read as dead code). A post-pass
168+
// (resolveCallTargets) rewrites the dotted targets to canonical slash symbol names
169+
// and drops stdlib/third-party edges; the extractor also emits KindFileRef edges for
170+
// module-level (top-level) calls and records decorator applications as uses. Bump so
171+
// cached Python snapshots re-extract with the new edges.
172+
// v79: Python extractor tracks more reference mechanisms — function-local (lazy)
173+
// imports are registered so calls through them resolve; functions/classes passed by
174+
// name as call arguments (Depends(fn), add_command(cmd)) emit reference edges;
175+
// parameter-default and decorator-call-argument expressions are walked (FastAPI
176+
// Depends(...) in signatures and route-decorator dependencies); and click/Typer
177+
// @command/@group functions are tagged cli_command. Reduces Python dead-code false
178+
// positives; bump so cached snapshots re-extract.
179+
// v80: Python extractor tracks three more reference mechanisms — FastAPI route
180+
// decorators declared with the path= keyword (and empty paths) now emit route facts
181+
// (so their handlers are rescued); pyproject.toml entry-points / console-scripts emit
182+
// reference edges to the registered module:function; and dotted-path string literals
183+
// (>=3 identifier segments) that name an internal symbol (lazy_load_command targets,
184+
// provider "class-name" metadata) emit reference edges. Reduces Python dead-code
185+
// false positives; bump so cached snapshots re-extract.
186+
// v81: Python extractor closes three more reference gaps — class-body statements are
187+
// walked for calls/value-refs (attrs/pydantic/SQLAlchemy field wiring like
188+
// factory=_helper(...) and default=Factory(fn)); same-module functions/classes passed
189+
// by name as a value are credited (via a per-module top-level-def index, excluding
190+
// shadowing params); and FastAPI route handlers declared with a non-literal/computed
191+
// path are tagged web_component=route_handler (framework entry points). Reduces Python
192+
// dead-code false positives; bump so cached snapshots re-extract.
193+
// v82: Python extractor closes the last registration gaps — functions decorated with a
194+
// framework-registration decorator (@compiles, @x.register singledispatch, @sig.connect,
195+
// @event.listens_for, Flask hooks) are marked used via a self file-ref edge; value
196+
// references now also resolve attribute args (register_error_handler(404, m.handler))
197+
// and dict/list/set/tuple values (dispatch tables like {"ds": ds_filter}). Reduces
198+
// Python dead-code false positives; bump so cached snapshots re-extract.
199+
// v83: complexity signals gain a new prop scaling_loop_depth (loop nesting counting only
200+
// input-scaling loops — literal/constant/range(<const>) iterables and infinite
201+
// while(true)/for{} event loops are discounted), emitted by the Python, TypeScript, and Go
202+
// extractors; the Python extractor also now emits io_direct/performs_io (transitive
203+
// DB/network/file I/O). Consumed by the enterprise performance analyzer to deflate the
204+
// O(n^k) tail and tighten call-in-loop precision; bump so cached snapshots re-extract.
205+
// v84: complexity signals gain calls_in_scaling_loop — the subset of calls_in_loop made
206+
// inside an input-scaling (unbounded) loop — emitted by the Python, TypeScript, and Go
207+
// extractors. Lets the performance analyzer treat a call in a bounded loop (literal /
208+
// range(<const>) / while(true)) as a fixed count, not an N+1; bump so cached snapshots
209+
// re-extract with the new signal.
210+
// v85: Python extractor emits two new structural props so package-metrics stops
211+
// mislabeling idiomatic-Python packages. (1) `enum` on Enum/IntEnum/StrEnum/Flag/IntFlag
212+
// subclasses (excluded from N like Kotlin enums) and `data_class` on DTO/schema/record
213+
// classes — @dataclass/@attrs-decorated, Pydantic BaseModel, NamedTuple, TypedDict —
214+
// so DTO/model packages (e.g. OpenAPI-generated Pydantic "datamodels") are no longer
215+
// flagged "rigid — extract interfaces". data_class covers Pydantic BaseModel, NamedTuple,
216+
// and TypedDict subclasses plus @dataclass/@attrs-decorated classes. (2) `abstract` now
217+
// also covers the duck-typed abstract pattern (a method whose whole body is
218+
// `raise NotImplementedError`), so abstractness (A) is meaningful for Python base classes
219+
// that don't use ABC. Bump so cached Python snapshots re-extract with the new props.
220+
// v86: Python data_class detection broadened to Pydantic RootModel/GenericModel/BaseSettings
221+
// and any "*BaseModel" subclass — covers project-local Pydantic bases (StrictBaseModel,
222+
// <App>BaseModel) used by hand-written schema packages, which v85 missed because BaseModel
223+
// wasn't a direct base (so those datamodels packages were still flagged "rigid"). Bump so
224+
// snapshots cached under v85 re-extract with the widened detection.
225+
const cacheVersion = "v86"
166226

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

internal/engine/engine_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,34 @@ func TestIsIgnored(t *testing.T) {
3737
[]string{"node_modules/**"},
3838
true,
3939
},
40+
{
41+
// The reported bug: a repo-local .venv held the whole dependency tree
42+
// and was indexed. The **/x/** form must prune the directory itself so
43+
// the walk never descends into it.
44+
"venv dir itself pruned at any depth",
45+
"backend/.venv", true,
46+
[]string{"**/.venv/**"},
47+
true,
48+
},
49+
{
50+
"site-packages under a nested venv",
51+
"backend/.venv/lib/python3.12/site-packages/pandas/core/frame.py", false,
52+
[]string{"**/.venv/**", "**/site-packages/**"},
53+
true,
54+
},
55+
{
56+
"plain venv dir",
57+
"venv/lib/python3.11/site-packages/x.py", false,
58+
[]string{"**/venv/**"},
59+
true,
60+
},
61+
{
62+
// "env" alone must NOT be treated as a venv (too common a name).
63+
"env is not ignored",
64+
"app/env/settings.py", false,
65+
[]string{"**/.venv/**", "**/venv/**", "**/site-packages/**"},
66+
false,
67+
},
4068
{
4169
"git directory",
4270
".git/HEAD", false,
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
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"}}
3-
{"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":"declares","target":"app"}]}
3+
{"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"}]}
44
{"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"}]}

internal/extractors/goextractor/complexity_test.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,3 +178,68 @@ func Simple(a, b bool) bool {
178178
t.Errorf("calls_in_loop should be omitted, got %v", f.Props["calls_in_loop"])
179179
}
180180
}
181+
182+
func TestExtract_ScalingLoopDepth_BoundedDiscounted(t *testing.T) {
183+
ff := extractAll(t, map[string]string{
184+
"pkg/proc.go": `package pkg
185+
186+
func Process(items []int) {
187+
for _, x := range items {
188+
for _, m := range []string{"a", "b"} {
189+
use(x, m)
190+
}
191+
}
192+
}
193+
194+
func Poll() {
195+
for {
196+
tick()
197+
}
198+
}
199+
200+
func use(x int, m string) {}
201+
func tick() {}
202+
`,
203+
})
204+
205+
f, _ := findFact(ff, "pkg.Process")
206+
if got := intProp(t, f, "loop_depth"); got != 2 {
207+
t.Errorf("loop_depth = %d, want 2", got)
208+
}
209+
// Inner range is over a composite literal → bounded → only the outer loop scales.
210+
if got := intProp(t, f, "scaling_loop_depth"); got != 1 {
211+
t.Errorf("scaling_loop_depth = %d, want 1", got)
212+
}
213+
214+
p, _ := findFact(ff, "pkg.Poll")
215+
if got := intProp(t, p, "scaling_loop_depth"); got != 0 {
216+
t.Errorf("infinite for{} scaling_loop_depth = %d, want 0", got)
217+
}
218+
}
219+
220+
func TestExtract_CallsInScalingLoop_BoundedExcluded(t *testing.T) {
221+
ff := extractAll(t, map[string]string{
222+
"pkg/proc.go": `package pkg
223+
224+
func Process(items []int) {
225+
for _, c := range []string{"a", "b"} {
226+
setup(c)
227+
}
228+
for _, x := range items {
229+
consume(x)
230+
}
231+
}
232+
233+
func setup(c string) {}
234+
func consume(x int) {}
235+
`,
236+
})
237+
f, _ := findFact(ff, "pkg.Process")
238+
scaling := strSliceProp(f, "calls_in_scaling_loop")
239+
if !containsStr(scaling, "pkg.consume") {
240+
t.Errorf("calls_in_scaling_loop = %v, want consume (range over slice arg)", scaling)
241+
}
242+
if containsStr(scaling, "pkg.setup") {
243+
t.Errorf("calls_in_scaling_loop = %v, must NOT contain setup (range over composite literal)", scaling)
244+
}
245+
}

internal/extractors/goextractor/go.go

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -316,13 +316,19 @@ func (e *GoExtractor) extractFunc(fset *token.FileSet, fn *ast.FuncDecl, relFile
316316
symbolFact.Props["cyclomatic"] = m.cyclomatic
317317
if m.loopDepth > 0 {
318318
symbolFact.Props["loop_depth"] = m.loopDepth
319+
// Emit the scaling depth (bounded loops discounted) alongside — even when 0 —
320+
// so the consumer distinguishes "all loops bounded" from "signal absent".
321+
symbolFact.Props["scaling_loop_depth"] = m.scalingLoopDepth
319322
}
320323
if m.loopCount > 0 {
321324
symbolFact.Props["loop_count"] = m.loopCount
322325
}
323326
if len(m.callsInLoop) > 0 {
324327
symbolFact.Props["calls_in_loop"] = m.callsInLoop
325328
}
329+
if len(m.callsInScalingLoop) > 0 {
330+
symbolFact.Props["calls_in_scaling_loop"] = m.callsInScalingLoop
331+
}
326332
if m.recursiveSelf {
327333
symbolFact.Props["recursive_self"] = true
328334
}
@@ -416,12 +422,14 @@ type resolveCtx struct {
416422
// bodyMetrics holds the call list and the per-function complexity signals
417423
// derived from a single walk of a function body.
418424
type bodyMetrics struct {
419-
calls []string // resolved call targets, deduped, in source order
420-
callsInLoop []string // subset of calls invoked at loop nesting depth >= 1
421-
loopDepth int // max nesting depth of for/range loops
422-
loopCount int // total number of for/range loops
423-
cyclomatic int // McCabe complexity (1 + decision points)
424-
recursiveSelf bool // body directly calls the enclosing function
425+
calls []string // resolved call targets, deduped, in source order
426+
callsInLoop []string // subset of calls invoked at loop nesting depth >= 1
427+
callsInScalingLoop []string // subset of calls invoked at scaling (unbounded) nesting depth >= 1
428+
loopDepth int // max nesting depth of for/range loops
429+
scalingLoopDepth int // max nesting counting only unbounded (input-scaling) loops
430+
loopCount int // total number of for/range loops
431+
cyclomatic int // McCabe complexity (1 + decision points)
432+
recursiveSelf bool // body directly calls the enclosing function
425433
}
426434

427435
// analyzeBody walks a function body once and extracts both the call edges
@@ -438,7 +446,12 @@ func analyzeBody(body ast.Node, ctx resolveCtx, selfName string) bodyMetrics {
438446
decisions := 0
439447
seen := make(map[string]bool)
440448
inLoopSeen := make(map[string]bool)
449+
inScalingSeen := make(map[string]bool)
441450
var loopEnds []token.Pos // end positions of enclosing loops
451+
// scalingEnds tracks only the enclosing loops that scale with input (bounded loops —
452+
// `for {}` event loops and `range` over a composite literal — are excluded), so
453+
// len(scalingEnds) is the current scaling nesting depth used for Big-O.
454+
var scalingEnds []token.Pos
442455

443456
ast.Inspect(body, func(n ast.Node) bool {
444457
if n == nil {
@@ -448,6 +461,9 @@ func analyzeBody(body ast.Node, ctx resolveCtx, selfName string) bodyMetrics {
448461
for len(loopEnds) > 0 && n.Pos() >= loopEnds[len(loopEnds)-1] {
449462
loopEnds = loopEnds[:len(loopEnds)-1]
450463
}
464+
for len(scalingEnds) > 0 && n.Pos() >= scalingEnds[len(scalingEnds)-1] {
465+
scalingEnds = scalingEnds[:len(scalingEnds)-1]
466+
}
451467
switch x := n.(type) {
452468
case *ast.ForStmt:
453469
m.loopCount++
@@ -456,13 +472,25 @@ func analyzeBody(body ast.Node, ctx resolveCtx, selfName string) bodyMetrics {
456472
if len(loopEnds) > m.loopDepth {
457473
m.loopDepth = len(loopEnds)
458474
}
475+
if !goForBounded(x) {
476+
scalingEnds = append(scalingEnds, x.End())
477+
if len(scalingEnds) > m.scalingLoopDepth {
478+
m.scalingLoopDepth = len(scalingEnds)
479+
}
480+
}
459481
case *ast.RangeStmt:
460482
m.loopCount++
461483
decisions++
462484
loopEnds = append(loopEnds, x.End())
463485
if len(loopEnds) > m.loopDepth {
464486
m.loopDepth = len(loopEnds)
465487
}
488+
if !goRangeBounded(x) {
489+
scalingEnds = append(scalingEnds, x.End())
490+
if len(scalingEnds) > m.scalingLoopDepth {
491+
m.scalingLoopDepth = len(scalingEnds)
492+
}
493+
}
466494
case *ast.IfStmt:
467495
decisions++
468496
case *ast.CaseClause:
@@ -494,6 +522,12 @@ func analyzeBody(body ast.Node, ctx resolveCtx, selfName string) bodyMetrics {
494522
inLoopSeen[resolved] = true
495523
m.callsInLoop = append(m.callsInLoop, resolved)
496524
}
525+
// A call inside an input-scaling loop is an N+1 candidate; a call only ever in
526+
// a bounded loop (`for {}` / range over a composite literal) is not.
527+
if len(scalingEnds) > 0 && !inScalingSeen[resolved] {
528+
inScalingSeen[resolved] = true
529+
m.callsInScalingLoop = append(m.callsInScalingLoop, resolved)
530+
}
497531
if resolved == selfName {
498532
m.recursiveSelf = true
499533
}
@@ -504,6 +538,21 @@ func analyzeBody(body ast.Node, ctx resolveCtx, selfName string) bodyMetrics {
504538
return m
505539
}
506540

541+
// goForBounded reports whether a for-statement runs a fixed number of times regardless
542+
// of input: a bare `for { }` infinite loop is driven by break/return/events, not data
543+
// size, so it does not add a factor of n to Big-O. (A `for i := 0; i < n; i++` with a
544+
// data-derived bound is treated as unbounded — its static bound is not evident here.)
545+
func goForBounded(x *ast.ForStmt) bool {
546+
return x.Cond == nil && x.Init == nil && x.Post == nil
547+
}
548+
549+
// goRangeBounded reports whether a range loop iterates a fixed-size composite literal
550+
// (`for _, x := range []T{a, b, c}` / a map literal) — a constant count, not input-scaling.
551+
func goRangeBounded(x *ast.RangeStmt) bool {
552+
_, ok := x.X.(*ast.CompositeLit)
553+
return ok
554+
}
555+
507556
// flattenSelector converts a (potentially deep) selector chain to a left-to-right
508557
// slice of name segments. Returns nil for non-identifier/non-selector expressions
509558
// (e.g. function-result calls, type assertions, index expressions).

0 commit comments

Comments
 (0)