(
units: FunctionUnit[],
opts: { minSim?: number; maxSim?: number; minTokens?: number } = {},
)
| 92 | * collapses a whole codebase of vaguely-alike boilerplate into one meaningless mega-cluster. |
| 93 | */ |
| 94 | export function findSimilarHelpers( |
| 95 | units: FunctionUnit[], |
| 96 | opts: { minSim?: number; maxSim?: number; minTokens?: number } = {}, |
| 97 | ): HelperCluster[] { |
| 98 | const minSim = opts.minSim ?? 0.88; |
| 99 | const maxSim = opts.maxSim ?? 1.0; |
| 100 | const minTokens = opts.minTokens ?? 40; |
| 101 | |
| 102 | const base = units |
| 103 | .map(u => { |
| 104 | const norm = normalizeBody(u.body, u.name); |
| 105 | const tokens = codeTokens(norm); |
| 106 | return { u, norm, tf: termFreq(tokens), tok: tokens.length }; |
| 107 | }) |
| 108 | .filter(x => x.tok >= minTokens); |
| 109 | |
| 110 | // TF-IDF: weight each token by how DISTINCTIVE it is across the corpus. Ubiquitous structural |
| 111 | // tokens (`.`, `(`, `const`, `=`) appear in nearly every function → idf≈0 → they stop dominating, |
| 112 | // so only genuinely-shared logic (specific identifiers + call sequences) drives similarity. Without |
| 113 | // this, every large function looks ~85% like every other and the whole thing over-clusters. |
| 114 | const df = new Map<string, number>(); |
| 115 | for (const b of base) for (const t of b.tf.keys()) df.set(t, (df.get(t) ?? 0) + 1); |
| 116 | const N = base.length; |
| 117 | const idf = (t: string): number => Math.log((N + 1) / ((df.get(t) ?? 0) + 1)); // smoothed; token in all docs → ~0 |
| 118 | const weight = (tf: Map<string, number>): Map<string, number> => { |
| 119 | const w = new Map<string, number>(); |
| 120 | for (const [t, f] of tf) { const iw = idf(t); if (iw > 0) w.set(t, f * iw); } |
| 121 | return w; |
| 122 | }; |
| 123 | |
| 124 | const prepared = base |
| 125 | // Weighted vector; fall back to raw tf if weighting empties it (degenerate all-tokens-universal case). |
| 126 | .map(x => { const w = weight(x.tf); return { ...x, tf: w.size > 0 ? w : x.tf }; }) |
| 127 | // Larger functions first, so the biggest is the seed a cluster forms around (stable + meaningful). |
| 128 | .sort((a, b) => b.tok - a.tok); |
| 129 | |
| 130 | // Seed-based clustering: each function joins the seed it's MOST similar to (≥ minSim), else seeds a new cluster. |
| 131 | const seeded: { seed: number; members: number[] }[] = []; |
| 132 | for (let i = 0; i < prepared.length; i++) { |
| 133 | let best = -1, bestSim = -1; |
| 134 | for (let c = 0; c < seeded.length; c++) { |
| 135 | const s = cosineSim(prepared[i]!.tf, prepared[seeded[c]!.seed]!.tf); |
| 136 | if (s >= minSim && s <= maxSim && s > bestSim) { bestSim = s; best = c; } |
| 137 | } |
| 138 | if (best >= 0) seeded[best]!.members.push(i); |
| 139 | else seeded.push({ seed: i, members: [i] }); |
| 140 | } |
| 141 | |
| 142 | const clusters: HelperCluster[] = []; |
| 143 | for (const { members: rawIdxs } of seeded) { |
| 144 | // Drop members NESTED inside another member (same file, contained line range): a inner helper |
| 145 | // shares most of its parent's tokens, so parent+child cluster together — but "extract the child |
| 146 | // from its parent" is not a duplicate-consolidation opportunity. Keep the outermost. |
| 147 | const idxs = rawIdxs.filter(i => { |
| 148 | const a = prepared[i]!.u; |
| 149 | return !rawIdxs.some(j => { |
| 150 | if (j === i) return false; |
| 151 | const b = prepared[j]!.u; |
no test coverage detected