Files
fusion/packages/core/src/duplicate-detection.ts
gsxdsm 8c9b84ae38 batch-core: packages/core + dashboard/src lifecycle conversion (129 → 92) (#2780)
## batch-core — `packages/core` + `packages/dashboard/src`

Shared branch: two workers are converting into it. Opening the PR
because the branch was green with none, and a branch without a PR merges
nothing.

### Census

Measured with `node scripts/lifecycle-column-census.mjs --json`.

| | guards |
|---|---|
| batch-core scope at branch point | 129 |
| batch-core scope now | **92** (51 files) |
| repo total now | 358 |

Files closed so far: `store.ts` 11→0, `task-merge.ts` 6→0,
`live-agent-count.ts` 6→0 (marked, not converted — see #2762),
`task-update.ts` 3→0, display-ordering + Wake Delta ranking 5→0,
`register-git-github.ts` 4→0.

### The `register-git-github.ts` slice

Three PR routes — `pr/create`, `pr/push-branch`, `pr/resolve-conflicts`
— plus the `CHANGES_REQUESTED` handler each compared `task.column !==
"in-review"`. On a renamed board **none** of them matched, so every PR
affordance the dashboard offers was refused for a card sitting in the
lane that board calls review, and the refusal named a column that does
not exist there.

All four now share one helper, `reviewColumnsForTask`, which gets two
things right that this program has repeatedly gotten wrong:

- **Membership, not a single id.** It takes the broad review set
(`mergeOrchestration ∪ mergeBlocker ∪ humanReview`).
`resolveLifecycleColumns` returns the *first* column per trait, so a
single-id answer silently ignores a board that declares a merge lane
**and** a separate human sign-off lane. These guards only refuse or
permit — they never move the card — so over-admitting costs nothing
while under-admitting refuses a request that should have worked.
- **An empty resolved set means UNEXPRESSED, not absent.**
`synthesizeDefaultColumns` upgrades a v1 graph by emitting every default
column with `traits: []`, so a v1-upgraded workflow resolves to an empty
review set while its `in-review` column plainly exists and holds the
card. Reading empty as "this board has no review lane" would refuse
these routes on **every pre-v2 project** — a worse regression than the
one being fixed, and invisible to any v2 test.

This is the dashboard twin of the `fn pr create` guard in
`packages/cli/src/commands/pr.ts` (#2775). The two surfaces answer the
same question and now agree — FN-5893 surface enumeration.

### Testing note: why the seam and not the routes

I wrote route-level HTTP tests first and **deleted them**. An express
fixture over `registerGitGitHubRoutes` hangs — every case, including the
pure refusals, times out at 4s, because registering the router starts
background work the fixture never satisfies. Making it run would mean
mocking git, the GitHub client, and the pollers: a mock-the-world shell,
which is what the project's do-not-add-slow-tests rule (FN-5048) says to
avoid in favour of a narrow seam.

`reviewColumnsForTask` *is* the narrow seam — it holds the entire
decision, and the four call sites now do nothing but ask it and render
its answer. Six cases pin it: the renamed lane is returned and
`in-review` is not, a two-lane board returns both, a v1-upgraded board
falls back, an unresolvable workflow falls back, and the refusal renders
lanes an operator can act on.

**Mutation-verified, both directions:** reverting the helper to the
legacy literal fails 2 of 6; treating an empty set as an answer fails 1
of 6.

One fixture bug worth recording, since it would have made the two-lane
case vacuous: the trait id is kebab-case `human-review`, not
`humanReview`, and the built-in traits must be registered via `import
"@fusion/core"` before flags resolve.

### Verification

- `pnpm --filter @fusion/dashboard exec tsc --noEmit -p tsconfig.json` →
0 errors
- `pnpm lint` → 0 errors
- `register-git-github.review-lanes.test.ts` → 6 passed

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 09:28:27 -07:00

188 lines
5.1 KiB
TypeScript

import { createHash } from "node:crypto";
import type { ColumnId } from "./types.js";
export interface DuplicateMatch {
id: string;
title: string;
description: string;
column: ColumnId;
score: number;
}
export interface DuplicateMatchInput {
title?: string;
description: string;
}
export interface DuplicateCandidate {
id: string;
title: string;
description: string;
column: ColumnId;
}
export interface ContentFingerprintInput {
title?: string | null;
description: string;
}
const DEFAULT_THRESHOLD = 0.45;
const DEFAULT_LIMIT = 5;
/* DELIBERATE-LITERAL — the default for a caller that does not supply resolved terminal columns,
reviewed 2026-07-31-04:20. Callers that CAN resolve should pass `excludeColumns`; this pair is the
degraded mode, not the rule. */
const DEFAULT_EXCLUDE_COLUMNS: readonly string[] = ["done", "archived"];
export const STOPWORDS = new Set([
"a",
"an",
"the",
"and",
"or",
"of",
"to",
"for",
"in",
"is",
"on",
"with",
"fn",
]);
function normalizeFingerprintPart(value: string): string {
return value
.toLowerCase()
.replace(/[,.;:!?"'`(){}]+/g, "")
.replaceAll("[", "")
.replaceAll("]", "")
.replace(/…$/u, "")
.replace(/\s+/g, " ")
.trim();
}
/**
* Deterministic dedup fingerprint for task content.
*/
export function computeContentFingerprint(
input: ContentFingerprintInput,
): string | null {
const normalizedDescription = normalizeFingerprintPart(input.description);
if (normalizedDescription.length === 0) {
return null;
}
const normalizedTitle = normalizeFingerprintPart(input.title ?? "");
return createHash("sha256")
.update(`${normalizedTitle}\n${normalizedDescription}`)
.digest("hex");
}
export function tokenize(value: string): string[] {
return value
.toLowerCase()
.split(/\W+/)
.map((token) => token.trim())
.filter((token) => token.length > 0 && !STOPWORDS.has(token));
}
function toTrigrams(value: string): Set<string> {
const normalized = value.toLowerCase().replace(/\s+/g, " ").trim();
if (normalized.length < 3) {
return new Set();
}
const trigrams = new Set<string>();
for (let index = 0; index <= normalized.length - 3; index += 1) {
trigrams.add(normalized.slice(index, index + 3));
}
return trigrams;
}
function jaccardScore(left: Set<string>, right: Set<string>): number {
if (left.size === 0 || right.size === 0) {
return 0;
}
let intersectionCount = 0;
for (const token of left) {
if (right.has(token)) {
intersectionCount += 1;
}
}
const unionCount = left.size + right.size - intersectionCount;
return unionCount > 0 ? intersectionCount / unionCount : 0;
}
function hasTitleTrigramOverlap(left: string, right: string): boolean {
const leftTrigrams = toTrigrams(left);
const rightTrigrams = toTrigrams(right);
if (leftTrigrams.size === 0 || rightTrigrams.size === 0) {
return false;
}
for (const trigram of leftTrigrams) {
if (rightTrigrams.has(trigram)) {
return true;
}
}
return false;
}
export function findDuplicateMatches(
input: DuplicateMatchInput,
candidates: DuplicateCandidate[],
opts?: {
threshold?: number;
limit?: number;
/*
FNXC:WorkflowLifecycleColumns 2026-07-31-04:20 (batch-core feed):
`readonly string[]`, not the legacy `Column[]` enum. A renamed board's terminal column is a
perfectly valid id that the enum cannot express, so the enum made this parameter unusable by
exactly the callers that need it — the type was the guard's real limit, not the logic.
Widening only; every existing caller passes legacy ids, which remain assignable.
*/
excludeColumns?: readonly string[];
},
): DuplicateMatch[] {
const description = input.description.trim();
if (description.length === 0) {
return [];
}
const threshold = opts?.threshold ?? DEFAULT_THRESHOLD;
const limit = opts?.limit ?? DEFAULT_LIMIT;
const excludedColumns = new Set<string>(opts?.excludeColumns ?? DEFAULT_EXCLUDE_COLUMNS);
const sourceText = `${input.title ?? ""} ${description}`.trim();
const sourceTokens = new Set(tokenize(sourceText));
const sourceTitle = input.title ?? "";
const sourceTitleTokens = new Set(tokenize(sourceTitle));
const matches: DuplicateMatch[] = [];
for (const candidate of candidates) {
if (excludedColumns.has(candidate.column)) {
continue;
}
const candidateText = `${candidate.title} ${candidate.description}`.trim();
const candidateTokens = new Set(tokenize(candidateText));
const candidateTitleTokens = new Set(tokenize(candidate.title));
const contentScore = jaccardScore(sourceTokens, candidateTokens);
const titleScore = jaccardScore(sourceTitleTokens, candidateTitleTokens);
let score = Math.max(contentScore, contentScore * 0.7 + titleScore * 0.3);
if (hasTitleTrigramOverlap(sourceTitle, candidate.title)) {
score += 0.05;
}
if (score >= threshold) {
matches.push({
id: candidate.id,
title: candidate.title,
description: candidate.description,
column: candidate.column,
score,
});
}
}
return matches.sort((a, b) => b.score - a.score).slice(0, limit);
}