206 lines
6.5 KiB
TypeScript
206 lines
6.5 KiB
TypeScript
import { STOPWORDS, tokenize } from "./duplicate-detection.js";
|
|
import type { ColumnId } from "./types.js";
|
|
|
|
const DEFAULT_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
|
|
const DEFAULT_LIMIT = 5;
|
|
const DEFAULT_MIN_SHARED_TOKENS = 2;
|
|
const DEFAULT_TITLE_THRESHOLD = 0.3;
|
|
const GENERIC_LARGE_FILE_TITLE_THRESHOLD = 0.5;
|
|
const MAX_TOKENS_PER_BUCKET = 32;
|
|
|
|
// FN-5152: frequently touched broad files should not trigger near-duplicate matches by themselves.
|
|
const GENERIC_LARGE_FILES = new Set([
|
|
"register-git-github.ts",
|
|
"register-task-workflow-routes.ts",
|
|
"store.ts",
|
|
"types.ts",
|
|
"styles.css",
|
|
]);
|
|
|
|
export interface IntentSignature {
|
|
routePaths: string[];
|
|
filePaths: string[];
|
|
identifiers: string[];
|
|
titleTokens: string[];
|
|
}
|
|
|
|
export interface NearDuplicateInput {
|
|
title?: string | null;
|
|
description: string;
|
|
fileScope?: string[];
|
|
}
|
|
|
|
export interface NearDuplicateCandidate {
|
|
id: string;
|
|
title: string;
|
|
description: string;
|
|
column: ColumnId;
|
|
fileScope?: string[];
|
|
createdAt?: number;
|
|
}
|
|
|
|
export interface NearDuplicateMatch {
|
|
id: string;
|
|
score: number;
|
|
sharedTokens: string[];
|
|
titleScore: number;
|
|
reason: "near-duplicate-intent";
|
|
}
|
|
|
|
interface SignalToken {
|
|
token: string;
|
|
kind: "route" | "file" | "identifier";
|
|
}
|
|
|
|
function toUnique(values: string[], limit = MAX_TOKENS_PER_BUCKET): string[] {
|
|
return Array.from(new Set(values)).slice(0, limit);
|
|
}
|
|
|
|
function jaccard(left: string[], right: string[]): number {
|
|
const leftSet = new Set(left);
|
|
const rightSet = new Set(right);
|
|
if (leftSet.size === 0 || rightSet.size === 0) {
|
|
return 0;
|
|
}
|
|
let intersection = 0;
|
|
for (const token of leftSet) {
|
|
if (rightSet.has(token)) {
|
|
intersection += 1;
|
|
}
|
|
}
|
|
const union = leftSet.size + rightSet.size - intersection;
|
|
return union === 0 ? 0 : intersection / union;
|
|
}
|
|
|
|
function extractRoutePaths(text: string): string[] {
|
|
const matches = text.match(/\/[a-z][\w/:.-]+(?:\/[\w:.-]+)+/gi) ?? [];
|
|
const expanded: string[] = [];
|
|
for (const match of matches) {
|
|
const lower = match.toLowerCase();
|
|
expanded.push(lower);
|
|
const prTail = lower.match(/\/pr\/[\w:.-]+(?:\/[\w:.-]+)*/g);
|
|
if (prTail) {
|
|
expanded.push(...prTail);
|
|
}
|
|
}
|
|
const filtered = expanded
|
|
.filter((entry) => /\/[\w:.-]+\/[\w:.-]+/.test(entry))
|
|
.filter((entry) => /\b(pr|api|tasks|users|repos|reviews|merge|settings|workflow)\b/.test(entry));
|
|
return toUnique(filtered);
|
|
}
|
|
|
|
function extractFilePaths(text: string): string[] {
|
|
const matches = text.match(/(?:packages|app|scripts|docs|tests?)\/[\w./-]+\.(?:ts|tsx|js|mjs|cjs|css|md|json)/g) ?? [];
|
|
return toUnique(matches.map((entry) => entry.toLowerCase()));
|
|
}
|
|
|
|
function extractIdentifiers(text: string): string[] {
|
|
const backticked = Array.from(text.matchAll(/`([^`]+)`/g)).map((match) => match[1]?.trim() ?? "");
|
|
const codeShaped = text.match(/\b(?:[A-Z][A-Za-z0-9]+[A-Z][A-Za-z0-9]*|[a-z0-9]+_[a-z0-9_]+|[a-z0-9]+(?:-[a-z0-9]+)+(?:\.[a-z0-9]+)?|[a-z]+[A-Z][A-Za-z0-9]*)\b/g) ?? [];
|
|
const raw = [...backticked, ...codeShaped];
|
|
const filtered = raw
|
|
.map((value) => value.trim())
|
|
.filter((value) => value.length >= 4)
|
|
.filter((value) => !STOPWORDS.has(value.toLowerCase()))
|
|
.map((value) => value.toLowerCase());
|
|
return toUnique(filtered);
|
|
}
|
|
|
|
export function extractIntentSignature(input: NearDuplicateInput): IntentSignature {
|
|
const title = input.title ?? "";
|
|
const fileScope = input.fileScope ?? [];
|
|
const text = `${title}\n${input.description}\n${fileScope.join("\n")}`;
|
|
return {
|
|
routePaths: extractRoutePaths(text),
|
|
filePaths: extractFilePaths(text),
|
|
identifiers: extractIdentifiers(text),
|
|
titleTokens: toUnique(tokenize(title).filter((token) => token.length >= 3)),
|
|
};
|
|
}
|
|
|
|
function getSignalTokens(signature: IntentSignature): SignalToken[] {
|
|
return [
|
|
...signature.routePaths.map((token) => ({ token, kind: "route" as const })),
|
|
...signature.filePaths.map((token) => ({ token, kind: "file" as const })),
|
|
...signature.identifiers.map((token) => ({ token, kind: "identifier" as const })),
|
|
];
|
|
}
|
|
|
|
export function findNearDuplicates(
|
|
input: NearDuplicateInput,
|
|
candidates: NearDuplicateCandidate[],
|
|
opts?: {
|
|
minSharedTokens?: number;
|
|
titleThreshold?: number;
|
|
windowMs?: number;
|
|
nowMs?: number;
|
|
limit?: number;
|
|
},
|
|
): NearDuplicateMatch[] {
|
|
const source = extractIntentSignature(input);
|
|
const sourceSignals = getSignalTokens(source);
|
|
if (sourceSignals.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const minSharedTokens = opts?.minSharedTokens ?? DEFAULT_MIN_SHARED_TOKENS;
|
|
const titleThreshold = opts?.titleThreshold ?? DEFAULT_TITLE_THRESHOLD;
|
|
const windowMs = opts?.windowMs ?? DEFAULT_WINDOW_MS;
|
|
const nowMs = opts?.nowMs ?? Date.now();
|
|
const cutoff = nowMs - windowMs;
|
|
const limit = opts?.limit ?? DEFAULT_LIMIT;
|
|
|
|
const matches: NearDuplicateMatch[] = [];
|
|
|
|
for (const candidate of candidates) {
|
|
if (candidate.createdAt != null && candidate.createdAt < cutoff) {
|
|
continue;
|
|
}
|
|
|
|
const candidateSignature = extractIntentSignature({
|
|
title: candidate.title,
|
|
description: candidate.description,
|
|
fileScope: candidate.fileScope,
|
|
});
|
|
const candidateSignals = getSignalTokens(candidateSignature);
|
|
if (candidateSignals.length === 0) {
|
|
continue;
|
|
}
|
|
|
|
const candidateTokenKinds = new Map(candidateSignals.map((entry) => [entry.token, entry.kind]));
|
|
const sharedTokens = toUnique(sourceSignals
|
|
.filter((entry) => candidateTokenKinds.has(entry.token))
|
|
.map((entry) => entry.token), 256);
|
|
|
|
const titleScore = jaccard(source.titleTokens, candidateSignature.titleTokens);
|
|
|
|
const sharedFileTokens = sharedTokens.filter((token) => candidateTokenKinds.get(token) === "file");
|
|
const allSharedAreFiles = sharedFileTokens.length === sharedTokens.length;
|
|
const allSharedAreGenericLargeFiles =
|
|
sharedFileTokens.length > 0 &&
|
|
sharedFileTokens.every((token) => GENERIC_LARGE_FILES.has(token.split("/").pop() ?? token));
|
|
const effectiveTitleThreshold =
|
|
allSharedAreFiles && allSharedAreGenericLargeFiles
|
|
? Math.max(titleThreshold, GENERIC_LARGE_FILE_TITLE_THRESHOLD)
|
|
: titleThreshold;
|
|
|
|
if (sharedTokens.length < minSharedTokens || titleScore < effectiveTitleThreshold) {
|
|
continue;
|
|
}
|
|
|
|
const signalDenominator = Math.max(sourceSignals.length, candidateSignals.length, 1);
|
|
const score =
|
|
0.5 * (sharedTokens.length / signalDenominator) + 0.5 * titleScore;
|
|
|
|
matches.push({
|
|
id: candidate.id,
|
|
score,
|
|
sharedTokens,
|
|
titleScore,
|
|
reason: "near-duplicate-intent",
|
|
});
|
|
}
|
|
|
|
return matches.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
}
|