prune-os
v0.9.0
Published
Code simplification tool - same functionality, less complexity
Maintainers
Readme
PruneOS
Same code, less of it.
PruneOS is a fast, zero-config code simplifier for TypeScript and JavaScript.
It removes verbose patterns that AI-generated code (and humans) tend to write,
without changing behaviour: redundant === true, x !== null && x !== undefined,
nested if/else chains that should be guard clauses, unused imports, duplicate
same-source imports, and more.
It has two modes:
pruneos <files>: per-file simplifier. Removes line-level verbosity. Fast.pruneos audit: repo-wide deletion-first audit. Finds unused exports, single-use abstractions, overengineering patterns (*Manager,*Factory, empty interfaces, one-method classes), AI slop (// TODO, mock data, empty rethrows), and over-fragmented component directories.
It also ships a Claude Code skill so AI agents working inside your project run both modes automatically. See For AI agents below.
Example
A real function before and after pruneos -a --fix, 49 lines down to 42, six
distinct transformations across the diff. Behaviour is identical.
// before (49 lines)
import { fetchUser } from './api';
import { logEvent } from './log';
import { logError } from './log';
import { Cache } from './cache';
const cache = new Cache<string, string>();
export async function getDisplayName(id: string, fallback: string): Promise<string> {
if (id === null || id === undefined || id === '') {
return fallback;
}
const cached = cache.get(id);
if (cached !== null && cached !== undefined) {
return cached;
}
const user = await fetchUser(id);
if (user === null || user === undefined) {
logEvent({ type: 'user.miss', id: id, fallback: fallback });
return fallback;
}
let display: string;
if (user.name !== null && user.name !== undefined && user.name !== '') {
display = user.name;
} else {
if (user.email !== null && user.email !== undefined) {
display = user.email;
} else {
display = fallback;
}
}
cache.set(id, display);
return display;
}// after (42 lines)
import { fetchUser } from './api';
import { logEvent } from './log';
import { Cache } from './cache';
const cache = new Cache<string, string>();
export async function getDisplayName(id: string, fallback: string): Promise<string> {
if (id == null || id === '') return fallback;
const cached = cache.get(id);
if (cached != null) return cached;
const user = await fetchUser(id);
if (user == null) {
logEvent({ type: 'user.miss', id, fallback });
return fallback;
}
let display: string;
if (user.name != null && user.name !== '') {
display = user.name;
} else if (user.email != null) {
display = user.email;
} else {
display = fallback;
}
cache.set(id, display);
return display;
}What changed: unused logError import dropped, triple-empty checks collapsed,
dual !== null && !== undefined collapsed to != null, single-statement if
blocks made one-line, } else { if (...) } flattened to else if, object
property shorthand applied. Six rules; one diff; nothing else touched.
Install
# global
npm install -g prune-os
# or one-shot
npx prune-os src/ -r --fixRequires Node 18+.
For AI agents
If you let an AI agent write code in your project, install the Claude Code
skill that ships inside prune-os:
npx prune-os install-skill # writes .claude/skills/pruneos/SKILL.md
npx prune-os install-skill --global # writes ~/.claude/skills/pruneos/SKILL.mdThe skill tells the agent: after writing or editing any TS/JS file in this
project, run npx prune-os <those files> --check, and apply --fix if it
reports changes.
The effect: the agent's first draft still looks like an LLM wrote it
(defensive null checks, === true, redundant ternaries, single-statement
if blocks), but the agent strips that boilerplate before you ever read
the diff. You get the cleaner version on the first pass.
The skill is auto-discovered by Claude Code from .claude/skills/. It works
the same as any other skill: the agent reads its description, decides when
to apply it, and runs the documented commands.
You don't need the skill to use prune-os. The CLI works standalone.
60-second tour
pruneos app.ts # simplify one file in place
pruneos src/ -r --fix # simplify everything under src/
pruneos app.ts --diff # preview the diff, don't write
pruneos src/ -r --check # CI mode, exit 1 if anything would change
pruneos src/ -r --json # machine-readable report
cat app.ts | pruneos --stdin # pipe mode: simplified source on stdout
pruneos audit # cross-file audit (unused exports, slop, ...)
pruneos audit --fix --json # apply safe auto-fixes, emit JSON
pruneos install-skill # install the Claude Code skillExit codes: 0 clean, 1 --check found changes, 2 a file failed to
process. Output is deterministic and idempotent — running pruneos on its
own output is a no-op, so hooks and agents can run it after every edit
without oscillating.
What you see
The summary tells you which patterns dominated the run and which files paid
the rent, not just an aggregate count. Example, real output on
bench/fixtures:
PruneOS v0.9.0
────────────────────────────────────────────────────────
✓ 23 simplified · 221 lines saved · 165 changes · 39ms · 9 unchanged
Top rules fired
x === true → x 76×
unused import removed 21×
null && undefined check → != null 15×
if (x) { return y; } → one line 9×
if/else both return → guard 6×
=> { return X; } → => X 1×
Top files
bench/fixtures/website/lib/format.ts 16 lines 36% 2 changes
bench/fixtures/website/app/pricing/page.tsx 10 lines 10% 4 changes
bench/fixtures/website/components/NewsletterForm.tsx 9 lines 10% 4 changes
bench/fixtures/website/lib/api.ts 8 lines 15% 2 changes
bench/fixtures/website/components/Hero.tsx 7 lines 8% 6 changesUse -v to see every file, --diff for a real LCS-based unified diff per
file, or --json for a structured report (the JSON includes per-rule counts
under rules).
Audit (cross-file deletion-first scan)
pruneos audit walks the repo, builds an export/import graph, and reports five
categories of findings:
| Category | What it catches | Auto-fix |
| -------------------- | ---------------------------------------------------------------------------- | ----------------- |
| unused-export | Exports nobody imports anywhere | Yes (--fix) |
| single-use-export | Exports used by exactly one file. Inline candidate. | No (advisory) |
| overengineering | *Factory, *Manager, *Provider, *Adapter with 0-1 uses. Empty interfaces. One-method classes with no state. | No (review) |
| slop | // TODO, // FIXME, // AI generated markers. mockX/dummyX/fakeX in non-test files. Empty rethrows. Empty catch {}. | Rethrows yes |
| fragmentation | Foo/{Foo.tsx, Foo.types.ts, Foo.utils.ts, index.ts} patterns. Suggests collapsing into one file. | No |
Example output on a deliberately-bad fixture:
PruneOS audit v0.9.0
────────────────────────────────────────────────
Unused exports (dead code) (8)
lib/format.ts:12 `unusedHelper` is exported but never imported anywhere
lib/UserManager.ts:2 `UserManager` is exported but never imported anywhere
...
Single-use exports (consider inlining) (2)
lib/format.ts:7 `capitalize` is exported but used by exactly one file
Overengineering patterns (4)
lib/format.ts:12 `unusedHelper` uses the `Helper` pattern but has no callers
lib/UserManager.ts:2 `UserManager` uses the `Manager` pattern but has no callers
lib/UserManager.ts:9 `interface EmptyConfig` is empty
lib/UserManager.ts:2 `class UserManager` has exactly one method and no state
AI slop / leftover markers (6)
lib/api.ts:3 try/catch that rethrows the same error unchanged
lib/slop.ts:1 Leftover marker comment: // TODO: replace with real logic
...
File fragmentation (1)
components/Button/Button.tsx:1 `Button/` is split across 4 files. Consider one file.
Total: 21 findings across 10 files.The audit respects package.json main/module/types/exports/bin and
any src/index.ts at the root. Exports re-exported through those entry points
are considered public API and never flagged. Add --only unused-export,slop to
filter categories, --json for machine-readable output, --fix to apply the
safe transforms (delete unused exports, remove empty rethrows).
Limitations: regex parsing, not a TS AST. Doesn't follow dynamic import('./x')
or tsconfig paths aliases. Symbols used only via those patterns may show up
as unused. Fix by adding a static import or moving them into the public entrypoint.
What it changes
| Pattern | Becomes |
| -------------------------------------------------- | -------------------------------- |
| x === true / x !== false | x |
| x === false / x !== true | !x |
| Boolean(x) / !!x | x (kept when comparison operand) |
| x !== null && x !== undefined | x != null |
| x === null \|\| x === undefined \|\| x === '' | x == null \|\| x === '' |
| x != null ? x : y | x ?? y |
| a === b ? true : false | a === b |
| if (cond) { return X; } else { return Y; } | if (cond) return X; return Y; |
| if (cond) return X; else { ... } | if (cond) return X; + unwrapped body |
| if (cond) return true; return false; | return cond; |
| if (cond) { return X; } | if (cond) return X; |
| const x = expr; return x; | return expr; |
| try { ... } catch (e) { throw e; } | unwrapped body |
| constructor() {} | removed |
| .filter(x => x) / .filter(x => !!x) | .filter(Boolean) |
| (args) => { return EXPR; } | (args) => EXPR |
| { foo: foo, bar: bar } | { foo, bar } |
| } else { if (...) { ... } } | } else if (...) { ... } |
| void 0 | undefined |
| Multiple import { a } from 'x' from same module | One merged import |
| Unused imports / unused locals | Removed |
| Runs of blank lines, trailing whitespace | Collapsed |
x !== null ? x : y is deliberately not converted to x ?? y: when x
is undefined the two forms disagree. Only the exact-equivalent forms
(x != null ? x : y and the dual null/undefined check) are rewritten.
Everything that's removed or rewritten is reported as a change in the JSON
output, so it's auditable.
What it preserves
- Functions, exports, types, interfaces, JSDoc.
- Public API of every module.
- Comments (unless they are commented-out code on their own line).
- The runtime behaviour of the original file.
If a transform isn't provably safe with a simple text rewrite, PruneOS doesn't do it. The goal is "I'd be happy to merge this diff," not "trust me."
Benchmark
npm run bench runs PruneOS over fixtures in bench/fixtures/. The
fixtures are real AI-generated code: full React components with hooks
and JSX, plus standalone utility modules.
Median of 5 runs, Node 24, Windows x64. Counts only real removals. Blank lines that were already in the source are preserved.
A full mini SaaS website
bench/fixtures/website/ is a small Next.js-style
landing site: 14 files, 898 lines. Layout, two pages (home + pricing), nine
components (header, footer, hero, feature grid, pricing card, testimonial,
newsletter form, FAQ list, cookie banner) and a lib/ folder.
pruneos bench/fixtures/website -r -a --fix
→ 14 files, 66 lines saved, 59 changes, ~16ms (7.3% reduction)Per-file range is 0-36%. Heaviest wins on utility files (lib/format.ts
at 36%, lib/api.ts at 15%) and the pricing card / hero components.
React components (smaller scope)
| Fixture | Lines | After | Saved | % | Changes | Time | | ---------------------------- | ----- | ----- | ----- | ----- | ------- | ---- | | components/UserDashboard.tsx | 167 | 148 | 19 | 11.4% | 15 | 1ms | | components/TodoList.tsx | 202 | 176 | 26 | 12.9% | 7 | 1ms | | components/SearchBar.tsx | 190 | 176 | 14 | 7.4% | 5 | 1ms | | React subtotal | 559 | 500 | 59 | 10.6% | 27 | 3ms |
Functional components with useState/useEffect/useCallback/useMemo,
conditional JSX, event handlers, and the verbose patterns AI likes to
emit (x !== null && x !== undefined, if (cond) { return X; } else
{ return Y; }, { foo: foo }). PruneOS removes the patterns it can
prove are safe and leaves the rest. Numbers count line reductions only;
they do not include in-line shortenings (e.g. a 5-line if becoming
a 1-line guard) which dominate the actual diff.
Other patterns
| Fixture | Lines | After | Saved | % | Changes | Time | | ---------------------- | ------ | ------ | ----- | ----- | ------- | ---- | | boolean-noise.ts | 307 | 215 | 92 | 30.0% | 61 | 3ms | | react-patterns.ts | 1,673 | 1,664 | 9 | 0.5% | 19 | 8ms | | notification-system.ts | 2,094 | 2,092 | 2 | 0.1% | 4 | 14ms | | state-store.ts | 2,695 | 2,692 | 3 | 0.1% | 15 | 13ms | | data-pipeline.ts | 3,765 | 3,752 | 13 | 0.3% | 16 | 23ms | | Total (all) | 11,991 | 11,747 | 244 | 2.0% | 201 | 70ms |
boolean-noise.ts is a synthetic upper bound on what's possible when the
input is dense with patterns PruneOS knows. The four utility files
(react-patterns, notification-system, state-store, data-pipeline)
are 70%+ JSDoc and type definitions; PruneOS preserves both, so the
percentage there is small by design.
If you're running PruneOS on actual component code, expect line reductions in the 7-15% range with zero behavioural risk. The diff itself is larger than the line count suggests, because most transforms shorten lines rather than removing them. Throughput is around 170k lines/second on commodity hardware.
The bench/RESULTS.md file is regenerated on every npm run bench and
contains the full table plus per-issue detection counts.
Reproduce
git clone https://github.com/Hi9841/PruneOS.git
cd PruneOS
npm install
npm run benchSet PRUNE_BENCH_ITERATIONS=20 npm run bench for a more stable median.
Comparative benchmark: deletion-first vs default AI
The CLI benchmark above measures one thing: how much PruneOS shrinks a file
after the code is written. The comparative benchmark in
benchmarks/ measures something different and more
interesting: how big is the diff an AI produces in the first place when
deletion-first constraints are active.
For each scenario the same task is solved twice from the same starting
files. Baseline is a default AI session. PruneOS is the same model with the
deletion-first system prompt and the PruneOS Claude Code skill
active. The runner then diffs each output against the starting fixture with
git diff --no-index --numstat.
Results
Three scenarios. Single-model self-play (Claude Opus 4.7). Behaviour verified by reading the diff, not by running tests. See caveats below before quoting these numbers.
| Scenario | Baseline files / new / net LOC | PruneOS files / new / net LOC | | ------------------------ | -----------------------------: | ----------------------------: | | complex-conditional | 2 / 1 / +26 | 1 / 0 / -19 | | duplicate-helper-cleanup | 4 / 2 / +7 | 3 / 1 / -5 | | minimal-bug-fix | 1 / 0 / +17 | 1 / 0 / +0 | | Suite total | 7 / 3 / +50 | 5 / 1 / -24 |
In every scenario the PruneOS run touched fewer or equal files, introduced
fewer new files, and produced a smaller net diff. On minimal-bug-fix the
baseline fixed the bug and refactored the surrounding function; the
PruneOS run changed the one line that contained the bug.
What the runs actually did
- complex-conditional. Baseline extracted a
PermissionRuleinterface and aPERMISSION_RULESarray in a new file. PruneOS rewrote the function as guard clauses in place. Same boolean output for the same inputs. - duplicate-helper-cleanup. Baseline created a
utils/directory with a barrel re-export and JSDoc. PruneOS added onedate.tsexporting one function. - minimal-bug-fix. Baseline rewrote the loop as
reduce, extracted alineTotalhelper, and added JSDoc. PruneOS changeditem.pricetoitem.price * item.quantity.
Caveats
- Self-play bias. Both sides are the same model. A different model, or a real engineer driving the baseline, may close the gap or invert it.
- Small fixtures. 10–25 LOC of starting code per scenario, chosen to be situations where AI overengineers. Real engineering work is bigger and messier.
- No automated correctness check. Behaviour preservation was verified by reading the diff. Not the same as running a test suite.
- LOC is not quality. Smaller diffs are reported as smaller diffs. A PruneOS run that won on lines by breaking behaviour would be a loss.
The full per-run breakdown, methodology, and a script to reproduce the
numbers live in benchmarks/RESULTS.md and
benchmarks/README.md. To re-run:
node benchmarks/measure.mjsCLI reference
| Flag | Description |
| --------------------- | ----------------------------------------------------- |
| -r, --recursive | Recurse into directories |
| -a, --aggressive | Run multiple simplification passes |
| -f, --fix | Write changes to disk (on by default unless --dry-run) |
| -d, --dry-run | Preview without writing |
| --diff | Show a unified diff per file (implies --dry-run) |
| -c, --check | CI mode - exit 1 if any change would be made |
| --json | Emit a machine-readable JSON report on stdout |
| -v, --verbose | Per-file output |
| -s, --silent | Suppress all non-error output |
| -e, --extensions | Comma-separated extensions, defaults to .ts,.js,... |
| --ignore | Comma-separated dirs to skip |
| -h, --help | Show help |
| -V, --version | Show version |
Programmatic API
import { analyze, simplify } from 'prune-os';
const code = await fs.promises.readFile('app.ts', 'utf-8');
const a = analyze(code);
console.log(a.metrics.cyclomaticComplexity, a.issues.length);
const r = simplify(code, { aggressive: true });
console.log(`saved ${-r.metrics.netChange} lines, ${r.changes.length} changes`);
await fs.promises.writeFile('app.ts', r.simplified);simplify returns { original, simplified, changes, metrics, issues }.
analyze returns the same metrics PruneOS uses internally (LOC, cyclomatic
and cognitive complexity, Halstead volume, maintainability index, unused
imports/vars, deep nesting, magic numbers).
CI integration
GitHub Actions:
- run: npx prune-os src/ -r --checkPre-commit hook:
npx prune-os "$(git diff --cached --name-only --diff-filter=AM | grep -E '\.(ts|tsx|js|jsx)$' | xargs)" --fixConfiguration
pruneos init writes a starter pruneos.config.ts template. Config file
loading is not wired up yet — today the CLI is controlled entirely by
flags (--extensions, --ignore, -a, ...). The template documents the
planned shape so projects can adopt it early; flag parity lands in a future
release.
Status
PruneOS is at v0.9. It's safe to run on real codebases (every transform is text-level, behavior-preserving, and reversible via git), and the simplify pass is guaranteed idempotent. The rule set is still growing — if you hit a pattern that should be simplified but isn't, open an issue with a minimal repro.
License
MIT - see LICENSE.
