diff --git a/.omp/commands/cleanup.md b/.omp/commands/cleanup.md
new file mode 100644
index 000000000..6550207f1
--- /dev/null
+++ b/.omp/commands/cleanup.md
@@ -0,0 +1,108 @@
+# Cleanup Command
+
+One iteration of an autonomous cleanup loop. Each run: discover ONE target, execute it completely, verify, report. Runs are stateless — derive everything from the current tree; assume prior iterations already happened and left the tree consistent.
+
+
+- Behavior-preserving ONLY. Observable behavior of the CLI, SDK, RPC surface, and rendered output NEVER changes.
+- Every iteration MUST deliver a named, concrete quality win (duplicate implementation gone, responsibility extracted, dead cluster removed, guard clutter deleted). Lean toward deletion: net-negative LOC is the expected shape and the tie-breaker between candidates, but justified net-neutral/positive work (a split, a hierarchy fix) is acceptable when the win is real. Report the LOC delta either way.
+- NEVER commit. NEVER touch generated or vendored code.
+- Complete the full cutover in this run: every copy migrated, every callsite updated, originals deleted. Half-migrations are worse than nothing.
+- No target clears the bar? Output exactly `CLEAN: no target above threshold` and stop.
+
+
+## Scope
+
+- TypeScript only. Priority order: `packages/coding-agent`, `packages/ai`, `packages/catalog`, `packages/utils`. Other packages MAY be edited only when callsite migration drags them in.
+- NEVER touch: `**/*-gen/**`, `**/vendor/**`, generated JSON catalogs, `.d.ts`, test fixtures/snapshots, lockfiles, anything non-TS.
+
+## 1. Discover
+
+Run the scanner first: `bun scripts/cleanup-scan.ts` (add `--json` for machine output, `--pkg=|all` to widen). It reports god-object candidates, clone clusters with line ranges, junk drawers, tiered dead-export candidates, deep relative imports, and defensive-check hotspots. Scanner output is EVIDENCE, not verdict — every entry still needs reading before action. Supplement with `lsp references` and targeted grep where the scanner is blind (semantic duplication, wrong-home modules with shallow imports).
+
+Candidate classes:
+
+**Dead weight** (highest value per risk)
+- Exported symbols with zero non-test references in the repo (scanner: `dead-exports`). Tiers: `barrel-public` (re-exported through an explicit `exports`-map entry or public barrel) = published surface, PROTECTED — external consumers exist that no tool can see. `wildcard-only` (importable only via a `./*` subpath pattern) = internal-by-default, deletable once proven.
+- Options/parameters no caller passes; branches no input reaches.
+- Compatibility shims, deprecated aliases, re-export indirection left by past refactors.
+- Runtime checks re-verifying what the type system already guarantees.
+
+**Duplication**
+- Scanner `clones` clusters give exact line ranges; literal-heavy boilerplate (schema tables, registry descriptors) is repetitive by design — extract only when a helper genuinely simplifies every site.
+- Same helper reimplemented in 2+ files; copies differing only by a literal or flag.
+- Inline reimplementations of an existing central utility (path shortening, truncation, spawning, stream reading, caching).
+- Parallel switch/if-chains that dispatch on the same discriminant in multiple places.
+
+**God objects**
+- Files whose size dwarfs their siblings AND mix responsibilities (state + IO + rendering + parsing in one module; classes whose method list spans several domains).
+- Size alone is not a smell — a large file with one coherent responsibility stays.
+
+**Hierarchy rot**
+- Junk drawers: modules named after no domain (`utils`, `helpers`, `misc`, `common`) accreting unrelated code.
+- Deep relative imports (`../../..`) signaling a module living in the wrong place.
+- Directories grouped by kind (`types/`, `constants/`, `interfaces/`) instead of domain.
+- Barrels re-exporting things nobody imports through them; single-file directories; module names that no longer describe contents.
+
+## 2. Select
+
+Score candidates by `(quality win × confidence) / blast radius`. Pick exactly ONE cluster, roughly ≤12 files touched. Tie-break: deletion > dedup > split > move; between equals, prefer the larger LOC reduction.
+
+Bar for "worth doing" — a quality win you can name in one sentence, e.g.:
+- Removes an entire duplicate implementation or ≥100 duplicated/dead lines.
+- Splits a file that is both oversized for its package and multi-responsibility.
+- Eliminates a junk drawer, dead-export cluster, or guard-clutter hotspot entirely.
+- Moves a module cluster so the tree reads as designed, not accreted.
+
+## 3. Execute
+
+**Dead weight / type checks**
+- Delete dead exports and the tests that only mirrored them. Two proofs REQUIRED before deleting any export: (1) `lsp references` shows no callsites — missed callsites are bugs; (2) the symbol is `wildcard-only`: not re-exported, directly or transitively, through any explicit `exports`-map entry or public barrel. Fails either proof? It stays.
+- Narrow once at the IO boundary; internal code takes the narrowed type. Delete downstream `?.` chains on non-nullable values, `?? fallback` on non-optional, `typeof`/`Array.isArray` re-narrowing, `as` casts papering over flow.
+- Value genuinely sometimes-absent? Fix the TYPE upstream; NEVER sprinkle guards downstream.
+- try/catch that swallows and limps on → delete it or let the error propagate. Precise catches (e.g. ENOENT) only.
+
+**Dedup**
+- 2+ copies → one function in the nearest common domain module; cross-package → the shared utils package. NEVER create a new junk drawer to hold it.
+- Copies differing by a literal/flag → one function with an options object. NEVER boolean positionals.
+- Prefer the hardened copy (timeouts, caps, sanitization) as the survivor; the fresh copies lose that hardening.
+
+**God objects**
+- Split along existing seams into domain-named modules; one responsibility each.
+- Extraction is MOVEMENT: code moves verbatim except imports/visibility. Rewriting-while-moving hides regressions.
+- Update every importer; NEVER leave a re-export shim. A split that introduces an interface, base class, event bus, or DI where a direct call existed is a failed split.
+
+**Hierarchy**
+- Move files with `lsp rename_file` so imports rewrite everywhere.
+- Group by domain, not kind. Collapse single-file directories; delete empty barrels.
+- After the move, the tree MUST read as if this were always the design.
+
+**Perf** (opportunistic — only inside code already being touched)
+- Hoist loop invariants; precompile regexes; single pass over chained filter/map on hot paths; drop intermediate arrays/strings/copies.
+- NEVER trade clarity for micro-perf on cold paths. NEVER add caching layers.
+
+## 4. Prohibitions
+
+- NEVER add: dependencies, config/options, feature flags, wrapper layers, abstractions with one implementation, "future-proofing".
+- NEVER rename or alter public surface. Public surface = the CLI, plus every symbol reachable from an explicit (non-wildcard) `exports`-map entry point or public barrel — external consumers exist beyond this repo's references. Wildcard `./*` subpaths expose files mechanically, not contractually; explicit entries and barrels are the contract.
+- NEVER reformat or restyle code outside the touched cluster.
+- NEVER do drive-by comment/doc sweeps; comment only new non-obvious code.
+- NEVER add tests for moved-but-unchanged code; keep existing tests passing, relocating them alongside their subject.
+
+## 5. Verify
+
+1. `bun check` — clean.
+2. Run the touched package's tests scoped to affected areas.
+3. Renderer/TUI code touched? Confirm sanitization helpers still wrap every render path.
+
+## 6. Report
+
+- Target: what was chosen and which smell class.
+- Actions: deleted / merged / split / moved, the named quality win, and the LOC delta.
+- Verification: exact commands run and results.
+- Risk: anything a reviewer should eyeball.
+
+
+- One target per run, executed to completion — full callsite migration, originals deleted, `bun check` clean.
+- A named quality win, behavior identical, no new abstractions, no shims. Deletion-leaning: justify any net-positive delta.
+- Nothing above the bar → output `CLEAN: no target above threshold`.
+
diff --git a/scripts/cleanup-scan.ts b/scripts/cleanup-scan.ts
new file mode 100644
index 000000000..198d35e4f
--- /dev/null
+++ b/scripts/cleanup-scan.ts
@@ -0,0 +1,492 @@
+/**
+ * Cleanup-loop discovery scanner. Feeds the `/cleanup` command's Discover phase
+ * with ranked, machine-generated candidates so each iteration starts from
+ * evidence instead of ad-hoc grepping.
+ *
+ * Reports (all heuristic — candidates, not proofs):
+ * clones near-duplicate code regions (normalized line-window hashing)
+ * god-objects oversized multi-responsibility files (LOC, exports, class methods)
+ * junk-drawers domain-less modules (utils/helpers/misc/common) accreting code
+ * dead-exports exported symbols with zero references elsewhere in the repo,
+ * tiered by exports-map exposure (barrel-public / wildcard-only)
+ * deep-imports files importing via ../../.. (wrong-home signal)
+ * check-density defensive-check hotspots (as-casts, ?., ??, typeof re-narrowing)
+ *
+ * Usage: bun scripts/cleanup-scan.ts [--json] [--top=N] [--pkg=ai,utils|all]
+ */
+import * as fs from "node:fs/promises";
+import * as path from "node:path";
+import { ts } from "@ts-morph/common";
+
+const DEFAULT_PKGS = ["coding-agent", "ai", "catalog", "utils"];
+const EXCLUDE = /(-gen\/|\/vendor\/|\.d\.ts$|\.test\.ts$|__tests__\/|\/fixtures\/|\/snapshots\/)/;
+const JUNK_NAME = /(^|[-_.])(utils?|helpers?|misc|common)\.ts$/;
+const CLONE_WINDOW = 7;
+
+interface FileInfo {
+ /** Repo-relative path. */
+ rel: string;
+ pkg: string;
+ text: string;
+ lines: string[];
+ /** Non-blank line count. */
+ loc: number;
+ /** Locally declared exported symbol names (excludes re-exports). */
+ exportedNames: string[];
+ /** `export * from` specifiers (relative only). */
+ exportStar: string[];
+ /** `export { a, b } from` re-exports: name → specifier. */
+ namedReexports: { name: string; from: string }[];
+ classes: { name: string; methods: number }[];
+ topLevelStatements: number;
+ deepImports: number;
+ checks: { asCasts: number; optChain: number; coalesce: number; typeofNarrow: number; isArray: number };
+}
+
+interface CloneRegion {
+ rel: string;
+ startLine: number;
+ endLine: number;
+}
+
+const args = new Map();
+for (const a of Bun.argv.slice(2)) {
+ const m = a.match(/^--([^=]+)(?:=(.*))?$/);
+ if (m) args.set(m[1], m[2] ?? "true");
+}
+const asJson = args.get("json") === "true";
+const top = Number(args.get("top") ?? 20);
+const pkgArg = args.get("pkg");
+
+async function listPackages(): Promise {
+ if (pkgArg && pkgArg !== "all") return pkgArg.split(",");
+ if (pkgArg === "all") {
+ const entries = await fs.readdir("packages", { withFileTypes: true });
+ return entries.filter(e => e.isDirectory()).map(e => e.name);
+ }
+ return DEFAULT_PKGS;
+}
+
+function parseFile(rel: string, pkg: string, text: string): FileInfo {
+ const sf = ts.createSourceFile(rel, text, ts.ScriptTarget.Latest, true);
+ const info: FileInfo = {
+ rel,
+ pkg,
+ text,
+ lines: text.split("\n"),
+ loc: 0,
+ exportedNames: [],
+ exportStar: [],
+ namedReexports: [],
+ classes: [],
+ topLevelStatements: sf.statements.length,
+ deepImports: 0,
+ checks: { asCasts: 0, optChain: 0, coalesce: 0, typeofNarrow: 0, isArray: 0 },
+ };
+ for (const line of info.lines) if (line.trim().length > 0) info.loc++;
+
+ const hasExport = (node: ts.HasModifiers): boolean =>
+ !!ts.getModifiers(node)?.some(m => m.kind === ts.SyntaxKind.ExportKeyword);
+
+ for (const stmt of sf.statements) {
+ if (ts.isImportDeclaration(stmt) && ts.isStringLiteral(stmt.moduleSpecifier)) {
+ if (stmt.moduleSpecifier.text.startsWith("../../..")) info.deepImports++;
+ continue;
+ }
+ if (ts.isExportDeclaration(stmt)) {
+ const spec = stmt.moduleSpecifier;
+ if (spec && ts.isStringLiteral(spec) && spec.text.startsWith(".")) {
+ if (!stmt.exportClause) info.exportStar.push(spec.text);
+ else if (ts.isNamedExports(stmt.exportClause))
+ for (const el of stmt.exportClause.elements)
+ info.namedReexports.push({ name: el.name.text, from: spec.text });
+ } else if (!spec && stmt.exportClause && ts.isNamedExports(stmt.exportClause)) {
+ for (const el of stmt.exportClause.elements) info.exportedNames.push(el.name.text);
+ }
+ continue;
+ }
+ if (ts.isClassDeclaration(stmt)) {
+ let methods = 0;
+ for (const m of stmt.members)
+ if (ts.isMethodDeclaration(m) || ts.isGetAccessor(m) || ts.isSetAccessor(m)) methods++;
+ info.classes.push({ name: stmt.name?.text ?? "", methods });
+ if (stmt.name && hasExport(stmt)) info.exportedNames.push(stmt.name.text);
+ continue;
+ }
+ if (
+ (ts.isFunctionDeclaration(stmt) ||
+ ts.isInterfaceDeclaration(stmt) ||
+ ts.isTypeAliasDeclaration(stmt) ||
+ ts.isEnumDeclaration(stmt) ||
+ ts.isModuleDeclaration(stmt)) &&
+ hasExport(stmt) &&
+ stmt.name &&
+ ts.isIdentifier(stmt.name)
+ ) {
+ info.exportedNames.push(stmt.name.text);
+ continue;
+ }
+ if (ts.isVariableStatement(stmt) && hasExport(stmt)) {
+ for (const decl of stmt.declarationList.declarations)
+ if (ts.isIdentifier(decl.name)) info.exportedNames.push(decl.name.text);
+ }
+ }
+
+ const t = text;
+ info.checks.asCasts = (t.match(/\sas\s+(any|unknown|[A-Z][\w.]*)/g) ?? []).length;
+ info.checks.optChain = (t.match(/\?\./g) ?? []).length;
+ info.checks.coalesce = (t.match(/\?\?/g) ?? []).length;
+ info.checks.typeofNarrow = (t.match(/\btypeof\s+[\w$.]+\s*[!=]==?/g) ?? []).length;
+ info.checks.isArray = (t.match(/Array\.isArray\(/g) ?? []).length;
+ return info;
+}
+
+/** Normalized significant lines for clone hashing: [normalizedText, originalLineNo][]. */
+function significantLines(info: FileInfo): [string, number][] {
+ const out: [string, number][] = [];
+ let inBlock = false;
+ for (let i = 0; i < info.lines.length; i++) {
+ let line = info.lines[i];
+ if (inBlock) {
+ const end = line.indexOf("*/");
+ if (end === -1) continue;
+ line = line.slice(end + 2);
+ inBlock = false;
+ }
+ const blockStart = line.indexOf("/*");
+ if (blockStart !== -1 && !line.includes("*/", blockStart)) {
+ line = line.slice(0, blockStart);
+ inBlock = true;
+ }
+ line = line
+ .replace(/(^|\s)\/\/.*$/, "$1")
+ .trim()
+ .replace(/`(?:[^`\\]|\\.)*`/g, '"S"')
+ .replace(/"(?:[^"\\]|\\.)*"/g, '"S"')
+ .replace(/'(?:[^'\\]|\\.)*'/g, '"S"')
+ .replace(/\b\d[\d._]*\b/g, "0")
+ .replace(/\s+/g, " ");
+ if (line.length < 6) continue;
+ if (/^(import\b|export \{[^}]*\} from|export \* from)/.test(line)) continue;
+ out.push([line, i + 1]);
+ }
+ return out;
+}
+
+class UnionFind {
+ #parent = new Map();
+ find(x: number): number {
+ let r = this.#parent.get(x) ?? x;
+ if (r !== x) {
+ r = this.find(r);
+ this.#parent.set(x, r);
+ }
+ return r;
+ }
+ union(a: number, b: number): void {
+ const ra = this.find(a);
+ const rb = this.find(b);
+ if (ra !== rb) this.#parent.set(ra, rb);
+ }
+}
+
+/** Detect duplicated regions via hashed sliding windows + union-find chaining. */
+function detectClones(files: FileInfo[]): { regions: CloneRegion[]; sigLines: number }[] {
+ const sig = files.map(significantLines);
+ const byHash = new Map(); // encoded position = fileIdx * 2^24 + windowIdx
+ const POS = 1 << 24;
+ for (let f = 0; f < files.length; f++) {
+ const s = sig[f];
+ for (let i = 0; i + CLONE_WINDOW <= s.length; i++) {
+ const h = Bun.hash(
+ s
+ .slice(i, i + CLONE_WINDOW)
+ .map(x => x[0])
+ .join("\n"),
+ );
+ let list = byHash.get(h);
+ if (!list) {
+ list = [];
+ byHash.set(h, list);
+ }
+ list.push(f * POS + i);
+ }
+ }
+ const uf = new UnionFind();
+ const matched = new Set();
+ for (const list of byHash.values()) {
+ if (list.length < 2) continue;
+ for (const pos of list) {
+ matched.add(pos);
+ uf.union(list[0], pos);
+ }
+ }
+ for (const pos of matched) if (matched.has(pos + 1)) uf.union(pos, pos + 1);
+
+ const clusters = new Map();
+ for (const pos of matched) {
+ const root = uf.find(pos);
+ let list = clusters.get(root);
+ if (!list) {
+ list = [];
+ clusters.set(root, list);
+ }
+ list.push(pos);
+ }
+
+ const results: { regions: CloneRegion[]; sigLines: number }[] = [];
+ for (const positions of clusters.values()) {
+ // Merge window positions into per-file line intervals.
+ const perFile = new Map();
+ for (const pos of positions) {
+ const f = Math.floor(pos / POS);
+ let list = perFile.get(f);
+ if (!list) {
+ list = [];
+ perFile.set(f, list);
+ }
+ list.push(pos % POS);
+ }
+ const regions: CloneRegion[] = [];
+ let sigLines = 0;
+ for (const [f, idxs] of perFile) {
+ idxs.sort((a, b) => a - b);
+ let start = idxs[0];
+ let prev = idxs[0];
+ const flush = (endIdx: number) => {
+ const s = sig[f];
+ regions.push({ rel: files[f].rel, startLine: s[start][1], endLine: s[endIdx + CLONE_WINDOW - 1][1] });
+ sigLines += endIdx + CLONE_WINDOW - start;
+ };
+ for (let k = 1; k < idxs.length; k++) {
+ if (idxs[k] > prev + CLONE_WINDOW) {
+ flush(prev);
+ start = idxs[k];
+ }
+ prev = idxs[k];
+ }
+ flush(prev);
+ }
+ if (regions.length < 2) continue;
+ results.push({ regions, sigLines });
+ }
+ return results.sort((a, b) => b.sigLines - a.sigLines);
+}
+
+/** Resolve a relative re-export specifier to a repo-relative .ts path. */
+function resolveSpecifier(fromRel: string, spec: string, known: Set): string | null {
+ const base = path.join(path.dirname(fromRel), spec);
+ for (const cand of [base, `${base}.ts`, path.join(base, "index.ts")]) {
+ const norm = cand.replaceAll("\\", "/");
+ if (known.has(norm)) return norm;
+ }
+ return null;
+}
+
+/**
+ * Public-surface tiers from package.json exports maps.
+ * Explicit (non-wildcard) entries + their `export *` closure = barrel-public.
+ * Wildcard patterns (`./*`) technically expose everything; tracked separately.
+ */
+async function computePublicSurface(pkgs: string[], byRel: Map) {
+ const barrelFiles = new Set();
+ const barrelNames = new Map>(); // file → names made public via named re-export
+ for (const pkg of pkgs) {
+ let exportsMap: Record;
+ try {
+ const pkgJson: { exports?: Record } = await Bun.file(`packages/${pkg}/package.json`).json();
+ exportsMap = pkgJson.exports ?? {};
+ } catch {
+ continue;
+ }
+ const queue: string[] = [];
+ for (const key in exportsMap) {
+ if (key.includes("*")) continue;
+ const value = exportsMap[key];
+ let target: string | undefined;
+ if (typeof value === "string") target = value;
+ else if (value && typeof value === "object" && "import" in value && typeof value.import === "string")
+ target = value.import;
+ if (!target?.endsWith(".ts")) continue;
+ const rel = path.join("packages", pkg, target).replaceAll("\\", "/");
+ if (byRel.has(rel)) queue.push(rel);
+ }
+ const known = new Set(byRel.keys());
+ for (let rel = queue.pop(); rel !== undefined; rel = queue.pop()) {
+ if (barrelFiles.has(rel)) continue;
+ barrelFiles.add(rel);
+ const info = byRel.get(rel);
+ if (!info) continue;
+ for (const spec of info.exportStar) {
+ const target = resolveSpecifier(rel, spec, known);
+ if (target) queue.push(target);
+ }
+ for (const re of info.namedReexports) {
+ const target = resolveSpecifier(rel, re.from, known);
+ if (!target) continue;
+ let names = barrelNames.get(target);
+ if (!names) {
+ names = new Set();
+ barrelNames.set(target, names);
+ }
+ names.add(re.name);
+ }
+ }
+ }
+ return { barrelFiles, barrelNames };
+}
+
+async function main() {
+ const pkgs = await listPackages();
+ const scanFiles: FileInfo[] = [];
+ const byRel = new Map();
+
+ // Reference corpus = every TS file in the repo (including tests/scripts),
+ // so dead-export candidacy sees all in-repo consumers.
+ const corpusIdents = new Map>(); // identifier → referencing rel paths
+ const glob = new Bun.Glob("**/*.ts");
+ const corpusRoots = ["packages", "scripts"];
+ for (const root of corpusRoots) {
+ for await (const p of glob.scan({ cwd: root, onlyFiles: true })) {
+ const rel = `${root}/${p}`.replaceAll("\\", "/");
+ if (rel.includes("node_modules/") || /(-gen\/|\/vendor\/)/.test(rel)) continue;
+ const text = await Bun.file(rel).text();
+ const isTest = /(\.test\.ts$|__tests__\/)/.test(rel);
+ for (const m of text.matchAll(/[A-Za-z_$][A-Za-z0-9_$]*/g)) {
+ const name = m[0];
+ if (name.length < 3) continue;
+ let set = corpusIdents.get(name);
+ if (!set) {
+ set = new Set();
+ corpusIdents.set(name, set);
+ }
+ set.add(isTest ? `test:${rel}` : rel);
+ }
+ const inScope = !EXCLUDE.test(rel) && pkgs.some(pkg => rel.startsWith(`packages/${pkg}/src/`));
+ if (inScope) {
+ const pkg = rel.split("/")[1];
+ const info = parseFile(rel, pkg, text);
+ scanFiles.push(info);
+ byRel.set(rel, info);
+ }
+ }
+ }
+
+ const { barrelFiles, barrelNames } = await computePublicSurface(pkgs, byRel);
+
+ // God objects: rank by LOC, annotate structure.
+ const godObjects = scanFiles
+ .filter(f => f.loc >= 800)
+ .sort((a, b) => b.loc - a.loc)
+ .slice(0, top)
+ .map(f => ({
+ file: f.rel,
+ loc: f.loc,
+ exports: f.exportedNames.length,
+ topLevelStatements: f.topLevelStatements,
+ classes: f.classes.filter(c => c.methods >= 10).sort((a, b) => b.methods - a.methods),
+ godScore: Math.round(
+ f.loc * (1 + f.exportedNames.length / 20 + Math.max(0, ...f.classes.map(c => c.methods)) / 30),
+ ),
+ }))
+ .sort((a, b) => b.godScore - a.godScore);
+
+ // Clones.
+ const clones = detectClones(scanFiles)
+ .filter(c => c.sigLines >= 2 * CLONE_WINDOW)
+ .slice(0, top)
+ .map(c => ({
+ duplicatedSigLines: c.sigLines,
+ regions: c.regions.map(r => `${r.rel}:${r.startLine}-${r.endLine}`),
+ }));
+
+ // Junk drawers.
+ const junkDrawers = scanFiles
+ .filter(f => JUNK_NAME.test(f.rel) || /\/(utils|helpers)\//.test(f.rel))
+ .map(f => ({ file: f.rel, loc: f.loc, exports: f.exportedNames.length }))
+ .sort((a, b) => b.loc - a.loc)
+ .slice(0, top);
+
+ // Dead-export candidates.
+ const deadExports: {
+ file: string;
+ name: string;
+ tier: "barrel-public" | "wildcard-only";
+ testOnly: boolean;
+ }[] = [];
+ for (const f of scanFiles) {
+ const publicNames = barrelNames.get(f.rel);
+ for (const name of f.exportedNames) {
+ const refs = corpusIdents.get(name);
+ if (!refs) continue;
+ const others = [...refs].filter(r => r !== f.rel && r !== `test:${f.rel}`);
+ if (others.length > 0 && others.some(r => !r.startsWith("test:"))) continue;
+ deadExports.push({
+ file: f.rel,
+ name,
+ tier: barrelFiles.has(f.rel) || publicNames?.has(name) ? "barrel-public" : "wildcard-only",
+ testOnly: others.length > 0,
+ });
+ }
+ }
+ deadExports.sort((a, b) => a.file.localeCompare(b.file) || a.name.localeCompare(b.name));
+
+ // Deep imports.
+ const deepImports = scanFiles
+ .filter(f => f.deepImports > 0)
+ .map(f => ({ file: f.rel, count: f.deepImports }))
+ .sort((a, b) => b.count - a.count)
+ .slice(0, top);
+
+ // Defensive-check density (per 100 LOC, min 150 LOC).
+ const checkDensity = scanFiles
+ .filter(f => f.loc >= 150)
+ .map(f => {
+ const total =
+ f.checks.asCasts + f.checks.optChain + f.checks.coalesce + f.checks.typeofNarrow + f.checks.isArray;
+ return { file: f.rel, loc: f.loc, per100: Math.round((total / f.loc) * 1000) / 10, ...f.checks };
+ })
+ .sort((a, b) => b.per100 - a.per100)
+ .slice(0, top);
+
+ const report = { godObjects, clones, junkDrawers, deadExports, deepImports, checkDensity };
+ if (asJson) {
+ console.log(JSON.stringify(report, null, 1));
+ return;
+ }
+
+ const lines: string[] = [];
+ lines.push(`# cleanup-scan — packages: ${pkgs.join(", ")} (${scanFiles.length} files)`);
+ lines.push("\n## God-object candidates (LOC ≥ 800, ranked by size × structure)");
+ for (const g of godObjects) {
+ const cls = g.classes.map(c => `${c.name}:${c.methods}m`).join(" ");
+ lines.push(
+ `- ${g.file} — ${g.loc} loc, ${g.exports} exports, ${g.topLevelStatements} top-level stmts${cls ? `, big classes: ${cls}` : ""}`,
+ );
+ }
+ lines.push("\n## Clone clusters (normalized, ≥2 regions; savings ≈ dup lines × (regions−1))");
+ for (const c of clones) {
+ lines.push(`- ~${c.duplicatedSigLines} dup lines across ${c.regions.length} regions:`);
+ for (const r of c.regions.slice(0, 6)) lines.push(` ${r}`);
+ if (c.regions.length > 6) lines.push(` … ${c.regions.length - 6} more`);
+ }
+ lines.push("\n## Junk drawers (domain-less names; sanctioned central utils are fine — judge contents)");
+ for (const j of junkDrawers) lines.push(`- ${j.file} — ${j.loc} loc, ${j.exports} exports`);
+ lines.push(
+ "\n## Dead-export candidates (zero non-test refs in repo — CANDIDATES ONLY, prove with lsp + barrel trace)",
+ );
+ lines.push(" barrel-public = re-exported via explicit entry point: PROTECTED, do not delete.");
+ lines.push(" wildcard-only = deep-importable only: deletable if lsp references confirms.");
+ for (const d of deadExports)
+ lines.push(`- [${d.tier}]${d.testOnly ? "[test-only-refs]" : ""} ${d.file} → ${d.name}`);
+ lines.push("\n## Deep relative imports (../../.. — module likely lives in the wrong place)");
+ for (const d of deepImports) lines.push(`- ${d.file} — ${d.count} imports`);
+ lines.push("\n## Defensive-check density (as-casts + ?. + ?? + typeof-narrow + isArray per 100 loc)");
+ for (const c of checkDensity)
+ lines.push(
+ `- ${c.file} — ${c.per100}/100loc (as:${c.asCasts} ?.:${c.optChain} ??:${c.coalesce} typeof:${c.typeofNarrow} isArr:${c.isArray}, ${c.loc} loc)`,
+ );
+ console.log(lines.join("\n"));
+}
+
+await main();