@rigour-labs/core 2.9.4 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/index.js +4 -0
  2. package/dist/pattern-index/embeddings.d.ts +19 -0
  3. package/dist/pattern-index/embeddings.js +78 -0
  4. package/dist/pattern-index/index.d.ts +12 -0
  5. package/dist/pattern-index/index.js +17 -0
  6. package/dist/pattern-index/indexer.d.ts +127 -0
  7. package/dist/pattern-index/indexer.js +891 -0
  8. package/dist/pattern-index/indexer.test.d.ts +6 -0
  9. package/dist/pattern-index/indexer.test.js +188 -0
  10. package/dist/pattern-index/matcher.d.ts +106 -0
  11. package/dist/pattern-index/matcher.js +376 -0
  12. package/dist/pattern-index/matcher.test.d.ts +6 -0
  13. package/dist/pattern-index/matcher.test.js +238 -0
  14. package/dist/pattern-index/overrides.d.ts +64 -0
  15. package/dist/pattern-index/overrides.js +196 -0
  16. package/dist/pattern-index/security.d.ts +25 -0
  17. package/dist/pattern-index/security.js +127 -0
  18. package/dist/pattern-index/staleness.d.ts +71 -0
  19. package/dist/pattern-index/staleness.js +381 -0
  20. package/dist/pattern-index/staleness.test.d.ts +6 -0
  21. package/dist/pattern-index/staleness.test.js +211 -0
  22. package/dist/pattern-index/types.d.ts +221 -0
  23. package/dist/pattern-index/types.js +7 -0
  24. package/package.json +14 -1
  25. package/src/index.ts +4 -0
  26. package/src/pattern-index/embeddings.ts +84 -0
  27. package/src/pattern-index/index.ts +59 -0
  28. package/src/pattern-index/indexer.test.ts +276 -0
  29. package/src/pattern-index/indexer.ts +1022 -0
  30. package/src/pattern-index/matcher.test.ts +293 -0
  31. package/src/pattern-index/matcher.ts +493 -0
  32. package/src/pattern-index/overrides.ts +235 -0
  33. package/src/pattern-index/security.ts +151 -0
  34. package/src/pattern-index/staleness.test.ts +313 -0
  35. package/src/pattern-index/staleness.ts +438 -0
  36. package/src/pattern-index/types.ts +339 -0
  37. package/vitest.config.ts +7 -0
  38. package/vitest.setup.ts +30 -0
@@ -0,0 +1,1022 @@
1
+ /**
2
+ * Pattern Indexer
3
+ *
4
+ * Scans the codebase and extracts patterns using AST parsing.
5
+ * This is the core engine of the Pattern Index system.
6
+ */
7
+
8
+ import * as fs from 'fs/promises';
9
+ import * as path from 'path';
10
+ import { createHash } from 'crypto';
11
+ import { globby } from 'globby';
12
+ import ts from 'typescript';
13
+ import type {
14
+ PatternEntry,
15
+ PatternIndex,
16
+ PatternIndexConfig,
17
+ PatternIndexStats,
18
+ PatternType,
19
+ IndexedFile
20
+ } from './types.js';
21
+ import { generateEmbedding } from './embeddings.js';
22
+
23
+ /** Default configuration for the indexer */
24
+ const DEFAULT_CONFIG: PatternIndexConfig = {
25
+ include: ['src/**/*', 'lib/**/*', 'app/**/*', 'components/**/*', 'utils/**/*', 'hooks/**/*', '**/tests/**/*', '**/test/**/*'],
26
+ exclude: [
27
+ '**/node_modules/**',
28
+ '**/dist/**',
29
+ '**/build/**',
30
+ '**/.git/**',
31
+ '**/coverage/**',
32
+ '**/venv/**',
33
+ '**/.venv/**',
34
+ '**/__pycache__/**',
35
+ '**/site-packages/**',
36
+ '**/.pytest_cache/**',
37
+ '**/target/**', // Rust build dir
38
+ '**/bin/**',
39
+ '**/.gradle/**',
40
+ '**/.mvn/**'
41
+ ],
42
+ extensions: ['.ts', '.tsx', '.js', '.jsx', '.py', '.go', '.rs', '.java', '.cpp', '.h', '.rb', '.php', '.cs', '.kt'],
43
+ indexTests: false,
44
+ indexNodeModules: false,
45
+ minNameLength: 2,
46
+ categories: {},
47
+ useEmbeddings: false
48
+ };
49
+
50
+ /** Current index format version */
51
+ const INDEX_VERSION = '1.0.0';
52
+
53
+ /**
54
+ * Pattern Indexer class.
55
+ * Responsible for scanning and indexing code patterns.
56
+ */
57
+ export class PatternIndexer {
58
+ private config: PatternIndexConfig;
59
+ private rootDir: string;
60
+
61
+ constructor(rootDir: string, config: Partial<PatternIndexConfig> = {}) {
62
+ this.rootDir = rootDir;
63
+ this.config = { ...DEFAULT_CONFIG, ...config };
64
+ }
65
+
66
+ async buildIndex(): Promise<PatternIndex> {
67
+ const startTime = Date.now();
68
+
69
+ // Find all files to index
70
+ const files = await this.findFiles();
71
+
72
+ // Process files in parallel batches (concurrency: 10)
73
+ const BATCH_SIZE = 10;
74
+ const patterns: PatternEntry[] = [];
75
+ const indexedFiles: IndexedFile[] = [];
76
+
77
+ for (let i = 0; i < files.length; i += BATCH_SIZE) {
78
+ const batch = files.slice(i, i + BATCH_SIZE);
79
+ const results = await Promise.all(batch.map(async (file) => {
80
+ try {
81
+ const relativePath = path.relative(this.rootDir, file);
82
+ const content = await fs.readFile(file, 'utf-8');
83
+ const fileHash = this.hashContent(content);
84
+
85
+ const filePatterns = await this.extractPatterns(file, content);
86
+
87
+ return {
88
+ patterns: filePatterns,
89
+ fileInfo: {
90
+ path: relativePath,
91
+ hash: fileHash,
92
+ patternCount: filePatterns.length,
93
+ indexedAt: new Date().toISOString()
94
+ }
95
+ };
96
+ } catch (error) {
97
+ console.error(`Error indexing ${file}:`, error);
98
+ return null;
99
+ }
100
+ }));
101
+
102
+ for (const result of results) {
103
+ if (result) {
104
+ patterns.push(...result.patterns);
105
+ indexedFiles.push(result.fileInfo);
106
+ }
107
+ }
108
+ }
109
+
110
+ // Generate embeddings in parallel batches if enabled
111
+ if (this.config.useEmbeddings && patterns.length > 0) {
112
+ console.log(`Generating embeddings for ${patterns.length} patterns...`);
113
+ for (let i = 0; i < patterns.length; i += BATCH_SIZE) {
114
+ const batch = patterns.slice(i, i + BATCH_SIZE);
115
+ await Promise.all(batch.map(async (pattern) => {
116
+ pattern.embedding = await generateEmbedding(`${pattern.name} ${pattern.type} ${pattern.description}`);
117
+ }));
118
+ }
119
+ }
120
+
121
+ const endTime = Date.now();
122
+ const stats = this.calculateStats(patterns, indexedFiles, endTime - startTime);
123
+
124
+ const index: PatternIndex = {
125
+ version: INDEX_VERSION,
126
+ lastUpdated: new Date().toISOString(),
127
+ rootDir: this.rootDir,
128
+ patterns,
129
+ stats,
130
+ files: indexedFiles
131
+ };
132
+
133
+ return index;
134
+ }
135
+
136
+ /**
137
+ * Incremental index update - only reindex changed files.
138
+ */
139
+ async updateIndex(existingIndex: PatternIndex): Promise<PatternIndex> {
140
+ const startTime = Date.now();
141
+ const files = await this.findFiles();
142
+
143
+ const updatedPatterns: PatternEntry[] = [];
144
+ const updatedFiles: IndexedFile[] = [];
145
+
146
+ // Create a map of existing file hashes
147
+ const existingFileMap = new Map(
148
+ existingIndex.files.map(f => [f.path, f])
149
+ );
150
+
151
+ // Process files in parallel batches (concurrency: 10)
152
+ const BATCH_SIZE = 10;
153
+ for (let i = 0; i < files.length; i += BATCH_SIZE) {
154
+ const batch = files.slice(i, i + BATCH_SIZE);
155
+ const results = await Promise.all(batch.map(async (file) => {
156
+ const relativePath = path.relative(this.rootDir, file);
157
+ const content = await fs.readFile(file, 'utf-8');
158
+ const fileHash = this.hashContent(content);
159
+
160
+ const existingFile = existingFileMap.get(relativePath);
161
+
162
+ if (existingFile && existingFile.hash === fileHash) {
163
+ // File unchanged, keep existing patterns
164
+ const existingPatterns = existingIndex.patterns.filter(
165
+ p => p.file === relativePath
166
+ );
167
+ return { patterns: existingPatterns, fileInfo: existingFile };
168
+ } else {
169
+ // File changed or new, reindex
170
+ const filePatterns = await this.extractPatterns(file, content);
171
+ return {
172
+ patterns: filePatterns,
173
+ fileInfo: {
174
+ path: relativePath,
175
+ hash: fileHash,
176
+ patternCount: filePatterns.length,
177
+ indexedAt: new Date().toISOString()
178
+ }
179
+ };
180
+ }
181
+ }));
182
+
183
+ for (const result of results) {
184
+ updatedPatterns.push(...result.patterns);
185
+ updatedFiles.push(result.fileInfo);
186
+ }
187
+ }
188
+
189
+ // Update embeddings for new/changed patterns if enabled
190
+ if (this.config.useEmbeddings && updatedPatterns.length > 0) {
191
+ for (let i = 0; i < updatedPatterns.length; i += BATCH_SIZE) {
192
+ const batch = updatedPatterns.slice(i, i + BATCH_SIZE);
193
+ await Promise.all(batch.map(async (pattern) => {
194
+ if (!pattern.embedding) {
195
+ pattern.embedding = await generateEmbedding(`${pattern.name} ${pattern.type} ${pattern.description}`);
196
+ }
197
+ }));
198
+ }
199
+ }
200
+
201
+ const endTime = Date.now();
202
+ const stats = this.calculateStats(updatedPatterns, updatedFiles, endTime - startTime);
203
+
204
+ return {
205
+ version: INDEX_VERSION,
206
+ lastUpdated: new Date().toISOString(),
207
+ rootDir: this.rootDir,
208
+ patterns: updatedPatterns,
209
+ stats,
210
+ files: updatedFiles
211
+ };
212
+ }
213
+
214
+ /**
215
+ * Find all files to index based on configuration.
216
+ */
217
+ private async findFiles(): Promise<string[]> {
218
+ const patterns = this.config.include.map(p =>
219
+ this.config.extensions.map(ext =>
220
+ p.endsWith('*') ? `${p}${ext}` : p
221
+ )
222
+ ).flat();
223
+
224
+ let exclude = [...this.config.exclude];
225
+
226
+ if (!this.config.indexTests) {
227
+ exclude.push('**/*.test.*', '**/*.spec.*', '**/__tests__/**');
228
+ }
229
+
230
+ const files = await globby(patterns, {
231
+ cwd: this.rootDir,
232
+ absolute: true,
233
+ ignore: exclude,
234
+ gitignore: true
235
+ });
236
+
237
+ return files;
238
+ }
239
+
240
+ /**
241
+ * Extract patterns from a single file using TypeScript AST.
242
+ */
243
+ private async extractPatterns(filePath: string, content: string): Promise<PatternEntry[]> {
244
+ const ext = path.extname(filePath).toLowerCase();
245
+
246
+ // Specific high-fidelity extractors
247
+ if (ext === '.py') return this.extractPythonPatterns(filePath, content);
248
+ if (ext === '.go') return this.extractGoPatterns(filePath, content);
249
+ if (ext === '.rs') return this.extractRustPatterns(filePath, content);
250
+ if (ext === '.java' || ext === '.kt' || ext === '.cs') return this.extractJVMStylePatterns(filePath, content);
251
+
252
+ // Fallback for TS/JS or other C-style languages
253
+ const patterns: PatternEntry[] = [];
254
+ const relativePath = path.relative(this.rootDir, filePath);
255
+
256
+ // For TS/JS, use AST
257
+ if (['.ts', '.tsx', '.js', '.jsx'].includes(ext)) {
258
+ const sourceFile = ts.createSourceFile(
259
+ filePath,
260
+ content,
261
+ ts.ScriptTarget.Latest,
262
+ true,
263
+ this.getScriptKind(filePath)
264
+ );
265
+
266
+ const visit = (node: ts.Node) => {
267
+ const pattern = this.nodeToPattern(node, sourceFile, relativePath, content);
268
+ if (pattern) patterns.push(pattern);
269
+ ts.forEachChild(node, visit);
270
+ };
271
+ visit(sourceFile);
272
+ return patterns;
273
+ }
274
+
275
+ // Generic C-style fallback (C++, PHP, etc.)
276
+ return this.extractGenericCPatterns(filePath, content);
277
+ }
278
+
279
+ /**
280
+ * Extract patterns from Go files.
281
+ */
282
+ private extractGoPatterns(filePath: string, content: string): PatternEntry[] {
283
+ const patterns: PatternEntry[] = [];
284
+ const relativePath = path.relative(this.rootDir, filePath);
285
+ const lines = content.split('\n');
286
+
287
+ const funcRegex = /^func\s+(?:\([^)]*\)\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*\(([^)]*)\)\s*([^\{]*)\s*\{/;
288
+ const typeRegex = /^type\s+([A-Za-z_][A-Za-z0-9_]*)\s+(struct|interface)/;
289
+
290
+ for (let i = 0; i < lines.length; i++) {
291
+ const line = lines[i];
292
+
293
+ // Functions
294
+ const funcMatch = line.match(funcRegex);
295
+ if (funcMatch) {
296
+ const name = funcMatch[1];
297
+ patterns.push(this.createPatternEntry({
298
+ type: 'function',
299
+ name,
300
+ file: relativePath,
301
+ line: i + 1,
302
+ endLine: this.findBraceBlockEnd(lines, i),
303
+ signature: `func ${name}(${funcMatch[2]}) ${funcMatch[3].trim()}`,
304
+ description: this.getCOMLineComments(lines, i - 1),
305
+ keywords: this.extractKeywords(name),
306
+ content: this.getBraceBlockContent(lines, i),
307
+ exported: /^[A-Z]/.test(name)
308
+ }));
309
+ }
310
+
311
+ // Types/Structs
312
+ const typeMatch = line.match(typeRegex);
313
+ if (typeMatch) {
314
+ const name = typeMatch[1];
315
+ patterns.push(this.createPatternEntry({
316
+ type: typeMatch[2] as any,
317
+ name,
318
+ file: relativePath,
319
+ line: i + 1,
320
+ endLine: this.findBraceBlockEnd(lines, i),
321
+ signature: `type ${name} ${typeMatch[2]}`,
322
+ description: this.getCOMLineComments(lines, i - 1),
323
+ keywords: this.extractKeywords(name),
324
+ content: this.getBraceBlockContent(lines, i),
325
+ exported: /^[A-Z]/.test(name)
326
+ }));
327
+ }
328
+ }
329
+ return patterns;
330
+ }
331
+
332
+ /**
333
+ * Extract patterns from Rust files.
334
+ */
335
+ private extractRustPatterns(filePath: string, content: string): PatternEntry[] {
336
+ const patterns: PatternEntry[] = [];
337
+ const relativePath = path.relative(this.rootDir, filePath);
338
+ const lines = content.split('\n');
339
+
340
+ const fnRegex = /^(?:pub\s+)?(?:async\s+)?fn\s+([A-Za-z_][A-Za-z0-9_]*)\s*[<(][^)]*[>)]\s*(?:->\s*[^\{]+)?\s*\{/;
341
+ const typeRegex = /^(?:pub\s+)?(struct|enum|trait)\s+([A-Za-z_][A-Za-z0-9_]*)/;
342
+
343
+ for (let i = 0; i < lines.length; i++) {
344
+ const line = lines[i];
345
+
346
+ const fnMatch = line.match(fnRegex);
347
+ if (fnMatch) {
348
+ const name = fnMatch[1];
349
+ patterns.push(this.createPatternEntry({
350
+ type: 'function',
351
+ name,
352
+ file: relativePath,
353
+ line: i + 1,
354
+ endLine: this.findBraceBlockEnd(lines, i),
355
+ signature: line.split('{')[0].trim(),
356
+ description: this.getCOMLineComments(lines, i - 1),
357
+ keywords: this.extractKeywords(name),
358
+ content: this.getBraceBlockContent(lines, i),
359
+ exported: line.startsWith('pub')
360
+ }));
361
+ }
362
+ }
363
+ return patterns;
364
+ }
365
+
366
+ /**
367
+ * Generic extraction for C-style languages (Java, C++, PHP, etc.)
368
+ */
369
+ private extractJVMStylePatterns(filePath: string, content: string): PatternEntry[] {
370
+ const patterns: PatternEntry[] = [];
371
+ const relativePath = path.relative(this.rootDir, filePath);
372
+ const lines = content.split('\n');
373
+
374
+ // Simplified for classes and methods
375
+ const classRegex = /^(?:public|private|protected|internal)?\s*(?:static\s+)?(?:final\s+)?(?:class|interface|enum)\s+([A-Za-z0-9_]+)/;
376
+ const methodRegex = /^(?:public|private|protected|internal)\s+(?:static\s+)?(?:async\s+)?(?:[A-Za-z0-9_<>\[\]]+\s+)([A-Za-z0-9_]+)\s*\(/;
377
+
378
+ for (let i = 0; i < lines.length; i++) {
379
+ const line = lines[i].trim();
380
+
381
+ const classMatch = line.match(classRegex);
382
+ if (classMatch) {
383
+ patterns.push(this.createPatternEntry({
384
+ type: 'class',
385
+ name: classMatch[1],
386
+ file: relativePath,
387
+ line: i + 1,
388
+ endLine: this.findBraceBlockEnd(lines, i),
389
+ signature: line,
390
+ description: this.getJavaDoc(lines, i - 1),
391
+ keywords: this.extractKeywords(classMatch[1]),
392
+ content: this.getBraceBlockContent(lines, i),
393
+ exported: line.includes('public')
394
+ }));
395
+ }
396
+ }
397
+ return patterns;
398
+ }
399
+
400
+ private extractGenericCPatterns(filePath: string, content: string): PatternEntry[] {
401
+ // Fallback for everything else
402
+ return [];
403
+ }
404
+
405
+ private getCOMLineComments(lines: string[], startIndex: number): string {
406
+ let comments = [];
407
+ for (let i = startIndex; i >= 0; i--) {
408
+ const line = lines[i].trim();
409
+ if (line.startsWith('//')) comments.unshift(line.replace('//', '').trim());
410
+ else break;
411
+ }
412
+ return comments.join(' ');
413
+ }
414
+
415
+ private getJavaDoc(lines: string[], startIndex: number): string {
416
+ let comments = [];
417
+ let inDoc = false;
418
+ for (let i = startIndex; i >= 0; i--) {
419
+ const line = lines[i].trim();
420
+ if (line.endsWith('*/')) inDoc = true;
421
+ if (inDoc) comments.unshift(line.replace('/**', '').replace('*/', '').replace('*', '').trim());
422
+ if (line.startsWith('/**')) break;
423
+ }
424
+ return comments.join(' ');
425
+ }
426
+
427
+ private findBraceBlockEnd(lines: string[], startIndex: number): number {
428
+ let braceCount = 0;
429
+ let started = false;
430
+ for (let i = startIndex; i < lines.length; i++) {
431
+ const line = lines[i];
432
+ if (line.includes('{')) {
433
+ braceCount += (line.match(/\{/g) || []).length;
434
+ started = true;
435
+ }
436
+ if (line.includes('}')) {
437
+ braceCount -= (line.match(/\}/g) || []).length;
438
+ }
439
+ if (started && braceCount === 0) return i + 1;
440
+ }
441
+ return lines.length;
442
+ }
443
+
444
+ private getBraceBlockContent(lines: string[], startIndex: number): string {
445
+ const end = this.findBraceBlockEnd(lines, startIndex);
446
+ return lines.slice(startIndex, end).join('\n');
447
+ }
448
+
449
+ /**
450
+ * Extract patterns from Python files using regex.
451
+ */
452
+ private extractPythonPatterns(filePath: string, content: string): PatternEntry[] {
453
+ const patterns: PatternEntry[] = [];
454
+ const relativePath = path.relative(this.rootDir, filePath);
455
+ const lines = content.split('\n');
456
+
457
+ // Regex for Class definitions
458
+ const classRegex = /^class\s+([A-Za-z_][A-Za-z0-9_]*)\s*(\([^)]*\))?\s*:/;
459
+ // Regex for Function definitions (including async)
460
+ const funcRegex = /^(?:async\s+)?def\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(([^)]*)\)\s*(?:->\s*[^:]+)?\s*:/;
461
+ // Regex for Constants (Top-level UPPER_CASE variables)
462
+ const constRegex = /^([A-Z][A-Z0-9_]*)\s*=\s*(.+)$/;
463
+
464
+ for (let i = 0; i < lines.length; i++) {
465
+ const lineContent = lines[i].trim();
466
+ const originalLine = lines[i];
467
+ const lineNum = i + 1;
468
+
469
+ // Classes
470
+ const classMatch = originalLine.match(classRegex);
471
+ if (classMatch) {
472
+ const name = classMatch[1];
473
+ if (name.length >= this.config.minNameLength) {
474
+ patterns.push(this.createPatternEntry({
475
+ type: this.detectPythonClassType(name),
476
+ name,
477
+ file: relativePath,
478
+ line: lineNum,
479
+ endLine: this.findPythonBlockEnd(lines, i),
480
+ signature: `class ${name}${classMatch[2] || ''}`,
481
+ description: this.getPythonDocstring(lines, i + 1),
482
+ keywords: this.extractKeywords(name),
483
+ content: this.getPythonBlockContent(lines, i),
484
+ exported: !name.startsWith('_')
485
+ }));
486
+ continue;
487
+ }
488
+ }
489
+
490
+ // Functions
491
+ const funcMatch = originalLine.match(funcRegex);
492
+ if (funcMatch) {
493
+ const name = funcMatch[1];
494
+ if (name.length >= this.config.minNameLength) {
495
+ patterns.push(this.createPatternEntry({
496
+ type: this.detectPythonFunctionType(name),
497
+ name,
498
+ file: relativePath,
499
+ line: lineNum,
500
+ endLine: this.findPythonBlockEnd(lines, i),
501
+ signature: `def ${name}(${funcMatch[2]})`,
502
+ description: this.getPythonDocstring(lines, i + 1),
503
+ keywords: this.extractKeywords(name),
504
+ content: this.getPythonBlockContent(lines, i),
505
+ exported: !name.startsWith('_')
506
+ }));
507
+ continue;
508
+ }
509
+ }
510
+
511
+ // Constants
512
+ const constMatch = originalLine.match(constRegex);
513
+ if (constMatch) {
514
+ const name = constMatch[1];
515
+ if (name.length >= this.config.minNameLength) {
516
+ patterns.push(this.createPatternEntry({
517
+ type: 'constant',
518
+ name,
519
+ file: relativePath,
520
+ line: lineNum,
521
+ endLine: lineNum,
522
+ signature: `${name} = ...`,
523
+ description: '',
524
+ keywords: this.extractKeywords(name),
525
+ content: originalLine,
526
+ exported: !name.startsWith('_')
527
+ }));
528
+ }
529
+ }
530
+ }
531
+
532
+ return patterns;
533
+ }
534
+
535
+ private detectPythonClassType(name: string): PatternType {
536
+ if (name.endsWith('Error') || name.endsWith('Exception')) return 'error';
537
+ if (name.endsWith('Model')) return 'model';
538
+ if (name.endsWith('Schema')) return 'schema';
539
+ return 'class';
540
+ }
541
+
542
+ private detectPythonFunctionType(name: string): PatternType {
543
+ if (name.startsWith('test_')) return 'function'; // Tests are filtered by indexTests config
544
+ if (name.includes('middleware')) return 'middleware';
545
+ if (name.includes('handler')) return 'handler';
546
+ return 'function';
547
+ }
548
+
549
+ private getPythonDocstring(lines: string[], startIndex: number): string {
550
+ if (startIndex >= lines.length) return '';
551
+ const nextLine = lines[startIndex].trim();
552
+ if (nextLine.startsWith('"""') || nextLine.startsWith("'''")) {
553
+ const quote = nextLine.startsWith('"""') ? '"""' : "'''";
554
+ let doc = nextLine.replace(quote, '');
555
+ if (doc.endsWith(quote)) return doc.replace(quote, '').trim();
556
+
557
+ for (let i = startIndex + 1; i < lines.length; i++) {
558
+ if (lines[i].includes(quote)) {
559
+ doc += ' ' + lines[i].split(quote)[0].trim();
560
+ break;
561
+ }
562
+ doc += ' ' + lines[i].trim();
563
+ }
564
+ return doc.trim();
565
+ }
566
+ return '';
567
+ }
568
+
569
+ private findPythonBlockEnd(lines: string[], startIndex: number): number {
570
+ const startIndent = lines[startIndex].search(/\S/);
571
+ for (let i = startIndex + 1; i < lines.length; i++) {
572
+ if (lines[i].trim() === '') continue;
573
+ const currentIndent = lines[i].search(/\S/);
574
+ if (currentIndent <= startIndent) return i;
575
+ }
576
+ return lines.length;
577
+ }
578
+
579
+ private getPythonBlockContent(lines: string[], startIndex: number): string {
580
+ const endLine = this.findPythonBlockEnd(lines, startIndex);
581
+ return lines.slice(startIndex, endLine).join('\n');
582
+ }
583
+
584
+ /**
585
+ * Convert an AST node to a PatternEntry if applicable.
586
+ */
587
+ private nodeToPattern(
588
+ node: ts.Node,
589
+ sourceFile: ts.SourceFile,
590
+ filePath: string,
591
+ content: string
592
+ ): PatternEntry | null {
593
+ const startPos = sourceFile.getLineAndCharacterOfPosition(node.getStart());
594
+ const endPos = sourceFile.getLineAndCharacterOfPosition(node.getEnd());
595
+ const line = startPos.line + 1;
596
+ const endLine = endPos.line + 1;
597
+
598
+ // Function declarations
599
+ if (ts.isFunctionDeclaration(node) && node.name) {
600
+ const name = node.name.text;
601
+ if (name.length < this.config.minNameLength) return null;
602
+
603
+ return this.createPatternEntry({
604
+ type: this.detectFunctionType(name, node),
605
+ name,
606
+ file: filePath,
607
+ line,
608
+ endLine,
609
+ signature: this.getFunctionSignature(node, sourceFile),
610
+ description: this.getJSDocDescription(node, sourceFile),
611
+ keywords: this.extractKeywords(name),
612
+ content: node.getText(sourceFile),
613
+ exported: this.isExported(node)
614
+ });
615
+ }
616
+
617
+ // Variable declarations with arrow functions
618
+ if (ts.isVariableStatement(node)) {
619
+ const patterns: PatternEntry[] = [];
620
+ for (const decl of node.declarationList.declarations) {
621
+ if (ts.isIdentifier(decl.name) && decl.initializer) {
622
+ const name = decl.name.text;
623
+ if (name.length < this.config.minNameLength) continue;
624
+
625
+ if (ts.isArrowFunction(decl.initializer) || ts.isFunctionExpression(decl.initializer)) {
626
+ return this.createPatternEntry({
627
+ type: this.detectFunctionType(name, decl.initializer),
628
+ name,
629
+ file: filePath,
630
+ line,
631
+ endLine,
632
+ signature: this.getArrowFunctionSignature(decl.initializer, sourceFile),
633
+ description: this.getJSDocDescription(node, sourceFile),
634
+ keywords: this.extractKeywords(name),
635
+ content: node.getText(sourceFile),
636
+ exported: this.isExported(node)
637
+ });
638
+ }
639
+
640
+ // Constants
641
+ if (ts.isStringLiteral(decl.initializer) ||
642
+ ts.isNumericLiteral(decl.initializer) ||
643
+ ts.isObjectLiteralExpression(decl.initializer)) {
644
+
645
+ const isConstant = node.declarationList.flags & ts.NodeFlags.Const;
646
+ if (isConstant && name === name.toUpperCase()) {
647
+ return this.createPatternEntry({
648
+ type: 'constant',
649
+ name,
650
+ file: filePath,
651
+ line,
652
+ endLine,
653
+ signature: '',
654
+ description: this.getJSDocDescription(node, sourceFile),
655
+ keywords: this.extractKeywords(name),
656
+ content: node.getText(sourceFile),
657
+ exported: this.isExported(node)
658
+ });
659
+ }
660
+ }
661
+ }
662
+ }
663
+ }
664
+
665
+ // Class declarations
666
+ if (ts.isClassDeclaration(node) && node.name) {
667
+ const name = node.name.text;
668
+ if (name.length < this.config.minNameLength) return null;
669
+
670
+ return this.createPatternEntry({
671
+ type: this.detectClassType(name, node),
672
+ name,
673
+ file: filePath,
674
+ line,
675
+ endLine,
676
+ signature: this.getClassSignature(node, sourceFile),
677
+ description: this.getJSDocDescription(node, sourceFile),
678
+ keywords: this.extractKeywords(name),
679
+ content: node.getText(sourceFile),
680
+ exported: this.isExported(node)
681
+ });
682
+ }
683
+
684
+ // Interface declarations
685
+ if (ts.isInterfaceDeclaration(node)) {
686
+ const name = node.name.text;
687
+ if (name.length < this.config.minNameLength) return null;
688
+
689
+ return this.createPatternEntry({
690
+ type: 'interface',
691
+ name,
692
+ file: filePath,
693
+ line,
694
+ endLine,
695
+ signature: this.getInterfaceSignature(node, sourceFile),
696
+ description: this.getJSDocDescription(node, sourceFile),
697
+ keywords: this.extractKeywords(name),
698
+ content: node.getText(sourceFile),
699
+ exported: this.isExported(node)
700
+ });
701
+ }
702
+
703
+ // Type alias declarations
704
+ if (ts.isTypeAliasDeclaration(node)) {
705
+ const name = node.name.text;
706
+ if (name.length < this.config.minNameLength) return null;
707
+
708
+ return this.createPatternEntry({
709
+ type: 'type',
710
+ name,
711
+ file: filePath,
712
+ line,
713
+ endLine,
714
+ signature: node.getText(sourceFile).split('=')[0].trim(),
715
+ description: this.getJSDocDescription(node, sourceFile),
716
+ keywords: this.extractKeywords(name),
717
+ content: node.getText(sourceFile),
718
+ exported: this.isExported(node)
719
+ });
720
+ }
721
+
722
+ // Enum declarations
723
+ if (ts.isEnumDeclaration(node)) {
724
+ const name = node.name.text;
725
+ if (name.length < this.config.minNameLength) return null;
726
+
727
+ return this.createPatternEntry({
728
+ type: 'enum',
729
+ name,
730
+ file: filePath,
731
+ line,
732
+ endLine,
733
+ signature: `enum ${name}`,
734
+ description: this.getJSDocDescription(node, sourceFile),
735
+ keywords: this.extractKeywords(name),
736
+ content: node.getText(sourceFile),
737
+ exported: this.isExported(node)
738
+ });
739
+ }
740
+
741
+ return null;
742
+ }
743
+
744
+ /**
745
+ * Detect the specific type of a function based on naming conventions.
746
+ */
747
+ private detectFunctionType(name: string, node: ts.Node): PatternType {
748
+ // React hooks
749
+ if (name.startsWith('use') && name.length > 3 && name[3] === name[3].toUpperCase()) {
750
+ return 'hook';
751
+ }
752
+
753
+ // React components (PascalCase and returns JSX)
754
+ if (name[0] === name[0].toUpperCase() && this.containsJSX(node)) {
755
+ return 'component';
756
+ }
757
+
758
+ // Middleware patterns
759
+ if (name.includes('Middleware') || name.includes('middleware')) {
760
+ return 'middleware';
761
+ }
762
+
763
+ // Handler patterns
764
+ if (name.includes('Handler') || name.includes('handler')) {
765
+ return 'handler';
766
+ }
767
+
768
+ // Factory patterns
769
+ if (name.startsWith('create') || name.startsWith('make') || name.includes('Factory')) {
770
+ return 'factory';
771
+ }
772
+
773
+ return 'function';
774
+ }
775
+
776
+ /**
777
+ * Detect the specific type of a class.
778
+ */
779
+ private detectClassType(name: string, node: ts.ClassDeclaration): PatternType {
780
+ // Error classes
781
+ if (name.endsWith('Error') || name.endsWith('Exception')) {
782
+ return 'error';
783
+ }
784
+
785
+ // Check for React component (extends Component/PureComponent)
786
+ if (node.heritageClauses) {
787
+ for (const clause of node.heritageClauses) {
788
+ const text = clause.getText();
789
+ if (text.includes('Component') || text.includes('PureComponent')) {
790
+ return 'component';
791
+ }
792
+ }
793
+ }
794
+
795
+ // Store patterns
796
+ if (name.endsWith('Store') || name.endsWith('State')) {
797
+ return 'store';
798
+ }
799
+
800
+ // Model patterns
801
+ if (name.endsWith('Model') || name.endsWith('Entity')) {
802
+ return 'model';
803
+ }
804
+
805
+ return 'class';
806
+ }
807
+
808
+ /**
809
+ * Check if a node contains JSX.
810
+ */
811
+ private containsJSX(node: ts.Node): boolean {
812
+ let hasJSX = false;
813
+ const visit = (n: ts.Node) => {
814
+ if (ts.isJsxElement(n) || ts.isJsxSelfClosingElement(n) || ts.isJsxFragment(n)) {
815
+ hasJSX = true;
816
+ return;
817
+ }
818
+ ts.forEachChild(n, visit);
819
+ };
820
+ visit(node);
821
+ return hasJSX;
822
+ }
823
+
824
+ /**
825
+ * Get function signature.
826
+ */
827
+ private getFunctionSignature(node: ts.FunctionDeclaration, sourceFile: ts.SourceFile): string {
828
+ const params = node.parameters
829
+ .map(p => p.getText(sourceFile))
830
+ .join(', ');
831
+ const returnType = node.type ? `: ${node.type.getText(sourceFile)}` : '';
832
+ return `(${params})${returnType}`;
833
+ }
834
+
835
+ /**
836
+ * Get arrow function signature.
837
+ */
838
+ private getArrowFunctionSignature(
839
+ node: ts.ArrowFunction | ts.FunctionExpression,
840
+ sourceFile: ts.SourceFile
841
+ ): string {
842
+ const params = node.parameters
843
+ .map(p => p.getText(sourceFile))
844
+ .join(', ');
845
+ const returnType = node.type ? `: ${node.type.getText(sourceFile)}` : '';
846
+ return `(${params})${returnType}`;
847
+ }
848
+
849
+ /**
850
+ * Get class signature.
851
+ */
852
+ private getClassSignature(node: ts.ClassDeclaration, sourceFile: ts.SourceFile): string {
853
+ let sig = `class ${node.name?.text || 'Anonymous'}`;
854
+ if (node.heritageClauses) {
855
+ sig += ' ' + node.heritageClauses.map(c => c.getText(sourceFile)).join(' ');
856
+ }
857
+ return sig;
858
+ }
859
+
860
+ /**
861
+ * Get interface signature.
862
+ */
863
+ private getInterfaceSignature(node: ts.InterfaceDeclaration, sourceFile: ts.SourceFile): string {
864
+ let sig = `interface ${node.name.text}`;
865
+ if (node.typeParameters) {
866
+ sig += `<${node.typeParameters.map(p => p.getText(sourceFile)).join(', ')}>`;
867
+ }
868
+ return sig;
869
+ }
870
+
871
+ /**
872
+ * Extract JSDoc description from a node.
873
+ */
874
+ private getJSDocDescription(node: ts.Node, sourceFile: ts.SourceFile): string {
875
+ const jsDocTags = ts.getJSDocTags(node);
876
+ const jsDocComment = ts.getJSDocCommentsAndTags(node);
877
+
878
+ for (const tag of jsDocComment) {
879
+ if (ts.isJSDoc(tag) && tag.comment) {
880
+ if (typeof tag.comment === 'string') {
881
+ return tag.comment;
882
+ }
883
+ return tag.comment.map(c => c.getText(sourceFile)).join(' ');
884
+ }
885
+ }
886
+
887
+ return '';
888
+ }
889
+
890
+ /**
891
+ * Check if a node is exported.
892
+ */
893
+ private isExported(node: ts.Node): boolean {
894
+ if (ts.canHaveModifiers(node)) {
895
+ const modifiers = ts.getModifiers(node);
896
+ if (modifiers) {
897
+ return modifiers.some(m => m.kind === ts.SyntaxKind.ExportKeyword);
898
+ }
899
+ }
900
+ return false;
901
+ }
902
+
903
+ /**
904
+ * Extract keywords from a name for semantic matching.
905
+ */
906
+ private extractKeywords(name: string): string[] {
907
+ // Split camelCase and PascalCase
908
+ const words = name
909
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
910
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
911
+ .toLowerCase()
912
+ .split(/[\s_-]+/)
913
+ .filter(w => w.length > 1);
914
+
915
+ return [...new Set(words)];
916
+ }
917
+
918
+ /**
919
+ * Create a PatternEntry with computed fields.
920
+ */
921
+ private createPatternEntry(params: {
922
+ type: PatternType;
923
+ name: string;
924
+ file: string;
925
+ line: number;
926
+ endLine: number;
927
+ signature: string;
928
+ description: string;
929
+ keywords: string[];
930
+ content: string;
931
+ exported: boolean;
932
+ }): PatternEntry {
933
+ const id = this.hashContent(`${params.file}:${params.name}:${params.line}`);
934
+ const hash = this.hashContent(params.content);
935
+
936
+ return {
937
+ id,
938
+ type: params.type,
939
+ name: params.name,
940
+ file: params.file,
941
+ line: params.line,
942
+ endLine: params.endLine,
943
+ signature: params.signature,
944
+ description: params.description,
945
+ keywords: params.keywords,
946
+ hash,
947
+ exported: params.exported,
948
+ usageCount: 0, // Will be calculated in a separate pass
949
+ indexedAt: new Date().toISOString()
950
+ };
951
+ }
952
+
953
+ /**
954
+ * Get the TypeScript ScriptKind for a file.
955
+ */
956
+ private getScriptKind(filePath: string): ts.ScriptKind {
957
+ const ext = path.extname(filePath).toLowerCase();
958
+ switch (ext) {
959
+ case '.ts': return ts.ScriptKind.TS;
960
+ case '.tsx': return ts.ScriptKind.TSX;
961
+ case '.js': return ts.ScriptKind.JS;
962
+ case '.jsx': return ts.ScriptKind.JSX;
963
+ default: return ts.ScriptKind.TS;
964
+ }
965
+ }
966
+
967
+ /**
968
+ * Calculate index statistics.
969
+ */
970
+ private calculateStats(
971
+ patterns: PatternEntry[],
972
+ files: IndexedFile[],
973
+ durationMs: number
974
+ ): PatternIndexStats {
975
+ const byType: Record<string, number> = {};
976
+
977
+ for (const pattern of patterns) {
978
+ byType[pattern.type] = (byType[pattern.type] || 0) + 1;
979
+ }
980
+
981
+ return {
982
+ totalPatterns: patterns.length,
983
+ totalFiles: files.length,
984
+ byType: byType as Record<PatternType, number>,
985
+ indexDurationMs: durationMs
986
+ };
987
+ }
988
+
989
+ /**
990
+ * Hash content using SHA-256.
991
+ */
992
+ private hashContent(content: string): string {
993
+ return createHash('sha256').update(content).digest('hex').slice(0, 16);
994
+ }
995
+ }
996
+
997
+ /**
998
+ * Save a pattern index to disk.
999
+ */
1000
+ export async function savePatternIndex(index: PatternIndex, outputPath: string): Promise<void> {
1001
+ await fs.mkdir(path.dirname(outputPath), { recursive: true });
1002
+ await fs.writeFile(outputPath, JSON.stringify(index, null, 2), 'utf-8');
1003
+ }
1004
+
1005
+ /**
1006
+ * Load a pattern index from disk.
1007
+ */
1008
+ export async function loadPatternIndex(indexPath: string): Promise<PatternIndex | null> {
1009
+ try {
1010
+ const content = await fs.readFile(indexPath, 'utf-8');
1011
+ return JSON.parse(content) as PatternIndex;
1012
+ } catch {
1013
+ return null;
1014
+ }
1015
+ }
1016
+
1017
+ /**
1018
+ * Get the default index path for a project.
1019
+ */
1020
+ export function getDefaultIndexPath(rootDir: string): string {
1021
+ return path.join(rootDir, '.rigour', 'patterns.json');
1022
+ }