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.
Files changed (153) hide show
  1. package/dist/adr/adr.d.ts +17 -0
  2. package/dist/adr/adr.d.ts.map +1 -0
  3. package/dist/{adr.js → adr/adr.js} +4 -44
  4. package/dist/adr/adr.js.map +1 -0
  5. package/dist/adr/adr.test.d.ts +5 -0
  6. package/dist/{adr.test.d.ts.map → adr/adr.test.d.ts.map} +1 -1
  7. package/dist/{adr.test.js → adr/adr.test.js} +0 -14
  8. package/dist/adr/adr.test.js.map +1 -0
  9. package/dist/adr/index.d.ts +2 -0
  10. package/dist/adr/index.d.ts.map +1 -0
  11. package/dist/adr/index.js +18 -0
  12. package/dist/adr/index.js.map +1 -0
  13. package/dist/analysis/analysis.orchestrator.d.ts +14 -0
  14. package/dist/analysis/analysis.orchestrator.d.ts.map +1 -0
  15. package/dist/analysis/analysis.orchestrator.js +175 -0
  16. package/dist/analysis/analysis.orchestrator.js.map +1 -0
  17. package/dist/analysis/analysis.orchestrator.test.d.ts +2 -0
  18. package/dist/analysis/analysis.orchestrator.test.d.ts.map +1 -0
  19. package/dist/analysis/analysis.orchestrator.test.js +177 -0
  20. package/dist/analysis/analysis.orchestrator.test.js.map +1 -0
  21. package/dist/analysis/strategies/git-strategy.d.ts +22 -0
  22. package/dist/analysis/strategies/git-strategy.d.ts.map +1 -0
  23. package/dist/analysis/strategies/git-strategy.js +114 -0
  24. package/dist/analysis/strategies/git-strategy.js.map +1 -0
  25. package/dist/analysis/strategies/git-strategy.test.d.ts +2 -0
  26. package/dist/analysis/strategies/git-strategy.test.d.ts.map +1 -0
  27. package/dist/analysis/strategies/git-strategy.test.js +147 -0
  28. package/dist/analysis/strategies/git-strategy.test.js.map +1 -0
  29. package/dist/commands/analyze.js +3 -3
  30. package/dist/commands/analyze.js.map +1 -1
  31. package/dist/commands/analyze.test.d.ts +2 -0
  32. package/dist/commands/analyze.test.d.ts.map +1 -0
  33. package/dist/commands/analyze.test.js +70 -0
  34. package/dist/commands/analyze.test.js.map +1 -0
  35. package/dist/commands/init.test.js +128 -2
  36. package/dist/commands/init.test.js.map +1 -1
  37. package/dist/config.test.js +167 -0
  38. package/dist/config.test.js.map +1 -1
  39. package/dist/git/git.errors.d.ts +6 -0
  40. package/dist/git/git.errors.d.ts.map +1 -0
  41. package/dist/git/git.errors.js +15 -0
  42. package/dist/git/git.errors.js.map +1 -0
  43. package/dist/git/git.errors.test.d.ts +2 -0
  44. package/dist/git/git.errors.test.d.ts.map +1 -0
  45. package/dist/git/git.errors.test.js +34 -0
  46. package/dist/git/git.errors.test.js.map +1 -0
  47. package/dist/git/git.operations.d.ts +12 -0
  48. package/dist/git/git.operations.d.ts.map +1 -0
  49. package/dist/git/git.operations.js +64 -0
  50. package/dist/git/git.operations.js.map +1 -0
  51. package/dist/git/git.operations.test.d.ts +2 -0
  52. package/dist/git/git.operations.test.d.ts.map +1 -0
  53. package/dist/git/git.operations.test.js +164 -0
  54. package/dist/git/git.operations.test.js.map +1 -0
  55. package/dist/git/index.d.ts +4 -0
  56. package/dist/git/index.d.ts.map +1 -0
  57. package/dist/git/index.js +19 -0
  58. package/dist/git/index.js.map +1 -0
  59. package/dist/llm/index.d.ts +3 -0
  60. package/dist/llm/index.d.ts.map +1 -0
  61. package/dist/llm/index.js +19 -0
  62. package/dist/llm/index.js.map +1 -0
  63. package/dist/llm/llm.d.ts +35 -0
  64. package/dist/llm/llm.d.ts.map +1 -0
  65. package/dist/{llm.js → llm/llm.js} +16 -58
  66. package/dist/llm/llm.js.map +1 -0
  67. package/dist/{llm.test.d.ts.map → llm/llm.test.d.ts.map} +1 -1
  68. package/dist/llm/llm.test.js +224 -0
  69. package/dist/llm/llm.test.js.map +1 -0
  70. package/dist/{prompts.d.ts → llm/prompts.d.ts} +1 -38
  71. package/dist/llm/prompts.d.ts.map +1 -0
  72. package/dist/{prompts.js → llm/prompts.js} +9 -54
  73. package/dist/llm/prompts.js.map +1 -0
  74. package/dist/llm/response-parser.d.ts +9 -0
  75. package/dist/llm/response-parser.d.ts.map +1 -0
  76. package/dist/llm/response-parser.js +67 -0
  77. package/dist/llm/response-parser.js.map +1 -0
  78. package/dist/llm/response-parser.test.d.ts +2 -0
  79. package/dist/llm/response-parser.test.d.ts.map +1 -0
  80. package/dist/llm/response-parser.test.js +134 -0
  81. package/dist/llm/response-parser.test.js.map +1 -0
  82. package/dist/presenters/console-presenter.d.ts +35 -0
  83. package/dist/presenters/console-presenter.d.ts.map +1 -0
  84. package/dist/presenters/console-presenter.js +114 -0
  85. package/dist/presenters/console-presenter.js.map +1 -0
  86. package/dist/presenters/console-presenter.test.d.ts +2 -0
  87. package/dist/presenters/console-presenter.test.d.ts.map +1 -0
  88. package/dist/presenters/console-presenter.test.js +227 -0
  89. package/dist/presenters/console-presenter.test.js.map +1 -0
  90. package/dist/version.test.d.ts +1 -2
  91. package/dist/version.test.d.ts.map +1 -1
  92. package/dist/version.test.js +29 -16
  93. package/dist/version.test.js.map +1 -1
  94. package/package.json +1 -1
  95. package/src/{adr.test.ts → adr/adr.test.ts} +10 -23
  96. package/src/{adr.ts → adr/adr.ts} +7 -48
  97. package/src/adr/index.ts +1 -0
  98. package/src/analysis/analysis.orchestrator.test.ts +237 -0
  99. package/src/analysis/analysis.orchestrator.ts +175 -0
  100. package/src/analysis/strategies/git-strategy.test.ts +210 -0
  101. package/src/analysis/strategies/git-strategy.ts +106 -0
  102. package/src/commands/analyze.test.ts +91 -0
  103. package/src/commands/analyze.ts +8 -9
  104. package/src/commands/init.test.ts +200 -5
  105. package/src/config.test.ts +232 -2
  106. package/src/git/git.errors.test.ts +43 -0
  107. package/src/git/git.errors.ts +10 -0
  108. package/src/git/git.operations.test.ts +222 -0
  109. package/src/git/git.operations.ts +85 -0
  110. package/src/git/index.ts +3 -0
  111. package/src/llm/index.ts +2 -0
  112. package/src/llm/llm.test.ts +315 -0
  113. package/src/{llm.ts → llm/llm.ts} +46 -107
  114. package/src/{prompts.ts → llm/prompts.ts} +30 -72
  115. package/src/llm/response-parser.test.ts +170 -0
  116. package/src/llm/response-parser.ts +90 -0
  117. package/src/presenters/console-presenter.test.ts +259 -0
  118. package/src/presenters/console-presenter.ts +152 -0
  119. package/src/version.test.ts +30 -16
  120. package/dist/adr.d.ts +0 -50
  121. package/dist/adr.d.ts.map +0 -1
  122. package/dist/adr.js.map +0 -1
  123. package/dist/adr.test.d.ts +0 -8
  124. package/dist/adr.test.js.map +0 -1
  125. package/dist/analysis.d.ts +0 -24
  126. package/dist/analysis.d.ts.map +0 -1
  127. package/dist/analysis.js +0 -281
  128. package/dist/analysis.js.map +0 -1
  129. package/dist/analysis.test.d.ts +0 -8
  130. package/dist/analysis.test.d.ts.map +0 -1
  131. package/dist/analysis.test.js +0 -351
  132. package/dist/analysis.test.js.map +0 -1
  133. package/dist/git.d.ts +0 -54
  134. package/dist/git.d.ts.map +0 -1
  135. package/dist/git.js +0 -204
  136. package/dist/git.js.map +0 -1
  137. package/dist/llm.d.ts +0 -73
  138. package/dist/llm.d.ts.map +0 -1
  139. package/dist/llm.js.map +0 -1
  140. package/dist/llm.test.js +0 -592
  141. package/dist/llm.test.js.map +0 -1
  142. package/dist/prompts.d.ts.map +0 -1
  143. package/dist/prompts.js.map +0 -1
  144. package/dist/prompts.test.d.ts +0 -2
  145. package/dist/prompts.test.d.ts.map +0 -1
  146. package/dist/prompts.test.js +0 -427
  147. package/dist/prompts.test.js.map +0 -1
  148. package/src/analysis.test.ts +0 -396
  149. package/src/analysis.ts +0 -262
  150. package/src/git.ts +0 -300
  151. package/src/llm.test.ts +0 -701
  152. package/src/prompts.test.ts +0 -515
  153. /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
- * Formats the generation prompt with actual diff data
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
- ? data.file_paths.join('\n')
137
- : 'No files';
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
- // eslint-disable-next-line no-console
116
+ /* eslint-disable-next-line no-console */
161
117
  console.log(`\n💭 ${reason}\n`);
162
-
163
- rl.question('📝 Would you like to generate an ADR for this change? (Press ENTER or type "yes" to confirm, "no" to skip): ', (answer) => {
164
- rl.close();
165
-
166
- const normalized = answer.trim().toLowerCase();
167
-
168
- // Accept: empty (ENTER), "y", "yes"
169
- const confirmed = normalized === '' || normalized === 'y' || normalized === 'yes';
170
-
171
- resolve(confirmed);
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
+ });