@rigour-labs/core 4.0.0 → 4.0.1
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 {};
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { buildAnalysisPrompt, buildCrossFilePrompt, chunkFacts, DEEP_SYSTEM_PROMPT } from './prompts.js';
|
|
3
|
+
// ── Test helpers ──
|
|
4
|
+
function makeGoFacts(overrides = {}) {
|
|
5
|
+
return {
|
|
6
|
+
path: 'pkg/server.go',
|
|
7
|
+
language: 'go',
|
|
8
|
+
lineCount: 200,
|
|
9
|
+
classes: [],
|
|
10
|
+
functions: [{
|
|
11
|
+
name: 'Server.Start',
|
|
12
|
+
lineStart: 10,
|
|
13
|
+
lineEnd: 50,
|
|
14
|
+
lineCount: 40,
|
|
15
|
+
paramCount: 1,
|
|
16
|
+
params: ['ctx context.Context'],
|
|
17
|
+
maxNesting: 3,
|
|
18
|
+
hasReturn: true,
|
|
19
|
+
isAsync: false,
|
|
20
|
+
isExported: true,
|
|
21
|
+
}],
|
|
22
|
+
imports: ['net/http', 'context', 'sync'],
|
|
23
|
+
exports: [],
|
|
24
|
+
errorHandling: [],
|
|
25
|
+
testAssertions: 0,
|
|
26
|
+
hasTests: false,
|
|
27
|
+
structs: [{
|
|
28
|
+
name: 'Server',
|
|
29
|
+
lineStart: 5,
|
|
30
|
+
lineEnd: 15,
|
|
31
|
+
fieldCount: 5,
|
|
32
|
+
methodCount: 4,
|
|
33
|
+
methods: ['Start', 'Stop', 'Handle', 'Route'],
|
|
34
|
+
lineCount: 10,
|
|
35
|
+
embeds: ['BaseServer'],
|
|
36
|
+
}],
|
|
37
|
+
interfaces: [{
|
|
38
|
+
name: 'Handler',
|
|
39
|
+
lineStart: 20,
|
|
40
|
+
methodCount: 2,
|
|
41
|
+
methods: ['ServeHTTP', 'Health'],
|
|
42
|
+
}],
|
|
43
|
+
goroutines: 3,
|
|
44
|
+
channels: 1,
|
|
45
|
+
mutexes: 1,
|
|
46
|
+
defers: 2,
|
|
47
|
+
...overrides,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function makeTsFacts(overrides = {}) {
|
|
51
|
+
return {
|
|
52
|
+
path: 'src/service.ts',
|
|
53
|
+
language: 'typescript',
|
|
54
|
+
lineCount: 150,
|
|
55
|
+
classes: [{
|
|
56
|
+
name: 'UserService',
|
|
57
|
+
lineStart: 5,
|
|
58
|
+
lineEnd: 140,
|
|
59
|
+
methodCount: 6,
|
|
60
|
+
methods: ['find', 'create', 'update', 'delete', 'validate', 'notify'],
|
|
61
|
+
publicMethods: ['find', 'create', 'update', 'delete'],
|
|
62
|
+
lineCount: 135,
|
|
63
|
+
dependencies: ['Database', 'Logger'],
|
|
64
|
+
}],
|
|
65
|
+
functions: [],
|
|
66
|
+
imports: ['express', 'lodash', './types'],
|
|
67
|
+
exports: ['UserService'],
|
|
68
|
+
errorHandling: [
|
|
69
|
+
{ type: 'try-catch', lineStart: 10, isEmpty: false, strategy: 'throw' },
|
|
70
|
+
],
|
|
71
|
+
testAssertions: 0,
|
|
72
|
+
hasTests: false,
|
|
73
|
+
...overrides,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
describe('Deep Analysis Prompts', () => {
|
|
77
|
+
// ── DEEP_SYSTEM_PROMPT ──
|
|
78
|
+
describe('DEEP_SYSTEM_PROMPT', () => {
|
|
79
|
+
it('should contain all category groups', () => {
|
|
80
|
+
expect(DEEP_SYSTEM_PROMPT).toContain('SOLID Principles');
|
|
81
|
+
expect(DEEP_SYSTEM_PROMPT).toContain('Design Patterns');
|
|
82
|
+
expect(DEEP_SYSTEM_PROMPT).toContain('DRY');
|
|
83
|
+
expect(DEEP_SYSTEM_PROMPT).toContain('Error Handling');
|
|
84
|
+
expect(DEEP_SYSTEM_PROMPT).toContain('Concurrency');
|
|
85
|
+
expect(DEEP_SYSTEM_PROMPT).toContain('Testing');
|
|
86
|
+
});
|
|
87
|
+
it('should contain key category IDs', () => {
|
|
88
|
+
expect(DEEP_SYSTEM_PROMPT).toContain('god_class');
|
|
89
|
+
expect(DEEP_SYSTEM_PROMPT).toContain('god_function');
|
|
90
|
+
expect(DEEP_SYSTEM_PROMPT).toContain('srp_violation');
|
|
91
|
+
expect(DEEP_SYSTEM_PROMPT).toContain('race_condition');
|
|
92
|
+
expect(DEEP_SYSTEM_PROMPT).toContain('empty_catch');
|
|
93
|
+
expect(DEEP_SYSTEM_PROMPT).toContain('missing_test');
|
|
94
|
+
});
|
|
95
|
+
it('should include output schema', () => {
|
|
96
|
+
expect(DEEP_SYSTEM_PROMPT).toContain('"findings"');
|
|
97
|
+
expect(DEEP_SYSTEM_PROMPT).toContain('"category"');
|
|
98
|
+
expect(DEEP_SYSTEM_PROMPT).toContain('"severity"');
|
|
99
|
+
expect(DEEP_SYSTEM_PROMPT).toContain('"confidence"');
|
|
100
|
+
});
|
|
101
|
+
it('should contain Go-specific guidance', () => {
|
|
102
|
+
expect(DEEP_SYSTEM_PROMPT).toContain('Go code');
|
|
103
|
+
expect(DEEP_SYSTEM_PROMPT).toContain('struct');
|
|
104
|
+
expect(DEEP_SYSTEM_PROMPT).toContain('receiver method');
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
// ── buildAnalysisPrompt ──
|
|
108
|
+
describe('buildAnalysisPrompt', () => {
|
|
109
|
+
it('should build prompt with default checks', () => {
|
|
110
|
+
const factsStr = 'FILE: src/service.ts (typescript, 150 lines)';
|
|
111
|
+
const prompt = buildAnalysisPrompt(factsStr);
|
|
112
|
+
// Default checks include descriptions for SOLID, DRY, design patterns
|
|
113
|
+
expect(prompt).toContain('SOLID');
|
|
114
|
+
expect(prompt).toContain('DRY');
|
|
115
|
+
expect(prompt).toContain('god class');
|
|
116
|
+
expect(prompt).toContain(factsStr);
|
|
117
|
+
});
|
|
118
|
+
it('should include language-specific guidance for Go', () => {
|
|
119
|
+
const factsStr = 'FILE: pkg/server.go (go, 200 lines)\n STRUCT Server (10 lines, 5 fields)';
|
|
120
|
+
const prompt = buildAnalysisPrompt(factsStr);
|
|
121
|
+
// Should detect Go as dominant language and add Go guidance
|
|
122
|
+
expect(prompt.toLowerCase()).toContain('go');
|
|
123
|
+
});
|
|
124
|
+
it('should include language-specific guidance for TypeScript', () => {
|
|
125
|
+
const factsStr = 'FILE: src/service.ts (typescript, 150 lines)\n CLASS UserService';
|
|
126
|
+
const prompt = buildAnalysisPrompt(factsStr);
|
|
127
|
+
expect(prompt.toLowerCase()).toContain('typescript');
|
|
128
|
+
});
|
|
129
|
+
it('should respect custom check selection', () => {
|
|
130
|
+
const factsStr = 'FILE: src/service.ts (typescript, 150 lines)';
|
|
131
|
+
const checks = {
|
|
132
|
+
solid: true,
|
|
133
|
+
dry: false,
|
|
134
|
+
design_patterns: false,
|
|
135
|
+
concurrency: true,
|
|
136
|
+
};
|
|
137
|
+
const prompt = buildAnalysisPrompt(factsStr, checks);
|
|
138
|
+
expect(prompt).toContain('SOLID');
|
|
139
|
+
expect(prompt).toContain('Concurrency');
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
// ── buildCrossFilePrompt ──
|
|
143
|
+
describe('buildCrossFilePrompt', () => {
|
|
144
|
+
it('should analyze patterns across multiple files', () => {
|
|
145
|
+
const allFacts = [
|
|
146
|
+
makeTsFacts({ path: 'src/user.service.ts' }),
|
|
147
|
+
makeTsFacts({
|
|
148
|
+
path: 'src/order.service.ts',
|
|
149
|
+
classes: [{
|
|
150
|
+
name: 'OrderService',
|
|
151
|
+
lineStart: 1,
|
|
152
|
+
lineEnd: 100,
|
|
153
|
+
methodCount: 5,
|
|
154
|
+
methods: ['find', 'create', 'update', 'delete', 'process'],
|
|
155
|
+
publicMethods: ['find', 'create'],
|
|
156
|
+
lineCount: 100,
|
|
157
|
+
dependencies: ['Database', 'Logger'],
|
|
158
|
+
}],
|
|
159
|
+
}),
|
|
160
|
+
makeTsFacts({
|
|
161
|
+
path: 'src/product.service.ts',
|
|
162
|
+
errorHandling: [
|
|
163
|
+
{ type: 'try-catch', lineStart: 5, isEmpty: false, strategy: 'log' },
|
|
164
|
+
{ type: 'try-catch', lineStart: 15, isEmpty: false, strategy: 'throw' },
|
|
165
|
+
],
|
|
166
|
+
}),
|
|
167
|
+
];
|
|
168
|
+
const prompt = buildCrossFilePrompt(allFacts);
|
|
169
|
+
expect(prompt).toBeDefined();
|
|
170
|
+
expect(prompt.length).toBeGreaterThan(0);
|
|
171
|
+
// Should reference file count
|
|
172
|
+
expect(prompt).toContain('3');
|
|
173
|
+
});
|
|
174
|
+
it('should include Go-specific cross-file info', () => {
|
|
175
|
+
const allFacts = [
|
|
176
|
+
makeGoFacts({ path: 'pkg/server.go' }),
|
|
177
|
+
makeGoFacts({
|
|
178
|
+
path: 'pkg/handler.go',
|
|
179
|
+
structs: [{
|
|
180
|
+
name: 'Handler',
|
|
181
|
+
lineStart: 1,
|
|
182
|
+
lineEnd: 50,
|
|
183
|
+
fieldCount: 3,
|
|
184
|
+
methodCount: 5,
|
|
185
|
+
methods: ['Get', 'Post', 'Put', 'Delete', 'Options'],
|
|
186
|
+
lineCount: 50,
|
|
187
|
+
embeds: [],
|
|
188
|
+
}],
|
|
189
|
+
}),
|
|
190
|
+
];
|
|
191
|
+
const prompt = buildCrossFilePrompt(allFacts);
|
|
192
|
+
expect(prompt).toBeDefined();
|
|
193
|
+
expect(prompt.length).toBeGreaterThan(0);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
// ── chunkFacts ──
|
|
197
|
+
describe('chunkFacts', () => {
|
|
198
|
+
it('should split facts into token-limited chunks', () => {
|
|
199
|
+
const manyFacts = Array.from({ length: 50 }, (_, i) => makeTsFacts({
|
|
200
|
+
path: `src/file${i}.ts`,
|
|
201
|
+
lineCount: 200,
|
|
202
|
+
}));
|
|
203
|
+
const chunks = chunkFacts(manyFacts, 4000);
|
|
204
|
+
expect(chunks.length).toBeGreaterThan(1);
|
|
205
|
+
// All facts should be distributed across chunks
|
|
206
|
+
const totalFiles = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
|
207
|
+
expect(totalFiles).toBe(50);
|
|
208
|
+
});
|
|
209
|
+
it('should handle single file within budget', () => {
|
|
210
|
+
const facts = [makeTsFacts()];
|
|
211
|
+
const chunks = chunkFacts(facts, 10000);
|
|
212
|
+
expect(chunks).toHaveLength(1);
|
|
213
|
+
expect(chunks[0]).toHaveLength(1);
|
|
214
|
+
});
|
|
215
|
+
it('should handle empty input', () => {
|
|
216
|
+
const chunks = chunkFacts([], 4000);
|
|
217
|
+
expect(chunks).toHaveLength(0);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { verifyFindings } from './verifier.js';
|
|
3
|
+
// ── Test helpers ──
|
|
4
|
+
function makeFinding(overrides = {}) {
|
|
5
|
+
return {
|
|
6
|
+
category: 'god_class',
|
|
7
|
+
severity: 'high',
|
|
8
|
+
file: 'src/service.ts',
|
|
9
|
+
line: 10,
|
|
10
|
+
description: 'The UserService class has too many responsibilities',
|
|
11
|
+
suggestion: 'Split into smaller services',
|
|
12
|
+
confidence: 0.8,
|
|
13
|
+
...overrides,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
function makeFileFacts(overrides = {}) {
|
|
17
|
+
return {
|
|
18
|
+
path: 'src/service.ts',
|
|
19
|
+
language: 'typescript',
|
|
20
|
+
lineCount: 300,
|
|
21
|
+
classes: [{
|
|
22
|
+
name: 'UserService',
|
|
23
|
+
lineStart: 5,
|
|
24
|
+
lineEnd: 290,
|
|
25
|
+
methodCount: 12,
|
|
26
|
+
methods: ['find', 'create', 'update', 'delete', 'validate', 'transform', 'cache', 'notify', 'log', 'serialize', 'auth', 'batch'],
|
|
27
|
+
publicMethods: ['find', 'create', 'update', 'delete'],
|
|
28
|
+
lineCount: 285,
|
|
29
|
+
dependencies: ['Database', 'Logger'],
|
|
30
|
+
}],
|
|
31
|
+
functions: [{
|
|
32
|
+
name: 'processData',
|
|
33
|
+
lineStart: 10,
|
|
34
|
+
lineEnd: 80,
|
|
35
|
+
lineCount: 70,
|
|
36
|
+
paramCount: 5,
|
|
37
|
+
params: ['a', 'b', 'c', 'd', 'e'],
|
|
38
|
+
maxNesting: 4,
|
|
39
|
+
hasReturn: true,
|
|
40
|
+
isAsync: true,
|
|
41
|
+
isExported: true,
|
|
42
|
+
}],
|
|
43
|
+
imports: ['express', './types'],
|
|
44
|
+
exports: ['UserService'],
|
|
45
|
+
errorHandling: [
|
|
46
|
+
{ type: 'try-catch', lineStart: 20, isEmpty: false, strategy: 'throw' },
|
|
47
|
+
{ type: 'try-catch', lineStart: 50, isEmpty: true, strategy: 'ignore' },
|
|
48
|
+
],
|
|
49
|
+
testAssertions: 0,
|
|
50
|
+
hasTests: false,
|
|
51
|
+
...overrides,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
describe('Verifier', () => {
|
|
55
|
+
// ── File existence ──
|
|
56
|
+
describe('file existence check', () => {
|
|
57
|
+
it('should drop findings for files not in facts', () => {
|
|
58
|
+
const findings = [makeFinding({ file: 'src/nonexistent.ts' })];
|
|
59
|
+
const facts = [makeFileFacts()];
|
|
60
|
+
const result = verifyFindings(findings, facts);
|
|
61
|
+
expect(result).toHaveLength(0);
|
|
62
|
+
});
|
|
63
|
+
it('should accept findings for files that exist', () => {
|
|
64
|
+
const findings = [makeFinding()];
|
|
65
|
+
const facts = [makeFileFacts()];
|
|
66
|
+
const result = verifyFindings(findings, facts);
|
|
67
|
+
expect(result).toHaveLength(1);
|
|
68
|
+
expect(result[0].verified).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
it('should handle path normalization (leading ./)', () => {
|
|
71
|
+
const findings = [makeFinding({ file: './src/service.ts' })];
|
|
72
|
+
const facts = [makeFileFacts()];
|
|
73
|
+
const result = verifyFindings(findings, facts);
|
|
74
|
+
expect(result).toHaveLength(1);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
// ── Class/Struct findings (SOLID) ──
|
|
78
|
+
describe('class/struct-based verification', () => {
|
|
79
|
+
it('should verify god_class when class has many methods', () => {
|
|
80
|
+
const findings = [makeFinding({ category: 'god_class' })];
|
|
81
|
+
const facts = [makeFileFacts()];
|
|
82
|
+
const result = verifyFindings(findings, facts);
|
|
83
|
+
expect(result).toHaveLength(1);
|
|
84
|
+
expect(result[0].verified).toBe(true);
|
|
85
|
+
expect(result[0].verificationNotes).toContain('12 methods');
|
|
86
|
+
});
|
|
87
|
+
it('should reject god_class when class is small', () => {
|
|
88
|
+
const findings = [makeFinding({ category: 'god_class' })];
|
|
89
|
+
const facts = [makeFileFacts({
|
|
90
|
+
classes: [{
|
|
91
|
+
name: 'UserService',
|
|
92
|
+
lineStart: 5,
|
|
93
|
+
lineEnd: 30,
|
|
94
|
+
methodCount: 3,
|
|
95
|
+
methods: ['find', 'create', 'update'],
|
|
96
|
+
publicMethods: ['find', 'create'],
|
|
97
|
+
lineCount: 25,
|
|
98
|
+
dependencies: [],
|
|
99
|
+
}],
|
|
100
|
+
})];
|
|
101
|
+
const result = verifyFindings(findings, facts);
|
|
102
|
+
expect(result).toHaveLength(0);
|
|
103
|
+
});
|
|
104
|
+
it('should accept god_class for Go structs', () => {
|
|
105
|
+
const findings = [makeFinding({
|
|
106
|
+
category: 'god_class',
|
|
107
|
+
file: 'pkg/server.go',
|
|
108
|
+
description: 'The Server struct has too many responsibilities',
|
|
109
|
+
})];
|
|
110
|
+
const facts = [makeFileFacts({
|
|
111
|
+
path: 'pkg/server.go',
|
|
112
|
+
language: 'go',
|
|
113
|
+
classes: [],
|
|
114
|
+
structs: [{
|
|
115
|
+
name: 'Server',
|
|
116
|
+
lineStart: 5,
|
|
117
|
+
lineEnd: 250,
|
|
118
|
+
fieldCount: 8,
|
|
119
|
+
methodCount: 10,
|
|
120
|
+
methods: ['Start', 'Stop', 'Handle', 'Route', 'Auth', 'Log', 'Cache', 'Validate', 'Transform', 'Serialize'],
|
|
121
|
+
lineCount: 245,
|
|
122
|
+
embeds: [],
|
|
123
|
+
}],
|
|
124
|
+
})];
|
|
125
|
+
const result = verifyFindings(findings, facts);
|
|
126
|
+
expect(result).toHaveLength(1);
|
|
127
|
+
expect(result[0].verified).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
it('should accept Go module-level god_class when file has many functions', () => {
|
|
130
|
+
const findings = [makeFinding({
|
|
131
|
+
category: 'god_class',
|
|
132
|
+
file: 'pkg/utils.go',
|
|
133
|
+
description: 'This module has too many responsibilities',
|
|
134
|
+
})];
|
|
135
|
+
const facts = [makeFileFacts({
|
|
136
|
+
path: 'pkg/utils.go',
|
|
137
|
+
language: 'go',
|
|
138
|
+
classes: [],
|
|
139
|
+
structs: [],
|
|
140
|
+
functions: Array.from({ length: 15 }, (_, i) => ({
|
|
141
|
+
name: `func${i}`,
|
|
142
|
+
lineStart: i * 20,
|
|
143
|
+
lineEnd: i * 20 + 15,
|
|
144
|
+
lineCount: 15,
|
|
145
|
+
paramCount: 2,
|
|
146
|
+
params: ['a', 'b'],
|
|
147
|
+
maxNesting: 2,
|
|
148
|
+
hasReturn: true,
|
|
149
|
+
isAsync: false,
|
|
150
|
+
isExported: true,
|
|
151
|
+
})),
|
|
152
|
+
})];
|
|
153
|
+
const result = verifyFindings(findings, facts);
|
|
154
|
+
expect(result).toHaveLength(1);
|
|
155
|
+
expect(result[0].verified).toBe(true);
|
|
156
|
+
expect(result[0].verificationNotes).toContain('15 functions');
|
|
157
|
+
});
|
|
158
|
+
it('should reject when entity name not found in file', () => {
|
|
159
|
+
const findings = [makeFinding({
|
|
160
|
+
category: 'srp_violation',
|
|
161
|
+
description: 'The NonExistentClass has too many responsibilities',
|
|
162
|
+
})];
|
|
163
|
+
const facts = [makeFileFacts()];
|
|
164
|
+
const result = verifyFindings(findings, facts);
|
|
165
|
+
// Should still verify since file has classes with confidence >= 0.4
|
|
166
|
+
expect(result).toHaveLength(1);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
// ── Function findings ──
|
|
170
|
+
describe('function-based verification', () => {
|
|
171
|
+
it('should verify god_function when function is long', () => {
|
|
172
|
+
const findings = [makeFinding({
|
|
173
|
+
category: 'god_function',
|
|
174
|
+
description: 'processData is too long and complex',
|
|
175
|
+
})];
|
|
176
|
+
const facts = [makeFileFacts()];
|
|
177
|
+
const result = verifyFindings(findings, facts);
|
|
178
|
+
expect(result).toHaveLength(1);
|
|
179
|
+
expect(result[0].verified).toBe(true);
|
|
180
|
+
});
|
|
181
|
+
it('should reject god_function when function is short', () => {
|
|
182
|
+
const findings = [makeFinding({
|
|
183
|
+
category: 'god_function',
|
|
184
|
+
description: 'processData is too complex',
|
|
185
|
+
})];
|
|
186
|
+
const facts = [makeFileFacts({
|
|
187
|
+
functions: [{
|
|
188
|
+
name: 'processData',
|
|
189
|
+
lineStart: 10,
|
|
190
|
+
lineEnd: 25,
|
|
191
|
+
lineCount: 15,
|
|
192
|
+
paramCount: 2,
|
|
193
|
+
params: ['a', 'b'],
|
|
194
|
+
maxNesting: 1,
|
|
195
|
+
hasReturn: true,
|
|
196
|
+
isAsync: false,
|
|
197
|
+
isExported: true,
|
|
198
|
+
}],
|
|
199
|
+
})];
|
|
200
|
+
const result = verifyFindings(findings, facts);
|
|
201
|
+
expect(result).toHaveLength(0);
|
|
202
|
+
});
|
|
203
|
+
it('should verify long_params when function has many params', () => {
|
|
204
|
+
const findings = [makeFinding({
|
|
205
|
+
category: 'long_params',
|
|
206
|
+
description: 'processData has too many parameters',
|
|
207
|
+
})];
|
|
208
|
+
const facts = [makeFileFacts()];
|
|
209
|
+
const result = verifyFindings(findings, facts);
|
|
210
|
+
expect(result).toHaveLength(1);
|
|
211
|
+
expect(result[0].verified).toBe(true);
|
|
212
|
+
});
|
|
213
|
+
it('should reject long_params when function has few params', () => {
|
|
214
|
+
const findings = [makeFinding({
|
|
215
|
+
category: 'long_params',
|
|
216
|
+
description: 'processData has too many parameters',
|
|
217
|
+
})];
|
|
218
|
+
const facts = [makeFileFacts({
|
|
219
|
+
functions: [{
|
|
220
|
+
name: 'processData',
|
|
221
|
+
lineStart: 10,
|
|
222
|
+
lineEnd: 50,
|
|
223
|
+
lineCount: 40,
|
|
224
|
+
paramCount: 2,
|
|
225
|
+
params: ['a', 'b'],
|
|
226
|
+
maxNesting: 2,
|
|
227
|
+
hasReturn: true,
|
|
228
|
+
isAsync: false,
|
|
229
|
+
isExported: true,
|
|
230
|
+
}],
|
|
231
|
+
})];
|
|
232
|
+
const result = verifyFindings(findings, facts);
|
|
233
|
+
expect(result).toHaveLength(0);
|
|
234
|
+
});
|
|
235
|
+
it('should verify complex_conditional when nesting is deep', () => {
|
|
236
|
+
const findings = [makeFinding({
|
|
237
|
+
category: 'complex_conditional',
|
|
238
|
+
description: 'processData has deeply nested conditionals',
|
|
239
|
+
})];
|
|
240
|
+
const facts = [makeFileFacts()];
|
|
241
|
+
const result = verifyFindings(findings, facts);
|
|
242
|
+
expect(result).toHaveLength(1);
|
|
243
|
+
expect(result[0].verified).toBe(true);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
// ── Error handling findings ──
|
|
247
|
+
describe('error handling verification', () => {
|
|
248
|
+
it('should verify empty_catch when empty catches exist', () => {
|
|
249
|
+
const findings = [makeFinding({
|
|
250
|
+
category: 'empty_catch',
|
|
251
|
+
description: 'Empty catch block silently swallows errors',
|
|
252
|
+
})];
|
|
253
|
+
const facts = [makeFileFacts()];
|
|
254
|
+
const result = verifyFindings(findings, facts);
|
|
255
|
+
expect(result).toHaveLength(1);
|
|
256
|
+
expect(result[0].verified).toBe(true);
|
|
257
|
+
});
|
|
258
|
+
it('should reject empty_catch when no empty catches exist', () => {
|
|
259
|
+
const findings = [makeFinding({
|
|
260
|
+
category: 'empty_catch',
|
|
261
|
+
description: 'Empty catch block',
|
|
262
|
+
})];
|
|
263
|
+
const facts = [makeFileFacts({
|
|
264
|
+
errorHandling: [
|
|
265
|
+
{ type: 'try-catch', lineStart: 10, isEmpty: false, strategy: 'throw' },
|
|
266
|
+
],
|
|
267
|
+
})];
|
|
268
|
+
const result = verifyFindings(findings, facts);
|
|
269
|
+
expect(result).toHaveLength(0);
|
|
270
|
+
});
|
|
271
|
+
it('should verify error_inconsistency when multiple strategies exist', () => {
|
|
272
|
+
const findings = [makeFinding({
|
|
273
|
+
category: 'error_inconsistency',
|
|
274
|
+
description: 'Mixed error handling strategies',
|
|
275
|
+
})];
|
|
276
|
+
const facts = [makeFileFacts()]; // Has 'throw' and 'ignore' strategies
|
|
277
|
+
const result = verifyFindings(findings, facts);
|
|
278
|
+
expect(result).toHaveLength(1);
|
|
279
|
+
expect(result[0].verified).toBe(true);
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
// ── Concurrency findings ──
|
|
283
|
+
describe('concurrency verification', () => {
|
|
284
|
+
const goFacts = () => makeFileFacts({
|
|
285
|
+
path: 'pkg/worker.go',
|
|
286
|
+
language: 'go',
|
|
287
|
+
goroutines: 3,
|
|
288
|
+
channels: 2,
|
|
289
|
+
mutexes: 1,
|
|
290
|
+
defers: 2,
|
|
291
|
+
});
|
|
292
|
+
it('should verify race_condition when concurrency constructs exist', () => {
|
|
293
|
+
const findings = [makeFinding({
|
|
294
|
+
category: 'race_condition',
|
|
295
|
+
file: 'pkg/worker.go',
|
|
296
|
+
description: 'Potential race condition',
|
|
297
|
+
confidence: 0.6,
|
|
298
|
+
})];
|
|
299
|
+
const result = verifyFindings(findings, [goFacts()]);
|
|
300
|
+
expect(result).toHaveLength(1);
|
|
301
|
+
expect(result[0].verified).toBe(true);
|
|
302
|
+
});
|
|
303
|
+
it('should reject goroutine_leak when no goroutines exist', () => {
|
|
304
|
+
const findings = [makeFinding({
|
|
305
|
+
category: 'goroutine_leak',
|
|
306
|
+
file: 'pkg/worker.go',
|
|
307
|
+
description: 'Goroutine leak detected',
|
|
308
|
+
confidence: 0.7,
|
|
309
|
+
})];
|
|
310
|
+
const facts = goFacts();
|
|
311
|
+
facts.goroutines = 0;
|
|
312
|
+
const result = verifyFindings(findings, [facts]);
|
|
313
|
+
expect(result).toHaveLength(0);
|
|
314
|
+
});
|
|
315
|
+
it('should reject channel_misuse when no channels exist', () => {
|
|
316
|
+
const findings = [makeFinding({
|
|
317
|
+
category: 'channel_misuse',
|
|
318
|
+
file: 'pkg/worker.go',
|
|
319
|
+
description: 'Channel misuse detected',
|
|
320
|
+
confidence: 0.7,
|
|
321
|
+
})];
|
|
322
|
+
const facts = goFacts();
|
|
323
|
+
facts.channels = 0;
|
|
324
|
+
const result = verifyFindings(findings, [facts]);
|
|
325
|
+
expect(result).toHaveLength(0);
|
|
326
|
+
});
|
|
327
|
+
it('should reject concurrency finding when no concurrency exists', () => {
|
|
328
|
+
const findings = [makeFinding({
|
|
329
|
+
category: 'race_condition',
|
|
330
|
+
file: 'pkg/worker.go',
|
|
331
|
+
description: 'Race condition',
|
|
332
|
+
confidence: 0.8,
|
|
333
|
+
})];
|
|
334
|
+
const facts = goFacts();
|
|
335
|
+
facts.goroutines = 0;
|
|
336
|
+
facts.channels = 0;
|
|
337
|
+
facts.mutexes = 0;
|
|
338
|
+
facts.functions = facts.functions.map(f => ({ ...f, isAsync: false }));
|
|
339
|
+
const result = verifyFindings(findings, [facts]);
|
|
340
|
+
expect(result).toHaveLength(0);
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
// ── Interface findings ──
|
|
344
|
+
describe('interface verification', () => {
|
|
345
|
+
it('should verify ISP violation on large interface', () => {
|
|
346
|
+
const findings = [makeFinding({
|
|
347
|
+
category: 'isp_violation_interface',
|
|
348
|
+
file: 'pkg/store.go',
|
|
349
|
+
description: 'Store interface has too many methods',
|
|
350
|
+
confidence: 0.8,
|
|
351
|
+
})];
|
|
352
|
+
const facts = [makeFileFacts({
|
|
353
|
+
path: 'pkg/store.go',
|
|
354
|
+
language: 'go',
|
|
355
|
+
interfaces: [{
|
|
356
|
+
name: 'Store',
|
|
357
|
+
lineStart: 5,
|
|
358
|
+
methodCount: 8,
|
|
359
|
+
methods: ['Get', 'Set', 'Delete', 'List', 'Close', 'Watch', 'Backup', 'Restore'],
|
|
360
|
+
}],
|
|
361
|
+
})];
|
|
362
|
+
const result = verifyFindings(findings, facts);
|
|
363
|
+
expect(result).toHaveLength(1);
|
|
364
|
+
expect(result[0].verified).toBe(true);
|
|
365
|
+
});
|
|
366
|
+
it('should reject ISP violation when no interfaces exist', () => {
|
|
367
|
+
const findings = [makeFinding({
|
|
368
|
+
category: 'isp_violation_interface',
|
|
369
|
+
file: 'pkg/store.go',
|
|
370
|
+
description: 'Interface has too many methods',
|
|
371
|
+
})];
|
|
372
|
+
const facts = [makeFileFacts({
|
|
373
|
+
path: 'pkg/store.go',
|
|
374
|
+
interfaces: [],
|
|
375
|
+
})];
|
|
376
|
+
const result = verifyFindings(findings, facts);
|
|
377
|
+
expect(result).toHaveLength(0);
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
// ── Test findings ──
|
|
381
|
+
describe('test verification', () => {
|
|
382
|
+
it('should verify missing_test for substantial code files', () => {
|
|
383
|
+
const findings = [makeFinding({
|
|
384
|
+
category: 'missing_test',
|
|
385
|
+
description: 'No tests for this module',
|
|
386
|
+
})];
|
|
387
|
+
const facts = [makeFileFacts({
|
|
388
|
+
functions: [
|
|
389
|
+
{ name: 'processData', lineStart: 10, lineEnd: 80, lineCount: 70, paramCount: 5, params: ['a', 'b', 'c', 'd', 'e'], maxNesting: 4, hasReturn: true, isAsync: true, isExported: true },
|
|
390
|
+
{ name: 'helperFn', lineStart: 85, lineEnd: 100, lineCount: 15, paramCount: 1, params: ['x'], maxNesting: 1, hasReturn: true, isAsync: false, isExported: false },
|
|
391
|
+
],
|
|
392
|
+
})]; // hasTests: false, 300 lines, 2 functions
|
|
393
|
+
const result = verifyFindings(findings, facts);
|
|
394
|
+
expect(result).toHaveLength(1);
|
|
395
|
+
expect(result[0].verified).toBe(true);
|
|
396
|
+
});
|
|
397
|
+
it('should reject missing_test when file already has tests', () => {
|
|
398
|
+
const findings = [makeFinding({
|
|
399
|
+
category: 'missing_test',
|
|
400
|
+
description: 'No tests',
|
|
401
|
+
})];
|
|
402
|
+
const facts = [makeFileFacts({ hasTests: true })];
|
|
403
|
+
const result = verifyFindings(findings, facts);
|
|
404
|
+
expect(result).toHaveLength(0);
|
|
405
|
+
});
|
|
406
|
+
it('should reject missing_test for trivial files', () => {
|
|
407
|
+
const findings = [makeFinding({
|
|
408
|
+
category: 'missing_test',
|
|
409
|
+
description: 'No tests',
|
|
410
|
+
})];
|
|
411
|
+
const facts = [makeFileFacts({ lineCount: 20, functions: [] })];
|
|
412
|
+
const result = verifyFindings(findings, facts);
|
|
413
|
+
expect(result).toHaveLength(0);
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
// ── File-level categories ──
|
|
417
|
+
describe('file-level verification', () => {
|
|
418
|
+
it('should verify long_file when file exceeds 300 lines', () => {
|
|
419
|
+
const findings = [makeFinding({ category: 'long_file' })];
|
|
420
|
+
const facts = [makeFileFacts({ lineCount: 500 })];
|
|
421
|
+
const result = verifyFindings(findings, facts);
|
|
422
|
+
expect(result).toHaveLength(1);
|
|
423
|
+
expect(result[0].verified).toBe(true);
|
|
424
|
+
});
|
|
425
|
+
it('should reject long_file when file is short', () => {
|
|
426
|
+
const findings = [makeFinding({ category: 'long_file' })];
|
|
427
|
+
const facts = [makeFileFacts({ lineCount: 100 })];
|
|
428
|
+
const result = verifyFindings(findings, facts);
|
|
429
|
+
expect(result).toHaveLength(0);
|
|
430
|
+
});
|
|
431
|
+
it('should verify magic_number when many magic numbers detected', () => {
|
|
432
|
+
const findings = [makeFinding({ category: 'magic_number' })];
|
|
433
|
+
const facts = [makeFileFacts({ magicNumbers: 10 })];
|
|
434
|
+
const result = verifyFindings(findings, facts);
|
|
435
|
+
expect(result).toHaveLength(1);
|
|
436
|
+
});
|
|
437
|
+
it('should reject magic_number when few magic numbers detected', () => {
|
|
438
|
+
const findings = [makeFinding({ category: 'magic_number' })];
|
|
439
|
+
const facts = [makeFileFacts({ magicNumbers: 1 })];
|
|
440
|
+
const result = verifyFindings(findings, facts);
|
|
441
|
+
expect(result).toHaveLength(0);
|
|
442
|
+
});
|
|
443
|
+
});
|
|
444
|
+
// ── Confidence-based categories ──
|
|
445
|
+
describe('confidence-based verification', () => {
|
|
446
|
+
const confidenceCategories = [
|
|
447
|
+
'dry_violation', 'feature_envy', 'architecture',
|
|
448
|
+
'naming_convention', 'dead_code', 'performance',
|
|
449
|
+
];
|
|
450
|
+
for (const category of confidenceCategories) {
|
|
451
|
+
it(`should accept ${category} with confidence >= 0.3`, () => {
|
|
452
|
+
const findings = [makeFinding({ category, confidence: 0.5 })];
|
|
453
|
+
const facts = [makeFileFacts()];
|
|
454
|
+
const result = verifyFindings(findings, facts);
|
|
455
|
+
expect(result).toHaveLength(1);
|
|
456
|
+
});
|
|
457
|
+
it(`should reject ${category} with low confidence`, () => {
|
|
458
|
+
const findings = [makeFinding({ category, confidence: 0.1 })];
|
|
459
|
+
const facts = [makeFileFacts()];
|
|
460
|
+
const result = verifyFindings(findings, facts);
|
|
461
|
+
expect(result).toHaveLength(0);
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
// ── Resource leak (Go-specific) ──
|
|
466
|
+
describe('resource leak verification', () => {
|
|
467
|
+
it('should verify Go resource leak when resource imports present', () => {
|
|
468
|
+
const findings = [makeFinding({
|
|
469
|
+
category: 'resource_leak',
|
|
470
|
+
file: 'pkg/db.go',
|
|
471
|
+
confidence: 0.6,
|
|
472
|
+
})];
|
|
473
|
+
const facts = [makeFileFacts({
|
|
474
|
+
path: 'pkg/db.go',
|
|
475
|
+
language: 'go',
|
|
476
|
+
imports: ['database/sql', 'net/http'],
|
|
477
|
+
})];
|
|
478
|
+
const result = verifyFindings(findings, facts);
|
|
479
|
+
expect(result).toHaveLength(1);
|
|
480
|
+
expect(result[0].verified).toBe(true);
|
|
481
|
+
});
|
|
482
|
+
it('should reject Go resource leak when no resource imports', () => {
|
|
483
|
+
const findings = [makeFinding({
|
|
484
|
+
category: 'resource_leak',
|
|
485
|
+
file: 'pkg/util.go',
|
|
486
|
+
confidence: 0.6,
|
|
487
|
+
})];
|
|
488
|
+
const facts = [makeFileFacts({
|
|
489
|
+
path: 'pkg/util.go',
|
|
490
|
+
language: 'go',
|
|
491
|
+
imports: ['fmt', 'strings'],
|
|
492
|
+
})];
|
|
493
|
+
const result = verifyFindings(findings, facts);
|
|
494
|
+
expect(result).toHaveLength(0);
|
|
495
|
+
});
|
|
496
|
+
});
|
|
497
|
+
// ── Multiple findings batch ──
|
|
498
|
+
describe('batch verification', () => {
|
|
499
|
+
it('should process multiple findings and filter correctly', () => {
|
|
500
|
+
const findings = [
|
|
501
|
+
makeFinding({ category: 'god_class' }), // Should pass (12 methods)
|
|
502
|
+
makeFinding({ category: 'god_function', file: 'nonexistent.ts' }), // Should fail (no file)
|
|
503
|
+
makeFinding({ category: 'long_file' }), // Should fail (300 lines, need >300)
|
|
504
|
+
makeFinding({ category: 'magic_number' }), // Should fail (no magicNumbers set)
|
|
505
|
+
makeFinding({ category: 'dry_violation', confidence: 0.1 }), // Should fail (low confidence)
|
|
506
|
+
makeFinding({ category: 'dry_violation', confidence: 0.5 }), // Should pass
|
|
507
|
+
];
|
|
508
|
+
const facts = [makeFileFacts()];
|
|
509
|
+
const result = verifyFindings(findings, facts);
|
|
510
|
+
const verified = result.filter(r => r.verified);
|
|
511
|
+
expect(verified.length).toBeGreaterThanOrEqual(2); // god_class, dry_violation(0.5)
|
|
512
|
+
});
|
|
513
|
+
});
|
|
514
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rigour-labs/core",
|
|
3
|
-
"version": "4.0.
|
|
3
|
+
"version": "4.0.1",
|
|
4
4
|
"description": "Deterministic quality gate engine for AI-generated code. AST analysis, drift detection, and Fix Packet generation across TypeScript, JavaScript, Python, Go, Ruby, and C#.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://rigour.run",
|