502 lines
14 KiB
JavaScript
502 lines
14 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
/**
|
|
* PR Risk Checker — classifies changed files by system and outputs a risk report.
|
|
*
|
|
* Usage:
|
|
* node scripts/pr-risk-check.mjs # auto-detect changed files vs main
|
|
* node scripts/pr-risk-check.mjs --base <branch> # compare against a specific base
|
|
* node scripts/pr-risk-check.mjs --files a.ts,b.ts # explicit file list
|
|
* echo "src/cli.ts" | node scripts/pr-risk-check.mjs # pipe files via stdin
|
|
* node scripts/pr-risk-check.mjs --json # JSON output
|
|
* node scripts/pr-risk-check.mjs --github # GitHub Actions summary output
|
|
*/
|
|
|
|
import { execSync } from "node:child_process";
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
import { resolve } from "node:path";
|
|
import { createInterface } from "node:readline";
|
|
|
|
const __dirname = import.meta.dirname;
|
|
const REPO_ROOT = resolve(__dirname, "..");
|
|
const MAP_PATH = resolve(REPO_ROOT, "docs/dev/FILE-SYSTEM-MAP.md");
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Risk tier definitions
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const RISK_TIERS = {
|
|
critical: [
|
|
"State Machine",
|
|
"Agent Core",
|
|
"Auth/OAuth",
|
|
"Permissions",
|
|
"Auto Engine",
|
|
"MCP Server/Client",
|
|
"Native/Rust Tools",
|
|
],
|
|
high: [
|
|
"SF Workflow",
|
|
"Tool System",
|
|
"AI Providers",
|
|
"Extension Registry",
|
|
"Session Management",
|
|
"Extensions",
|
|
"Modes",
|
|
"Event System",
|
|
"Node.js Bindings",
|
|
"Compaction",
|
|
],
|
|
medium: [
|
|
"Web UI",
|
|
"Web Mode",
|
|
"TUI Components",
|
|
"CLI",
|
|
"Commands",
|
|
"Worktree",
|
|
"API Routes",
|
|
"Doctor/Diagnostics",
|
|
"LSP",
|
|
"Model System",
|
|
"Subagent",
|
|
"Browser Tools",
|
|
"Bg Shell",
|
|
"Async Jobs",
|
|
"TTSR",
|
|
],
|
|
low: [
|
|
"Build System",
|
|
"Skills",
|
|
"Integration Tests",
|
|
"Config",
|
|
"Migration",
|
|
"Onboarding",
|
|
"Memory Extension",
|
|
"VS Code Extension",
|
|
"Voice",
|
|
"CMux",
|
|
"Mac Tools",
|
|
"Universal Config",
|
|
"Remote Questions",
|
|
"Search the Web",
|
|
"Google Search",
|
|
"Context7",
|
|
"Slash Commands",
|
|
"File Search",
|
|
"Syntax Highlighting",
|
|
"Text Processing",
|
|
"Image Processing",
|
|
"AST",
|
|
"Loader/Bootstrap",
|
|
],
|
|
};
|
|
|
|
const TIER_ORDER = ["critical", "high", "medium", "low"];
|
|
|
|
const TIER_EMOJI = {
|
|
critical: "🔴",
|
|
high: "🟠",
|
|
medium: "🟡",
|
|
low: "🟢",
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Parse FILE-SYSTEM-MAP.md
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Returns a Map<normalizedPathPattern, string[]> of systems.
|
|
* Patterns ending in /* are treated as prefix matches.
|
|
*/
|
|
function parseMap(mapPath) {
|
|
if (!existsSync(mapPath)) {
|
|
throw new Error(`FILE-SYSTEM-MAP.md not found at ${mapPath}`);
|
|
}
|
|
|
|
const lines = readFileSync(mapPath, "utf8").split("\n");
|
|
const entries = [];
|
|
|
|
for (const line of lines) {
|
|
// Only process table rows with at least 3 pipe-separated columns
|
|
if (!line.startsWith("|")) continue;
|
|
const cols = line
|
|
.split("|")
|
|
.map((c) => c.trim())
|
|
.filter(Boolean);
|
|
if (cols.length < 2) continue;
|
|
// Skip header and separator rows
|
|
if (
|
|
cols[0].startsWith("-") ||
|
|
cols[0].toLowerCase() === "file" ||
|
|
cols[0].toLowerCase() === "file path" ||
|
|
cols[0].toLowerCase() === "skill directory" ||
|
|
cols[0].toLowerCase() === "file / directory" ||
|
|
cols[0].toLowerCase() === "system"
|
|
)
|
|
continue;
|
|
|
|
const rawPath = cols[0];
|
|
const rawSystems = cols[1] || "";
|
|
|
|
// Skip bold section headers like **SF Extension (Core Workflow Engine)**
|
|
if (rawPath.startsWith("**") || rawPath === "") continue;
|
|
|
|
// Clean up path — remove parenthetical notes like "(50+ files)"
|
|
const cleanPath = rawPath.replace(/\s*\(.*?\)/g, "").trim();
|
|
if (!cleanPath || cleanPath.startsWith("-")) continue;
|
|
|
|
// Parse systems — comma or pipe separated
|
|
const systems = rawSystems
|
|
.split(/[,|]/)
|
|
.map((s) => s.trim())
|
|
.filter(Boolean)
|
|
.filter((s) => !s.startsWith("-") && s !== "System Label(s)");
|
|
|
|
if (systems.length === 0) continue;
|
|
|
|
entries.push({ pattern: cleanPath, systems });
|
|
}
|
|
|
|
return entries;
|
|
}
|
|
|
|
/**
|
|
* Normalize a file path to a repo-relative path for matching.
|
|
*/
|
|
function normalizePath(filePath) {
|
|
return filePath.replace(/^\.\//, "").replace(/\\/g, "/");
|
|
}
|
|
|
|
/**
|
|
* Check if a changed file matches a map entry pattern.
|
|
* Supports:
|
|
* - Exact suffix match: src/cli.ts matches src/cli.ts
|
|
* - Glob prefix match: sf/auto/* matches sf/auto/anything.ts
|
|
* - Wildcard extension: *.tsx matches any .tsx
|
|
*/
|
|
function fileMatchesPattern(filePath, pattern) {
|
|
const file = normalizePath(filePath);
|
|
const pat = normalizePath(pattern);
|
|
|
|
// Glob prefix: ends with /* or /**
|
|
if (pat.endsWith("/*") || pat.endsWith("/**")) {
|
|
const prefix = pat.replace(/\/\*+$/, "/");
|
|
return file.includes(prefix);
|
|
}
|
|
|
|
// Wildcard extension: *.ext
|
|
if (pat.startsWith("*.")) {
|
|
return file.endsWith(pat.slice(1));
|
|
}
|
|
|
|
// Exact suffix match (map paths are relative, git paths may include root prefix)
|
|
return file === pat || file.endsWith("/" + pat) || pat.endsWith("/" + file);
|
|
}
|
|
|
|
/**
|
|
* Given a list of changed files and map entries, return matched systems.
|
|
*/
|
|
function classifyFiles(changedFiles, mapEntries) {
|
|
const systemsPerFile = new Map();
|
|
const unmatchedFiles = [];
|
|
|
|
for (const file of changedFiles) {
|
|
const matched = new Set();
|
|
for (const entry of mapEntries) {
|
|
if (fileMatchesPattern(file, entry.pattern)) {
|
|
entry.systems.forEach((s) => matched.add(s));
|
|
}
|
|
}
|
|
if (matched.size > 0) {
|
|
systemsPerFile.set(file, [...matched]);
|
|
} else {
|
|
unmatchedFiles.push(file);
|
|
}
|
|
}
|
|
|
|
return { systemsPerFile, unmatchedFiles };
|
|
}
|
|
|
|
/**
|
|
* Get the risk tier for a system label.
|
|
*/
|
|
function tierForSystem(system) {
|
|
for (const tier of TIER_ORDER) {
|
|
if (
|
|
RISK_TIERS[tier].some((s) => system.includes(s) || s.includes(system))
|
|
) {
|
|
return tier;
|
|
}
|
|
}
|
|
return "low";
|
|
}
|
|
|
|
/**
|
|
* Aggregate overall risk from a set of system labels.
|
|
*/
|
|
function overallRisk(allSystems) {
|
|
let worst = "low";
|
|
for (const system of allSystems) {
|
|
const tier = tierForSystem(system);
|
|
if (TIER_ORDER.indexOf(tier) < TIER_ORDER.indexOf(worst)) {
|
|
worst = tier;
|
|
}
|
|
}
|
|
return worst;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Collect changed files
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function getChangedFilesFromStdin() {
|
|
return new Promise((resolve) => {
|
|
const lines = [];
|
|
const rl = createInterface({ input: process.stdin, terminal: false });
|
|
rl.on("line", (line) => {
|
|
if (line.trim()) lines.push(line.trim());
|
|
});
|
|
rl.on("close", () => resolve(lines));
|
|
});
|
|
}
|
|
|
|
function getChangedFilesFromGit(base = "main") {
|
|
try {
|
|
const output = execSync(`git diff --name-only ${base}...HEAD`, {
|
|
cwd: REPO_ROOT,
|
|
encoding: "utf8",
|
|
});
|
|
return output.trim().split("\n").filter(Boolean);
|
|
} catch {
|
|
// Fallback: compare staged + unstaged changes
|
|
try {
|
|
const output = execSync("git diff --name-only HEAD", {
|
|
cwd: REPO_ROOT,
|
|
encoding: "utf8",
|
|
});
|
|
return output.trim().split("\n").filter(Boolean);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Render output
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function renderConsole(report) {
|
|
const { changedFiles, systemsPerFile, unmatchedFiles, systemRisks, risk } =
|
|
report;
|
|
|
|
console.log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
|
console.log(" sf PR Risk Report");
|
|
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
|
|
|
|
console.log(`Overall Risk: ${TIER_EMOJI[risk]} ${risk.toUpperCase()}`);
|
|
console.log(
|
|
`Files changed: ${changedFiles.length} | Systems affected: ${systemRisks.length}\n`,
|
|
);
|
|
|
|
if (systemRisks.length > 0) {
|
|
console.log("Affected Systems:");
|
|
for (const { system, tier } of systemRisks) {
|
|
console.log(` ${TIER_EMOJI[tier]} ${system}`);
|
|
}
|
|
console.log("");
|
|
}
|
|
|
|
if (systemsPerFile.size > 0) {
|
|
console.log("File Breakdown:");
|
|
for (const [file, systems] of systemsPerFile) {
|
|
const tier = overallRisk(systems);
|
|
console.log(` ${TIER_EMOJI[tier]} ${file}`);
|
|
console.log(` → ${systems.join(", ")}`);
|
|
}
|
|
console.log("");
|
|
}
|
|
|
|
if (unmatchedFiles.length > 0) {
|
|
console.log(`Unclassified files (${unmatchedFiles.length}):`);
|
|
unmatchedFiles.forEach((f) => console.log(` ⚪ ${f}`));
|
|
console.log("");
|
|
}
|
|
|
|
// Reviewer checklist
|
|
if (risk === "critical") {
|
|
console.log("⚠️ Reviewer checklist for CRITICAL changes:");
|
|
console.log(" • Test state persistence across session restart");
|
|
console.log(" • Verify auth token lifecycle (create, refresh, revoke)");
|
|
console.log(" • Check for race conditions in agent loop");
|
|
console.log(" • Ensure no breaking changes to RPC protocol");
|
|
} else if (risk === "high") {
|
|
console.log("⚠️ Reviewer checklist for HIGH-risk changes:");
|
|
console.log(" • Run full integration test suite");
|
|
console.log(" • Verify tool call/response contracts unchanged");
|
|
console.log(" • Check extension event dispatch still works");
|
|
}
|
|
|
|
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
|
|
}
|
|
|
|
function renderGitHubSummary(report) {
|
|
const { changedFiles, systemsPerFile, unmatchedFiles, systemRisks, risk } =
|
|
report;
|
|
|
|
const lines = [];
|
|
lines.push(`## ${TIER_EMOJI[risk]} PR Risk Report — ${risk.toUpperCase()}`);
|
|
lines.push("");
|
|
lines.push(`| | |`);
|
|
lines.push(`|---|---|`);
|
|
lines.push(`| **Files changed** | ${changedFiles.length} |`);
|
|
lines.push(`| **Systems affected** | ${systemRisks.length} |`);
|
|
lines.push(
|
|
`| **Overall risk** | ${TIER_EMOJI[risk]} ${risk.toUpperCase()} |`,
|
|
);
|
|
lines.push("");
|
|
|
|
if (systemRisks.length > 0) {
|
|
lines.push("### Affected Systems");
|
|
lines.push("");
|
|
lines.push("| Risk | System |");
|
|
lines.push("|------|--------|");
|
|
for (const { system, tier } of systemRisks) {
|
|
lines.push(`| ${TIER_EMOJI[tier]} ${tier} | ${system} |`);
|
|
}
|
|
lines.push("");
|
|
}
|
|
|
|
if (systemsPerFile.size > 0) {
|
|
lines.push("<details>");
|
|
lines.push("<summary>File Breakdown</summary>");
|
|
lines.push("");
|
|
lines.push("| Risk | File | Systems |");
|
|
lines.push("|------|------|---------|");
|
|
for (const [file, systems] of systemsPerFile) {
|
|
const tier = overallRisk(systems);
|
|
lines.push(
|
|
`| ${TIER_EMOJI[tier]} | \`${file}\` | ${systems.join(", ")} |`,
|
|
);
|
|
}
|
|
if (unmatchedFiles.length > 0) {
|
|
for (const file of unmatchedFiles) {
|
|
lines.push(`| ⚪ | \`${file}\` | *(unclassified)* |`);
|
|
}
|
|
}
|
|
lines.push("");
|
|
lines.push("</details>");
|
|
lines.push("");
|
|
}
|
|
|
|
if (risk === "critical") {
|
|
lines.push(
|
|
"> ⚠️ **Critical risk** — please verify: state persistence, auth token lifecycle, agent loop race conditions, RPC protocol compatibility.",
|
|
);
|
|
} else if (risk === "high") {
|
|
lines.push(
|
|
"> ⚠️ **High risk** — please run full integration tests and verify tool/extension contracts.",
|
|
);
|
|
}
|
|
|
|
return lines.join("\n");
|
|
}
|
|
|
|
function buildReport({ changedFiles, systemsPerFile, unmatchedFiles }) {
|
|
// Aggregate all systems
|
|
const allSystems = new Set();
|
|
for (const systems of systemsPerFile.values()) {
|
|
systems.forEach((s) => allSystems.add(s));
|
|
}
|
|
|
|
// Build system → tier list, sorted by risk
|
|
const systemRisks = [...allSystems]
|
|
.map((system) => ({ system, tier: tierForSystem(system) }))
|
|
.sort((a, b) => TIER_ORDER.indexOf(a.tier) - TIER_ORDER.indexOf(b.tier));
|
|
|
|
const risk = overallRisk(allSystems);
|
|
|
|
return { changedFiles, systemsPerFile, unmatchedFiles, systemRisks, risk };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function main() {
|
|
const args = process.argv.slice(2);
|
|
const isJson = args.includes("--json");
|
|
const isGitHub = args.includes("--github");
|
|
|
|
// Collect changed files
|
|
let changedFiles;
|
|
|
|
const filesIdx = args.indexOf("--files");
|
|
if (filesIdx !== -1 && args[filesIdx + 1]) {
|
|
changedFiles = args[filesIdx + 1]
|
|
.split(",")
|
|
.map((f) => f.trim())
|
|
.filter(Boolean);
|
|
} else if (!process.stdin.isTTY) {
|
|
changedFiles = await getChangedFilesFromStdin();
|
|
} else {
|
|
const baseIdx = args.indexOf("--base");
|
|
const base =
|
|
baseIdx !== -1 && args[baseIdx + 1] ? args[baseIdx + 1] : "main";
|
|
changedFiles = getChangedFilesFromGit(base);
|
|
}
|
|
|
|
if (changedFiles.length === 0) {
|
|
console.log("No changed files detected.");
|
|
process.exit(0);
|
|
}
|
|
|
|
// Load and parse map
|
|
const mapEntries = parseMap(MAP_PATH);
|
|
|
|
// Classify
|
|
const { systemsPerFile, unmatchedFiles } = classifyFiles(
|
|
changedFiles,
|
|
mapEntries,
|
|
);
|
|
const report = buildReport({ changedFiles, systemsPerFile, unmatchedFiles });
|
|
|
|
// Output
|
|
if (isJson) {
|
|
console.log(
|
|
JSON.stringify(
|
|
{
|
|
risk: report.risk,
|
|
filesChanged: report.changedFiles.length,
|
|
systemsAffected: report.systemRisks,
|
|
fileBreakdown: Object.fromEntries(report.systemsPerFile),
|
|
unclassified: report.unmatchedFiles,
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
} else if (isGitHub) {
|
|
const summary = renderGitHubSummary(report);
|
|
// Write to GitHub step summary if available
|
|
if (process.env.GITHUB_STEP_SUMMARY) {
|
|
const { appendFileSync } = await import("node:fs");
|
|
appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary + "\n");
|
|
}
|
|
// Also output the summary markdown for use in PR comments
|
|
console.log(summary);
|
|
} else {
|
|
renderConsole(report);
|
|
}
|
|
|
|
// Exit with non-zero for critical so CI can gate on it if desired
|
|
if (report.risk === "critical") {
|
|
process.exitCode = 2;
|
|
} else if (report.risk === "high") {
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error("pr-risk-check error:", err.message);
|
|
process.exit(1);
|
|
});
|