# Codebase Value Audit

> Use when asked whether a codebase's size is justified — "is 800k lines reasonable?", "audit our LOC", "are we bloated?", "值不值" — or when a LOC number is being used in an argument and nobody agrees what it measures. Produces a defensible per-sub-product accounting: strict line counts, a product-surface inventory, every line attributed to exactly one sub-product, and a worth verdict per block.

- Skill: `innei/codebase-value-audit` (Agent Skill, multi-file: 6 files)
- Install (CLI): `npx skillmds@latest add innei/codebase-value-audit`
- Raw SKILL.md: https://api.skillmd.com/api/skills/innei/codebase-value-audit/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Security
- Author: innei (https://skillmd.com/u/innei)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/innei/codebase-value-audit

---


# codebase-value-audit

Turns "our repo is N lines, is that bad?" from a vibes debate into an
audit. The core move: a raw LOC number is meaningless until (a) the
measurement lens is pinned, (b) the product surface it bought is
inventoried, and (c) every line is attributed to one sub-product and
judged against what a standalone alternative would cost. The output is a
report where the verdict is per-block, not global — because the honest
answer is always "these 4 blocks are gold, these 4 deserve a discount."

## When to use

- Someone quotes a repo-wide LOC figure and asks if it's reasonable.
- Two people quote different LOC figures for the same repo and both are
  "right" (different lenses) — reconcile before arguing.
- Deciding where to cut: which subsystems dilute maintenance attention.

Not for: per-PR review, performance work, or dead-code sweeps alone
(knip/depcheck do that); this skill tells you *which blocks* deserve a
sweep.

## Inputs

- Repo root (git-tracked; works on monorepos).
- Optional: the number the requester believes ("we have 800k lines") —
  you must reconcile your count with theirs, not just assert your own.

## Files provided

- `scripts/count-pure-loc.sh` — strict source-file list + pure-code line
  count (3 lenses printed side by side).
- `scripts/bucketize.py` — attributes every file to the first matching
  bucket prefix and sums pure-code lines per bucket.
- `scripts/drill-bucket.py` — aggregates one bucket's lines per
  directory at a chosen depth; mandatory before verdicts (step 4).
- `scripts/render-report.py` — renders the final report as a standalone
  HTML file (light/dark, verdict chips, share bars) from a JSON data
  file; schema in `references/report-data.example.json`.

## Workflow

```text
[1] Pin the lens        3 counts: tracked-total / non-test / pure-code
[2] Inventory surface   parallel read-only agents, one per layer
[3] Attribute lines     bucketize.py: every line -> exactly one bucket
[4] Verdict per block   5 tiers, judged vs standalone-alternative cost
[5] Report              JSON data -> render-report.py -> HTML + open
```

### [1] Pin the lens

Run `scripts/count-pure-loc.sh <repo-root>`. It prints three numbers:

1. **Tracked total** — all git-tracked TS/JS lines.
2. **Non-test physical** — minus tests/fixtures/e2e/scripts/mocks/
   stories/`.d.ts`.
3. **Pure code** — minus blank and comment-only lines.

These differ by 2x+ on a healthy repo (1.84M / 1.06M / 836k on the repo
this skill came from). When the requester's number differs from yours,
find which lens produces their number and say so explicitly — "your 800k
is lens 3, my 1.84M is lens 1, both correct" ends the argument; a
counter-assertion restarts it. All later steps use lens 3.

Also record: test:code ratio, and the largest data-as-code files (model
catalogs, locale tables, generated schemas) — they surface in step 4.

### [2] Inventory the product surface

Dispatch parallel read-only agents (Explore-type), one per layer, e.g.:
web client (routes/stores), server (routers/services/modules), desktop +
CLI, shared packages, and **docs/changelog** (the marketed feature list —
this one cross-checks the code-derived inventory against what the product
claims to ship; discrepancies are findings). Each agent returns a breadth
inventory: surfaces, one-liners, and a "full sub-products" shortlist —
no code review, no line counts.

Merge into a sub-product list (typically 15–25 entries). A sub-product is
something that could be a standalone company/tool: an eval platform, a
bot gateway, a document editor — not a feature like "dark mode".

### [3] Attribute every line

Copy `scripts/bucketize.py`, fill in the bucket → path-prefix table from
step 2's inventory, run it against the file list from step 1. Rules:

- First match wins; order buckets specific → general.
- One catch-all `infra` bucket with prefix `""` must be last; inspect its
  breakdown output — anything large hiding in it means a missing prefix.
- Cross-layer buckets are the point: one sub-product's bucket lists its
  frontend routes, its stores, its server services, its packages, and its
  CLI commands together.
- Expect ±10% precision; say so in the report. Sum must equal lens-3
  exactly (the script guarantees it — every file lands somewhere).

### [4] Verdict per block

**No verdict without a drill-down.** Before judging any bucket ≥ ~15k
lines, run `scripts/drill-bucket.py <file-list> <depth> <prefix...>` and
scan its top directories for the five water categories:

| Category | What it looks like |
| --- | --- |
| DEAD-ROUTED | deprecated pages/components still imported or routed — verify with grep against router configs and import sites, never by name alone |
| DATA-AS-CODE | large TS files that are pure declarations (catalogs, menus, prompt text) |
| SQUATTER | content semantically belonging to another domain living under this bucket's paths (feature pages under a settings route) |
| PARALLEL-DUP | two generations/implementations of the same thing coexisting |
| OVERSIZED | files past the repo's own size convention — count and total them |
| FIXTURE-IN-PROD | test fixtures / dev harnesses living outside test dirs, counted as product code |

Bucket-level verdicts issued without this pass systematically miss mixed
buckets: a "settings" bucket can be 60% provider forms + squatting
feature pages + dead routes, and the whole-bucket verdict ("necessary
cost") will be confidently wrong. Parallelize: one read-only subagent
per 2–3 buckets, each returning findings with line estimates and
file-path evidence; require them to verify DEAD claims and to say
"clean" when a category is empty — invented water is worse than missed
water.

**Then refute before publishing.** Every water claim from the first pass
gets attacked in a second adversarial round: prompt agents to disprove
each claim with import graphs, normalized diffs, and actual-wiring
traces. Expect casualties — in the audit this skill came from, 5 of 8
first-round claims fell (a "duplicate subsystem" was a dependency; "two
workflow generations" were layers; an unused-keys report was 43% false
positives). Publish only claims that survive, and record the refuted
ones in the report as corrections — an audit that shows its own
overturned accusations is more credible, not less.

Then judge each sub-product's pure-code count against **what acquiring
that capability standalone would cost** (build or adopt), not against
zero. Five tiers:

| Tier | Criteria |
| --- | --- |
| core asset | Directly implements the product's differentiation; rebuilding or adopting would cost more; high leverage per line |
| market price | Table-stakes capability at a normal size for what it does |
| risk bet | Coherent and well-built, but its value depends on an unproven product thesis — flag "watch usage data" |
| discount | Competes head-on with dedicated products without an edge, or carries measurable waste (data-as-code, duplicated generations, copy-paste variants) |
| necessary cost | Settings, auth, base layer — nobody loves it, nothing works without it; judge only its share (base layer ≤ ~20% is healthy) |

Also assemble the **squeeze list**: concrete removable waste with line
estimates (coexisting old/new generations of a subsystem, overlapping
sibling services, data-as-code to externalize, convention violations,
unused i18n keys, stale packages). This is the actionable output.

### [5] Report

Lead with the global verdict and its split (e.g. "70% worth it / 13%
core / 18% discounted / 8–10% pure squeeze"). Then: lens reconciliation,
the ranked table (lines, share, verdict chip), tier commentary, squeeze
list, and a closing that answers the requester's actual question. If they
invoked an "AI era = fewer lines" thesis, engage it honestly: writing
cost fell, review/maintenance cost didn't; the metric that matters is
features per maintenance unit, and a ~1:1 test ratio is agent-era
scaffolding, not bloat.

To ship it as a page: fill a JSON data file following
`references/report-data.example.json` (in prose fields `[[…]]` renders
bold, `((…))` renders accent-highlight), then

```bash
python3 scripts/render-report.py report-data.json report.html && open report.html
```

Output is self-contained HTML (inline CSS, system fonts, light/dark).
When a verdict gets challenged and revised, keep the row, annotate it
(REV note in eyebrow/footer + a breakdown in its `sub`), and re-render —
an audit that silently rewrites itself loses its authority.

## Common pitfalls

| Mistake | Fix |
| ------- | --- |
| `xargs wc -l \| tail -1` as the total | xargs batches into multiple `wc` calls; `tail -1` returns the last batch only. Sum with `awk '$2 != "total" {s+=$1}'`. |
| Counting merge-conflicted files 3x | `git ls-files` lists each unmerged file once per stage. Always `sort -u` the file list. |
| Arguing totals before pinning the lens | Both parties are usually right under different lenses. Reconcile first (step 1), then argue. |
| Running the counting script from the wrong cwd | Relative paths silently `open()`-fail and count 0 per file; totals become 0 without an error. Run from repo root; treat an all-zero bucket table as this bug, not as truth. |
| Treating a high test share as waste | ~1:1 test:code is the agent-era signature — it's the guardrail that lets agents keep writing. Cut discipline debt, not tests. |
| Data-as-code inflating "logic" | Model catalogs, locale tables, generated schemas are declarations. Measure them separately and recommend externalizing, or the density math is wrong. |
| Inventorying from code only | Also sweep docs/changelog/README with an agent — the marketed surface reveals sub-products the directory names hide, and mismatches are findings. |
| Catch-all bucket swallowing a subsystem | Order prefixes specific → general; always print the infra-bucket breakdown and chase anything unexpectedly large. |
| Verdict by size alone | A 10k-line block can be the highest-leverage asset (device gateway) and an 80k block half-waste (provider variants). Judge vs standalone-alternative cost. |
| Global verdict only | "Worth it overall" helps nobody decide anything. The per-block table with tiers is the deliverable; the global number is its summary row. |
| Defending a challenged verdict without drilling down | When a block's size surprises the requester ("why is settings 20k?"), re-aggregate that bucket one directory level deeper before answering. Route-named buckets often hide squatters: provider forms, feature pages living under a settings route, deprecated-but-routed dead pages. The challenge is usually a real finding. |
| Trusting a static "unused" report | An unused-i18n-keys report was 43% false positives: dynamic key construction, string-literal keys inside config objects, and plural suffixes all defeat static detection. Sample-verify against those three mechanisms before acting; a report whose own statistics are self-contradicting (usage rate > 100%) is telling you something. |
| Convicting by name or shape | "workflows/ vs workflows-hono/" looked like two generations — it was business logic + HTTP adapter, layered. "PluginDevModal" looked legacy — it was misnamed MCP UI. Prove duplication with import graphs and normalized diffs; naming is not evidence. |
| Treating a registry/identifier file as the wiring | An identifiers array turned out cosmetic (tools wired via register.ts and server runtimes); a desktop controllers registry.ts turned out type-only (runtime uses import.meta.glob). Find the mechanism that actually loads things before declaring anything unregistered/dead. |
| Publishing the first round's water list | Run the refutation round (step 4). First-pass water claims are hypotheses; in practice a large fraction die under import-graph scrutiny, and shipping them unverified torches the audit's credibility on the claims that were right. |

## Verification

- [ ] The three lens counts are internally consistent (lens1 > lens2 >
      lens3) and the requester's number is mapped to one of them.
- [ ] Bucket sums equal the lens-3 total exactly; file list was
      `sort -u`'d.
- [ ] Infra/catch-all breakdown inspected; nothing unexplained > ~5%.
- [ ] Every bucket ≥ ~15k lines went through the drill-down pass; each
      water finding carries a category, a line estimate, and verified
      evidence paths.
- [ ] Every sub-product has: lines, share, tier verdict, and a one-line
      justification referencing a standalone alternative or a concrete
      waste observation.
- [ ] Squeeze list items each carry a line estimate and a concrete fix.
- [ ] Report states the ±10% attribution caveat and any excluded scopes
      (e.g. closed-source stubs).
- [ ] `render-report.py` output opens cleanly in a browser; row count,
      total, and share bars match the bucketize output.

