feat(FN-5216): sanitize duplicate paths in file scope when parsing task sto

The merge adds a file scope sanitizer to the task store that deduplicates and normalizes path entries, with test coverage in `store-parsing.test.ts`; a small documentation fix accompanies the change in AGENTS.md.

Fusion-Task-Id: FN-5216
This commit is contained in:
Fusion (runfusion.ai)
2026-05-20 06:07:33 -07:00
committed by gsxdsm
parent 37d4115bb6
commit 35b2971f54
4 changed files with 125 additions and 27 deletions

View File

@@ -0,0 +1,8 @@
---
"@runfusion/fusion": patch
---
Duplicating or restoring a task no longer fails when the source PROMPT.md
contains legacy/invalid File Scope tokens. Invalid tokens are dropped from
the rewritten PROMPT.md with a `[file-scope-sanitize]` log entry. Authoring
paths (createTask, updateTask) continue to reject invalid tokens strictly.

View File

@@ -282,7 +282,7 @@ Every squash commit path enforces a file-scope invariant immediately before comm
Per-task opt-out: `task.scopeOverride = true` (log `task.scopeOverrideReason` when set). Empty scopes are not enforced.
File Scope entries are validated when PROMPT.md is written — non-path tokens (git refs, URLs, SHAs, bare identifiers) are rejected with `InvalidFileScopeError`.
File Scope entries are validated when PROMPT.md is written — author paths (`createTask`, `updateTask`) still reject non-path tokens (git refs, URLs, SHAs, bare identifiers) with `InvalidFileScopeError`, while copy paths (`duplicateTask`, `restoreFromArchive`) sanitize invalid entries out of the rewritten PROMPT.md and log the drop.
### Manual audit script

View File

@@ -320,8 +320,80 @@ describe("TaskStore", () => {
});
});
describe("FN-5216 File Scope sanitization on copy paths", () => {
const validScopeEntry = "packages/cli/src/extension.ts";
const invalidScopeEntries = [
"pr/create",
"pr/refresh",
"listBranches",
"listRepoLabels",
"listAssignableUsers",
"getRepoMetadata",
"baseUrl",
"classifyGhError",
".fusion/tasks/FN-5149/",
"fn_task_document_write",
];
const buildLegacyPrompt = (taskId: string) => `# ${taskId}: Legacy file scope
## Mission
Keep the tool names \`pr/create\` and \`classifyGhError\` in this section.
## File Scope
- \`${validScopeEntry}\`
${invalidScopeEntries.map((entry) => `- \`${entry}\``).join("\n")}
## Steps
### Step 0: Preflight
- [ ] Mention \`fn_task_document_write\` outside File Scope
`;
it("FN-5216 duplicateTask sanitizes invalid File Scope entries without touching other backticks", async () => {
const task = await store.createTask({ description: "duplicate legacy scope" });
const sourcePromptPath = join(rootDir, ".fusion", "tasks", task.id, "PROMPT.md");
await writeFile(sourcePromptPath, buildLegacyPrompt(task.id));
const duplicated = await store.duplicateTask(task.id);
const duplicatedPromptPath = join(rootDir, ".fusion", "tasks", duplicated.id, "PROMPT.md");
const duplicatedPrompt = await readFile(duplicatedPromptPath, "utf-8");
expect(duplicatedPrompt).toContain(`- \`${validScopeEntry}\``);
for (const entry of invalidScopeEntries) {
expect(duplicatedPrompt).not.toContain(`- \`${entry}\``);
}
expect(duplicatedPrompt).toContain("Keep the tool names `pr/create` and `classifyGhError` in this section.");
expect(duplicatedPrompt).toContain("- [ ] Mention `fn_task_document_write` outside File Scope");
await expect(store.parseFileScopeFromPrompt(duplicated.id)).resolves.toEqual([validScopeEntry]);
});
it("FN-5216 restoreFromArchive sanitizes invalid File Scope entries on unarchive", async () => {
const task = await store.createTask({ description: "restore legacy scope" });
const promptPath = join(rootDir, ".fusion", "tasks", task.id, "PROMPT.md");
await writeFile(promptPath, buildLegacyPrompt(task.id));
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "done");
await store.archiveTask(task.id, true);
const restored = await store.unarchiveTask(task.id);
const restoredPromptPath = join(rootDir, ".fusion", "tasks", restored.id, "PROMPT.md");
const restoredPrompt = await readFile(restoredPromptPath, "utf-8");
expect(restoredPrompt).toContain(`- \`${validScopeEntry}\``);
for (const entry of invalidScopeEntries) {
expect(restoredPrompt).not.toContain(`- \`${entry}\``);
}
expect(restoredPrompt).toContain("Keep the tool names `pr/create` and `classifyGhError` in this section.");
await expect(store.parseFileScopeFromPrompt(restored.id)).resolves.toEqual([validScopeEntry]);
});
});
describe("File Scope validation at write time", () => {
it("createTask rejects invalid File Scope entries and rolls back", async () => {
it("FN-5216 createTask rejects invalid File Scope entries and rolls back", async () => {
const badPrompt = `# Bad prompt\n\n## File Scope\n\n- \`packages/core/src/store.ts\`\n- \`origin/fusion/fn-4280\`\n`;
await expect(store.createTaskWithReservedId({ description: "bad create" }, { taskId: "FN-999", prompt: badPrompt }))
@@ -331,7 +403,7 @@ describe("TaskStore", () => {
expect(existsSync(join(rootDir, ".fusion", "tasks", "FN-999"))).toBe(false);
});
it("updateTask rejects invalid File Scope prompt and preserves existing PROMPT.md", async () => {
it("FN-5216 updateTask rejects invalid File Scope prompt and preserves existing PROMPT.md", async () => {
const task = await store.createTask({ description: "update scope" });
const promptPath = join(rootDir, ".fusion", "tasks", task.id, "PROMPT.md");
const originalPrompt = await readFile(promptPath, "utf-8");

View File

@@ -732,6 +732,38 @@ function validateFileScopeInPromptContent(prompt: string): { valid: string[]; in
return { valid, invalid };
}
function sanitizeFileScopeInPromptContent(prompt: string): { sanitized: string; dropped: string[]; kept: string[] } {
const headingMatch = prompt.match(/^##\s+File\s+Scope\s*$/m);
if (!headingMatch) {
return { sanitized: prompt, dropped: [], kept: [] };
}
const startIdx = headingMatch.index! + headingMatch[0].length;
const rest = prompt.slice(startIdx);
const nextHeading = rest.search(/\n##?\s/);
const endIdx = nextHeading === -1 ? prompt.length : startIdx + nextHeading;
const section = prompt.slice(startIdx, endIdx);
const { valid: kept, invalid: dropped } = validateFileScopeInPromptContent(prompt);
if (dropped.length === 0) {
return { sanitized: prompt, dropped, kept };
}
const sanitizedSection = section
.split("\n")
.filter((line) => {
const tokens = Array.from(line.matchAll(/`([^`]+)`/g), (match) => match[1]);
if (tokens.length === 0) return true;
return tokens.every((token) => isValidFileScopeEntry(token));
})
.join("\n");
return {
sanitized: `${prompt.slice(0, startIdx)}${sanitizedSection}${prompt.slice(endIdx)}`,
dropped,
kept,
};
}
export const SELF_DEFEATING_OPERATION_VERBS = [
"finalize", // Terminalize target task state
"diagnose", // Investigate/diagnose target task failure
@@ -3636,17 +3668,12 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
const newDir = this.taskDir(newId);
await this.atomicCreateTaskJson(newDir, newTask, "duplicateTask");
const validation = validateFileScopeInPromptContent(sourceTask.prompt);
if (validation.invalid.length > 0) {
this.deleteTaskById(newId);
const { rm } = await import("node:fs/promises");
if (existsSync(newDir)) {
await rm(newDir, { recursive: true, force: true });
}
throw new InvalidFileScopeError(newId, validation.invalid);
const sanitizedPrompt = sanitizeFileScopeInPromptContent(sourceTask.prompt);
if (sanitizedPrompt.dropped.length > 0) {
storeLog.log(`[file-scope-sanitize] duplicate ${newId} from ${id}: dropped=[${sanitizedPrompt.dropped.join(",")}]`);
}
await mkdir(newDir, { recursive: true });
await writeFile(join(newDir, "PROMPT.md"), sourceTask.prompt);
await writeFile(join(newDir, "PROMPT.md"), sanitizedPrompt.sanitized);
if (this.isWatching) this.taskCache.set(newId, { ...newTask });
this.emit("task:created", newTask);
@@ -3718,18 +3745,9 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
const newDir = this.taskDir(newId);
await this.atomicCreateTaskJson(newDir, newTask, "refineTask");
const prompt = `# ${newTask.title}\n\n${newTask.description}\n`;
// Defensive no-op for refine prompts, which typically have no File Scope section.
const validation = validateFileScopeInPromptContent(prompt);
if (validation.invalid.length > 0) {
this.deleteTaskById(newId);
const { rm } = await import("node:fs/promises");
if (existsSync(newDir)) {
await rm(newDir, { recursive: true, force: true });
}
throw new InvalidFileScopeError(newId, validation.invalid);
}
const sanitizedPrompt = sanitizeFileScopeInPromptContent(prompt);
await mkdir(newDir, { recursive: true });
await writeFile(join(newDir, "PROMPT.md"), prompt);
await writeFile(join(newDir, "PROMPT.md"), sanitizedPrompt.sanitized);
if (sourceTask.attachments && sourceTask.attachments.length > 0) {
const sourceAttachDir = join(this.taskDir(id), "attachments");
@@ -8809,12 +8827,12 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
// Generate PROMPT.md with preserved steps
const prompt = entry.prompt ?? this.generatePromptFromArchiveEntry(entry);
const validation = validateFileScopeInPromptContent(prompt);
if (validation.invalid.length > 0) {
throw new InvalidFileScopeError(entry.id, validation.invalid);
const sanitizedPrompt = sanitizeFileScopeInPromptContent(prompt);
if (sanitizedPrompt.dropped.length > 0) {
storeLog.log(`[file-scope-sanitize] restore ${entry.id}: dropped=[${sanitizedPrompt.dropped.join(",")}]`);
}
await mkdir(dir, { recursive: true });
await writeFile(join(dir, "PROMPT.md"), prompt);
await writeFile(join(dir, "PROMPT.md"), sanitizedPrompt.sanitized);
// Create empty attachments directory if attachments existed
if (entry.attachments && entry.attachments.length > 0) {