From 7db04b2a1a8962063997df3d95d0623bf3da9372 Mon Sep 17 00:00:00 2001 From: "Dina Berry (She/her)" Date: Fri, 3 Apr 2026 10:56:19 -0700 Subject: [PATCH] feat(ci): add PR architectural impact analysis (#733) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scripts/analyze-impact.mjs — main analysis engine - scripts/impact-utils/ — diff parser, risk scorer, report generator - .github/workflows/squad-impact.yml — triggers on PRs to dev Phase 1 MVP: file-count risk tiers + module mapping. Phase 2 (future): routing.md ownership + reviewer auto-assignment. Closes #733 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/squad-impact.yml | 84 +++++++++++++++ scripts/analyze-impact.mjs | 126 ++++++++++++++++++++++ scripts/impact-utils/parse-diff.mjs | 53 +++++++++ scripts/impact-utils/report-generator.mjs | 87 +++++++++++++++ scripts/impact-utils/risk-scorer.mjs | 68 ++++++++++++ test/scripts/parse-diff.test.ts | 99 +++++++++++++++++ test/scripts/risk-scorer.test.ts | 113 +++++++++++++++++++ 7 files changed, 630 insertions(+) create mode 100644 .github/workflows/squad-impact.yml create mode 100644 scripts/analyze-impact.mjs create mode 100644 scripts/impact-utils/parse-diff.mjs create mode 100644 scripts/impact-utils/report-generator.mjs create mode 100644 scripts/impact-utils/risk-scorer.mjs create mode 100644 test/scripts/parse-diff.test.ts create mode 100644 test/scripts/risk-scorer.test.ts diff --git a/.github/workflows/squad-impact.yml b/.github/workflows/squad-impact.yml new file mode 100644 index 000000000..75811695c --- /dev/null +++ b/.github/workflows/squad-impact.yml @@ -0,0 +1,84 @@ +name: Squad Impact Analysis + +on: + pull_request_target: + branches: [dev] + types: [opened, synchronize, reopened] + +# Security: Using pull_request_target so we get a write token for fork PRs. +# Scripts are checked out from the BASE branch (trusted), not the PR head. +# PR data is fetched read-only via gh CLI — no untrusted code is executed. +permissions: + contents: read + pull-requests: read + issues: write + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + impact: + runs-on: ubuntu-latest + timeout-minutes: 5 + if: github.actor != 'dependabot[bot]' + steps: + - name: Checkout scripts (base branch only) + uses: actions/checkout@v4 + with: + sparse-checkout: | + scripts/analyze-impact.mjs + scripts/impact-utils/parse-diff.mjs + scripts/impact-utils/risk-scorer.mjs + scripts/impact-utils/report-generator.mjs + sparse-checkout-cone-mode: false + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Run impact analysis + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + run: node scripts/analyze-impact.mjs ${{ github.event.pull_request.number }} + + - name: Post impact report + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const marker = ''; + const report = fs.readFileSync('impact-report.md', 'utf8'); + const body = `${marker}\n${report}`; + const prNumber = ${{ github.event.pull_request.number }}; + + // Upsert: find existing comment by marker, update or create. + // paginate() follows Link headers so we never miss an existing marker. + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + per_page: 100, + }); + + const existing = comments.find(c => c.body && c.body.includes(marker)); + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + core.info('Updated existing impact report comment'); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body, + }); + core.info('Created new impact report comment'); + } diff --git a/scripts/analyze-impact.mjs b/scripts/analyze-impact.mjs new file mode 100644 index 000000000..3693829bf --- /dev/null +++ b/scripts/analyze-impact.mjs @@ -0,0 +1,126 @@ +#!/usr/bin/env node +/** + * PR Architectural Impact Analysis + * + * Usage: node scripts/analyze-impact.mjs + * + * Uses the gh CLI to fetch PR data, then: + * 1. Maps changed files → modules + * 2. Calculates a risk tier (LOW / MEDIUM / HIGH / CRITICAL) + * 3. Writes impact-report.md to cwd + * 4. Outputs JSON summary to stdout + * + * Uses only Node.js built-ins (no npm dependencies). + * Issue: #733 + */ + +import { execSync } from 'node:child_process'; +import { writeFileSync } from 'node:fs'; +import { parseDiffNames, enrichFileStatuses } from './impact-utils/parse-diff.mjs'; +import { calculateRisk } from './impact-utils/risk-scorer.mjs'; +import { generateReport } from './impact-utils/report-generator.mjs'; + +// ── Module mapping ──────────────────────────────────────────────────────── +// Directory prefix → module name (first match wins). +const MODULE_MAP = [ + ['packages/squad-sdk/', 'squad-sdk'], + ['packages/squad-cli/', 'squad-cli'], + ['.squad-templates/', 'templates'], + ['.github/', 'ci-workflows'], + ['scripts/', 'scripts'], + ['.copilot/', 'copilot-config'], + ['.squad/', 'squad-state'], + ['test/', 'tests'], + ['docs/', 'docs'], +]; + +// Patterns that flag a file as "critical" (config or entry points). +const CRITICAL_PATTERNS = [/package\.json$/, /tsconfig\.json$/, /index\.ts$/]; + +function mapFileToModule(filePath) { + for (const [prefix, mod] of MODULE_MAP) { + if (filePath.startsWith(prefix)) return mod; + } + return 'root'; +} + +function isCriticalFile(filePath) { + return CRITICAL_PATTERNS.some((p) => p.test(filePath)); +} + +// ── Main ────────────────────────────────────────────────────────────────── + +const prNumberRaw = process.argv[2]; +const prNumber = parseInt(prNumberRaw, 10); +if (!Number.isInteger(prNumber) || prNumber <= 0) { + console.error('Usage: node scripts/analyze-impact.mjs (must be a positive integer)'); + process.exit(1); +} + +// Resolve repo slug (works in CI via env var, locally via gh). +const repoSlug = + process.env.GITHUB_REPOSITORY || + execSync('gh repo view --json nameWithOwner -q .nameWithOwner', { + encoding: 'utf8', + }).trim(); + +// 1. Get changed files with statuses +let files; +try { + // --paginate can emit multiple JSON arrays; use --jq '.[]' to emit one + // JSON object per line, then parse each line individually. + const apiOutput = execSync( + `gh api repos/${repoSlug}/pulls/${prNumber}/files --paginate --jq '.[]'`, + { encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 }, + ); + const apiFiles = apiOutput + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => JSON.parse(line)); + files = enrichFileStatuses(apiFiles); +} catch { + // Fallback: name-only diff (no added/deleted distinction) + console.error('⚠ API file listing unavailable, falling back to gh pr diff --name-only'); + const diffOutput = execSync(`gh pr diff ${prNumber} --name-only`, { + encoding: 'utf8', + }); + files = parseDiffNames(diffOutput); +} + +// 2. Map files → modules +const modules = {}; +for (const filePath of files.all) { + const mod = mapFileToModule(filePath); + if (!modules[mod]) modules[mod] = []; + modules[mod].push(filePath); +} + +// 3. Identify critical files +const criticalFiles = files.all.filter((f) => isCriticalFile(f)); + +// 4. Calculate risk tier +const risk = calculateRisk({ + filesChanged: files.all.length, + filesDeleted: files.deleted.length, + modulesTouched: Object.keys(modules).length, + criticalFiles, +}); + +// 5. Generate markdown report and write to cwd +const report = generateReport({ prNumber, risk, modules, files, criticalFiles }); +writeFileSync('impact-report.md', report, 'utf8'); + +// 6. Output JSON summary to stdout +const result = { + prNumber: Number(prNumber), + risk, + modules: Object.fromEntries(Object.entries(modules).map(([k, v]) => [k, v.length])), + filesChanged: files.all.length, + filesAdded: files.added.length, + filesModified: files.modified.length, + filesDeleted: files.deleted.length, + criticalFiles, +}; + +console.log(JSON.stringify(result, null, 2)); diff --git a/scripts/impact-utils/parse-diff.mjs b/scripts/impact-utils/parse-diff.mjs new file mode 100644 index 000000000..8a36f4312 --- /dev/null +++ b/scripts/impact-utils/parse-diff.mjs @@ -0,0 +1,53 @@ +/** + * Parse PR diff output into structured file data. + * Uses only Node.js built-ins. + * + * Issue: #733 + */ + +/** + * Parse `gh pr diff --name-only` output into structured data. + * @param {string} diffOutput — raw output from `gh pr diff --name-only` + * @returns {{ added: string[], modified: string[], deleted: string[], all: string[] }} + */ +export function parseDiffNames(diffOutput) { + const all = diffOutput + .trim() + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + + // Name-only output has no status info; classify all as modified. + // Caller should use enrichFileStatuses() when API data is available. + return { added: [], modified: [...all], deleted: [], all }; +} + +/** + * Build structured file data from the GitHub Pulls files API response. + * Each entry has {filename, status} where status is added|removed|modified|renamed|copied|changed. + * @param {Array<{filename: string, status: string}>} apiFiles + * @returns {{ added: string[], modified: string[], deleted: string[], all: string[] }} + */ +export function enrichFileStatuses(apiFiles) { + const added = []; + const modified = []; + const deleted = []; + const all = []; + + for (const f of apiFiles) { + all.push(f.filename); + switch (f.status) { + case 'added': + added.push(f.filename); + break; + case 'removed': + deleted.push(f.filename); + break; + default: // modified, renamed, copied, changed + modified.push(f.filename); + break; + } + } + + return { added, modified, deleted, all }; +} diff --git a/scripts/impact-utils/report-generator.mjs b/scripts/impact-utils/report-generator.mjs new file mode 100644 index 000000000..35ecfa87a --- /dev/null +++ b/scripts/impact-utils/report-generator.mjs @@ -0,0 +1,87 @@ +/** + * Generate markdown impact report from analysis results. + * Uses only Node.js built-ins. + * + * Issue: #733 + */ + +const TIER_EMOJI = { + LOW: '🟢', + MEDIUM: '🟡', + HIGH: '🟠', + CRITICAL: '🔴', +}; + +/** + * Generate a markdown impact report. + * + * @param {{ prNumber: number|string, risk: {tier: string, factors: string[]}, modules: Record, files: {added: string[], modified: string[], deleted: string[], all: string[]}, criticalFiles: string[] }} params + * @returns {string} Markdown report body + */ +export function generateReport({ prNumber, risk, modules, files, criticalFiles }) { + const emoji = TIER_EMOJI[risk.tier] || '⚪'; + const lines = []; + + lines.push(`## ${emoji} Impact Analysis — PR #${prNumber}`); + lines.push(''); + lines.push(`**Risk tier:** ${emoji} **${risk.tier}**`); + lines.push(''); + + // Summary table + lines.push('### 📊 Summary'); + lines.push(''); + lines.push('| Metric | Count |'); + lines.push('|--------|-------|'); + lines.push(`| Files changed | ${files.all.length} |`); + lines.push(`| Files added | ${files.added.length} |`); + lines.push(`| Files modified | ${files.modified.length} |`); + lines.push(`| Files deleted | ${files.deleted.length} |`); + lines.push(`| Modules touched | ${Object.keys(modules).length} |`); + if (criticalFiles.length > 0) { + lines.push(`| Critical files | ${criticalFiles.length} |`); + } + lines.push(''); + + // Risk factors + lines.push('### 🎯 Risk Factors'); + lines.push(''); + for (const factor of risk.factors) { + lines.push(`- ${factor}`); + } + lines.push(''); + + // Module breakdown + lines.push('### 📦 Modules Affected'); + lines.push(''); + const moduleNames = Object.keys(modules).sort(); + for (const mod of moduleNames) { + const modFiles = modules[mod]; + lines.push( + `
${mod} (${modFiles.length} file${modFiles.length === 1 ? '' : 's'})`, + ); + lines.push(''); + for (const f of modFiles) { + lines.push(`- \`${f}\``); + } + lines.push(''); + lines.push('
'); + lines.push(''); + } + + // Critical files + if (criticalFiles.length > 0) { + lines.push('### ⚠️ Critical Files'); + lines.push(''); + for (const f of criticalFiles) { + lines.push(`- \`${f}\``); + } + lines.push(''); + } + + lines.push('---'); + lines.push( + '*This report is generated automatically for every PR. See [#733](https://github.com/bradygaster/squad/issues/733) for details.*', + ); + + return lines.join('\n'); +} diff --git a/scripts/impact-utils/risk-scorer.mjs b/scripts/impact-utils/risk-scorer.mjs new file mode 100644 index 000000000..4c1e07ce4 --- /dev/null +++ b/scripts/impact-utils/risk-scorer.mjs @@ -0,0 +1,68 @@ +/** + * Calculate risk tier from file counts and module data. + * Uses only Node.js built-ins. + * + * Issue: #733 + */ + +const TIER_ORDER = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL']; + +function maxTier(a, b) { + return TIER_ORDER.indexOf(a) >= TIER_ORDER.indexOf(b) ? a : b; +} + +/** + * Calculate risk tier based on PR change metrics. + * Takes the highest tier from all individual factors. + * + * @param {{ filesChanged: number, filesDeleted: number, modulesTouched: number, criticalFiles: string[] }} params + * @returns {{ tier: 'LOW'|'MEDIUM'|'HIGH'|'CRITICAL', factors: string[] }} + */ +export function calculateRisk({ filesChanged, filesDeleted, modulesTouched, criticalFiles }) { + const factors = []; + let tier = 'LOW'; + + // Files changed: ≤5=LOW, 6-20=MEDIUM, 21-50=HIGH, >50=CRITICAL + if (filesChanged > 50) { + tier = maxTier(tier, 'CRITICAL'); + factors.push(`${filesChanged} files changed (>50 → CRITICAL)`); + } else if (filesChanged > 20) { + tier = maxTier(tier, 'HIGH'); + factors.push(`${filesChanged} files changed (21-50 → HIGH)`); + } else if (filesChanged > 5) { + tier = maxTier(tier, 'MEDIUM'); + factors.push(`${filesChanged} files changed (6-20 → MEDIUM)`); + } else { + factors.push(`${filesChanged} files changed (≤5 → LOW)`); + } + + // Modules touched: ≤1=LOW, 2-4=MEDIUM, 5-8=HIGH, >8=CRITICAL + if (modulesTouched > 8) { + tier = maxTier(tier, 'CRITICAL'); + factors.push(`${modulesTouched} modules touched (>8 → CRITICAL)`); + } else if (modulesTouched >= 5) { + tier = maxTier(tier, 'HIGH'); + factors.push(`${modulesTouched} modules touched (5-8 → HIGH)`); + } else if (modulesTouched >= 2) { + tier = maxTier(tier, 'MEDIUM'); + factors.push(`${modulesTouched} modules touched (2-4 → MEDIUM)`); + } else { + factors.push(`${modulesTouched} module(s) touched (≤1 → LOW)`); + } + + // Files deleted: >10=CRITICAL + if (filesDeleted > 10) { + tier = maxTier(tier, 'CRITICAL'); + factors.push(`${filesDeleted} files deleted (>10 → CRITICAL)`); + } else if (filesDeleted > 0) { + factors.push(`${filesDeleted} file(s) deleted`); + } + + // Critical files (package.json, tsconfig.json, index.ts entry points) + if (criticalFiles.length > 0) { + tier = maxTier(tier, 'MEDIUM'); + factors.push(`Critical files touched: ${criticalFiles.join(', ')}`); + } + + return { tier, factors }; +} diff --git a/test/scripts/parse-diff.test.ts b/test/scripts/parse-diff.test.ts new file mode 100644 index 000000000..a66c74028 --- /dev/null +++ b/test/scripts/parse-diff.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect } from 'vitest'; +import { parseDiffNames, enrichFileStatuses } from '../../scripts/impact-utils/parse-diff.mjs'; + +// ── parseDiffNames ────────────────────────────────────────────────────── + +describe('parseDiffNames', () => { + it('parses a normal multi-line diff output', () => { + const result = parseDiffNames('src/a.ts\nsrc/b.ts\n'); + expect(result.all).toEqual(['src/a.ts', 'src/b.ts']); + expect(result.modified).toEqual(['src/a.ts', 'src/b.ts']); + expect(result.added).toEqual([]); + expect(result.deleted).toEqual([]); + }); + + it('returns empty arrays for empty string input', () => { + const result = parseDiffNames(''); + expect(result.all).toEqual([]); + expect(result.modified).toEqual([]); + }); + + it('returns empty arrays for whitespace-only input', () => { + const result = parseDiffNames(' \n \n '); + expect(result.all).toEqual([]); + }); + + it('handles trailing newlines without creating empty entries', () => { + const result = parseDiffNames('file.ts\n\n\n'); + expect(result.all).toEqual(['file.ts']); + }); + + it('trims leading/trailing whitespace from filenames', () => { + const result = parseDiffNames(' src/a.ts \n src/b.ts \n'); + expect(result.all).toEqual(['src/a.ts', 'src/b.ts']); + }); + + it('handles a single file with no trailing newline', () => { + const result = parseDiffNames('only-file.ts'); + expect(result.all).toEqual(['only-file.ts']); + }); +}); + +// ── enrichFileStatuses ────────────────────────────────────────────────── + +describe('enrichFileStatuses', () => { + it('classifies added files', () => { + const result = enrichFileStatuses([{ filename: 'new.ts', status: 'added' }]); + expect(result.added).toEqual(['new.ts']); + expect(result.modified).toEqual([]); + expect(result.deleted).toEqual([]); + expect(result.all).toEqual(['new.ts']); + }); + + it('classifies removed files', () => { + const result = enrichFileStatuses([{ filename: 'old.ts', status: 'removed' }]); + expect(result.deleted).toEqual(['old.ts']); + expect(result.added).toEqual([]); + }); + + it('classifies modified files', () => { + const result = enrichFileStatuses([{ filename: 'mod.ts', status: 'modified' }]); + expect(result.modified).toEqual(['mod.ts']); + }); + + it('classifies renamed files as modified', () => { + const result = enrichFileStatuses([{ filename: 'renamed.ts', status: 'renamed' }]); + expect(result.modified).toEqual(['renamed.ts']); + }); + + it('classifies copied files as modified', () => { + const result = enrichFileStatuses([{ filename: 'copy.ts', status: 'copied' }]); + expect(result.modified).toEqual(['copy.ts']); + }); + + it('classifies changed files as modified', () => { + const result = enrichFileStatuses([{ filename: 'chg.ts', status: 'changed' }]); + expect(result.modified).toEqual(['chg.ts']); + }); + + it('handles empty array input', () => { + const result = enrichFileStatuses([]); + expect(result.all).toEqual([]); + expect(result.added).toEqual([]); + expect(result.modified).toEqual([]); + expect(result.deleted).toEqual([]); + }); + + it('handles a mix of all statuses', () => { + const result = enrichFileStatuses([ + { filename: 'a.ts', status: 'added' }, + { filename: 'b.ts', status: 'removed' }, + { filename: 'c.ts', status: 'modified' }, + { filename: 'd.ts', status: 'renamed' }, + ]); + expect(result.added).toEqual(['a.ts']); + expect(result.deleted).toEqual(['b.ts']); + expect(result.modified).toEqual(['c.ts', 'd.ts']); + expect(result.all).toEqual(['a.ts', 'b.ts', 'c.ts', 'd.ts']); + }); +}); diff --git a/test/scripts/risk-scorer.test.ts b/test/scripts/risk-scorer.test.ts new file mode 100644 index 000000000..957a6c6c5 --- /dev/null +++ b/test/scripts/risk-scorer.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect } from 'vitest'; +import { calculateRisk } from '../../scripts/impact-utils/risk-scorer.mjs'; + +const defaults = { filesChanged: 1, filesDeleted: 0, modulesTouched: 1, criticalFiles: [] }; + +describe('calculateRisk', () => { + // ── Files-changed thresholds ────────────────────────────────────────── + + it('returns LOW when filesChanged ≤ 5', () => { + const { tier } = calculateRisk({ ...defaults, filesChanged: 5 }); + expect(tier).toBe('LOW'); + }); + + it('returns MEDIUM when filesChanged is 6 (boundary)', () => { + const { tier } = calculateRisk({ ...defaults, filesChanged: 6 }); + expect(tier).toBe('MEDIUM'); + }); + + it('returns MEDIUM when filesChanged is 20 (upper boundary)', () => { + const { tier } = calculateRisk({ ...defaults, filesChanged: 20 }); + expect(tier).toBe('MEDIUM'); + }); + + it('returns HIGH when filesChanged is 21 (boundary)', () => { + const { tier } = calculateRisk({ ...defaults, filesChanged: 21 }); + expect(tier).toBe('HIGH'); + }); + + it('returns HIGH when filesChanged is 50', () => { + const { tier } = calculateRisk({ ...defaults, filesChanged: 50 }); + expect(tier).toBe('HIGH'); + }); + + it('returns CRITICAL when filesChanged is 51 (boundary)', () => { + const { tier } = calculateRisk({ ...defaults, filesChanged: 51 }); + expect(tier).toBe('CRITICAL'); + }); + + // ── Modules-touched thresholds ──────────────────────────────────────── + + it('returns LOW when modulesTouched ≤ 1', () => { + const { tier } = calculateRisk({ ...defaults, modulesTouched: 1 }); + expect(tier).toBe('LOW'); + }); + + it('returns MEDIUM when modulesTouched is 2 (boundary)', () => { + const { tier } = calculateRisk({ ...defaults, modulesTouched: 2 }); + expect(tier).toBe('MEDIUM'); + }); + + it('returns HIGH when modulesTouched is 5 (boundary)', () => { + const { tier } = calculateRisk({ ...defaults, modulesTouched: 5 }); + expect(tier).toBe('HIGH'); + }); + + it('returns HIGH when modulesTouched is 8 (upper boundary)', () => { + const { tier } = calculateRisk({ ...defaults, modulesTouched: 8 }); + expect(tier).toBe('HIGH'); + }); + + it('returns CRITICAL when modulesTouched is 9 (boundary)', () => { + const { tier } = calculateRisk({ ...defaults, modulesTouched: 9 }); + expect(tier).toBe('CRITICAL'); + }); + + // ── Deletions threshold ─────────────────────────────────────────────── + + it('returns CRITICAL when filesDeleted > 10', () => { + const { tier } = calculateRisk({ ...defaults, filesDeleted: 11 }); + expect(tier).toBe('CRITICAL'); + }); + + it('stays LOW when filesDeleted is 10 (boundary, ≤ 10)', () => { + const { tier } = calculateRisk({ ...defaults, filesDeleted: 10 }); + expect(tier).toBe('LOW'); + }); + + // ── Critical files ──────────────────────────────────────────────────── + + it('bumps to at least MEDIUM when criticalFiles are present', () => { + const { tier, factors } = calculateRisk({ + ...defaults, + criticalFiles: ['package.json'], + }); + expect(tier).toBe('MEDIUM'); + expect(factors.some((f) => f.includes('Critical files touched'))).toBe(true); + }); + + // ── Factors strings ─────────────────────────────────────────────────── + + it('includes a factor string for every evaluated dimension', () => { + const { factors } = calculateRisk({ ...defaults }); + // At minimum: files changed + modules touched + expect(factors.length).toBeGreaterThanOrEqual(2); + }); + + it('includes deletion factor when filesDeleted > 0', () => { + const { factors } = calculateRisk({ ...defaults, filesDeleted: 3 }); + expect(factors.some((f) => f.includes('deleted'))).toBe(true); + }); + + // ── Highest tier wins ───────────────────────────────────────────────── + + it('returns the highest tier across all dimensions', () => { + const { tier } = calculateRisk({ + filesChanged: 51, // CRITICAL + filesDeleted: 0, + modulesTouched: 1, // LOW + criticalFiles: [], + }); + expect(tier).toBe('CRITICAL'); + }); +});