Skip to content
Back to skills

Complexity Scan

ASecurity

Find super-linear work on data-sized input, uncapped recursion, cycles and stalled loops; caps resolved, counted proof.

  • 2 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 20, 2026
ai-agentspythonrustgojavarubyphpkotlinc#bashsql

Works with

  • cursor
  • cli
  • api

Security analysis

A100/100

Pro scans all 2 files and shows the line behind each finding

Scanned October 20, 2026

npx -y skills add adnanmokhtar/refract --skill complexity-scan --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Complexity Scan?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Complexity Scan
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/adnanmokhtar-complexity-scan/badge)](https://www.skillsdirectory.com/skills/adnanmokhtar-complexity-scan)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: complexity-scan
description: Find super-linear work on data-sized input, uncapped recursion, cycles and stalled loops; caps resolved, counted proof.
kind: skill
pack: algorithms
allowed-tools: [Read, Grep, Glob, Bash]
---

# Skill: complexity-scan

## Premise

A complexity defect is a procedure whose work, depth or iteration count grows faster than the budget
as a collection the users or the data control grows: a lookup that scans a list per element, an
accumulator copied per step, a front-removal that shifts the array, a sort or a `Set` rebuilt every
iteration, two collections joined by nested loops, a product generated before the cap is applied, a
recursion as deep as the input, a walk that never meets a visited set, an exponential self-call, a
loop whose cursor can stall. CWE files them under **CWE-407** (inefficient algorithmic complexity),
**CWE-1050/1046/1176** (resource-consuming loop shapes), **CWE-674** (uncontrolled recursion) and
**CWE-835** (a loop with an unreachable exit). Every function is fast on the three rows in the test
fixture; the cost appears when a tenant has 50,000.

**Every finding cites the site `<file:line>` + its class + the cap on each collection (or its absence,
with where the size comes from) + `n_max × m_max` against the budget + the entry point that reaches it
+ the proof + the fix.** A grep hit is never a finding, and "no complexity defect" is never printed
without the class-coverage block (§ Output).

**Resolve caps before reading, count operations to prove.** On a real NestJS + React monorepo the
naive greps returned 97 linear-lookup hits, 264 nested loops, 31 sorts in callbacks and 179
self-calls, and every one was refuted by the same reads: what bounds each collection, and whether the
recursion carries a depth or cycle guard. `scripts/algo-sites.py` does those reads first and prints
the arithmetic, so a reader opens the short list. The proof is an **operation count** at N and 4N
([`proof-oracles.md`](../../../../snippets/proof-oracles.md) § Algorithms, V590–V598), never a
model-asserted complexity class, and never a single wall-clock run.

## The rule

A site is a finding when all four hold:

1. **A production path runs it.** A route, resolver, consumer, scheduled job, or a page rendered on a
   real route. Scripts, seeds, CLIs, benchmarks and test helpers fail here.
2. **The size, depth or cycle comes from users or data.** Cite the cap on each collection — a schema
   `.max(N)`, a `limit:`/`take:`, `Math.min(…, N)`, `.slice(0, N)`, a constant, a body limit — or
   its absence at every entry point. A side capped at ≤ 50 makes the work linear in the other.
3. **No control on the path stops it.** An index built once, a memo, a depth cap and a visited set,
   a unique cursor with strict `>`, a short-page exit, a cap enforced before generation.
4. **The oracle fires failing-first** (V590–V598), or, for a cap that is absent, the two-ended static
   fact (the collection's source and every entry point with no bound) is cited. Without either the
   row is Unconfirmed with the test written out.

**Severity.** **High** when a server path's work or depth is set by an unauthenticated or
tenant-sized input past the budget, or a recursion or loop can crash or hang a shared worker;
**medium** for job and worker paths and authenticated request paths with a cap above the budget;
**low** for one browser tab or an admin-only path. Drop one step when a cap exists but sits above the
budget. The budget is the pack rule's: `n ≤ 5e3 → O(n²)`, `n·m ≤ ~1e6` operations per request.

## Boundaries

Per-call construction (a regex, validator or client built per call) is performance
`request-path-scan` §8; memory held across requests is `memory-growth-scan` (an unbounded
process-lifetime set §1, a per-request clone of a large constant §8); a query per element (N+1) is
database `n-plus-one-scan`; ReDoS is security `injection-scan` §13; a loop bound taken from untrusted
input with no limit (CWE-606) is security's input validation. This skill owns the growth class of a
procedure on data-sized input, its recursion depth and cycles, and whether its loops terminate.
**Without this pack, `request-path-scan` §11 grades CX-1…CX-5** (performance pack); with it installed
§11 prints `→ complexity-scan` and does not grade. GraphQL query depth is `@api-security-reviewer`'s;
body-size limits are `request-path-scan` §13 (this skill reads them as the RC-1 bound). Retry loops
with no cap are distributed-systems `retry-deadline-scan`. A comparator, parse or date expression
wrong for one input is code-quality `correctness-smell-scan`. Each cross-pack route applies when that
pack is installed; without it the row is listed here, tagged `<class> (owner pack <x> not installed)`,
and never graded as the owner would grade it.

## Adapt to the codebase — lanes

Read every analyzer's version from the lockfile or manifest (grep 0a); a lane whose binary is not in
the project (its `node_modules/.bin`, its virtualenv, its build) is `NOT-RUN (<reason>)`, never PASS
and never installed from the network. No linter in any stack flags a linear lookup inside a loop:
§1 is the script plus a read in every lane.

| Lane | Shape-exact analyzer rules (version from the lockfile) | Parsed by `algo-sites.py` | Fix primitive |
|---|---|---|---|
| **TS / JS** | Biome `lint/performance/noAccumulatingSpread` (reduce only; misses `acc.concat` and loops); oxlint `oxc/no-accumulating-spread` (reduce and loops); unicorn `prefer-set-has` is style (constant arrays) — never a CX finding | yes (masking scanner; `.vue`/`.svelte` script blocks) | `Set`/`Map` index, `push` + one copy, a head index or deque, hoist, `Map` group-by |
| **Python** | Ruff `RUF017` (`sum(lists, [])`); `PERF*` rules are constant-factor — never report them as CX | yes (`ast`) | `set`/`dict`, `list.extend`, `collections.deque`, hoist, `itertools.chain` |
| **Go** | staticcheck `SA6000` (`regexp.Match*` in a loop; issue #1502 false positive on a switch in a map range) | counted, greps 1a, 3–5, 9a, 10 | `map[K]struct{}`, `strings.Builder`, a ring buffer, compile once |
| **JVM** (Java, Kotlin) | SpotBugs `SBSC_USE_STRINGBUFFER_CONCATENATION`, `IIL_PATTERN_COMPILE_IN_LOOP[_INDIRECT]` (bytecode); PMD `UseStringBufferForStringAppends` (`AvoidInstantiatingObjectsInLoops` is noise); CodeQL `java/unreachable-exit-in-loop`, `java/constant-loop-condition` (optional, GHAS) | counted, greps 1b, 2c, 2e, 3–5, 9b, 10 | `HashSet`/`HashMap`, `StringBuilder`, `ArrayDeque`, a static `Pattern` |
| **.NET** | — (`DOTNET_CLI_TELEMETRY_OPTOUT=1` for any `dotnet` call) | counted, greps 1c, 2c, 3–5, 9b, 10 | `HashSet<T>`, `StringBuilder`, `Queue<T>`, `ToDictionary` once |
| **Ruby** | RuboCop Performance `CollectionLiteralInLoop`, `ConstantRegexp` (no cop for `include?` in a loop) | counted, greps 1d, 1e, 2d, 3, 9c, 10 | `Set`, `<<`, `index_by` once |
| **PHP** | — | counted, greps 1f, 3, 5, 9c, 10 | `isset($map[$k])` with `array_flip` once, `SplQueue` |
| **Rust** | Clippy `regex_creation_in_loops`, `manual_contains` (group UNVERIFIED) | counted, greps 1g, 4, 5, 10 | `HashSet`, `VecDeque`, a `LazyLock` regex |
| **SQL** | — | greps 7a, 7b, 7c, 8 | `CYCLE … SET` (PostgreSQL ≥ 14), a path array, a depth column, a unique key with strict `>` |

## Pre-flight

Run as **one** bash script from the repo root. Set `ROOTS` to the production source roots first
(`scripts/`, `test/`, seeds and CLIs are not roots unless the caller says so). Grep **N** feeds
detector **§N**; grep 0 is context. Every `rg` names its path; every exit code is printed, and an
exit 2 (a pattern or path error) makes the step fail rather than read as zero hits. Output goes to a
`mktemp -d` folder; nothing is written in the repo and nothing touches the network.

```bash
set -u
declare -p ROOTS >/dev/null 2>&1 || ROOTS=(.)   # production source roots, e.g. ROOTS=(apps/api/src apps/web/src)
S="${ALGO_SITES:-.claude/skills/complexity-scan/scripts/algo-sites.py}"
OUT="$(mktemp -d "${TMPDIR:-/tmp}/complexity-scan.XXXXXX")"; RC=0
st() { printf '# %-3s rc=%s\n' "$1" "$2"; [ "$2" != 2 ] || RC=2; }   # rg: 0 hits, 1 none, 2 FAILED (never "zero hits")
X=(-g '!**/node_modules/**' -g '!**/vendor/**' -g '!**/dist/**' -g '!**/build/**' -g '!**/.next/**' -g '!**/target/**'
   -g '!**/.venv/**' -g '!**/coverage/**' -g '!**/*.min.*' -g '!**/*.d.ts' -g '!**/*.{md,mdx,lock,map,svg,json,snap,yml,yaml,css,scss,html}')
T=(-g '!**/{test,tests,__tests__,spec,e2e,__mocks__,mocks,fixtures,__fixtures__,testdata,seeds,seeders,migrations,locales,i18n,generated,__generated__,docs}/**'
   -g '!**/*.{spec,test,e2e-spec,stories,fuzz-spec,test-data,fixture,bench}.*' -g '!**/{*_test,*_spec,test_*,conftest}.*' -g '!ai/**')
NC=(rg -v --pcre2 '^[^:]+:\d+:\s*(//|/\*|\*(\s|/|$)|#(?!\{)|<!--)')
for t in js ts py go java kotlin cs ruby php rust; do printf '%-7s %s\n' "$t" "$(rg --files -t "$t" "${X[@]}" "${T[@]}" "${ROOTS[@]}" | wc -l)"; done

# 0. Context: 0a analyzer pins (lane versions), 0b body-size / parser-depth / stack settings (the RC-1 bound).
rg -n --no-heading --pcre2 '"(?:@biomejs/biome|oxlint|eslint-plugin-unicorn|eslint)"\s*:\s*"[^"]+"|^\s*(?:ruff|staticcheck)\b[^\n]*\d|honnef\.co/go/tools\s+v|<artifactId>(?:spotbugs|pmd)[\w-]*</artifactId>|\brubocop-performance\b|com\.github\.spotbugs|\bpmd\b[^\n]*version' -g 'package.json' -g 'pyproject.toml' -g 'requirements*.txt' -g 'go.mod' -g 'pom.xml' -g 'build.gradle*' -g 'Gemfile' -g '!**/node_modules/**' .; st 0a $?
rg -n --no-heading --pcre2 '\bbodyLimit\s*:|\b(?:express|bodyParser)\.(?:json|urlencoded)\(\s*\{[^}\n]*\blimit\b|\bclient_max_body_size\b|\bDATA_UPLOAD_MAX_MEMORY_SIZE\b|\bsys\.setrecursionlimit\(|-Xss\d|--stack-size\b|\b(?:maxDepth|max_depth|depthLimit)\s*[:=]' "${X[@]}" "${T[@]}" . | "${NC[@]}"; st 0b "${PIPESTATUS[0]}"

# 1. CX-1 / CX-5 on the stacks algo-sites.py counts but does not parse: a lookup within 6 lines of a loop head.
#    1a Go, 1b JVM, 1c .NET, 1d Ruby block (multi-line) and 1e one-line, 1f PHP, 1g Rust, 1h lodash *With helpers (JS).
rg -n -U --pcre2 '^[ \t]*+for\b[^\n]*\{[ \t]*\n(?:[^\n]*\n){0,6}?[^\n]*?\K\bslices\.(?:Contains|ContainsFunc|Index|IndexFunc)\(' -t go "${X[@]}" "${T[@]}" "${ROOTS[@]}" | "${NC[@]}"; st 1a "${PIPESTATUS[0]}"
rg -n -U --pcre2 '^[ \t]*+(?:for\s*\([^\n]*[:)]|[\w.]+\.forEach\s*[({]|while\s*\()[^\n]*\n(?:[^\n]*\n){0,6}?[^\n]*?\K(?:\b(?!this\b)[\w.]+\.(?:contains|indexOf|lastIndexOf)\(|\.stream\(\)\.(?:anyMatch|noneMatch|filter)\()' -t java -t kotlin "${X[@]}" "${T[@]}" "${ROOTS[@]}" | "${NC[@]}"; st 1b "${PIPESTATUS[0]}"
rg -n -U --pcre2 '^[ \t]*+(?:foreach|for)\s*\([^\n]*\n(?:[^\n]*\n){0,6}?[^\n]*?\K\b[\w.]+\.(?:Contains|IndexOf|Any|First|FirstOrDefault|Where)\(' -t cs "${X[@]}" "${T[@]}" "${ROOTS[@]}" | "${NC[@]}"; st 1c "${PIPESTATUS[0]}"
rg -n -U --pcre2 '^[ \t]*+[^\n]*\.(?:each|each_with_index|each_with_object|map|flat_map|select|reject|filter_map|sum|count)\b[^\n]*\|[^|\n]+\|[ \t]*\n(?:[^\n]*\n){0,6}?[^\n]*?\K\b[\w.@]+\.(?:include\?|index|find|detect|any\?)' -t ruby "${X[@]}" "${T[@]}" "${ROOTS[@]}" | "${NC[@]}"; st 1d "${PIPESTATUS[0]}"
rg -n --pcre2 '\.(?:each|map|select|reject|count|any\?|all\?|find|detect)\s*\{\s*\|\w+\|[^\n}]*\b[\w.@]+\.(?:include\?|index|find|detect)\b' -t ruby "${X[@]}" "${T[@]}" "${ROOTS[@]}" | "${NC[@]}"; st 1e "${PIPESTATUS[0]}"
rg -n -U --pcre2 '^[ \t]*+foreach\s*\([^\n]*\n(?:[^\n]*\n){0,6}?[^\n]*?\K\b(?:in_array|array_search)\(|\barray_(?:filter|map)\([^\n]*\bin_array\(' -t php "${X[@]}" "${T[@]}" "${ROOTS[@]}" | "${NC[@]}"; st 1f "${PIPESTATUS[0]}"
rg -n -U --pcre2 '^[ \t]*+for\s+\w+\s+in\b[^\n]*\n(?:[^\n]*\n){0,6}?[^\n]*?\K\.(?:contains|iter\(\)\.(?:any|position|find))\(' -t rust "${X[@]}" "${T[@]}" "${ROOTS[@]}" | "${NC[@]}"; st 1g "${PIPESTATUS[0]}"
rg -n --pcre2 '\b_\.(?:differenceWith|intersectionWith|uniqWith|unionWith|xorWith|pullAllWith)\(|\bfrom\s+["\x27]lodash/(?:differenceWith|intersectionWith|uniqWith)["\x27]' -t js -t ts "${X[@]}" "${T[@]}" "${ROOTS[@]}" | "${NC[@]}"; st 1h "${PIPESTATUS[0]}"

# 2. CX-2 copy-per-iteration: 2a JS reduce spread/concat (cross-checks the script), 2b Python sum(…, []) / reduce(add),
#    2c string append in loops (JVM, .NET, Go), 2d Ruby inject with acc + [x] / non-bang merge, 2e Kotlin fold with +.
rg -n --pcre2 '\.reduce(?:Right)?\(\s*(?:async\s*)?\(\s*(\w+)\b[^)\n]*\)\s*(?::[^=\n]+)?=>[^\n]*?(?:\{\s*\.\.\.\1\b|\[\s*\.\.\.\1\b|(?<![\w$.])\1\s*\.\s*concat\(|Object\.assign\(\s*\{\s*\}\s*,\s*\1\b)' -t js -t ts "${X[@]}" "${T[@]}" "${ROOTS[@]}" | "${NC[@]}"; st 2a "${PIPESTATUS[0]}"
rg -n --pcre2 '\bsum\([^\n]*,\s*(?:start\s*=\s*)?\[\]\s*\)|\breduce\(\s*(?:operator\.)?(?:add|concat|iadd)\b' -t py "${X[@]}" "${T[@]}" "${ROOTS[@]}" | "${NC[@]}"; st 2b "${PIPESTATUS[0]}"
rg -n -U --pcre2 '^[ \t]*+(?:for|foreach|while)\b[^\n]*\n(?:[^\n]*\n){0,6}?[^\n]*?\K(?:\b(\w+)\s*=\s*\1\s*\+\s*(?:"|\$")|\b\w+\s*\+=\s*(?:"|\$"|fmt\.Sprint|String\.format\())' -t java -t kotlin -t cs -t go "${X[@]}" "${T[@]}" "${ROOTS[@]}" | "${NC[@]}"; st 2c "${PIPESTATUS[0]}"
rg -n --pcre2 '\.(?:inject|reduce)\b[^\n]*\|\s*(\w+)\s*,[^\n]*\|[^\n]*?(?:\b\1\s*\+\s*\[|\b\1\.merge\()' -t ruby "${X[@]}" "${T[@]}" "${ROOTS[@]}" | "${NC[@]}"; st 2d "${PIPESTATUS[0]}"
rg -n --pcre2 '\.fold\([^\n]*\{\s*(\w+)\s*,[^\n]*->\s*\1\s*\+\s' -t kotlin "${X[@]}" "${T[@]}" "${ROOTS[@]}" | "${NC[@]}"; st 2e "${PIPESTATUS[0]}"

# 3. CX-3 O(n) per-operation container use (JVM, .NET, Go, PHP, Ruby; JS and Python are in the script).
rg -n --pcre2 '\.remove\(\s*0\s*\)|\.add\(\s*0\s*,|\.RemoveAt\(\s*0\s*\)|\.Insert\(\s*0\s*,|\bappend\(\s*\[\][\w.*]+\{[^}\n]*\}\s*,\s*[\w.]+\.\.\.\s*\)|\barray_(?:shift|unshift)\(|\.(?:shift|unshift)\b' -t java -t kotlin -t cs -t go -t php -t ruby "${X[@]}" "${T[@]}" "${ROOTS[@]}" | "${NC[@]}"; st 3 "${PIPESTATUS[0]}"

# 4. CX-4 loop-invariant work per iteration (JVM, .NET, Go, Rust): compile, formatter or sort within 8 lines of a loop head.
rg -n -U --pcre2 '^[ \t]*+(?:for|foreach|while)\b[^\n]*\n(?:[^\n]*\n){0,8}?[^\n]*?\K(?:\bPattern\.compile\(|\.matches\(\s*"|\.replaceAll\(\s*"|\bregexp\.(?:MustCompile|Compile|MatchString)\(|\bRegex::new\(|\bnew\s+Regex\(|\bDateTimeFormatter\.ofPattern\(|\bnew\s+SimpleDateFormat\(|\b(?:Collections\.sort|Arrays\.sort|slices\.Sort|sort\.(?:Slice|Strings|Ints))\(|\.(?:OrderBy|OrderByDescending)\()' -t java -t kotlin -t cs -t go -t rust "${X[@]}" "${T[@]}" "${ROOTS[@]}" | "${NC[@]}"; st 4 "${PIPESTATUS[0]}"

# 5. CX-5 nested loops joining on keys (Go, JVM, .NET, PHP, Rust): an inner loop ≤ 4 lines into an outer one, then an equality.
rg -n -U --pcre2 '^([ \t]*+)(?:for|foreach)\b[^\n]*\n(?:\1[ \t]+[^\n]*\n){0,4}?\1[ \t]++(?:for|foreach)\b[^\n]*\n(?:[^\n]*\n){0,6}?[^\n]*?\K\b\w+\.\w+\s*(?:==|\.equals\()\s*\w+\.\w+' -t go -t java -t kotlin -t cs -t php -t rust "${X[@]}" "${T[@]}" "${ROOTS[@]}" | "${NC[@]}"; st 5 "${PIPESTATUS[0]}"

# 6. CX-6 combinatorial generators, every stack (read where the cap is enforced: before generation, not after).
rg -n --pcre2 '\bitertools\.(?:product|permutations|combinations(?:_with_replacement)?)\(|\b(?:function\s*\*?\s*|def\s+|func\s+|fun\s+)\w*(?:[Cc]ombinations|[Cc]artesian\w*|[Pp]ermutations|[Pp]ower[Ss]et|power_set|[Ss]ubsets|[Aa]ll[Pp]airs|all_pairs)\b|\b(?:const|let|var)\s+\w*(?:[Cc]ombinations|[Cc]artesian\w*|[Pp]ermutations|[Pp]ower[Ss]et|[Ss]ubsets|[Aa]ll[Pp]airs)\s*(?::[^=\n]+)?=\s*(?:async\s*)?(?:\(|function\b|\w+\s*=>)|\b(?:Sets|Lists)\.cartesianProduct\(|\bSets\.powerSet\(|\bR\.xprod\(' "${X[@]}" "${T[@]}" "${ROOTS[@]}" | "${NC[@]}"; st 6 "${PIPESTATUS[0]}"

# 7. RC-1/RC-2 in SQL: 7a recursive CTEs in code, 7c in migrations (a view), 7b the guard each needs (CYCLE clause, depth bound); 8 self-referencing parent
#    keys (a cycle the data can hold; migrations INCLUDED — they are the schema); 9 self-calls on the unparsed stacks;
#    10 unconditioned loops on the unparsed stacks (JS/TS and Python RC rows come from the script below).
rg -n --pcre2 '(?i)\bwith\s+recursive\b' "${X[@]}" -g '!**/node_modules/**' "${ROOTS[@]}"; st 7a $?
rg -n --pcre2 '(?i)\bwith\s+recursive\b' -g '**/{migrations,migration,db/migrate,alembic/versions,sql}/**' -g '!**/node_modules/**' .; st 7c $?   # views and functions in migrations: outside ROOTS
rg -n --pcre2 '(?i)\bCYCLE\s+\w+(?:\s*,\s*\w+)*\s+SET\b|\b(?:depth|level|lvl)\s*<=?\s*\d+' "${X[@]}" "${T[@]}" "${ROOTS[@]}" | "${NC[@]}"; st 7b "${PIPESTATUS[0]}"
rg -n --pcre2 '(?i)\bparent_?id\b[^\n]{0,120}\b(?:references|@ManyToOne|ForeignKey|belongs_to|foreignKey|@relation)\b|\b(?:references|@ManyToOne|ForeignKey|belongs_to)\b[^\n]{0,120}\bparent_?id\b' "${X[@]}" -g '!**/{test,tests,__tests__,spec,e2e}/**' -g '!**/*.{spec,test}.*' . | "${NC[@]}"; st 8 "${PIPESTATUS[0]}"
rg -n -U --pcre2 '^[ \t]*func\s+(?:\([^)\n]*\)\s*)?(\w+)\s*\([^\n]*\{[ \t]*\n(?:(?!func\b)[^\n]*\n){0,40}?[^\n]*?\K(?<![\w.])\1\(' -t go "${X[@]}" "${T[@]}" "${ROOTS[@]}" | "${NC[@]}"; st 9a "${PIPESTATUS[0]}"
rg -n -U --pcre2 '^[ \t]*(?:(?:public|private|protected|internal|static|final|override|suspend|open|async|virtual)\s+)+(?:fun\s+)?[\w<>\[\],.? ]*?\b(\w+)\s*\([^\n;]*\)[^\n;=]*\{?[ \t]*\n(?:(?![ \t]*(?:(?:public|private|protected|internal|static|override|fun)\s))[^\n]*\n){0,40}?[^\n]*?\K(?<![\w.])(?:this\.)?\1\(' -t java -t kotlin -t cs "${X[@]}" "${T[@]}" "${ROOTS[@]}" | "${NC[@]}"; st 9b "${PIPESTATUS[0]}"
rg -n -U --pcre2 '^[ \t]*(?:def\s+(?:self\.)?(\w+[?!]?)|(?:public\s+|private\s+|protected\s+|static\s+)*function\s+(\w+))\b[^\n]*\n(?:(?![ \t]*(?:def\s|(?:(?:public|private|protected|static)\s+)*function\s))[^\n]*\n){0,40}?[^\n]*?\K(?:(?<![\w.:>$])\1\(|\$this->\2\(|(?<![\w>$])\2\()' -t ruby -t php "${X[@]}" "${T[@]}" "${ROOTS[@]}" | "${NC[@]}"; st 9c "${PIPESTATUS[0]}"
rg -n --pcre2 '^\s*for\s*\{\s*$|\bwhile\s*\(\s*(?:true|1)\s*\)|^\s*loop\s*\{|\bloop\s+do\b|\buntil\s+false\b|\bdo\s*\{' -t go -t java -t kotlin -t cs -t rust -t ruby -t php "${X[@]}" "${T[@]}" "${ROOTS[@]}" | "${NC[@]}"; st 10 "${PIPESTATUS[0]}"

# The candidate tables (JS/TS and Python): sites with caps resolved, then recursion and loop progress. Exit 2 = blocked.
R="$(IFS=,; echo "${ROOTS[*]}")"
python3 -I -B "$S" --repo=. --roots="$R" --tsv --out="$OUT/sites.tsv"; st py1 $?
python3 -I -B "$S" --repo=. --roots="$R" --recursion --tsv --out="$OUT/rec.tsv"; st py2 $?
awk -F'\t' 'NR>1 && ($13=="uncapped" || $13=="over-budget")' "$OUT/sites.tsv" | sort -t$'\t' -k13,13r -k11,11nr | head -40
awk -F'\t' 'NR>1 && $10 ~ /^(RC-[123]\?|no-exit|read)$/' "$OUT/rec.tsv"
echo "# tables: $OUT/sites.tsv $OUT/rec.tsv · pre-flight exit $RC"; [ "$RC" -eq 0 ]
```

A finding is a table row or a grep hit **plus** a read of the enclosing function **plus** each cap
checked in the file it names **plus** the hops back to an entry point. Three blind spots are
structural, so work around them rather than trust a quiet run:

- **Receiver typing is heuristic.** The script types a receiver from its declaration in the same file
  (`new Set`, a `: string` annotation, a literal, a string method); an import, a destructured
  parameter or a `useState` pair is `array?`. Read the declaration before dismissing or keeping.
- **Recursion through another function** (mutual recursion, a callback registry, a visitor that
  dispatches by type) is not a self-call. Grep the walker's name across the file when a
  `walks=children|json` row appears without its partner.
- **Other stacks are grep-only.** Go, JVM, .NET, Ruby, PHP and Rust are counted by the script and
  searched by greps 1–10 with a 6–8-line loop window; a lookup further from its loop head is missed.
  Read every function that the lane's loop greps list in full.

**Measured** (2026-10-19, read-only) on a real NestJS 11 + Fastify + MikroORM 6 + React monorepo,
`ROOTS=(apps/api/src apps/web/src packages/contracts/src)`, 6,930 TS files, about 38 s for the whole
block: the sites table has 560 rows — 245 dismissed by the arithmetic (172 `const-small`, 35
`below-budget`, 32 `string`, 6 `set`) and 315 open (141 server, 171 browser, 3 shared contracts; one
`over-budget`: a group-by-scan over two lists each capped at 5,000, 2.5e7 comparisons worst case, to
read for the realistic size). The recursion table has 28 rows: 6 `json-tree`, 5 `guarded`, 6
`keyset` loops, 7 loops to read, 4 `RC-1?` (one is the client-side Cartesian-product walker grep 6
also lists — the one live CX-6 candidate). Grep 6 returns 3 (two generators, one assertion), 7a and
7b 1 each (one recursive CTE advancing on a strict key), 8 none, 0b the body limit and two redaction
depth caps; the other-stack greps search no file there. Against the naive greps the eval measured on
the same tree (97 / 264 / 31 / 179), the open list is ordered by side and cap, not shorter in rows:
**the read is still the precision step**, and the 3,000-hit path below is how it stays affordable.
On the 28 other `benchmarks/fixtures` the script prints 30 site rows (15 open, mostly role and
permission lists checked per required item) and 10 recursion or loop rows; greps 1–9 return nothing
beyond them, and grep 10 lists four process-lifetime loops (precedent § 32). On `algo-defects`
(2026-10-19, staged without its key; TS, Python, Go, Java; 0.6 s; no `rg` exit 2) the block prints all
20 of its seeds: `sites.tsv` 11 rows (9 uncapped — 7 seeds, a `find` on one seed's per-iteration copy, and the
capped-FIFO decoy, precedent § 8 — 1 `below-budget` decoy at 800 × 800, 1 `set`), `rec.tsv` 9 rows (2 `RC-2?`, 2 `RC-3?`, 1 `RC-1?`, 1
`read` cursor; the `json-tree`, `guarded` and `keyset` decoys stay out of the awk print; the root-down
walk decoy prints as `RC-2?` and is closed by § 14), and greps 2a, 2c, 4, 5, 6 (2), 7a, 8, 9a and 10
one hit each, every hit a seed. Building it found three gaps, fixed the same day: grep 7c now reads
recursive CTEs in migrations, a `>=` cursor is never hinted `keyset`, and `i + 1` self-calls count as
RC-3. Two blind spots stay: no Python lane for a regex compiled per iteration, and a precompiled
regex's method call in a loop is not CX-4 (correctly: compiled once).

## The 3,000-hit path

1. **Split by side.** Server roots first (request, consumer, job), shared packages second, browser
   last; a browser-only row is low severity at most.
2. **Trust the arithmetic, check one cap per source.** Rows with hint `set`, `string`, `const-small`
   or `below-budget` are not read one by one: read **one** row per cap source (`const:<NAME>`,
   `schema:<file:line>`, `limit:<NAME>`) to confirm the cap is real and enforced on the path, then
   close the group with that citation in the coverage block.
3. **Group the open rows** by (file, receiver) and by cap source; one read decides a group that shares
   a receiver and a loop source.
4. **Rank** by reachability (an unauthenticated route, a list or import endpoint, a consumer) and by
   `ops` where known; read the top 20 groups fully.
5. **Prefer the oracle at scale.** For a suspected hot module, run V590's counter over the module's
   own tests with the fixture size raised to N and 4N; every function whose count ratio is ≥ 12×
   is confirmed in one run.
6. **Ledger the rest** as `Live, unreviewed (k groups / n rows)` with the command that continues
   them (`awk -F'\t' '$13=="uncapped" && $1 ~ /<dir>/' "$OUT/sites.tsv"`). Never mark them `checked`.

## Scans for

| § | Class | CWE | Default severity |
|---|---|---|---|
| 1 | CX-1 linear lookup inside a loop over a data-sized collection | CWE-407, CWE-1067 | high on a request path with tenant-sized n; else medium |
| 2 | CX-2 copy-per-iteration accumulation | CWE-407, CWE-1046 | medium |
| 3 | CX-3 O(n) per-operation container use in a loop | CWE-407 | medium |
| 4 | CX-4 loop-invariant recompute per iteration | CWE-1176 | low–medium |
| 5 | CX-5 nested-loop join of two data-sized collections | CWE-407 | medium |
| 6 | CX-6 combinatorial generation without the downstream cap | CWE-407, CWE-770 | low (browser) – high (server) |
| 7 | RC-1 recursion depth set by input with no cap | CWE-674 | medium; high when unauthenticated or the runtime dies (Go) |
| 8 | RC-2 traversal of a structure that can hold a cycle, no visited set | CWE-835, CWE-674 | high (hang) |
| 9 | RC-3 exponential recursion over overlapping subproblems, no memo | CWE-407 | medium–high |
| 10 | RC-4 loop whose progress is not guaranteed | CWE-835 | high (hang) |

### 1. Linear lookup inside a loop (CX-1)

```
BAD:   orders.filter(o => ids.includes(o.id))            · for x in items: if x in seen_list: …
       for (String x : xs) if (ids.contains(x)) …       · xs.each { |x| ids.include?(x) } · in_array($x, $ids) in foreach
GOOD:  const wanted = new Set(ids); orders.filter(o => wanted.has(o.id))   · seen = set() · Set<String> · array_flip once
```
**Read the code:** What bounds each collection (the row's `n_src` / `m_src`, checked in the file it
names)? Is the receiver really a list — or a `Set`, a string, a constant? Is the work per request,
per job, per frame? Is the receiver pushed to in the same loop (a dedupe-by-scan, kind
`accumulator`)? Does a library already hash (`_.difference` at ≥ 200 items; `precedents/algorithms.md`
§ 3)? **Finding when** both sides grow with users or data past the budget on a production path.
**Proof:** V590 — a counter on the lookup at N and 4N (≈ 16× before, ≈ 4× after).

### 2. Copy-per-iteration accumulation (CX-2)

```
BAD:   xs.reduce((acc, x) => ({ ...acc, [x.id]: x }), {})   · out = [...out, x] in a loop · all = all.concat(x.items)
       acc = acc + [r] · sum(lists, []) · s = s + "," + x in a Java/C#/Go loop · inject { |a, x| a + [x] }
GOOD:  const byId = {}; for (const x of xs) byId[x.id] = x  · out.push(x) · acc.append(r) · itertools.chain · StringBuilder
```
`acc.concat(x)` is the non-fix a lint rule does not flag (`precedents/algorithms.md` § 3 for
lodash; Sentry #7910). **Read the code:** Is the accumulator a persistent structure (Immer,
Immutable.js: § 7)? Is the JS string only appended (§ 4) or a CPython local (§ 5)? **Finding when**
the accumulator grows with a data-sized loop. **Proof:** V591.

### 3. O(n) per-operation container use (CX-3)

```
BAD:   while (queue.length) { const n = queue.shift(); … }   · q.pop(0) · list.remove(0) · RemoveAt(0) · append([]T{x}, s...)
GOOD:  a head index (`for (let i = 0; i < q.length; i++)`) · collections.deque · ArrayDeque · Queue<T> · a ring buffer
```
**Read the code:** Is the queue capped (`if (q.length >= MAX) throw` — the row's `guard` source; a
bounded FIFO is § 8)? Does it grow with the input (a BFS frontier, an import buffer)? **Finding when**
the container grows with data and each operation shifts it. **Proof:** V592.

### 4. Loop-invariant recompute (CX-4)

```
BAD:   for (const u of users) { const sorted = rates.slice().sort(cmp); const s = new Set(allowed); … }
       for (const x of xs) new RegExp(pattern) · Pattern.compile(p) / regexp.MustCompile(p) in a loop · sorted(prices) per user
GOOD:  hoist above the loop: const sorted = …; const s = new Set(allowed); const re = new RegExp(pattern)
```
The script keeps a site only when nothing in its subject is a loop variable or is declared inside the
loop body. **Read the code:** Is the subject really invariant across iterations? Python's `re` cache
and a JS regex literal are § 9. A per-*call* construction (once per request) is `request-path-scan`
§8, not here. **Finding when** invariant work of size m runs n times on a data-sized loop.
**Proof:** V593 (a spy counts one call per invocation).

### 5. Nested-loop join (CX-5)

```
BAD:   for (const a of as) for (const b of bs) if (a.id === b.aId) …   · teams.map(t => ({ …t, members: all.filter(m => m.teamId === t.id) }))
GOOD:  const byA = Map.groupBy(bs, b => b.aId)  (or a Map built once);  as.map(a => byA.get(a.id) ?? [])
```
A `filter` inside a `map` over a second collection is the same class (the script reports it as CX-1
on the inner receiver). **Read the code:** Do both sides scale? Is one a fixed list? **Proof:** V590.

### 6. Combinatorial generation without the downstream cap (CX-6)

```
BAD:   const combos = cartesian(attributes.map(a => a.values));  …  if (combos.length > MAX_SKUS) reject();
GOOD:  generate lazily and stop at MAX_SKUS + 1 (then refuse) · or refuse at the input: Π |values| ≤ MAX_SKUS
```
**Read the code:** Where is the downstream limit (a maximum the server enforces) and does generation
stop at it, or filter after it? What caps the factors (attribute count × values each)? **Finding
when** the product of the factors can exceed the downstream cap by orders of magnitude before the cap
applies. **Proof:** V594.

### 7. Recursion depth set by input (RC-1)

```
BAD:   function render(node) { return [node, ...node.children.flatMap(render)]; }   // a user-built tree, no depth cap
GOOD:  function render(node, depth = 0) { if (depth > MAX_DEPTH) throw new TooDeep(); … }  · or an explicit stack
```
**Read the code:** Where does the depth come from — parsed JSON under a body limit (grep 0b; § 12), a
trusted AST (§ 17), a stored tree a user edits? What does the runtime do at the limit (Node
`RangeError`, catchable; Python `RecursionError`; Java `StackOverflowError`, often uncaught; Go grows
to 1 GB, then dies fatally)? Is the handler unauthenticated? A `json-tree` row is § 12's candidate;
an `RC-1?` row is read. **Finding when** input sets the depth with no cap and the overflow fails more
than the one request. **Proof:** V595 (a depth bomb in a child process; measure, never quote a frame
count).

### 8. Traversal that can meet a cycle (RC-2)

```
BAD:   const rootOf = (c) => (c.parent ? rootOf(c.parent) : c);       // parent editable by users
       WITH RECURSIVE t AS (… UNION ALL SELECT c.* FROM cat c JOIN t ON c.parent_id = t.id)   -- no CYCLE, no depth
GOOD:  a visited set and a depth cap · CYCLE id SET is_cycle USING path (PostgreSQL ≥ 14) · a strictly increasing key
```
**Read the code:** Can the data hold a cycle — a self-referencing parent key (grep 8) a user can set,
an imported graph, a `cause` chain? Is the walk root-down from `null` (§ 14)? Does a recursive CTE
advance on a strictly increasing key (§ 15)? **Finding when** a cycle is writable and the walk has no
visited set or depth bound. **Proof:** V596.

### 9. Exponential recursion without a memo (RC-3)

```
BAD:   const ways = (n) => (n < 2 ? 1 : ways(n - 1) + ways(n - 2));     // n from a request
GOOD:  a memo keyed on the full argument tuple · functools.cache · an iterative DP table
```
**Read the code:** Do the self-calls overlap? Is n user-sized or a small constant? Is the memo keyed
on every argument that changes the result (§ 16)? **Proof:** V597.

### 10. Loop progress not guaranteed (RC-4)

```
BAD:   for (;;) { const rows = await find({ createdAt: { $gte: after } }, { limit: 500 }); …; after = rows.at(-1).createdAt; if (!rows.length) break; }
GOOD:  a unique key (or a composite) with strict `>`, and exit on a short page: if (rows.length < 500) break
```
The recursion table prints each `for(;;)` / `while(true)` / `do … while` with its progress variable,
the cursor compare (`strict` / `non-strict`) and whether it exits on a short page. **Read the code:**
What strictly increases per iteration? Is the cursor unique? Is the exit an empty page a concurrent
insert can postpone, or a float compared with `!=`? A loop meant to run until shutdown is § 32.
**Finding when** a reachable input stalls the progress variable. **Proof:** V598.

## Output

```
complexity-scan — lanes: ts (algo-sites.py), python (algo-sites.py); eslint 10.10 present, biome/oxlint/ruff absent → NOT-RUN; go, jvm, .net, ruby, php, rust: no files
Context: body limit 1 MiB (src/main.ts:12) · tables: /tmp/complexity-scan.Ab12/sites.tsv, rec.tsv

Findings: 1 · Unconfirmed: 1

1. src/reports/roster-summary.service.ts:41                               [report-with-fix]   medium
   §5 nested-loop join — teams.map(t => members.filter(m => m.teamId === t.id)); teams ≤ 5,000 and
   members ≤ 5,000 (limit: MAX_REPORT_ROWS, src/reports/limits.ts:3) → 2.5e7 compares per call,
   budget 1e6. Reached by GET /reports/teams (admin). Proof: test/reports/roster-summary.ops.spec.ts —
   compares 16× from N=500 to N=2,000 (fails); after: 4×. Fix: group members once with Map.groupBy.

2. web/src/catalog/option-grid.ts:7                                [report-flagged]    low
   §6 combinatorial generation — the Cartesian product of every attribute's values is built before
   the server's MAX_SKUS = 500 applies; 20 attributes × 100 values are accepted. Unproven link: no
   component test harness. Settling test (V594): 6 × 10 values → generation stops at 501.

Verification: 9 candidates → 1 finding · 1 unconfirmed · 4 hardening · 3 refuted

Class coverage:
  §1  linear lookup in a loop      checked (553 rows: 245 closed by cap source, 9 groups read → 0) · Live, unreviewed (41 groups / 288 rows — continue with the awk line above on web/)
  §2  copy-per-iteration           checked (2 rows → 0: below budget)
  §3  O(n) container op            checked (8 rows → 0: two capped queues, § 8)
  §4  invariant recompute          checked (12 rows → 0)
  §5  nested-loop join             checked (4 rows → 1 finding)
  §6  combinatorial generation     checked (grep 6: 3 → 1 unconfirmed)
  §7  recursion depth              checked (10 rows: 6 json-tree under the body limit, 4 read → 0)
  §8  cycles                       checked (grep 8: 0 self-referencing parent keys; 7a: 1 CTE on a strict key, § 15)
  §9  exponential recursion        checked (0 rows)
  §10 loop progress                checked (13 loops: 6 keyset, 7 read → 0)
```

`[report-with-fix]`: the caps are cited, the entry point is traced, the counting test failed, and the
fix is a local change with the language's primitive. `[report-flagged]`: one link is unproven (no
harness, a cap read only in part, a recursion through code not read), and the row names it and the
settling test. The coverage block lists **all ten** classes on every run: `checked (k → m)` only when
the table or grep ran on a present lane; `N/A (<reason>)` only when the repo has no such surface (no
SQL, no unconditioned loop); `blocked (<reason>)` when a grep exited 2 or the script could not run. A
partial review reads `Live, unreviewed (k groups / n rows)`, never `checked`.

## False positives / precedents

The verifier reads [`precedents/algorithms.md`](../../../../snippets/precedents/algorithms.md)
(§ 1–§ 17, § 29–§ 33 for this skill) and
[`refute-before-report.md`](../../../../snippets/refute-before-report.md) § 4. The ones met most:
a side bounded by a cited constant (§ 1, § 29); an index built once outside the loop (§ 2); a bounded
FIFO (§ 8); parsed JSON under a body limit (§ 12); a walker with a depth cap and an ancestor set
(§ 13); a root-down parent walk (§ 14); a keyset loop on a unique key (§ 15); a memo (§ 16); a
process-lifetime loop (§ 32); a counted retry or Euclid's recursion (§ 33); an ORM `find(Entity, …)`
per element, which is database's N+1 (§ 30).

## When to run

- On any diff that adds or changes a loop over query results, request arrays or uploads; a
  `reduce`/`filter`/`find` inside another loop; a recursive function; a `for(;;)`/`while(true)`
  cursor loop; a recursive CTE; a generator of combinations — the `/analyze-complexity` dispatch
  signal.
- On every whole-repo `/analyze-complexity`, reporting all ten classes. `/perf-audit` lists §11's
  classes as `owner: algorithms` when this pack is installed.
- When a profile or a trace shows one function's time growing with a tenant's data size.

## Halt conditions

- Do not report a high row as confirmed until a verifier other than the run that found it has passed
  it through [`refute-before-report.md`](../../../../snippets/refute-before-report.md) § 1–§ 8. Under
  `/analyze-complexity` that is `@algorithm-designer` in refute mode plus `@verifier`; run standalone,
  apply the protocol as a separate second pass, tag those rows `self-verified`, and list the rest as
  Unconfirmed, Hardening or Refuted. Never drop them.
- Halt when any `rg` exits 2 or `algo-sites.py` exits 2: the class it feeds is `blocked`, never
  `checked`.
- Halt on any finding without the site `<file:line>`, the cap on each side (or the cited absence),
  the entry point, and the fix.
- Do not mark a row `[report-with-fix]` without an operation-count (or depth, cycle, progress) test
  that failed first. Never assert a complexity class from reading or from a model.
- Do not propose a cache, more replicas, a bigger stack (`--stack-size`, `setrecursionlimit`) or a
  timeout as the fix. The fix changes the data structure, adds the cap, the visited set, the memo or
  the strict cursor.
- Do not print "no complexity defect" without the class-coverage block.
- Do not re-derive a class another skill owns (§ Boundaries). Cross-link it.

## Related

- `/analyze-complexity` dispatches this skill with `algorithm-contract-scan` and
  `numeric-stability-scan` and folds the coverage blocks into its class ledger;
  `/design-algorithm --from-audit` closes the findings failing-first and re-runs the class's greps.
- `complexity-derivation` (this pack) derives the bound a row's arithmetic states; `@algorithm-designer`
  judges rows in refute mode.
- `request-path-scan` §8, §11, §13 and `memory-growth-scan` §1, §8 (performance pack, when installed);
  `n-plus-one-scan` (database pack, when installed); `injection-scan` §13 (security pack, when
  installed).
- `rules/algorithm-principles.md` — the budget table and the "right container" MUST this skill
  enforces.

Files in this skill

  • SKILL.md36.9 KB
  • scripts/algo-sites.py71.3 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.