@rigour-labs/core 6.5.1 → 6.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/README.md +1 -1
  2. package/dist/context/automatic-index.d.ts +5 -0
  3. package/dist/context/automatic-index.js +24 -3
  4. package/dist/context/index-status.test.d.ts +1 -0
  5. package/dist/context/index-status.test.js +26 -0
  6. package/dist/deep/fact-extractor.js +2 -1
  7. package/dist/deep/fact-extractor.test.js +2 -1
  8. package/dist/gates/ast.js +4 -2
  9. package/dist/gates/coverage.js +4 -2
  10. package/dist/gates/dedupe-failures.test.d.ts +1 -0
  11. package/dist/gates/dedupe-failures.test.js +12 -0
  12. package/dist/gates/deprecated-dependencies.d.ts +27 -0
  13. package/dist/gates/deprecated-dependencies.js +104 -0
  14. package/dist/gates/deprecated-dependencies.test.d.ts +1 -0
  15. package/dist/gates/deprecated-dependencies.test.js +55 -0
  16. package/dist/gates/runner.d.ts +7 -1
  17. package/dist/gates/runner.js +25 -11
  18. package/dist/gates/security-patterns-data.js +2 -1
  19. package/dist/gates/security-patterns-owasp.test.js +15 -0
  20. package/dist/gates/semantic-bugs.js +1 -1
  21. package/dist/gates/unindexed-reads/coverage.d.ts +15 -0
  22. package/dist/gates/unindexed-reads/coverage.js +24 -0
  23. package/dist/gates/unindexed-reads/index.d.ts +20 -0
  24. package/dist/gates/unindexed-reads/index.js +98 -0
  25. package/dist/gates/unindexed-reads/queries.d.ts +17 -0
  26. package/dist/gates/unindexed-reads/queries.js +147 -0
  27. package/dist/gates/unindexed-reads/queries.test.d.ts +1 -0
  28. package/dist/gates/unindexed-reads/queries.test.js +54 -0
  29. package/dist/gates/unindexed-reads/schema.d.ts +38 -0
  30. package/dist/gates/unindexed-reads/schema.js +256 -0
  31. package/dist/gates/unindexed-reads/schema.test.d.ts +1 -0
  32. package/dist/gates/unindexed-reads/schema.test.js +81 -0
  33. package/dist/gates/unindexed-reads/sql.d.ts +21 -0
  34. package/dist/gates/unindexed-reads/sql.js +131 -0
  35. package/dist/gates/unindexed-reads/unindexed-reads.test.d.ts +1 -0
  36. package/dist/gates/unindexed-reads/unindexed-reads.test.js +90 -0
  37. package/dist/index.d.ts +4 -1
  38. package/dist/index.js +4 -1
  39. package/dist/inference/brain-placeholder.test.d.ts +1 -0
  40. package/dist/inference/brain-placeholder.test.js +28 -0
  41. package/dist/inference/llama-engine-checksum.test.d.ts +1 -0
  42. package/dist/inference/llama-engine-checksum.test.js +27 -0
  43. package/dist/inference/llama-engine.d.ts +4 -1
  44. package/dist/inference/llama-engine.js +15 -8
  45. package/dist/memory/recall.d.ts +37 -0
  46. package/dist/memory/recall.js +93 -0
  47. package/dist/memory/recall.test.d.ts +1 -0
  48. package/dist/memory/recall.test.js +36 -0
  49. package/dist/pattern-index/assess.d.ts +16 -0
  50. package/dist/pattern-index/assess.js +37 -0
  51. package/dist/pattern-index/embeddings.d.ts +19 -0
  52. package/dist/pattern-index/embeddings.js +16 -0
  53. package/dist/pattern-index/index.d.ts +3 -2
  54. package/dist/pattern-index/index.js +4 -2
  55. package/dist/pattern-index/indexer.js +19 -9
  56. package/dist/pattern-index/matcher.d.ts +6 -0
  57. package/dist/pattern-index/matcher.js +13 -3
  58. package/dist/pattern-index/pattern-reuse.test.d.ts +1 -0
  59. package/dist/pattern-index/pattern-reuse.test.js +76 -0
  60. package/dist/pattern-index/staleness.d.ts +2 -15
  61. package/dist/pattern-index/staleness.js +1 -78
  62. package/dist/pattern-index/types.d.ts +2 -0
  63. package/dist/review/agent-fixes.d.ts +4 -0
  64. package/dist/review/agent-fixes.js +10 -7
  65. package/dist/review/agent-fixes.test.js +4 -4
  66. package/dist/review/changed-lines.d.ts +2 -1
  67. package/dist/review/changed-lines.js +12 -2
  68. package/dist/review/check-outcomes.d.ts +31 -0
  69. package/dist/review/check-outcomes.js +81 -0
  70. package/dist/review/check-outcomes.test.d.ts +1 -0
  71. package/dist/review/check-outcomes.test.js +56 -0
  72. package/dist/review/migration-order.d.ts +5 -0
  73. package/dist/review/migration-order.js +70 -0
  74. package/dist/review/migration-order.test.d.ts +1 -0
  75. package/dist/review/migration-order.test.js +62 -0
  76. package/dist/review/quiet.d.ts +2 -0
  77. package/dist/review/quiet.js +12 -3
  78. package/dist/review/review.d.ts +4 -0
  79. package/dist/review/review.js +17 -5
  80. package/dist/review/review.test.js +36 -1
  81. package/dist/semantic/ast.d.ts +1 -0
  82. package/dist/semantic/ast.js +4 -0
  83. package/dist/semantic/learn/compile.js +3 -1
  84. package/dist/semantic/learn/learn.js +40 -5
  85. package/dist/semantic/types.d.ts +2 -0
  86. package/dist/services/context-scope-summary.test.d.ts +1 -0
  87. package/dist/services/context-scope-summary.test.js +17 -0
  88. package/dist/services/context-telemetry-service.js +2 -1
  89. package/dist/storage/context-telemetry.js +33 -19
  90. package/dist/storage/db.js +52 -1
  91. package/dist/storage/findings.js +1 -1
  92. package/dist/storage/fix-lessons.d.ts +13 -0
  93. package/dist/storage/fix-lessons.js +84 -0
  94. package/dist/storage/fix-lessons.test.d.ts +1 -0
  95. package/dist/storage/fix-lessons.test.js +61 -0
  96. package/dist/storage/index.d.ts +2 -1
  97. package/dist/storage/index.js +2 -1
  98. package/dist/storage/lessons.d.ts +16 -2
  99. package/dist/storage/lessons.js +27 -61
  100. package/dist/storage/lessons.test.js +1 -15
  101. package/dist/storage/local-memory.js +39 -5
  102. package/dist/storage/local-memory.test.d.ts +1 -0
  103. package/dist/storage/local-memory.test.js +55 -0
  104. package/dist/storage/patterns.d.ts +1 -1
  105. package/dist/storage/patterns.js +1 -1
  106. package/dist/storage/share-memory.test.d.ts +1 -0
  107. package/dist/storage/share-memory.test.js +34 -0
  108. package/dist/storage/team-vector-store.js +2 -1
  109. package/dist/storage/telemetry-scope.test.d.ts +1 -0
  110. package/dist/storage/telemetry-scope.test.js +38 -0
  111. package/dist/templates/universal-config.js +11 -0
  112. package/dist/types/index.d.ts +132 -0
  113. package/dist/types/index.js +15 -0
  114. package/dist/utils/glob-paths.d.ts +4 -0
  115. package/dist/utils/glob-paths.js +26 -0
  116. package/dist/utils/glob-paths.test.d.ts +1 -0
  117. package/dist/utils/glob-paths.test.js +40 -0
  118. package/dist/utils/scanner.js +4 -2
  119. package/dist/utils/scanner.test.js +2 -1
  120. package/package.json +7 -7
@@ -0,0 +1,36 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { RECALL_LIMIT, rankMemories } from './recall.js';
3
+ const memories = [
4
+ { scope: 'repo', key: 'retry_policy', value: 'Wrap network calls in withRetry from lib/net; never hand-roll backoff.' },
5
+ { scope: 'repo', key: 'db_access', value: 'Reports read from the replica, never the primary.' },
6
+ { scope: 'user', key: 'pr_size', value: 'Keep pull requests under 400 lines.' },
7
+ ];
8
+ /** A stand-in embedding: one dimension per topic word, so similarity is predictable. */
9
+ const TOPICS = ['retry', 'backoff', 'network', 'replica', 'report', 'pull', 'request'];
10
+ const embed = async (text) => TOPICS.map(topic => (text.toLowerCase().includes(topic) ? 1 : 0));
11
+ describe('rankMemories', () => {
12
+ it('returns the memories that match by meaning, best first, above the floor', async () => {
13
+ const ranked = await rankMemories('add exponential backoff to a flaky network call', memories, embed);
14
+ expect(ranked.map(m => [m.key, m.match])).toEqual([['retry_policy', 'semantic']]);
15
+ });
16
+ it('returns nothing rather than the closest weak match', async () => {
17
+ expect(await rankMemories('rename a css class', memories, embed)).toEqual([]);
18
+ });
19
+ it('falls back to keyword overlap when the model is unavailable, and says so', async () => {
20
+ const ranked = await rankMemories('replica reports', memories, async () => []);
21
+ expect(ranked.map(m => [m.key, m.match])).toEqual([['db_access', 'keyword']]);
22
+ });
23
+ it(`returns at most ${RECALL_LIMIT}`, async () => {
24
+ const many = Array.from({ length: 10 }, (_, i) => ({ scope: 'repo', key: `retry_${i}`, value: 'network retry backoff' }));
25
+ expect(await rankMemories('network retry backoff', many, embed)).toHaveLength(RECALL_LIMIT);
26
+ });
27
+ it('embeds each text once, not on every recall', async () => {
28
+ let calls = 0;
29
+ const counting = async (text) => { calls++; return embed(text); };
30
+ const fresh = memories.map(m => ({ ...m, value: `${m.value} (cache test)` }));
31
+ await rankMemories('network retry', fresh, counting);
32
+ const first = calls;
33
+ await rankMemories('replica reports', fresh, counting);
34
+ expect(calls - first).toBe(1); // only the new query
35
+ });
36
+ });
@@ -0,0 +1,16 @@
1
+ import type { PatternIndex, PatternMatch, StalenessIssue } from './types.js';
2
+ export interface PatternQuery {
3
+ name: string;
4
+ type?: string;
5
+ intent?: string;
6
+ signature?: string;
7
+ keywords?: string[];
8
+ }
9
+ export interface PatternAssessment {
10
+ action: 'BLOCK' | 'WARN' | 'ALLOW';
11
+ match?: PatternMatch;
12
+ suggestion: string;
13
+ /** Deprecated usage inside the matched function, if any. */
14
+ deprecations: StalenessIssue[];
15
+ }
16
+ export declare function assessPattern(cwd: string, index: PatternIndex, query: PatternQuery): Promise<PatternAssessment>;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Before writing a function: does the codebase already have it, and is what
3
+ * would be reused itself out of date? Shared by the MCP rigour_check_pattern
4
+ * tool and the CLI `rigour check-pattern`.
5
+ *
6
+ * BLOCK means an exact or near-certain match (reuse it); WARN means a similar
7
+ * function exists (look before writing). Deprecations come from the matched
8
+ * function's own source, never from its name.
9
+ */
10
+ import fs from 'fs-extra';
11
+ import path from 'path';
12
+ import { PatternMatcher } from './matcher.js';
13
+ import { StalenessDetector } from './staleness.js';
14
+ export async function assessPattern(cwd, index, query) {
15
+ const result = await new PatternMatcher(index).match(query);
16
+ if (result.status !== 'FOUND_SIMILAR')
17
+ return { action: 'ALLOW', suggestion: '', deprecations: [] };
18
+ const match = result.matches[0];
19
+ const source = await readPatternSource(cwd, match.pattern);
20
+ const staleness = source ? await new StalenessDetector(cwd).checkStaleness(source, match.pattern.file) : undefined;
21
+ return {
22
+ action: result.action,
23
+ match,
24
+ suggestion: result.suggestion,
25
+ deprecations: staleness && staleness.status !== 'FRESH' ? staleness.issues : [],
26
+ };
27
+ }
28
+ /** The lines a pattern spans, or undefined when its file cannot be read. */
29
+ async function readPatternSource(cwd, pattern) {
30
+ try {
31
+ const lines = (await fs.readFile(path.join(cwd, pattern.file), 'utf8')).split('\n');
32
+ return lines.slice(Math.max(0, pattern.line - 1), pattern.endLine).join('\n');
33
+ }
34
+ catch {
35
+ return undefined;
36
+ }
37
+ }
@@ -7,6 +7,25 @@
7
7
  * Generate an embedding for a piece of text.
8
8
  */
9
9
  export declare function generateEmbedding(text: string): Promise<number[]>;
10
+ /**
11
+ * Version of the text patterns are embedded by. An index embedded with an older text is
12
+ * re-embedded on its next enrichment.
13
+ * 2: the name split into words (`retryWithBackoff` → "retry with backoff"). Measured by
14
+ * scripts/eval/pattern-intent.mjs: recall by intent 87% at 0.30 with no false matches,
15
+ * against 13% for the raw name at the old 0.60 bar.
16
+ */
17
+ export declare const EMBEDDING_TEXT_VERSION = 2;
18
+ export declare function patternEmbeddingText(pattern: {
19
+ name: string;
20
+ type: string;
21
+ description?: string;
22
+ }): string;
23
+ /** A pattern's embedding, rounded to 4 decimals: a third of the size on disk, no change to ranking. */
24
+ export declare function embedPattern(pattern: {
25
+ name: string;
26
+ type: string;
27
+ description?: string;
28
+ }): Promise<number[]>;
10
29
  /**
11
30
  * Calculate cosine similarity between two vectors.
12
31
  */
@@ -56,6 +56,22 @@ export async function generateEmbedding(text) {
56
56
  return [];
57
57
  }
58
58
  }
59
+ /**
60
+ * Version of the text patterns are embedded by. An index embedded with an older text is
61
+ * re-embedded on its next enrichment.
62
+ * 2: the name split into words (`retryWithBackoff` → "retry with backoff"). Measured by
63
+ * scripts/eval/pattern-intent.mjs: recall by intent 87% at 0.30 with no false matches,
64
+ * against 13% for the raw name at the old 0.60 bar.
65
+ */
66
+ export const EMBEDDING_TEXT_VERSION = 2;
67
+ export function patternEmbeddingText(pattern) {
68
+ const words = pattern.name.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/[_-]+/g, ' ').toLowerCase();
69
+ return `${words} ${pattern.type} ${pattern.description ?? ''}`.trim();
70
+ }
71
+ /** A pattern's embedding, rounded to 4 decimals: a third of the size on disk, no change to ranking. */
72
+ export async function embedPattern(pattern) {
73
+ return (await generateEmbedding(patternEmbeddingText(pattern))).map(x => Math.round(x * 1e4) / 1e4);
74
+ }
59
75
  /**
60
76
  * Calculate cosine similarity between two vectors.
61
77
  */
@@ -5,8 +5,9 @@
5
5
  */
6
6
  export type { PatternType, PatternEntry, PatternIndex, PatternIndexConfig, PatternIndexStats, IndexedFile, PatternMatchResult, PatternMatch, PatternOverride, StalenessResult, StalenessIssue, DeprecationEntry } from './types.js';
7
7
  export { PatternIndexer, savePatternIndex, loadPatternIndex, getDefaultIndexPath } from './indexer.js';
8
- export { PatternMatcher, checkPatternDuplicate, type MatcherConfig } from './matcher.js';
8
+ export { PatternMatcher, checkPatternDuplicate, SEMANTIC_MATCH_FLOOR, type MatcherConfig } from './matcher.js';
9
+ export { assessPattern, type PatternQuery, type PatternAssessment } from './assess.js';
9
10
  export { StalenessDetector, checkCodeStaleness } from './staleness.js';
10
11
  export { SecurityDetector } from './security.js';
11
12
  export { OverrideManager, loadConfigOverrides } from './overrides.js';
12
- export { generateEmbedding, semanticSearch, cosineSimilarity } from './embeddings.js';
13
+ export { generateEmbedding, semanticSearch, cosineSimilarity, embedPattern, patternEmbeddingText, EMBEDDING_TEXT_VERSION } from './embeddings.js';
@@ -6,7 +6,9 @@
6
6
  // Indexer
7
7
  export { PatternIndexer, savePatternIndex, loadPatternIndex, getDefaultIndexPath } from './indexer.js';
8
8
  // Matcher
9
- export { PatternMatcher, checkPatternDuplicate } from './matcher.js';
9
+ export { PatternMatcher, checkPatternDuplicate, SEMANTIC_MATCH_FLOOR } from './matcher.js';
10
+ // Reuse check before writing
11
+ export { assessPattern } from './assess.js';
10
12
  // Staleness Detection
11
13
  export { StalenessDetector, checkCodeStaleness } from './staleness.js';
12
14
  // Security Detection
@@ -14,4 +16,4 @@ export { SecurityDetector } from './security.js';
14
16
  // Override Management
15
17
  export { OverrideManager, loadConfigOverrides } from './overrides.js';
16
18
  // Embeddings
17
- export { generateEmbedding, semanticSearch, cosineSimilarity } from './embeddings.js';
19
+ export { generateEmbedding, semanticSearch, cosineSimilarity, embedPattern, patternEmbeddingText, EMBEDDING_TEXT_VERSION } from './embeddings.js';
@@ -13,10 +13,11 @@ import * as path from 'path';
13
13
  import { globby } from 'globby';
14
14
  import ts from 'typescript';
15
15
  import micromatch from 'micromatch';
16
- import { generateEmbedding } from './embeddings.js';
16
+ import { EMBEDDING_TEXT_VERSION, embedPattern } from './embeddings.js';
17
17
  import { hashContent } from './indexer-helpers.js';
18
18
  import { extractGoPatterns, extractRustPatterns, extractJVMStylePatterns, extractPythonPatterns, extractGenericCPatterns, } from './indexer-lang.js';
19
19
  import { nodeToPattern } from './indexer-ts.js';
20
+ import { directoriesAsGlobs, withDirectoryForms } from '../utils/glob-paths.js';
20
21
  // ---------------------------------------------------------------------------
21
22
  // Configuration
22
23
  // ---------------------------------------------------------------------------
@@ -87,7 +88,7 @@ export class PatternIndexer {
87
88
  for (let i = 0; i < patterns.length; i += BATCH_SIZE) {
88
89
  const batch = patterns.slice(i, i + BATCH_SIZE);
89
90
  await Promise.all(batch.map(async (pattern) => {
90
- pattern.embedding = await generateEmbedding(`${pattern.name} ${pattern.type} ${pattern.description}`);
91
+ pattern.embedding = await embedPattern(pattern);
91
92
  }));
92
93
  }
93
94
  }
@@ -95,6 +96,8 @@ export class PatternIndexer {
95
96
  const stats = this.calculateStats(patterns, indexedFiles, endTime - startTime);
96
97
  return {
97
98
  version: INDEX_VERSION,
99
+ // Set only when this build embedded the patterns.
100
+ ...(this.config.useEmbeddings ? { embeddingTextVersion: EMBEDDING_TEXT_VERSION } : {}),
98
101
  lastUpdated: new Date().toISOString(),
99
102
  rootDir: this.rootDir,
100
103
  patterns,
@@ -141,7 +144,7 @@ export class PatternIndexer {
141
144
  const batch = updatedPatterns.slice(i, i + BATCH_SIZE);
142
145
  await Promise.all(batch.map(async (pattern) => {
143
146
  if (!pattern.embedding) {
144
- pattern.embedding = await generateEmbedding(`${pattern.name} ${pattern.type} ${pattern.description}`);
147
+ pattern.embedding = await embedPattern(pattern);
145
148
  }
146
149
  }));
147
150
  }
@@ -150,6 +153,8 @@ export class PatternIndexer {
150
153
  const stats = this.calculateStats(updatedPatterns, updatedFiles, endTime - startTime);
151
154
  return {
152
155
  version: INDEX_VERSION,
156
+ // Kept from the index being updated, so an incremental update never looks stale.
157
+ embeddingTextVersion: this.config.useEmbeddings ? EMBEDDING_TEXT_VERSION : existingIndex.embeddingTextVersion,
153
158
  lastUpdated: new Date().toISOString(),
154
159
  rootDir: this.rootDir,
155
160
  patterns: updatedPatterns,
@@ -181,7 +186,7 @@ export class PatternIndexer {
181
186
  const filePatterns = await this.extractPatterns(absolutePath, content);
182
187
  if (this.config.useEmbeddings) {
183
188
  await Promise.all(filePatterns.map(async (pattern) => {
184
- pattern.embedding = await generateEmbedding(`${pattern.name} ${pattern.type} ${pattern.description}`);
189
+ pattern.embedding = await embedPattern(pattern);
185
190
  }));
186
191
  }
187
192
  patterns.push(...filePatterns);
@@ -199,6 +204,7 @@ export class PatternIndexer {
199
204
  }
200
205
  return {
201
206
  version: INDEX_VERSION,
207
+ embeddingTextVersion: this.config.useEmbeddings ? EMBEDDING_TEXT_VERSION : existingIndex.embeddingTextVersion,
202
208
  lastUpdated: new Date().toISOString(),
203
209
  rootDir: this.rootDir,
204
210
  patterns,
@@ -209,20 +215,23 @@ export class PatternIndexer {
209
215
  async enrichIndex(existingIndex) {
210
216
  const startTime = Date.now();
211
217
  const patterns = existingIndex.patterns.map(pattern => ({ ...pattern }));
212
- const pending = patterns.filter(pattern => !pattern.embedding?.length);
218
+ // An index embedded from an older text is re-embedded whole: mixed vectors would not compare.
219
+ const stale = existingIndex.embeddingTextVersion !== EMBEDDING_TEXT_VERSION;
220
+ const pending = patterns.filter(pattern => stale || !pattern.embedding?.length);
213
221
  if (pending.length > 0) {
214
- pending[0].embedding = await generateEmbedding(`${pending[0].name} ${pending[0].type} ${pending[0].description}`);
222
+ pending[0].embedding = await embedPattern(pending[0]);
215
223
  if (!pending[0].embedding.length)
216
224
  return existingIndex;
217
225
  }
218
226
  const batchSize = 10;
219
227
  for (let i = 1; i < pending.length; i += batchSize) {
220
228
  await Promise.all(pending.slice(i, i + batchSize).map(async (pattern) => {
221
- pattern.embedding = await generateEmbedding(`${pattern.name} ${pattern.type} ${pattern.description}`);
229
+ pattern.embedding = await embedPattern(pattern);
222
230
  }));
223
231
  }
224
232
  return {
225
233
  ...existingIndex,
234
+ embeddingTextVersion: EMBEDDING_TEXT_VERSION,
226
235
  lastUpdated: new Date().toISOString(),
227
236
  patterns,
228
237
  stats: this.calculateStats(patterns, existingIndex.files, Date.now() - startTime),
@@ -239,10 +248,11 @@ export class PatternIndexer {
239
248
  if (!this.config.indexTests) {
240
249
  exclude.push('**/*.test.*', '**/*.spec.*', '**/__tests__/**');
241
250
  }
242
- return globby(patterns, {
251
+ return globby(directoriesAsGlobs(this.rootDir, patterns), {
243
252
  cwd: this.rootDir,
244
253
  absolute: true,
245
- ignore: exclude,
254
+ ignore: withDirectoryForms(exclude),
255
+ expandDirectories: false,
246
256
  gitignore: true,
247
257
  });
248
258
  }
@@ -8,6 +8,12 @@
8
8
  * 4. Keyword/semantic match
9
9
  */
10
10
  import type { PatternIndex, PatternMatchResult, PatternOverride } from './types.js';
11
+ /**
12
+ * The similarity a match by meaning needs. Measured by scripts/eval/pattern-intent.mjs: at 0.30,
13
+ * 87% of intents found the existing function with no false matches; name-based strategies keep
14
+ * minConfidence, which this cosine scale does not share.
15
+ */
16
+ export declare const SEMANTIC_MATCH_FLOOR = 0.3;
11
17
  /**
12
18
  * Configuration for pattern matching.
13
19
  */
@@ -7,7 +7,13 @@
7
7
  * 3. Signature match
8
8
  * 4. Keyword/semantic match
9
9
  */
10
- import { generateEmbedding, cosineSimilarity } from './embeddings.js';
10
+ import { generateEmbedding, cosineSimilarity, patternEmbeddingText } from './embeddings.js';
11
+ /**
12
+ * The similarity a match by meaning needs. Measured by scripts/eval/pattern-intent.mjs: at 0.30,
13
+ * 87% of intents found the existing function with no false matches; name-based strategies keep
14
+ * minConfidence, which this cosine scale does not share.
15
+ */
16
+ export const SEMANTIC_MATCH_FLOOR = 0.3;
11
17
  /** Default matcher configuration */
12
18
  const DEFAULT_MATCHER_CONFIG = {
13
19
  minConfidence: 60,
@@ -48,7 +54,8 @@ export class PatternMatcher {
48
54
  // Pre-calculate query embedding if semantic search is enabled
49
55
  let queryEmbedding = null;
50
56
  if (this.config.useEmbeddings && (query.intent || query.name)) {
51
- queryEmbedding = await generateEmbedding(`${query.name || ''} ${query.intent || ''}`);
57
+ // The same text shape patterns are embedded by: the name split into words, then the intent.
58
+ queryEmbedding = await generateEmbedding(`${patternEmbeddingText({ name: query.name || '', type: '' })} ${query.intent || ''}`.trim());
52
59
  }
53
60
  // Check for override first
54
61
  if (query.name && this.hasOverride(query.name)) {
@@ -118,7 +125,10 @@ export class PatternMatcher {
118
125
  maxConfidence = confidence;
119
126
  }
120
127
  }
121
- if (currentBest && maxConfidence >= this.config.minConfidence) {
128
+ const accepted = currentBest?.matchType === 'semantic'
129
+ ? maxConfidence >= SEMANTIC_MATCH_FLOOR * 100
130
+ : maxConfidence >= this.config.minConfidence;
131
+ if (currentBest && accepted) {
122
132
  matches.push(currentBest);
123
133
  }
124
134
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,76 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
+ /** Topic vectors: one dimension per word, so similarity between texts is predictable. */
6
+ const TOPICS = ['retry', 'backoff', 'request', 'date', 'format', 'chart'];
7
+ const topicVector = (text) => TOPICS.map(t => (text.toLowerCase().includes(t) ? 1 : 0));
8
+ vi.mock('./embeddings.js', async (importOriginal) => {
9
+ const real = await importOriginal();
10
+ return {
11
+ ...real,
12
+ generateEmbedding: async (text) => topicVector(text),
13
+ embedPattern: async (p) => topicVector(real.patternEmbeddingText(p)),
14
+ };
15
+ });
16
+ const { patternEmbeddingText, EMBEDDING_TEXT_VERSION } = await import('./embeddings.js');
17
+ const { PatternMatcher, SEMANTIC_MATCH_FLOOR } = await import('./matcher.js');
18
+ const { PatternIndexer } = await import('./indexer.js');
19
+ const { assessPattern } = await import('./assess.js');
20
+ const pattern = (name, extra = {}) => ({
21
+ id: name, type: 'function', name, file: 'src/util.ts', line: 1, endLine: 3, signature: '', description: '',
22
+ keywords: [], hash: name, exported: true, usageCount: 0, indexedAt: '2026-10-02T00:00:00Z', ...extra,
23
+ });
24
+ const index = (patterns, version) => ({
25
+ version: '1.0.0', lastUpdated: '', rootDir: '/x', patterns, files: [],
26
+ stats: { totalPatterns: patterns.length, totalFiles: 1, byType: {}, indexDurationMs: 0 },
27
+ ...(version ? { embeddingTextVersion: version } : {}),
28
+ });
29
+ describe('pattern embedding text', () => {
30
+ it('reads a name as words, which is what lets an intent find it', () => {
31
+ expect(patternEmbeddingText({ name: 'retryWithBackoff', type: 'function' })).toBe('retry with backoff function');
32
+ expect(patternEmbeddingText({ name: 'parse_csv-rows', type: 'function', description: 'Rows from a file' })).toBe('parse csv rows function Rows from a file');
33
+ });
34
+ });
35
+ describe('matching by meaning', () => {
36
+ it(`accepts a match by meaning at ${SEMANTIC_MATCH_FLOOR}, below the bar name matching keeps`, async () => {
37
+ // The query shares 1 of 3 topics with the pattern: cosine 0.58, under the old 0.60.
38
+ const retry = pattern('retryWithBackoff', { embedding: topicVector('retry backoff request') });
39
+ const result = await new PatternMatcher(index([retry]), { useFuzzy: false }).match({ name: 'callAgain', intent: 'make the request work on a flaky network' });
40
+ expect(result.matches.map(m => [m.pattern.name, m.matchType])).toEqual([['retryWithBackoff', 'semantic']]);
41
+ expect(result.action).toBe('WARN');
42
+ });
43
+ it('returns nothing for an unrelated intent', async () => {
44
+ const retry = pattern('retryWithBackoff', { embedding: topicVector('retry backoff request') });
45
+ const result = await new PatternMatcher(index([retry]), { useFuzzy: false }).match({ name: 'drawPie', intent: 'render a pie chart' });
46
+ expect(result.status).toBe('NO_MATCH');
47
+ });
48
+ });
49
+ describe('enrichIndex', () => {
50
+ it('re-embeds an index built from an older embedding text, and marks the version', async () => {
51
+ const stale = index([pattern('formatDate', { embedding: [9, 9, 9, 9, 9, 9] })], 1);
52
+ const enriched = await new PatternIndexer('/x', { useEmbeddings: true }).enrichIndex(stale);
53
+ expect(enriched.embeddingTextVersion).toBe(EMBEDDING_TEXT_VERSION);
54
+ expect(enriched.patterns[0].embedding).toEqual(topicVector('format date function'));
55
+ });
56
+ });
57
+ describe('assessPattern', () => {
58
+ let cwd;
59
+ beforeEach(() => {
60
+ cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'assess-'));
61
+ fs.mkdirSync(path.join(cwd, 'src'));
62
+ fs.writeFileSync(path.join(cwd, 'package.json'), JSON.stringify({ dependencies: { 'react-dom': '18.2.0' } }));
63
+ fs.writeFileSync(path.join(cwd, 'src/mount.ts'), 'export function mountApp(el) {\n ReactDOM.render(<App />, el);\n}\n');
64
+ });
65
+ afterEach(() => fs.rmSync(cwd, { recursive: true, force: true }));
66
+ it('blocks an exact duplicate and reports what is deprecated inside the function it would reuse', async () => {
67
+ const mount = pattern('mountApp', { file: 'src/mount.ts', line: 1, endLine: 3 });
68
+ const result = await assessPattern(cwd, index([mount]), { name: 'mountApp' });
69
+ expect(result.action).toBe('BLOCK');
70
+ expect(result.deprecations.map(d => d.replacement)).toEqual(['createRoot(container).render(<App />)']);
71
+ });
72
+ it('allows a name nothing resembles', async () => {
73
+ const result = await assessPattern(cwd, index([pattern('mountApp', { file: 'src/mount.ts' })]), { name: 'exportPdfReport' });
74
+ expect(result).toEqual({ action: 'ALLOW', suggestion: '', deprecations: [] });
75
+ });
76
+ });
@@ -4,7 +4,7 @@
4
4
  * Detects when AI is suggesting deprecated or outdated patterns.
5
5
  * Uses package.json analysis and a deprecation database.
6
6
  */
7
- import type { StalenessResult, StalenessIssue, DeprecationEntry } from './types.js';
7
+ import type { StalenessResult, DeprecationEntry } from './types.js';
8
8
  /**
9
9
  * Project context extracted from package.json and other config files.
10
10
  */
@@ -21,18 +21,7 @@ export declare class StalenessDetector {
21
21
  private deprecations;
22
22
  private projectContext;
23
23
  private rootDir;
24
- private remoteRulesUrl;
25
24
  constructor(rootDir: string, customDeprecations?: DeprecationEntry[]);
26
- /**
27
- * Fetch latest deprecation rules from Rigour's remote registry.
28
- * This ensures the tool stays up-to-date even without a package update.
29
- */
30
- syncRemoteRules(): Promise<number>;
31
- /**
32
- * Check NPM registry for live deprecation status of project dependencies.
33
- * This is the ultimate "up-to-date" check.
34
- */
35
- checkLiveRegistry(context: ProjectContext): Promise<StalenessIssue[]>;
36
25
  /**
37
26
  * Load project context from package.json.
38
27
  */
@@ -40,9 +29,7 @@ export declare class StalenessDetector {
40
29
  /**
41
30
  * Check code for staleness issues.
42
31
  */
43
- checkStaleness(code: string, filePath?: string, options?: {
44
- live?: boolean;
45
- }): Promise<StalenessResult>;
32
+ checkStaleness(code: string, filePath?: string): Promise<StalenessResult>;
46
33
  /**
47
34
  * Check if project has a library.
48
35
  */
@@ -19,82 +19,10 @@ export class StalenessDetector {
19
19
  deprecations;
20
20
  projectContext = null;
21
21
  rootDir;
22
- remoteRulesUrl = 'https://raw.githubusercontent.com/rigour-labs/rules/main/deprecations.json';
23
22
  constructor(rootDir, customDeprecations = []) {
24
23
  this.rootDir = rootDir;
25
24
  this.deprecations = [...BUILT_IN_DEPRECATIONS, ...customDeprecations];
26
25
  }
27
- /**
28
- * Fetch latest deprecation rules from Rigour's remote registry.
29
- * This ensures the tool stays up-to-date even without a package update.
30
- */
31
- async syncRemoteRules() {
32
- try {
33
- // Using dynamic import for fetch to avoid Node < 18 issues
34
- const response = await fetch(this.remoteRulesUrl);
35
- if (!response.ok)
36
- throw new Error(`HTTP error! status: ${response.status}`);
37
- const data = await response.json();
38
- if (data.deprecations && Array.isArray(data.deprecations)) {
39
- // Merge remote rules, avoiding duplicates
40
- const existingPatterns = new Set(this.deprecations.map(d => d.pattern));
41
- const newRules = data.deprecations.filter((d) => !existingPatterns.has(d.pattern));
42
- this.deprecations.push(...newRules);
43
- return newRules.length;
44
- }
45
- return 0;
46
- }
47
- catch (error) {
48
- console.warn('Failed to sync remote rules, using built-in database:', error);
49
- return 0;
50
- }
51
- }
52
- /**
53
- * Check NPM registry for live deprecation status of project dependencies.
54
- * This is the ultimate "up-to-date" check.
55
- */
56
- async checkLiveRegistry(context) {
57
- const issues = [];
58
- const { execa } = await import('execa');
59
- // We only check top-level dependencies to avoid noise/performance hits
60
- const deps = Object.keys(context.dependencies);
61
- for (const dep of deps) {
62
- try {
63
- // Run 'npm info <package> --json' to get metadata
64
- const { stdout } = await execa('npm', ['info', dep, '--json']);
65
- const info = JSON.parse(stdout);
66
- // 1. Check if package is deprecated
67
- if (info.deprecated) {
68
- issues.push({
69
- line: 0, // Package-level
70
- pattern: dep,
71
- severity: 'error',
72
- reason: `Package "${dep}" is marked as DEPRECATED in NPM registry: ${info.deprecated}`,
73
- replacement: 'Check package README for suggested alternatives',
74
- docs: `https://www.npmjs.com/package/${dep}`
75
- });
76
- }
77
- // 2. Check for latest version staleness
78
- const current = context.dependencies[dep].replace(/^[\^~>=<]+/, '');
79
- const latest = info['dist-tags']?.latest;
80
- if (latest && semver.major(latest) > semver.major(current)) {
81
- issues.push({
82
- line: 0,
83
- pattern: dep,
84
- severity: 'info',
85
- reason: `Package "${dep}" has a new major version available (${latest}). Your version: ${current}`,
86
- replacement: `npm install ${dep}@latest`,
87
- docs: `https://www.npmjs.com/package/${dep}`
88
- });
89
- }
90
- }
91
- catch (error) {
92
- // Silently skip if npm check fails
93
- continue;
94
- }
95
- }
96
- return issues;
97
- }
98
26
  /**
99
27
  * Load project context from package.json.
100
28
  */
@@ -125,7 +53,7 @@ export class StalenessDetector {
125
53
  /**
126
54
  * Check code for staleness issues.
127
55
  */
128
- async checkStaleness(code, filePath, options = {}) {
56
+ async checkStaleness(code, filePath) {
129
57
  const context = await this.loadProjectContext();
130
58
  const issues = [];
131
59
  // 1. Check built-in/remote rules
@@ -161,11 +89,6 @@ export class StalenessDetector {
161
89
  regex.lastIndex = 0;
162
90
  }
163
91
  }
164
- // 2. Perform live registry check if requested (only once per run/file usually)
165
- if (options.live) {
166
- const liveIssues = await this.checkLiveRegistry(context);
167
- issues.push(...liveIssues);
168
- }
169
92
  // Determine overall status
170
93
  let status = 'FRESH';
171
94
  if (issues.some(i => i.severity === 'error')) {
@@ -49,6 +49,8 @@ export interface PatternEntry {
49
49
  export interface PatternIndex {
50
50
  /** Index format version */
51
51
  version: string;
52
+ /** Version of the text its embeddings were built from (embeddings.ts EMBEDDING_TEXT_VERSION). */
53
+ embeddingTextVersion?: number;
52
54
  /** When the index was last updated */
53
55
  lastUpdated: string;
54
56
  /** Root directory that was indexed */
@@ -3,6 +3,8 @@ export interface ResolvedFix {
3
3
  id: string;
4
4
  file: string;
5
5
  rule: string;
6
+ title?: string;
7
+ details?: string;
6
8
  before: string;
7
9
  after: string;
8
10
  resolvedAt: string;
@@ -10,6 +12,8 @@ export interface ResolvedFix {
10
12
  export interface FixCapture {
11
13
  opened: number;
12
14
  resolved: number;
15
+ /** The fixes this review resolved, for turning into lessons (storage/fix-lessons.ts). */
16
+ fixes: ResolvedFix[];
13
17
  }
14
18
  /** Record a review's findings: open new ones, resolve open ones the review no longer reports. */
15
19
  export declare function recordReviewOutcome(cwd: string, findings: Failure[], reviewedFiles: string[]): FixCapture;
@@ -13,6 +13,7 @@
13
13
  import crypto from 'crypto';
14
14
  import fs from 'fs';
15
15
  import path from 'path';
16
+ import { checkId, recordOutcome } from './check-outcomes.js';
16
17
  const DIR = path.join('.rigour', 'agent-fixes');
17
18
  const MAX_FILE_BYTES = 200_000;
18
19
  const OPEN_TTL_MS = 7 * 24 * 60 * 60 * 1000;
@@ -20,13 +21,14 @@ const OPEN_TTL_MS = 7 * 24 * 60 * 60 * 1000;
20
21
  export function recordReviewOutcome(cwd, findings, reviewedFiles) {
21
22
  const open = readOpen(cwd);
22
23
  const current = new Map(findings.flatMap(f => (f.files?.[0] ? [[`${f.id}:${f.files[0]}`, f]] : [])));
23
- const capture = { resolved: resolveGone(cwd, open, current, new Set(reviewedFiles)), opened: openNew(cwd, open, current) };
24
+ const fixes = resolveGone(cwd, open, current, new Set(reviewedFiles));
25
+ const opened = openNew(cwd, open, current);
24
26
  writeOpen(cwd, open);
25
- return capture;
27
+ return { opened, resolved: fixes.length, fixes };
26
28
  }
27
29
  /** Open findings this review no longer reports: resolved when their file was reviewed and changed. */
28
30
  function resolveGone(cwd, open, current, reviewed) {
29
- let resolved = 0;
31
+ const resolved = [];
30
32
  for (const [key, entry] of Object.entries(open)) {
31
33
  if (current.has(key))
32
34
  continue;
@@ -37,8 +39,8 @@ function resolveGone(cwd, open, current, reviewed) {
37
39
  }
38
40
  const after = readSmall(cwd, entry.file);
39
41
  if (after !== null && after !== entry.before) {
40
- writeResolved(cwd, entry, after);
41
- resolved++;
42
+ resolved.push(writeResolved(cwd, entry, after));
43
+ recordOutcome(cwd, checkId({ rule: entry.rule, title: entry.title }), 'fixed');
42
44
  }
43
45
  delete open[key];
44
46
  }
@@ -53,7 +55,7 @@ function openNew(cwd, open, current) {
53
55
  const before = readSmall(cwd, file);
54
56
  if (before === null)
55
57
  continue;
56
- open[key] = { file, rule: finding.id, before, openedAt: new Date().toISOString() };
58
+ open[key] = { file, rule: finding.id, title: finding.title, details: finding.details, before, openedAt: new Date().toISOString() };
57
59
  opened++;
58
60
  }
59
61
  return opened;
@@ -96,7 +98,7 @@ function writeOpen(cwd, open) {
96
98
  }
97
99
  function writeResolved(cwd, entry, after) {
98
100
  const id = crypto.createHash('sha256').update(`${entry.rule}\0${entry.file}\0${entry.before}\0${after}`).digest('hex').slice(0, 16);
99
- const fix = { id, file: entry.file, rule: entry.rule, before: entry.before, after, resolvedAt: new Date().toISOString() };
101
+ const fix = { id, file: entry.file, rule: entry.rule, title: entry.title, details: entry.details, before: entry.before, after, resolvedAt: new Date().toISOString() };
100
102
  try {
101
103
  fs.mkdirSync(path.join(cwd, DIR, 'resolved'), { recursive: true });
102
104
  fs.writeFileSync(path.join(cwd, DIR, 'resolved', `${id}.json`), JSON.stringify(fix));
@@ -104,6 +106,7 @@ function writeResolved(cwd, entry, after) {
104
106
  catch {
105
107
  // Best-effort, as above.
106
108
  }
109
+ return fix;
107
110
  }
108
111
  function readSmall(cwd, file) {
109
112
  try {