Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ DEFAULT_REVIEW_MODE=fast # fast | rlm
# OpenAI-compatible endpoint (optional — for Azure OpenAI, local models, etc.)
OPENAI_BASE_URL= # e.g. "https://your-resource.openai.azure.com/openai/deployments/your-model"

# Impact Analysis
IMPACT_ENABLED=true # Enable dependency impact analysis (default: true)
IMPACT_DEPTH_THRESHOLD=10 # Max depth for transitive dependency tracing

# GitHub (CLI mode)
GITHUB_PAT= # Personal Access Token for CLI use

Expand Down
8 changes: 7 additions & 1 deletion .pnpm-approve-builds
Original file line number Diff line number Diff line change
@@ -1 +1,7 @@
["unrs-resolver@*"]
[
"unrs-resolver@*",
"tree-sitter@*",
"tree-sitter-javascript@*",
"tree-sitter-python@*",
"tree-sitter-typescript@*"
]
6 changes: 6 additions & 0 deletions GETTING_STARTED.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,8 @@ GITHUB_PAT=ghp_your-token-here
| `EXCLUDE_GLOBS` | — | File patterns to exclude (e.g., `dist/**`) |
| `OPENAI_BASE_URL` | — | Custom OpenAI-compatible endpoint |
| `DEFAULT_REVIEW_MODE` | `fast` | Default: `fast` or `rlm` |
| `IMPACT_ENABLED` | `true` | Enable dependency impact analysis |
| `IMPACT_DEPTH_THRESHOLD` | `10` | Max depth for transitive dependency tracing |

See [`.env.example`](.env.example) for the complete list with linter toggles and all options.

Expand Down Expand Up @@ -301,6 +303,9 @@ node cli/dist/main.mjs review --url https://github.com/owner/repo/pull/123 --out

# Post findings directly as GitHub PR comments
node cli/dist/main.mjs review --url https://github.com/owner/repo/pull/123 --submit

# Run with dependency impact analysis explicitly disabled
node cli/dist/main.mjs review --url https://github.com/owner/repo/pull/123 --no-impact
```

### Step 6: Explore other commands
Expand Down Expand Up @@ -334,6 +339,7 @@ PR Diff → File Filtering → Diff Chunking → LLM Review (per chunk) → Cita
- Large diffs are chunked by file (~40K chars per LLM call) for reliable results
- Lock files, generated code, and binaries are skipped automatically
- Built-in linters (ESLint, Ruff, Semgrep, ShellCheck, Gitleaks) run in parallel
- **Impact Analysis** computes the blast radius of changes across the codebase to prioritize findings that break critical dependents

### Deep / RLM Mode

Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Open-source, agentic code review tool. AI-powered bug detection, sandboxed code
- **Codebase-aware chat** — ask questions about your PR with full repo context via `@openreview`.
- **Learns from feedback** — persistent learnings database avoids repeating false positives.
- **Language & framework aware** — detects TypeScript, Python, Go, Rust, React, Next.js, and more for targeted review.
- **Impact Analysis** — computes the blast radius of changes, scoring transitive dependents to prioritize high-impact findings.

## Quick Start

Expand Down Expand Up @@ -100,6 +101,7 @@ npx openreview review --url <PR-URL> [options]
| `--expert` | off | Comprehensive SOLID, security, and quality review |
| `--submit` | off | Post findings as GitHub PR comment (inline + summary) |
| `--quiet` | off | Suppress progress output (only print findings) |
| `--impact` | auto | Enable or disable (`--no-impact`) dependency impact analysis |

**Expert mode** (`--expert`) adds deep analysis covering:
- SOLID principles (single responsibility, open/closed, Liskov, interface segregation, dependency inversion)
Expand Down Expand Up @@ -242,8 +244,8 @@ See [GETTING_STARTED.md](GETTING_STARTED.md) for the full setup guide, [CONTRIBU

## Roadmap

- **Phase 1 (MVP)** ✅ — CLI + GitHub Action, Fast + RLM review, codebase chat, learnings, trace logging
- **Phase 2 (Growth)** — Web UI, auto-fix, Jira/Linear integration, 30+ linters, Docker sandbox, Impact Analysis
- **Phase 1 (MVP)** ✅ — CLI + GitHub Action, Fast + RLM review, codebase chat, learnings, trace logging, Impact Analysis
- **Phase 2 (Growth)** — Web UI, auto-fix, Jira/Linear integration, 30+ linters, Docker sandbox
- **Phase 3 (Enterprise)** — Multi-platform (GitLab, Azure DevOps, Bitbucket), IDE extension, cloud hosting, analytics

## Contributing
Expand Down
19 changes: 18 additions & 1 deletion cli/src/commands/review.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createInterface } from 'node:readline/promises';

Check failure on line 1 in cli/src/commands/review.ts

View workflow job for this annotation

GitHub Actions / lint-typecheck-test (20)

There should be at least one empty line between import groups

Check failure on line 1 in cli/src/commands/review.ts

View workflow job for this annotation

GitHub Actions / lint-typecheck-test (22)

There should be at least one empty line between import groups
import {
CommentPoster,
GitHubClient,
Expand All @@ -24,6 +25,8 @@
.option('--expert', 'Comprehensive SOLID/security/quality review')
.option('--submit', 'Post findings as GitHub PR comment')
.option('--quiet', 'Suppress progress output')
.option('--impact', 'Include impact analysis')
.option('--no-impact', 'Skip impact analysis')
.action(async (opts) => {
try {
const cfg = loadConfig();
Expand All @@ -33,6 +36,17 @@
(cfg as unknown as Record<string, unknown>).mainModel = opts.model;
}

if (opts.impact === true) {
cfg.impactEnabled = true;
} else if (opts.impact === false) {
cfg.impactEnabled = false;
} else if (!process.env.CI && process.stdout.isTTY) {
const rl = createInterface({ input: process.stdin, output: process.stdout });
const answer = await rl.question('Would you like to include impact analysis? (y/n) ');
rl.close();
cfg.impactEnabled = answer.trim().toLowerCase() === 'y';
}

const { client, prNumber } = GitHubClient.fromPRUrl(opts.url);

if (!opts.quiet) {
Expand Down Expand Up @@ -73,6 +87,8 @@
let findings: ReviewFinding[];
let summary: ReviewSummary;

let impactResult: any;

if (opts.mode === 'rlm') {
if (!opts.quiet) {
process.stderr.write('Running RLM deep review...\n');
Expand Down Expand Up @@ -109,9 +125,10 @@
process.stderr.write('Running fast review...\n');
}

const result = await runFastReview(prContext);
const result = await runFastReview(prContext, process.cwd());
findings = result.findings;
summary = result.summary;
impactResult = result.impact;

Check failure on line 131 in cli/src/commands/review.ts

View workflow job for this annotation

GitHub Actions / lint-typecheck-test (20)

'impactResult' is assigned a value but never used. Allowed unused vars must match /^_/u

Check failure on line 131 in cli/src/commands/review.ts

View workflow job for this annotation

GitHub Actions / lint-typecheck-test (22)

'impactResult' is assigned a value but never used. Allowed unused vars must match /^_/u
}

// Format and print output
Expand Down
12 changes: 10 additions & 2 deletions cli/src/formatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ export function formatText(findings: ReviewFinding[], summary: ReviewSummary): s

lines.push(`OpenReview — ${summary.mode} mode | ${summary.filesReviewed} files | ${summary.duration}`);
lines.push(`Findings: ${summary.totalFindings}`);
if (summary.impactSummary) {
lines.push(`Impact Summary: ${summary.impactSummary.totalImpacted} total files impacted, ${summary.impactSummary.affectedPageCount} UI pages/routes affected`);
}
lines.push('');

if (findings.length === 0) {
Expand All @@ -26,7 +29,8 @@ export function formatText(findings: ReviewFinding[], summary: ReviewSummary): s
for (const f of findings) {
const icon = SEVERITY_ICONS[f.severity] ?? '❓';
const source = f.source === 'linter' ? ` [${f.linterName}]` : '';
lines.push(`${icon} ${f.severity.toUpperCase()} ${f.file}:${f.startLine} — ${f.title}${source}`);
const impact = f.impactScope ? ` [Impact: ${f.impactScope.affectedPages} pages, ${f.impactScope.affectedFiles} files]` : '';
lines.push(`${icon} ${f.severity.toUpperCase()} ${f.file}:${f.startLine} — ${f.title}${source}${impact}`);
lines.push(` ${f.explanation}`);
if (f.suggestedFix) {
lines.push(` Fix: ${f.suggestedFix}`);
Expand All @@ -47,6 +51,9 @@ export function formatMarkdown(findings: ReviewFinding[], summary: ReviewSummary
lines.push(`## OpenReview Summary`);
lines.push('');
lines.push(`**Files reviewed:** ${summary.filesReviewed} | **Duration:** ${summary.duration} | **Mode:** ${summary.mode}`);
if (summary.impactSummary) {
lines.push(`**Impact:** ${summary.impactSummary.totalImpacted} total files impacted, ${summary.impactSummary.affectedPageCount} UI pages/routes affected`);
}
lines.push('');
lines.push('| Severity | Count |');
lines.push('|---|---|');
Expand Down Expand Up @@ -74,7 +81,8 @@ export function formatMarkdown(findings: ReviewFinding[], summary: ReviewSummary
lines.push(`### ${icon} ${severity}`);
lines.push('');
for (const f of group) {
lines.push(`**${f.title}** — \`${f.file}:${f.startLine}\``);
const impact = f.impactScope ? ` _[Impact: ${f.impactScope.affectedPages} pages, ${f.impactScope.affectedFiles} files]_` : '';
lines.push(`**${f.title}** — \`${f.file}:${f.startLine}\`${impact}`);
lines.push('');
lines.push(f.explanation);
if (f.suggestedFix) {
Expand Down
76 changes: 76 additions & 0 deletions cli/src/impact-formatter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import type { ImpactResult, ImpactNode } from '@openreview/core';

export function formatImpactTree(result: ImpactResult): string {
if (!result || result.impactedFiles.length === 0) {
return 'No impact analysis data available or no files impacted.';
}

const lines: string[] = [];
lines.push('================================================================================');
lines.push('🌳 IMPACT ANALYSIS TREE');
lines.push('================================================================================');
lines.push('');

// Group by proximity
const byProximity = new Map<number, ImpactNode[]>();
for (const node of result.impactedFiles) {
const group = byProximity.get(node.proximity) || [];
group.push(node);
byProximity.set(node.proximity, group);
}

const sortedProximities = Array.from(byProximity.keys()).sort((a, b) => a - b);

for (const proximity of sortedProximities) {
const nodes = byProximity.get(proximity) || [];

// Determine section title
let sectionTitle = '';

Check failure on line 28 in cli/src/impact-formatter.ts

View workflow job for this annotation

GitHub Actions / lint-typecheck-test (20)

The value assigned to 'sectionTitle' is not used in subsequent statements

Check failure on line 28 in cli/src/impact-formatter.ts

View workflow job for this annotation

GitHub Actions / lint-typecheck-test (22)

The value assigned to 'sectionTitle' is not used in subsequent statements
if (proximity === 1) sectionTitle = 'Direct Dependents (Proximity 1)';
else if (proximity === 2) sectionTitle = '2nd Degree Dependents (Proximity 2)';
else if (proximity === 3) sectionTitle = '3rd Degree Dependents (Proximity 3)';
else sectionTitle = `Deeper Dependents (Proximity ${proximity})`;

lines.push(`## ${sectionTitle}`);

for (const node of nodes) {
// Score and File
const scoreStr = (node.relevanceScore * 100).toFixed(0);
lines.push(`- 📄 ${node.file} (Score: ${scoreStr})`);

// Import Chain Path
if (node.importChain && node.importChain.length > 1) {
// Show path starting from the changed file
const pathStr = node.importChain.join(' ➔ ');
lines.push(` Chain: ${pathStr}`);
}
}
lines.push('');
}

// Component-to-Page Mapping Highlight
if (result.affectedPages.length > 0 || result.affectedComponents.length > 0) {
lines.push('--------------------------------------------------------------------------------');
lines.push('🎯 AFFECTED UI PAGES & ROUTES');
lines.push('--------------------------------------------------------------------------------');
lines.push('');

if (result.affectedPages.length > 0) {
lines.push('Affected Pages/Routes:');
for (const page of result.affectedPages) {
lines.push(` - 🌐 ${page}`);
}
lines.push('');
}

if (result.affectedComponents.length > 0) {
lines.push('Modified Components triggering these updates:');
for (const comp of result.affectedComponents) {
lines.push(` - 🧩 ${comp}`);
}
lines.push('');
}
}

return lines.join('\n');
}
38 changes: 38 additions & 0 deletions cli/src/impact-report.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import fs from 'node:fs/promises';
import path from 'node:path';

Check failure on line 2 in cli/src/impact-report.ts

View workflow job for this annotation

GitHub Actions / lint-typecheck-test (20)

There should be at least one empty line between import groups

Check failure on line 2 in cli/src/impact-report.ts

View workflow job for this annotation

GitHub Actions / lint-typecheck-test (22)

There should be at least one empty line between import groups
import type { ImpactResult } from '@openreview/core';

/**
* Writes the full ImpactResult to a JSON file.
*
* @param result The impact analysis result.
* @param outputPath The directory or specific file path to write to. If it's a directory, writes to `impact-report.json`.
*/
export async function writeImpactReport(result: ImpactResult, outputPath: string): Promise<void> {
let targetPath = outputPath;

try {
const stats = await fs.stat(outputPath);
if (stats.isDirectory()) {
targetPath = path.join(outputPath, 'impact-report.json');
}
} catch (error: any) {
// If the path doesn't exist, we check if it looks like a file extension
if (error.code === 'ENOENT') {
if (!path.extname(outputPath)) {
// Assume it's a directory that needs to be created
await fs.mkdir(outputPath, { recursive: true });
targetPath = path.join(outputPath, 'impact-report.json');
} else {
// Ensure parent directory exists
const parentDir = path.dirname(outputPath);
await fs.mkdir(parentDir, { recursive: true });
}
} else {
throw error;
}
}

const outputData = JSON.stringify(result, null, 2);
await fs.writeFile(targetPath, outputData, 'utf-8');
}
59 changes: 59 additions & 0 deletions cli/tests/formatter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, it, expect } from 'vitest';

Check failure on line 1 in cli/tests/formatter.test.ts

View workflow job for this annotation

GitHub Actions / lint-typecheck-test (20)

There should be at least one empty line between import groups

Check failure on line 1 in cli/tests/formatter.test.ts

View workflow job for this annotation

GitHub Actions / lint-typecheck-test (22)

There should be at least one empty line between import groups
import { formatText, formatMarkdown } from '../src/formatter.js';

Check failure on line 2 in cli/tests/formatter.test.ts

View workflow job for this annotation

GitHub Actions / lint-typecheck-test (20)

There should be at least one empty line between import groups

Check failure on line 2 in cli/tests/formatter.test.ts

View workflow job for this annotation

GitHub Actions / lint-typecheck-test (22)

There should be at least one empty line between import groups
import type { ReviewFinding, ReviewSummary } from '@openreview/core';

Check failure on line 3 in cli/tests/formatter.test.ts

View workflow job for this annotation

GitHub Actions / lint-typecheck-test (20)

`@openreview/core` type import should occur before import of `vitest`

Check failure on line 3 in cli/tests/formatter.test.ts

View workflow job for this annotation

GitHub Actions / lint-typecheck-test (22)

`@openreview/core` type import should occur before import of `vitest`

describe('CLI Formatter', () => {
const mockSummary: ReviewSummary = {
filesReviewed: 5,
duration: '2s',
mode: 'fast',
findingsBySeverity: { severe: 1, 'non-severe': 0, investigate: 0, informational: 0 },
totalFindings: 1,
impactSummary: {
totalImpacted: 10,
affectedPageCount: 3
}
};

const mockFinding: ReviewFinding = {
id: '1',
category: 'bug',
severity: 'severe',
file: 'src/components/Button.tsx',
startLine: 10,
endLine: 10,
title: 'Null pointer exception',
explanation: 'Could be null',
source: 'ai',
citations: [],
impactScope: {
affectedFiles: 5,
affectedPages: 2
}
};

describe('formatText', () => {
it('should format finding with impact scope', () => {
const output = formatText([mockFinding], mockSummary);
expect(output).toContain('[Impact: 2 pages, 5 files]');
expect(output).toContain('🔴 SEVERE src/components/Button.tsx:10 — Null pointer exception');
});

it('should include impact summary', () => {
const output = formatText([mockFinding], mockSummary);
expect(output).toContain('Impact Summary: 10 total files impacted, 3 UI pages/routes affected');
});
});

describe('formatMarkdown', () => {
it('should format finding with impact scope', () => {
const output = formatMarkdown([mockFinding], mockSummary);
expect(output).toContain('_[Impact: 2 pages, 5 files]_');
});

it('should include impact summary', () => {
const output = formatMarkdown([mockFinding], mockSummary);
expect(output).toContain('**Impact:** 10 total files impacted, 3 UI pages/routes affected');
});
});
});
Loading
Loading