@rigour-labs/core 4.3.5 → 5.0.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.
@@ -0,0 +1,386 @@
1
+ /**
2
+ * Temporal Drift Engine (v5)
3
+ *
4
+ * The "bank statement" for code quality.
5
+ * Reads from SQLite scan history and computes:
6
+ *
7
+ * 1. Cross-session temporal trends — how is quality changing over weeks/months?
8
+ * 2. Per-provenance EWMA streams — is AI getting worse? Structural? Security?
9
+ * 3. Anomaly detection — is today's scan statistically unusual?
10
+ *
11
+ * This is Rigour's core differentiator:
12
+ * No other tool can tell a CTO "your AI contributions are degrading
13
+ * your codebase at 3x the rate of human contributions."
14
+ *
15
+ * Data source: ~/.rigour/rigour.db (scans + findings tables)
16
+ * All computation is read-only — no writes to DB.
17
+ *
18
+ * @since v5.0.0
19
+ */
20
+ import { openDatabase } from '../storage/db.js';
21
+ import { Logger } from '../utils/logger.js';
22
+ import path from 'path';
23
+ // ─── EWMA + Statistical Utilities ───────────────────────────────────
24
+ const DEFAULT_ALPHA = 0.3;
25
+ function computeEWMASeries(values, alpha = DEFAULT_ALPHA) {
26
+ if (values.length === 0)
27
+ return [];
28
+ const series = [];
29
+ let ewma = values[0].value;
30
+ for (const point of values) {
31
+ ewma = alpha * point.value + (1 - alpha) * ewma;
32
+ series.push({
33
+ timestamp: point.timestamp,
34
+ value: point.value,
35
+ ewma: Math.round(ewma * 10) / 10,
36
+ });
37
+ }
38
+ return series;
39
+ }
40
+ function meanAndStd(values) {
41
+ if (values.length === 0)
42
+ return { mean: 0, std: 0 };
43
+ const mean = values.reduce((a, b) => a + b, 0) / values.length;
44
+ const variance = values.reduce((sum, v) => sum + (v - mean) ** 2, 0) / values.length;
45
+ return { mean, std: Math.sqrt(variance) };
46
+ }
47
+ function zScore(value, mean, std) {
48
+ if (std === 0)
49
+ return 0;
50
+ return Math.round(((value - mean) / std) * 100) / 100;
51
+ }
52
+ function directionFromZ(z) {
53
+ // For failure counts: positive Z = more failures = degrading
54
+ if (z > 2.0)
55
+ return 'degrading';
56
+ if (z < -2.0)
57
+ return 'improving';
58
+ return 'stable';
59
+ }
60
+ function directionFromScoreZ(z) {
61
+ // For scores: positive Z = higher score = improving
62
+ if (z > 2.0)
63
+ return 'improving';
64
+ if (z < -2.0)
65
+ return 'degrading';
66
+ return 'stable';
67
+ }
68
+ // ─── Monthly/Weekly Bucketing ───────────────────────────────────────
69
+ function toMonthKey(timestamp) {
70
+ const d = new Date(timestamp);
71
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
72
+ }
73
+ function toWeekKey(timestamp) {
74
+ const d = new Date(timestamp);
75
+ // Get Monday of this week (don't mutate d in-place)
76
+ const day = d.getDay();
77
+ const diff = d.getDate() - day + (day === 0 ? -6 : 1);
78
+ const monday = new Date(d);
79
+ monday.setDate(diff);
80
+ return monday.toISOString().split('T')[0];
81
+ }
82
+ // ─── Main Engine ────────────────────────────────────────────────────
83
+ /**
84
+ * Generate a complete temporal drift report for a project.
85
+ *
86
+ * Reads from SQLite: scans (scores over time) + findings (provenance counts).
87
+ * Computes EWMA streams, Z-scores, monthly/weekly rollups, and a narrative.
88
+ *
89
+ * @param cwd - Project root path (used to derive repo name)
90
+ * @param maxScans - Max scans to analyze (default 200)
91
+ */
92
+ export function generateTemporalDriftReport(cwd, maxScans = 200) {
93
+ const db = openDatabase();
94
+ if (!db) {
95
+ Logger.warn('Temporal drift: SQLite not available');
96
+ return null;
97
+ }
98
+ const repo = path.basename(cwd);
99
+ try {
100
+ // 1. Load all scans (oldest first)
101
+ const scans = db.db.prepare(`
102
+ SELECT * FROM scans WHERE repo = ?
103
+ ORDER BY timestamp ASC LIMIT ?
104
+ `).all(repo, maxScans);
105
+ if (scans.length < 3) {
106
+ return createEmptyReport(repo, scans.length);
107
+ }
108
+ // 2. Load per-scan provenance failure counts
109
+ const provenanceByScan = new Map();
110
+ for (const scan of scans) {
111
+ const counts = db.db.prepare(`
112
+ SELECT provenance, COUNT(*) as cnt FROM findings
113
+ WHERE scan_id = ? GROUP BY provenance
114
+ `).all(scan.id);
115
+ const breakdown = { aiDrift: 0, structural: 0, security: 0 };
116
+ for (const row of counts) {
117
+ if (row.provenance === 'ai-drift')
118
+ breakdown.aiDrift = row.cnt;
119
+ else if (row.provenance === 'traditional')
120
+ breakdown.structural = row.cnt;
121
+ else if (row.provenance === 'security')
122
+ breakdown.security = row.cnt;
123
+ }
124
+ provenanceByScan.set(scan.id, breakdown);
125
+ }
126
+ // 3. Build EWMA streams
127
+ const overallStream = buildStream(scans.map(s => ({ timestamp: s.timestamp, value: s.overall_score ?? 100 })), true // higher score = better
128
+ );
129
+ const aiDriftStream = buildStream(scans.map(s => ({
130
+ timestamp: s.timestamp,
131
+ value: provenanceByScan.get(s.id)?.aiDrift ?? 0,
132
+ })), false // higher count = worse
133
+ );
134
+ const structuralStream = buildStream(scans.map(s => ({
135
+ timestamp: s.timestamp,
136
+ value: provenanceByScan.get(s.id)?.structural ?? 0,
137
+ })), false);
138
+ const securityStream = buildStream(scans.map(s => ({
139
+ timestamp: s.timestamp,
140
+ value: provenanceByScan.get(s.id)?.security ?? 0,
141
+ })), false);
142
+ // 4. Monthly rollups
143
+ const monthMap = new Map();
144
+ for (const scan of scans) {
145
+ const key = toMonthKey(scan.timestamp);
146
+ const prov = provenanceByScan.get(scan.id) || { aiDrift: 0, structural: 0, security: 0 };
147
+ if (!monthMap.has(key)) {
148
+ monthMap.set(key, {
149
+ month: key,
150
+ avgScore: 0,
151
+ avgAiHealth: 0,
152
+ avgStructural: 0,
153
+ scanCount: 0,
154
+ totalFailures: 0,
155
+ provenanceBreakdown: { aiDrift: 0, structural: 0, security: 0 },
156
+ });
157
+ }
158
+ const bucket = monthMap.get(key);
159
+ bucket.scanCount++;
160
+ bucket.avgScore += scan.overall_score ?? 100;
161
+ bucket.avgAiHealth += scan.ai_health_score ?? 100;
162
+ bucket.totalFailures += prov.aiDrift + prov.structural + prov.security;
163
+ bucket.provenanceBreakdown.aiDrift += prov.aiDrift;
164
+ bucket.provenanceBreakdown.structural += prov.structural;
165
+ bucket.provenanceBreakdown.security += prov.security;
166
+ }
167
+ const monthly = Array.from(monthMap.values())
168
+ .filter(b => b.scanCount > 0)
169
+ .map(b => ({
170
+ ...b,
171
+ avgScore: Math.round(b.avgScore / b.scanCount),
172
+ avgAiHealth: Math.round(b.avgAiHealth / b.scanCount),
173
+ avgStructural: Math.round((100 - (b.provenanceBreakdown.structural / b.scanCount) * 5)),
174
+ }));
175
+ // 5. Weekly rollups
176
+ const weekMap = new Map();
177
+ for (const scan of scans) {
178
+ const key = toWeekKey(scan.timestamp);
179
+ if (!weekMap.has(key)) {
180
+ weekMap.set(key, { weekStart: key, avgScore: 0, avgAiHealth: 0, scanCount: 0 });
181
+ }
182
+ const bucket = weekMap.get(key);
183
+ bucket.scanCount++;
184
+ bucket.avgScore += scan.overall_score ?? 100;
185
+ bucket.avgAiHealth += scan.ai_health_score ?? 100;
186
+ }
187
+ const weekly = Array.from(weekMap.values())
188
+ .filter(b => b.scanCount > 0)
189
+ .map(b => ({
190
+ ...b,
191
+ avgScore: Math.round(b.avgScore / b.scanCount),
192
+ avgAiHealth: Math.round(b.avgAiHealth / b.scanCount),
193
+ }));
194
+ // 6. Latest scan anomaly detection
195
+ const recentScores = scans.slice(-20).map(s => s.overall_score ?? 100);
196
+ const { mean, std } = meanAndStd(recentScores);
197
+ const latestScore = scans[scans.length - 1].overall_score ?? 100;
198
+ const latestZ = zScore(latestScore, mean, std);
199
+ const latestAnomaly = {
200
+ isAnomaly: Math.abs(latestZ) > 2.0,
201
+ direction: latestZ > 2.0 ? 'spike' : latestZ < -2.0 ? 'dip' : 'normal',
202
+ zScore: latestZ,
203
+ message: Math.abs(latestZ) > 2.0
204
+ ? `Latest scan score (${latestScore}) is ${latestZ > 0 ? 'unusually high' : 'unusually low'} (Z=${latestZ})`
205
+ : `Latest scan score (${latestScore}) is within normal range`,
206
+ };
207
+ // 7. Time span
208
+ const firstTs = scans[0].timestamp;
209
+ const lastTs = scans[scans.length - 1].timestamp;
210
+ const timeSpanDays = Math.round((lastTs - firstTs) / (1000 * 60 * 60 * 24));
211
+ // 8. Generate narrative
212
+ const narrative = generateNarrative(repo, timeSpanDays, monthly, overallStream, aiDriftStream, structuralStream, securityStream);
213
+ return {
214
+ repo,
215
+ totalScans: scans.length,
216
+ timeSpanDays,
217
+ overallDirection: overallStream.direction,
218
+ overallZScore: overallStream.zScore,
219
+ streams: {
220
+ overall: overallStream,
221
+ aiDrift: aiDriftStream,
222
+ structural: structuralStream,
223
+ security: securityStream,
224
+ },
225
+ monthly,
226
+ weekly,
227
+ latestScanAnomaly: latestAnomaly,
228
+ narrative,
229
+ };
230
+ }
231
+ catch (error) {
232
+ Logger.warn(`Temporal drift generation failed: ${error}`);
233
+ return null;
234
+ }
235
+ finally {
236
+ db.close();
237
+ }
238
+ }
239
+ // ─── Helpers ────────────────────────────────────────────────────────
240
+ function buildStream(data, higherIsBetter) {
241
+ const series = computeEWMASeries(data);
242
+ const values = data.map(d => d.value);
243
+ const { mean, std } = meanAndStd(values);
244
+ // Z-score of recent average (last 5) against full history
245
+ const recentValues = values.slice(-5);
246
+ const recentAvg = recentValues.length > 0
247
+ ? recentValues.reduce((a, b) => a + b, 0) / recentValues.length
248
+ : 0;
249
+ const z = zScore(recentAvg, mean, std);
250
+ const direction = higherIsBetter
251
+ ? directionFromScoreZ(z)
252
+ : directionFromZ(z); // for failure counts: positive Z = more = degrading
253
+ return {
254
+ direction,
255
+ zScore: z,
256
+ series,
257
+ currentEWMA: series.length > 0 ? series[series.length - 1].ewma : 0,
258
+ historicalAvg: Math.round(mean * 10) / 10,
259
+ };
260
+ }
261
+ function createEmptyReport(repo, scanCount) {
262
+ const emptyStream = {
263
+ direction: 'stable', zScore: 0, series: [], currentEWMA: 0, historicalAvg: 0,
264
+ };
265
+ return {
266
+ repo,
267
+ totalScans: scanCount,
268
+ timeSpanDays: 0,
269
+ overallDirection: 'stable',
270
+ overallZScore: 0,
271
+ streams: {
272
+ overall: emptyStream,
273
+ aiDrift: { ...emptyStream },
274
+ structural: { ...emptyStream },
275
+ security: { ...emptyStream },
276
+ },
277
+ monthly: [],
278
+ weekly: [],
279
+ latestScanAnomaly: { isAnomaly: false, direction: 'normal', zScore: 0, message: 'Not enough data' },
280
+ narrative: `${repo}: Only ${scanCount} scans recorded. Need at least 3 for trend analysis.`,
281
+ };
282
+ }
283
+ /**
284
+ * Generate the human-readable narrative that answers:
285
+ * "3 mahine mein hum better hue ya worse?"
286
+ */
287
+ function generateNarrative(repo, days, monthly, overall, aiDrift, structural, security) {
288
+ const parts = [];
289
+ // Time span
290
+ if (days > 30) {
291
+ parts.push(`Over the last ${Math.round(days / 30)} months`);
292
+ }
293
+ else {
294
+ parts.push(`Over the last ${days} days`);
295
+ }
296
+ // Overall direction
297
+ if (overall.direction === 'improving') {
298
+ parts.push(`${repo} quality is IMPROVING (score trending up, Z=${overall.zScore}).`);
299
+ }
300
+ else if (overall.direction === 'degrading') {
301
+ parts.push(`${repo} quality is DEGRADING (score trending down, Z=${overall.zScore}).`);
302
+ }
303
+ else {
304
+ parts.push(`${repo} quality is STABLE.`);
305
+ }
306
+ // Per-provenance diagnosis
307
+ const problems = [];
308
+ if (aiDrift.direction === 'degrading') {
309
+ problems.push(`AI-generated code is getting worse (Z=${aiDrift.zScore}) — agents are producing more drift`);
310
+ }
311
+ if (structural.direction === 'degrading') {
312
+ problems.push(`Structural code quality is declining (Z=${structural.zScore}) — team may be taking shortcuts`);
313
+ }
314
+ if (security.direction === 'degrading') {
315
+ problems.push(`Security posture is weakening (Z=${security.zScore}) — credential/vulnerability issues increasing`);
316
+ }
317
+ if (problems.length > 0) {
318
+ parts.push('Root causes: ' + problems.join('; ') + '.');
319
+ }
320
+ // Monthly progression
321
+ if (monthly.length >= 2) {
322
+ const first = monthly[0];
323
+ const last = monthly[monthly.length - 1];
324
+ const scoreDelta = last.avgScore - first.avgScore;
325
+ const failDelta = last.totalFailures / last.scanCount - first.totalFailures / first.scanCount;
326
+ if (Math.abs(scoreDelta) > 5) {
327
+ parts.push(`Monthly avg score: ${first.avgScore} (${first.month}) → ${last.avgScore} (${last.month}), ` +
328
+ `delta ${scoreDelta > 0 ? '+' : ''}${scoreDelta}.`);
329
+ }
330
+ if (Math.abs(failDelta) > 1) {
331
+ parts.push(`Avg failures/scan: ${(first.totalFailures / first.scanCount).toFixed(1)} → ${(last.totalFailures / last.scanCount).toFixed(1)}.`);
332
+ }
333
+ }
334
+ // Bright spots
335
+ const improvements = [];
336
+ if (aiDrift.direction === 'improving')
337
+ improvements.push('AI drift reducing');
338
+ if (structural.direction === 'improving')
339
+ improvements.push('structural quality improving');
340
+ if (security.direction === 'improving')
341
+ improvements.push('security posture strengthening');
342
+ if (improvements.length > 0) {
343
+ parts.push('Bright spots: ' + improvements.join(', ') + '.');
344
+ }
345
+ return parts.join(' ');
346
+ }
347
+ /**
348
+ * Get a formatted summary string for CLI/MCP output.
349
+ */
350
+ export function formatDriftSummary(report) {
351
+ const lines = [];
352
+ lines.push(`═══ Temporal Drift: ${report.repo} (${report.totalScans} scans, ${report.timeSpanDays} days) ═══`);
353
+ lines.push('');
354
+ // EWMA Streams
355
+ const bar = (direction) => {
356
+ switch (direction) {
357
+ case 'improving': return '████████░░ IMPROVING';
358
+ case 'stable': return '█████░░░░░ stable';
359
+ case 'degrading': return '██░░░░░░░░ DEGRADING';
360
+ default: return '░░░░░░░░░░ unknown';
361
+ }
362
+ };
363
+ lines.push('Per-Provenance Health:');
364
+ lines.push(` AI Drift: ${bar(report.streams.aiDrift.direction)} (Z=${report.streams.aiDrift.zScore})`);
365
+ lines.push(` Structural: ${bar(report.streams.structural.direction)} (Z=${report.streams.structural.zScore})`);
366
+ lines.push(` Security: ${bar(report.streams.security.direction)} (Z=${report.streams.security.zScore})`);
367
+ lines.push(` Overall: ${bar(report.streams.overall.direction)} (Z=${report.streams.overall.zScore})`);
368
+ lines.push('');
369
+ // Monthly trend
370
+ if (report.monthly.length > 0) {
371
+ lines.push('Monthly Trend:');
372
+ for (const m of report.monthly.slice(-6)) {
373
+ const failPerScan = (m.totalFailures / m.scanCount).toFixed(1);
374
+ lines.push(` ${m.month}: score=${m.avgScore}, failures/scan=${failPerScan}, scans=${m.scanCount}`);
375
+ }
376
+ lines.push('');
377
+ }
378
+ // Anomaly
379
+ if (report.latestScanAnomaly.isAnomaly) {
380
+ lines.push(`WARNING: ${report.latestScanAnomaly.message}`);
381
+ lines.push('');
382
+ }
383
+ // Narrative
384
+ lines.push(report.narrative);
385
+ return lines.join('\n');
386
+ }
@@ -18,6 +18,10 @@ export const UNIVERSAL_CONFIG = {
18
18
  },
19
19
  dependencies: {
20
20
  forbid: [],
21
+ detect_unused: true,
22
+ detect_heavy_alternatives: true,
23
+ detect_duplicate_purpose: true,
24
+ unused_allowlist: [],
21
25
  },
22
26
  architecture: {
23
27
  boundaries: [],
@@ -217,6 +221,19 @@ export const UNIVERSAL_CONFIG = {
217
221
  ignore_patterns: [],
218
222
  audit_log: true,
219
223
  },
224
+ style_drift: {
225
+ enabled: true,
226
+ deviation_threshold: 0.25,
227
+ sample_size: 100,
228
+ baseline_path: '.rigour/style-baseline.json',
229
+ },
230
+ logic_drift: {
231
+ enabled: true,
232
+ baseline_path: '.rigour/logic-baseline.json',
233
+ track_operators: true,
234
+ track_branches: true,
235
+ track_returns: true,
236
+ },
220
237
  side_effect_analysis: {
221
238
  enabled: true,
222
239
  check_unbounded_timers: true,