From a0efb56b6bc89aafcddb8d82866e01d24f32f907 Mon Sep 17 00:00:00 2001 From: can1357 Date: Sat, 8 Aug 2026 05:03:49 +0200 Subject: [PATCH] feat: cleanup command --- .omp/commands/cleanup.md | 108 +++++++++ scripts/cleanup-scan.ts | 492 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 600 insertions(+) create mode 100644 .omp/commands/cleanup.md create mode 100644 scripts/cleanup-scan.ts 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();