feat(FN-4409): complete Step 1 — add cross-contamination audit script
Fusion-Task-Id: FN-4409 Fusion-Task-Lineage: c608c942-fec7-4489-9970-b8dc314f8be2
This commit is contained in:
47
scripts/__tests__/audit-branch-cross-contamination.test.mjs
Normal file
47
scripts/__tests__/audit-branch-cross-contamination.test.mjs
Normal file
@@ -0,0 +1,47 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
import { auditBranchCrossContamination } from "../audit-branch-cross-contamination.mjs";
|
||||
|
||||
function run(cwd, cmd, args) {
|
||||
return execFileSync(cmd, args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
||||
}
|
||||
|
||||
test("flags branch as tainted when foreign task commits are present", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "fn-4409-audit-"));
|
||||
try {
|
||||
run(dir, "git", ["init", "-b", "main"]);
|
||||
run(dir, "git", ["config", "user.name", "Fusion"]);
|
||||
run(dir, "git", ["config", "user.email", "noreply@runfusion.ai"]);
|
||||
|
||||
fs.writeFileSync(path.join(dir, "README.md"), "base\n");
|
||||
run(dir, "git", ["add", "README.md"]);
|
||||
run(dir, "git", ["commit", "-m", "chore: base"]);
|
||||
const base = run(dir, "git", ["rev-parse", "HEAD"]);
|
||||
|
||||
run(dir, "git", ["checkout", "-b", "fusion/fn-0001"]);
|
||||
fs.writeFileSync(path.join(dir, "feature.txt"), "foreign\n");
|
||||
run(dir, "git", ["add", "feature.txt"]);
|
||||
run(dir, "git", ["commit", "-m", "feat(FN-0002): foreign work", "-m", "Fusion-Task-Id: FN-0002"]);
|
||||
|
||||
const fusionDir = path.join(dir, ".fusion");
|
||||
fs.mkdirSync(fusionDir, { recursive: true });
|
||||
const dbPath = path.join(fusionDir, "fusion.db");
|
||||
run(dir, "sqlite3", [dbPath, `CREATE TABLE tasks (id TEXT PRIMARY KEY, title TEXT, branch TEXT, baseCommitSha TEXT, "column" TEXT);`]);
|
||||
run(dir, "sqlite3", [dbPath, `INSERT INTO tasks (id, title, branch, baseCommitSha, "column") VALUES ('FN-0001', 'Task 1', 'fusion/fn-0001', '${base}', 'in-progress');`]);
|
||||
|
||||
const report = auditBranchCrossContamination({ projectRoot: dir });
|
||||
assert.equal(report.taintedTaskCount, 1);
|
||||
const task = report.tasks.find((entry) => entry.taskId === "FN-0001");
|
||||
assert.ok(task);
|
||||
assert.equal(task.tainted, true);
|
||||
assert.equal(task.recommendation, "force-reset");
|
||||
assert.equal(task.taintedCommits[0].foreignTaskId, "FN-0002");
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
190
scripts/audit-branch-cross-contamination.mjs
Normal file
190
scripts/audit-branch-cross-contamination.mjs
Normal file
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
function runGit(projectRoot, args, { allowFailure = false } = {}) {
|
||||
try {
|
||||
return execFileSync("git", args, { cwd: projectRoot, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
||||
} catch (error) {
|
||||
if (allowFailure) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
projectRoot: process.cwd(),
|
||||
outPath: null,
|
||||
};
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (arg === "--project-root") {
|
||||
options.projectRoot = path.resolve(argv[i + 1] ?? process.cwd());
|
||||
i += 1;
|
||||
} else if (arg.startsWith("--out=")) {
|
||||
options.outPath = path.resolve(arg.slice("--out=".length));
|
||||
} else if (arg === "--out") {
|
||||
options.outPath = path.resolve(argv[i + 1] ?? "audit-branch-cross-contamination.json");
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function sqliteJson(dbPath, sql) {
|
||||
const output = execFileSync("sqlite3", ["-json", dbPath, sql], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
}).trim();
|
||||
return output ? JSON.parse(output) : [];
|
||||
}
|
||||
|
||||
function parseTaskIdFromSubject(subject) {
|
||||
const match = String(subject).match(/^[a-z]+\((FN-\d+)\):/i);
|
||||
return match ? match[1].toUpperCase() : null;
|
||||
}
|
||||
|
||||
function parseTaskIdFromBody(body) {
|
||||
const match = String(body).match(/(?:^|\n)Fusion-Task-Id:\s*(FN-\d+)\s*(?:\n|$)/i);
|
||||
return match ? match[1].toUpperCase() : null;
|
||||
}
|
||||
|
||||
function expectedBranch(taskId, branch) {
|
||||
if (branch && String(branch).trim()) return String(branch).trim();
|
||||
return `fusion/${String(taskId).toLowerCase()}`;
|
||||
}
|
||||
|
||||
function branchExists(projectRoot, branchName) {
|
||||
return runGit(projectRoot, ["rev-parse", "--verify", `refs/heads/${branchName}`], { allowFailure: true }) !== null;
|
||||
}
|
||||
|
||||
function parseCommits(raw) {
|
||||
if (!raw) return [];
|
||||
const lines = raw.split("\n").map((line) => line.trim()).filter(Boolean);
|
||||
return lines.map((line) => {
|
||||
const [sha, subject, body] = line.split("\u001f");
|
||||
const trailerTaskId = parseTaskIdFromBody(body ?? "");
|
||||
const subjectTaskId = parseTaskIdFromSubject(subject ?? "");
|
||||
return {
|
||||
sha,
|
||||
subject,
|
||||
trailerTaskId,
|
||||
subjectTaskId,
|
||||
attributedTaskId: trailerTaskId ?? subjectTaskId,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function auditBranchCrossContamination({ projectRoot = process.cwd() } = {}) {
|
||||
const dbPath = path.join(projectRoot, ".fusion", "fusion.db");
|
||||
const taskRows = sqliteJson(
|
||||
dbPath,
|
||||
`SELECT id, title, branch, baseCommitSha, "column" AS columnName FROM tasks WHERE "column" IN ('triage','todo','in-progress','in-review') ORDER BY id`,
|
||||
);
|
||||
|
||||
const report = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
projectRoot,
|
||||
scannedTaskCount: taskRows.length,
|
||||
scannedColumns: ["triage", "todo", "in-progress", "in-review"],
|
||||
taintedTaskCount: 0,
|
||||
missingBranchCount: 0,
|
||||
tasks: [],
|
||||
};
|
||||
|
||||
for (const task of taskRows) {
|
||||
const taskId = String(task.id).toUpperCase();
|
||||
const branchName = expectedBranch(taskId, task.branch);
|
||||
const baseCommitSha = task.baseCommitSha ? String(task.baseCommitSha).trim() : null;
|
||||
|
||||
if (!baseCommitSha) {
|
||||
report.tasks.push({
|
||||
taskId,
|
||||
title: task.title,
|
||||
branchName,
|
||||
baseCommitSha: null,
|
||||
column: task.columnName,
|
||||
skipped: true,
|
||||
reason: "missing-baseCommitSha",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!branchExists(projectRoot, branchName)) {
|
||||
report.missingBranchCount += 1;
|
||||
process.stderr.write(`[skip] ${taskId}: branch not found locally (${branchName})\n`);
|
||||
report.tasks.push({
|
||||
taskId,
|
||||
title: task.title,
|
||||
branchName,
|
||||
baseCommitSha,
|
||||
column: task.columnName,
|
||||
skipped: true,
|
||||
reason: "branch-missing-local",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const rawLog = runGit(projectRoot, ["log", `${baseCommitSha}..${branchName}`, "--format=%H%x1f%s%x1f%b"]);
|
||||
const commits = parseCommits(rawLog);
|
||||
const taintedCommits = commits.filter((commit) => commit.attributedTaskId && commit.attributedTaskId !== taskId);
|
||||
const ownCommits = commits.filter((commit) => commit.attributedTaskId === taskId);
|
||||
const isTainted = taintedCommits.length > 0;
|
||||
|
||||
if (isTainted) report.taintedTaskCount += 1;
|
||||
|
||||
report.tasks.push({
|
||||
taskId,
|
||||
title: task.title,
|
||||
branchName,
|
||||
baseCommitSha,
|
||||
column: task.columnName,
|
||||
totalCommits: commits.length,
|
||||
taskAttributedCommitCount: ownCommits.length,
|
||||
tainted: isTainted,
|
||||
taintedCommits: taintedCommits.map((commit) => ({
|
||||
sha: commit.sha,
|
||||
subject: commit.subject,
|
||||
foreignTaskId: commit.attributedTaskId,
|
||||
})),
|
||||
recommendation: !isTainted ? "clean" : ownCommits.length > 0 ? "refile" : "force-reset",
|
||||
commits,
|
||||
});
|
||||
}
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
function renderSummary(report) {
|
||||
const lines = [
|
||||
`Branch cross-contamination audit`,
|
||||
`Scanned tasks: ${report.scannedTaskCount}`,
|
||||
`Tainted branches: ${report.taintedTaskCount}`,
|
||||
`Missing local branches: ${report.missingBranchCount}`,
|
||||
"",
|
||||
];
|
||||
for (const task of report.tasks) {
|
||||
if (task.skipped) continue;
|
||||
if (!task.tainted) continue;
|
||||
lines.push(`${task.taskId} | branch=${task.branchName} | base=${task.baseCommitSha} | commits=${task.totalCommits} | recommendation=${task.recommendation}`);
|
||||
for (const commit of task.taintedCommits) {
|
||||
lines.push(` - ${commit.sha.slice(0, 12)} ${commit.subject} [foreign=${commit.foreignTaskId ?? "unknown"}]`);
|
||||
}
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const report = auditBranchCrossContamination({ projectRoot: options.projectRoot });
|
||||
const json = JSON.stringify(report, null, 2);
|
||||
process.stdout.write(`${json}\n`);
|
||||
process.stderr.write(`${renderSummary(report)}\n`);
|
||||
if (options.outPath) {
|
||||
fs.mkdirSync(path.dirname(options.outPath), { recursive: true });
|
||||
fs.writeFileSync(options.outPath, json);
|
||||
process.stderr.write(`\nWrote report: ${options.outPath}\n`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user