feat(FN-5112): complete Step 1 — dangling task-doc reference detector
Fusion-Task-Id: FN-5112 Fusion-Task-Lineage: 692fba2a-5856-4256-8144-d87bda56e1aa
This commit is contained in:
committed by
gsxdsm
parent
febd1f872c
commit
b5e257d6e3
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { detectDanglingTaskDocReferences, formatDanglingDiagnostic } from "../spec-validation/task-document-references.js";
|
||||
|
||||
describe("detectDanglingTaskDocReferences", () => {
|
||||
it("flags FN-5110-style missing artifact references", async () => {
|
||||
const prompt = "## Context to Read First\n- `.fusion/tasks/FN-5110/failure-inventory.md`\n\n## Steps\n### Step 0: Preflight\n- Read .fusion/tasks/FN-5110/failure-inventory.md\n\n### Step 4: Verify\n- Use (.fusion/tasks/FN-5110/failure-inventory.md)\n\n### Step 5: Delivery\n- Final check: `.fusion/tasks/FN-5110/failure-inventory.md`";
|
||||
const refs = await detectDanglingTaskDocReferences(prompt, {
|
||||
rootDir: "/tmp/project",
|
||||
taskId: "FN-5112",
|
||||
existsImpl: async () => false,
|
||||
});
|
||||
|
||||
expect(refs).toEqual([
|
||||
{
|
||||
path: ".fusion/tasks/FN-5110/failure-inventory.md",
|
||||
sections: ["Context to Read First", "Step 0", "Step 4", "Step 5", "Steps"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores sibling PROMPT.md and task.json references", async () => {
|
||||
const prompt = `## Context to Read First\n- .fusion/tasks/FN-9999/PROMPT.md\n- .fusion/tasks/FN-9999/task.json`;
|
||||
const refs = await detectDanglingTaskDocReferences(prompt, {
|
||||
rootDir: "/tmp/project",
|
||||
taskId: "FN-5112",
|
||||
existsImpl: async () => false,
|
||||
});
|
||||
expect(refs).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not flag (new) artifacts", async () => {
|
||||
const prompt = "## Steps\n### Step 1: Create inventory\n- Read .fusion/tasks/FN-5112/failure-inventory.md\n\n**Artifacts:**\n- `.fusion/tasks/FN-5112/failure-inventory.md` (new)";
|
||||
const refs = await detectDanglingTaskDocReferences(prompt, {
|
||||
rootDir: "/tmp/project",
|
||||
taskId: "FN-5112",
|
||||
existsImpl: async () => false,
|
||||
});
|
||||
expect(refs).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not flag paths listed in file scope", async () => {
|
||||
const prompt = "## File Scope\n- `.fusion/tasks/FN-5112/failure-inventory.md`\n\n## Steps\n### Step 1: Read\n- .fusion/tasks/FN-5112/failure-inventory.md";
|
||||
const refs = await detectDanglingTaskDocReferences(prompt, {
|
||||
rootDir: "/tmp/project",
|
||||
taskId: "FN-5112",
|
||||
existsImpl: async () => false,
|
||||
});
|
||||
expect(refs).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty when all candidates exist", async () => {
|
||||
const prompt = `## Steps\n### Step 1: Read\n- .fusion/tasks/FN-9999/notes.md`;
|
||||
const refs = await detectDanglingTaskDocReferences(prompt, {
|
||||
rootDir: "/tmp/project",
|
||||
taskId: "FN-5112",
|
||||
existsImpl: async () => true,
|
||||
});
|
||||
expect(refs).toEqual([]);
|
||||
});
|
||||
|
||||
it("parses wrapped and punctuated path tokens", async () => {
|
||||
const prompt = "## Steps\n### Step 1: Parse\n- (`.fusion/tasks/FN-9999/notes%20encoded.md`),\n- '.fusion/tasks/FN-9999/path(with)-parens.md'.";
|
||||
|
||||
const refs = await detectDanglingTaskDocReferences(prompt, {
|
||||
rootDir: "/tmp/project",
|
||||
taskId: "FN-5112",
|
||||
existsImpl: async () => false,
|
||||
});
|
||||
|
||||
expect(refs.map((r) => r.path)).toEqual([
|
||||
".fusion/tasks/FN-9999/notes%20encoded.md",
|
||||
".fusion/tasks/FN-9999/path(with)-parens.md",
|
||||
]);
|
||||
});
|
||||
|
||||
it("formats revise diagnostics", async () => {
|
||||
const formatted = formatDanglingDiagnostic([
|
||||
{ path: ".fusion/tasks/FN-5110/failure-inventory.md", sections: ["Step 0", "Step 4", "Step 5"] },
|
||||
]);
|
||||
expect(formatted).toContain("REVISE — Dangling task-document references");
|
||||
expect(formatted).toContain("Step 0, Step 4, Step 5");
|
||||
});
|
||||
|
||||
it("can read the FN-5110 fixture prompt", async () => {
|
||||
const fixture = await readFile("/Users/eclipxe/Projects/kb/.fusion/tasks/FN-5110/PROMPT.md", "utf8");
|
||||
expect(fixture).toContain("# Task: FN-5110");
|
||||
});
|
||||
});
|
||||
1
packages/engine/src/spec-validation/index.ts
Normal file
1
packages/engine/src/spec-validation/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./task-document-references.js";
|
||||
135
packages/engine/src/spec-validation/task-document-references.ts
Normal file
135
packages/engine/src/spec-validation/task-document-references.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { access } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { extractSection } from "../step-session-executor.js";
|
||||
|
||||
export interface DanglingTaskDocReference {
|
||||
path: string;
|
||||
sections: string[];
|
||||
}
|
||||
|
||||
export interface DetectDanglingOptions {
|
||||
rootDir: string;
|
||||
taskId: string;
|
||||
existsImpl?: (absPath: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
const TASK_PATH_REGEX = /\.fusion\/tasks\/[A-Z0-9]+(?:-[A-Z0-9]+)*\/[^\s`'"\]]+/g;
|
||||
|
||||
function normalizePathToken(token: string): string {
|
||||
return token.replace(/^[`([{"']+/, "").replace(/[`),.;:!?\]}'"]+$/, "");
|
||||
}
|
||||
|
||||
function collectTaskPaths(text: string): string[] {
|
||||
const matches = text.match(TASK_PATH_REGEX) ?? [];
|
||||
return matches.map(normalizePathToken);
|
||||
}
|
||||
|
||||
function collectStepSections(stepsSection: string): Array<{ name: string; body: string }> {
|
||||
const sections: Array<{ name: string; body: string }> = [];
|
||||
const headingMatches = Array.from(stepsSection.matchAll(/^### Step (\d+):[^\n]*$/gm));
|
||||
for (let i = 0; i < headingMatches.length; i += 1) {
|
||||
const heading = headingMatches[i];
|
||||
const start = (heading.index ?? 0) + heading[0].length;
|
||||
const end = i + 1 < headingMatches.length ? (headingMatches[i + 1].index ?? stepsSection.length) : stepsSection.length;
|
||||
sections.push({ name: `Step ${heading[1]}`, body: stepsSection.slice(start, end).trim() });
|
||||
}
|
||||
return sections;
|
||||
}
|
||||
|
||||
function collectProducedArtifacts(stepsSection: string): Set<string> {
|
||||
const produced = new Set<string>();
|
||||
for (const step of collectStepSections(stepsSection)) {
|
||||
let inArtifacts = false;
|
||||
for (const line of step.body.split("\n")) {
|
||||
if (line.trim().startsWith("**Artifacts:**")) {
|
||||
inArtifacts = true;
|
||||
continue;
|
||||
}
|
||||
if (inArtifacts && /^##|^###/.test(line.trim())) {
|
||||
inArtifacts = false;
|
||||
}
|
||||
if (!inArtifacts) continue;
|
||||
if (!line.includes("(new)")) continue;
|
||||
for (const token of collectTaskPaths(line)) {
|
||||
produced.add(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
return produced;
|
||||
}
|
||||
|
||||
function collectFileScopePaths(promptContent: string): Set<string> {
|
||||
const section = extractSection(promptContent, "File Scope");
|
||||
const paths = new Set<string>();
|
||||
for (const token of collectTaskPaths(section)) {
|
||||
paths.add(token);
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
function isWhitelistedSiblingArtifact(path: string): boolean {
|
||||
return path.endsWith("/PROMPT.md") || path.endsWith("/task.json") || path.includes("/attachments/");
|
||||
}
|
||||
|
||||
function shouldSkipCandidate(path: string, taskId: string, producedArtifacts: Set<string>, fileScopePaths: Set<string>): boolean {
|
||||
if (isWhitelistedSiblingArtifact(path)) return true;
|
||||
const thisTaskPrefix = `.fusion/tasks/${taskId}/`;
|
||||
if (!path.startsWith(thisTaskPrefix)) return false;
|
||||
if (producedArtifacts.has(path)) return true;
|
||||
if (fileScopePaths.has(path)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function detectDanglingTaskDocReferences(
|
||||
promptContent: string,
|
||||
opts: DetectDanglingOptions,
|
||||
): Promise<DanglingTaskDocReference[]> {
|
||||
const existsImpl = opts.existsImpl ?? (async (absPath: string) => {
|
||||
try {
|
||||
await access(absPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
const contextSection = extractSection(promptContent, "Context to Read First");
|
||||
const stepsSection = extractSection(promptContent, "Steps");
|
||||
const producedArtifacts = collectProducedArtifacts(stepsSection);
|
||||
const fileScopePaths = collectFileScopePaths(promptContent);
|
||||
|
||||
const references = new Map<string, Set<string>>();
|
||||
const addReferences = (sectionName: string, text: string) => {
|
||||
for (const path of collectTaskPaths(text)) {
|
||||
if (shouldSkipCandidate(path, opts.taskId, producedArtifacts, fileScopePaths)) continue;
|
||||
const sectionSet = references.get(path) ?? new Set<string>();
|
||||
sectionSet.add(sectionName);
|
||||
references.set(path, sectionSet);
|
||||
}
|
||||
};
|
||||
|
||||
addReferences("Context to Read First", contextSection);
|
||||
addReferences("Steps", stepsSection);
|
||||
for (const step of collectStepSections(stepsSection)) {
|
||||
addReferences(step.name, step.body);
|
||||
}
|
||||
|
||||
const dangling: DanglingTaskDocReference[] = [];
|
||||
for (const [path, sections] of references.entries()) {
|
||||
const absPath = join(opts.rootDir, path);
|
||||
if (await existsImpl(absPath)) continue;
|
||||
dangling.push({ path, sections: Array.from(sections).sort() });
|
||||
}
|
||||
|
||||
return dangling.sort((a, b) => a.path.localeCompare(b.path));
|
||||
}
|
||||
|
||||
export function formatDanglingDiagnostic(refs: DanglingTaskDocReference[]): string {
|
||||
if (refs.length === 0) return "REVISE — Dangling task-document references in PROMPT.md: none.";
|
||||
const lines = ["REVISE — Dangling task-document references in PROMPT.md:"];
|
||||
for (const ref of refs) {
|
||||
lines.push(` - ${ref.path} (cited in: ${ref.sections.join(", ")})`);
|
||||
lines.push(" Fix: either remove these references, list the file under File Scope as a (new) artifact, or add a step that creates it before it is read.");
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -507,7 +507,7 @@ function countSteps(prompt: string): number {
|
||||
* Extract a named section from the prompt (e.g. "File Scope", "Do NOT").
|
||||
* Returns the section from the heading to the next ## or ### heading, or end.
|
||||
*/
|
||||
function extractSection(prompt: string, sectionName: string): string {
|
||||
export function extractSection(prompt: string, sectionName: string): string {
|
||||
// Match ## Section Name or **Section Name** as a heading
|
||||
const regex = new RegExp(`^## ${escapeRegex(sectionName)}\\s*$`, "m");
|
||||
const match = regex.exec(prompt);
|
||||
|
||||
Reference in New Issue
Block a user