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:
committed by
gsxdsm
parent
37d4115bb6
commit
35b2971f54
8
.changeset/FN-5216-file-scope-duplicate-sanitize.md
Normal file
8
.changeset/FN-5216-file-scope-duplicate-sanitize.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user