
Complexity Optimizer
- 1 installs
- 191 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
Scans a codebase for algorithmic complexity hotspots (nested loops, N+1 queries, sort-in-loop) and reports ranked findings, optionally applying safe optimizations.
About
Runs an AST/regex scanner plus manual inspection to rank performance hotspots by impact, then proposes or implements optimizations only after tests confirm behavior is preserved. A developer uses it to audit a repo for O(n^2) patterns or reduce complexity without breaking outputs.
- Python AST analysis plus regex heuristics for 20+ other languages
- Report-only by default; edits only on explicit request with rollback via git restore
Complexity Optimizer by the numbers
- 1 all-time installs (skills.sh)
- Ranked #982 of 1,352 Code Review & Quality skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pproenca/dot-skills --skill complexity-optimizerAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 191 |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
What it does
Scans a codebase for algorithmic complexity hotspots (nested loops, N+1 queries, sort-in-loop) and reports ranked findings, optionally applying safe optimizations.
Files
Complexity Optimizer
Find algorithmic complexity hotspots in a codebase and produce a structured report. Optionally implement low-risk optimizations after explicit consent.
When to Apply
Use this skill when the user asks to:
- Analyze, audit, scan, or review a codebase for performance hotspots or algorithmic complexity
- Find inefficient loops, nested iteration, N+1 queries, sort-in-loop, render-path recomputation
- Reduce complexity (e.g. O(n^2) → O(n log n) or O(n))
- "Give me a report" on a codebase's complexity profile
Do not use this skill for:
- Micro-optimizations on cold code paths
- Memory tuning (this skill targets time complexity, not allocation profiles)
- Code style refactoring unrelated to complexity
Workflow Overview
Baseline → Rank → Prove behavior → Optimize (opt-in) → Verify
↓ ↓ ↓
scanner prioritize hot rollback if
+ manual paths & large I/O tests regress| Step | Action | Tool | Risk |
|---|---|---|---|
| 1 | Establish baseline: detect stack, test command, hot paths | scripts/analyze_complexity.py + manual inspection | read-only |
| 2 | Rank opportunities by impact, separating algorithmic wins from constant-factor cleanup | reasoning | read-only |
| 3 | Locate or add tests covering the function/component | Read + test framework | read-only |
| 4 | Apply optimization (ONLY when user explicitly requests) | Edit/Write | destructive |
| 5 | Run tests, lint, type-check, and a benchmark when warranted; report before/after complexity | Bash test commands | read-only |
Core Rule
Optimize only when current behavior is understood and can be preserved. Prefer a small, proven improvement with tests over a broad rewrite with unclear correctness.
Default Behavior
When the user asks to analyze, scan, audit, review, or "give me a report" for a codebase, produce the full complexity report automatically. Do not require the user to specify report fields.
Default report contents (see references/report-template.md):
- Scope analyzed and detected stack/test commands
- Top findings ranked by likely impact
- File and line for each finding
- Current pattern and why it may be costly
- Estimated current complexity
- Recommended change and estimated complexity after
- Risk level
- Tests, benchmarks, or manual checks needed
- Clear statement that no files were modified, unless the user explicitly requested implementation
Only edit files when the user uses an explicit edit verb: implement, fix, optimize, apply, change, refactor. If the request is analysis-only or a report-only request, do not modify files.
Workflow Detail
1. Baseline
- Identify language, framework, test command, build command, and performance-sensitive paths.
- Inspect existing tests before touching code.
- Run
python3 scripts/analyze_complexity.py <repo>for a first-pass hotspot list when scanning a repository.
2. Rank
- Prioritize hot paths, large-input paths, rendering loops, database/API loops, shared utilities.
- Separate algorithmic complexity from constant-factor cleanup.
- Treat scanner output as leads, not proof.
- For report-only requests, inspect enough surrounding code to estimate current and proposed complexity. Do not stop at raw scanner output.
3. Prove behavior
- Locate or add focused tests for the function/component being changed.
- Capture edge cases: empty input, duplicates, ordering stability, null/missing values, errors, permissions, pagination, time zones, mutation side effects.
- If tests are absent and behavior is ambiguous, make the smallest possible refactor or ask for expected behavior before changing semantics.
4. Optimize (only on explicit request)
- Replace repeated linear lookup with maps/sets when key equality is stable.
- Replace nested scans with indexing, grouping, two-pointer scans, sweep-line logic, binary search, memoization, batching, or precomputation — only when the data shape supports it.
- In UI code, reduce unnecessary renders with stable props, memoized derived data, virtualization, debounced work, and moving expensive work out of render paths.
- In data access code, remove N+1 with bulk fetches, joins, preloading, caching, or batching while preserving authorization and filtering.
5. Verify
- Run relevant tests and type/lint/build commands.
- Add a micro-benchmark or measurement when the complexity improvement is non-obvious or performance-critical.
- Report original complexity, new complexity, files changed, tests run, and any residual risk.
First-Pass Scanner
python3 scripts/analyze_complexity.py /path/to/repo --format markdown
python3 scripts/analyze_complexity.py /path/to/repo --format json
python3 scripts/analyze_complexity.py /path/to/repo --changed-only --base origin/main--changed-only restricts the scan to files changed vs --base (default HEAD~1). Use it for PR-focused complexity review.
Language depth:
- Python (
.py) — AST-based analysis. High precision: nested loops, sort/membership in loops, query/I/O in loops, all tracked per-function via the Pythonastmodule. - All other supported languages (
.js,.ts,.jsx,.tsx,.java,.go,.rb,.php,.cs,.c,.cpp,.swift,.vue,.svelte,.kt,.rs,.dart,.scala) — regex-based pattern matching with indent + function-boundary heuristics. Treat findings as leads; verify by reading the surrounding code before recommending fixes.
If the scanner reports nothing, still inspect known hot paths manually. Rendering churn, database query patterns, and framework lifecycle issues often need repository-specific context the scanner cannot see.
Exit codes: 0 = scanned successfully, 2 = bad input (non-existent path / file instead of directory / git error), 3 = zero files matched, 130 = interrupted.
Triage before reporting: consult references/false-positives.md to dismiss known noise patterns (single-call predicates, Redux selectors, SQL-builder fluent methods, render-derived work on small static arrays) before recommending fixes.
Testing the scanner: python3 scripts/test_analyze_complexity.py runs 13 regression tests that pin the false-positive fixes. Run after modifying the scanner.
Optimization Safety Checklist
Before editing:
- Confirm the data sizes are large enough for complexity to matter.
- Confirm the optimization preserves output ordering where callers may rely on it.
- Confirm object identity, mutability, and reference sharing are not part of public behavior.
- Confirm caches have a valid invalidation strategy.
- Confirm deduplication does not collapse distinct records that share a display label.
- Confirm database batching preserves tenant, permission, soft-delete, pagination, and sorting constraints.
After editing:
- Run the narrowest relevant test first, then the broader build/lint/typecheck.
- Compare before/after benchmark numbers when a benchmark exists or was added.
- Keep the patch localized — avoid formatting churn in unrelated files.
Rollback
If the optimization breaks a test or changes observable behavior:
1. Revert the changed file(s) immediately:
git restore <file> # restore a single file
git restore -SW <file> # restore both index and working treeIf multiple files were modified: git restore -SW . (only within the affected directory).
2. Re-run the failing test to confirm restoration.
3. Report the failure to the user with:
- The exact test/assertion that failed
- The semantics that diverged (ordering, mutation, key equality, etc.)
- Whether to retry with a different transformation, or stay with the original code
4. Do not re-attempt the same optimization with a small tweak. If a transformation breaks behavior, either the data shape doesn't support it, or there's an unstated invariant — re-read the code before trying again.
References
references/optimization-playbook.md— common O(n^2) → O(n log n) / O(n) transformations, framework-specific patterns, and "What Not To Do".references/report-template.md— structure for the final analysis or audit output.references/false-positives.md— catalog of scanner findings that look real but aren't. Consult before recommending fixes.scripts/_sections.md— scanner invocation, flags, exit codes, and limitations.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "echo 'complexity-optimizer: file modified. Run the project test command for the changed module before reporting success. If tests fail, revert with: git restore <file>'",
"timeout": 2
}
]
}
]
}
}
{
"version": "0.3.0",
"organization": "Community",
"technology": "polyglot static analysis",
"discipline": "composition",
"type": "quality",
"date": "May 2026",
"abstract": "Analyze a codebase for algorithmic complexity hotspots (nested loops, sort-in-loop, repeated membership checks, N+1 I/O, render-path recomputation) and propose safe optimizations that preserve behavior. Bundles a heuristic scanner (Python AST for .py, regex-based for JS/TS/JSX/TSX/Java/Go/Ruby/PHP/C#/C/C++/Swift/Vue/Svelte/Kotlin/Rust/Dart/Scala) plus a methodology that requires test coverage, an Optimization Safety Checklist, and explicit user consent before editing files. Report-only by default; destructive only when the user explicitly asks to implement, fix, apply, or refactor.",
"references": [
"https://en.wikipedia.org/wiki/Big_O_notation",
"https://docs.python.org/3/library/ast.html"
]
}
Common Scanner False Positives
Use this catalog to triage scanner output before recommending fixes. The scanner is intentionally conservative on the Python side (AST) and heuristic on every other language (regex + indent tracking). The patterns below produce findings that look suspicious but are not real complexity hotspots — dismiss them after a quick read.
Iteration-shaped patterns that aren't loops
Single predicate call
const selected = users.find(u => u.id === id); // not a loop — one-pass O(n)
const ok = items.some(i => i.valid); // not a loop — short-circuits O(n)
const total = nums.reduce((a, b) => a + b, 0); // not a loop — single sweep O(n)These were removed from LOOP_RE in v0.3.0, but legacy reports may still mention them. A single call to find/findIndex/some/every/reduce is O(n) — the only concern is when it is wrapped inside another loop, which the scanner already catches via the forEach/map/filter/for/while parent.
.map() outside a render path
function normalizeIds(items: Item[]) {
return items.map(i => i.id); // pure data transform — not a render hotspot
}render-derived-work is only relevant when the transform runs every render cycle and the input list is large. Outside a UI component, .map() is just the right tool.
Render-path heuristic noise
Constants adjacent to utility code
const MAX_RETRIES = 5; // SCREAMING_SNAKE — fixed in v0.2.0
const HttpStatus = { OK: 200 }; // PascalCase namespace, not a component
export function pickActive(items) {
return items.filter(i => i.active);
}RENDER_HINT_RE requires a lowercase letter in the second position, so MAX_RETRIES and HTTP_STATUS no longer trigger render-path mode. If you still see render-derived-work near a constant declaration, check whether a true component definition is also in scope.
Type aliases / interfaces
type UserId = string;
interface User { id: UserId; name: string }
export function pickFirst(users: User[]) {
return users[0];
}type/interface lines do not match the render hint pattern. If a finding lands on these, treat it as a stale leak from an earlier function in the same file.
I/O-in-loop noise
Redux / Zustand selectors
const ids = useSelector(selectActiveIds);
ids.map(id => useSelector(selectUserById, id)); // not a DB query — Redux selectorRedux selectors are pure functions over the store; calling them in a loop is not N+1. The scanner no longer flags select* calls. If you see one, it's likely from Array.prototype.find() being conflated — check the actual call shape.
SQL builder DSLs
const q = db.select('*').from('users').where('active', true);Method-chain SQL builders use select() and where() as fluent-API methods, not as the actual query execution. These were removed from QUERY_IN_LOOP_RE in v0.3.0. The real cost lives on .exec(), .execute() (when terminal), or the framework-specific method (prisma.user.findMany, knex(...).then(...)).
React Testing Library
const items = await screen.findAllByRole('listitem');
for (const item of items) {
expect(item).toBeInTheDocument();
}screen.findBy* is a query against the rendered DOM, not a database. It is also typically called once. Do not flag.
Sort-in-loop noise
Sort outside the loop
const sorted = [...items].sort((a, b) => a.id - b.id);
for (const item of sorted) {
process(item);
}The sort happens once, before the loop. The scanner uses an indent + function-boundary heuristic; if a sort() call sits at the same indent as the surrounding for, but lexically above it, the scanner can mis-place it. Inspect the actual line numbers.
Stable user-defined comparator
items.sort((a, b) => priorityOrder[a.kind] - priorityOrder[b.kind]);If priorityOrder is a stable lookup map, this is O(n log n) once and not the hot path. Do not flag unless the surrounding loop calls sort() repeatedly.
When to override the scanner
The scanner emits HIGH severity for "structural" hotspots (nested-loop, nested-or-callback-loop, sort-in-loop, io-or-query-in-loop). Even at HIGH, ALL of the following must hold before recommending a fix:
1. The data size is large enough for complexity to matter (>~10³ for O(n²) → O(n)). 2. The path is hot (called on a per-request, per-render, or per-event basis). 3. The transformation preserves ordering, mutability, identity, and authorization (see Optimization Safety Checklist in SKILL.md).
If a finding fails any of these tests, dismiss it in the report with a one-line explanation rather than recommending a change.
Reporting dismissals
In a report, label dismissed findings as:
## DISMISSED nested-or-callback-loop
- Location: `Component.tsx:10`
- Reason: `.map()` over a small static array (length ≤ 5). Not a hotspot.This keeps the audit trail honest — readers can see the scanner output AND the judgment applied to it.
Optimization Playbook
Common Transformations
Nested lookup loops
Symptom: for each item in A, scan all of B to find a match.
Preferred fix: build a map from B once, then perform O(1) lookups.
Complexity: O(a*b) to O(a+b).
Correctness checks:
- Are duplicate keys possible?
- Does the original code pick first match, last match, or all matches?
- Is ordering observable?
- Is key normalization required?
Repeated membership checks
Symptom: items.includes(x), x in list, array.indexOf(x), or equivalent inside a loop.
Preferred fix: convert the membership collection to a set once.
Complexity: O(n*m) to O(n+m).
Correctness checks:
- Does equality change after conversion? JavaScript object identity and Python hashability matter.
- Are values normalized the same way?
Sorting inside loops
Symptom: sorting the same or growing collection repeatedly.
Preferred fix: sort once outside the loop, maintain a heap, or use binary insertion/search.
Complexity: often O(n^2 log n) to O(n log n), or O(n log k) with a heap.
Correctness checks:
- Is each intermediate sorted state externally observed?
- Does the comparator depend on loop-local state?
Pairwise comparisons
Symptom: compare every pair to find overlaps, nearest values, conflicts, or ranges.
Preferred fixes:
- Sort + two pointers for pair/range matching.
- Sweep line for interval overlaps.
- Spatial/hash bucketing for local-neighborhood checks.
- Union-find for connectivity.
Complexity: commonly O(n^2) to O(n log n) or O(n alpha(n)).
Recomputing derived data in render paths
Symptom: filters, sorts, grouping, or expensive transforms run during every render.
Preferred fixes:
- Memoize derived values with correct dependencies.
- Move derivation to selectors, loaders, or server-side preparation.
- Virtualize long lists.
- Stabilize callbacks and object props only when child renders are measurably affected.
Correctness checks:
- Dependency arrays must include every semantic input.
- Memoization must not hide mutations of mutable input objects.
N+1 database or API calls
Symptom: a query or request inside a loop.
Preferred fixes:
- Bulk fetch by IDs and join in memory.
- Use joins, includes/preloads, dataloaders, or batched API endpoints.
- Preserve filtering, authorization, tenancy, ordering, pagination, and error behavior.
Correctness checks:
- Do not fetch records the previous per-item logic would not authorize.
- Preserve missing-record behavior.
- Preserve rate-limit and retry semantics.
What Not To Do
- Do not replace clear linear code with complex structures when input sizes are tiny or the path is cold.
- Do not cache without invalidation.
- Do not use JSON serialization as a general-purpose key unless the key format is stable and collision-safe for the domain.
- Do not change public ordering unless tests and callers prove it is irrelevant.
- Do not trade O(n) for O(n log n) unless it removes a larger bottleneck or enables batching.
Report Template
Use this structure by default when the user asks for a complexity analysis, audit, scan, review, or report. Do not wait for the user to ask for these fields.
Summary
- Scope analyzed:
- Stack detected:
- Test/build commands detected:
- Highest-impact hotspot:
- Patch status: proposed / implemented / blocked
- Files modified: yes / no
Findings
For each finding:
- Location:
- Current pattern:
- Estimated current complexity:
- Recommended change:
- Estimated complexity after:
- Why behavior should remain equivalent:
- Risk level:
- Tests or measurements needed:
Changes Made
- Files changed:
- Main algorithmic change:
- Complexity before:
- Complexity after:
Verification
- Tests run:
- Build/type/lint run:
- Benchmark or measurement:
- Residual risk:
Scripts
| Script | Purpose | Risk |
|---|---|---|
analyze_complexity.py | Heuristic complexity-hotspot scanner. AST-based for Python; regex-based pattern matching for JS/TS/JSX/TSX/Java/Go/Ruby/PHP/C#/C/C++/Swift/Vue/Svelte/Kotlin/Rust/Dart/Scala. | read-only |
test_analyze_complexity.py | 13 regression tests pinning the scanner's false-positive fixes (nested-loop detection, cross-function isolation, SCREAMING_SNAKE handling, exit codes, --changed-only). | read-only |
analyze_complexity.py
Invocation:
python3 scripts/analyze_complexity.py <repo-or-dir> [flags]Flags:
| Flag | Default | Purpose |
|---|---|---|
| `--format markdown\ | json` | markdown |
--exclude DIR | (repeatable) | Skip directories with this name anywhere in the tree. |
--max-findings N | 80 | Cap reported findings (sorted by severity then path). |
--changed-only | off | Scan only files changed vs --base. Requires a git repo at root. |
--base REF | HEAD~1 | Git ref to diff against when --changed-only is set. Examples: origin/main, HEAD, HEAD~5. |
Exit codes:
| Code | Meaning |
|---|---|
| 0 | Scan completed (zero or more findings reported; --changed-only with no matching files also returns 0) |
| 2 | Bad input: path does not exist, path is a file (not directory), or git diff failed |
| 3 | Scanned 0 files — check path / extensions / --exclude flags |
| 130 | Interrupted (Ctrl-C) |
Output: markdown (default) or JSON. Markdown ends with _Scanned N files (M skipped)._ JSON output shape:
{
"files_scanned": 42,
"files_failed": 0,
"findings": [{ "path": "...", "line": 5, "severity": "high", "kind": "nested-loop", "message": "...", "suggestion": "..." }]
}Heuristics intentionally lean on false negatives over false positives. The scanner is correct enough to surface true hotspots and conservative enough to avoid drowning the model in noise. Always read the surrounding code before recommending a fix — consult references/false-positives.md for known noise patterns.
Limitations:
- Only Python gets real AST analysis. All other languages use line-based regex matching: nesting tracked by indent + function-boundary heuristic.
- Templated languages (
.vue,.svelte) are scanned across the full file, including non-script sections — false positives possible inside<template>blocks. --excludematches bare directory names, not paths. For monorepo-scoped runs, pointrootat the package subdirectory or use--changed-only.
test_analyze_complexity.py
Invocation:
python3 scripts/test_analyze_complexity.pyPrints one ✓ or ✗ per case, returns exit 0 on success. Uses stdlib only (subprocess, tempfile, pathlib). The --changed-only test creates a throwaway git repo via subprocess.run(["git", "init"]) — skipped if git is not on PATH.
#!/usr/bin/env python3
"""Heuristic complexity hotspot scanner for mixed-language repositories."""
from __future__ import annotations
import argparse
import ast
import json
import os
import re
import subprocess
import sys
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Iterable
DEFAULT_EXCLUDES = {
".git",
".hg",
".svn",
"node_modules",
"vendor",
"dist",
"build",
".next",
".nuxt",
"coverage",
"__pycache__",
".venv",
"venv",
"target",
".turbo",
}
TEXT_EXTENSIONS = {
".py",
".js",
".jsx",
".ts",
".tsx",
".mjs",
".cjs",
".java",
".go",
".rb",
".php",
".cs",
".cpp",
".cc",
".c",
".h",
".hpp",
".swift",
".vue",
".svelte",
".kt",
".kts",
".rs",
".dart",
".scala",
}
# Iteration constructs only — single-call predicate methods (find, findIndex, some, every, reduce)
# are O(n) one-pass operations, not loops in the complexity sense.
LOOP_RE = re.compile(r"\b(for|while|forEach|map|filter)\b")
MEMBERSHIP_RE = re.compile(
r"(\.includes\s*\(|\.indexOf\s*\(|\bin_array\s*\(|\bcontains\s*\()"
)
SORT_RE = re.compile(r"(\.sort\s*\(|\bsorted\s*\(|\bsort\s*\()")
# Distinctive I/O / ORM method names only. Dropped `query|execute|select|where` —
# they collide with Redux selectors (selectFoo), React Testing Library (`screen.findBy`),
# and SQL-builder DSLs used outside loops.
QUERY_IN_LOOP_RE = re.compile(
r"\b(fetch\s*\(|axios\.|request\s*\(|findMany\s*\(|findOne\s*\(|findUnique\s*\(|prisma\.|knex\.|\.exec\s*\()"
)
# Require a lowercase letter in the second position so SCREAMING_SNAKE constants
# (MAX_RETRIES, API_URL) do not trigger render-path detection.
RENDER_HINT_RE = re.compile(
r"\b(function\s+[A-Z][a-z][A-Za-z0-9_]*|const\s+[A-Z][a-z][A-Za-z0-9_]*\s*=|export\s+default\s+function\s+[A-Z][a-z])"
)
FN_BOUNDARY_RE = re.compile(
r"^\s*(export\s+)?(async\s+)?(function|const|let|var|class|def|public|private|protected|fn)\b|=>\s*\{?\s*$"
)
@dataclass
class Finding:
path: str
line: int
severity: str
kind: str
message: str
suggestion: str
def iter_files(root: Path, excludes: set[str]) -> Iterable[Path]:
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames if d not in excludes]
for filename in filenames:
path = Path(dirpath) / filename
if path.suffix in TEXT_EXTENSIONS:
yield path
def git_changed_files(root: Path, base: str) -> list[Path] | None:
"""Return files changed vs `base` (e.g. 'HEAD~1', 'origin/main'). None if not a git repo."""
try:
result = subprocess.run(
["git", "-C", str(root), "diff", "--name-only", "--diff-filter=ACMR", base, "--"],
capture_output=True,
text=True,
check=False,
)
except FileNotFoundError:
print("error: git not found on PATH", file=sys.stderr)
return None
if result.returncode != 0:
stderr = result.stderr.strip()
print(f"error: git diff failed (base={base!r}): {stderr}", file=sys.stderr)
return None
paths: list[Path] = []
for line in result.stdout.splitlines():
line = line.strip()
if not line:
continue
path = (root / line).resolve()
if path.exists() and path.suffix in TEXT_EXTENSIONS:
paths.append(path)
return paths
def read_text(path: Path) -> str | None:
try:
return path.read_text(encoding="utf-8")
except UnicodeDecodeError:
try:
return path.read_text(encoding="latin-1")
except Exception:
return None
except Exception:
return None
def rel(path: Path, root: Path) -> str:
try:
return str(path.relative_to(root))
except ValueError:
return str(path)
class PythonVisitor(ast.NodeVisitor):
def __init__(self, path: Path, root: Path) -> None:
self.path = path
self.root = root
self.loop_depth = 0
self.findings: list[Finding] = []
def add(self, node: ast.AST, severity: str, kind: str, message: str, suggestion: str) -> None:
self.findings.append(
Finding(rel(self.path, self.root), getattr(node, "lineno", 1), severity, kind, message, suggestion)
)
def visit_For(self, node: ast.For) -> None:
self._visit_loop(node)
def visit_While(self, node: ast.While) -> None:
self._visit_loop(node)
def _visit_loop(self, node: ast.AST) -> None:
if self.loop_depth >= 1:
self.add(
node,
"high",
"nested-loop",
"Nested loop may create O(n^2) or worse behavior.",
"Check whether a map/set index, sort+two-pointer pass, grouping, or batching can replace the inner scan.",
)
self.loop_depth += 1
self.generic_visit(node)
self.loop_depth -= 1
def visit_Compare(self, node: ast.Compare) -> None:
if self.loop_depth and any(isinstance(op, (ast.In, ast.NotIn)) for op in node.ops):
self.add(
node,
"medium",
"membership-in-loop",
"Membership check inside a loop can become O(n*m) when the right side is a list or computed sequence.",
"If semantics allow it, build a set or dict once before the loop.",
)
self.generic_visit(node)
def visit_Call(self, node: ast.Call) -> None:
name = call_name(node.func)
if self.loop_depth and name in {"sorted", "sort"}:
self.add(
node,
"high",
"sort-in-loop",
"Sorting inside a loop is often avoidable repeated O(n log n) work.",
"Sort once outside the loop, maintain a heap, or use binary search/insertion if intermediate ordering is required.",
)
if self.loop_depth and name in {"filter", "map"}:
self.add(
node,
"medium",
"repeated-scan",
f"{name}() inside a loop may repeatedly scan a collection.",
"Consider precomputing an index/grouping or combining passes.",
)
# Distinctive ORM/HTTP method names only. `query`, `execute`, `select`, `where`,
# `find` are too common in non-DB contexts (Redux selectors, list utilities) to
# flag without surrounding evidence.
if self.loop_depth and name in {
"fetch",
"request",
"find_one",
"find_many",
"findone",
"findmany",
"findunique",
"find_unique",
}:
self.add(
node,
"high",
"io-or-query-in-loop",
"Potential database/API/file operation inside a loop.",
"Look for N+1 behavior; batch or preload while preserving auth, filters, ordering, and error handling.",
)
self.generic_visit(node)
def call_name(func: ast.AST) -> str:
if isinstance(func, ast.Name):
return func.id
if isinstance(func, ast.Attribute):
return func.attr
return ""
def scan_python(path: Path, root: Path, text: str) -> list[Finding]:
try:
tree = ast.parse(text)
except SyntaxError as exc:
return [
Finding(
rel(path, root),
exc.lineno or 1,
"info",
"parse-error",
"Python file could not be parsed; falling back to textual scanning only.",
"Inspect manually if this file is on a hot path.",
)
] + scan_text(path, root, text)
visitor = PythonVisitor(path, root)
visitor.visit(tree)
return visitor.findings
def scan_text(path: Path, root: Path, text: str) -> list[Finding]:
findings: list[Finding] = []
lines = text.splitlines()
loop_stack: list[tuple[int, int]] = []
function_component_ranges = component_ranges(lines) if path.suffix in {".jsx", ".tsx", ".js", ".ts"} else set()
for idx, line in enumerate(lines, start=1):
stripped = line.strip()
if not stripped or stripped.startswith(("//", "#", "*")):
continue
indent = len(line) - len(line.lstrip(" "))
# Reset stack when re-entering top-level scope or crossing a function boundary —
# otherwise predicate calls in function A leak into nested-loop reports in function B.
if indent == 0 or FN_BOUNDARY_RE.match(line):
loop_stack = []
loop_stack = [(level, lno) for level, lno in loop_stack if level < indent]
if LOOP_RE.search(stripped):
if loop_stack:
findings.append(
Finding(
rel(path, root),
idx,
"high",
"nested-or-callback-loop",
"Loop or array iteration appears inside another loop/callback.",
"Check whether indexing, grouping, batching, or a single-pass algorithm can remove repeated scans.",
)
)
loop_stack.append((indent, idx))
if loop_stack and MEMBERSHIP_RE.search(stripped):
findings.append(
Finding(
rel(path, root),
idx,
"medium",
"membership-in-loop",
"Membership/search operation appears inside iterative code.",
"Consider a Set/Map or precomputed lookup if equality and ordering semantics allow it.",
)
)
if loop_stack and SORT_RE.search(stripped):
findings.append(
Finding(
rel(path, root),
idx,
"high",
"sort-in-loop",
"Sort appears inside iterative code.",
"Move sorting out of the loop or use a heap/binary-search strategy if intermediate order is needed.",
)
)
if loop_stack and QUERY_IN_LOOP_RE.search(stripped):
findings.append(
Finding(
rel(path, root),
idx,
"high",
"io-or-query-in-loop",
"Potential database/API/file operation inside a loop.",
"Look for N+1 behavior; batch or preload while preserving auth, filters, ordering, and error handling.",
)
)
if idx in function_component_ranges and any(token in stripped for token in [".filter(", ".map(", ".sort(", ".reduce("]):
findings.append(
Finding(
rel(path, root),
idx,
"medium",
"render-derived-work",
"Collection transform appears in a likely UI component render path.",
"For large collections, consider memoized selectors, server-side derivation, or virtualization.",
)
)
return findings
def component_ranges(lines: list[str]) -> set[int]:
active_until = 0
interesting: set[int] = set()
brace_balance = 0
in_component = False
for idx, line in enumerate(lines, start=1):
if RENDER_HINT_RE.search(line):
in_component = True
active_until = idx + 120
brace_balance = 0
if in_component:
interesting.add(idx)
brace_balance += line.count("{") - line.count("}")
if idx > active_until or (idx > active_until - 110 and brace_balance <= 0 and "}" in line):
in_component = False
return interesting
def dedupe(findings: list[Finding]) -> list[Finding]:
seen: set[tuple[str, int, str]] = set()
result: list[Finding] = []
for finding in findings:
key = (finding.path, finding.line, finding.kind)
if key not in seen:
seen.add(key)
result.append(finding)
return result
def severity_rank(finding: Finding) -> tuple[int, str, int]:
order = {"high": 0, "medium": 1, "info": 2}
return (order.get(finding.severity, 3), finding.path, finding.line)
def render_markdown(findings: list[Finding]) -> str:
if not findings:
return "No obvious complexity hotspots found by heuristic scanning.\n"
lines = ["# Complexity Hotspots", ""]
for finding in findings:
lines.extend(
[
f"## {finding.severity.upper()} {finding.kind}",
f"- Location: `{finding.path}:{finding.line}`",
f"- Finding: {finding.message}",
f"- Suggestion: {finding.suggestion}",
"",
]
)
return "\n".join(lines)
def main() -> int:
parser = argparse.ArgumentParser(description="Scan a repository for likely complexity hotspots.")
parser.add_argument("root", nargs="?", default=".", help="Repository or directory to scan.")
parser.add_argument("--format", choices=["markdown", "json"], default="markdown")
parser.add_argument("--exclude", action="append", default=[], help="Additional directory name to exclude.")
parser.add_argument("--max-findings", type=int, default=80)
parser.add_argument(
"--changed-only",
action="store_true",
help="Restrict scan to files changed vs --base. Requires a git repo at root.",
)
parser.add_argument(
"--base",
default="HEAD~1",
help="Git ref to diff against when --changed-only is set (default: HEAD~1).",
)
args = parser.parse_args()
root = Path(args.root).resolve()
if not root.exists():
print(f"error: path does not exist: {root}", file=sys.stderr)
return 2
if not root.is_dir():
print(
f"error: root must be a directory (got file: {root}). Pass the enclosing directory instead.",
file=sys.stderr,
)
return 2
excludes = DEFAULT_EXCLUDES | set(args.exclude)
if args.changed_only:
scoped = git_changed_files(root, args.base)
if scoped is None:
return 2
if not scoped:
print(
f"no changed files vs {args.base} in supported extensions",
file=sys.stderr,
)
return 0
file_iter: Iterable[Path] = scoped
else:
file_iter = iter_files(root, excludes)
findings: list[Finding] = []
files_scanned = 0
files_failed = 0
try:
for path in file_iter:
text = read_text(path)
if text is None:
files_failed += 1
continue
files_scanned += 1
try:
if path.suffix == ".py":
findings.extend(scan_python(path, root, text))
else:
findings.extend(scan_text(path, root, text))
except Exception as exc: # keep scanning other files
files_failed += 1
print(
f"warn: scan failed for {rel(path, root)}: {exc.__class__.__name__}: {exc}",
file=sys.stderr,
)
except KeyboardInterrupt:
print("interrupted", file=sys.stderr)
return 130
if files_scanned == 0:
print(
f"error: scanned 0 files under {root}. Check the path, supported extensions, or --exclude flags.",
file=sys.stderr,
)
return 3
findings = sorted(dedupe(findings), key=severity_rank)[: args.max_findings]
if args.format == "json":
print(
json.dumps(
{
"files_scanned": files_scanned,
"files_failed": files_failed,
"findings": [asdict(f) for f in findings],
},
indent=2,
)
)
else:
print(render_markdown(findings))
print(f"\n_Scanned {files_scanned} files ({files_failed} skipped due to read/parse errors)._")
return 0
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
"""Regression tests for analyze_complexity.py — pins the false-positive fixes.
Run with: python3 scripts/test_analyze_complexity.py
Exits 0 on success, 1 on any failure.
"""
from __future__ import annotations
import json
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
from textwrap import dedent
HERE = Path(__file__).resolve().parent
SCANNER = HERE / "analyze_complexity.py"
def run(args: list[str], cwd: Path | None = None) -> tuple[int, str, str]:
proc = subprocess.run(
[sys.executable, str(SCANNER), *args],
capture_output=True,
text=True,
cwd=cwd,
)
return proc.returncode, proc.stdout, proc.stderr
def write(root: Path, rel: str, content: str) -> Path:
path = root / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(dedent(content).lstrip("\n"))
return path
def findings_of(stdout: str) -> list[dict]:
data = json.loads(stdout)
return data["findings"] if isinstance(data, dict) else data
class TestCase:
def __init__(self, name: str, fn):
self.name = name
self.fn = fn
def run(self) -> tuple[bool, str]:
try:
self.fn()
return True, ""
except AssertionError as exc:
return False, str(exc) or "assertion failed"
except Exception as exc:
return False, f"{exc.__class__.__name__}: {exc}"
CASES: list[TestCase] = []
def case(name: str):
def wrap(fn):
CASES.append(TestCase(name, fn))
return fn
return wrap
@case("real nested for-loop in Python IS flagged")
def _():
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
write(root, "real.py", """
def find_dupes(items):
dupes = []
for a in items:
for b in items:
if a != b and a.name == b.name:
dupes.append(a)
return dupes
""")
code, out, _ = run([str(root), "--format", "json"])
assert code == 0
kinds = [f["kind"] for f in findings_of(out)]
assert "nested-loop" in kinds, f"expected nested-loop, got: {kinds}"
@case("benign React component with .find() + .map() is NOT flagged as nested")
def _():
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
write(root, "Component.tsx", """
const MAX_RETRIES = 5;
export function UserList({ users, selectedId }) {
const selected = users.find(u => u.id === selectedId);
return (
<ul>
{users.map(u => <li key={u.id}>{u.name}</li>)}
</ul>
);
}
""")
code, out, _ = run([str(root), "--format", "json"])
assert code == 0
kinds = [f["kind"] for f in findings_of(out)]
assert "nested-or-callback-loop" not in kinds, f"false positive: {kinds}"
assert "membership-in-loop" not in kinds, f"false positive: {kinds}"
@case("two unrelated top-level fns do NOT cross-pollinate loop_stack")
def _():
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
write(root, "utils.ts", """
export function findById(arr, id) {
return arr.find(u => u.id === id);
}
export function getNames(arr) {
return arr.map(u => u.name);
}
""")
code, out, _ = run([str(root), "--format", "json"])
assert code == 0
assert findings_of(out) == [], f"expected no findings, got: {findings_of(out)}"
@case("SCREAMING_SNAKE const does NOT trigger render-path mode")
def _():
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
write(root, "constants.ts", """
const MAX_RETRIES = 5;
const API_URL = 'https://api.example.com';
export function pickActive(items) {
return items.filter(i => i.active).map(i => i.id);
}
""")
code, out, _ = run([str(root), "--format", "json"])
assert code == 0
kinds = [f["kind"] for f in findings_of(out)]
assert "render-derived-work" not in kinds, f"false positive: {kinds}"
@case("bad path exits 2 with actionable error")
def _():
code, out, err = run(["/this/does/not/exist"])
assert code == 2, f"expected exit 2, got {code}"
assert "does not exist" in err, f"missing actionable error, got: {err!r}"
@case("file as root exits 2 with hint")
def _():
with tempfile.TemporaryDirectory() as tmp:
f = Path(tmp) / "lonely.py"
f.write_text("x = 1\n")
code, _, err = run([str(f)])
assert code == 2
assert "must be a directory" in err, f"missing hint, got: {err!r}"
@case("zero files matched exits 3")
def _():
with tempfile.TemporaryDirectory() as tmp:
Path(tmp, "README.md").write_text("# nope\n") # unsupported ext
code, _, err = run([str(tmp)])
assert code == 3, f"expected exit 3, got {code}"
assert "Scanned 0 files" in err or "scanned 0 files" in err
@case(".vue and .svelte files ARE scanned")
def _():
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
write(root, "App.vue", """
<script setup>
for (const a of items) {
for (const b of items) {
if (a === b) continue;
}
}
</script>
""")
write(root, "App.svelte", """
<script>
export let items;
for (const a of items) {
for (const b of items) {
console.log(a, b);
}
}
</script>
""")
code, out, _ = run([str(root), "--format", "json"])
assert code == 0
data = json.loads(out)
assert data["files_scanned"] == 2, f"expected 2 scanned, got {data}"
kinds = [f["kind"] for f in data["findings"]]
assert "nested-or-callback-loop" in kinds, f"vue/svelte not analyzed: {kinds}"
@case("Redux-style selectFoo call is NOT flagged as io-or-query-in-loop")
def _():
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
write(root, "selectors.ts", """
export function makeRows(state, ids) {
return ids.map(id => selectUserById(state, id));
}
""")
code, out, _ = run([str(root), "--format", "json"])
assert code == 0
kinds = [f["kind"] for f in findings_of(out)]
assert "io-or-query-in-loop" not in kinds, f"selectUserById misflagged: {kinds}"
@case("Prisma findMany inside loop IS flagged")
def _():
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
write(root, "n_plus_one.ts", """
export async function loadComments(posts) {
const out = [];
for (const p of posts) {
out.push(await prisma.comment.findMany({ where: { postId: p.id } }));
}
return out;
}
""")
code, out, _ = run([str(root), "--format", "json"])
assert code == 0
kinds = [f["kind"] for f in findings_of(out)]
assert "io-or-query-in-loop" in kinds, f"prisma N+1 not flagged: {kinds}"
@case("JSON output includes files_scanned + files_failed counts")
def _():
with tempfile.TemporaryDirectory() as tmp:
write(Path(tmp), "x.py", "x = 1\n")
write(Path(tmp), "y.ts", "const y = 1;\n")
code, out, _ = run([str(tmp), "--format", "json"])
assert code == 0
data = json.loads(out)
assert data["files_scanned"] == 2
assert data["files_failed"] == 0
assert isinstance(data["findings"], list)
@case("--changed-only requires a git repo, fails gracefully without one")
def _():
with tempfile.TemporaryDirectory() as tmp:
write(Path(tmp), "a.ts", "const a = 1;\n")
code, _, err = run([str(tmp), "--changed-only"])
assert code == 2, f"expected exit 2 on non-git dir, got {code}"
assert "git diff failed" in err or "git" in err, f"missing git error, got: {err!r}"
@case("--changed-only scopes to files in git diff")
def _():
if shutil.which("git") is None:
return # skip
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
subprocess.run(["git", "init", "-q"], cwd=root, check=True)
subprocess.run(["git", "-C", str(root), "config", "user.email", "t@t.t"], check=True)
subprocess.run(["git", "-C", str(root), "config", "user.name", "t"], check=True)
write(root, "untouched.py", "x = 1\n")
write(root, "touched.py", "x = 1\n")
subprocess.run(["git", "-C", str(root), "add", "."], check=True)
subprocess.run(
["git", "-C", str(root), "commit", "-q", "-m", "init"],
check=True,
env={**__import__("os").environ, "GIT_AUTHOR_NAME": "t", "GIT_COMMITTER_NAME": "t",
"GIT_AUTHOR_EMAIL": "t@t.t", "GIT_COMMITTER_EMAIL": "t@t.t"},
)
write(root, "touched.py", """
def f(items):
for a in items:
for b in items:
pass
""")
subprocess.run(["git", "-C", str(root), "add", "touched.py"], check=True)
subprocess.run(
["git", "-C", str(root), "commit", "-q", "-m", "edit"],
check=True,
env={**__import__("os").environ, "GIT_AUTHOR_NAME": "t", "GIT_COMMITTER_NAME": "t",
"GIT_AUTHOR_EMAIL": "t@t.t", "GIT_COMMITTER_EMAIL": "t@t.t"},
)
code, out, _ = run([str(root), "--changed-only", "--base", "HEAD~1", "--format", "json"])
assert code == 0, f"expected exit 0, got {code}"
data = json.loads(out)
assert data["files_scanned"] == 1, f"expected 1 changed file scanned, got {data}"
paths = {f["path"] for f in data["findings"]}
assert all("touched" in p for p in paths), f"untouched file leaked: {paths}"
def main() -> int:
failed = 0
for c in CASES:
ok, msg = c.run()
marker = "✓" if ok else "✗"
print(f" {marker} {c.name}")
if not ok:
print(f" {msg}")
failed += 1
total = len(CASES)
print(f"\n{total - failed}/{total} passed")
return 0 if failed == 0 else 1
if __name__ == "__main__":
raise SystemExit(main())