codecritique 2.3.0 → 2.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codecritique",
3
- "version": "2.3.0",
3
+ "version": "2.3.2",
4
4
  "description": "AI-powered code review tool for any programming language",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -63,7 +63,7 @@
63
63
  "fastembed": "2.1.0",
64
64
  "glob": "13.0.6",
65
65
  "linguist-languages": "9.5.0",
66
- "lru-cache": "11.5.1",
66
+ "lru-cache": "11.5.2",
67
67
  "minimatch": "10.2.5",
68
68
  "stopwords-iso": "1.1.0"
69
69
  },
@@ -78,8 +78,8 @@
78
78
  "@vitest/eslint-plugin": "1.6.27",
79
79
  "eslint": "9.39.1",
80
80
  "eslint-plugin-import": "2.32.0",
81
- "globals": "16.5.0",
82
- "knip": "5.70.2",
81
+ "globals": "17.12.0",
82
+ "knip": "6.36.0",
83
83
  "prettier": "3.9.6",
84
84
  "prettier-plugin-brace-style": "0.10.3",
85
85
  "typescript": "5.9.3",
@@ -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
+ });
@@ -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
  }
@@ -2491,12 +2468,12 @@ function filterLowSeverityIssues(analysisResults, options = {}) {
2491
2468
  * @param {Object} analysisResults - Raw analysis results from LLM
2492
2469
  * @param {Object} feedbackData - Loaded feedback data
2493
2470
  * @param {Object} options - Filtering options
2494
- * @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
2495
2472
  * @param {boolean} [options.verbose=false] - Enable verbose similarity and filtering logs
2496
2473
  * @returns {Promise<Object>} Filtered analysis results
2497
2474
  */
2498
2475
  async function filterAnalysisResults(analysisResults, feedbackData, options = {}) {
2499
- const { similarityThreshold = 0.7, verbose = false } = options;
2476
+ const { similarityThreshold = DEFAULT_SIMILARITY_THRESHOLD, verbose = false } = options;
2500
2477
 
2501
2478
  if (!analysisResults || !analysisResults.issues || !Array.isArray(analysisResults.issues)) {
2502
2479
  return analysisResults;
@@ -2504,16 +2481,6 @@ async function filterAnalysisResults(analysisResults, feedbackData, options = {}
2504
2481
 
2505
2482
  const originalCount = analysisResults.issues.length;
2506
2483
 
2507
- // Ensure semantic similarity is initialized for better matching
2508
- await ensureSemanticSimilarityInitialized();
2509
-
2510
- // Log whether semantic similarity is available
2511
- const usingSemanticSimilarity = isSemanticSimilarityAvailable();
2512
- verboseLog(
2513
- verbose,
2514
- chalk.cyan(`🔍 Filtering issues using ${usingSemanticSimilarity ? 'semantic + word-based similarity' : 'word-based similarity only'}`)
2515
- );
2516
-
2517
2484
  // Filter issues based on feedback (now async due to semantic similarity)
2518
2485
  const filterResults = await Promise.all(
2519
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', () => ({