## 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>
188 lines
5.1 KiB
TypeScript
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);
|
|
}
|