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:
committed by
gsxdsm
parent
cad2434710
commit
50654dff6e
79
packages/engine/src/__tests__/triage-preflight.test.ts
Normal file
79
packages/engine/src/__tests__/triage-preflight.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
155
packages/engine/src/triage-preflight.ts
Normal file
155
packages/engine/src/triage-preflight.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user