feat(FN-4978): merge fusion/fn-4978

This commit is contained in:
gsxdsm
2026-05-18 03:46:28 -07:00
parent da9aedd1b1
commit 3a3f4edbef
6 changed files with 69 additions and 10 deletions

View File

@@ -305,6 +305,13 @@ describe("ai-summarize", () => {
expect(sanitizeTitle("Why?")).toBe("Why");
});
it("strips empty placeholder groups (FN-4978 regression)", () => {
expect(sanitizeTitle("Fix executor.ts stale session deadlock ( )")).toBe("Fix executor.ts stale session deadlock");
expect(sanitizeTitle("Add feature []")).toBe("Add feature");
expect(sanitizeTitle("Refactor (hotfix)")).toBe("Refactor (hotfix)");
expect(sanitizeTitle("( )")).toBeNull();
});
it("hard-caps at MAX_TITLE_LENGTH", () => {
const long = "x".repeat(100);
const out = sanitizeTitle(long)!;

View File

@@ -48,9 +48,36 @@ describe("task-title-id-drift", () => {
expect(normalizeTitleForTaskId("Foo FN-100:", "FN-999")).toEqual({ title: "Foo", changed: true });
});
it("strips empty placeholder left by fn-token removal (FN-4978 regression)", () => {
const normalized = normalizeTitleForTaskId("Fix executor.ts stale session deadlock (FN-1234)", "FN-9999");
expect(normalized).toEqual({ title: "Fix executor.ts stale session deadlock", changed: true });
expect(normalized.title).not.toContain("(");
expect(normalized.title).not.toContain(")");
});
it("strips empty square and curly bracket placeholders", () => {
expect(normalizeTitleForTaskId("Bug [FN-1] thing", "FN-9")).toEqual({ title: "Bug thing", changed: true });
expect(normalizeTitleForTaskId("Bug {FN-1} thing", "FN-9")).toEqual({ title: "Bug thing", changed: true });
});
it("preserves valid qualifiers and matching row id token", () => {
expect(normalizeTitleForTaskId("Refactor parser (hotfix)", "FN-9")).toEqual({
title: "Refactor parser (hotfix)",
changed: false,
});
expect(normalizeTitleForTaskId("Add docs (FN-9)", "FN-9")).toEqual({
title: "Add docs (FN-9)",
changed: false,
});
});
it("is idempotent", () => {
const first = normalizeTitleForTaskId("Refinement: FN-100: foo", "FN-200");
const second = normalizeTitleForTaskId(first.title ?? "", "FN-200");
expect(second.changed).toBe(false);
const firstPlaceholderPass = normalizeTitleForTaskId("Fix executor.ts stale session deadlock (FN-1234)", "FN-9999");
const secondPlaceholderPass = normalizeTitleForTaskId(firstPlaceholderPass.title ?? "", "FN-9999");
expect(secondPlaceholderPass.changed).toBe(false);
});
});

View File

@@ -12,6 +12,7 @@
*/
import { getFnAgent, type AgentMessage } from "./ai-engine-loader.js";
import { stripEmptyPlaceholders } from "./task-title-id-drift.js";
// ── Constants ───────────────────────────────────────────────────────────────
@@ -880,6 +881,8 @@ export function sanitizeTitle(raw: string | undefined | null): string | null {
return null;
}
title = stripEmptyPlaceholders(title);
// Drop trailing punctuation that summary-like sentences leave behind.
title = title.replace(/[.!?,;:]+$/, "").trim();
if (!title) return null;

View File

@@ -3,6 +3,31 @@ import { MAX_TITLE_LENGTH } from "./ai-summarize.js";
export const TASK_ID_TOKEN_RE = /\bFN-(\d+)\b/gi;
const CONNECTOR_RE = /[:\-—–]/;
const EMPTY_PLACEHOLDER_CONTENT_RE = /^[\s,:;\-—–.!?]*$/;
export function stripEmptyPlaceholders(text: string): string {
let normalized = text;
// Remove empty bracketed placeholders ((), [], {}) that contain only
// whitespace or punctuation residue/connectors.
normalized = normalized.replace(/\(([^)]*)\)|\[([^\]]*)\]|\{([^}]*)\}/g, (match, paren, square, brace) => {
const content = (paren ?? square ?? brace ?? "").trim();
return EMPTY_PLACEHOLDER_CONTENT_RE.test(content) ? " " : match;
});
normalized = normalized
.replace(/\s+([,:;.!?])/g, "$1")
.replace(/([:\-—–])\s*(?=[:\-—–])/g, "$1")
.replace(/\s+/g, " ")
.trim();
normalized = normalized
.replace(new RegExp(`(?:\\s*${CONNECTOR_RE.source}\\s*)+$`), "")
.replace(/[,:;.!?]+$/g, "")
.trim();
return normalized;
}
export function extractTaskIdTokens(title: string): string[] {
const tokens = new Set<string>();
@@ -36,15 +61,7 @@ export function normalizeTitleForTaskId(
}
let normalized = initial.replace(/\bFN-\d+\b\s*([:\-—–])?\s*/gi, " ");
normalized = normalized
.replace(/\s+([,:;.!?])/g, "$1")
.replace(/\s+/g, " ")
.trim();
normalized = normalized
.replace(new RegExp(`(?:\\s*${CONNECTOR_RE.source}\\s*)+$`), "")
.replace(/[,:;.!?]+$/g, "")
.trim();
normalized = stripEmptyPlaceholders(normalized);
if (normalized.length > MAX_TITLE_LENGTH) {
normalized = normalized.slice(0, MAX_TITLE_LENGTH).trim();