@rigour-labs/core 4.0.0 → 4.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,547 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ // We test the internal extractFileFacts by importing extractFacts and factsToPromptString
3
+ // But we need to test extraction logic directly, so we also test via the public API
4
+ const mockGlobby = vi.hoisted(() => vi.fn());
5
+ const mockReadFile = vi.hoisted(() => vi.fn());
6
+ vi.mock('globby', () => ({
7
+ globby: mockGlobby,
8
+ }));
9
+ vi.mock('fs-extra', () => ({
10
+ default: {
11
+ readFile: mockReadFile,
12
+ },
13
+ }));
14
+ import { extractFacts, factsToPromptString } from './fact-extractor.js';
15
+ describe('Fact Extractor', () => {
16
+ beforeEach(() => {
17
+ vi.clearAllMocks();
18
+ });
19
+ // ── TypeScript extraction ──
20
+ describe('TypeScript/JavaScript extraction', () => {
21
+ it('should extract classes with methods and dependencies', async () => {
22
+ mockGlobby.mockResolvedValue(['src/service.ts']);
23
+ mockReadFile.mockResolvedValue(`
24
+ import { Logger } from './logger';
25
+
26
+ export class UserService {
27
+ constructor(
28
+ private db: Database,
29
+ private logger: Logger
30
+ ) {}
31
+
32
+ async findById(id: string): Promise<User> {
33
+ return this.db.find(id);
34
+ }
35
+
36
+ async create(data: CreateUserDto): Promise<User> {
37
+ this.logger.info('Creating user');
38
+ return this.db.create(data);
39
+ }
40
+
41
+ async update(id: string, data: UpdateUserDto): Promise<User> {
42
+ return this.db.update(id, data);
43
+ }
44
+
45
+ async delete(id: string): Promise<void> {
46
+ return this.db.delete(id);
47
+ }
48
+ }
49
+ `);
50
+ const facts = await extractFacts('/project');
51
+ expect(facts).toHaveLength(1);
52
+ expect(facts[0].language).toBe('typescript');
53
+ expect(facts[0].classes).toHaveLength(1);
54
+ expect(facts[0].classes[0].name).toBe('UserService');
55
+ expect(facts[0].classes[0].methodCount).toBeGreaterThanOrEqual(4);
56
+ expect(facts[0].classes[0].dependencies).toContain('Database');
57
+ expect(facts[0].classes[0].dependencies).toContain('Logger');
58
+ });
59
+ it('should extract functions with params and async detection', async () => {
60
+ mockGlobby.mockResolvedValue(['src/utils.ts']);
61
+ mockReadFile.mockResolvedValue(`
62
+ export async function processData(input: string, config: Config, options: Options): Promise<Result> {
63
+ if (input.length > 0) {
64
+ if (config.validate) {
65
+ if (options.strict) {
66
+ return validate(input);
67
+ }
68
+ }
69
+ }
70
+ return { data: input };
71
+ }
72
+
73
+ export function simpleHelper(x: number): number {
74
+ return x * 2;
75
+ }
76
+ `);
77
+ const facts = await extractFacts('/project');
78
+ const funcs = facts[0].functions;
79
+ expect(funcs.length).toBeGreaterThanOrEqual(1);
80
+ const processData = funcs.find(f => f.name === 'processData');
81
+ expect(processData).toBeDefined();
82
+ expect(processData.isAsync).toBe(true);
83
+ expect(processData.isExported).toBe(true);
84
+ expect(processData.paramCount).toBe(3);
85
+ expect(processData.maxNesting).toBeGreaterThanOrEqual(3);
86
+ });
87
+ it('should extract imports from ES modules and require', async () => {
88
+ mockGlobby.mockResolvedValue(['src/index.ts']);
89
+ mockReadFile.mockResolvedValue(`
90
+ import { Router } from 'express';
91
+ import path from 'path';
92
+ const fs = require('fs-extra');
93
+ import type { Config } from './types';
94
+
95
+ export function init() {
96
+ const router = Router();
97
+ return router;
98
+ }
99
+ `);
100
+ const facts = await extractFacts('/project');
101
+ expect(facts[0].imports).toContain('express');
102
+ expect(facts[0].imports).toContain('path');
103
+ expect(facts[0].imports).toContain('fs-extra');
104
+ expect(facts[0].imports).toContain('./types');
105
+ });
106
+ it('should extract error handling patterns', async () => {
107
+ mockGlobby.mockResolvedValue(['src/api.ts']);
108
+ mockReadFile.mockResolvedValue(`
109
+ async function fetchData() {
110
+ try {
111
+ const res = await fetch('/api');
112
+ return res.json();
113
+ } catch (err) {
114
+ console.error(err);
115
+ throw err;
116
+ }
117
+ }
118
+
119
+ async function riskyOp() {
120
+ try {
121
+ await doSomething();
122
+ } catch (err) {
123
+ }
124
+ }
125
+
126
+ fetchData().then(data => {
127
+ process(data);
128
+ }).catch(() => {});
129
+ `);
130
+ const facts = await extractFacts('/project');
131
+ expect(facts[0].errorHandling.length).toBeGreaterThanOrEqual(2);
132
+ const tryCatches = facts[0].errorHandling.filter(e => e.type === 'try-catch');
133
+ expect(tryCatches.length).toBeGreaterThanOrEqual(2);
134
+ // Strategy detection finds console.error first → 'log'
135
+ const strategies = tryCatches.map(e => e.strategy);
136
+ expect(strategies.length).toBeGreaterThanOrEqual(2);
137
+ });
138
+ it('should detect test files and count assertions', async () => {
139
+ mockGlobby.mockResolvedValue(['src/utils.test.ts']);
140
+ mockReadFile.mockResolvedValue(`
141
+ import { describe, it, expect } from 'vitest';
142
+
143
+ describe('Utils', () => {
144
+ it('should add numbers', () => {
145
+ expect(add(1, 2)).toBe(3);
146
+ expect(add(0, 0)).toBe(0);
147
+ });
148
+
149
+ it('should handle negatives', () => {
150
+ expect(add(-1, 1)).toBe(0);
151
+ });
152
+ });
153
+ `);
154
+ const facts = await extractFacts('/project');
155
+ expect(facts[0].hasTests).toBe(true);
156
+ expect(facts[0].testAssertions).toBeGreaterThanOrEqual(3);
157
+ });
158
+ it('should extract exports', async () => {
159
+ mockGlobby.mockResolvedValue(['src/types.ts']);
160
+ mockReadFile.mockResolvedValue(`
161
+ export interface User {
162
+ id: string;
163
+ name: string;
164
+ }
165
+
166
+ export const DEFAULT_CONFIG = {};
167
+
168
+ export function createUser(): User {
169
+ return { id: '1', name: 'test' };
170
+ }
171
+
172
+ export type Severity = 'high' | 'low';
173
+ `);
174
+ const facts = await extractFacts('/project');
175
+ expect(facts[0].exports).toContain('User');
176
+ expect(facts[0].exports).toContain('DEFAULT_CONFIG');
177
+ expect(facts[0].exports).toContain('createUser');
178
+ expect(facts[0].exports).toContain('Severity');
179
+ });
180
+ });
181
+ // ── Python extraction ──
182
+ describe('Python extraction', () => {
183
+ it('should extract Python classes', async () => {
184
+ mockGlobby.mockResolvedValue(['app/service.py']);
185
+ mockReadFile.mockResolvedValue(`
186
+ class UserService:
187
+ def __init__(self, db):
188
+ self.db = db
189
+
190
+ def find_by_id(self, user_id):
191
+ return self.db.find(user_id)
192
+
193
+ def create(self, data):
194
+ return self.db.create(data)
195
+
196
+ def _validate(self, data):
197
+ pass
198
+ `);
199
+ const facts = await extractFacts('/project');
200
+ expect(facts[0].language).toBe('python');
201
+ expect(facts[0].classes).toHaveLength(1);
202
+ expect(facts[0].classes[0].name).toBe('UserService');
203
+ expect(facts[0].classes[0].methodCount).toBeGreaterThanOrEqual(3);
204
+ });
205
+ it('should extract Python imports', async () => {
206
+ mockGlobby.mockResolvedValue(['app/main.py']);
207
+ mockReadFile.mockResolvedValue(`
208
+ import os
209
+ from pathlib import Path
210
+ import json
211
+ from typing import Optional
212
+ from app.service import UserService
213
+
214
+ def main():
215
+ svc = UserService()
216
+ return svc
217
+ `);
218
+ const facts = await extractFacts('/project');
219
+ expect(facts[0].imports).toContain('os');
220
+ expect(facts[0].imports).toContain('pathlib');
221
+ expect(facts[0].imports).toContain('app.service');
222
+ });
223
+ });
224
+ // ── Go extraction ──
225
+ describe('Go extraction', () => {
226
+ it('should extract Go structs with fields and methods', async () => {
227
+ mockGlobby.mockResolvedValue(['pkg/server.go']);
228
+ mockReadFile.mockResolvedValue(`package server
229
+
230
+ import (
231
+ "net/http"
232
+ "sync"
233
+ )
234
+
235
+ type Server struct {
236
+ addr string
237
+ port int
238
+ handler http.Handler
239
+ mu sync.Mutex
240
+ *Base
241
+ }
242
+
243
+ func NewServer(addr string, port int) *Server {
244
+ return &Server{addr: addr, port: port}
245
+ }
246
+
247
+ func (s *Server) Start() error {
248
+ s.mu.Lock()
249
+ defer s.mu.Unlock()
250
+ return http.ListenAndServe(s.addr, s.handler)
251
+ }
252
+
253
+ func (s *Server) Stop() error {
254
+ return nil
255
+ }
256
+
257
+ func (s *Server) Handler() http.Handler {
258
+ return s.handler
259
+ }
260
+ `);
261
+ const facts = await extractFacts('/project');
262
+ expect(facts[0].language).toBe('go');
263
+ expect(facts[0].structs).toBeDefined();
264
+ expect(facts[0].structs.length).toBeGreaterThanOrEqual(1);
265
+ const server = facts[0].structs[0];
266
+ expect(server.name).toBe('Server');
267
+ expect(server.fieldCount).toBeGreaterThanOrEqual(4); // addr, port, handler, mu, Base
268
+ expect(server.methodCount).toBeGreaterThanOrEqual(3); // Start, Stop, Handler
269
+ expect(server.methods).toContain('Start');
270
+ expect(server.methods).toContain('Stop');
271
+ expect(server.embeds).toContain('Base');
272
+ });
273
+ it('should extract Go interfaces', async () => {
274
+ mockGlobby.mockResolvedValue(['pkg/store.go']);
275
+ mockReadFile.mockResolvedValue(`package store
276
+
277
+ type Store interface {
278
+ Get(key string) (string, error)
279
+ Set(key string, value string) error
280
+ Delete(key string) error
281
+ List(prefix string) ([]string, error)
282
+ Close() error
283
+ Watch(key string) <-chan Event
284
+ }
285
+
286
+ type SimpleStore struct {
287
+ data map[string]string
288
+ }
289
+ `);
290
+ const facts = await extractFacts('/project');
291
+ expect(facts[0].interfaces).toBeDefined();
292
+ expect(facts[0].interfaces.length).toBeGreaterThanOrEqual(1);
293
+ const store = facts[0].interfaces[0];
294
+ expect(store.name).toBe('Store');
295
+ expect(store.methodCount).toBe(6);
296
+ expect(store.methods).toContain('Get');
297
+ expect(store.methods).toContain('Close');
298
+ });
299
+ it('should extract Go functions with receiver methods', async () => {
300
+ mockGlobby.mockResolvedValue(['pkg/handler.go']);
301
+ mockReadFile.mockResolvedValue(`package handler
302
+
303
+ type Handler struct {
304
+ service Service
305
+ }
306
+
307
+ func NewHandler(svc Service) *Handler {
308
+ return &Handler{service: svc}
309
+ }
310
+
311
+ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
312
+ if r.Method == "GET" {
313
+ h.handleGet(w, r)
314
+ } else {
315
+ h.handlePost(w, r)
316
+ }
317
+ }
318
+
319
+ func (h *Handler) handleGet(w http.ResponseWriter, r *http.Request) {
320
+ data, err := h.service.Find(r.URL.Query().Get("id"))
321
+ if err != nil {
322
+ http.Error(w, err.Error(), 500)
323
+ return
324
+ }
325
+ json.NewEncoder(w).Encode(data)
326
+ }
327
+ `);
328
+ const facts = await extractFacts('/project');
329
+ const funcs = facts[0].functions;
330
+ // Should have receiver methods named as Receiver.Method
331
+ const serveHTTP = funcs.find(f => f.name === 'Handler.ServeHTTP');
332
+ expect(serveHTTP).toBeDefined();
333
+ const handleGet = funcs.find(f => f.name === 'Handler.handleGet');
334
+ expect(handleGet).toBeDefined();
335
+ // NewHandler should be a standalone func (no receiver)
336
+ const newHandler = funcs.find(f => f.name === 'NewHandler');
337
+ expect(newHandler).toBeDefined();
338
+ expect(newHandler.isExported).toBe(true);
339
+ });
340
+ it('should count concurrency metrics', async () => {
341
+ mockGlobby.mockResolvedValue(['pkg/worker.go']);
342
+ mockReadFile.mockResolvedValue(`package worker
343
+
344
+ import "sync"
345
+
346
+ func StartWorkers(n int) {
347
+ var mu sync.Mutex
348
+ var wg sync.WaitGroup
349
+ ch := make(chan int, 10)
350
+
351
+ for i := 0; i < n; i++ {
352
+ wg.Add(1)
353
+ go processItem(ch, &mu, &wg)
354
+ }
355
+
356
+ go monitorWorkers(ch)
357
+
358
+ defer close(ch)
359
+ }
360
+
361
+ func processItem(ch chan int, mu *sync.Mutex, wg *sync.WaitGroup) {
362
+ defer wg.Done()
363
+ for item := range ch {
364
+ mu.Lock()
365
+ process(item)
366
+ mu.Unlock()
367
+ }
368
+ }
369
+ `);
370
+ const facts = await extractFacts('/project');
371
+ expect(facts[0].goroutines).toBeGreaterThanOrEqual(2);
372
+ expect(facts[0].channels).toBeGreaterThanOrEqual(1);
373
+ expect(facts[0].defers).toBeGreaterThanOrEqual(2);
374
+ expect(facts[0].mutexes).toBeGreaterThanOrEqual(2);
375
+ });
376
+ });
377
+ // ── Quality metrics ──
378
+ describe('Quality metrics', () => {
379
+ it('should detect magic numbers', async () => {
380
+ mockGlobby.mockResolvedValue(['src/calc.ts']);
381
+ mockReadFile.mockResolvedValue(`
382
+ export function calculate(input: number): number {
383
+ const base = input * 42;
384
+ const tax = base * 17;
385
+ const fee = 325 + 1250;
386
+ const limit = 9999;
387
+ return base + tax + fee + limit;
388
+ }
389
+ `);
390
+ const facts = await extractFacts('/project');
391
+ expect(facts[0].magicNumbers).toBeGreaterThanOrEqual(3);
392
+ });
393
+ it('should count TODOs', async () => {
394
+ mockGlobby.mockResolvedValue(['src/app.ts']);
395
+ mockReadFile.mockResolvedValue(`
396
+ // TODO: implement caching
397
+ export function getData() {
398
+ // FIXME: this is slow
399
+ const data = fetch('/api');
400
+ // HACK: workaround for bug
401
+ return data;
402
+ }
403
+ `);
404
+ const facts = await extractFacts('/project');
405
+ expect(facts[0].todoCount).toBe(4); // TODO, FIXME, HACK, WORKAROUND
406
+ });
407
+ it('should calculate comment ratio', async () => {
408
+ mockGlobby.mockResolvedValue(['src/app.ts']);
409
+ mockReadFile.mockResolvedValue(`
410
+ // This is a comment
411
+ // Another comment
412
+ export function foo() {
413
+ return 1;
414
+ }
415
+
416
+ function bar() {
417
+ return 2;
418
+ }
419
+ `);
420
+ const facts = await extractFacts('/project');
421
+ expect(facts[0].commentRatio).toBeGreaterThan(0);
422
+ });
423
+ it('should skip trivial files (< 3 lines)', async () => {
424
+ mockGlobby.mockResolvedValue(['src/empty.ts']);
425
+ mockReadFile.mockResolvedValue(`// empty\n`);
426
+ const facts = await extractFacts('/project');
427
+ expect(facts).toHaveLength(0);
428
+ });
429
+ });
430
+ // ── factsToPromptString ──
431
+ describe('factsToPromptString', () => {
432
+ it('should serialize file facts to a prompt string', () => {
433
+ const facts = [
434
+ {
435
+ path: 'src/service.ts',
436
+ language: 'typescript',
437
+ lineCount: 150,
438
+ classes: [{
439
+ name: 'UserService',
440
+ lineStart: 5,
441
+ lineEnd: 140,
442
+ methodCount: 8,
443
+ methods: ['find', 'create', 'update', 'delete', 'validate', 'transform', 'cache', 'notify'],
444
+ publicMethods: ['find', 'create', 'update', 'delete'],
445
+ lineCount: 135,
446
+ dependencies: ['Database', 'Logger'],
447
+ }],
448
+ functions: [],
449
+ imports: ['express', './types', 'lodash'],
450
+ exports: ['UserService'],
451
+ errorHandling: [
452
+ { type: 'try-catch', lineStart: 10, isEmpty: false, strategy: 'throw' },
453
+ { type: 'try-catch', lineStart: 30, isEmpty: true, strategy: 'ignore' },
454
+ ],
455
+ testAssertions: 0,
456
+ hasTests: false,
457
+ },
458
+ ];
459
+ const result = factsToPromptString(facts);
460
+ expect(result).toContain('FILE: src/service.ts');
461
+ expect(result).toContain('CLASS UserService');
462
+ expect(result).toContain('135 lines');
463
+ expect(result).toContain('8 methods');
464
+ expect(result).toContain('deps: Database, Logger');
465
+ expect(result).toContain('ERROR_HANDLING');
466
+ expect(result).toContain('1 empty catches');
467
+ expect(result).toContain('IMPORTS: 3');
468
+ });
469
+ it('should include Go structs and concurrency info', () => {
470
+ const facts = [
471
+ {
472
+ path: 'pkg/worker.go',
473
+ language: 'go',
474
+ lineCount: 200,
475
+ classes: [],
476
+ functions: [{
477
+ name: 'StartWorkers',
478
+ lineStart: 10,
479
+ lineEnd: 60,
480
+ lineCount: 50,
481
+ paramCount: 2,
482
+ params: ['n int', 'config Config'],
483
+ maxNesting: 3,
484
+ hasReturn: true,
485
+ isAsync: true,
486
+ isExported: true,
487
+ }],
488
+ imports: ['sync', 'context'],
489
+ exports: [],
490
+ errorHandling: [],
491
+ testAssertions: 0,
492
+ hasTests: false,
493
+ structs: [{
494
+ name: 'Worker',
495
+ lineStart: 5,
496
+ lineEnd: 15,
497
+ fieldCount: 4,
498
+ methodCount: 3,
499
+ methods: ['Start', 'Stop', 'Process'],
500
+ lineCount: 10,
501
+ embeds: [],
502
+ }],
503
+ goroutines: 5,
504
+ channels: 2,
505
+ defers: 3,
506
+ mutexes: 1,
507
+ },
508
+ ];
509
+ const result = factsToPromptString(facts);
510
+ expect(result).toContain('STRUCT Worker');
511
+ expect(result).toContain('4 fields');
512
+ expect(result).toContain('3 methods');
513
+ expect(result).toContain('CONCURRENCY');
514
+ expect(result).toContain('goroutines:5');
515
+ expect(result).toContain('channels:2');
516
+ expect(result).toContain('defers:3');
517
+ expect(result).toContain('mutexes:1');
518
+ });
519
+ it('should respect maxChars budget', () => {
520
+ const manyFacts = Array.from({ length: 100 }, (_, i) => ({
521
+ path: `src/file${i}.ts`,
522
+ language: 'typescript',
523
+ lineCount: 100,
524
+ classes: [],
525
+ functions: [{
526
+ name: `func${i}`,
527
+ lineStart: 1,
528
+ lineEnd: 50,
529
+ lineCount: 50,
530
+ paramCount: 2,
531
+ params: ['a', 'b'],
532
+ maxNesting: 2,
533
+ hasReturn: true,
534
+ isAsync: false,
535
+ isExported: true,
536
+ }],
537
+ imports: ['express', 'lodash', 'react'],
538
+ exports: [`func${i}`],
539
+ errorHandling: [],
540
+ testAssertions: 0,
541
+ hasTests: false,
542
+ }));
543
+ const result = factsToPromptString(manyFacts, 500);
544
+ expect(result.length).toBeLessThanOrEqual(600); // Allow some slack
545
+ });
546
+ });
547
+ });
@@ -0,0 +1 @@
1
+ export {};