codereview-aia 0.1.1 → 0.1.3

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.
@@ -2,7 +2,7 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import { basename, isAbsolute, join, resolve } from 'node:path';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { execa } from 'execa';
5
- import { collectFiles } from './fileCollector';
5
+ import { collectFiles, type FileCollectionMode } from './fileCollector';
6
6
  import { loadManifest } from './manifest';
7
7
  import { runAiCodeReview } from './runAiCodeReview';
8
8
  import { ensureProxyEnvironmentInitialized } from './proxyEnvironment';
@@ -10,14 +10,27 @@ import { mergeReports } from './reportMerge';
10
10
  import { collectReportData } from './reporting/reportDataCollector';
11
11
  import { buildMarkdownReport, injectSummary } from './reporting/markdownReportBuilder';
12
12
  import { generateReportSummary } from './reporting/summaryGenerator';
13
-
14
- export type ReviewStage = 'collecting' | 'reviewing' | 'merging';
13
+ import logger from '../utils/logger';
14
+
15
+ export type ReviewStage = 'preparing' | 'collecting' | 'reviewing' | 'merging';
16
+ export type ReviewMode = FileCollectionMode;
17
+
18
+ export interface StageUpdate {
19
+ filesFound?: number;
20
+ currentFile?: string;
21
+ fileIndex?: number;
22
+ totalFiles?: number;
23
+ uncommittedFiles?: number;
24
+ contextFiles?: number;
25
+ }
15
26
 
16
27
  export interface ReviewOptions {
17
28
  model: string;
18
29
  outDir: string;
19
30
  debug?: boolean;
20
- onStage?: (stage: ReviewStage, info?: { filesFound?: number }) => void;
31
+ mode?: ReviewMode;
32
+ preprodTargetUrl?: string;
33
+ onStage?: (stage: ReviewStage, info?: StageUpdate) => void;
21
34
  }
22
35
 
23
36
  export interface ReviewTotals {
@@ -33,21 +46,29 @@ export interface ReviewResult {
33
46
  duration: number;
34
47
  repo: string;
35
48
  filesReviewed: number;
49
+ fileBreakdown?: { uncommitted: number; context: number };
50
+ reportSummary?: string;
36
51
  reportPath?: string;
37
52
  }
38
53
 
39
54
  export async function executeReview(options: ReviewOptions): Promise<ReviewResult> {
40
- const { model, outDir, debug = false, onStage } = options;
55
+ const { model, outDir, debug = false, onStage, mode = 'uncommitted' } = options;
41
56
 
42
57
  onStage?.('collecting');
43
- const files = await collectFiles();
44
- const fileCount = files.length;
58
+ logger.info('Collecting files for review', { mode, workspace: process.cwd() });
59
+ const { targets, context, stats } = await collectFiles({ mode });
60
+ const fileCount = targets.length;
45
61
 
46
62
  if (fileCount === 0) {
47
63
  throw new Error('No files to review');
48
64
  }
49
65
 
50
- onStage?.('collecting', { filesFound: fileCount });
66
+ onStage?.('collecting', {
67
+ filesFound: fileCount,
68
+ uncommittedFiles: stats.uncommitted,
69
+ contextFiles: stats.context,
70
+ });
71
+ logger.info('File collection complete', { fileCount });
51
72
 
52
73
  const workspaceRoot = process.cwd();
53
74
  const repoRoot = await resolveRepoRoot();
@@ -64,9 +85,15 @@ export async function executeReview(options: ReviewOptions): Promise<ReviewResul
64
85
 
65
86
  const start = Date.now();
66
87
 
88
+ const repoName = basename(workspaceRoot) || 'workspace';
67
89
  try {
68
- onStage?.('reviewing', { filesFound: fileCount });
69
- const reports = await runAiCodeReview(files, {
90
+ onStage?.('reviewing', {
91
+ filesFound: fileCount,
92
+ totalFiles: fileCount,
93
+ uncommittedFiles: stats.uncommitted,
94
+ contextFiles: stats.context,
95
+ });
96
+ const reports = await runAiCodeReview(targets, context, {
70
97
  provider: 'openrouter',
71
98
  type: 'comprehensive',
72
99
  outDir: rawOutDir,
@@ -74,6 +101,22 @@ export async function executeReview(options: ReviewOptions): Promise<ReviewResul
74
101
  model,
75
102
  configPath: manifestPath,
76
103
  debug,
104
+ runContext: {
105
+ repoName,
106
+ reviewMode: mode,
107
+ totalFiles: fileCount,
108
+ uncommittedFiles: stats.uncommitted,
109
+ contextFiles: stats.context,
110
+ },
111
+ onFileProgress: ({ file, index, total }) =>
112
+ onStage?.('reviewing', {
113
+ filesFound: fileCount,
114
+ currentFile: file,
115
+ fileIndex: index,
116
+ totalFiles: total,
117
+ uncommittedFiles: stats.uncommitted,
118
+ contextFiles: stats.context,
119
+ }),
77
120
  });
78
121
 
79
122
  onStage?.('merging');
@@ -82,7 +125,7 @@ export async function executeReview(options: ReviewOptions): Promise<ReviewResul
82
125
  (merged?.totals as ReviewTotals) || ({ critical: 0, high: 0, medium: 0, low: 0 } as ReviewTotals);
83
126
  const findings = (merged?.findings as any[]) || [];
84
127
 
85
- const repo = basename(workspaceRoot) || 'workspace';
128
+ const repo = repoName;
86
129
  const duration = Math.round((Date.now() - start) / 1000);
87
130
  const reportTimestamp = new Date();
88
131
 
@@ -119,6 +162,8 @@ export async function executeReview(options: ReviewOptions): Promise<ReviewResul
119
162
  duration,
120
163
  repo,
121
164
  filesReviewed: fileCount,
165
+ fileBreakdown: { uncommitted: stats.uncommitted, context: stats.context },
166
+ reportSummary: summary,
122
167
  reportPath,
123
168
  };
124
169
  } finally {
@@ -1,11 +1,35 @@
1
1
  import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from 'node:fs';
2
+ import path from 'node:path';
2
3
  import { join, relative, resolve } from 'node:path';
3
4
  import { execa } from 'execa';
4
5
  import type { OutputFormat } from '../types/common';
5
- import type { ReviewOptions, ReviewType } from '../types/review';
6
+ import type { ReviewOptions, ReviewType, RunContext } from '../types/review';
6
7
  import { orchestrateReview } from '../core/reviewOrchestrator';
7
8
  import { RUNTIME_CONFIG } from './runtimeConfig';
8
9
 
10
+ interface FileProgressPayload {
11
+ file: string;
12
+ index: number;
13
+ total: number;
14
+ }
15
+
16
+ interface ReviewGroup {
17
+ label: string;
18
+ files: string[];
19
+ absoluteFiles: string[];
20
+ targetPath: string;
21
+ }
22
+
23
+ const MAX_FILES_PER_GROUP = 5;
24
+ const MAX_CONTEXT_DOCS_PER_GROUP = 5;
25
+ const MAX_CONTEXT_DOC_CHARS = 2000;
26
+
27
+ interface ContextDocEntry {
28
+ path: string;
29
+ relativePath: string;
30
+ snippet: string;
31
+ }
32
+
9
33
  export interface AiReviewOptions {
10
34
  provider: 'openrouter';
11
35
  type: string;
@@ -14,10 +38,16 @@ export interface AiReviewOptions {
14
38
  model?: string;
15
39
  configPath?: string;
16
40
  debug?: boolean;
41
+ onFileProgress?: (payload: FileProgressPayload) => void;
42
+ runContext?: RunContext;
17
43
  }
18
44
 
19
- export async function runAiCodeReview(files: string[], opts: AiReviewOptions): Promise<any[]> {
20
- if (files.length === 0) return [];
45
+ export async function runAiCodeReview(
46
+ targetFiles: string[],
47
+ contextFiles: string[],
48
+ opts: AiReviewOptions,
49
+ ): Promise<any[]> {
50
+ if (targetFiles.length === 0) return [];
21
51
 
22
52
  const workspaceRoot = process.cwd();
23
53
  let repoRoot: string;
@@ -68,7 +98,7 @@ export async function runAiCodeReview(files: string[], opts: AiReviewOptions): P
68
98
  }
69
99
 
70
100
  const expandedFiles: string[] = [];
71
- for (const file of files) {
101
+ for (const file of targetFiles) {
72
102
  const absPath = resolve(file);
73
103
  try {
74
104
  const stats = statSync(absPath);
@@ -144,10 +174,135 @@ export async function runAiCodeReview(files: string[], opts: AiReviewOptions): P
144
174
  baseOptions.config = opts.configPath;
145
175
  }
146
176
 
147
- for (const target of targets) {
148
- await orchestrateReview(target, baseOptions);
177
+ const groups = createReviewGroups(targets, workspaceRoot);
178
+ const contextDocEntries = prepareContextDocs(contextFiles, workspaceRoot);
179
+ const totalFiles = targets.length;
180
+ let processedCount = 0;
181
+
182
+ const groupCount = groups.length;
183
+ for (let groupIdx = 0; groupIdx < groups.length; groupIdx += 1) {
184
+ const group = groups[groupIdx];
185
+ const progressIndex = Math.min(processedCount + 1, totalFiles);
186
+ const displayName = formatGroupDisplay(group.files);
187
+ opts.onFileProgress?.({ file: displayName, index: progressIndex, total: totalFiles });
188
+
189
+ const selectedContextDocs = selectContextDocs(group.files, contextDocEntries);
190
+ const inheritedContext = opts.runContext;
191
+ const runContext = inheritedContext
192
+ ? {
193
+ ...inheritedContext,
194
+ groupIndex: groupIdx + 1,
195
+ groupCount,
196
+ groupLabel: group.label,
197
+ groupFiles: group.files,
198
+ docFiles: selectedContextDocs.map((doc) => doc.relativePath),
199
+ contextDocs: selectedContextDocs.map((doc) => ({
200
+ path: doc.relativePath,
201
+ snippet: doc.snippet,
202
+ })),
203
+ }
204
+ : undefined;
205
+
206
+ await orchestrateReview(group.targetPath, {
207
+ ...baseOptions,
208
+ targetFileList: group.absoluteFiles,
209
+ runContext,
210
+ });
211
+ processedCount += group.files.length;
149
212
  collectOutputs();
150
213
  }
151
214
 
152
215
  return outputs;
153
216
  }
217
+
218
+ function createReviewGroups(targets: string[], workspaceRoot: string): ReviewGroup[] {
219
+ const directoryMap = new Map<string, string[]>();
220
+ for (const target of targets) {
221
+ const dir = path.posix.dirname(target) || '.';
222
+ if (!directoryMap.has(dir)) {
223
+ directoryMap.set(dir, []);
224
+ }
225
+ directoryMap.get(dir)!.push(target);
226
+ }
227
+
228
+ const groups: ReviewGroup[] = [];
229
+ for (const [dir, files] of directoryMap) {
230
+ for (let i = 0; i < files.length; i += MAX_FILES_PER_GROUP) {
231
+ const chunk = files.slice(i, i + MAX_FILES_PER_GROUP);
232
+ const absoluteFiles = chunk.map((file) => resolve(workspaceRoot, file));
233
+ const targetPath = dir === '.' ? chunk[0] ?? '.' : dir;
234
+ groups.push({
235
+ label: dir,
236
+ files: chunk,
237
+ absoluteFiles,
238
+ targetPath,
239
+ });
240
+ }
241
+ }
242
+
243
+ return groups;
244
+ }
245
+
246
+ function formatGroupDisplay(files: string[]): string {
247
+ if (files.length === 0) {
248
+ return 'pending files';
249
+ }
250
+
251
+ if (files.length === 1) {
252
+ return files[0];
253
+ }
254
+
255
+ return `${files[0]} (+${files.length - 1} more)`;
256
+ }
257
+
258
+ function prepareContextDocs(contextFiles: string[], workspaceRoot: string): ContextDocEntry[] {
259
+ const docs: ContextDocEntry[] = [];
260
+ const seen = new Set<string>();
261
+
262
+ for (const file of contextFiles) {
263
+ const absPath = resolve(file);
264
+ if (seen.has(absPath)) continue;
265
+ seen.add(absPath);
266
+
267
+ try {
268
+ const stats = statSync(absPath);
269
+ if (!stats.isFile()) continue;
270
+ const buffer = readFileSync(absPath);
271
+ if (buffer.length === 0 || buffer.includes(0)) continue;
272
+ const snippet = buffer.toString('utf-8').slice(0, MAX_CONTEXT_DOC_CHARS);
273
+ const relativePath = relative(workspaceRoot, absPath).replace(/\\/g, '/');
274
+ if (!relativePath || relativePath.startsWith('..')) continue;
275
+ docs.push({ path: absPath, relativePath, snippet });
276
+ } catch {
277
+ continue;
278
+ }
279
+ }
280
+
281
+ return docs;
282
+ }
283
+
284
+ function selectContextDocs(
285
+ groupFiles: string[],
286
+ docs: ContextDocEntry[],
287
+ limit = MAX_CONTEXT_DOCS_PER_GROUP,
288
+ ): ContextDocEntry[] {
289
+ if (docs.length === 0 || limit <= 0) {
290
+ return [];
291
+ }
292
+
293
+ const groupDirs = new Set(groupFiles.map((file) => path.posix.dirname(file)));
294
+ const prioritized: ContextDocEntry[] = [];
295
+ const remaining: ContextDocEntry[] = [];
296
+
297
+ for (const doc of docs) {
298
+ const docDir = path.posix.dirname(doc.relativePath);
299
+ if (groupDirs.has(docDir)) {
300
+ prioritized.push(doc);
301
+ } else {
302
+ remaining.push(doc);
303
+ }
304
+ }
305
+
306
+ const ordered = [...prioritized, ...remaining];
307
+ return ordered.slice(0, limit);
308
+ }
@@ -1,16 +1,19 @@
1
- import React, { useCallback, useEffect, useState } from 'react';
1
+ import React, { useCallback, useEffect, useMemo, useState } from 'react';
2
2
  import { LayoutProvider, useLayout } from './Layout';
3
3
  import { AuthScreen } from './screens/AuthScreen';
4
4
  import { ModeSelection } from './screens/ModeSelection';
5
5
  import { ProgressScreen } from './screens/ProgressScreen';
6
6
  import { ResultsScreen } from './screens/ResultsScreen';
7
7
  import { getInk, getInkSpinner } from './inkModules';
8
- import type { ReviewResult, ReviewStage } from '../reviewPipeline';
8
+ import type { ReviewMode, ReviewResult, ReviewStage, StageUpdate } from '../reviewPipeline';
9
9
  import { executeReview } from '../reviewPipeline';
10
10
  import { loadSession, getSessionToken } from '../auth/session';
11
11
  import type { SessionUser } from '../auth/types';
12
12
  import { RUNTIME_CONFIG } from '../runtimeConfig';
13
13
  import { MissingCrIgnoreError } from '../errors';
14
+ import { triggerManualWebCheck } from '../preprod/webCheck';
15
+ import { startDebugLogSession, type DebugLogSession } from '../debug/logManager';
16
+ import logger from '../../utils/logger';
14
17
 
15
18
  type ScreenState = 'auth' | 'mode' | 'progress' | 'results';
16
19
 
@@ -21,9 +24,31 @@ interface RuntimeAppProps {
21
24
  interface ProgressState {
22
25
  stage: ReviewStage;
23
26
  filesFound?: number;
27
+ currentFile?: string;
28
+ currentIndex?: number;
29
+ totalFiles?: number;
30
+ uncommittedFiles?: number;
31
+ contextFiles?: number;
24
32
  }
25
33
 
26
34
  const defaultProgress: ProgressState = { stage: 'collecting' };
35
+ const GENERIC_ERROR_MESSAGE =
36
+ 'Something went wrong. Press D to enable debug mode and rerun to capture logs.';
37
+ const PREPROD_ERROR_MESSAGE =
38
+ 'Could not start the website check. Double-check the URL or try again later.';
39
+
40
+ function transformStageUpdate(stage: ReviewStage, info?: StageUpdate): ProgressState {
41
+ return {
42
+ stage,
43
+ filesFound: info?.filesFound,
44
+ currentFile: info?.currentFile,
45
+ currentIndex:
46
+ typeof info?.fileIndex === 'number' && info.fileIndex > 0 ? info.fileIndex : undefined,
47
+ totalFiles: info?.totalFiles,
48
+ uncommittedFiles: info?.uncommittedFiles,
49
+ contextFiles: info?.contextFiles,
50
+ };
51
+ }
27
52
 
28
53
  function AppBody({ debug = false }: RuntimeAppProps) {
29
54
  const { Box, Text, useApp, useInput } = getInk();
@@ -33,8 +58,11 @@ function AppBody({ debug = false }: RuntimeAppProps) {
33
58
  const [user, setUser] = useState<SessionUser | null>(null);
34
59
  const [progress, setProgress] = useState<ProgressState>(defaultProgress);
35
60
  const [results, setResults] = useState<ReviewResult | null>(null);
61
+ const [debugEnabled, setDebugEnabled] = useState(false);
62
+ const [lastDebugLogPath, setLastDebugLogPath] = useState<string | null>(null);
36
63
  type ErrorInfo = { type: 'generic' | 'crignore'; message: string; hint?: string };
37
64
  const [error, setError] = useState<ErrorInfo | null>(null);
65
+ const combinedDebugFlag = useMemo(() => debug || debugEnabled, [debug, debugEnabled]);
38
66
 
39
67
  useEffect(() => {
40
68
  const sessionUser = loadSession();
@@ -68,23 +96,86 @@ function AppBody({ debug = false }: RuntimeAppProps) {
68
96
  setScreen('mode');
69
97
  }, []);
70
98
 
71
- const handleRunReview = useCallback(async (_mode: string) => {
99
+ const handleToggleDebug = useCallback(() => {
100
+ setDebugEnabled((prev) => {
101
+ if (prev) {
102
+ setLastDebugLogPath(null);
103
+ }
104
+ return !prev;
105
+ });
106
+ }, []);
107
+
108
+ const handleRunReview = useCallback(async (mode: ReviewMode, extras?: { targetUrl?: string }) => {
109
+ const trimmedUrl = extras?.targetUrl?.trim();
110
+
111
+ if (mode === 'preprod' && !trimmedUrl) {
112
+ setError({ type: 'generic', message: 'Pre-production mode requires a website URL.' });
113
+ return;
114
+ }
115
+
72
116
  setScreen('progress');
73
117
  setResults(null);
74
118
  setError(null);
75
- setProgress(defaultProgress);
119
+ setProgress(mode === 'preprod' ? { stage: 'preparing' } : { ...defaultProgress });
120
+ setLastDebugLogPath(null);
121
+
122
+ let debugSession: DebugLogSession | null = null;
123
+ const finalizeDebugLogging = () => {
124
+ if (debugSession) {
125
+ debugSession.stop();
126
+ setLastDebugLogPath(debugSession.filePath);
127
+ } else {
128
+ setLastDebugLogPath(null);
129
+ }
130
+ };
131
+
132
+ if (debugEnabled) {
133
+ try {
134
+ debugSession = startDebugLogSession(process.cwd());
135
+ } catch (logInitError) {
136
+ const message =
137
+ logInitError instanceof Error
138
+ ? logInitError.message
139
+ : 'Unable to initialize debug logging.';
140
+ setError({ type: 'generic', message: `Debug logging disabled: ${message}` });
141
+ }
142
+ }
143
+
144
+ if (mode === 'preprod' && trimmedUrl) {
145
+ try {
146
+ logger.info('Triggering manual website check', { url: trimmedUrl });
147
+ await triggerManualWebCheck(trimmedUrl);
148
+ } catch (webError) {
149
+ logger.error('Pre-production web check failed', webError);
150
+ setError({ type: 'generic', message: PREPROD_ERROR_MESSAGE });
151
+ setScreen('mode');
152
+ finalizeDebugLogging();
153
+ return;
154
+ }
155
+ }
76
156
 
77
157
  try {
158
+ logger.info('Starting code review run', { mode, workspace: process.cwd(), preprodTarget: trimmedUrl });
159
+ setProgress({ ...defaultProgress });
78
160
  const reviewResult = await executeReview({
79
161
  model: process.env.AI_CODE_REVIEW_MODEL || RUNTIME_CONFIG.DEFAULT_MODEL,
80
162
  outDir: './reports',
81
- debug,
82
- onStage: (stage, info) => setProgress({ stage, filesFound: info?.filesFound }),
163
+ debug: combinedDebugFlag,
164
+ mode,
165
+ preprodTargetUrl: trimmedUrl,
166
+ onStage: (stage, info) =>
167
+ setProgress(transformStageUpdate(stage, info)),
83
168
  });
84
169
 
170
+ logger.info('Code review run complete', {
171
+ mode,
172
+ durationSeconds: reviewResult.duration,
173
+ filesReviewed: reviewResult.filesReviewed,
174
+ });
85
175
  setResults(reviewResult);
86
176
  setScreen('results');
87
177
  } catch (reviewError) {
178
+ logger.error('Code review run failed', reviewError);
88
179
  if (reviewError instanceof MissingCrIgnoreError) {
89
180
  setError({
90
181
  type: 'crignore',
@@ -92,22 +183,38 @@ function AppBody({ debug = false }: RuntimeAppProps) {
92
183
  hint: `Create ${reviewError.crIgnorePath} (can be empty, gitignore syntax) and rerun.`,
93
184
  });
94
185
  } else {
95
- const message =
96
- reviewError instanceof Error
97
- ? reviewError.message
98
- : 'Failed to run review. Check proxy/auth configuration.';
99
- setError({ type: 'generic', message });
186
+ setError({ type: 'generic', message: GENERIC_ERROR_MESSAGE });
100
187
  }
101
188
  setScreen('mode');
189
+ } finally {
190
+ finalizeDebugLogging();
102
191
  }
103
- }, [debug]);
192
+ }, [combinedDebugFlag, debugEnabled]);
104
193
 
105
194
  return (
106
195
  <Box width={layout.frameWidth} flexDirection="column" gap={1}>
107
196
  {screen === 'auth' && <AuthScreen onAuth={handleAuth} />}
108
- {screen === 'mode' && user && <ModeSelection onSelect={(mode) => handleRunReview(mode)} />}
109
- {screen === 'progress' && <ProgressScreen stage={progress.stage} filesFound={progress.filesFound} />}
110
- {screen === 'results' && results && <ResultsScreen result={results} />}
197
+ {screen === 'mode' && user && (
198
+ <ModeSelection
199
+ onSelect={(mode, payload) => handleRunReview(mode, payload)}
200
+ debugEnabled={debugEnabled}
201
+ onToggleDebug={handleToggleDebug}
202
+ />
203
+ )}
204
+ {screen === 'progress' && (
205
+ <ProgressScreen
206
+ stage={progress.stage}
207
+ filesFound={progress.filesFound}
208
+ currentFile={progress.currentFile}
209
+ currentIndex={progress.currentIndex}
210
+ totalFiles={progress.totalFiles}
211
+ uncommittedFiles={progress.uncommittedFiles}
212
+ contextFiles={progress.contextFiles}
213
+ />
214
+ )}
215
+ {screen === 'results' && results && (
216
+ <ResultsScreen result={results} debugLogPath={lastDebugLogPath} />
217
+ )}
111
218
  {error && (
112
219
  <Box
113
220
  borderStyle="round"
@@ -121,6 +228,11 @@ function AppBody({ debug = false }: RuntimeAppProps) {
121
228
  </Text>
122
229
  </Box>
123
230
  )}
231
+ {lastDebugLogPath && screen !== 'results' && (
232
+ <Box>
233
+ <Text dimColor>Debug logs saved to: {lastDebugLogPath}</Text>
234
+ </Box>
235
+ )}
124
236
  <Box justifyContent="space-between">
125
237
  <Text dimColor>{user ? `Logged in as ${user.username}` : 'Authenticate to start reviewing'}</Text>
126
238
  <Text dimColor>Ctrl+C to exit</Text>