dry-ts
Version:
Find candidate duplicate TypeScript code by comparing normalized AST structure.
162 lines (161 loc) • 5.97 kB
JavaScript
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;
}