codecritique 2.2.5 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -415,7 +415,7 @@ jobs:
415
415
  | `anthropic-api-key` | Anthropic API key for Claude models | **Yes** | - |
416
416
  | `skip-label` | Label name to skip AI review | No | `ai-review-disabled` |
417
417
  | `verbose` | Show verbose output | No | `false` |
418
- | `model` | LLM model to use (e.g., `claude-sonnet-4-6`) | No | Auto-selected |
418
+ | `model` | LLM model to use (e.g., `claude-sonnet-5`) | No | Auto-selected |
419
419
  | `max-tokens` | Maximum tokens for LLM response | No | Auto-calculated |
420
420
  | `cache-ttl` | Cache TTL for LLM prompts: "5m" (default, no extra cost) or "1h" (extended, extra cost for cache writes) | No | `5m` |
421
421
  | `batch` | Use Anthropic Message Batches for lower-cost, asynchronous reviews | No | `false` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codecritique",
3
- "version": "2.2.5",
3
+ "version": "2.3.0",
4
4
  "description": "AI-powered code review tool for any programming language",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -51,7 +51,7 @@
51
51
  "license": "MIT",
52
52
  "author": "cosmocoder",
53
53
  "dependencies": {
54
- "@anthropic-ai/sdk": "0.123.0",
54
+ "@anthropic-ai/sdk": "0.125.0",
55
55
  "@huggingface/transformers": "3.8.0",
56
56
  "@lancedb/lancedb": "0.38.0",
57
57
  "@octokit/rest": "22.0.1",
package/src/index.js CHANGED
@@ -63,7 +63,7 @@ program
63
63
  .option('--output-file <file>', 'Save output to file (useful with --output json)')
64
64
  .option('--no-color', 'Disable colored output')
65
65
  .option('--verbose', 'Show verbose output')
66
- .option('--model <model>', 'LLM model to use (e.g., claude-sonnet-4-6)')
66
+ .option('--model <model>', 'LLM model to use (e.g., claude-sonnet-5)')
67
67
  .option('--temperature <number>', 'LLM temperature', parseFloat, 0.2)
68
68
  .option('--max-tokens <number>', 'LLM max tokens', parseIntegerOption, 8192)
69
69
  .option('--cache-ttl <ttl>', 'Cache TTL for LLM prompts: "5m" (default, no extra cost) or "1h" (extended, extra cost for writes)', '5m')
package/src/llm.js CHANGED
@@ -42,7 +42,11 @@ function getAnthropicClient() {
42
42
  }
43
43
 
44
44
  // Default model
45
- const DEFAULT_MODEL = 'claude-sonnet-4-6';
45
+ const DEFAULT_MODEL = 'claude-sonnet-5';
46
+
47
+ // Models after the 4.6 generation reject `temperature` with a 400. Only send it
48
+ // for the older families; unknown models fall back to the API default.
49
+ const SAMPLING_MODEL_PATTERN = /^claude-(?:3|(?:opus|sonnet|haiku)-4-[0-6])/;
46
50
 
47
51
  // Maximum tokens for response
48
52
  const MAX_TOKENS = 4096;
@@ -138,6 +142,8 @@ async function createBatchedMessage(client, requestParams, options) {
138
142
  * @param {string} options.system - System prompt (will be cached for cost optimization)
139
143
  * @param {Array<string|Object>} options.cachedSystemBlocks - Additional stable system blocks to cache when possible
140
144
  * @param {Object} options.jsonSchema - JSON schema for structured output
145
+ * @param {boolean} [options.strict=false] - Enforce the schema on the tool input. Every object in the
146
+ * schema must set `additionalProperties: false`, and `additionalProperties` accepts no other value.
141
147
  * @param {string} options.cacheTtl - Cache TTL: '5m' (default, no extra cost) or '1h' (extended, extra cost for writes)
142
148
  * @param {boolean} [options.batch=false] - Use the asynchronous Message Batches API
143
149
  * @returns {Promise<Object>} The response from Claude with structured data
@@ -150,6 +156,7 @@ async function sendPromptToClaude(prompt, options = {}) {
150
156
  system = '',
151
157
  cachedSystemBlocks = [],
152
158
  jsonSchema = null,
159
+ strict = false,
153
160
  cacheTtl = '5m',
154
161
  } = options;
155
162
 
@@ -169,7 +176,7 @@ async function sendPromptToClaude(prompt, options = {}) {
169
176
  const requestParams = {
170
177
  model,
171
178
  max_tokens: maxTokens,
172
- temperature,
179
+ ...(SAMPLING_MODEL_PATTERN.test(model) ? { temperature } : {}),
173
180
  system: systemContent,
174
181
  messages: [
175
182
  {
@@ -186,6 +193,9 @@ async function sendPromptToClaude(prompt, options = {}) {
186
193
  name: 'return_json',
187
194
  description: 'Return the final answer strictly as JSON matching the schema.',
188
195
  input_schema: jsonSchema,
196
+ // Omitted rather than sent as false so the tool block stays byte-identical
197
+ // for callers that do not use it, which keeps their cached prefix valid.
198
+ ...(strict ? { strict: true } : {}),
189
199
  },
190
200
  ];
191
201
  requestParams.tool_choice = { type: 'tool', name: 'return_json' };
@@ -199,6 +209,22 @@ async function sendPromptToClaude(prompt, options = {}) {
199
209
  verboseLog(options, chalk.gray(` Response stop_reason: ${response.stop_reason}`));
200
210
  verboseLog(options, chalk.gray(` Response content blocks: ${response.content?.length || 0}`));
201
211
 
212
+ // A refusal arrives as HTTP 200 with no tool_use and no text, so it must be caught
213
+ // here or it surfaces as a missing-output error that hides the real cause.
214
+ if (response.stop_reason === 'refusal') {
215
+ const { category, explanation } = response.stop_details || {};
216
+ const reason = explanation || 'no explanation provided';
217
+ throw new Error(`Claude declined the request${category ? ` (${category})` : ''}: ${reason}`);
218
+ }
219
+
220
+ // Truncation also arrives as HTTP 200. Without this the tool path reports missing
221
+ // output and the text path returns a severed answer that reads as a complete one.
222
+ if (response.stop_reason === 'max_tokens' || response.stop_reason === 'model_context_window_exceeded') {
223
+ const remedy =
224
+ response.stop_reason === 'max_tokens' ? 'Increase the configured output limit or continue the response.' : 'Reduce the input size.';
225
+ throw new Error(`Claude's response was truncated (${response.stop_reason}). ${remedy}`);
226
+ }
227
+
202
228
  // Process response based on whether we used tool calling
203
229
  if (jsonSchema) {
204
230
  const toolUse = response.content.find((block) => block.type === 'tool_use' && block.name === 'return_json');
@@ -221,7 +247,7 @@ async function sendPromptToClaude(prompt, options = {}) {
221
247
  }
222
248
  else {
223
249
  return {
224
- content: response.content[0]?.text || '',
250
+ content: response.content?.find((block) => block.type === 'text')?.text || '',
225
251
  model: response.model,
226
252
  usage: response.usage,
227
253
  };
package/src/llm.test.js CHANGED
@@ -65,13 +65,122 @@ describe('sendPromptToClaude', () => {
65
65
 
66
66
  expect(mockMessagesCreate).toHaveBeenCalledWith(
67
67
  expect.objectContaining({
68
- model: 'claude-sonnet-4-6',
68
+ model: 'claude-sonnet-5',
69
69
  max_tokens: 4096,
70
- temperature: 0.7,
71
70
  })
72
71
  );
73
72
  });
74
73
 
74
+ it('should surface the refusal explanation instead of a missing-output error', async () => {
75
+ mockMessagesCreate.mockResolvedValue({
76
+ stop_reason: 'refusal',
77
+ stop_details: { type: 'refusal', category: 'cyber', explanation: 'Declined: offensive tooling.' },
78
+ content: [],
79
+ model: 'claude-sonnet-5',
80
+ usage: {},
81
+ });
82
+
83
+ await expect(sendPromptToClaude('Test prompt', { jsonSchema: { type: 'object' } })).rejects.toThrow(
84
+ 'Claude declined the request (cyber): Declined: offensive tooling.'
85
+ );
86
+ });
87
+
88
+ it.each([
89
+ ['max_tokens', 'Increase the configured output limit or continue the response.'],
90
+ ['model_context_window_exceeded', 'Reduce the input size.'],
91
+ ])('should report a truncated response for stop_reason %s', async (stopReason, remedy) => {
92
+ mockMessagesCreate.mockResolvedValue({
93
+ stop_reason: stopReason,
94
+ content: [],
95
+ model: 'claude-sonnet-5',
96
+ usage: {},
97
+ });
98
+
99
+ await expect(sendPromptToClaude('Test prompt', { jsonSchema: { type: 'object' } })).rejects.toThrow(
100
+ `Claude's response was truncated (${stopReason}). ${remedy}`
101
+ );
102
+ });
103
+
104
+ it('should report truncation rather than returning a severed plain-text answer', async () => {
105
+ mockMessagesCreate.mockResolvedValue({
106
+ stop_reason: 'max_tokens',
107
+ content: [{ type: 'text', text: 'partial answer cut off mid-' }],
108
+ model: 'claude-sonnet-5',
109
+ usage: {},
110
+ });
111
+
112
+ await expect(sendPromptToClaude('Test prompt')).rejects.toThrow('was truncated (max_tokens)');
113
+ });
114
+
115
+ it('should read the text block even when a thinking block precedes it', async () => {
116
+ mockMessagesCreate.mockResolvedValue({
117
+ stop_reason: 'end_turn',
118
+ content: [
119
+ { type: 'thinking', thinking: '' },
120
+ { type: 'text', text: 'The real answer' },
121
+ ],
122
+ model: 'claude-sonnet-5',
123
+ usage: {},
124
+ });
125
+
126
+ const result = await sendPromptToClaude('Test prompt');
127
+
128
+ expect(result.content).toBe('The real answer');
129
+ });
130
+
131
+ it('should omit temperature for models that reject sampling parameters', async () => {
132
+ mockMessagesCreate.mockResolvedValue({
133
+ content: [{ type: 'text', text: 'Response' }],
134
+ model: 'claude-sonnet-5',
135
+ usage: {},
136
+ });
137
+
138
+ await sendPromptToClaude('Test prompt', { temperature: 0.5 });
139
+
140
+ expect(mockMessagesCreate.mock.calls[0][0]).not.toHaveProperty('temperature');
141
+ });
142
+
143
+ it('should set strict on the return_json tool when requested', async () => {
144
+ mockMessagesCreate.mockResolvedValue({
145
+ content: [{ type: 'tool_use', name: 'return_json', input: { ok: true } }],
146
+ model: 'claude-sonnet-5',
147
+ usage: {},
148
+ });
149
+
150
+ await sendPromptToClaude('Test prompt', {
151
+ jsonSchema: { type: 'object', properties: {}, required: [], additionalProperties: false },
152
+ strict: true,
153
+ });
154
+
155
+ expect(mockMessagesCreate.mock.calls[0][0].tools[0]).toHaveProperty('strict', true);
156
+ });
157
+
158
+ it('should omit strict from the return_json tool by default', async () => {
159
+ mockMessagesCreate.mockResolvedValue({
160
+ content: [{ type: 'tool_use', name: 'return_json', input: { ok: true } }],
161
+ model: 'claude-sonnet-5',
162
+ usage: {},
163
+ });
164
+
165
+ await sendPromptToClaude('Test prompt', {
166
+ jsonSchema: { type: 'object', properties: {}, required: [], additionalProperties: false },
167
+ });
168
+
169
+ expect(mockMessagesCreate.mock.calls[0][0].tools[0]).not.toHaveProperty('strict');
170
+ });
171
+
172
+ it('should keep temperature for models that still accept it', async () => {
173
+ mockMessagesCreate.mockResolvedValue({
174
+ content: [{ type: 'text', text: 'Response' }],
175
+ model: 'claude-haiku-4-5',
176
+ usage: {},
177
+ });
178
+
179
+ await sendPromptToClaude('Test prompt', { model: 'claude-haiku-4-5', temperature: 0.1 });
180
+
181
+ expect(mockMessagesCreate).toHaveBeenCalledWith(expect.objectContaining({ temperature: 0.1 }));
182
+ });
183
+
75
184
  it('should use custom options', async () => {
76
185
  mockMessagesCreate.mockResolvedValue({
77
186
  content: [{ type: 'text', text: 'Response' }],
@@ -105,7 +214,7 @@ describe('sendPromptToClaude', () => {
105
214
  it('should retry polling and unwrap a successful batch request', async () => {
106
215
  const message = {
107
216
  content: [{ type: 'text', text: 'Batched response' }],
108
- model: 'claude-sonnet-4-6',
217
+ model: 'claude-sonnet-5',
109
218
  usage: { input_tokens: 100, output_tokens: 50 },
110
219
  };
111
220
  mockBatchesCreate.mockResolvedValue({ id: 'msgbatch_123', processing_status: 'in_progress' });
@@ -127,7 +236,7 @@ describe('sendPromptToClaude', () => {
127
236
  {
128
237
  custom_id: 'codecritique-review',
129
238
  params: expect.objectContaining({
130
- model: 'claude-sonnet-4-6',
239
+ model: 'claude-sonnet-5',
131
240
  messages: [{ role: 'user', content: 'Review this code' }],
132
241
  }),
133
242
  },
@@ -592,6 +592,7 @@ Select files following the criteria in the system instructions.`;
592
592
  temperature: 0.1,
593
593
  maxTokens: 1000,
594
594
  jsonSchema: fileSelectionSchema,
595
+ strict: true,
595
596
  });
596
597
 
597
598
  verboseLog({}, chalk.gray(' 📄 LLM Response preview:'), response.content.substring(0, 200));
@@ -556,7 +556,10 @@ describe('ProjectAnalyzer', () => {
556
556
 
557
557
  const result = await analyzer.selectFinalKeyFiles(candidates, mockProjectPath);
558
558
 
559
- expect(llm.sendPromptToClaude).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ model: 'claude-haiku-4-5' }));
559
+ expect(llm.sendPromptToClaude).toHaveBeenCalledWith(
560
+ expect.any(String),
561
+ expect.objectContaining({ model: 'claude-haiku-4-5', strict: true })
562
+ );
560
563
  expect(result.length).toBe(1);
561
564
  expect(result[0].relativePath).toBe('package.json');
562
565
  });
@@ -673,6 +676,9 @@ describe('ProjectAnalyzer', () => {
673
676
  const result = await analyzer.generateProjectSummary(mockKeyFiles, mockProjectPath);
674
677
 
675
678
  expect(llm.sendPromptToClaude).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ model: 'claude-haiku-4-5' }));
679
+ // projectSummarySchema sets additionalProperties: false on its top level only, and
680
+ // the documented strict subset requires it on every object.
681
+ expect(llm.sendPromptToClaude.mock.calls[0][1]).not.toHaveProperty('strict');
676
682
  expect(result.projectName).toBe('test-project');
677
683
  expect(result.analysisDate).toBeDefined();
678
684
  expect(result.projectPath).toBe(mockProjectPath);
@@ -248,9 +248,9 @@ You must respond with EXACTLY this JSON structure, with no additional text:
248
248
  {
249
249
  "type": "bug | improvement | convention | architecture",
250
250
  "severity": "critical | high | medium",
251
- "description": "Detailed description of an issue that spans multiple files...",
251
+ "message": "Detailed description of an issue that spans multiple files...",
252
252
  "suggestion": "Actionable suggestion to resolve the cross-file issue.",
253
- "filesInvolved": ["path/to/file1.js", "path/to/file2.ts"]
253
+ "files": ["path/to/file1.js", "path/to/file2.ts"]
254
254
  }
255
255
  ],
256
256
  "fileSpecificIssues": {
@@ -272,9 +272,10 @@ You must respond with EXACTLY this JSON structure, with no additional text:
272
272
  },
273
273
  "recommendations": [
274
274
  {
275
- "type": "refactoring | testing | documentation",
276
- "description": "A high-level recommendation for improving the codebase...",
277
- "filesInvolved": ["path/to/relevant/file.js"]
275
+ "category": "refactoring | testing | documentation",
276
+ "suggestion": "A high-level recommendation for improving the codebase...",
277
+ "priority": "high | medium | low",
278
+ "impact": "Expected benefit of applying this recommendation."
278
279
  }
279
280
  ]
280
281
  }`;
@@ -0,0 +1,61 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { PR_REVIEW_JSON_SCHEMA } from './prompt-cache.js';
3
+ import { collectPRLevelFindings } from './utils/review-results.js';
4
+
5
+ /**
6
+ * The prompt tells the model which field names to emit. `review-results.js` reads
7
+ * those names back. A divergence drops data silently, so these tests pin the
8
+ * prompt to the names the consumers actually read.
9
+ */
10
+ describe('PR_REVIEW_JSON_SCHEMA', () => {
11
+ it('should name the cross-file issue fields that normalizePRLevelIssue reads', () => {
12
+ expect(PR_REVIEW_JSON_SCHEMA).toContain('"message"');
13
+ expect(PR_REVIEW_JSON_SCHEMA).toContain('"files"');
14
+ });
15
+
16
+ it('should name the recommendation fields that normalizeRecommendation reads', () => {
17
+ // "suggestion" appears in three blocks, so bound the slice at both ends: a slice
18
+ // that ran to the end of the schema would pass on any later block instead.
19
+ const start = PR_REVIEW_JSON_SCHEMA.indexOf('"recommendations"');
20
+ const recommendations = PR_REVIEW_JSON_SCHEMA.slice(start, PR_REVIEW_JSON_SCHEMA.indexOf(']', start) + 1);
21
+
22
+ expect(recommendations).toContain('"category"');
23
+ expect(recommendations).toContain('"suggestion"');
24
+ expect(recommendations).toContain('"impact"');
25
+ });
26
+
27
+ it('should not name filesInvolved, which no consumer reads', () => {
28
+ expect(PR_REVIEW_JSON_SCHEMA).not.toContain('filesInvolved');
29
+ });
30
+ });
31
+
32
+ describe('collectPRLevelFindings with prompt-shaped input', () => {
33
+ const holisticAnalysis = {
34
+ results: {
35
+ summary: 'PR summary',
36
+ crossFileIssues: [
37
+ {
38
+ type: 'architecture',
39
+ severity: 'high',
40
+ message: 'Duplicated retry logic',
41
+ suggestion: 'Extract a shared helper',
42
+ files: ['a.js', 'b.js'],
43
+ },
44
+ ],
45
+ recommendations: [{ category: 'testing', suggestion: 'Cover the retry path', impact: 'Fewer regressions' }],
46
+ },
47
+ };
48
+
49
+ it('should keep the file list from a cross-file issue', () => {
50
+ const findings = collectPRLevelFindings([], { holisticAnalysis });
51
+
52
+ expect(findings.issues[0].files).toEqual(['a.js', 'b.js']);
53
+ expect(findings.issues[0].description).toBe('Duplicated retry logic');
54
+ });
55
+
56
+ it('should render a recommendation as text rather than raw JSON', () => {
57
+ const findings = collectPRLevelFindings([], { holisticAnalysis });
58
+
59
+ expect(findings.recommendations[0]).toBe('testing: Cover the retry path: Fewer regressions');
60
+ });
61
+ });
@@ -764,7 +764,6 @@ function prepareContextForLLM(filePath, content, language, finalCodeExamples, fi
764
764
  async function callLLMForAnalysis(context, options = {}) {
765
765
  try {
766
766
  let prompt;
767
- const model = options.model || 'claude-sonnet-4-6';
768
767
  const maxTokens = options.maxTokens || 8192; // Default to a safe limit
769
768
 
770
769
  if (options.isHolisticPRReview) {
@@ -778,7 +777,7 @@ async function callLLMForAnalysis(context, options = {}) {
778
777
  const llmResponse = await sendPromptToLLM(prompt, {
779
778
  temperature: 0,
780
779
  maxTokens: maxTokens,
781
- model: model,
780
+ model: options.model,
782
781
  isJsonMode: true, // Standardize on using JSON mode if available
783
782
  verbose: options.verbose || false, // Pass verbose flag for cache statistics
784
783
  cacheTtl: options.cacheTtl || '5m', // Pass cache TTL option (default: 5m, no extra cost)
@@ -242,6 +242,12 @@ describe('rag-analyzer', () => {
242
242
  expect(result.success).toBe(true);
243
243
  });
244
244
 
245
+ it('should forward an explicit model option to the LLM', async () => {
246
+ await runAnalysis('/test/file.js', { model: 'claude-3-opus' });
247
+
248
+ expect(llm.sendPromptToClaude).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ model: 'claude-3-opus' }));
249
+ });
250
+
245
251
  it('should handle diff-only mode', async () => {
246
252
  const result = await runAnalysis('/test/file.js', {
247
253
  diffOnly: true,