@rigour-labs/core 2.9.3 → 2.10.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 +11 -0
  5. package/dist/pattern-index/index.js +15 -0
  6. package/dist/pattern-index/indexer.d.ts +101 -0
  7. package/dist/pattern-index/indexer.js +573 -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 +53 -0
  28. package/src/pattern-index/indexer.test.ts +276 -0
  29. package/src/pattern-index/indexer.ts +693 -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,693 @@
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/**/*'],
26
+ exclude: ['**/node_modules/**', '**/dist/**', '**/build/**', '**/.git/**', '**/coverage/**'],
27
+ extensions: ['.ts', '.tsx', '.js', '.jsx'],
28
+ indexTests: false,
29
+ indexNodeModules: false,
30
+ minNameLength: 2,
31
+ categories: {},
32
+ useEmbeddings: false
33
+ };
34
+
35
+ /** Current index format version */
36
+ const INDEX_VERSION = '1.0.0';
37
+
38
+ /**
39
+ * Pattern Indexer class.
40
+ * Responsible for scanning and indexing code patterns.
41
+ */
42
+ export class PatternIndexer {
43
+ private config: PatternIndexConfig;
44
+ private rootDir: string;
45
+
46
+ constructor(rootDir: string, config: Partial<PatternIndexConfig> = {}) {
47
+ this.rootDir = rootDir;
48
+ this.config = { ...DEFAULT_CONFIG, ...config };
49
+ }
50
+
51
+ async buildIndex(): Promise<PatternIndex> {
52
+ const startTime = Date.now();
53
+
54
+ // Find all files to index
55
+ const files = await this.findFiles();
56
+
57
+ // Process files in parallel batches (concurrency: 10)
58
+ const BATCH_SIZE = 10;
59
+ const patterns: PatternEntry[] = [];
60
+ const indexedFiles: IndexedFile[] = [];
61
+
62
+ for (let i = 0; i < files.length; i += BATCH_SIZE) {
63
+ const batch = files.slice(i, i + BATCH_SIZE);
64
+ const results = await Promise.all(batch.map(async (file) => {
65
+ try {
66
+ const relativePath = path.relative(this.rootDir, file);
67
+ const content = await fs.readFile(file, 'utf-8');
68
+ const fileHash = this.hashContent(content);
69
+
70
+ const filePatterns = await this.extractPatterns(file, content);
71
+
72
+ return {
73
+ patterns: filePatterns,
74
+ fileInfo: {
75
+ path: relativePath,
76
+ hash: fileHash,
77
+ patternCount: filePatterns.length,
78
+ indexedAt: new Date().toISOString()
79
+ }
80
+ };
81
+ } catch (error) {
82
+ console.error(`Error indexing ${file}:`, error);
83
+ return null;
84
+ }
85
+ }));
86
+
87
+ for (const result of results) {
88
+ if (result) {
89
+ patterns.push(...result.patterns);
90
+ indexedFiles.push(result.fileInfo);
91
+ }
92
+ }
93
+ }
94
+
95
+ // Generate embeddings in parallel batches if enabled
96
+ if (this.config.useEmbeddings && patterns.length > 0) {
97
+ console.log(`Generating embeddings for ${patterns.length} patterns...`);
98
+ for (let i = 0; i < patterns.length; i += BATCH_SIZE) {
99
+ const batch = patterns.slice(i, i + BATCH_SIZE);
100
+ await Promise.all(batch.map(async (pattern) => {
101
+ pattern.embedding = await generateEmbedding(`${pattern.name} ${pattern.type} ${pattern.description}`);
102
+ }));
103
+ }
104
+ }
105
+
106
+ const endTime = Date.now();
107
+ const stats = this.calculateStats(patterns, indexedFiles, endTime - startTime);
108
+
109
+ const index: PatternIndex = {
110
+ version: INDEX_VERSION,
111
+ lastUpdated: new Date().toISOString(),
112
+ rootDir: this.rootDir,
113
+ patterns,
114
+ stats,
115
+ files: indexedFiles
116
+ };
117
+
118
+ return index;
119
+ }
120
+
121
+ /**
122
+ * Incremental index update - only reindex changed files.
123
+ */
124
+ async updateIndex(existingIndex: PatternIndex): Promise<PatternIndex> {
125
+ const startTime = Date.now();
126
+ const files = await this.findFiles();
127
+
128
+ const updatedPatterns: PatternEntry[] = [];
129
+ const updatedFiles: IndexedFile[] = [];
130
+
131
+ // Create a map of existing file hashes
132
+ const existingFileMap = new Map(
133
+ existingIndex.files.map(f => [f.path, f])
134
+ );
135
+
136
+ // Process files in parallel batches (concurrency: 10)
137
+ const BATCH_SIZE = 10;
138
+ for (let i = 0; i < files.length; i += BATCH_SIZE) {
139
+ const batch = files.slice(i, i + BATCH_SIZE);
140
+ const results = await Promise.all(batch.map(async (file) => {
141
+ const relativePath = path.relative(this.rootDir, file);
142
+ const content = await fs.readFile(file, 'utf-8');
143
+ const fileHash = this.hashContent(content);
144
+
145
+ const existingFile = existingFileMap.get(relativePath);
146
+
147
+ if (existingFile && existingFile.hash === fileHash) {
148
+ // File unchanged, keep existing patterns
149
+ const existingPatterns = existingIndex.patterns.filter(
150
+ p => p.file === relativePath
151
+ );
152
+ return { patterns: existingPatterns, fileInfo: existingFile };
153
+ } else {
154
+ // File changed or new, reindex
155
+ const filePatterns = await this.extractPatterns(file, content);
156
+ return {
157
+ patterns: filePatterns,
158
+ fileInfo: {
159
+ path: relativePath,
160
+ hash: fileHash,
161
+ patternCount: filePatterns.length,
162
+ indexedAt: new Date().toISOString()
163
+ }
164
+ };
165
+ }
166
+ }));
167
+
168
+ for (const result of results) {
169
+ updatedPatterns.push(...result.patterns);
170
+ updatedFiles.push(result.fileInfo);
171
+ }
172
+ }
173
+
174
+ // Update embeddings for new/changed patterns if enabled
175
+ if (this.config.useEmbeddings && updatedPatterns.length > 0) {
176
+ for (let i = 0; i < updatedPatterns.length; i += BATCH_SIZE) {
177
+ const batch = updatedPatterns.slice(i, i + BATCH_SIZE);
178
+ await Promise.all(batch.map(async (pattern) => {
179
+ if (!pattern.embedding) {
180
+ pattern.embedding = await generateEmbedding(`${pattern.name} ${pattern.type} ${pattern.description}`);
181
+ }
182
+ }));
183
+ }
184
+ }
185
+
186
+ const endTime = Date.now();
187
+ const stats = this.calculateStats(updatedPatterns, updatedFiles, endTime - startTime);
188
+
189
+ return {
190
+ version: INDEX_VERSION,
191
+ lastUpdated: new Date().toISOString(),
192
+ rootDir: this.rootDir,
193
+ patterns: updatedPatterns,
194
+ stats,
195
+ files: updatedFiles
196
+ };
197
+ }
198
+
199
+ /**
200
+ * Find all files to index based on configuration.
201
+ */
202
+ private async findFiles(): Promise<string[]> {
203
+ const patterns = this.config.include.map(p =>
204
+ this.config.extensions.map(ext =>
205
+ p.endsWith('*') ? `${p}${ext}` : p
206
+ )
207
+ ).flat();
208
+
209
+ let exclude = [...this.config.exclude];
210
+
211
+ if (!this.config.indexTests) {
212
+ exclude.push('**/*.test.*', '**/*.spec.*', '**/__tests__/**');
213
+ }
214
+
215
+ const files = await globby(patterns, {
216
+ cwd: this.rootDir,
217
+ absolute: true,
218
+ ignore: exclude,
219
+ gitignore: true
220
+ });
221
+
222
+ return files;
223
+ }
224
+
225
+ /**
226
+ * Extract patterns from a single file using TypeScript AST.
227
+ */
228
+ private async extractPatterns(filePath: string, content: string): Promise<PatternEntry[]> {
229
+ const patterns: PatternEntry[] = [];
230
+ const relativePath = path.relative(this.rootDir, filePath);
231
+
232
+ // Parse with TypeScript
233
+ const sourceFile = ts.createSourceFile(
234
+ filePath,
235
+ content,
236
+ ts.ScriptTarget.Latest,
237
+ true,
238
+ this.getScriptKind(filePath)
239
+ );
240
+
241
+ // Walk the AST
242
+ const visit = (node: ts.Node) => {
243
+ const pattern = this.nodeToPattern(node, sourceFile, relativePath, content);
244
+ if (pattern) {
245
+ patterns.push(pattern);
246
+ }
247
+ ts.forEachChild(node, visit);
248
+ };
249
+
250
+ visit(sourceFile);
251
+
252
+ return patterns;
253
+ }
254
+
255
+ /**
256
+ * Convert an AST node to a PatternEntry if applicable.
257
+ */
258
+ private nodeToPattern(
259
+ node: ts.Node,
260
+ sourceFile: ts.SourceFile,
261
+ filePath: string,
262
+ content: string
263
+ ): PatternEntry | null {
264
+ const startPos = sourceFile.getLineAndCharacterOfPosition(node.getStart());
265
+ const endPos = sourceFile.getLineAndCharacterOfPosition(node.getEnd());
266
+ const line = startPos.line + 1;
267
+ const endLine = endPos.line + 1;
268
+
269
+ // Function declarations
270
+ if (ts.isFunctionDeclaration(node) && node.name) {
271
+ const name = node.name.text;
272
+ if (name.length < this.config.minNameLength) return null;
273
+
274
+ return this.createPatternEntry({
275
+ type: this.detectFunctionType(name, node),
276
+ name,
277
+ file: filePath,
278
+ line,
279
+ endLine,
280
+ signature: this.getFunctionSignature(node, sourceFile),
281
+ description: this.getJSDocDescription(node, sourceFile),
282
+ keywords: this.extractKeywords(name),
283
+ content: node.getText(sourceFile),
284
+ exported: this.isExported(node)
285
+ });
286
+ }
287
+
288
+ // Variable declarations with arrow functions
289
+ if (ts.isVariableStatement(node)) {
290
+ const patterns: PatternEntry[] = [];
291
+ for (const decl of node.declarationList.declarations) {
292
+ if (ts.isIdentifier(decl.name) && decl.initializer) {
293
+ const name = decl.name.text;
294
+ if (name.length < this.config.minNameLength) continue;
295
+
296
+ if (ts.isArrowFunction(decl.initializer) || ts.isFunctionExpression(decl.initializer)) {
297
+ return this.createPatternEntry({
298
+ type: this.detectFunctionType(name, decl.initializer),
299
+ name,
300
+ file: filePath,
301
+ line,
302
+ endLine,
303
+ signature: this.getArrowFunctionSignature(decl.initializer, sourceFile),
304
+ description: this.getJSDocDescription(node, sourceFile),
305
+ keywords: this.extractKeywords(name),
306
+ content: node.getText(sourceFile),
307
+ exported: this.isExported(node)
308
+ });
309
+ }
310
+
311
+ // Constants
312
+ if (ts.isStringLiteral(decl.initializer) ||
313
+ ts.isNumericLiteral(decl.initializer) ||
314
+ ts.isObjectLiteralExpression(decl.initializer)) {
315
+
316
+ const isConstant = node.declarationList.flags & ts.NodeFlags.Const;
317
+ if (isConstant && name === name.toUpperCase()) {
318
+ return this.createPatternEntry({
319
+ type: 'constant',
320
+ name,
321
+ file: filePath,
322
+ line,
323
+ endLine,
324
+ signature: '',
325
+ description: this.getJSDocDescription(node, sourceFile),
326
+ keywords: this.extractKeywords(name),
327
+ content: node.getText(sourceFile),
328
+ exported: this.isExported(node)
329
+ });
330
+ }
331
+ }
332
+ }
333
+ }
334
+ }
335
+
336
+ // Class declarations
337
+ if (ts.isClassDeclaration(node) && node.name) {
338
+ const name = node.name.text;
339
+ if (name.length < this.config.minNameLength) return null;
340
+
341
+ return this.createPatternEntry({
342
+ type: this.detectClassType(name, node),
343
+ name,
344
+ file: filePath,
345
+ line,
346
+ endLine,
347
+ signature: this.getClassSignature(node, sourceFile),
348
+ description: this.getJSDocDescription(node, sourceFile),
349
+ keywords: this.extractKeywords(name),
350
+ content: node.getText(sourceFile),
351
+ exported: this.isExported(node)
352
+ });
353
+ }
354
+
355
+ // Interface declarations
356
+ if (ts.isInterfaceDeclaration(node)) {
357
+ const name = node.name.text;
358
+ if (name.length < this.config.minNameLength) return null;
359
+
360
+ return this.createPatternEntry({
361
+ type: 'interface',
362
+ name,
363
+ file: filePath,
364
+ line,
365
+ endLine,
366
+ signature: this.getInterfaceSignature(node, sourceFile),
367
+ description: this.getJSDocDescription(node, sourceFile),
368
+ keywords: this.extractKeywords(name),
369
+ content: node.getText(sourceFile),
370
+ exported: this.isExported(node)
371
+ });
372
+ }
373
+
374
+ // Type alias declarations
375
+ if (ts.isTypeAliasDeclaration(node)) {
376
+ const name = node.name.text;
377
+ if (name.length < this.config.minNameLength) return null;
378
+
379
+ return this.createPatternEntry({
380
+ type: 'type',
381
+ name,
382
+ file: filePath,
383
+ line,
384
+ endLine,
385
+ signature: node.getText(sourceFile).split('=')[0].trim(),
386
+ description: this.getJSDocDescription(node, sourceFile),
387
+ keywords: this.extractKeywords(name),
388
+ content: node.getText(sourceFile),
389
+ exported: this.isExported(node)
390
+ });
391
+ }
392
+
393
+ // Enum declarations
394
+ if (ts.isEnumDeclaration(node)) {
395
+ const name = node.name.text;
396
+ if (name.length < this.config.minNameLength) return null;
397
+
398
+ return this.createPatternEntry({
399
+ type: 'enum',
400
+ name,
401
+ file: filePath,
402
+ line,
403
+ endLine,
404
+ signature: `enum ${name}`,
405
+ description: this.getJSDocDescription(node, sourceFile),
406
+ keywords: this.extractKeywords(name),
407
+ content: node.getText(sourceFile),
408
+ exported: this.isExported(node)
409
+ });
410
+ }
411
+
412
+ return null;
413
+ }
414
+
415
+ /**
416
+ * Detect the specific type of a function based on naming conventions.
417
+ */
418
+ private detectFunctionType(name: string, node: ts.Node): PatternType {
419
+ // React hooks
420
+ if (name.startsWith('use') && name.length > 3 && name[3] === name[3].toUpperCase()) {
421
+ return 'hook';
422
+ }
423
+
424
+ // React components (PascalCase and returns JSX)
425
+ if (name[0] === name[0].toUpperCase() && this.containsJSX(node)) {
426
+ return 'component';
427
+ }
428
+
429
+ // Middleware patterns
430
+ if (name.includes('Middleware') || name.includes('middleware')) {
431
+ return 'middleware';
432
+ }
433
+
434
+ // Handler patterns
435
+ if (name.includes('Handler') || name.includes('handler')) {
436
+ return 'handler';
437
+ }
438
+
439
+ // Factory patterns
440
+ if (name.startsWith('create') || name.startsWith('make') || name.includes('Factory')) {
441
+ return 'factory';
442
+ }
443
+
444
+ return 'function';
445
+ }
446
+
447
+ /**
448
+ * Detect the specific type of a class.
449
+ */
450
+ private detectClassType(name: string, node: ts.ClassDeclaration): PatternType {
451
+ // Error classes
452
+ if (name.endsWith('Error') || name.endsWith('Exception')) {
453
+ return 'error';
454
+ }
455
+
456
+ // Check for React component (extends Component/PureComponent)
457
+ if (node.heritageClauses) {
458
+ for (const clause of node.heritageClauses) {
459
+ const text = clause.getText();
460
+ if (text.includes('Component') || text.includes('PureComponent')) {
461
+ return 'component';
462
+ }
463
+ }
464
+ }
465
+
466
+ // Store patterns
467
+ if (name.endsWith('Store') || name.endsWith('State')) {
468
+ return 'store';
469
+ }
470
+
471
+ // Model patterns
472
+ if (name.endsWith('Model') || name.endsWith('Entity')) {
473
+ return 'model';
474
+ }
475
+
476
+ return 'class';
477
+ }
478
+
479
+ /**
480
+ * Check if a node contains JSX.
481
+ */
482
+ private containsJSX(node: ts.Node): boolean {
483
+ let hasJSX = false;
484
+ const visit = (n: ts.Node) => {
485
+ if (ts.isJsxElement(n) || ts.isJsxSelfClosingElement(n) || ts.isJsxFragment(n)) {
486
+ hasJSX = true;
487
+ return;
488
+ }
489
+ ts.forEachChild(n, visit);
490
+ };
491
+ visit(node);
492
+ return hasJSX;
493
+ }
494
+
495
+ /**
496
+ * Get function signature.
497
+ */
498
+ private getFunctionSignature(node: ts.FunctionDeclaration, sourceFile: ts.SourceFile): string {
499
+ const params = node.parameters
500
+ .map(p => p.getText(sourceFile))
501
+ .join(', ');
502
+ const returnType = node.type ? `: ${node.type.getText(sourceFile)}` : '';
503
+ return `(${params})${returnType}`;
504
+ }
505
+
506
+ /**
507
+ * Get arrow function signature.
508
+ */
509
+ private getArrowFunctionSignature(
510
+ node: ts.ArrowFunction | ts.FunctionExpression,
511
+ sourceFile: ts.SourceFile
512
+ ): string {
513
+ const params = node.parameters
514
+ .map(p => p.getText(sourceFile))
515
+ .join(', ');
516
+ const returnType = node.type ? `: ${node.type.getText(sourceFile)}` : '';
517
+ return `(${params})${returnType}`;
518
+ }
519
+
520
+ /**
521
+ * Get class signature.
522
+ */
523
+ private getClassSignature(node: ts.ClassDeclaration, sourceFile: ts.SourceFile): string {
524
+ let sig = `class ${node.name?.text || 'Anonymous'}`;
525
+ if (node.heritageClauses) {
526
+ sig += ' ' + node.heritageClauses.map(c => c.getText(sourceFile)).join(' ');
527
+ }
528
+ return sig;
529
+ }
530
+
531
+ /**
532
+ * Get interface signature.
533
+ */
534
+ private getInterfaceSignature(node: ts.InterfaceDeclaration, sourceFile: ts.SourceFile): string {
535
+ let sig = `interface ${node.name.text}`;
536
+ if (node.typeParameters) {
537
+ sig += `<${node.typeParameters.map(p => p.getText(sourceFile)).join(', ')}>`;
538
+ }
539
+ return sig;
540
+ }
541
+
542
+ /**
543
+ * Extract JSDoc description from a node.
544
+ */
545
+ private getJSDocDescription(node: ts.Node, sourceFile: ts.SourceFile): string {
546
+ const jsDocTags = ts.getJSDocTags(node);
547
+ const jsDocComment = ts.getJSDocCommentsAndTags(node);
548
+
549
+ for (const tag of jsDocComment) {
550
+ if (ts.isJSDoc(tag) && tag.comment) {
551
+ if (typeof tag.comment === 'string') {
552
+ return tag.comment;
553
+ }
554
+ return tag.comment.map(c => c.getText(sourceFile)).join(' ');
555
+ }
556
+ }
557
+
558
+ return '';
559
+ }
560
+
561
+ /**
562
+ * Check if a node is exported.
563
+ */
564
+ private isExported(node: ts.Node): boolean {
565
+ if (ts.canHaveModifiers(node)) {
566
+ const modifiers = ts.getModifiers(node);
567
+ if (modifiers) {
568
+ return modifiers.some(m => m.kind === ts.SyntaxKind.ExportKeyword);
569
+ }
570
+ }
571
+ return false;
572
+ }
573
+
574
+ /**
575
+ * Extract keywords from a name for semantic matching.
576
+ */
577
+ private extractKeywords(name: string): string[] {
578
+ // Split camelCase and PascalCase
579
+ const words = name
580
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
581
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
582
+ .toLowerCase()
583
+ .split(/[\s_-]+/)
584
+ .filter(w => w.length > 1);
585
+
586
+ return [...new Set(words)];
587
+ }
588
+
589
+ /**
590
+ * Create a PatternEntry with computed fields.
591
+ */
592
+ private createPatternEntry(params: {
593
+ type: PatternType;
594
+ name: string;
595
+ file: string;
596
+ line: number;
597
+ endLine: number;
598
+ signature: string;
599
+ description: string;
600
+ keywords: string[];
601
+ content: string;
602
+ exported: boolean;
603
+ }): PatternEntry {
604
+ const id = this.hashContent(`${params.file}:${params.name}:${params.line}`);
605
+ const hash = this.hashContent(params.content);
606
+
607
+ return {
608
+ id,
609
+ type: params.type,
610
+ name: params.name,
611
+ file: params.file,
612
+ line: params.line,
613
+ endLine: params.endLine,
614
+ signature: params.signature,
615
+ description: params.description,
616
+ keywords: params.keywords,
617
+ hash,
618
+ exported: params.exported,
619
+ usageCount: 0, // Will be calculated in a separate pass
620
+ indexedAt: new Date().toISOString()
621
+ };
622
+ }
623
+
624
+ /**
625
+ * Get the TypeScript ScriptKind for a file.
626
+ */
627
+ private getScriptKind(filePath: string): ts.ScriptKind {
628
+ const ext = path.extname(filePath).toLowerCase();
629
+ switch (ext) {
630
+ case '.ts': return ts.ScriptKind.TS;
631
+ case '.tsx': return ts.ScriptKind.TSX;
632
+ case '.js': return ts.ScriptKind.JS;
633
+ case '.jsx': return ts.ScriptKind.JSX;
634
+ default: return ts.ScriptKind.TS;
635
+ }
636
+ }
637
+
638
+ /**
639
+ * Calculate index statistics.
640
+ */
641
+ private calculateStats(
642
+ patterns: PatternEntry[],
643
+ files: IndexedFile[],
644
+ durationMs: number
645
+ ): PatternIndexStats {
646
+ const byType: Record<string, number> = {};
647
+
648
+ for (const pattern of patterns) {
649
+ byType[pattern.type] = (byType[pattern.type] || 0) + 1;
650
+ }
651
+
652
+ return {
653
+ totalPatterns: patterns.length,
654
+ totalFiles: files.length,
655
+ byType: byType as Record<PatternType, number>,
656
+ indexDurationMs: durationMs
657
+ };
658
+ }
659
+
660
+ /**
661
+ * Hash content using SHA-256.
662
+ */
663
+ private hashContent(content: string): string {
664
+ return createHash('sha256').update(content).digest('hex').slice(0, 16);
665
+ }
666
+ }
667
+
668
+ /**
669
+ * Save a pattern index to disk.
670
+ */
671
+ export async function savePatternIndex(index: PatternIndex, outputPath: string): Promise<void> {
672
+ await fs.mkdir(path.dirname(outputPath), { recursive: true });
673
+ await fs.writeFile(outputPath, JSON.stringify(index, null, 2), 'utf-8');
674
+ }
675
+
676
+ /**
677
+ * Load a pattern index from disk.
678
+ */
679
+ export async function loadPatternIndex(indexPath: string): Promise<PatternIndex | null> {
680
+ try {
681
+ const content = await fs.readFile(indexPath, 'utf-8');
682
+ return JSON.parse(content) as PatternIndex;
683
+ } catch {
684
+ return null;
685
+ }
686
+ }
687
+
688
+ /**
689
+ * Get the default index path for a project.
690
+ */
691
+ export function getDefaultIndexPath(rootDir: string): string {
692
+ return path.join(rootDir, '.rigour', 'patterns.json');
693
+ }