Run the repo's own test suite fresh with branch coverage, measure
complexity, and join the two into a per-function CRAP score:
complexity² × (1 − coverage)³ + complexity, coverage = min(statement%, branch%) / 100. High CRAP means a function is both likely to break on a
change and unlikely to be caught by a test — the two properties multiply,
not add, so a function needs to be both complex and undertested to rank
high. audit.py's score does this math for every language this skill
supports; only the input side (normalize for Python, normalize_ts for
TypeScript) differs — the work here is getting real, fresh tool output and
turning the ranking into a report.
Python repos: radon (cc -j) + coverage.py (coverage json), joined by
audit.py's normalize. TypeScript repos (Vitest or Jest): a single
tool, @barney-media/crap-typescript-core, via its npx-invoked CLI
(@barney-media/crap-typescript), read by audit.py's normalize_ts. Its
coverage rule was verified to match this skill's exactly — see "TypeScript
path" below — so both languages feed the same unmodified score().
One pass, not two. Unlike the vulture/mutmut/jscpd-backed audits in this
family, CRAP's arithmetic is fully deterministic — there's no judgment call
between "the tool flagged it" and "it's really a hotspot" the way dead-code
sorts dead from dynamic. The judgment this skill does is upstream of the
score: discovering the right test command, keeping scope to real source, and
failing loudly rather than scoring on stale or partial data.
Buckets & categories
critical— CRAP ≥ 30 (Uncle Bob's classic gate).hotspot— CRAP ≥ the floor (15) but below 30.
Below the floor, a function isn't reported as a finding at all — see "Under-floor" below.
category names which term dominates the score: low-coverage when the
coverage penalty (complexity² × (1 − coverage)³) is at least as large as
the flat complexity term, high-complexity otherwise. A
high-complexity finding is well-tested but still risky because it's hard
to reason about; a low-coverage finding is the more urgent kind — the test
suite isn't watching it at all.
Run
Scope tight. Audit
$ARGUMENTSif given, else the current working directory. Exclude tests, fixtures, generated code, and vendored/ dependency trees from both the complexity scan and the coverage run — they inflate or deflate scores for code nobody is asking about:**/test_*.py,**/*_test.py,**/*.test.ts,**/*.spec.ts,**/tests/*,**/__tests__/*,**/fixtures/*,node_modules,.venv,dist,vendor, build output, lockfiles,.git/,worktrees/, and anything the repo itself marks generated (# generated/@generatedheaders,*_pb2.py, migration folders). TypeScript:crap-typescriptalready excludes test/spec files,__tests__/,dist/,coverage/, andnode_modules/by default — its--exclude/--exclude-path-regexflags are for anything beyond that baseline.Detect the language, then discover the test command by judgment. TypeScript if the scope has a
package.jsonplus atsconfig.jsonor.ts/.tsxsource files and no Python project marker takes priority; Python (pyproject.toml/setup.py/.pysources) otherwise. A mixed repo audits both, one pass each, same report.- Python: a
Makefiletarget namedtest/coverage, atest/coveragescript inpackage.json(a Python subproject driven from npm), or instructions inAGENTS.md/CONTRIBUTING.md. Falls back to:
If the repo already tracks auv run coverage run --branch -m pytest uv run coverage json -o coverage.json.coveragerc/[tool.coverage]config, respect itsomit/sourcesettings instead of overriding them — don't fight a repo's own exclusion list, add the audit's own exclusions (fixtures/vendored/generated) on top of it if missing. - TypeScript: don't discover a bespoke test command — the
crap-typescriptCLI (see step 4) drives the test run itself, via its own--test-runner auto|vitest|jestdetection. Only check that the coverage flags it needs are reachable: Vitest needscoverage.provider: 'istanbul'invitest.config.ts(its default provider,v8, does not emit the Istanbul-shapedfnMap/branchMap/statementMapthis whole pipeline depends on — a repo on thev8provider needs that one-line config change, or the tool falls back to no coverage and every function scorescov: null); Jest's defaultcoverageProvider: "babel"already produces this shape, nothing to add. If neither config is present and the repo can't be changed, use the ESLint/Istanbul fallback in "TypeScript path" below instead.
- Python: a
Run fresh, every time. Never reuse a stale
coverage.json/.coverage/coverage-final.jsonfile lying around in the repo — coverage numbers from a run before the current diff are worse than no numbers, because they look authoritative (Python: deletecoverage.jsonfirst; TypeScript: delete thecoverage/directory the test runner writes to before invokingcrap-typescript, which otherwise may reuse it). Fail loudly and stop — no partial score, no silent fallback to a stale file — if: the test command exits non-zero for reasons other than the CRAP threshold itself, coverage collection produces no coverage data at all, the complexity tool errors out, or (Python) radon and coverage.json share no file keys at all —normalizeraisesValueErrorfor that last case rather than silently scoring every function 0%/0%.normalizealso raises when only some radon files fail to join and the failure looks like the same file under two path spellings (its basename collides with an otherwise-unmatched coverage file) rather than a file that's genuinely never imported by the test run. Report exactly what failed and why; this audit needs real inputs to mean anything.Run the language's complexity+coverage tool. Resolve
<tmpdir>from$TMPDIR, fall back to/tmp— same resolution as step 6, done once and reused for every scratch file below.Python — radon for complexity, joined to the coverage.json from step 3.
<scope>here must be cwd-relative, exactly matching what step 3'scoverage jsonwrote its file keys as — coverage.py's keys are always cwd-relative, and radon'scc -jkeys mirror whatever path string it was invoked with verbatim. Run both from the same directory and pass the same relative path to both; an absolute<scope>here (e.g. from$ARGUMENTS) makes every radon key miss every coverage key, andnormalizenow raises loudly on that rather than silently scoring everything 0%/0%. That guard catches a total mismatch only — if some keys join and some don't (radon's scope is wider than coverage.py'ssource/omit, say), the unmatched files score 0%/0% silently and read as real findings. Compare the two key sets yourself when radon's scope and the coverage config were not derived from the same path:uvx radon cc -j <scope> \ --exclude "*/node_modules/*,*/.venv/*,*/dist/*,*/vendor/*,*/.git/*,*/build/*,*/worktrees/*,test_*,*_test.py,*/test_*,*/*_test.py,tests/*,*/tests/*,fixtures/*,*/fixtures/*" \ > <tmpdir>/radon.jsonTypeScript —
@barney-media/crap-typescript, exact-pinned vianpx(never a bare unpinnednpx crap-typescript, which floats to whateverlatestresolves to on the day the audit runs):npx --yes -p @barney-media/crap-typescript@0.5.1 crap-typescript \ --format json --test-runner auto <scope> > <tmpdir>/crap_typescript.jsonExit code
2means "CRAP threshold exceeded" (the package's own 6.0 default gate, unrelated to this skill's floor/gates) — not a failure of the run; still read<tmpdir>/crap_typescript.json, it's valid. Exit code1is a real failure (bad args, IO, parse error) — fail loudly per step- See "TypeScript path" below for the fallback if the package can't run at all.
Score. Feed the captured JSON to the tested pure core — same
score()either way:python3 ~/.agents/skills/crap-audit/audit.py <tmpdir>/radon.json coverage.json # Python python3 ~/.agents/skills/crap-audit/audit.py --ts <tmpdir>/crap_typescript.json # TypeScriptaudit.py'smain()prints the wholescore()result as one JSON object —findings(bucket ≥ floor),under_floor(count+ a smallsample),gates(classic,above_current_max),ranking(every row, sorted) — nothing further to call.Write the deliverables and render the report — the default output. This audit touches no code: it's a report, not a fix. Resolve
<tmpdir>per~/.agents/skills/all-audits/harness/AUDIT-RUN.md's write-and-deliver step. Write to<tmpdir>/crap-audit-<timestamp>/:findings.jsonl— one line per finding (CRAP ≥ floor), thescore()result'sfindingslist verbatim, each row already carrying the requiredbucket/file/line/category/summary/failurefields plusextra.{complexity,statement_coverage,branch_coverage, coverage,crap,recommendation}—recommendationis{action: "write tests"|"refactor", projected_crap}, the CRAP the function would have at full coverage (coverage's penalty term vanishes atcov=1, so this is justcomplexity);actionis"write tests"when that projection clears the classic gate,"refactor"when complexity alone already meets or exceeds it. On the TypeScript path the tool reports only the lower of the two axes, so the unmeasured one isnullhere — never the synthetic100.0normalize_tsuses internally to keepmin()honest. Readextra.coveragefor the effective figure.ranking.jsonl— the full-ranking asset: every scored function,score()'srankinglist, one line each, sorted by CRAP descending. This is the calibration asset — what a repo re-scores against if it adopts a different gate later. Not filtered by the floor. Carries the same null convention asfindings.jsonl: on a TypeScript row, the unmeasured axis isnullhere too, not the synthetic100.0normalize_tsuses internally to keepmin()honest —score()nulls it once, inranking, before derivingfindingsfrom it. A TS row also carriescoverage_axis(stmt,branch, orboth) naming which axis was really measured; Python rows have nocoverage_axisand both their axes are always measured.report.html— a grouped visual-teach summary, following~/.agents/skills/all-audits/harness/findings-schema.md(the shared JSONL/summary contract) and~/.agents/skills/all-audits/harness/HTML-REPORT.md(asset delivery — copybase/,components/callout,components/chipnext to the report and link relatively;<!doctype html>on line 1; notype="module").- Header —
vt-kicker"crap-audit",<h1>repo name,vt-ledeone-line verdict,vt-metabar:"N scored · F findings (C critical · H hotspot) · U under floor". - What to fix first, right after the lede — the top offenders
(findings sorted by CRAP descending, same order as
ranking.jsonl; 5-10 is plenty) each as one line built fromextra.recommendation:low-coverage/write-tests —"write tests for <name> — full coverage drops CRAP from <crap> to <projected_crap>";high-complexity/refactor —"refactor <name> — even full coverage leaves CRAP at <projected_crap>, above the gate"(or "below the gate" ifprojected_crap < gates.classic— a refactor recommendation withprojected_crapunder the classic gate still means shrinking complexity, just not urgently). This is the action list; the grouped overview and standouts below it are the supporting detail, not the other way around. - Gate values, named explicitly in the lede or a
vt-callout— bothgates.classic(30, the fixed Uncle Bob gate) andgates.above_current_max(the smallest integer strictly above this run's worst score, i.e. a gate the repo could adopt today with zero existing failures). Naming both is a hard requirement, not a nice detail — this is the "adopt a CI gate" decision the report exists to inform. - Grouped overview — findings by bucket then category
(
critical/hotspot×low-coverage/high-complexity) with counts, plus a per-file roll-up if the repo is large. - Standouts — a
vt-calloutnaming the handful of highest-CRAP functions byfile:line, not every finding. - Under-floor, expandable and sampled, never dumped in full — show
under_floor.countplainly, andunder_floor.sample(already capped byscore()) behind a<details>/expandable section. A repo can have hundreds of clean functions; don't list them. - Full record — one line pointing at
findings.jsonland one atranking.jsonl, both beside the report.
- Header —
Then open the report (
xdg-open/open/start) and print its absolute path — unless invoked from inside anall-auditssweep, which instead names a manifest path in its prompt to write to (see~/.agents/skills/all-audits/harness/AUDIT-RUN.md#the-manifest-559) and says not to open anything.
TypeScript path
@barney-media/crap-typescript-core's coverage rule was verified against
this skill's coverage = min(statement%, branch%) / 100 by reading the
published package's source (coverageNormalization.js): its
combineCoverageMetrics takes Math.min(...measuredPercents) over
statement and branch, same rule, and its crapScore.js computes
complexity² × (1 − coverage)³ + complexity, the same formula. Verdict:
matches — no fallback needed for the arithmetic. The one real gap is
shape, not math: the CLI's --format json report exposes only the
already-combined cov/covKind per method (which axis was lower), never
both raw percentages — normalize_ts (~/.agents/skills/crap-audit/audit.py) fills the
non-dominant axis with 100.0 (can't be the minimum), so score() —
ticket 1's, unmodified — still reproduces the package's own crap value
exactly. See ~/.agents/skills/crap-audit/fixtures/ts_sample_project/answer-key.md for the
full worked verification, including both covKind: "N/A" cases (missing
coverage data vs. structural_na).
Fallback, only if the package itself can't run against a repo (parse
failure, unsupported config): ESLint's complexity rule at max: 0 with
--format json for per-function complexity (the rule always computes the
real value; max: 0 forces every function into the report, and the number
is embedded in the message string), plus a hand-rolled spatial join of
Istanbul's coverage-final.json (fnMap for function ranges,
statementMap/s and branchMap/b for per-function statement/branch
coverage — Istanbul does not provide this join itself) — worked in full,
including the exact Vitest/Jest commands and known gaps (arrow-function
fnMap attribution, TS source-map coordinate space, v8/istanbul
provider non-equivalence), in docs/research/crap-ts-tooling.md
(research/crap-ts-tooling branch, commit 2bb412b).
Verify against the fixtures
Python — ~/.agents/skills/crap-audit/fixtures/sample_project/ (radon.json +
coverage.json, real captured tool output) plus
~/.agents/skills/crap-audit/fixtures/sample_project/answer-key.md (the worked-by-hand CRAP arithmetic) is
the acceptance fixture: inner at 32.244 (critical), uncovered_fn at
20.0 (hotspot), three functions under the floor (entirely_uncovered,
outer, branchless_fn), gates classic=30 / above_current_max=33.
TypeScript — ~/.agents/skills/crap-audit/fixtures/ts_sample_project/
(crap_typescript.json, real captured crap-typescript --format json
output, plus the small Vitest project it was captured from) and
~/.agents/skills/crap-audit/fixtures/ts_sample_project/answer-key.md: inner (nested,
radon-closures-equivalent) at 24.432 and uncoveredFn at 20.0, both
hotspot; arrowFn (the arrow-function attribution case) at 2.0, under
floor along with entirelyUncovered/outer/branchlessFn; gates
classic=30 / above_current_max=25.
Running this skill's steps 5–6 directly against either fixture's captured
JSON (skipping the test-run/tool-run steps, since it's already captured)
should reproduce its table exactly — ~/.agents/skills/crap-audit/audit_test.py already
asserts this programmatically for both scoring paths; this skill's own dry
run confirms the report-writing step reproduces the same numbers end to
end.