codereview-aia 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/.cr-aia.yml +23 -0
  2. package/.crignore +0 -0
  3. package/dist/index.js +27 -0
  4. package/package.json +85 -0
  5. package/src/analysis/FindingsExtractor.ts +431 -0
  6. package/src/analysis/ai-detection/analyzers/BaseAnalyzer.ts +267 -0
  7. package/src/analysis/ai-detection/analyzers/DocumentationAnalyzer.ts +622 -0
  8. package/src/analysis/ai-detection/analyzers/GitHistoryAnalyzer.ts +430 -0
  9. package/src/analysis/ai-detection/core/AIDetectionEngine.ts +467 -0
  10. package/src/analysis/ai-detection/types/DetectionTypes.ts +406 -0
  11. package/src/analysis/ai-detection/utils/SubmissionConverter.ts +390 -0
  12. package/src/analysis/context/ReviewContext.ts +378 -0
  13. package/src/analysis/context/index.ts +7 -0
  14. package/src/analysis/index.ts +8 -0
  15. package/src/analysis/tokens/TokenAnalysisFormatter.ts +154 -0
  16. package/src/analysis/tokens/TokenAnalyzer.ts +747 -0
  17. package/src/analysis/tokens/index.ts +8 -0
  18. package/src/clients/base/abstractClient.ts +190 -0
  19. package/src/clients/base/httpClient.ts +160 -0
  20. package/src/clients/base/index.ts +12 -0
  21. package/src/clients/base/modelDetection.ts +107 -0
  22. package/src/clients/base/responseProcessor.ts +586 -0
  23. package/src/clients/factory/clientFactory.ts +55 -0
  24. package/src/clients/factory/index.ts +8 -0
  25. package/src/clients/implementations/index.ts +8 -0
  26. package/src/clients/implementations/openRouterClient.ts +411 -0
  27. package/src/clients/openRouterClient.ts +863 -0
  28. package/src/clients/openRouterClientWrapper.ts +44 -0
  29. package/src/clients/utils/directoryStructure.ts +52 -0
  30. package/src/clients/utils/index.ts +11 -0
  31. package/src/clients/utils/languageDetection.ts +44 -0
  32. package/src/clients/utils/promptFormatter.ts +105 -0
  33. package/src/clients/utils/promptLoader.ts +53 -0
  34. package/src/clients/utils/tokenCounter.ts +297 -0
  35. package/src/core/ApiClientSelector.ts +37 -0
  36. package/src/core/ConfigurationService.ts +591 -0
  37. package/src/core/ConsolidationService.ts +423 -0
  38. package/src/core/InteractiveDisplayManager.ts +81 -0
  39. package/src/core/OutputManager.ts +275 -0
  40. package/src/core/ReviewGenerator.ts +140 -0
  41. package/src/core/fileDiscovery.ts +237 -0
  42. package/src/core/handlers/EstimationHandler.ts +104 -0
  43. package/src/core/handlers/FileProcessingHandler.ts +204 -0
  44. package/src/core/handlers/OutputHandler.ts +125 -0
  45. package/src/core/handlers/ReviewExecutor.ts +104 -0
  46. package/src/core/reviewOrchestrator.ts +333 -0
  47. package/src/core/utils/ModelInfoUtils.ts +56 -0
  48. package/src/formatters/outputFormatter.ts +62 -0
  49. package/src/formatters/utils/IssueFormatters.ts +83 -0
  50. package/src/formatters/utils/JsonFormatter.ts +77 -0
  51. package/src/formatters/utils/MarkdownFormatters.ts +609 -0
  52. package/src/formatters/utils/MetadataFormatter.ts +269 -0
  53. package/src/formatters/utils/ModelInfoExtractor.ts +115 -0
  54. package/src/index.ts +27 -0
  55. package/src/plugins/PluginInterface.ts +50 -0
  56. package/src/plugins/PluginManager.ts +126 -0
  57. package/src/prompts/PromptManager.ts +69 -0
  58. package/src/prompts/cache/PromptCache.ts +50 -0
  59. package/src/prompts/promptText/common/variables/css-frameworks.json +33 -0
  60. package/src/prompts/promptText/common/variables/framework-versions.json +45 -0
  61. package/src/prompts/promptText/frameworks/react/comprehensive.hbs +19 -0
  62. package/src/prompts/promptText/languages/css/comprehensive.hbs +18 -0
  63. package/src/prompts/promptText/languages/generic/comprehensive.hbs +20 -0
  64. package/src/prompts/promptText/languages/html/comprehensive.hbs +18 -0
  65. package/src/prompts/promptText/languages/javascript/comprehensive.hbs +18 -0
  66. package/src/prompts/promptText/languages/python/comprehensive.hbs +18 -0
  67. package/src/prompts/promptText/languages/typescript/comprehensive.hbs +18 -0
  68. package/src/runtime/auth/service.ts +58 -0
  69. package/src/runtime/auth/session.ts +103 -0
  70. package/src/runtime/auth/types.ts +11 -0
  71. package/src/runtime/cliEntry.ts +196 -0
  72. package/src/runtime/errors.ts +13 -0
  73. package/src/runtime/fileCollector.ts +188 -0
  74. package/src/runtime/manifest.ts +64 -0
  75. package/src/runtime/openrouterProxy.ts +45 -0
  76. package/src/runtime/proxyConfig.ts +94 -0
  77. package/src/runtime/proxyEnvironment.ts +71 -0
  78. package/src/runtime/reportMerge.ts +102 -0
  79. package/src/runtime/reporting/markdownReportBuilder.ts +138 -0
  80. package/src/runtime/reporting/reportDataCollector.ts +234 -0
  81. package/src/runtime/reporting/summaryGenerator.ts +86 -0
  82. package/src/runtime/reviewPipeline.ts +155 -0
  83. package/src/runtime/runAiCodeReview.ts +153 -0
  84. package/src/runtime/runtimeConfig.ts +5 -0
  85. package/src/runtime/ui/Layout.tsx +57 -0
  86. package/src/runtime/ui/RuntimeApp.tsx +150 -0
  87. package/src/runtime/ui/inkModules.ts +73 -0
  88. package/src/runtime/ui/screens/AuthScreen.tsx +128 -0
  89. package/src/runtime/ui/screens/ModeSelection.tsx +52 -0
  90. package/src/runtime/ui/screens/ProgressScreen.tsx +55 -0
  91. package/src/runtime/ui/screens/ResultsScreen.tsx +76 -0
  92. package/src/strategies/ArchitecturalReviewStrategy.ts +54 -0
  93. package/src/strategies/CodingTestReviewStrategy.ts +920 -0
  94. package/src/strategies/ConsolidatedReviewStrategy.ts +59 -0
  95. package/src/strategies/ExtractPatternsReviewStrategy.ts +64 -0
  96. package/src/strategies/MultiPassReviewStrategy.ts +785 -0
  97. package/src/strategies/ReviewStrategy.ts +64 -0
  98. package/src/strategies/StrategyFactory.ts +79 -0
  99. package/src/strategies/index.ts +14 -0
  100. package/src/tokenizers/baseTokenizer.ts +61 -0
  101. package/src/tokenizers/gptTokenizer.ts +27 -0
  102. package/src/tokenizers/index.ts +8 -0
  103. package/src/types/apiResponses.ts +40 -0
  104. package/src/types/cli.ts +24 -0
  105. package/src/types/common.ts +39 -0
  106. package/src/types/configuration.ts +201 -0
  107. package/src/types/handlebars.d.ts +5 -0
  108. package/src/types/patch.d.ts +25 -0
  109. package/src/types/review.ts +294 -0
  110. package/src/types/reviewContext.d.ts +65 -0
  111. package/src/types/reviewSchema.ts +181 -0
  112. package/src/types/structuredReview.ts +167 -0
  113. package/src/types/tokenAnalysis.ts +56 -0
  114. package/src/utils/FileReader.ts +93 -0
  115. package/src/utils/FileWriter.ts +76 -0
  116. package/src/utils/PathGenerator.ts +97 -0
  117. package/src/utils/api/apiUtils.ts +14 -0
  118. package/src/utils/api/index.ts +1 -0
  119. package/src/utils/apiErrorHandler.ts +287 -0
  120. package/src/utils/ciDataCollector.ts +252 -0
  121. package/src/utils/codingTestConfigLoader.ts +466 -0
  122. package/src/utils/dependencies/aiDependencyAnalyzer.ts +454 -0
  123. package/src/utils/detection/frameworkDetector.ts +879 -0
  124. package/src/utils/detection/index.ts +10 -0
  125. package/src/utils/detection/projectTypeDetector.ts +518 -0
  126. package/src/utils/diagramGenerator.ts +206 -0
  127. package/src/utils/errorLogger.ts +60 -0
  128. package/src/utils/estimationUtils.ts +407 -0
  129. package/src/utils/fileFilters.ts +373 -0
  130. package/src/utils/fileSystem.ts +57 -0
  131. package/src/utils/index.ts +36 -0
  132. package/src/utils/logger.ts +240 -0
  133. package/src/utils/pathValidator.ts +98 -0
  134. package/src/utils/priorityFilter.ts +59 -0
  135. package/src/utils/projectDocs.ts +189 -0
  136. package/src/utils/promptPaths.ts +29 -0
  137. package/src/utils/promptTemplateManager.ts +157 -0
  138. package/src/utils/review/consolidateReview.ts +553 -0
  139. package/src/utils/review/fixDisplay.ts +100 -0
  140. package/src/utils/review/fixImplementation.ts +61 -0
  141. package/src/utils/review/index.ts +36 -0
  142. package/src/utils/review/interactiveProcessing.ts +294 -0
  143. package/src/utils/review/progressTracker.ts +296 -0
  144. package/src/utils/review/reviewExtraction.ts +382 -0
  145. package/src/utils/review/types.ts +46 -0
  146. package/src/utils/reviewActionHandler.ts +18 -0
  147. package/src/utils/reviewParser.ts +253 -0
  148. package/src/utils/sanitizer.ts +238 -0
  149. package/src/utils/smartFileSelector.ts +255 -0
  150. package/src/utils/templateLoader.ts +514 -0
  151. package/src/utils/treeGenerator.ts +153 -0
  152. package/tsconfig.build.json +14 -0
  153. package/tsconfig.json +59 -0
@@ -0,0 +1,234 @@
1
+ import type { StructuredDataReport, StructuredIssue } from '../reportMerge';
2
+ import type { ReviewTotals } from '../reviewPipeline';
3
+
4
+ export type IssueSeverity = 'critical' | 'high' | 'medium' | 'low';
5
+
6
+ export interface NormalizedIssue {
7
+ filePath: string;
8
+ severity: IssueSeverity;
9
+ title?: string;
10
+ description?: string;
11
+ impact?: string;
12
+ suggestedFix?: string;
13
+ lineLabel?: string;
14
+ lineStart?: number;
15
+ lineEnd?: number;
16
+ }
17
+
18
+ export interface FileIssueGroup {
19
+ filePath: string;
20
+ issues: NormalizedIssue[];
21
+ }
22
+
23
+ export interface TokenStats {
24
+ totalTokens: number;
25
+ inputTokens: number;
26
+ outputTokens: number;
27
+ }
28
+
29
+ export interface CollectedReportData {
30
+ totals: ReviewTotals;
31
+ groupedIssues: FileIssueGroup[];
32
+ tokenStats?: TokenStats;
33
+ estimatedCostUSD?: number;
34
+ issueCount: number;
35
+ }
36
+
37
+ const SEVERITY_ORDER: Record<IssueSeverity, number> = {
38
+ critical: 0,
39
+ high: 1,
40
+ medium: 2,
41
+ low: 3,
42
+ };
43
+
44
+ function normalizeSeverity(priority?: string): IssueSeverity {
45
+ const normalized = priority?.toLowerCase().trim();
46
+ if (normalized === 'critical') return 'critical';
47
+ if (normalized === 'high') return 'high';
48
+ if (normalized === 'low') return 'low';
49
+ return 'medium';
50
+ }
51
+
52
+ function parseLineLabel(lineNumbers?: string): {
53
+ label?: string;
54
+ start?: number;
55
+ end?: number;
56
+ } {
57
+ if (!lineNumbers) {
58
+ return {};
59
+ }
60
+
61
+ const trimmed = lineNumbers.trim();
62
+ if (!trimmed) {
63
+ return {};
64
+ }
65
+
66
+ const match = trimmed.match(/^(\d+)(?:\s*-\s*(\d+))?$/);
67
+ if (!match) {
68
+ return { label: trimmed };
69
+ }
70
+
71
+ const start = Number.parseInt(match[1], 10);
72
+ const end = match[2] ? Number.parseInt(match[2], 10) : start;
73
+ const label = start === end ? `${start}` : `${start}-${end}`;
74
+
75
+ return { label, start, end };
76
+ }
77
+
78
+ function toNumber(value: unknown): number | undefined {
79
+ if (typeof value === 'number' && Number.isFinite(value)) {
80
+ return value;
81
+ }
82
+ if (typeof value === 'string') {
83
+ const parsed = Number.parseFloat(value);
84
+ if (!Number.isNaN(parsed)) {
85
+ return parsed;
86
+ }
87
+ }
88
+ return undefined;
89
+ }
90
+
91
+ function extractIssues(reports: StructuredDataReport[]): NormalizedIssue[] {
92
+ const issues: NormalizedIssue[] = [];
93
+
94
+ for (const report of reports) {
95
+ let structuredIssues: StructuredIssue[] | undefined = report.structuredData?.issues;
96
+
97
+ if (!structuredIssues && typeof report.structuredData === 'string') {
98
+ try {
99
+ const parsed = JSON.parse(report.structuredData);
100
+ structuredIssues = parsed?.issues;
101
+ } catch {
102
+ structuredIssues = undefined;
103
+ }
104
+ }
105
+
106
+ if (!Array.isArray(structuredIssues)) {
107
+ continue;
108
+ }
109
+
110
+ for (const issue of structuredIssues) {
111
+ const filePath = issue.filePath?.trim() || 'Unspecified file';
112
+ const severity = normalizeSeverity(issue.priority);
113
+ const { label, start, end } = parseLineLabel(issue.lineNumbers);
114
+
115
+ issues.push({
116
+ filePath,
117
+ severity,
118
+ title: issue.title,
119
+ description: issue.description,
120
+ impact: issue.impact,
121
+ suggestedFix: issue.suggestedFix,
122
+ lineLabel: label,
123
+ lineStart: start,
124
+ lineEnd: end,
125
+ });
126
+ }
127
+ }
128
+
129
+ return issues;
130
+ }
131
+
132
+ function groupIssuesByFile(issues: NormalizedIssue[]): FileIssueGroup[] {
133
+ const groups = new Map<string, NormalizedIssue[]>();
134
+
135
+ for (const issue of issues) {
136
+ if (!groups.has(issue.filePath)) {
137
+ groups.set(issue.filePath, []);
138
+ }
139
+ groups.get(issue.filePath)!.push(issue);
140
+ }
141
+
142
+ const sortedGroups = Array.from(groups.entries())
143
+ .map(([filePath, fileIssues]) => {
144
+ fileIssues.sort((a, b) => {
145
+ const severityDiff = SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity];
146
+ if (severityDiff !== 0) {
147
+ return severityDiff;
148
+ }
149
+ if (a.lineStart && b.lineStart) {
150
+ return a.lineStart - b.lineStart;
151
+ }
152
+ return (a.title || '').localeCompare(b.title || '');
153
+ });
154
+
155
+ return { filePath, issues: fileIssues };
156
+ })
157
+ .sort((a, b) => a.filePath.localeCompare(b.filePath));
158
+
159
+ return sortedGroups;
160
+ }
161
+
162
+ function aggregateCostInfo(reports: StructuredDataReport[]): {
163
+ tokenStats?: TokenStats;
164
+ estimatedCostUSD?: number;
165
+ } {
166
+ let inputTokens = 0;
167
+ let outputTokens = 0;
168
+ let estimatedCost = 0;
169
+ let hasCostInfo = false;
170
+
171
+ for (const report of reports) {
172
+ const costInfo = (report.costInfo || (report as any).costInfo) as Record<string, unknown> | undefined;
173
+ if (!costInfo) continue;
174
+
175
+ const input = toNumber(costInfo.inputTokens);
176
+ const output = toNumber(costInfo.outputTokens);
177
+ const total = toNumber(costInfo.totalTokens);
178
+ const estimate = toNumber(costInfo.estimatedCost ?? costInfo.cost);
179
+
180
+ if (input) {
181
+ inputTokens += input;
182
+ hasCostInfo = true;
183
+ }
184
+ if (output) {
185
+ outputTokens += output;
186
+ hasCostInfo = true;
187
+ }
188
+ if (estimate) {
189
+ estimatedCost += estimate;
190
+ hasCostInfo = true;
191
+ }
192
+
193
+ if (!input && !output && total && !Number.isNaN(total)) {
194
+ // If total tokens is provided without split, distribute evenly.
195
+ inputTokens += total / 2;
196
+ outputTokens += total / 2;
197
+ hasCostInfo = true;
198
+ }
199
+ }
200
+
201
+ if (!hasCostInfo) {
202
+ return {};
203
+ }
204
+
205
+ return {
206
+ tokenStats: {
207
+ inputTokens: Math.round(inputTokens),
208
+ outputTokens: Math.round(outputTokens),
209
+ totalTokens: Math.round(inputTokens + outputTokens),
210
+ },
211
+ estimatedCostUSD: estimatedCost || undefined,
212
+ };
213
+ }
214
+
215
+ export function collectReportData(reports: StructuredDataReport[]): CollectedReportData {
216
+ const issues = extractIssues(reports);
217
+ const groupedIssues = groupIssuesByFile(issues);
218
+
219
+ // Derive severity totals if missing (default zero).
220
+ const totals: ReviewTotals = { critical: 0, high: 0, medium: 0, low: 0 };
221
+ for (const issue of issues) {
222
+ totals[issue.severity] += 1;
223
+ }
224
+
225
+ const { tokenStats, estimatedCostUSD } = aggregateCostInfo(reports);
226
+
227
+ return {
228
+ totals,
229
+ groupedIssues,
230
+ tokenStats,
231
+ estimatedCostUSD,
232
+ issueCount: issues.length,
233
+ };
234
+ }
@@ -0,0 +1,86 @@
1
+ import { buildOpenRouterProxyHeaders, resolveOpenRouterProxyUrl, withProxyMetadata } from '../openrouterProxy';
2
+ import type { ReviewTotals } from '../reviewPipeline';
3
+ import logger from '../../utils/logger';
4
+
5
+ interface SummaryGeneratorOptions {
6
+ model?: string;
7
+ totals: ReviewTotals;
8
+ durationSeconds: number;
9
+ estimatedCostUSD?: number;
10
+ issueCount: number;
11
+ }
12
+
13
+ function buildFallbackSummary(options: SummaryGeneratorOptions): string {
14
+ const parts: string[] = [];
15
+ const { totals, durationSeconds, estimatedCostUSD, issueCount } = options;
16
+ const totalIssues = issueCount || totals.critical + totals.high + totals.medium + totals.low;
17
+ parts.push(
18
+ `Review completed in ${Math.round(durationSeconds)}s with ${totalIssues} tracked issues ` +
19
+ `(critical ${totals.critical}, high ${totals.high}, medium ${totals.medium}, low ${totals.low}).`,
20
+ );
21
+
22
+ if (typeof estimatedCostUSD === 'number') {
23
+ parts.push(`Approximate spend: $${estimatedCostUSD.toFixed(estimatedCostUSD < 1 ? 4 : 2)} USD.`);
24
+ }
25
+
26
+ return parts.join(' ');
27
+ }
28
+
29
+ export async function generateReportSummary(
30
+ markdownDraft: string,
31
+ options: SummaryGeneratorOptions,
32
+ ): Promise<string> {
33
+ const summaryModel = process.env.CR_AIA_SUMMARY_MODEL || options.model;
34
+ if (!summaryModel) {
35
+ return buildFallbackSummary(options);
36
+ }
37
+
38
+ const [, modelName] = summaryModel.includes(':')
39
+ ? summaryModel.split(':')
40
+ : ['openrouter', summaryModel];
41
+
42
+ const systemPrompt = `You are an engineering lead summarizing a comprehensive code review report.
43
+ Provide 2-3 sentences capturing:
44
+ - Overall health of the reviewed codebase
45
+ - Highest-risk themes or repeating issues
46
+ - Notable spend/time observations if they appear.
47
+ Keep it factual and concise.`;
48
+
49
+ const payload = withProxyMetadata({
50
+ model: modelName,
51
+ temperature: 0.2,
52
+ max_tokens: 200,
53
+ messages: [
54
+ { role: 'system', content: systemPrompt },
55
+ { role: 'user', content: markdownDraft },
56
+ ],
57
+ });
58
+
59
+ try {
60
+ const response = await fetch(resolveOpenRouterProxyUrl(), {
61
+ method: 'POST',
62
+ headers: buildOpenRouterProxyHeaders(),
63
+ body: JSON.stringify(payload),
64
+ });
65
+
66
+ if (!response.ok) {
67
+ const errorData = await response.text();
68
+ logger.warn(`AI summary generation failed (${response.status}): ${errorData}`);
69
+ return buildFallbackSummary(options);
70
+ }
71
+
72
+ const data = await response.json();
73
+ const content = data?.choices?.[0]?.message?.content?.trim();
74
+ if (content) {
75
+ return content;
76
+ }
77
+
78
+ logger.warn('AI summary response missing content, falling back to deterministic summary.');
79
+ return buildFallbackSummary(options);
80
+ } catch (error) {
81
+ logger.warn(
82
+ `Unable to generate AI summary: ${error instanceof Error ? error.message : String(error)}`,
83
+ );
84
+ return buildFallbackSummary(options);
85
+ }
86
+ }
@@ -0,0 +1,155 @@
1
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { basename, isAbsolute, join, resolve } from 'node:path';
3
+ import { tmpdir } from 'node:os';
4
+ import { execa } from 'execa';
5
+ import { collectFiles } from './fileCollector';
6
+ import { loadManifest } from './manifest';
7
+ import { runAiCodeReview } from './runAiCodeReview';
8
+ import { ensureProxyEnvironmentInitialized } from './proxyEnvironment';
9
+ import { mergeReports } from './reportMerge';
10
+ import { collectReportData } from './reporting/reportDataCollector';
11
+ import { buildMarkdownReport, injectSummary } from './reporting/markdownReportBuilder';
12
+ import { generateReportSummary } from './reporting/summaryGenerator';
13
+
14
+ export type ReviewStage = 'collecting' | 'reviewing' | 'merging';
15
+
16
+ export interface ReviewOptions {
17
+ model: string;
18
+ outDir: string;
19
+ debug?: boolean;
20
+ onStage?: (stage: ReviewStage, info?: { filesFound?: number }) => void;
21
+ }
22
+
23
+ export interface ReviewTotals {
24
+ critical: number;
25
+ high: number;
26
+ medium: number;
27
+ low: number;
28
+ }
29
+
30
+ export interface ReviewResult {
31
+ totals: ReviewTotals;
32
+ findings: any[];
33
+ duration: number;
34
+ repo: string;
35
+ filesReviewed: number;
36
+ reportPath?: string;
37
+ }
38
+
39
+ export async function executeReview(options: ReviewOptions): Promise<ReviewResult> {
40
+ const { model, outDir, debug = false, onStage } = options;
41
+
42
+ onStage?.('collecting');
43
+ const files = await collectFiles();
44
+ const fileCount = files.length;
45
+
46
+ if (fileCount === 0) {
47
+ throw new Error('No files to review');
48
+ }
49
+
50
+ onStage?.('collecting', { filesFound: fileCount });
51
+
52
+ const workspaceRoot = process.cwd();
53
+ const repoRoot = await resolveRepoRoot();
54
+ const manifest = loadManifest(workspaceRoot);
55
+ const tempDir = mkdtempSync(join(tmpdir(), 'cr-aia-'));
56
+ const rawOutDir = mkdtempSync(join(tmpdir(), 'cr-aia-raw-'));
57
+ const manifestPath = join(tempDir, 'ai-code-review.json');
58
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
59
+
60
+ ensureProxyEnvironmentInitialized(repoRoot);
61
+
62
+ const finalOutDir = resolveOutputDirectory(workspaceRoot, outDir);
63
+ mkdirSync(finalOutDir, { recursive: true });
64
+
65
+ const start = Date.now();
66
+
67
+ try {
68
+ onStage?.('reviewing', { filesFound: fileCount });
69
+ const reports = await runAiCodeReview(files, {
70
+ provider: 'openrouter',
71
+ type: 'comprehensive',
72
+ outDir: rawOutDir,
73
+ format: 'json',
74
+ model,
75
+ configPath: manifestPath,
76
+ debug,
77
+ });
78
+
79
+ onStage?.('merging');
80
+ const merged = mergeReports(reports || []);
81
+ const totals =
82
+ (merged?.totals as ReviewTotals) || ({ critical: 0, high: 0, medium: 0, low: 0 } as ReviewTotals);
83
+ const findings = (merged?.findings as any[]) || [];
84
+
85
+ const repo = basename(workspaceRoot) || 'workspace';
86
+ const duration = Math.round((Date.now() - start) / 1000);
87
+ const reportTimestamp = new Date();
88
+
89
+ const collected = collectReportData(reports || []);
90
+ const severityTotals = totals || collected.totals;
91
+
92
+ const markdownDraft = buildMarkdownReport({
93
+ repoName: repo,
94
+ generatedAt: reportTimestamp,
95
+ durationSeconds: duration,
96
+ filesReviewed: fileCount,
97
+ totals: severityTotals,
98
+ tokenStats: collected.tokenStats,
99
+ estimatedCostUSD: collected.estimatedCostUSD,
100
+ issueGroups: collected.groupedIssues,
101
+ });
102
+
103
+ const summary = await generateReportSummary(markdownDraft, {
104
+ model,
105
+ totals: severityTotals,
106
+ durationSeconds: duration,
107
+ estimatedCostUSD: collected.estimatedCostUSD,
108
+ issueCount: collected.issueCount,
109
+ });
110
+
111
+ const finalMarkdown = injectSummary(markdownDraft, summary);
112
+ const reportFileName = formatReportFileName(repo, reportTimestamp);
113
+ const reportPath = join(finalOutDir, reportFileName);
114
+ writeFileSync(reportPath, finalMarkdown, 'utf-8');
115
+
116
+ return {
117
+ totals: severityTotals,
118
+ findings,
119
+ duration,
120
+ repo,
121
+ filesReviewed: fileCount,
122
+ reportPath,
123
+ };
124
+ } finally {
125
+ rmSync(tempDir, { recursive: true, force: true });
126
+ rmSync(rawOutDir, { recursive: true, force: true });
127
+ }
128
+ }
129
+
130
+ async function resolveRepoRoot(): Promise<string> {
131
+ try {
132
+ const { stdout } = await execa('git', ['rev-parse', '--show-toplevel']);
133
+ return stdout.trim();
134
+ } catch {
135
+ return process.cwd();
136
+ }
137
+ }
138
+
139
+ function resolveOutputDirectory(root: string, outputDir: string): string {
140
+ if (isAbsolute(outputDir)) {
141
+ return outputDir;
142
+ }
143
+ return resolve(root, outputDir);
144
+ }
145
+
146
+ function formatReportFileName(repo: string, date: Date): string {
147
+ const sanitizedRepo = repo.replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/-+/g, '-');
148
+ const pad = (value: number) => value.toString().padStart(2, '0');
149
+ const day = pad(date.getDate());
150
+ const month = pad(date.getMonth() + 1);
151
+ const year = date.getFullYear();
152
+ const hours = pad(date.getHours());
153
+ const minutes = pad(date.getMinutes());
154
+ return `cr-${sanitizedRepo}-${day}-${month}-${year}-${hours}-${minutes}.md`;
155
+ }
@@ -0,0 +1,153 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from 'node:fs';
2
+ import { join, relative, resolve } from 'node:path';
3
+ import { execa } from 'execa';
4
+ import type { OutputFormat } from '../types/common';
5
+ import type { ReviewOptions, ReviewType } from '../types/review';
6
+ import { orchestrateReview } from '../core/reviewOrchestrator';
7
+ import { RUNTIME_CONFIG } from './runtimeConfig';
8
+
9
+ export interface AiReviewOptions {
10
+ provider: 'openrouter';
11
+ type: string;
12
+ outDir: string;
13
+ format: 'json' | 'md';
14
+ model?: string;
15
+ configPath?: string;
16
+ debug?: boolean;
17
+ }
18
+
19
+ export async function runAiCodeReview(files: string[], opts: AiReviewOptions): Promise<any[]> {
20
+ if (files.length === 0) return [];
21
+
22
+ const workspaceRoot = process.cwd();
23
+ let repoRoot: string;
24
+ try {
25
+ const { stdout } = await execa('git', ['rev-parse', '--show-toplevel']);
26
+ repoRoot = stdout.trim();
27
+ } catch {
28
+ repoRoot = process.cwd();
29
+ }
30
+
31
+ const absOutDir = resolve(workspaceRoot, opts.outDir);
32
+
33
+ if (!existsSync(absOutDir)) {
34
+ mkdirSync(absOutDir, { recursive: true });
35
+ }
36
+
37
+ const outputFileMeta = new Map<string, { mtimeMs: number; size: number }>();
38
+ if (existsSync(absOutDir)) {
39
+ for (const file of readdirSync(absOutDir)) {
40
+ if (!file.endsWith('.json')) continue;
41
+ try {
42
+ const stats = statSync(join(absOutDir, file));
43
+ outputFileMeta.set(file, { mtimeMs: stats.mtimeMs, size: stats.size });
44
+ } catch {
45
+ // ignore stale files
46
+ }
47
+ }
48
+ }
49
+
50
+ function getAllFilesInDir(dirPath: string, repo: string): string[] {
51
+ const entries = readdirSync(dirPath, { withFileTypes: true });
52
+ const nested: string[] = [];
53
+ for (const entry of entries) {
54
+ const fullPath = join(dirPath, entry.name);
55
+ const relPath = relative(repo, fullPath);
56
+ if (relPath.includes('.git/') || relPath.includes('node_modules/')) {
57
+ continue;
58
+ }
59
+
60
+ if (entry.isDirectory()) {
61
+ const subFiles = getAllFilesInDir(fullPath, repo);
62
+ nested.push(...subFiles);
63
+ } else if (entry.isFile()) {
64
+ nested.push(fullPath);
65
+ }
66
+ }
67
+ return nested;
68
+ }
69
+
70
+ const expandedFiles: string[] = [];
71
+ for (const file of files) {
72
+ const absPath = resolve(file);
73
+ try {
74
+ const stats = statSync(absPath);
75
+ if (stats.isDirectory()) {
76
+ const dirFiles = getAllFilesInDir(absPath, repoRoot);
77
+ expandedFiles.push(...dirFiles);
78
+ } else if (stats.isFile()) {
79
+ expandedFiles.push(absPath);
80
+ }
81
+ } catch {
82
+ continue;
83
+ }
84
+ }
85
+
86
+ const uniqueExpandedFiles = Array.from(new Set(expandedFiles));
87
+ const relativeFilesSet = new Set<string>();
88
+ for (const absPath of uniqueExpandedFiles) {
89
+ try {
90
+ const stats = statSync(absPath);
91
+ if (!stats.isFile()) continue;
92
+ const repoRelative = relative(repoRoot, absPath);
93
+ if (!repoRelative || repoRelative.startsWith('..')) continue;
94
+
95
+ const workspaceRelative = relative(workspaceRoot, absPath).replace(/\\/g, '/');
96
+ if (!workspaceRelative || workspaceRelative.startsWith('..')) continue;
97
+
98
+ relativeFilesSet.add(workspaceRelative);
99
+ } catch {
100
+ continue;
101
+ }
102
+ }
103
+
104
+ const outputs: any[] = [];
105
+
106
+ const collectOutputs = () => {
107
+ if (!existsSync(absOutDir)) {
108
+ return;
109
+ }
110
+ const jsonFiles = readdirSync(absOutDir).filter((f) => f.endsWith('.json'));
111
+ for (const file of jsonFiles) {
112
+ const filePath = join(absOutDir, file);
113
+ try {
114
+ const stats = statSync(filePath);
115
+ const previous = outputFileMeta.get(file);
116
+ if (previous && stats.mtimeMs <= previous.mtimeMs && stats.size === previous.size) {
117
+ continue;
118
+ }
119
+ const content = JSON.parse(readFileSync(filePath, 'utf-8'));
120
+ outputs.push(content);
121
+ outputFileMeta.set(file, { mtimeMs: stats.mtimeMs, size: stats.size });
122
+ } catch {
123
+ continue;
124
+ }
125
+ }
126
+ };
127
+
128
+ if (relativeFilesSet.size === 0) {
129
+ throw new Error('No files to review after filtering');
130
+ }
131
+
132
+ const targets = Array.from(relativeFilesSet);
133
+ const mappedFormat: OutputFormat = opts.format === 'md' ? 'markdown' : 'json';
134
+
135
+ const baseOptions: (ReviewOptions & { config?: string }) = {
136
+ type: opts.type as ReviewType,
137
+ output: mappedFormat,
138
+ outputDir: absOutDir,
139
+ model: opts.model || RUNTIME_CONFIG.DEFAULT_MODEL,
140
+ debug: opts.debug ?? false,
141
+ };
142
+
143
+ if (opts.configPath) {
144
+ baseOptions.config = opts.configPath;
145
+ }
146
+
147
+ for (const target of targets) {
148
+ await orchestrateReview(target, baseOptions);
149
+ collectOutputs();
150
+ }
151
+
152
+ return outputs;
153
+ }
@@ -0,0 +1,5 @@
1
+ export const RUNTIME_CONFIG = {
2
+ DEFAULT_MODEL: 'openrouter:x-ai/grok-4-fast',
3
+ } as const;
4
+
5
+ export type RuntimeConfig = typeof RUNTIME_CONFIG;
@@ -0,0 +1,57 @@
1
+ import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
2
+ import { getInk } from './inkModules';
3
+
4
+ interface LayoutValue {
5
+ columns: number;
6
+ rows: number;
7
+ frameWidth: number;
8
+ }
9
+
10
+ const DEFAULT_COLUMNS = 80;
11
+ const DEFAULT_ROWS = 24;
12
+
13
+ const LayoutContext = createContext<LayoutValue>({
14
+ columns: DEFAULT_COLUMNS,
15
+ rows: DEFAULT_ROWS,
16
+ frameWidth: DEFAULT_COLUMNS - 4,
17
+ });
18
+
19
+ export function LayoutProvider({ children }: { children: React.ReactNode }) {
20
+ const { useStdout } = getInk();
21
+ const { stdout } = useStdout();
22
+ const [dimensions, setDimensions] = useState<LayoutValue>(() => ({
23
+ columns: stdout?.columns ?? DEFAULT_COLUMNS,
24
+ rows: stdout?.rows ?? DEFAULT_ROWS,
25
+ frameWidth: Math.max(60, Math.min((stdout?.columns ?? DEFAULT_COLUMNS) - 4, 110)),
26
+ }));
27
+
28
+ useEffect(() => {
29
+ if (!stdout) {
30
+ return;
31
+ }
32
+
33
+ const handleResize = () => {
34
+ const cols = stdout.columns ?? DEFAULT_COLUMNS;
35
+ const rows = stdout.rows ?? DEFAULT_ROWS;
36
+ setDimensions({
37
+ columns: cols,
38
+ rows,
39
+ frameWidth: Math.max(60, Math.min(cols - 4, 110)),
40
+ });
41
+ };
42
+
43
+ stdout.on('resize', handleResize);
44
+ return () => {
45
+ stdout.off('resize', handleResize);
46
+ };
47
+ }, [stdout]);
48
+
49
+ const value = useMemo<LayoutValue>(() => dimensions, [dimensions]);
50
+
51
+ return <LayoutContext.Provider value={value}>{children}</LayoutContext.Provider>;
52
+ }
53
+
54
+ export function useLayout(): LayoutValue {
55
+ return useContext(LayoutContext);
56
+ }
57
+