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:
committed by
gsxdsm
parent
b6011da52d
commit
4b01e8e66e
59
packages/core/src/__tests__/duplicate-lineage.test.ts
Normal file
59
packages/core/src/__tests__/duplicate-lineage.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
82
packages/core/src/duplicate-lineage.ts
Normal file
82
packages/core/src/duplicate-lineage.ts
Normal 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);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user