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 +1 -1
- package/package.json +1 -1
- package/src/feedback-loader.js +70 -45
- package/src/feedback-loader.test.js +114 -0
- package/src/index.js +1 -1
- package/src/llm.js +29 -3
- package/src/llm.test.js +113 -4
- package/src/project-analyzer.js +1 -0
- package/src/project-analyzer.test.js +7 -1
- package/src/prompt-cache.js +6 -5
- package/src/prompt-cache.test.js +61 -0
- package/src/rag-analyzer.js +5 -39
- package/src/rag-analyzer.test.js +7 -0
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-
|
|
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
package/src/feedback-loader.js
CHANGED
|
@@ -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
|
-
*
|
|
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=
|
|
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 {
|
|
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
|
-
//
|
|
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?.
|
|
194
|
-
feedback?.
|
|
195
|
-
(
|
|
196
|
-
reply
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
//
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
215
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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 >
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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
|
|
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-
|
|
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-
|
|
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-
|
|
239
|
+
model: 'claude-sonnet-5',
|
|
131
240
|
messages: [{ role: 'user', content: 'Review this code' }],
|
|
132
241
|
}),
|
|
133
242
|
},
|
package/src/project-analyzer.js
CHANGED
|
@@ -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(
|
|
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);
|
package/src/prompt-cache.js
CHANGED
|
@@ -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
|
-
"
|
|
251
|
+
"message": "Detailed description of an issue that spans multiple files...",
|
|
252
252
|
"suggestion": "Actionable suggestion to resolve the cross-file issue.",
|
|
253
|
-
"
|
|
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
|
-
"
|
|
276
|
-
"
|
|
277
|
-
"
|
|
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
|
+
});
|
package/src/rag-analyzer.js
CHANGED
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
shouldSkipSimilarIssue,
|
|
17
17
|
extractDismissedPatterns,
|
|
18
18
|
generateFeedbackContext,
|
|
19
|
-
|
|
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 ||
|
|
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=
|
|
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 =
|
|
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) => {
|
package/src/rag-analyzer.test.js
CHANGED
|
@@ -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,
|