feat(FN-4656): complete Step 1 — add owned landed evidence classifier

Fusion-Task-Id: FN-4656
Fusion-Task-Lineage: 81e8bd91-bc02-4bb7-a590-0e6e84e3535f
This commit is contained in:
Fusion
2026-05-15 13:34:57 -07:00
committed by gsxdsm
parent 210174005f
commit b8337a9dcd
2 changed files with 298 additions and 0 deletions

View File

@@ -0,0 +1,171 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { execSync, spawnSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Settings, Task, TaskStore } from "@fusion/core";
import { DEFAULT_SETTINGS } from "@fusion/core";
vi.mock("../pi.js", () => ({
createFnAgent: vi.fn(async () => ({ session: { prompt: vi.fn(async () => undefined), dispose: vi.fn() } })),
describeModel: vi.fn(() => "mock-provider/mock-model"),
promptWithFallback: vi.fn(async (session: any, prompt: string, options?: any) => {
if (options === undefined) {
await session.prompt(prompt);
} else {
await session.prompt(prompt, options);
}
}),
compactSessionContext: vi.fn(),
}));
import { aiMergeTask, classifyOwnedLandedEvidence } from "../merger.js";
const hasGit = spawnSync("git", ["--version"], { stdio: "pipe" }).status === 0;
const describeIfGit = hasGit ? describe : describe.skip;
function git(repo: string, command: string): string {
return execSync(command, { cwd: repo, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
}
function createStore(task: Task, settings: Partial<Settings> = {}): TaskStore {
let currentTask = { ...task };
const mergedSettings: Settings = {
...DEFAULT_SETTINGS,
mergeStrategy: "direct",
directMergeCommitStrategy: "auto",
autoMerge: true,
includeTaskIdInCommit: false,
commitAuthorEnabled: false,
useAiMergeCommitSummary: false,
...settings,
} as Settings;
return {
getTask: vi.fn(async () => currentTask),
getSettings: vi.fn(async () => mergedSettings),
listTasks: vi.fn(async () => [currentTask]),
updateTask: vi.fn(async (_id: string, updates: Partial<Task>) => {
currentTask = { ...currentTask, ...updates, updatedAt: new Date().toISOString() } as Task;
return currentTask;
}),
moveTask: vi.fn(async (_id: string, column: Task["column"]) => {
currentTask = {
...currentTask,
column,
columnMovedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as Task;
return currentTask;
}),
logEntry: vi.fn(async () => undefined),
appendAgentLog: vi.fn(async () => undefined),
updateSettings: vi.fn(async () => mergedSettings),
getActiveMergingTask: vi.fn(() => null),
emit: vi.fn(),
on: vi.fn(),
clearStaleExecutionStartBranchReferences: vi.fn(() => []),
getVerificationCacheHit: vi.fn(() => null),
recordVerificationCachePass: vi.fn(() => undefined),
upsertTaskCommitAssociation: vi.fn(async () => undefined),
} as unknown as TaskStore;
}
describeIfGit("aiMergeTask finalize no-op unproven reproduction (real git)", () => {
const repos: string[] = [];
afterEach(() => {
for (const repo of repos.splice(0)) {
rmSync(repo, { recursive: true, force: true });
}
});
it("classifies owned-commit when landed trailer commit exists on target", async () => {
const repo = mkdtempSync(join(tmpdir(), "fusion-merger-owned-"));
repos.push(repo);
git(repo, "git init -b main");
git(repo, 'git config user.email "test@example.com"');
git(repo, 'git config user.name "Test User"');
git(repo, "git commit --allow-empty -m 'init'");
git(repo, "git checkout -b fusion/fn-owned");
writeFileSync(join(repo, "owned.txt"), "owned\n", "utf-8");
git(repo, "git add owned.txt && git commit -m 'feat(FN-OWNED): landed' -m 'Fusion-Task-Id: FN-OWNED'");
const ownedSha = git(repo, "git rev-parse HEAD");
git(repo, "git checkout main");
git(repo, `git cherry-pick ${ownedSha}`);
const classification = await classifyOwnedLandedEvidence(repo, { id: "FN-OWNED", branch: "fusion/fn-owned" } as Task, {
mergeTargetBranch: "main",
});
expect(classification.kind).toBe("owned-commit");
});
it("classifies proven-no-op when branch is zero-ahead from merge target and base is reachable", async () => {
const repo = mkdtempSync(join(tmpdir(), "fusion-merger-proven-noop-"));
repos.push(repo);
git(repo, "git init -b main");
git(repo, 'git config user.email "test@example.com"');
git(repo, 'git config user.name "Test User"');
git(repo, "git commit --allow-empty -m 'init'");
const baseSha = git(repo, "git rev-parse HEAD");
git(repo, "git checkout -b fusion/fn-noop");
git(repo, "git checkout main");
const classification = await classifyOwnedLandedEvidence(
repo,
{ id: "FN-NOOP", branch: "fusion/fn-noop", baseCommitSha: baseSha } as Task,
{ mergeTargetBranch: "main" },
);
expect(classification).toEqual({ kind: "proven-no-op", baseRef: "main", ownDiffEmpty: true });
});
it("reproduces FN-4653 shape: foreign start-point branch with no FN-owned commits can auto-complete", async () => {
const repo = mkdtempSync(join(tmpdir(), "fusion-merger-unproven-"));
repos.push(repo);
git(repo, "git init -b main");
git(repo, 'git config user.email "test@example.com"');
git(repo, 'git config user.name "Test User"');
writeFileSync(join(repo, "README.md"), "init\n", "utf-8");
git(repo, "git add README.md && git commit -m 'chore: init'");
const baseSha = git(repo, "git rev-parse HEAD");
git(repo, "git checkout -b fusion/fn-a");
writeFileSync(join(repo, "foreign.txt"), "from fn-a\n", "utf-8");
git(repo, "git add foreign.txt");
git(repo, "git commit -m 'feat(FN-A): foreign start point' -m 'Fusion-Task-Id: FN-A'");
const foreignBaseSha = git(repo, "git rev-parse HEAD");
git(repo, "git checkout -b fusion/fn-b");
git(repo, "git checkout main");
const task = {
id: "FN-B",
title: "FN-B",
description: "FN-B",
column: "in-review",
branch: "fusion/fn-b",
baseBranch: "main",
baseCommitSha: foreignBaseSha,
modifiedFiles: ["foreign.txt"],
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
prompt: "# FN-B",
} as unknown as Task;
const classification = await classifyOwnedLandedEvidence(repo, task, { mergeTargetBranch: "main" });
expect(classification.kind).toBe("unproven");
expect(classification.reason).toBe("foreign-start-point");
const store = createStore(task);
const result = await aiMergeTask(store, repo, "FN-B");
expect(result.merged).toBe(true);
expect((store.moveTask as ReturnType<typeof vi.fn>).mock.calls.some(([, column]) => column === "done")).toBe(true);
}, 20_000);
});

View File

@@ -403,6 +403,15 @@ interface OwnedLandedCommit {
deletions?: number; deletions?: number;
} }
export type OwnedLandedClassification =
| { kind: "owned-commit"; commit: OwnedLandedCommit }
| { kind: "proven-no-op"; baseRef: string; ownDiffEmpty: true }
| {
kind: "unproven";
reason: "foreign-start-point" | "no-owned-commit-foreign-deltas" | "missing-evidence";
details: Record<string, unknown>;
};
function commitOwnedByTask(taskId: string, subject: string, body: string): boolean { function commitOwnedByTask(taskId: string, subject: string, body: string): boolean {
return body.includes(`${FUSION_TASK_ID_TRAILER_KEY}: ${taskId}`) || subject.includes(taskId); return body.includes(`${FUSION_TASK_ID_TRAILER_KEY}: ${taskId}`) || subject.includes(taskId);
} }
@@ -461,6 +470,124 @@ async function findOwnedLandedCommitForTask(rootDir: string, task: Task): Promis
return null; return null;
} }
function toTaskToken(value: string): string {
return value.toUpperCase().replace(/[^A-Z0-9]/g, "");
}
export async function classifyOwnedLandedEvidence(
rootDir: string,
task: Task,
opts: { mergeTargetBranch: string },
): Promise<OwnedLandedClassification> {
const branch = task.branch || `fusion/${task.id.toLowerCase()}`;
const mergeTargetBranch = opts.mergeTargetBranch;
const ownedCommit = await findOwnedLandedCommitForTask(rootDir, task);
if (ownedCommit) {
try {
await execFileAsync("git", ["merge-base", "--is-ancestor", ownedCommit.sha, mergeTargetBranch], { cwd: rootDir });
return { kind: "owned-commit", commit: ownedCommit };
} catch {
// fall through
}
}
let aheadCount: number | null = null;
try {
const { stdout } = await execFileAsync("git", ["rev-list", "--count", `${mergeTargetBranch}..${branch}`], {
cwd: rootDir,
encoding: "utf-8",
});
aheadCount = Number.parseInt(stdout.trim(), 10);
if (!Number.isFinite(aheadCount)) aheadCount = null;
} catch {
aheadCount = null;
}
let baseReachableFromTarget = false;
if (task.baseCommitSha) {
try {
await execFileAsync("git", ["merge-base", "--is-ancestor", task.baseCommitSha, mergeTargetBranch], { cwd: rootDir });
baseReachableFromTarget = true;
} catch {
baseReachableFromTarget = false;
}
}
if (aheadCount === 0 && baseReachableFromTarget) {
return { kind: "proven-no-op", baseRef: mergeTargetBranch, ownDiffEmpty: true };
}
if (task.baseCommitSha && !baseReachableFromTarget) {
try {
const { stdout } = await execFileAsync("git", ["for-each-ref", "--format=%(refname:short)", "refs/heads/fusion"], {
cwd: rootDir,
encoding: "utf-8",
});
const refs = stdout.split("\n").map((line) => line.trim()).filter(Boolean);
for (const ref of refs) {
if (ref === branch) continue;
try {
await execFileAsync("git", ["merge-base", "--is-ancestor", task.baseCommitSha, ref], { cwd: rootDir });
return {
kind: "unproven",
reason: "foreign-start-point",
details: { branch, mergeTargetBranch, baseCommitSha: task.baseCommitSha, foreignRef: ref, aheadCount },
};
} catch {
// continue
}
}
} catch {
// continue to missing evidence
}
}
if (aheadCount !== null && aheadCount > 0) {
try {
const { stdout } = await execFileAsync("git", ["log", "--format=%s%x1f%b", `${mergeTargetBranch}..${branch}`], {
cwd: rootDir,
encoding: "utf-8",
});
const currentToken = toTaskToken(task.id);
const lines = stdout.split("\n").filter(Boolean);
let foreignCount = 0;
for (const line of lines) {
const [subject = "", body = ""] = line.split("\x1f");
const trailerMatch = body.match(/Fusion-Task-Id:\s*([^\n\r]+)/i);
const trailerToken = trailerMatch ? toTaskToken(trailerMatch[1] || "") : "";
const subjectTokenMatch = subject.match(/\((FN-[^)]+)\)/i);
const subjectToken = subjectTokenMatch ? toTaskToken(subjectTokenMatch[1] || "") : "";
if ((trailerToken && trailerToken !== currentToken) || (subjectToken && subjectToken !== currentToken)) {
foreignCount += 1;
}
}
if (foreignCount > 0) {
return {
kind: "unproven",
reason: "no-owned-commit-foreign-deltas",
details: { branch, mergeTargetBranch, aheadCount, foreignCommitCount: foreignCount },
};
}
} catch {
// continue
}
}
return {
kind: "unproven",
reason: "missing-evidence",
details: {
branch,
mergeTargetBranch,
aheadCount,
baseCommitShaPresent: Boolean(task.baseCommitSha),
baseReachableFromTarget,
hasOwnedCommit: Boolean(ownedCommit),
},
};
}
/** /**
* Infer a default test command based on project files. * Infer a default test command based on project files.
* Returns the command and whether it was explicitly configured or inferred. * Returns the command and whether it was explicitly configured or inferred.