codecritique 2.2.6 → 2.3.1

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.6",
3
+ "version": "2.3.1",
4
4
  "description": "AI-powered code review tool for any programming language",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -111,7 +111,6 @@ export async function initializeSemanticSimilarity() {
111
111
  try {
112
112
  embeddingsSystem = getDefaultEmbeddingsSystem();
113
113
  await embeddingsSystem.initialize();
114
- semanticSimilarityInitialized = true;
115
114
  semanticSimilarityAvailable = true;
116
115
  verboseLog({}, chalk.green('[FeedbackLoader] Semantic similarity initialized using embeddings system'));
117
116
  }
@@ -119,6 +118,11 @@ export async function initializeSemanticSimilarity() {
119
118
  console.warn(chalk.yellow(`[FeedbackLoader] Semantic similarity initialization failed: ${error.message}`));
120
119
  semanticSimilarityAvailable = false;
121
120
  }
121
+ finally {
122
+ // Records the attempt, not the outcome. Callers initialize lazily, so a failure
123
+ // that left this unset would reload the model once per issue.
124
+ semanticSimilarityInitialized = true;
125
+ }
122
126
  }
123
127
 
124
128
  /**
@@ -168,74 +172,84 @@ async function calculateSemanticSimilarity(text1, text2) {
168
172
  // ISSUE SIMILARITY CHECKING
169
173
  // ============================================================================
170
174
 
175
+ /** Score above which an issue counts as a repeat of a dismissed one. */
176
+ export const DEFAULT_SIMILARITY_THRESHOLD = 0.7;
177
+
178
+ // A dismissal on the same line is evidence, not proof: a reworded repeat and a
179
+ // different finding on the same topic score too close together to tell apart. The
180
+ // lower bar catches the reworded repeat while a plainly unrelated finding still posts.
181
+ const SAME_LOCATION_SIMILARITY_THRESHOLD = 0.65;
182
+
183
+ // The word term is what separates a repost from a new finding: semantic scores
184
+ // alone rank unrelated findings above the threshold.
185
+ const SEMANTIC_WEIGHT = 0.7;
186
+ const WORD_WEIGHT = 0.3;
187
+
171
188
  /**
172
- * Check if an issue should be skipped based on previous feedback
173
- * Uses semantic similarity when available, falls back to word-based similarity.
189
+ * Check if an issue should be skipped based on previous feedback.
190
+ * Compares against dismissed issues with the combined semantic and word-based score,
191
+ * initializing the embeddings system on first use.
174
192
  *
175
193
  * @param {string} issueDescription - Description of the current issue
176
194
  * @param {Object} feedbackData - Loaded feedback data
177
195
  * @param {Object} options - Filtering options
178
- * @param {number} [options.similarityThreshold=0.7] - Threshold for considering issues similar
196
+ * @param {number} [options.similarityThreshold=DEFAULT_SIMILARITY_THRESHOLD] - Threshold for considering issues similar
179
197
  * @param {boolean} [options.verbose=false] - Enable verbose progress logging
180
198
  * @param {boolean} [options.useSemanticSimilarity=true] - Use semantic similarity when available
199
+ * @param {string|null} [options.filePath=null] - Path of the file the current issue is on
200
+ * @param {number|null} [options.lineNumber=null] - Line the current issue is on
181
201
  * @returns {Promise<boolean>} True if issue should be skipped
182
202
  */
183
203
  export async function shouldSkipSimilarIssue(issueDescription, feedbackData, options = {}) {
184
- const { similarityThreshold = 0.7, verbose = false, useSemanticSimilarity = true } = options;
204
+ const {
205
+ similarityThreshold = DEFAULT_SIMILARITY_THRESHOLD,
206
+ verbose = false,
207
+ useSemanticSimilarity = true,
208
+ filePath = null,
209
+ lineNumber = null,
210
+ } = options;
185
211
 
186
212
  if (!feedbackData || Object.keys(feedbackData).length === 0) {
187
213
  return false;
188
214
  }
189
215
 
190
- // Check if similar issues were previously dismissed
216
+ // Dismissals worth comparing against: one without its own text can never match.
191
217
  const dismissedIssues = Object.values(feedbackData).filter(
192
218
  (feedback) =>
193
- feedback?.overallSentiment === 'negative' ||
194
- feedback?.userReplies?.some(
195
- (reply) =>
196
- reply.body.toLowerCase().includes('false positive') ||
197
- reply.body.toLowerCase().includes('not relevant') ||
198
- reply.body.toLowerCase().includes('ignore') ||
199
- reply.body.toLowerCase().includes('resolved')
200
- )
219
+ feedback?.originalIssue &&
220
+ (feedback?.overallSentiment === 'negative' ||
221
+ feedback?.userReplies?.some(
222
+ (reply) =>
223
+ reply.body.toLowerCase().includes('false positive') ||
224
+ reply.body.toLowerCase().includes('not relevant') ||
225
+ reply.body.toLowerCase().includes('ignore') ||
226
+ reply.body.toLowerCase().includes('resolved')
227
+ ))
201
228
  );
202
229
 
203
- if (dismissedIssues.length === 0) {
230
+ if (!issueDescription || dismissedIssues.length === 0) {
204
231
  return false;
205
232
  }
206
233
 
207
- // Determine if we should use semantic similarity
208
- const canUseSemanticSimilarity = useSemanticSimilarity && isSemanticSimilarityAvailable();
209
-
210
- verboseLog(verbose && canUseSemanticSimilarity, chalk.cyan('🔍 Using semantic similarity for issue comparison'));
234
+ // Loaded here rather than by the caller: this is the first point where a comparison
235
+ // is certain, and the embedding model is the expensive part of making one.
236
+ if (useSemanticSimilarity) {
237
+ await initializeSemanticSimilarity();
238
+ }
211
239
 
212
240
  // Check similarity with dismissed issues
213
241
  for (const dismissed of dismissedIssues) {
214
- if (!dismissed.originalIssue) {
215
- continue;
216
- }
217
-
218
- let similarity;
219
- let similarityMethod;
242
+ const threshold = isSameLocation(dismissed, filePath, lineNumber)
243
+ ? Math.min(similarityThreshold, SAME_LOCATION_SIMILARITY_THRESHOLD)
244
+ : similarityThreshold;
220
245
 
221
- if (canUseSemanticSimilarity) {
222
- // Try semantic similarity first using existing embeddings system
223
- similarity = await calculateSemanticSimilarity(issueDescription, dismissed.originalIssue);
224
- similarityMethod = 'semantic';
225
-
226
- // Fall back to word similarity if semantic calculation failed
227
- if (similarity === null) {
228
- similarity = calculateWordSimilarity(issueDescription, dismissed.originalIssue);
229
- similarityMethod = 'word-based';
230
- }
231
- }
232
- else {
233
- // Use word-based similarity
234
- similarity = calculateWordSimilarity(issueDescription, dismissed.originalIssue);
235
- similarityMethod = 'word-based';
236
- }
246
+ // The hybrid score, not the semantic score alone: a normalized semantic score ranks
247
+ // unrelated findings above the threshold, so one dismissal would suppress every issue.
248
+ const { similarity, method: similarityMethod } = await calculateIssueSimilarity(issueDescription, dismissed.originalIssue, {
249
+ useSemanticSimilarity,
250
+ });
237
251
 
238
- if (similarity > similarityThreshold) {
252
+ if (similarity > threshold) {
239
253
  verboseLog(
240
254
  verbose,
241
255
  chalk.yellow(`⏭️ Skipping similar dismissed issue (${(similarity * 100).toFixed(1)}% ${similarityMethod} similarity)`)
@@ -249,6 +263,19 @@ export async function shouldSkipSimilarIssue(issueDescription, feedbackData, opt
249
263
  return false;
250
264
  }
251
265
 
266
+ /**
267
+ * Check whether a dismissed issue was recorded at the given file and line.
268
+ * A null on either side never matches, so an unknown location never narrows anything.
269
+ *
270
+ * @param {Object} dismissed - A dismissed feedback entry
271
+ * @param {string|null} filePath - Path of the current issue
272
+ * @param {number|null} lineNumber - Line of the current issue
273
+ * @returns {boolean} True when both locations are known and equal
274
+ */
275
+ function isSameLocation(dismissed, filePath, lineNumber) {
276
+ return Boolean(filePath && lineNumber != null && dismissed.filePath === filePath && dismissed.lineNumber === lineNumber);
277
+ }
278
+
252
279
  /**
253
280
  * Calculate combined similarity between two issue descriptions.
254
281
  * Uses both semantic and word-based similarity for robust comparison.
@@ -275,9 +302,7 @@ export async function calculateIssueSimilarity(text1, text2, options = {}) {
275
302
  // Also calculate word similarity for a hybrid score
276
303
  const wordSimilarity = calculateWordSimilarity(text1, text2);
277
304
 
278
- // Combine both scores with more weight on semantic similarity
279
- // This helps catch both semantically similar and lexically similar issues
280
- const combinedSimilarity = semanticSimilarity * 0.7 + wordSimilarity * 0.3;
305
+ const combinedSimilarity = semanticSimilarity * SEMANTIC_WEIGHT + wordSimilarity * WORD_WEIGHT;
281
306
 
282
307
  return {
283
308
  similarity: combinedSimilarity,
@@ -527,3 +527,117 @@ describe('loadFeedbackData edge cases', () => {
527
527
  expect(result).toHaveProperty('item');
528
528
  });
529
529
  });
530
+
531
+ describe('shouldSkipSimilarIssue without a comparable pair', () => {
532
+ // Nothing to compare means nothing to load the embedding model for.
533
+ let loader;
534
+ let initialize;
535
+
536
+ beforeEach(async () => {
537
+ mockConsoleSelective('log', 'warn');
538
+ vi.resetModules();
539
+ initialize = vi.fn().mockResolvedValue(undefined);
540
+
541
+ // eslint-disable-next-line no-restricted-syntax
542
+ const freshFactory = await import('./embeddings/factory.js');
543
+ freshFactory.getDefaultEmbeddingsSystem.mockReturnValue({ initialize, calculateEmbedding: vi.fn() });
544
+
545
+ // eslint-disable-next-line no-restricted-syntax
546
+ loader = await import('./feedback-loader.js');
547
+ });
548
+
549
+ it.each([
550
+ ['the only dismissal has no text of its own', 'a candidate description', { i1: { overallSentiment: 'negative', originalIssue: '' } }],
551
+ ['the candidate has no description', '', { i1: { overallSentiment: 'negative', originalIssue: 'a dismissed finding' } }],
552
+ ])('should not initialize the embeddings system when %s', async (_name, description, feedbackData) => {
553
+ await expect(loader.shouldSkipSimilarIssue(description, feedbackData)).resolves.toBe(false);
554
+
555
+ expect(initialize).not.toHaveBeenCalled();
556
+ });
557
+ });
558
+
559
+ describe('shouldSkipSimilarIssue location match', () => {
560
+ // Word similarity 0.667: above the same-location bar of 0.65, below the 0.9 passed here.
561
+ // Only the location can decide these cases.
562
+ const SHARED = 'alpha bravo charlie delta echo foxtrot golf hotel';
563
+ const DISMISSED = `${SHARED} india juliett`;
564
+ const REWORDED = `${SHARED} kilo lima`;
565
+ const UNRELATED = 'mike november oscar papa quebec';
566
+ const LOCATION = { filePath: '.github/workflows/pr-review.yml', lineNumber: 51 };
567
+
568
+ const feedbackData = {
569
+ issue1: {
570
+ overallSentiment: 'neutral',
571
+ originalIssue: DISMISSED,
572
+ userReplies: [{ body: 'False positive.' }],
573
+ ...LOCATION,
574
+ },
575
+ };
576
+
577
+ const check = (description, options) =>
578
+ shouldSkipSimilarIssue(description, feedbackData, { similarityThreshold: 0.9, useSemanticSimilarity: false, ...options });
579
+
580
+ it.each([
581
+ ['a reworded repeat at the same location', REWORDED, LOCATION, true],
582
+ ['a reworded repeat on a different line', REWORDED, { ...LOCATION, lineNumber: 52 }, false],
583
+ ['an unrelated finding at the same location', UNRELATED, LOCATION, false],
584
+ ['a reworded repeat with no known line', REWORDED, { ...LOCATION, lineNumber: null }, false],
585
+ ])('should %s -> %s', async (_name, description, options, expected) => {
586
+ await expect(check(description, options)).resolves.toBe(expected);
587
+ });
588
+ });
589
+
590
+ describe('shouldSkipSimilarIssue with semantic similarity available', () => {
591
+ // Unit vectors whose cosine similarity is exact: [1, 0] against [cos, sin].
592
+ const vectorFor = (cosine) => [cosine, Math.sqrt(1 - cosine * cosine)];
593
+ const DISMISSED = 'Missing null check in the handler function';
594
+ const REPHRASED = 'The handler function does not guard against a null argument';
595
+ const UNRELATED = 'Prefer const over let for bindings that never change';
596
+
597
+ let loader;
598
+
599
+ beforeEach(async () => {
600
+ mockConsoleSelective('log', 'warn');
601
+ vi.resetModules();
602
+
603
+ // eslint-disable-next-line no-restricted-syntax
604
+ const freshFactory = await import('./embeddings/factory.js');
605
+ freshFactory.getDefaultEmbeddingsSystem.mockReturnValue({
606
+ initialize: vi.fn().mockResolvedValue(undefined),
607
+ calculateEmbedding: vi.fn(async (text) => {
608
+ if (text === DISMISSED) {
609
+ return vectorFor(1);
610
+ }
611
+ return vectorFor(text === REPHRASED ? 0.98 : 0.8);
612
+ }),
613
+ });
614
+
615
+ // eslint-disable-next-line no-restricted-syntax
616
+ loader = await import('./feedback-loader.js');
617
+ await loader.initializeSemanticSimilarity();
618
+ });
619
+
620
+ it('should skip a dismissed issue that the model rephrased', async () => {
621
+ const feedbackData = {
622
+ issue1: {
623
+ overallSentiment: 'neutral',
624
+ originalIssue: DISMISSED,
625
+ userReplies: [{ body: 'False positive.' }],
626
+ },
627
+ };
628
+
629
+ await expect(loader.shouldSkipSimilarIssue(REPHRASED, feedbackData, { similarityThreshold: 0.7 })).resolves.toBe(true);
630
+ });
631
+
632
+ it('should not skip an unrelated issue once another issue was dismissed', async () => {
633
+ const feedbackData = {
634
+ issue1: {
635
+ overallSentiment: 'neutral',
636
+ originalIssue: DISMISSED,
637
+ userReplies: [{ body: 'False positive.' }],
638
+ },
639
+ };
640
+
641
+ await expect(loader.shouldSkipSimilarIssue(UNRELATED, feedbackData, { similarityThreshold: 0.7 })).resolves.toBe(false);
642
+ });
643
+ });
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
+ });
@@ -16,7 +16,7 @@ import {
16
16
  shouldSkipSimilarIssue,
17
17
  extractDismissedPatterns,
18
18
  generateFeedbackContext,
19
- initializeSemanticSimilarity,
19
+ DEFAULT_SIMILARITY_THRESHOLD,
20
20
  isSemanticSimilarityAvailable,
21
21
  } from './feedback-loader.js';
22
22
  import * as llm from './llm.js';
@@ -37,9 +37,6 @@ const DEFAULT_TRUNCATE_LINES = 300;
37
37
  const GUIDELINE_TRUNCATE_LINES = 400;
38
38
  const MAX_PR_COMMENTS_FOR_CONTEXT = 15;
39
39
 
40
- // Track if semantic similarity has been initialized
41
- let semanticSimilarityInitialized = false;
42
-
43
40
  async function fileExists(filePath) {
44
41
  try {
45
42
  await fs.promises.access(filePath);
@@ -54,26 +51,6 @@ async function readTextFile(filePath) {
54
51
  return await fs.promises.readFile(filePath, 'utf8');
55
52
  }
56
53
 
57
- /**
58
- * Initialize semantic similarity for feedback filtering
59
- * Uses the shared embeddings system from feedback-loader.js
60
- */
61
- async function ensureSemanticSimilarityInitialized() {
62
- if (semanticSimilarityInitialized) {
63
- return;
64
- }
65
-
66
- try {
67
- // Initialize semantic similarity using the shared embeddings system
68
- await initializeSemanticSimilarity();
69
- semanticSimilarityInitialized = true;
70
- }
71
- catch (error) {
72
- console.warn(chalk.yellow(`⚠️ Could not initialize semantic similarity: ${error.message}`));
73
- // Continue without semantic similarity - word-based fallback will be used
74
- }
75
- }
76
-
77
54
  // ============================================================================
78
55
  // PROMPT FORMATTING HELPERS
79
56
  // Note: Static prompt content (critical rules, citation requirements, etc.) is
@@ -578,7 +555,7 @@ async function runAnalysis(filePath, options = {}) {
578
555
  if (options.trackFeedback && feedbackData && Object.keys(feedbackData).length > 0) {
579
556
  verboseLog(options, chalk.cyan('--- Filtering Results Based on Feedback ---'));
580
557
  filteredResults = await filterAnalysisResults(lowSeverityFiltered, feedbackData, {
581
- similarityThreshold: options.feedbackThreshold || 0.7,
558
+ similarityThreshold: options.feedbackThreshold || DEFAULT_SIMILARITY_THRESHOLD,
582
559
  verbose: options.verbose,
583
560
  });
584
561
  }
@@ -764,7 +741,6 @@ function prepareContextForLLM(filePath, content, language, finalCodeExamples, fi
764
741
  async function callLLMForAnalysis(context, options = {}) {
765
742
  try {
766
743
  let prompt;
767
- const model = options.model || 'claude-sonnet-4-6';
768
744
  const maxTokens = options.maxTokens || 8192; // Default to a safe limit
769
745
 
770
746
  if (options.isHolisticPRReview) {
@@ -778,7 +754,7 @@ async function callLLMForAnalysis(context, options = {}) {
778
754
  const llmResponse = await sendPromptToLLM(prompt, {
779
755
  temperature: 0,
780
756
  maxTokens: maxTokens,
781
- model: model,
757
+ model: options.model,
782
758
  isJsonMode: true, // Standardize on using JSON mode if available
783
759
  verbose: options.verbose || false, // Pass verbose flag for cache statistics
784
760
  cacheTtl: options.cacheTtl || '5m', // Pass cache TTL option (default: 5m, no extra cost)
@@ -2492,12 +2468,12 @@ function filterLowSeverityIssues(analysisResults, options = {}) {
2492
2468
  * @param {Object} analysisResults - Raw analysis results from LLM
2493
2469
  * @param {Object} feedbackData - Loaded feedback data
2494
2470
  * @param {Object} options - Filtering options
2495
- * @param {number} [options.similarityThreshold=0.7] - Threshold for considering issues similar to dismissed feedback
2471
+ * @param {number} [options.similarityThreshold=DEFAULT_SIMILARITY_THRESHOLD] - Threshold for considering issues similar to dismissed feedback
2496
2472
  * @param {boolean} [options.verbose=false] - Enable verbose similarity and filtering logs
2497
2473
  * @returns {Promise<Object>} Filtered analysis results
2498
2474
  */
2499
2475
  async function filterAnalysisResults(analysisResults, feedbackData, options = {}) {
2500
- const { similarityThreshold = 0.7, verbose = false } = options;
2476
+ const { similarityThreshold = DEFAULT_SIMILARITY_THRESHOLD, verbose = false } = options;
2501
2477
 
2502
2478
  if (!analysisResults || !analysisResults.issues || !Array.isArray(analysisResults.issues)) {
2503
2479
  return analysisResults;
@@ -2505,16 +2481,6 @@ async function filterAnalysisResults(analysisResults, feedbackData, options = {}
2505
2481
 
2506
2482
  const originalCount = analysisResults.issues.length;
2507
2483
 
2508
- // Ensure semantic similarity is initialized for better matching
2509
- await ensureSemanticSimilarityInitialized();
2510
-
2511
- // Log whether semantic similarity is available
2512
- const usingSemanticSimilarity = isSemanticSimilarityAvailable();
2513
- verboseLog(
2514
- verbose,
2515
- chalk.cyan(`🔍 Filtering issues using ${usingSemanticSimilarity ? 'semantic + word-based similarity' : 'word-based similarity only'}`)
2516
- );
2517
-
2518
2484
  // Filter issues based on feedback (now async due to semantic similarity)
2519
2485
  const filterResults = await Promise.all(
2520
2486
  analysisResults.issues.map(async (issue, index) => {
@@ -59,6 +59,7 @@ vi.mock('./feedback-loader.js', () => ({
59
59
  generateFeedbackContext: vi.fn().mockReturnValue(''),
60
60
  initializeSemanticSimilarity: vi.fn().mockResolvedValue(undefined),
61
61
  isSemanticSimilarityAvailable: vi.fn().mockReturnValue(false),
62
+ DEFAULT_SIMILARITY_THRESHOLD: 0.7,
62
63
  }));
63
64
 
64
65
  vi.mock('./llm.js', () => ({
@@ -242,6 +243,12 @@ describe('rag-analyzer', () => {
242
243
  expect(result.success).toBe(true);
243
244
  });
244
245
 
246
+ it('should forward an explicit model option to the LLM', async () => {
247
+ await runAnalysis('/test/file.js', { model: 'claude-3-opus' });
248
+
249
+ expect(llm.sendPromptToClaude).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ model: 'claude-3-opus' }));
250
+ });
251
+
245
252
  it('should handle diff-only mode', async () => {
246
253
  const result = await runAnalysis('/test/file.js', {
247
254
  diffOnly: true,