Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions examples/full.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,17 @@ ignore:
- ".git/**"
- ".enola/**"

# Python virtual environments, installed dependencies, and tool caches
- "**/.venv/**"
- "**/venv/**"
- "**/site-packages/**"
- "**/.tox/**"
- "**/.nox/**"
- "**/__pycache__/**"
- "**/.mypy_cache/**"
- "**/.pytest_cache/**"
- "**/.ruff_cache/**"

# Go tests
- "**/*_test.go"

Expand Down
3 changes: 3 additions & 0 deletions examples/python.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,11 @@ ignore:
- "**/.venv/**"
- "venv/**"
- "**/venv/**"
# Definitive catch for any oddly-named env (conda/direnv/.tox/.nox all nest one).
- "**/site-packages/**"
- ".tox/**"
- "**/.tox/**"
- "**/.nox/**"
- ".mypy_cache/**"
- "**/.mypy_cache/**"
- ".pytest_cache/**"
Expand Down
161 changes: 85 additions & 76 deletions internal/cachecov/coverage_test.go

Large diffs are not rendered by default.

17 changes: 16 additions & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,22 @@ func Default() *Config {
"coverage/**",
".nuxt/**",
".svelte-kit/**",
"__pycache__/**",
"**/__pycache__/**",
// Python virtual environments, installed dependencies, and tool caches.
// A repo-local .venv/venv holds the entire dependency tree (thousands of
// third-party .py files); indexing it is never wanted and dominates
// snapshot time. site-packages is the definitive catch for any oddly-named
// env (.tox/.nox/conda/direnv all nest one). Any-depth (**/x/**) form so
// monorepo sub-project venvs are pruned too.
"**/.venv/**",
"**/venv/**",
"**/site-packages/**",
"**/.tox/**",
"**/.nox/**",
"**/.eggs/**",
"**/.mypy_cache/**",
"**/.pytest_cache/**",
"**/.ruff_cache/**",
"**/Pods/**",
"**/.gradle/**",
// Minified / bundled JS by name. The extractor also detects minified
Expand Down
19 changes: 19 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,22 @@ func TestDefaultIgnoresNestedBuildAndPods(t *testing.T) {
t.Error("Default().Ignore still has top-level-only \"build/**\"; want \"**/build/**\"")
}
}

// TestDefaultIgnoresPythonEnvs locks in the .venv fix: Python virtual
// environments and installed dependencies must be ignored at any depth so a
// repo-local .venv (whole dependency tree) is never indexed.
func TestDefaultIgnoresPythonEnvs(t *testing.T) {
has := func(want string) bool {
for _, p := range Default().Ignore {
if p == want {
return true
}
}
return false
}
for _, want := range []string{"**/.venv/**", "**/venv/**", "**/site-packages/**"} {
if !has(want) {
t.Errorf("Default().Ignore missing %q", want)
}
}
}
62 changes: 61 additions & 1 deletion internal/engine/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,67 @@ import (
// v77: Ruby extractor detects Gemfile-less repos via a loose .rb/shebang scan and
// indexes extensionless Ruby executables. Bump so snapshots that cached an empty
// (undetected) Ruby result re-extract instead of serving stale zero facts.
const cacheVersion = "v77"
// v78: Python extractor now emits call edges for ABSOLUTE intra-project imports
// (previously only relative imports resolved, so functions reached via
// `from pkg.mod import fn` had no incoming edge and read as dead code). A post-pass
// (resolveCallTargets) rewrites the dotted targets to canonical slash symbol names
// and drops stdlib/third-party edges; the extractor also emits KindFileRef edges for
// module-level (top-level) calls and records decorator applications as uses. Bump so
// cached Python snapshots re-extract with the new edges.
// v79: Python extractor tracks more reference mechanisms — function-local (lazy)
// imports are registered so calls through them resolve; functions/classes passed by
// name as call arguments (Depends(fn), add_command(cmd)) emit reference edges;
// parameter-default and decorator-call-argument expressions are walked (FastAPI
// Depends(...) in signatures and route-decorator dependencies); and click/Typer
// @command/@group functions are tagged cli_command. Reduces Python dead-code false
// positives; bump so cached snapshots re-extract.
// v80: Python extractor tracks three more reference mechanisms — FastAPI route
// decorators declared with the path= keyword (and empty paths) now emit route facts
// (so their handlers are rescued); pyproject.toml entry-points / console-scripts emit
// reference edges to the registered module:function; and dotted-path string literals
// (>=3 identifier segments) that name an internal symbol (lazy_load_command targets,
// provider "class-name" metadata) emit reference edges. Reduces Python dead-code
// false positives; bump so cached snapshots re-extract.
// v81: Python extractor closes three more reference gaps — class-body statements are
// walked for calls/value-refs (attrs/pydantic/SQLAlchemy field wiring like
// factory=_helper(...) and default=Factory(fn)); same-module functions/classes passed
// by name as a value are credited (via a per-module top-level-def index, excluding
// shadowing params); and FastAPI route handlers declared with a non-literal/computed
// path are tagged web_component=route_handler (framework entry points). Reduces Python
// dead-code false positives; bump so cached snapshots re-extract.
// v82: Python extractor closes the last registration gaps — functions decorated with a
// framework-registration decorator (@compiles, @x.register singledispatch, @sig.connect,
// @event.listens_for, Flask hooks) are marked used via a self file-ref edge; value
// references now also resolve attribute args (register_error_handler(404, m.handler))
// and dict/list/set/tuple values (dispatch tables like {"ds": ds_filter}). Reduces
// Python dead-code false positives; bump so cached snapshots re-extract.
// v83: complexity signals gain a new prop scaling_loop_depth (loop nesting counting only
// input-scaling loops — literal/constant/range(<const>) iterables and infinite
// while(true)/for{} event loops are discounted), emitted by the Python, TypeScript, and Go
// extractors; the Python extractor also now emits io_direct/performs_io (transitive
// DB/network/file I/O). Consumed by the enterprise performance analyzer to deflate the
// O(n^k) tail and tighten call-in-loop precision; bump so cached snapshots re-extract.
// v84: complexity signals gain calls_in_scaling_loop — the subset of calls_in_loop made
// inside an input-scaling (unbounded) loop — emitted by the Python, TypeScript, and Go
// extractors. Lets the performance analyzer treat a call in a bounded loop (literal /
// range(<const>) / while(true)) as a fixed count, not an N+1; bump so cached snapshots
// re-extract with the new signal.
// v85: Python extractor emits two new structural props so package-metrics stops
// mislabeling idiomatic-Python packages. (1) `enum` on Enum/IntEnum/StrEnum/Flag/IntFlag
// subclasses (excluded from N like Kotlin enums) and `data_class` on DTO/schema/record
// classes — @dataclass/@attrs-decorated, Pydantic BaseModel, NamedTuple, TypedDict —
// so DTO/model packages (e.g. OpenAPI-generated Pydantic "datamodels") are no longer
// flagged "rigid — extract interfaces". data_class covers Pydantic BaseModel, NamedTuple,
// and TypedDict subclasses plus @dataclass/@attrs-decorated classes. (2) `abstract` now
// also covers the duck-typed abstract pattern (a method whose whole body is
// `raise NotImplementedError`), so abstractness (A) is meaningful for Python base classes
// that don't use ABC. Bump so cached Python snapshots re-extract with the new props.
// v86: Python data_class detection broadened to Pydantic RootModel/GenericModel/BaseSettings
// and any "*BaseModel" subclass — covers project-local Pydantic bases (StrictBaseModel,
// <App>BaseModel) used by hand-written schema packages, which v85 missed because BaseModel
// wasn't a direct base (so those datamodels packages were still flagged "rigid"). Bump so
// snapshots cached under v85 re-extract with the widened detection.
const cacheVersion = "v86"

// extractorCache holds per-extractor facts keyed by a content hash of the files
// the extractor depends on. It is loaded from disk at the start of a snapshot and
Expand Down
28 changes: 28 additions & 0 deletions internal/engine/engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,34 @@ func TestIsIgnored(t *testing.T) {
[]string{"node_modules/**"},
true,
},
{
// The reported bug: a repo-local .venv held the whole dependency tree
// and was indexed. The **/x/** form must prune the directory itself so
// the walk never descends into it.
"venv dir itself pruned at any depth",
"backend/.venv", true,
[]string{"**/.venv/**"},
true,
},
{
"site-packages under a nested venv",
"backend/.venv/lib/python3.12/site-packages/pandas/core/frame.py", false,
[]string{"**/.venv/**", "**/site-packages/**"},
true,
},
{
"plain venv dir",
"venv/lib/python3.11/site-packages/x.py", false,
[]string{"**/venv/**"},
true,
},
{
// "env" alone must NOT be treated as a venv (too common a name).
"env is not ignored",
"app/env/settings.py", false,
[]string{"**/.venv/**", "**/venv/**", "**/site-packages/**"},
false,
},
{
"git directory",
".git/HEAD", false,
Expand Down
2 changes: 1 addition & 1 deletion internal/engine/testdata/golden/python_sample.facts.jsonl
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{"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"}]}
{"kind":"module","name":"app","file":"app","repo":"python_sample","props":{"language":"python"}}
{"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"}]}
{"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"}]}
{"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"}]}
65 changes: 65 additions & 0 deletions internal/extractors/goextractor/complexity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,3 +178,68 @@ func Simple(a, b bool) bool {
t.Errorf("calls_in_loop should be omitted, got %v", f.Props["calls_in_loop"])
}
}

func TestExtract_ScalingLoopDepth_BoundedDiscounted(t *testing.T) {
ff := extractAll(t, map[string]string{
"pkg/proc.go": `package pkg

func Process(items []int) {
for _, x := range items {
for _, m := range []string{"a", "b"} {
use(x, m)
}
}
}

func Poll() {
for {
tick()
}
}

func use(x int, m string) {}
func tick() {}
`,
})

f, _ := findFact(ff, "pkg.Process")
if got := intProp(t, f, "loop_depth"); got != 2 {
t.Errorf("loop_depth = %d, want 2", got)
}
// Inner range is over a composite literal → bounded → only the outer loop scales.
if got := intProp(t, f, "scaling_loop_depth"); got != 1 {
t.Errorf("scaling_loop_depth = %d, want 1", got)
}

p, _ := findFact(ff, "pkg.Poll")
if got := intProp(t, p, "scaling_loop_depth"); got != 0 {
t.Errorf("infinite for{} scaling_loop_depth = %d, want 0", got)
}
}

func TestExtract_CallsInScalingLoop_BoundedExcluded(t *testing.T) {
ff := extractAll(t, map[string]string{
"pkg/proc.go": `package pkg

func Process(items []int) {
for _, c := range []string{"a", "b"} {
setup(c)
}
for _, x := range items {
consume(x)
}
}

func setup(c string) {}
func consume(x int) {}
`,
})
f, _ := findFact(ff, "pkg.Process")
scaling := strSliceProp(f, "calls_in_scaling_loop")
if !containsStr(scaling, "pkg.consume") {
t.Errorf("calls_in_scaling_loop = %v, want consume (range over slice arg)", scaling)
}
if containsStr(scaling, "pkg.setup") {
t.Errorf("calls_in_scaling_loop = %v, must NOT contain setup (range over composite literal)", scaling)
}
}
61 changes: 55 additions & 6 deletions internal/extractors/goextractor/go.go
Original file line number Diff line number Diff line change
Expand Up @@ -316,13 +316,19 @@ func (e *GoExtractor) extractFunc(fset *token.FileSet, fn *ast.FuncDecl, relFile
symbolFact.Props["cyclomatic"] = m.cyclomatic
if m.loopDepth > 0 {
symbolFact.Props["loop_depth"] = m.loopDepth
// Emit the scaling depth (bounded loops discounted) alongside — even when 0 —
// so the consumer distinguishes "all loops bounded" from "signal absent".
symbolFact.Props["scaling_loop_depth"] = m.scalingLoopDepth
}
if m.loopCount > 0 {
symbolFact.Props["loop_count"] = m.loopCount
}
if len(m.callsInLoop) > 0 {
symbolFact.Props["calls_in_loop"] = m.callsInLoop
}
if len(m.callsInScalingLoop) > 0 {
symbolFact.Props["calls_in_scaling_loop"] = m.callsInScalingLoop
}
if m.recursiveSelf {
symbolFact.Props["recursive_self"] = true
}
Expand Down Expand Up @@ -416,12 +422,14 @@ type resolveCtx struct {
// bodyMetrics holds the call list and the per-function complexity signals
// derived from a single walk of a function body.
type bodyMetrics struct {
calls []string // resolved call targets, deduped, in source order
callsInLoop []string // subset of calls invoked at loop nesting depth >= 1
loopDepth int // max nesting depth of for/range loops
loopCount int // total number of for/range loops
cyclomatic int // McCabe complexity (1 + decision points)
recursiveSelf bool // body directly calls the enclosing function
calls []string // resolved call targets, deduped, in source order
callsInLoop []string // subset of calls invoked at loop nesting depth >= 1
callsInScalingLoop []string // subset of calls invoked at scaling (unbounded) nesting depth >= 1
loopDepth int // max nesting depth of for/range loops
scalingLoopDepth int // max nesting counting only unbounded (input-scaling) loops
loopCount int // total number of for/range loops
cyclomatic int // McCabe complexity (1 + decision points)
recursiveSelf bool // body directly calls the enclosing function
}

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

ast.Inspect(body, func(n ast.Node) bool {
if n == nil {
Expand All @@ -448,6 +461,9 @@ func analyzeBody(body ast.Node, ctx resolveCtx, selfName string) bodyMetrics {
for len(loopEnds) > 0 && n.Pos() >= loopEnds[len(loopEnds)-1] {
loopEnds = loopEnds[:len(loopEnds)-1]
}
for len(scalingEnds) > 0 && n.Pos() >= scalingEnds[len(scalingEnds)-1] {
scalingEnds = scalingEnds[:len(scalingEnds)-1]
}
switch x := n.(type) {
case *ast.ForStmt:
m.loopCount++
Expand All @@ -456,13 +472,25 @@ func analyzeBody(body ast.Node, ctx resolveCtx, selfName string) bodyMetrics {
if len(loopEnds) > m.loopDepth {
m.loopDepth = len(loopEnds)
}
if !goForBounded(x) {
scalingEnds = append(scalingEnds, x.End())
if len(scalingEnds) > m.scalingLoopDepth {
m.scalingLoopDepth = len(scalingEnds)
}
}
case *ast.RangeStmt:
m.loopCount++
decisions++
loopEnds = append(loopEnds, x.End())
if len(loopEnds) > m.loopDepth {
m.loopDepth = len(loopEnds)
}
if !goRangeBounded(x) {
scalingEnds = append(scalingEnds, x.End())
if len(scalingEnds) > m.scalingLoopDepth {
m.scalingLoopDepth = len(scalingEnds)
}
}
case *ast.IfStmt:
decisions++
case *ast.CaseClause:
Expand Down Expand Up @@ -494,6 +522,12 @@ func analyzeBody(body ast.Node, ctx resolveCtx, selfName string) bodyMetrics {
inLoopSeen[resolved] = true
m.callsInLoop = append(m.callsInLoop, resolved)
}
// A call inside an input-scaling loop is an N+1 candidate; a call only ever in
// a bounded loop (`for {}` / range over a composite literal) is not.
if len(scalingEnds) > 0 && !inScalingSeen[resolved] {
inScalingSeen[resolved] = true
m.callsInScalingLoop = append(m.callsInScalingLoop, resolved)
}
if resolved == selfName {
m.recursiveSelf = true
}
Expand All @@ -504,6 +538,21 @@ func analyzeBody(body ast.Node, ctx resolveCtx, selfName string) bodyMetrics {
return m
}

// goForBounded reports whether a for-statement runs a fixed number of times regardless
// of input: a bare `for { }` infinite loop is driven by break/return/events, not data
// size, so it does not add a factor of n to Big-O. (A `for i := 0; i < n; i++` with a
// data-derived bound is treated as unbounded — its static bound is not evident here.)
func goForBounded(x *ast.ForStmt) bool {
return x.Cond == nil && x.Init == nil && x.Post == nil
}

// goRangeBounded reports whether a range loop iterates a fixed-size composite literal
// (`for _, x := range []T{a, b, c}` / a map literal) — a constant count, not input-scaling.
func goRangeBounded(x *ast.RangeStmt) bool {
_, ok := x.X.(*ast.CompositeLit)
return ok
}

// flattenSelector converts a (potentially deep) selector chain to a left-to-right
// slice of name segments. Returns nil for non-identifier/non-selector expressions
// (e.g. function-result calls, type assertions, index expressions).
Expand Down
Loading
Loading