feat(FN-4892): complete Step 1 — triage preflight module

Fusion-Task-Id: FN-4892
Fusion-Task-Lineage: 6f56b468-a827-416d-ba1f-444e1326d613
This commit is contained in:
Fusion (runfusion.ai)
2026-05-17 21:30:33 -07:00
committed by gsxdsm
parent cad2434710
commit 50654dff6e
2 changed files with 234 additions and 0 deletions

View File

@@ -0,0 +1,79 @@
import { describe, expect, it, vi } from "vitest";
import { extractCitedConstructs, isBugFixShape, runGhostBugPreflight } from "../triage-preflight";
describe("triage-preflight", () => {
it("isBugFixShape matrix", () => {
expect(isBugFixShape({ title: "fix: broken typecheck", description: "x" })).toBe(true);
expect(isBugFixShape({ title: "chore", description: "compile error appears" })).toBe(true);
expect(isBugFixShape({ title: "refactor", description: "cleanup" })).toBe(false);
expect(isBugFixShape({ title: null, description: "" })).toBe(false);
});
it("extracts constructs and dedupes", () => {
const prompt = [
"Use `secrets_sync.handle()` and `foo_bar` in packages/core/src/secrets-sync.ts:12",
"```ts",
"import { x } from 'y'",
"const a = b",
"```",
"pnpm --filter @fusion/core test",
"pnpm --filter @fusion/core test",
].join("\n");
const constructs = extractCitedConstructs(prompt);
expect(constructs.some((c) => c.kind === "identifier" && c.raw === "secrets_sync.handle()")).toBe(true);
expect(constructs.some((c) => c.filePath === "packages/core/src/secrets-sync.ts" && c.line === 12)).toBe(true);
expect(constructs.some((c) => c.kind === "snippet" && c.raw.includes("import"))).toBe(true);
expect(constructs.filter((c) => c.kind === "command")).toHaveLength(1);
});
it("caps extracted constructs at 20", () => {
const lines = Array.from({ length: 30 }, (_, i) => `\`value_${i}.x\``).join("\n");
expect(extractCitedConstructs(lines)).toHaveLength(20);
});
it("archives when all definitive probes are missing", async () => {
const exec = vi.fn().mockResolvedValue({ stdout: "", stderr: "" });
const decision = await runGhostBugPreflight(
{ title: "fix: typecheck error", description: "desc" },
"`foo_bar`",
{ cwd: process.cwd(), exec },
);
expect(decision.decision).toBe("archive");
});
it("passes when at least one construct matches", async () => {
const exec = vi
.fn()
.mockResolvedValueOnce({ stdout: "hit", stderr: "" })
.mockResolvedValueOnce({ stdout: "", stderr: "" });
const decision = await runGhostBugPreflight(
{ title: "fix: compile error", description: "desc" },
"`foo_bar`\n`bar_baz`",
{ cwd: process.cwd(), exec },
);
expect(decision.decision).toBe("pass");
});
it("fails open when all probes throw", async () => {
const exec = vi.fn().mockRejectedValue(new Error("boom"));
const decision = await runGhostBugPreflight(
{ title: "fix: regression", description: "desc" },
"`foo_bar`",
{ cwd: process.cwd(), exec },
);
expect(decision.decision).toBe("pass");
});
it("passes non bug-shape tasks", async () => {
const exec = vi.fn();
const decision = await runGhostBugPreflight(
{ title: "docs", description: "desc" },
"`foo_bar`",
{ cwd: process.cwd(), exec },
);
expect(decision.decision).toBe("pass");
expect(exec).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,155 @@
import type { Task } from "@fusion/core";
export interface CitedConstruct {
kind: "identifier" | "snippet" | "command";
raw: string;
filePath?: string;
line?: number;
}
export interface GhostBugProbeResult {
construct: CitedConstruct;
matched: boolean;
probeError?: string;
output?: string;
}
export interface GhostBugDecision {
decision: "archive" | "pass";
reason: string;
findings: GhostBugProbeResult[];
}
interface ExecResult {
stdout: string;
stderr: string;
}
export type ProbeExec = (command: string, options?: { cwd?: string; timeoutMs?: number }) => Promise<ExecResult>;
const BUG_FIX_REGEX = /typecheck error|compile error|broken|regression|lint error/i;
export function isBugFixShape(task: { title: string | null; description: string }): boolean {
const title = task.title?.trim() ?? "";
const description = task.description?.trim() ?? "";
if (!title && !description) return false;
if (/^\s*fix\b/i.test(title)) return true;
return BUG_FIX_REGEX.test(`${title}\n${description}`);
}
export function extractCitedConstructs(prompt: string): CitedConstruct[] {
const seen = new Set<string>();
const constructs: CitedConstruct[] = [];
const add = (construct: CitedConstruct) => {
if (construct.raw.trim().length === 0) return;
const key = `${construct.kind}:${construct.raw}:${construct.filePath ?? ""}:${construct.line ?? ""}`;
if (seen.has(key) || constructs.length >= 20) return;
seen.add(key);
constructs.push(construct);
};
const identifierRegex = /`([A-Za-z_][A-Za-z0-9_.]*\([^`]*\)|[A-Za-z_][\w.]{2,})`/g;
for (const match of prompt.matchAll(identifierRegex)) {
const raw = match[1].trim();
if (raw.includes("(") || raw.includes(".") || raw.includes("_")) {
add({ kind: "identifier", raw });
}
}
const fileRegex = /(packages\/[\w./-]+\.(?:ts|tsx|js|mjs|cjs|md))(?::(\d+))?/g;
for (const match of prompt.matchAll(fileRegex)) {
const filePath = match[1];
const line = match[2] ? Number.parseInt(match[2], 10) : undefined;
add({ kind: "identifier", raw: filePath, filePath, line });
}
const fenceRegex = /```(?:\w+)?\n([\s\S]*?)```/g;
for (const match of prompt.matchAll(fenceRegex)) {
const lines = match[1].split("\n").map((line) => line.trim()).filter(Boolean);
for (const line of lines) {
if (line.includes("(") || line.includes("=") || line.includes("import")) {
add({ kind: "snippet", raw: line });
}
}
}
for (const line of prompt.split("\n")) {
if (/^\s*(?:pnpm|npm|yarn|tsc|node|eslint)\b[^\n]+/m.test(line)) {
add({ kind: "command", raw: line.trim() });
}
}
return constructs;
}
export async function probeCitedConstructs(
constructs: CitedConstruct[],
opts: { cwd: string; timeoutMs?: number; exec: ProbeExec },
): Promise<GhostBugProbeResult[]> {
const findings: GhostBugProbeResult[] = [];
const timeoutMs = opts.timeoutMs ?? 5000;
for (const construct of constructs) {
try {
let command = "";
if (construct.kind === "identifier") {
if (construct.filePath) {
command = `git show HEAD:${construct.filePath} | grep -nF ${JSON.stringify(construct.raw)} || true`;
} else {
command = `git grep -nF -- ${JSON.stringify(construct.raw)} packages/ || true`;
}
} else if (construct.kind === "snippet") {
command = `git grep -nF -- ${JSON.stringify(construct.raw)} packages/ || true`;
} else {
command = construct.raw;
}
const { stdout, stderr } = await opts.exec(command, { cwd: opts.cwd, timeoutMs });
if (construct.kind === "command") {
findings.push({ construct, matched: true, output: `${stdout}${stderr}`.trim() });
continue;
}
const output = `${stdout}${stderr}`.trim();
findings.push({ construct, matched: output.length > 0, output });
} catch (error) {
findings.push({
construct,
matched: false,
probeError: error instanceof Error ? error.message : String(error),
});
}
}
return findings.filter((result) => !result.probeError || result.matched === true || result.matched === false);
}
export async function runGhostBugPreflight(
task: Pick<Task, "title" | "description">,
prompt: string,
opts: { cwd: string; timeoutMs?: number; exec: ProbeExec },
): Promise<GhostBugDecision> {
if (!isBugFixShape({ title: task.title ?? null, description: task.description ?? "" })) {
return { decision: "pass", reason: "not_bug_fix_shape", findings: [] };
}
const constructs = extractCitedConstructs(prompt);
if (constructs.length === 0) {
return { decision: "pass", reason: "no_constructs", findings: [] };
}
const findings = await probeCitedConstructs(constructs, opts);
const definitive = findings.filter((finding) => !finding.probeError);
if (definitive.length === 0) {
return { decision: "pass", reason: "no_definitive_probe_signal", findings };
}
if (definitive.every((finding) => finding.matched === false)) {
return {
decision: "archive",
reason: "all_cited_constructs_missing_on_main",
findings,
};
}
return { decision: "pass", reason: "construct_found_or_inconclusive", findings };
}