UNPKG

dry-ts

Version:

Find candidate duplicate TypeScript code by comparing normalized AST structure.

162 lines (161 loc) 5.97 kB
export class ClusterCollector { parents = new Map(); locationsByKey = new Map(); scoresByRoot = new Map(); addMatch(left, right, score) { const leftRoot = this.add(left); const rightRoot = this.add(right); const root = this.union(leftRoot, rightRoot); this.addScore(root, score); } clusters() { const membersByRoot = new Map(); for (const [key, location] of this.locationsByKey) { const root = this.find(key); const members = membersByRoot.get(root) ?? []; members.push(location); membersByRoot.set(root, members); } const clusters = []; for (const [root, locations] of membersByRoot) { const score = this.scoresByRoot.get(root); if (!score) { continue; } clusters.push({ score, locations: locations.sort(compareLocations), }); } return rankClusters(clusters); } find(key) { let root = key; while (this.parents.get(root) !== root) { root = this.parents.get(root); } let current = key; while (current !== root) { const next = this.parents.get(current); this.parents.set(current, root); current = next; } return root; } add(location) { const key = locationKey(location); if (!this.parents.has(key)) { this.parents.set(key, key); this.locationsByKey.set(key, location); } else { const existing = this.locationsByKey.get(key); if (existing && location.nodes > existing.nodes) { this.locationsByKey.set(key, location); } } return this.find(key); } union(leftRoot, rightRoot) { if (leftRoot === rightRoot) { return leftRoot; } this.parents.set(leftRoot, rightRoot); const leftScore = this.scoresByRoot.get(leftRoot); const rightScore = this.scoresByRoot.get(rightRoot); if (leftScore || rightScore) { this.scoresByRoot.set(rightRoot, mergeScores(leftScore, rightScore)); this.scoresByRoot.delete(leftRoot); } return rightRoot; } addScore(root, score) { const existing = this.scoresByRoot.get(root); this.scoresByRoot.set(root, { min: existing ? Math.min(existing.min, score) : score, max: existing ? Math.max(existing.max, score) : score, }); } } export function maxScore(cluster) { return cluster.score.max; } export function minScore(cluster) { return cluster.score.min; } function mergeScores(left, right) { if (!left) { return right; } if (!right) { return left; } return { min: Math.min(left.min, right.min), max: Math.max(left.max, right.max), }; } // Exported so the nearest-counterpart join (plan 014) keys by the SAME canonical // location key the collector dedupes on — a divergent key would silently miss the // join. Single source of truth. export function locationKey(location) { return `${location.file}:${location.startLine}-${location.endLine}`; } // Exported as the deterministic location tie-break for the nearest-counterpart // total order (plan 014, DD 2) — reused rather than duplicated so the two never drift. export function compareLocations(left, right) { return left.file.localeCompare(right.file) || left.startLine - right.startLine || left.endLine - right.endLine; } // Report order. Primary key: clusters whose SAME declaration name recurs across // two or more distinct files float to the top — the strongest "this is a real, // copy-pasted duplicate" signal (a `validateUser` cloned into another module), // near-zero false positive in practice. Within each tier, strongest score first, // then the deterministic location tie-break. Decorated up front (Schwartzian) so // the cross-file-name scan runs once per cluster, not once per comparison. function rankClusters(clusters) { return clusters .map((cluster) => ({ cluster, crossFile: hasCrossFileSharedName(cluster) ? 1 : 0 })) .sort((left, right) => right.crossFile - left.crossFile || maxScore(right.cluster) - maxScore(left.cluster) || compareLocations(left.cluster.locations[0], right.cluster.locations[0])) .map(({ cluster }) => cluster); } // The set of declaration names that appear in two or more distinct files within a // cluster — the cross-file recurrence that marks a cluster as a likely real // duplicate (vs an incidental structural twin). Names recurring within a single // file (overloads, shadowed locals) do not qualify: cross-file is the signal. // Anonymous locations (null name) are skipped. Returned sorted for deterministic // rendering; callers needing only the boolean use hasCrossFileSharedName. export function crossFileSharedNames(cluster) { const filesByName = new Map(); for (const location of cluster.locations) { if (location.name == null) { continue; } const files = filesByName.get(location.name) ?? new Set(); files.add(location.file); filesByName.set(location.name, files); } const shared = []; for (const [name, files] of filesByName) { if (files.size >= 2) { shared.push(name); } } return shared.sort(); } export function hasCrossFileSharedName(cluster) { const filesByName = new Map(); for (const location of cluster.locations) { if (location.name == null) { continue; } const files = filesByName.get(location.name) ?? new Set(); files.add(location.file); if (files.size >= 2) { return true; } filesByName.set(location.name, files); } return false; }