impact-scope
v1.0.0
Published
See the blast radius of your code changes. Import graph analysis + risk scoring for PRs.
Maintainers
Readme
💥 impact-scope
See the blast radius of your code changes
Import graph analysis and risk scoring for code reviews and PRs.
Import Graph + Risk Scoring + CI Gates
Why This Exists
Code review tools show you what changed, but not what those changes will break. impact-scope scans your TypeScript/JavaScript project, builds an import dependency graph, and shows exactly which files are transitively affected when you touch a file. It assigns a risk score from 0 to 100 based on impact depth, affected file count, test coverage gaps, and change size -- so reviewers can prioritize attention on the changes that actually matter.
Requirements
- Node.js >= 18.0.0
- Git installed and available in PATH
- A TypeScript or JavaScript project with relative imports
Quick Start
As a CLI tool
# Install globally
npm install -g impact-scope
# Analyze the impact of your latest commit
impact-scope analyze
# Analyze changes against a specific base ref
impact-scope analyze --base main
# Check impact of changing a specific file
impact-scope check src/utils/math.ts
# View import graph statistics
impact-scope graphAs a library
npm install impact-scopeimport {
parseDiff,
buildImportGraph,
analyzeImpact,
buildRiskReport,
formatTerminal,
} from 'impact-scope';
import { execSync } from 'child_process';
// Get diff from git
const diffOutput = execSync('git diff HEAD~1', { encoding: 'utf-8' });
const graph = buildImportGraph('.');
const changedFiles = parseDiff(diffOutput);
const affected = analyzeImpact(changedFiles, graph, '.');
const report = buildRiskReport(changedFiles, affected);
console.log(formatTerminal(report));CLI Commands
impact-scope analyze
Analyze the impact of code changes from a git diff.
| Option | Default | Description |
|--------|---------|-------------|
| --base <ref> | HEAD~1 | Base git ref for diff |
| --threshold <n> | 50 | Risk score threshold for CI mode |
| --format <type> | terminal | Output format: terminal, json, ci |
| --root <path> | . | Project root directory |
impact-scope graph
Show import graph statistics (file count, edge count, most-imported files).
| Option | Default | Description |
|--------|---------|-------------|
| --root <path> | . | Project root directory |
impact-scope check <file>
Check the blast radius of changing a specific file without needing a diff.
| Option | Default | Description |
|--------|---------|-------------|
| --format <type> | terminal | Output format: terminal, json |
| --root <path> | . | Project root directory |
Example Output
Terminal format
============================================================
IMPACT SCOPE ANALYSIS
============================================================
Risk Score: 42/100 (MEDIUM)
[#########################---------------]
Changed files: 1
Affected files: 3
Untested: 1
Changed Files:
+2 / -1 src/utils/math.ts
Affected Files (by depth):
depth 1: src/components/calculator.ts (add, multiply) [tested]
depth 1: src/utils/index.ts (*) [tested]
depth 2: src/app.ts [UNTESTED]
Untested Affected Files:
! src/app.ts
============================================================JSON format
{
"score": 42,
"level": "medium",
"changedFiles": [
{ "path": "src/utils/math.ts", "additions": 2, "deletions": 1, "hunks": [{ "startLine": 1, "endLine": 6 }] }
],
"affectedFiles": [
{ "filePath": "src/components/calculator.ts", "depth": 1, "affectedSymbols": ["add", "multiply"], "hasTests": true },
{ "filePath": "src/utils/index.ts", "depth": 1, "affectedSymbols": ["*"], "hasTests": true },
{ "filePath": "src/app.ts", "depth": 2, "affectedSymbols": [], "hasTests": false }
],
"untestedAffected": ["src/app.ts"],
"summary": "Risk Score: 42/100 (MEDIUM) | 1 changed file(s), 3 affected file(s), 1 untested, 3 line(s) changed",
"details": [
"--- Changed Files ---",
" src/utils/math.ts (+2/-1)",
"--- Affected Files ---",
" depth=1 src/components/calculator.ts [tested]",
" depth=1 src/utils/index.ts [tested]",
" depth=2 src/app.ts [UNTESTED]",
"--- Untested Affected Files ---",
" ! src/app.ts"
]
}CI format
impact-scope: PASS
score: 42/100 (medium)
threshold: 50
changed: 1 files
affected: 3 files
untested: 1 filesHow It Works
git diff --> parseDiff --> changedFiles
|
project root --> buildImportGraph --> importGraph
|
changedFiles + importGraph --> analyzeImpact --> affectedFiles
|
changedFiles + affectedFiles --> buildRiskReport --> RiskReport
|
formatTerminal / formatJSON / formatCIRisk Scoring
The risk score (0-100) is computed from four weighted factors:
| Factor | Weight | Description | |--------|--------|-------------| | Affected file count | 30% | Number of transitively affected files (caps at 20) | | Max depth | 20% | Deepest level in the impact chain (caps at 5) | | Untested ratio | 30% | Fraction of affected files lacking test coverage | | Change size | 20% | Total lines added + deleted (caps at 500) |
Risk levels: low (0-25), medium (26-50), high (51-75), critical (76-100).
How Risk Scoring Works
The risk score quantifies how dangerous a set of code changes is to your project. Here is exactly how each factor is computed:
1. Affected File Count (30% weight)
impact-scope walks the reverse import graph using BFS. Starting from each changed file, it finds every file that transitively depends on it. The raw count is divided by a cap of 20 files. If a utility module is imported by 15 files, changing it yields 15/20 = 0.75 for this factor.
2. Max Depth (20% weight)
Depth measures how far the impact ripples through the dependency chain. A change affecting only direct importers has depth 1. If those importers are themselves imported, depth grows. The cap is 5 levels. Depth 3 yields 3/5 = 0.6.
3. Untested Ratio (30% weight)
For each affected file, impact-scope checks whether a corresponding test file exists (e.g., src/foo.ts -> tests/foo.test.ts) or whether any test file in the import graph imports it. The ratio of untested affected files drives this factor. If 4 of 10 affected files lack tests: 4/10 = 0.4.
4. Change Size (20% weight)
Total lines added + deleted across all changed files, capped at 500. A 60-line change yields 60/500 = 0.12.
Final score: (factor1 * 0.3 + factor2 * 0.2 + factor3 * 0.3 + factor4 * 0.2) * 100, rounded and clamped to [0, 100].
You can override the default weights by passing a custom ScoringWeights object to computeRiskScore() or buildRiskReport().
Understanding the Impact Graph
The import graph is a directed graph where each node is a source file and each edge represents an import statement:
math.ts <-- calculator.ts <-- app.ts
| ^
v |
format.ts --> index.tsHow the graph is built:
collectSourceFiles()recursively scans the project root for.ts,.tsx,.js, and.jsxfiles, skippingnode_modules,dist, and.gitdirectories.extractImports()reads each file and uses regex patterns to extract five types of import/export statements:import { x } from './path'(named imports)import x from './path'(default imports)import * as x from './path'(namespace imports)export { x } from './path'(re-exports)export * from './path'(star re-exports)
resolveImportPath()resolves relative import specifiers to actual file paths, trying each extension andindexfile resolution.
How impact is analyzed:
getReverseGraph()inverts the edges so each file maps to its importers.analyzeImpact()runs BFS from each changed file through the reverse graph, recording the depth at which each affected file is reached.- Files at depth 0 (the changed files themselves) are excluded from the results.
Limitations:
- Only relative imports are resolved. Path aliases (
@/utils/math) and bare module specifiers (lodash) are not tracked. - Dynamic
import()expressions are not detected. - TypeScript
pathsconfiguration intsconfig.jsonis not read.
GitHub Actions Integration
Add this workflow to .github/workflows/impact-scope.yml to run impact analysis on every PR:
name: Impact Scope Analysis
on:
pull_request:
branches: [main]
jobs:
analyze:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm run build
- name: Run impact analysis
run: npx impact-scope analyze --base origin/main --format ci --threshold 50Integration Patterns
Danger.js
// dangerfile.ts
import { execSync } from 'child_process';
const output = execSync(
'npx impact-scope analyze --base origin/main --format json',
{ encoding: 'utf-8' }
);
const report = JSON.parse(output);
if (report.score > 50) {
warn(`Impact scope risk score: ${report.score}/100 (${report.level})`);
}
if (report.untestedAffected.length > 0) {
const files = report.untestedAffected.map((f: string) => `- \`${f}\``).join('\n');
warn(`Untested affected files:\n${files}`);
}
message(`**Impact Analysis:** ${report.summary}`);GitHub PR Comment via Actions
- name: Run impact analysis
id: impact
run: |
OUTPUT=$(npx impact-scope analyze --base origin/main --format json)
echo "report<<EOF" >> $GITHUB_OUTPUT
echo "$OUTPUT" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
continue-on-error: true
- name: Comment PR
uses: actions/github-script@v7
with:
script: |
const report = JSON.parse(`${{ steps.impact.outputs.report }}`);
const body = [
'## Impact Scope Analysis',
`**Risk Score:** ${report.score}/100 (${report.level.toUpperCase()})`,
`**Changed:** ${report.changedFiles.length} files | **Affected:** ${report.affectedFiles.length} files | **Untested:** ${report.untestedAffected.length}`,
'',
report.untestedAffected.length > 0
? '### Untested Affected Files\n' + report.untestedAffected.map(f => `- \`${f}\``).join('\n')
: '',
].filter(Boolean).join('\n');
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body
});Programmatic API
import {
parseDiff,
buildImportGraph,
analyzeImpact,
buildRiskReport,
formatTerminal,
formatJSON,
formatCI,
} from 'impact-scope';
import type { RiskReport } from 'impact-scope';
function analyzeChanges(diffOutput: string, projectRoot: string): RiskReport {
const changed = parseDiff(diffOutput);
const graph = buildImportGraph(projectRoot);
const affected = analyzeImpact(changed, graph, projectRoot);
return buildRiskReport(changed, affected);
}
// Render the report
const report = analyzeChanges(diffOutput, '.');
console.log(formatTerminal(report)); // colored terminal output
console.log(formatJSON(report)); // JSON string
const { output, exitCode } = formatCI(report, 50); // CI with thresholdAPI Reference
| Function | Description |
|----------|-------------|
| parseDiff(diffOutput) | Parse unified diff string into ChangedFile[] |
| buildImportGraph(rootDir) | Scan project and build ImportGraph |
| analyzeImpact(changed, graph, root) | Find transitively affected files via BFS |
| analyzeFileImpact(filePath, graph, root) | Shorthand for single-file impact check |
| computeRiskScore(changed, affected, weights?) | Compute 0-100 risk score |
| getRiskLevel(score) | Map score to 'low'/'medium'/'high'/'critical' |
| buildRiskReport(changed, affected, weights?) | Build complete RiskReport |
| formatTerminal(report) | Render colored terminal output |
| formatJSON(report) | Render as formatted JSON string |
| formatCI(report, threshold) | Render CI output with { output, exitCode } |
| checkTestCoverage(file, root, graph) | Check if a file has test coverage |
| isTestFile(filePath) | Check if a path looks like a test file |
| findTestFiles(graph) | Find all test files in the graph |
| getReverseGraph(graph) | Get reverse dependency map |
Error classes: ImpactScopeError, DiffParseError, GraphBuildError, AnalysisError
Types: ChangedFile, ImportEdge, ImportGraph, ImpactNode, RiskReport, ScoringWeights
FAQ / Troubleshooting
"Error: Command failed: git diff HEAD~1"
No previous commit to diff against. Use --base to specify a valid ref:
impact-scope analyze --base main"File not found in project"
The check command only works with files in the import graph (.ts, .tsx, .js, .jsx). Ensure:
- The file path is relative to the project root
- The file has a supported extension
- Use
--rootif running from outside the project directory
No affected files are found
This can happen when:
- The changed file is not imported by any other file (leaf/entry point)
- The project uses non-relative imports (path aliases) which are not yet resolved
- The changed file is not a .ts/.tsx/.js/.jsx file
License
MIT
