feat(FN-4899): complete Step 1 — add duplicate lineage helpers

Fusion-Task-Id: FN-4899
Fusion-Task-Lineage: 2ae24667-6635-4268-a620-d6012266d8f2
This commit is contained in:
Fusion (runfusion.ai)
2026-05-17 15:46:45 -07:00
committed by gsxdsm
parent b6011da52d
commit 4b01e8e66e
3 changed files with 148 additions and 0 deletions

View File

@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import {
extractDuplicateOfReferences,
getTaskDuplicateLineage,
} from "../duplicate-lineage.js";
describe("extractDuplicateOfReferences", () => {
it("parses supported duplicate-of phrasings", () => {
expect(extractDuplicateOfReferences("(duplicate of FN-4894)")).toEqual(["FN-4894"]);
expect(extractDuplicateOfReferences("duplicate of FN-4894/FN-4847")).toEqual(["FN-4894", "FN-4847"]);
expect(extractDuplicateOfReferences("dup of FN-1")).toEqual(["FN-1"]);
expect(extractDuplicateOfReferences("Duplicates FN-2, FN-3")).toEqual(["FN-2", "FN-3"]);
});
it("normalizes case, dedupes and handles no-match", () => {
expect(extractDuplicateOfReferences("duplicate of fn-7 and duplicate of FN-7")).toEqual(["FN-7"]);
expect(extractDuplicateOfReferences("no duplicates noted")).toEqual([]);
expect(extractDuplicateOfReferences(undefined)).toEqual([]);
});
});
describe("getTaskDuplicateLineage", () => {
it("orders lineage as parent, metadata, then parsed references", () => {
expect(
getTaskDuplicateLineage({
id: "FN-5000",
sourceType: "task_duplicate",
sourceParentTaskId: "fn-10",
sourceMetadata: { duplicateOfTaskIds: ["FN-11", "FN-10"] },
title: "Task (duplicate of fn-12)",
description: "duplicate of FN-13",
}),
).toEqual(["FN-10", "FN-11", "FN-12", "FN-13"]);
});
it("filters self references and honors limit", () => {
expect(
getTaskDuplicateLineage(
{
id: "FN-42",
sourceMetadata: { duplicateOfTaskIds: ["FN-42", "FN-43", "FN-44"] },
title: "duplicate of FN-45",
},
{ limit: 2 },
),
).toEqual(["FN-43", "FN-44"]);
});
it("ignores malformed metadata lineage payloads", () => {
expect(
getTaskDuplicateLineage({
id: "FN-80",
sourceMetadata: { duplicateOfTaskIds: "FN-81" },
title: "duplicate of FN-82",
}),
).toEqual(["FN-82"]);
});
});

View File

@@ -0,0 +1,82 @@
import type { SourceType } from "./types.js";
import { DUPLICATE_OF_METADATA_KEY } from "./types.js";
const DUPLICATE_REFERENCE_PATTERN = /\b(?:duplicate(?:s|d)?\s+of|dup(?:e|licate)?\s+of|duplicates)\b\s*[:\-]?\s*([A-Z]+-\d+(?:\s*[\/,]\s*[A-Z]+-\d+)*)/gi;
const PAREN_DUPLICATE_PATTERN = /\([Dd]uplicate\s+of\s+([A-Z]+-\d+(?:[\/,\s]+[A-Z]+-\d+)*)\)/g;
const TASK_ID_PATTERN = /\b[A-Z]+-\d+\b/g;
export function extractDuplicateOfReferences(text: string | null | undefined): string[] {
if (!text?.trim()) {
return [];
}
const normalized = text.toUpperCase();
const seen = new Set<string>();
const collected: string[] = [];
const collect = (value: string): void => {
for (const match of value.match(TASK_ID_PATTERN) ?? []) {
if (seen.has(match)) continue;
seen.add(match);
collected.push(match);
}
};
for (const pattern of [DUPLICATE_REFERENCE_PATTERN, PAREN_DUPLICATE_PATTERN]) {
pattern.lastIndex = 0;
let match: RegExpExecArray | null = null;
while ((match = pattern.exec(normalized)) !== null) {
collect(match[1] ?? "");
}
}
return collected;
}
export interface TaskDuplicateLineageInput {
id: string;
title?: string | null;
description?: string | null;
sourceType?: SourceType | null;
sourceParentTaskId?: string | null;
sourceMetadata?: Record<string, unknown> | null;
promptText?: string | null;
}
export function getTaskDuplicateLineage(
task: TaskDuplicateLineageInput,
opts?: { limit?: number },
): string[] {
const limit = Math.max(1, opts?.limit ?? 10);
const selfId = task.id.toUpperCase();
const seen = new Set<string>();
const lineage: string[] = [];
const push = (id: string): void => {
const normalizedId = id.toUpperCase();
if (normalizedId === selfId || seen.has(normalizedId)) {
return;
}
seen.add(normalizedId);
lineage.push(normalizedId);
};
if (task.sourceType === "task_duplicate" && task.sourceParentTaskId?.trim()) {
push(task.sourceParentTaskId.trim());
}
const metadataLineage = task.sourceMetadata?.[DUPLICATE_OF_METADATA_KEY];
if (Array.isArray(metadataLineage)) {
for (const id of metadataLineage) {
if (typeof id === "string" && id.trim()) {
push(id.trim());
}
}
}
for (const id of extractDuplicateOfReferences(task.title)) push(id);
for (const id of extractDuplicateOfReferences(task.description)) push(id);
for (const id of extractDuplicateOfReferences(task.promptText ?? "")) push(id);
return lineage.slice(0, limit);
}

View File

@@ -1404,6 +1404,8 @@ export type SourceType =
| "research"
| "unknown";
export const DUPLICATE_OF_METADATA_KEY = "duplicateOfTaskIds" as const;
/** Provenance metadata for how a task was created. */
export interface TaskSource {
sourceType: SourceType;
@@ -1412,6 +1414,11 @@ export interface TaskSource {
sourceSessionId?: string;
sourceMessageId?: string;
sourceParentTaskId?: string;
/**
* Reserved metadata keys:
* - `duplicateOfTaskIds: string[]` stores structured duplicate lineage captured
* from triage parsing and backfills.
*/
sourceMetadata?: Record<string, unknown>;
}