cadr-cli 2.0.0 → 2.0.2
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/adr/adr.d.ts +17 -0
- package/dist/adr/adr.d.ts.map +1 -0
- package/dist/{adr.js → adr/adr.js} +4 -44
- package/dist/adr/adr.js.map +1 -0
- package/dist/adr/adr.test.d.ts +5 -0
- package/dist/{adr.test.d.ts.map → adr/adr.test.d.ts.map} +1 -1
- package/dist/{adr.test.js → adr/adr.test.js} +0 -14
- package/dist/adr/adr.test.js.map +1 -0
- package/dist/adr/index.d.ts +2 -0
- package/dist/adr/index.d.ts.map +1 -0
- package/dist/adr/index.js +18 -0
- package/dist/adr/index.js.map +1 -0
- package/dist/analysis/analysis.orchestrator.d.ts +14 -0
- package/dist/analysis/analysis.orchestrator.d.ts.map +1 -0
- package/dist/analysis/analysis.orchestrator.js +175 -0
- package/dist/analysis/analysis.orchestrator.js.map +1 -0
- package/dist/analysis/analysis.orchestrator.test.d.ts +2 -0
- package/dist/analysis/analysis.orchestrator.test.d.ts.map +1 -0
- package/dist/analysis/analysis.orchestrator.test.js +177 -0
- package/dist/analysis/analysis.orchestrator.test.js.map +1 -0
- package/dist/analysis/strategies/git-strategy.d.ts +22 -0
- package/dist/analysis/strategies/git-strategy.d.ts.map +1 -0
- package/dist/analysis/strategies/git-strategy.js +114 -0
- package/dist/analysis/strategies/git-strategy.js.map +1 -0
- package/dist/analysis/strategies/git-strategy.test.d.ts +2 -0
- package/dist/analysis/strategies/git-strategy.test.d.ts.map +1 -0
- package/dist/analysis/strategies/git-strategy.test.js +147 -0
- package/dist/analysis/strategies/git-strategy.test.js.map +1 -0
- package/dist/commands/analyze.js +3 -3
- package/dist/commands/analyze.js.map +1 -1
- package/dist/commands/analyze.test.d.ts +2 -0
- package/dist/commands/analyze.test.d.ts.map +1 -0
- package/dist/commands/analyze.test.js +70 -0
- package/dist/commands/analyze.test.js.map +1 -0
- package/dist/commands/init.test.js +128 -2
- package/dist/commands/init.test.js.map +1 -1
- package/dist/config.test.js +167 -0
- package/dist/config.test.js.map +1 -1
- package/dist/git/git.errors.d.ts +6 -0
- package/dist/git/git.errors.d.ts.map +1 -0
- package/dist/git/git.errors.js +15 -0
- package/dist/git/git.errors.js.map +1 -0
- package/dist/git/git.errors.test.d.ts +2 -0
- package/dist/git/git.errors.test.d.ts.map +1 -0
- package/dist/git/git.errors.test.js +34 -0
- package/dist/git/git.errors.test.js.map +1 -0
- package/dist/git/git.operations.d.ts +12 -0
- package/dist/git/git.operations.d.ts.map +1 -0
- package/dist/git/git.operations.js +64 -0
- package/dist/git/git.operations.js.map +1 -0
- package/dist/git/git.operations.test.d.ts +2 -0
- package/dist/git/git.operations.test.d.ts.map +1 -0
- package/dist/git/git.operations.test.js +164 -0
- package/dist/git/git.operations.test.js.map +1 -0
- package/dist/git/index.d.ts +4 -0
- package/dist/git/index.d.ts.map +1 -0
- package/dist/git/index.js +19 -0
- package/dist/git/index.js.map +1 -0
- package/dist/llm/index.d.ts +3 -0
- package/dist/llm/index.d.ts.map +1 -0
- package/dist/llm/index.js +19 -0
- package/dist/llm/index.js.map +1 -0
- package/dist/llm/llm.d.ts +35 -0
- package/dist/llm/llm.d.ts.map +1 -0
- package/dist/{llm.js → llm/llm.js} +16 -58
- package/dist/llm/llm.js.map +1 -0
- package/dist/{llm.test.d.ts.map → llm/llm.test.d.ts.map} +1 -1
- package/dist/llm/llm.test.js +224 -0
- package/dist/llm/llm.test.js.map +1 -0
- package/dist/{prompts.d.ts → llm/prompts.d.ts} +1 -38
- package/dist/llm/prompts.d.ts.map +1 -0
- package/dist/{prompts.js → llm/prompts.js} +9 -54
- package/dist/llm/prompts.js.map +1 -0
- package/dist/llm/response-parser.d.ts +9 -0
- package/dist/llm/response-parser.d.ts.map +1 -0
- package/dist/llm/response-parser.js +67 -0
- package/dist/llm/response-parser.js.map +1 -0
- package/dist/llm/response-parser.test.d.ts +2 -0
- package/dist/llm/response-parser.test.d.ts.map +1 -0
- package/dist/llm/response-parser.test.js +134 -0
- package/dist/llm/response-parser.test.js.map +1 -0
- package/dist/presenters/console-presenter.d.ts +35 -0
- package/dist/presenters/console-presenter.d.ts.map +1 -0
- package/dist/presenters/console-presenter.js +114 -0
- package/dist/presenters/console-presenter.js.map +1 -0
- package/dist/presenters/console-presenter.test.d.ts +2 -0
- package/dist/presenters/console-presenter.test.d.ts.map +1 -0
- package/dist/presenters/console-presenter.test.js +227 -0
- package/dist/presenters/console-presenter.test.js.map +1 -0
- package/dist/version.test.d.ts +1 -2
- package/dist/version.test.d.ts.map +1 -1
- package/dist/version.test.js +29 -16
- package/dist/version.test.js.map +1 -1
- package/package.json +1 -1
- package/src/{adr.test.ts → adr/adr.test.ts} +10 -23
- package/src/{adr.ts → adr/adr.ts} +7 -48
- package/src/adr/index.ts +1 -0
- package/src/analysis/analysis.orchestrator.test.ts +237 -0
- package/src/analysis/analysis.orchestrator.ts +175 -0
- package/src/analysis/strategies/git-strategy.test.ts +210 -0
- package/src/analysis/strategies/git-strategy.ts +106 -0
- package/src/commands/analyze.test.ts +91 -0
- package/src/commands/analyze.ts +8 -9
- package/src/commands/init.test.ts +200 -5
- package/src/config.test.ts +232 -2
- package/src/git/git.errors.test.ts +43 -0
- package/src/git/git.errors.ts +10 -0
- package/src/git/git.operations.test.ts +222 -0
- package/src/git/git.operations.ts +85 -0
- package/src/git/index.ts +3 -0
- package/src/llm/index.ts +2 -0
- package/src/llm/llm.test.ts +315 -0
- package/src/{llm.ts → llm/llm.ts} +46 -107
- package/src/{prompts.ts → llm/prompts.ts} +30 -72
- package/src/llm/response-parser.test.ts +170 -0
- package/src/llm/response-parser.ts +90 -0
- package/src/presenters/console-presenter.test.ts +259 -0
- package/src/presenters/console-presenter.ts +152 -0
- package/src/version.test.ts +30 -16
- package/dist/adr.d.ts +0 -50
- package/dist/adr.d.ts.map +0 -1
- package/dist/adr.js.map +0 -1
- package/dist/adr.test.d.ts +0 -8
- package/dist/adr.test.js.map +0 -1
- package/dist/analysis.d.ts +0 -24
- package/dist/analysis.d.ts.map +0 -1
- package/dist/analysis.js +0 -281
- package/dist/analysis.js.map +0 -1
- package/dist/analysis.test.d.ts +0 -8
- package/dist/analysis.test.d.ts.map +0 -1
- package/dist/analysis.test.js +0 -351
- package/dist/analysis.test.js.map +0 -1
- package/dist/git.d.ts +0 -54
- package/dist/git.d.ts.map +0 -1
- package/dist/git.js +0 -204
- package/dist/git.js.map +0 -1
- package/dist/llm.d.ts +0 -73
- package/dist/llm.d.ts.map +0 -1
- package/dist/llm.js.map +0 -1
- package/dist/llm.test.js +0 -592
- package/dist/llm.test.js.map +0 -1
- package/dist/prompts.d.ts.map +0 -1
- package/dist/prompts.js.map +0 -1
- package/dist/prompts.test.d.ts +0 -2
- package/dist/prompts.test.d.ts.map +0 -1
- package/dist/prompts.test.js +0 -427
- package/dist/prompts.test.js.map +0 -1
- package/src/analysis.test.ts +0 -396
- package/src/analysis.ts +0 -262
- package/src/git.ts +0 -300
- package/src/llm.test.ts +0 -701
- package/src/prompts.test.ts +0 -515
- /package/dist/{llm.test.d.ts → llm/llm.test.d.ts} +0 -0
|
@@ -1,18 +1,5 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Prompts Module
|
|
3
|
-
*
|
|
4
|
-
* Contains versioned prompt templates for LLM analysis and ADR generation.
|
|
5
|
-
* Follows the constitution requirement for versioned prompts.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
1
|
import * as readline from 'readline';
|
|
9
2
|
|
|
10
|
-
/**
|
|
11
|
-
* Version 1 of the analysis prompt template.
|
|
12
|
-
*
|
|
13
|
-
* This prompt is designed to analyze code changes for architectural significance.
|
|
14
|
-
* It uses specific criteria for determining significance and enforces strict JSON output.
|
|
15
|
-
*/
|
|
16
3
|
export const ANALYSIS_PROMPT_V1 = `
|
|
17
4
|
You are an expert principal engineer and software architect acting as a meticulous code reviewer. Your sole task is to determine if the provided git diff represents an architecturally significant change that warrants an Architectural Decision Record (ADR).
|
|
18
5
|
|
|
@@ -35,34 +22,6 @@ Respond ONLY with a single, minified JSON object with no preamble, no markdown,
|
|
|
35
22
|
The "reason" should be a concise, one-sentence explanation for your decision, suitable for showing to a developer. If the change is not significant, the reason should be an empty string.
|
|
36
23
|
`;
|
|
37
24
|
|
|
38
|
-
/**
|
|
39
|
-
* Formats a prompt template by replacing placeholders with actual data.
|
|
40
|
-
*
|
|
41
|
-
* @param template - The prompt template with placeholders
|
|
42
|
-
* @param data - Object containing file_paths and diff_content
|
|
43
|
-
* @returns Formatted prompt with placeholders replaced
|
|
44
|
-
*/
|
|
45
|
-
export function formatPrompt(
|
|
46
|
-
template: string,
|
|
47
|
-
data: { file_paths: string[], diff_content: string }
|
|
48
|
-
): string {
|
|
49
|
-
// Format file paths as a readable list
|
|
50
|
-
const formattedFilePaths = data.file_paths.length > 0
|
|
51
|
-
? data.file_paths.join('\n')
|
|
52
|
-
: 'No files';
|
|
53
|
-
|
|
54
|
-
// Replace placeholders with actual data
|
|
55
|
-
return template
|
|
56
|
-
.replace('{file_paths}', formattedFilePaths)
|
|
57
|
-
.replace('{diff_content}', data.diff_content);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Version 1 of the generation prompt template.
|
|
62
|
-
*
|
|
63
|
-
* This prompt generates ADRs following the MADR (Markdown Architectural Decision Records) format.
|
|
64
|
-
* MADR is a lean template for documenting architectural decisions in a structured way.
|
|
65
|
-
*/
|
|
66
25
|
export const GENERATION_PROMPT_V1 = `
|
|
67
26
|
You are an expert software architect. Your task is to write a comprehensive Architectural Decision Record (ADR) following the MADR (Markdown Architectural Decision Records) template.
|
|
68
27
|
|
|
@@ -123,33 +82,30 @@ IMPORTANT INSTRUCTIONS:
|
|
|
123
82
|
Respond ONLY with the markdown content of the ADR. Do not include any preamble, explanation, or markdown code fences. Start directly with the # title.
|
|
124
83
|
`;
|
|
125
84
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
* @param data - Object containing file_paths and diff_content
|
|
130
|
-
* @returns Formatted generation prompt
|
|
131
|
-
*/
|
|
132
|
-
export function formatGenerationPrompt(
|
|
133
|
-
data: { file_paths: string[], diff_content: string }
|
|
85
|
+
export function formatPrompt(
|
|
86
|
+
template: string,
|
|
87
|
+
data: { file_paths: string[]; diff_content: string }
|
|
134
88
|
): string {
|
|
135
|
-
const formattedFilePaths = data.file_paths.length > 0
|
|
136
|
-
|
|
137
|
-
|
|
89
|
+
const formattedFilePaths = data.file_paths.length > 0 ? data.file_paths.join('\n') : 'No files';
|
|
90
|
+
|
|
91
|
+
return template
|
|
92
|
+
.replace('{file_paths}', formattedFilePaths)
|
|
93
|
+
.replace('{diff_content}', data.diff_content);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function formatGenerationPrompt(data: {
|
|
97
|
+
file_paths: string[];
|
|
98
|
+
diff_content: string;
|
|
99
|
+
}): string {
|
|
100
|
+
const formattedFilePaths = data.file_paths.length > 0 ? data.file_paths.join('\n') : 'No files';
|
|
138
101
|
|
|
139
102
|
const currentDate = new Date().toISOString().split('T')[0];
|
|
140
103
|
|
|
141
|
-
return GENERATION_PROMPT_V1
|
|
142
|
-
.replace('{file_paths}', formattedFilePaths)
|
|
104
|
+
return GENERATION_PROMPT_V1.replace('{file_paths}', formattedFilePaths)
|
|
143
105
|
.replace('{diff_content}', data.diff_content)
|
|
144
106
|
.replace('{current_date}', currentDate);
|
|
145
107
|
}
|
|
146
108
|
|
|
147
|
-
/**
|
|
148
|
-
* Prompt user for ADR generation confirmation
|
|
149
|
-
*
|
|
150
|
-
* @param reason - The reason why this change is architecturally significant
|
|
151
|
-
* @returns Promise<boolean> - true if user wants to generate, false otherwise
|
|
152
|
-
*/
|
|
153
109
|
export async function promptForGeneration(reason: string): Promise<boolean> {
|
|
154
110
|
return new Promise((resolve) => {
|
|
155
111
|
const rl = readline.createInterface({
|
|
@@ -157,18 +113,20 @@ export async function promptForGeneration(reason: string): Promise<boolean> {
|
|
|
157
113
|
output: process.stdout,
|
|
158
114
|
});
|
|
159
115
|
|
|
160
|
-
|
|
116
|
+
/* eslint-disable-next-line no-console */
|
|
161
117
|
console.log(`\n💭 ${reason}\n`);
|
|
162
|
-
|
|
163
|
-
rl.question(
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
118
|
+
|
|
119
|
+
rl.question(
|
|
120
|
+
'📝 Would you like to generate an ADR for this change? (Press ENTER or type "yes" to confirm, "no" to skip): ',
|
|
121
|
+
(answer) => {
|
|
122
|
+
rl.close();
|
|
123
|
+
|
|
124
|
+
const normalized = answer.trim().toLowerCase();
|
|
125
|
+
|
|
126
|
+
const confirmed = normalized === '' || normalized === 'y' || normalized === 'yes';
|
|
127
|
+
|
|
128
|
+
resolve(confirmed);
|
|
129
|
+
}
|
|
130
|
+
);
|
|
173
131
|
});
|
|
174
132
|
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
jest.mock('../logger', () => ({
|
|
2
|
+
loggerInstance: {
|
|
3
|
+
info: jest.fn(),
|
|
4
|
+
warn: jest.fn(),
|
|
5
|
+
error: jest.fn(),
|
|
6
|
+
debug: jest.fn(),
|
|
7
|
+
},
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
parseAnalysisResponse,
|
|
12
|
+
extractTitleFromMarkdown,
|
|
13
|
+
parseLLMResponse,
|
|
14
|
+
} from './response-parser';
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
jest.clearAllMocks();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
jest.restoreAllMocks();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('parseAnalysisResponse', () => {
|
|
25
|
+
it('should parse a clean JSON string', () => {
|
|
26
|
+
const input = JSON.stringify({ is_significant: true, reason: 'big change' });
|
|
27
|
+
const result = parseAnalysisResponse(input);
|
|
28
|
+
expect(result).toEqual({ is_significant: true, reason: 'big change' });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should extract JSON wrapped in ```json code block', () => {
|
|
32
|
+
const input = '```json\n{"is_significant": true, "reason": "refactored module"}\n```';
|
|
33
|
+
const result = parseAnalysisResponse(input);
|
|
34
|
+
expect(result).toEqual({ is_significant: true, reason: 'refactored module' });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('should extract JSON wrapped in ``` code block without language', () => {
|
|
38
|
+
const input = '```\n{"is_significant": true, "reason": "new feature"}\n```';
|
|
39
|
+
const result = parseAnalysisResponse(input);
|
|
40
|
+
expect(result).toEqual({ is_significant: true, reason: 'new feature' });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('should extract JSON embedded in surrounding text', () => {
|
|
44
|
+
const input = 'Here is the analysis:\n{"is_significant": false, "reason": "minor fix"}\nEnd of response.';
|
|
45
|
+
const result = parseAnalysisResponse(input);
|
|
46
|
+
expect(result).toEqual({ is_significant: false, reason: 'minor fix' });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should parse is_significant: false with a reason', () => {
|
|
50
|
+
const input = JSON.stringify({ is_significant: false, reason: 'no impact' });
|
|
51
|
+
const result = parseAnalysisResponse(input);
|
|
52
|
+
expect(result).toEqual({ is_significant: false, reason: 'no impact' });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should include confidence when within [0, 1]', () => {
|
|
56
|
+
const input = JSON.stringify({ is_significant: true, reason: 'important', confidence: 0.85 });
|
|
57
|
+
const result = parseAnalysisResponse(input);
|
|
58
|
+
expect(result).toEqual({ is_significant: true, reason: 'important', confidence: 0.85 });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should not include confidence when out of range (> 1)', () => {
|
|
62
|
+
const input = JSON.stringify({ is_significant: true, reason: 'important', confidence: 1.5 });
|
|
63
|
+
const result = parseAnalysisResponse(input);
|
|
64
|
+
expect(result).toEqual({ is_significant: true, reason: 'important' });
|
|
65
|
+
expect(result.confidence).toBeUndefined();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('should not include confidence when it is not a number', () => {
|
|
69
|
+
const input = JSON.stringify({ is_significant: true, reason: 'important', confidence: 'high' });
|
|
70
|
+
const result = parseAnalysisResponse(input);
|
|
71
|
+
expect(result).toEqual({ is_significant: true, reason: 'important' });
|
|
72
|
+
expect(result.confidence).toBeUndefined();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should throw when is_significant is true but reason is empty', () => {
|
|
76
|
+
const input = JSON.stringify({ is_significant: true, reason: '' });
|
|
77
|
+
expect(() => parseAnalysisResponse(input)).toThrow(
|
|
78
|
+
'LLM indicated significant change but provided no reason'
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should throw when is_significant is not a boolean', () => {
|
|
83
|
+
const input = JSON.stringify({ is_significant: 'yes', reason: 'something' });
|
|
84
|
+
expect(() => parseAnalysisResponse(input)).toThrow('Invalid response format');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('should throw when reason is not a string', () => {
|
|
88
|
+
const input = JSON.stringify({ is_significant: true, reason: 123 });
|
|
89
|
+
expect(() => parseAnalysisResponse(input)).toThrow('Invalid response format');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('should throw on invalid JSON', () => {
|
|
93
|
+
const input = 'not json at all {{{';
|
|
94
|
+
expect(() => parseAnalysisResponse(input)).toThrow();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should throw on empty string', () => {
|
|
98
|
+
expect(() => parseAnalysisResponse('')).toThrow();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('extractTitleFromMarkdown', () => {
|
|
103
|
+
it('should extract h1 title from markdown', () => {
|
|
104
|
+
const input = '# My Decision Title\n\nSome content here.';
|
|
105
|
+
expect(extractTitleFromMarkdown(input)).toBe('My Decision Title');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('should return "Untitled Decision" when only h2 is present', () => {
|
|
109
|
+
const input = '## Second level\n\nSome content.';
|
|
110
|
+
expect(extractTitleFromMarkdown(input)).toBe('Untitled Decision');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('should return "Untitled Decision" for empty string', () => {
|
|
114
|
+
expect(extractTitleFromMarkdown('')).toBe('Untitled Decision');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should extract h1 from content wrapped in ```markdown code block', () => {
|
|
118
|
+
const input = '```markdown\n# Wrapped Title\n\nBody text.\n```';
|
|
119
|
+
expect(extractTitleFromMarkdown(input)).toBe('Wrapped Title');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('should trim trailing whitespace from the title', () => {
|
|
123
|
+
const input = '# Title With Spaces \n\nContent.';
|
|
124
|
+
expect(extractTitleFromMarkdown(input)).toBe('Title With Spaces');
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('parseLLMResponse', () => {
|
|
129
|
+
it('should return validator result for valid JSON', () => {
|
|
130
|
+
const input = JSON.stringify({ key: 'value' });
|
|
131
|
+
const validator = (parsed: unknown) => parsed as { key: string };
|
|
132
|
+
const result = parseLLMResponse(input, validator);
|
|
133
|
+
expect(result).toEqual({ key: 'value' });
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('should rethrow with descriptive message when validator throws', () => {
|
|
137
|
+
const input = JSON.stringify({ key: 'value' });
|
|
138
|
+
const validator = (): never => {
|
|
139
|
+
throw new Error('validation failed');
|
|
140
|
+
};
|
|
141
|
+
expect(() => parseLLMResponse(input, validator)).toThrow(
|
|
142
|
+
'Failed to parse LLM response as JSON'
|
|
143
|
+
);
|
|
144
|
+
expect(() => parseLLMResponse(input, validator)).toThrow(
|
|
145
|
+
expect.objectContaining({
|
|
146
|
+
message: expect.stringContaining(input.substring(0, 20)),
|
|
147
|
+
})
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('should rethrow with descriptive message on invalid JSON', () => {
|
|
152
|
+
const input = 'totally not json';
|
|
153
|
+
const validator = (parsed: unknown) => parsed;
|
|
154
|
+
expect(() => parseLLMResponse(input, validator)).toThrow(
|
|
155
|
+
'Failed to parse LLM response as JSON'
|
|
156
|
+
);
|
|
157
|
+
expect(() => parseLLMResponse(input, validator)).toThrow(
|
|
158
|
+
expect.objectContaining({
|
|
159
|
+
message: expect.stringContaining('totally not json'),
|
|
160
|
+
})
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('should strip code block wrapping before parsing', () => {
|
|
165
|
+
const input = '```json\n{"status": "ok"}\n```';
|
|
166
|
+
const validator = (parsed: unknown) => parsed as { status: string };
|
|
167
|
+
const result = parseLLMResponse(input, validator);
|
|
168
|
+
expect(result).toEqual({ status: 'ok' });
|
|
169
|
+
});
|
|
170
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { loggerInstance as logger } from '../logger';
|
|
2
|
+
|
|
3
|
+
export interface ParsedAnalysisResponse {
|
|
4
|
+
is_significant: boolean;
|
|
5
|
+
reason: string;
|
|
6
|
+
confidence?: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function parseAnalysisResponse(responseContent: string): ParsedAnalysisResponse {
|
|
10
|
+
let jsonContent = responseContent.trim();
|
|
11
|
+
|
|
12
|
+
const codeBlockMatch = jsonContent.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
|
|
13
|
+
if (codeBlockMatch) {
|
|
14
|
+
jsonContent = codeBlockMatch[1].trim();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const jsonMatch = jsonContent.match(/\{[\s\S]*\}/);
|
|
18
|
+
if (jsonMatch) {
|
|
19
|
+
jsonContent = jsonMatch[0];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const parsedResponse = JSON.parse(jsonContent);
|
|
23
|
+
|
|
24
|
+
if (
|
|
25
|
+
typeof parsedResponse.is_significant !== 'boolean' ||
|
|
26
|
+
typeof parsedResponse.reason !== 'string'
|
|
27
|
+
) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
`Invalid response format. Expected {is_significant: boolean, reason: string}, got: ${JSON.stringify(parsedResponse).substring(0, 150)}...`
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (parsedResponse.is_significant && !parsedResponse.reason) {
|
|
34
|
+
throw new Error('LLM indicated significant change but provided no reason');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const result: ParsedAnalysisResponse = {
|
|
38
|
+
is_significant: parsedResponse.is_significant,
|
|
39
|
+
reason: parsedResponse.reason,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
if (
|
|
43
|
+
typeof parsedResponse.confidence === 'number' &&
|
|
44
|
+
parsedResponse.confidence >= 0 &&
|
|
45
|
+
parsedResponse.confidence <= 1
|
|
46
|
+
) {
|
|
47
|
+
result.confidence = parsedResponse.confidence;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return result;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function extractTitleFromMarkdown(content: string): string {
|
|
54
|
+
let cleanedContent = content.trim();
|
|
55
|
+
|
|
56
|
+
const codeBlockMatch = cleanedContent.match(/```(?:markdown|md)?\s*\n?([\s\S]*?)\n?```/);
|
|
57
|
+
if (codeBlockMatch) {
|
|
58
|
+
cleanedContent = codeBlockMatch[1].trim();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const titleMatch = cleanedContent.match(/^#\s+(.+)$/m);
|
|
62
|
+
return titleMatch ? titleMatch[1].trim() : 'Untitled Decision';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function parseLLMResponse<T>(responseContent: string, validator: (parsed: unknown) => T): T {
|
|
66
|
+
try {
|
|
67
|
+
let jsonContent = responseContent.trim();
|
|
68
|
+
|
|
69
|
+
const codeBlockMatch = jsonContent.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
|
|
70
|
+
if (codeBlockMatch) {
|
|
71
|
+
jsonContent = codeBlockMatch[1].trim();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const jsonMatch = jsonContent.match(/\{[\s\S]*\}/);
|
|
75
|
+
if (jsonMatch) {
|
|
76
|
+
jsonContent = jsonMatch[0];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const parsed = JSON.parse(jsonContent);
|
|
80
|
+
return validator(parsed);
|
|
81
|
+
} catch (parseError) {
|
|
82
|
+
logger.warn('Failed to parse LLM response as JSON', {
|
|
83
|
+
error: parseError,
|
|
84
|
+
response: responseContent,
|
|
85
|
+
});
|
|
86
|
+
throw new Error(
|
|
87
|
+
`Failed to parse LLM response as JSON. Response was:\n${responseContent.substring(0, 200)}...`
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { ConsolePresenter, presenter, AnalysisSummary } from './console-presenter';
|
|
2
|
+
|
|
3
|
+
describe('ConsolePresenter', () => {
|
|
4
|
+
let logSpy: jest.SpyInstance;
|
|
5
|
+
let errorSpy: jest.SpyInstance;
|
|
6
|
+
let instance: ConsolePresenter;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
jest.clearAllMocks();
|
|
10
|
+
logSpy = jest.spyOn(console, 'log').mockImplementation();
|
|
11
|
+
errorSpy = jest.spyOn(console, 'error').mockImplementation();
|
|
12
|
+
instance = new ConsolePresenter();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
logSpy.mockRestore();
|
|
17
|
+
errorSpy.mockRestore();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
function allLogOutput(): string {
|
|
21
|
+
return logSpy.mock.calls.map((c: unknown[]) => c.join(' ')).join('\n');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function allErrorOutput(): string {
|
|
25
|
+
return errorSpy.mock.calls.map((c: unknown[]) => c.join(' ')).join('\n');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('showConfigError', () => {
|
|
29
|
+
test('outputs Configuration Error to stderr', () => {
|
|
30
|
+
instance.showConfigError();
|
|
31
|
+
const output = allErrorOutput();
|
|
32
|
+
expect(output).toContain('Configuration Error');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('suggests running cadr init', () => {
|
|
36
|
+
instance.showConfigError();
|
|
37
|
+
const output = allErrorOutput();
|
|
38
|
+
expect(output).toContain('cadr init');
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('showGitError', () => {
|
|
43
|
+
test('outputs Git Error with the provided message to stderr', () => {
|
|
44
|
+
instance.showGitError('not a repository');
|
|
45
|
+
const output = allErrorOutput();
|
|
46
|
+
expect(output).toContain('Git Error: not a repository');
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('showReadFilesError', () => {
|
|
51
|
+
test('outputs Failed to read changed files to stderr', () => {
|
|
52
|
+
instance.showReadFilesError();
|
|
53
|
+
const output = allErrorOutput();
|
|
54
|
+
expect(output).toContain('Failed to read changed files');
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('showNoChanges', () => {
|
|
59
|
+
test('staged mode mentions staged, git add, and cadr analyze --staged', () => {
|
|
60
|
+
instance.showNoChanges({ mode: 'staged' });
|
|
61
|
+
const output = allLogOutput();
|
|
62
|
+
expect(output).toContain('staged');
|
|
63
|
+
expect(output).toContain('git add');
|
|
64
|
+
expect(output).toContain('cadr analyze --staged');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('all mode mentions uncommitted and cadr analyze', () => {
|
|
68
|
+
instance.showNoChanges({ mode: 'all' });
|
|
69
|
+
const output = allLogOutput();
|
|
70
|
+
expect(output).toContain('uncommitted');
|
|
71
|
+
expect(output).toContain('cadr analyze');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('branch-diff mode with base and head mentions between main and HEAD', () => {
|
|
75
|
+
instance.showNoChanges({ mode: 'branch-diff', base: 'main', head: 'HEAD' });
|
|
76
|
+
const output = allLogOutput();
|
|
77
|
+
expect(output).toContain('between main and HEAD');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('branch-diff mode without base/head defaults to origin/main and HEAD', () => {
|
|
81
|
+
instance.showNoChanges({ mode: 'branch-diff' });
|
|
82
|
+
const output = allLogOutput();
|
|
83
|
+
expect(output).toContain('origin/main');
|
|
84
|
+
expect(output).toContain('HEAD');
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('showAnalyzingFiles', () => {
|
|
89
|
+
test('all mode with 2 files shows count and lists both files', () => {
|
|
90
|
+
instance.showAnalyzingFiles(['a.ts', 'b.ts'], { mode: 'all' });
|
|
91
|
+
const output = allLogOutput();
|
|
92
|
+
expect(output).toContain('2 uncommitted files');
|
|
93
|
+
expect(output).toContain('a.ts');
|
|
94
|
+
expect(output).toContain('b.ts');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('staged mode with 1 file shows singular file count', () => {
|
|
98
|
+
instance.showAnalyzingFiles(['a.ts'], { mode: 'staged' });
|
|
99
|
+
const output = allLogOutput();
|
|
100
|
+
expect(output).toContain('1 staged file');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('branch-diff mode shows files changed between base and head', () => {
|
|
104
|
+
instance.showAnalyzingFiles(['a.ts', 'b.ts'], { mode: 'branch-diff', base: 'main', head: 'feat' });
|
|
105
|
+
const output = allLogOutput();
|
|
106
|
+
expect(output).toContain('2 files changed between main and feat');
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('showNoDiffContent', () => {
|
|
111
|
+
test('outputs No diff content found', () => {
|
|
112
|
+
instance.showNoDiffContent();
|
|
113
|
+
const output = allLogOutput();
|
|
114
|
+
expect(output).toContain('No diff content found');
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('showSendingToLLM', () => {
|
|
119
|
+
test('staged mode mentions staged changes, provider, and model', () => {
|
|
120
|
+
instance.showSendingToLLM({ mode: 'staged' }, 'openai', 'gpt-4');
|
|
121
|
+
const output = allLogOutput();
|
|
122
|
+
expect(output).toContain('staged changes');
|
|
123
|
+
expect(output).toContain('openai');
|
|
124
|
+
expect(output).toContain('gpt-4');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('all mode mentions uncommitted changes', () => {
|
|
128
|
+
instance.showSendingToLLM({ mode: 'all' }, 'gemini', 'gemini-pro');
|
|
129
|
+
const output = allLogOutput();
|
|
130
|
+
expect(output).toContain('uncommitted changes');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('branch-diff mode mentions changes', () => {
|
|
134
|
+
instance.showSendingToLLM({ mode: 'branch-diff' }, 'openai', 'gpt-4');
|
|
135
|
+
const output = allLogOutput();
|
|
136
|
+
expect(output).toContain('changes');
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe('showAnalysisFailed', () => {
|
|
141
|
+
test('outputs Analysis failed and the error message to stderr', () => {
|
|
142
|
+
instance.showAnalysisFailed('some error');
|
|
143
|
+
const output = allErrorOutput();
|
|
144
|
+
expect(output).toContain('Analysis failed');
|
|
145
|
+
expect(output).toContain('some error');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('shows Unknown error occurred when no argument provided', () => {
|
|
149
|
+
instance.showAnalysisFailed();
|
|
150
|
+
const output = allErrorOutput();
|
|
151
|
+
expect(output).toContain('Unknown error occurred');
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe('showAnalysisComplete', () => {
|
|
156
|
+
test('outputs Analysis Complete', () => {
|
|
157
|
+
instance.showAnalysisComplete();
|
|
158
|
+
const output = allLogOutput();
|
|
159
|
+
expect(output).toContain('Analysis Complete');
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe('showSignificantResult', () => {
|
|
164
|
+
const baseSummary: AnalysisSummary = {
|
|
165
|
+
fileCount: 5,
|
|
166
|
+
mode: 'all',
|
|
167
|
+
isSignificant: true,
|
|
168
|
+
reason: 'big change',
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
test('outputs ARCHITECTURALLY SIGNIFICANT with reason and confidence', () => {
|
|
172
|
+
instance.showSignificantResult({ ...baseSummary, confidence: 0.9 });
|
|
173
|
+
const output = allLogOutput();
|
|
174
|
+
expect(output).toContain('ARCHITECTURALLY SIGNIFICANT');
|
|
175
|
+
expect(output).toContain('big change');
|
|
176
|
+
expect(output).toContain('90%');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('does not show percentage when confidence is not provided', () => {
|
|
180
|
+
instance.showSignificantResult(baseSummary);
|
|
181
|
+
const output = allLogOutput();
|
|
182
|
+
expect(output).toContain('ARCHITECTURALLY SIGNIFICANT');
|
|
183
|
+
expect(output).not.toContain('%');
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe('showNotSignificantResult', () => {
|
|
188
|
+
const baseSummary: AnalysisSummary = {
|
|
189
|
+
fileCount: 2,
|
|
190
|
+
mode: 'all',
|
|
191
|
+
isSignificant: false,
|
|
192
|
+
reason: 'trivial',
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
test('outputs NOT ARCHITECTURALLY SIGNIFICANT with reason and confidence', () => {
|
|
196
|
+
instance.showNotSignificantResult({ ...baseSummary, confidence: 0.5 });
|
|
197
|
+
const output = allLogOutput();
|
|
198
|
+
expect(output).toContain('NOT ARCHITECTURALLY SIGNIFICANT');
|
|
199
|
+
expect(output).toContain('trivial');
|
|
200
|
+
expect(output).toContain('50%');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('does not show percentage when confidence is not provided', () => {
|
|
204
|
+
instance.showNotSignificantResult(baseSummary);
|
|
205
|
+
const output = allLogOutput();
|
|
206
|
+
expect(output).toContain('NOT ARCHITECTURALLY SIGNIFICANT');
|
|
207
|
+
expect(output).not.toContain('%');
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe('showGeneratingADR', () => {
|
|
212
|
+
test('outputs Generating ADR', () => {
|
|
213
|
+
instance.showGeneratingADR();
|
|
214
|
+
const output = allLogOutput();
|
|
215
|
+
expect(output).toContain('Generating ADR');
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe('showGenerationFailed', () => {
|
|
220
|
+
test('outputs ADR generation failed and error message to stderr', () => {
|
|
221
|
+
instance.showGenerationFailed('error msg');
|
|
222
|
+
const output = allErrorOutput();
|
|
223
|
+
expect(output).toContain('ADR generation failed');
|
|
224
|
+
expect(output).toContain('error msg');
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
describe('showADRSuccess', () => {
|
|
229
|
+
test('outputs Success, file path, and Next steps', () => {
|
|
230
|
+
instance.showADRSuccess('/path/to/adr.md');
|
|
231
|
+
const output = allLogOutput();
|
|
232
|
+
expect(output).toContain('Success');
|
|
233
|
+
expect(output).toContain('/path/to/adr.md');
|
|
234
|
+
expect(output).toContain('Next steps');
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
describe('showSkippingGeneration', () => {
|
|
239
|
+
test('outputs Skipping ADR generation', () => {
|
|
240
|
+
instance.showSkippingGeneration();
|
|
241
|
+
const output = allLogOutput();
|
|
242
|
+
expect(output).toContain('Skipping ADR generation');
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
describe('showUnexpectedError', () => {
|
|
247
|
+
test('outputs unexpected error to stderr', () => {
|
|
248
|
+
instance.showUnexpectedError();
|
|
249
|
+
const output = allErrorOutput();
|
|
250
|
+
expect(output).toContain('unexpected error');
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
describe('presenter export', () => {
|
|
255
|
+
test('is an instance of ConsolePresenter', () => {
|
|
256
|
+
expect(presenter).toBeInstanceOf(ConsolePresenter);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
});
|