singularity-forge/scripts/pr-risk-check.mjs
ace-pm 9d739dfa5d Rename GSD→SF: complete rebrand from fork origin
- All gsdDir/gsdRoot/gsdHome → sfDir/sfRootDir/sfHome
- GSDWorkspace* → SFWorkspace* interfaces
- bootstrapGsdProject → bootstrapProject
- runGSDDoctor → runSFDoctor
- GsdClient → SfClient, gsd-client.ts → sf-client.ts
- .gsd/ → .sf/ in all tests, docs, docker, native, vscode
- Auto-migration: headless detects .gsd/ → renames to .sf/
- Deleted gsd-phase-state.ts backward-compat re-export
- Renamed bin/gsd-from-source → bin/sf-from-source
- Updated mintlify docs, github workflows, docker configs
2026-04-15 18:33:47 +02:00

426 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 { readFileSync, existsSync } from 'fs';
import { execSync } from 'child_process';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
import { createInterface } from 'readline';
const __dirname = dirname(fileURLToPath(import.meta.url));
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', 'Studio App', '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('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);
});