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.
- package/dist/index.js +0 -1
- package/docs/opt-in-full-context.md +27 -0
- package/package.json +1 -1
- package/reports/cr-cr-aia-17-11-2025-20-13.md +354 -0
- package/src/clients/implementations/openRouterClient.ts +2 -0
- package/src/clients/openRouterClient.ts +8 -1
- package/src/clients/utils/promptFormatter.ts +97 -20
- package/src/core/handlers/FileProcessingHandler.ts +6 -0
- package/src/index.ts +0 -2
- package/src/runtime/cliEntry.ts +21 -3
- package/src/runtime/debug/logManager.ts +37 -0
- package/src/runtime/fileCollector.ts +335 -28
- package/src/runtime/preprod/webCheck.ts +104 -0
- package/src/runtime/reviewPipeline.ts +56 -11
- package/src/runtime/runAiCodeReview.ts +161 -6
- package/src/runtime/ui/RuntimeApp.tsx +127 -15
- package/src/runtime/ui/screens/ModeSelection.tsx +148 -15
- package/src/runtime/ui/screens/ProgressScreen.tsx +58 -3
- package/src/runtime/ui/screens/ResultsScreen.tsx +37 -10
- package/src/types/review.ts +18 -0
- package/src/utils/logger.ts +64 -14
|
@@ -1,23 +1,154 @@
|
|
|
1
|
-
import React from 'react';
|
|
2
|
-
import { getInk, getInkSelectInput } from '../inkModules';
|
|
1
|
+
import React, { useCallback, useState } from 'react';
|
|
2
|
+
import { getInk, getInkSelectInput, getInkTextInput } from '../inkModules';
|
|
3
3
|
import { useLayout } from '../Layout';
|
|
4
|
+
import type { ReviewMode } from '../../reviewPipeline';
|
|
4
5
|
|
|
5
6
|
interface ModeSelectionProps {
|
|
6
|
-
onSelect: (mode: string) => void;
|
|
7
|
+
onSelect: (mode: ReviewMode, extras?: { targetUrl?: string }) => void;
|
|
8
|
+
debugEnabled: boolean;
|
|
9
|
+
onToggleDebug: () => void;
|
|
7
10
|
}
|
|
8
11
|
|
|
9
|
-
|
|
12
|
+
interface ModeOption {
|
|
13
|
+
label: string;
|
|
14
|
+
value: ReviewMode;
|
|
15
|
+
description: string;
|
|
16
|
+
requiresUrl?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const OPTIONS: ModeOption[] = [
|
|
10
20
|
{
|
|
11
21
|
label: 'Review uncommitted changes',
|
|
12
22
|
value: 'uncommitted',
|
|
13
23
|
description: 'Scan staged and unstaged changes only',
|
|
14
24
|
},
|
|
25
|
+
{
|
|
26
|
+
label: 'Pre-production review',
|
|
27
|
+
value: 'preprod',
|
|
28
|
+
description: 'Scan the entire workspace and trigger website checks',
|
|
29
|
+
requiresUrl: true,
|
|
30
|
+
},
|
|
15
31
|
];
|
|
16
32
|
|
|
17
|
-
export function ModeSelection({ onSelect }: ModeSelectionProps) {
|
|
18
|
-
const { Box, Text } = getInk();
|
|
33
|
+
export function ModeSelection({ onSelect, debugEnabled, onToggleDebug }: ModeSelectionProps) {
|
|
34
|
+
const { Box, Text, useInput } = getInk();
|
|
19
35
|
const SelectInput = getInkSelectInput();
|
|
36
|
+
const TextInput = getInkTextInput();
|
|
20
37
|
const { frameWidth } = useLayout();
|
|
38
|
+
const [phase, setPhase] = useState<'list' | 'preprodUrl'>('list');
|
|
39
|
+
const [pendingMode, setPendingMode] = useState<ReviewMode>('preprod');
|
|
40
|
+
const [highlighted, setHighlighted] = useState<ReviewMode>('uncommitted');
|
|
41
|
+
const [urlValue, setUrlValue] = useState('');
|
|
42
|
+
const [urlError, setUrlError] = useState<string | null>(null);
|
|
43
|
+
|
|
44
|
+
const resetUrlState = useCallback(() => {
|
|
45
|
+
setUrlValue('');
|
|
46
|
+
setUrlError(null);
|
|
47
|
+
}, []);
|
|
48
|
+
|
|
49
|
+
const handleModeSelect = useCallback(
|
|
50
|
+
(mode: ReviewMode) => {
|
|
51
|
+
const option = OPTIONS.find((opt) => opt.value === mode);
|
|
52
|
+
if (!option) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (option.requiresUrl) {
|
|
57
|
+
setPendingMode(option.value);
|
|
58
|
+
resetUrlState();
|
|
59
|
+
setPhase('preprodUrl');
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
onSelect(option.value);
|
|
64
|
+
},
|
|
65
|
+
[onSelect, resetUrlState],
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const submitUrl = useCallback(() => {
|
|
69
|
+
if (phase !== 'preprodUrl') {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const trimmed = urlValue.trim();
|
|
74
|
+
if (!trimmed) {
|
|
75
|
+
setUrlError('URL is required');
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const parsed = new URL(trimmed);
|
|
81
|
+
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
|
82
|
+
throw new Error('invalid');
|
|
83
|
+
}
|
|
84
|
+
setUrlError(null);
|
|
85
|
+
onSelect(pendingMode, { targetUrl: parsed.toString() });
|
|
86
|
+
} catch {
|
|
87
|
+
setUrlError('Enter a valid URL starting with http:// or https://');
|
|
88
|
+
}
|
|
89
|
+
}, [onSelect, pendingMode, phase, urlValue]);
|
|
90
|
+
|
|
91
|
+
useInput((input, key) => {
|
|
92
|
+
const normalizedInput = input.toLowerCase();
|
|
93
|
+
if (normalizedInput === 'd' && !key.ctrl && !key.meta) {
|
|
94
|
+
onToggleDebug();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (phase !== 'preprodUrl') {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (key.escape) {
|
|
103
|
+
setPhase('list');
|
|
104
|
+
resetUrlState();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (key.return) {
|
|
109
|
+
submitUrl();
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
if (phase === 'preprodUrl') {
|
|
114
|
+
return (
|
|
115
|
+
<Box
|
|
116
|
+
width={frameWidth}
|
|
117
|
+
flexDirection="column"
|
|
118
|
+
borderStyle="round"
|
|
119
|
+
borderColor="cyan"
|
|
120
|
+
paddingX={2}
|
|
121
|
+
paddingY={1}
|
|
122
|
+
gap={1}
|
|
123
|
+
>
|
|
124
|
+
<Text color="cyan" bold>
|
|
125
|
+
Pre-production review
|
|
126
|
+
</Text>
|
|
127
|
+
<Text dimColor>Enter the full website URL. We will trigger ai.enki.si and scan every file here.</Text>
|
|
128
|
+
<Box flexDirection="column">
|
|
129
|
+
<Text dimColor>Target URL</Text>
|
|
130
|
+
<TextInput
|
|
131
|
+
value={urlValue}
|
|
132
|
+
onChange={(value) => {
|
|
133
|
+
setUrlValue(value);
|
|
134
|
+
if (urlError) {
|
|
135
|
+
setUrlError(null);
|
|
136
|
+
}
|
|
137
|
+
}}
|
|
138
|
+
placeholder="https://example.com"
|
|
139
|
+
focus
|
|
140
|
+
/>
|
|
141
|
+
</Box>
|
|
142
|
+
{urlError && (
|
|
143
|
+
<Text color="red">{urlError}</Text>
|
|
144
|
+
)}
|
|
145
|
+
<Box justifyContent="space-between">
|
|
146
|
+
<Text dimColor>Press Enter to start · Esc to go back</Text>
|
|
147
|
+
<Text dimColor>Debug: {debugEnabled ? 'On' : 'Off'} (press D)</Text>
|
|
148
|
+
</Box>
|
|
149
|
+
</Box>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
21
152
|
|
|
22
153
|
return (
|
|
23
154
|
<Box
|
|
@@ -29,23 +160,25 @@ export function ModeSelection({ onSelect }: ModeSelectionProps) {
|
|
|
29
160
|
paddingY={1}
|
|
30
161
|
gap={1}
|
|
31
162
|
>
|
|
32
|
-
<
|
|
33
|
-
|
|
34
|
-
|
|
163
|
+
<Box justifyContent="space-between" alignItems="center">
|
|
164
|
+
<Text color="green" bold>
|
|
165
|
+
Choose review mode
|
|
166
|
+
</Text>
|
|
167
|
+
<Text dimColor>Debug: {debugEnabled ? 'On' : 'Off'} (press D)</Text>
|
|
168
|
+
</Box>
|
|
35
169
|
<Text dimColor>Use arrow keys, press Enter to start.</Text>
|
|
36
170
|
<SelectInput
|
|
37
171
|
items={OPTIONS.map((option) => ({
|
|
38
172
|
label: option.label,
|
|
39
173
|
value: option.value,
|
|
40
174
|
}))}
|
|
41
|
-
|
|
175
|
+
onHighlight={(item) => setHighlighted(item.value as ReviewMode)}
|
|
176
|
+
onSelect={(item) => handleModeSelect(item.value as ReviewMode)}
|
|
42
177
|
/>
|
|
43
178
|
<Box flexDirection="column" marginTop={1}>
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
</Text>
|
|
48
|
-
))}
|
|
179
|
+
<Text dimColor>
|
|
180
|
+
· {OPTIONS.find((option) => option.value === highlighted)?.description}
|
|
181
|
+
</Text>
|
|
49
182
|
</Box>
|
|
50
183
|
</Box>
|
|
51
184
|
);
|
|
@@ -6,9 +6,18 @@ import { getInk, getInkSpinner } from '../inkModules';
|
|
|
6
6
|
interface ProgressScreenProps {
|
|
7
7
|
stage: ReviewStage;
|
|
8
8
|
filesFound?: number;
|
|
9
|
+
currentFile?: string;
|
|
10
|
+
currentIndex?: number;
|
|
11
|
+
totalFiles?: number;
|
|
12
|
+
uncommittedFiles?: number;
|
|
13
|
+
contextFiles?: number;
|
|
9
14
|
}
|
|
10
15
|
|
|
11
16
|
const STAGE_TEXT: Record<ReviewStage, { title: string; detail: string }> = {
|
|
17
|
+
preparing: {
|
|
18
|
+
title: 'Preparing run',
|
|
19
|
+
detail: 'Triggering website checks and configuring the reviewer',
|
|
20
|
+
},
|
|
12
21
|
collecting: {
|
|
13
22
|
title: 'Collecting files',
|
|
14
23
|
detail: 'Inspecting your working tree',
|
|
@@ -23,14 +32,45 @@ const STAGE_TEXT: Record<ReviewStage, { title: string; detail: string }> = {
|
|
|
23
32
|
},
|
|
24
33
|
};
|
|
25
34
|
|
|
26
|
-
export function ProgressScreen({
|
|
35
|
+
export function ProgressScreen({
|
|
36
|
+
stage,
|
|
37
|
+
filesFound,
|
|
38
|
+
currentFile,
|
|
39
|
+
currentIndex,
|
|
40
|
+
totalFiles,
|
|
41
|
+
uncommittedFiles,
|
|
42
|
+
contextFiles,
|
|
43
|
+
}: ProgressScreenProps) {
|
|
27
44
|
const { Box, Text } = getInk();
|
|
28
45
|
const Spinner = getInkSpinner();
|
|
29
46
|
const { frameWidth } = useLayout();
|
|
30
47
|
|
|
31
48
|
const meta = useMemo(() => STAGE_TEXT[stage], [stage]);
|
|
49
|
+
const base = typeof uncommittedFiles === 'number' ? uncommittedFiles : undefined;
|
|
50
|
+
const context = typeof contextFiles === 'number' ? contextFiles : undefined;
|
|
51
|
+
const totalBreakdown =
|
|
52
|
+
base !== undefined || context !== undefined
|
|
53
|
+
? `Queued: ${base ?? '?'} uncommitted${context ? ` + ${context} context` : ''}`
|
|
54
|
+
: '';
|
|
32
55
|
const fileStatus =
|
|
33
|
-
typeof filesFound === 'number' && filesFound >= 0
|
|
56
|
+
stage === 'collecting' && typeof filesFound === 'number' && filesFound >= 0
|
|
57
|
+
? `${filesFound} files queued`
|
|
58
|
+
: stage === 'collecting'
|
|
59
|
+
? 'Preparing file list…'
|
|
60
|
+
: '';
|
|
61
|
+
|
|
62
|
+
const showReviewingProgress =
|
|
63
|
+
stage === 'reviewing' &&
|
|
64
|
+
typeof currentIndex === 'number' &&
|
|
65
|
+
typeof totalFiles === 'number' &&
|
|
66
|
+
totalFiles > 0 &&
|
|
67
|
+
Boolean(currentFile);
|
|
68
|
+
|
|
69
|
+
const truncatedPath = useMemo(() => {
|
|
70
|
+
if (!currentFile) return '';
|
|
71
|
+
const availableWidth = Math.max(10, frameWidth - 10);
|
|
72
|
+
return truncateMiddle(currentFile, availableWidth);
|
|
73
|
+
}, [currentFile, frameWidth]);
|
|
34
74
|
|
|
35
75
|
return (
|
|
36
76
|
<Box
|
|
@@ -48,8 +88,23 @@ export function ProgressScreen({ stage, filesFound }: ProgressScreenProps) {
|
|
|
48
88
|
</Text>
|
|
49
89
|
</Box>
|
|
50
90
|
<Text dimColor>{meta.detail}</Text>
|
|
51
|
-
<Text dimColor>{fileStatus}</Text>
|
|
91
|
+
{fileStatus && <Text dimColor>{fileStatus}</Text>}
|
|
92
|
+
{totalBreakdown && <Text dimColor>{totalBreakdown}</Text>}
|
|
93
|
+
{showReviewingProgress && (
|
|
94
|
+
<Text dimColor>{`Reviewing (${currentIndex}/${totalFiles}): ${truncatedPath}`}</Text>
|
|
95
|
+
)}
|
|
52
96
|
</Box>
|
|
53
97
|
);
|
|
54
98
|
}
|
|
55
99
|
|
|
100
|
+
function truncateMiddle(value: string, maxLength: number): string {
|
|
101
|
+
if (value.length <= maxLength) {
|
|
102
|
+
return value;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const ellipsis = '…';
|
|
106
|
+
const keep = Math.max(1, Math.floor((maxLength - ellipsis.length) / 2));
|
|
107
|
+
const start = value.slice(0, keep);
|
|
108
|
+
const end = value.slice(value.length - keep);
|
|
109
|
+
return `${start}${ellipsis}${end}`;
|
|
110
|
+
}
|
|
@@ -5,9 +5,10 @@ import { getInk } from '../inkModules';
|
|
|
5
5
|
|
|
6
6
|
interface ResultsScreenProps {
|
|
7
7
|
result: ReviewResult;
|
|
8
|
+
debugLogPath?: string | null;
|
|
8
9
|
}
|
|
9
10
|
|
|
10
|
-
export function ResultsScreen({ result }: ResultsScreenProps) {
|
|
11
|
+
export function ResultsScreen({ result, debugLogPath }: ResultsScreenProps) {
|
|
11
12
|
const { Box, Text } = getInk();
|
|
12
13
|
const { frameWidth } = useLayout();
|
|
13
14
|
|
|
@@ -21,6 +22,14 @@ export function ResultsScreen({ result }: ResultsScreenProps) {
|
|
|
21
22
|
];
|
|
22
23
|
}, [result.totals]);
|
|
23
24
|
|
|
25
|
+
const scopeLine = useMemo(() => {
|
|
26
|
+
const breakdown = result.fileBreakdown;
|
|
27
|
+
if (!breakdown) return null;
|
|
28
|
+
return `Scope: ${breakdown.uncommitted} uncommitted + ${breakdown.context} context files`;
|
|
29
|
+
}, [result.fileBreakdown]);
|
|
30
|
+
|
|
31
|
+
const summaryText = result.reportSummary?.trim() || 'Summary unavailable. Open the saved report for details.';
|
|
32
|
+
|
|
24
33
|
return (
|
|
25
34
|
<Box
|
|
26
35
|
width={frameWidth}
|
|
@@ -35,15 +44,26 @@ export function ResultsScreen({ result }: ResultsScreenProps) {
|
|
|
35
44
|
Review complete for {result.repo}
|
|
36
45
|
</Text>
|
|
37
46
|
<Text dimColor>Duration: {result.duration}s — Files reviewed: {result.filesReviewed}</Text>
|
|
47
|
+
{scopeLine && <Text dimColor>{scopeLine}</Text>}
|
|
48
|
+
|
|
49
|
+
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1} paddingY={0}>
|
|
50
|
+
<Text color="cyan" bold>
|
|
51
|
+
LLM summary
|
|
52
|
+
</Text>
|
|
53
|
+
<Text>{summaryText}</Text>
|
|
54
|
+
</Box>
|
|
38
55
|
|
|
39
56
|
<Box flexDirection="column" marginTop={1}>
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
57
|
+
<Text bold>Severity totals</Text>
|
|
58
|
+
<Box flexDirection="row" flexWrap="wrap">
|
|
59
|
+
{severityRows.map((row) => (
|
|
60
|
+
<Box key={row.label} width={24}>
|
|
61
|
+
<Text>
|
|
62
|
+
{row.label}: {row.value}
|
|
63
|
+
</Text>
|
|
64
|
+
</Box>
|
|
65
|
+
))}
|
|
66
|
+
</Box>
|
|
47
67
|
</Box>
|
|
48
68
|
|
|
49
69
|
{result.findings.length === 0 ? (
|
|
@@ -52,8 +72,9 @@ export function ResultsScreen({ result }: ResultsScreenProps) {
|
|
|
52
72
|
<Box flexDirection="column" marginTop={1}>
|
|
53
73
|
<Text bold>Sample findings</Text>
|
|
54
74
|
{result.findings.slice(0, 5).map((finding, index) => (
|
|
55
|
-
<Text key={`${finding.filePath}-${index}`} dimColor>
|
|
56
|
-
· {finding.filePath ?? finding.file ?? 'unknown'}
|
|
75
|
+
<Text key={`${finding.filePath ?? finding.file ?? 'unknown'}-${index}`} dimColor>
|
|
76
|
+
· {finding.filePath ?? finding.file ?? 'unknown'}{' '}
|
|
77
|
+
{finding.priority ? `(${finding.priority})` : ''}
|
|
57
78
|
</Text>
|
|
58
79
|
))}
|
|
59
80
|
{result.findings.length > 5 && (
|
|
@@ -68,6 +89,12 @@ export function ResultsScreen({ result }: ResultsScreenProps) {
|
|
|
68
89
|
</Box>
|
|
69
90
|
)}
|
|
70
91
|
|
|
92
|
+
{debugLogPath && (
|
|
93
|
+
<Box marginTop={1}>
|
|
94
|
+
<Text dimColor>Debug logs saved to: {debugLogPath}</Text>
|
|
95
|
+
</Box>
|
|
96
|
+
)}
|
|
97
|
+
|
|
71
98
|
<Box marginTop={1}>
|
|
72
99
|
<Text dimColor>Press Enter to return to mode selection.</Text>
|
|
73
100
|
</Box>
|
package/src/types/review.ts
CHANGED
|
@@ -97,6 +97,10 @@ export interface ReviewOptions {
|
|
|
97
97
|
uiLanguage?: string;
|
|
98
98
|
/** Target file or directory (used internally) */
|
|
99
99
|
target?: string;
|
|
100
|
+
/** Explicit list of files to review (bypasses discovery) */
|
|
101
|
+
targetFileList?: string[];
|
|
102
|
+
/** Runtime context information for prompt enrichment */
|
|
103
|
+
runContext?: RunContext;
|
|
100
104
|
/** Schema instructions for structured output */
|
|
101
105
|
schemaInstructions?: string;
|
|
102
106
|
/** Examples for prompts */
|
|
@@ -277,6 +281,20 @@ export interface ReviewResult {
|
|
|
277
281
|
files?: FileInfo[];
|
|
278
282
|
}
|
|
279
283
|
|
|
284
|
+
export interface RunContext {
|
|
285
|
+
repoName: string;
|
|
286
|
+
reviewMode?: string;
|
|
287
|
+
totalFiles?: number;
|
|
288
|
+
groupIndex?: number;
|
|
289
|
+
groupCount?: number;
|
|
290
|
+
groupLabel?: string;
|
|
291
|
+
groupFiles?: string[];
|
|
292
|
+
docFiles?: string[];
|
|
293
|
+
uncommittedFiles?: number;
|
|
294
|
+
contextFiles?: number;
|
|
295
|
+
contextDocs?: Array<{ path: string; snippet: string }>;
|
|
296
|
+
}
|
|
297
|
+
|
|
280
298
|
/**
|
|
281
299
|
* Cost information for a single pass in a multi-pass review
|
|
282
300
|
*/
|
package/src/utils/logger.ts
CHANGED
|
@@ -25,6 +25,9 @@ const LOG_LEVEL_MAP: Record<string, LogLevel> = {
|
|
|
25
25
|
none: LogLevel.NONE,
|
|
26
26
|
};
|
|
27
27
|
|
|
28
|
+
import { createWriteStream, mkdirSync, type WriteStream } from 'node:fs';
|
|
29
|
+
import { dirname } from 'node:path';
|
|
30
|
+
|
|
28
31
|
// ANSI color codes for terminal output
|
|
29
32
|
const COLORS = {
|
|
30
33
|
reset: '\x1b[0m',
|
|
@@ -39,6 +42,8 @@ const COLORS = {
|
|
|
39
42
|
|
|
40
43
|
// Track if we're initializing to avoid circular dependencies
|
|
41
44
|
const isInitializing = false;
|
|
45
|
+
let logFileStream: WriteStream | null = null;
|
|
46
|
+
let suppressConsoleOutput = false;
|
|
42
47
|
|
|
43
48
|
// Get the current log level from environment variables
|
|
44
49
|
function getCurrentLogLevel(): LogLevel {
|
|
@@ -131,19 +136,54 @@ export function getLogLevel(): LogLevel {
|
|
|
131
136
|
return currentLogLevel;
|
|
132
137
|
}
|
|
133
138
|
|
|
134
|
-
|
|
135
|
-
* Format a log message with timestamp and level
|
|
136
|
-
* @param level The log level
|
|
137
|
-
* @param message The message to log
|
|
138
|
-
* @returns The formatted message
|
|
139
|
-
*/
|
|
140
|
-
function formatLogMessage(level: string, message: string): string {
|
|
141
|
-
const timestamp = new Date().toISOString();
|
|
139
|
+
function formatConsoleMessage(level: string, message: string, timestamp: string): string {
|
|
142
140
|
const levelUpper = level.toUpperCase().padEnd(5);
|
|
143
|
-
|
|
144
141
|
return `${COLORS.time}[${timestamp}]${COLORS.reset} ${COLORS[level as keyof typeof COLORS]}${levelUpper}${COLORS.reset} ${message}`;
|
|
145
142
|
}
|
|
146
143
|
|
|
144
|
+
function formatFileMessage(level: string, message: string, timestamp: string, args: any[]): string {
|
|
145
|
+
const levelUpper = level.toUpperCase().padEnd(5);
|
|
146
|
+
const extras = args.length ? ` ${args.map((arg) => serializeLogArg(arg)).join(' ')}` : '';
|
|
147
|
+
return `[${timestamp}] ${levelUpper} ${message}${extras}`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function serializeLogArg(arg: unknown): string {
|
|
151
|
+
if (typeof arg === 'string') {
|
|
152
|
+
return arg;
|
|
153
|
+
}
|
|
154
|
+
if (arg instanceof Error) {
|
|
155
|
+
return arg.stack || arg.message;
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
return JSON.stringify(arg);
|
|
159
|
+
} catch {
|
|
160
|
+
return String(arg);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function enableFileLogging(filePath: string, options?: { suppressConsole?: boolean }): void {
|
|
165
|
+
try {
|
|
166
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
167
|
+
if (logFileStream) {
|
|
168
|
+
logFileStream.end();
|
|
169
|
+
}
|
|
170
|
+
logFileStream = createWriteStream(filePath, { flags: 'a' });
|
|
171
|
+
suppressConsoleOutput = options?.suppressConsole ?? true;
|
|
172
|
+
} catch (error) {
|
|
173
|
+
console.error('Failed to enable file logging:', error);
|
|
174
|
+
logFileStream = null;
|
|
175
|
+
suppressConsoleOutput = false;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function disableFileLogging(): void {
|
|
180
|
+
if (logFileStream) {
|
|
181
|
+
logFileStream.end();
|
|
182
|
+
logFileStream = null;
|
|
183
|
+
}
|
|
184
|
+
suppressConsoleOutput = false;
|
|
185
|
+
}
|
|
186
|
+
|
|
147
187
|
/**
|
|
148
188
|
* Log a message if the current log level allows it
|
|
149
189
|
* @param level The log level
|
|
@@ -151,9 +191,16 @@ function formatLogMessage(level: string, message: string): string {
|
|
|
151
191
|
* @param args Additional arguments to log
|
|
152
192
|
*/
|
|
153
193
|
function log(level: LogLevel, levelName: string, message: string, ...args: any[]): void {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
194
|
+
const timestamp = new Date().toISOString();
|
|
195
|
+
|
|
196
|
+
if (logFileStream) {
|
|
197
|
+
logFileStream.write(`${formatFileMessage(levelName, message, timestamp, args)}\n`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const shouldLogToConsole = !suppressConsoleOutput && level >= currentLogLevel;
|
|
201
|
+
|
|
202
|
+
if (shouldLogToConsole) {
|
|
203
|
+
const formattedMessage = formatConsoleMessage(levelName, message, timestamp);
|
|
157
204
|
|
|
158
205
|
switch (level) {
|
|
159
206
|
case LogLevel.DEBUG:
|
|
@@ -168,9 +215,10 @@ function log(level: LogLevel, levelName: string, message: string, ...args: any[]
|
|
|
168
215
|
case LogLevel.ERROR:
|
|
169
216
|
console.error(formattedMessage, ...args);
|
|
170
217
|
break;
|
|
218
|
+
default:
|
|
219
|
+
break;
|
|
171
220
|
}
|
|
172
|
-
} else if (level === LogLevel.DEBUG && process.argv.includes('--trace-logger')) {
|
|
173
|
-
// Only show debug suppression messages when explicitly requested
|
|
221
|
+
} else if (!suppressConsoleOutput && level === LogLevel.DEBUG && process.argv.includes('--trace-logger')) {
|
|
174
222
|
console.error(
|
|
175
223
|
`Suppressing DEBUG log because currentLogLevel=${currentLogLevel}, message was: ${message}`,
|
|
176
224
|
);
|
|
@@ -237,4 +285,6 @@ export default {
|
|
|
237
285
|
getLogLevel,
|
|
238
286
|
createLogger,
|
|
239
287
|
LogLevel,
|
|
288
|
+
enableFileLogging,
|
|
289
|
+
disableFileLogging,
|
|
240
290
|
};
|