@rigour-labs/core 4.3.6 → 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,305 @@
1
+ /**
2
+ * Style Drift Detection Gate
3
+ *
4
+ * Detects when AI-generated code gradually drifts away from the project's
5
+ * established coding conventions. AI models tend to use their own "default"
6
+ * style which may differ from the project norm.
7
+ *
8
+ * What it checks:
9
+ * 1. Naming conventions — camelCase vs snake_case vs PascalCase consistency
10
+ * 2. Error handling patterns — try-catch vs .catch() vs Result type consistency
11
+ * 3. Import style — named vs default vs wildcard import consistency
12
+ * 4. Quote style — single vs double quote consistency
13
+ *
14
+ * How it works:
15
+ * 1. First scan: sample source files → compute a style fingerprint → store baseline
16
+ * 2. Subsequent scans: compare new/changed files against baseline
17
+ * 3. If a file deviates >25% on any dimension → flag as style drift
18
+ *
19
+ * The baseline is stored in .rigour/style-baseline.json and evolves with
20
+ * human-approved changes (not AI drift).
21
+ *
22
+ * @since v5.0.0
23
+ */
24
+ import { Gate } from './base.js';
25
+ import { FileScanner } from '../utils/scanner.js';
26
+ import { Logger } from '../utils/logger.js';
27
+ import fs from 'fs-extra';
28
+ import path from 'path';
29
+ export class StyleDriftGate extends Gate {
30
+ config;
31
+ constructor(config = {}) {
32
+ super('style-drift', 'Style Drift Detection');
33
+ this.config = {
34
+ enabled: config.enabled ?? true,
35
+ deviation_threshold: config.deviation_threshold ?? 0.25,
36
+ sample_size: config.sample_size ?? 100,
37
+ baseline_path: config.baseline_path ?? '.rigour/style-baseline.json',
38
+ };
39
+ }
40
+ get provenance() { return 'ai-drift'; }
41
+ async run(context) {
42
+ if (!this.config.enabled)
43
+ return [];
44
+ const failures = [];
45
+ const baselinePath = path.join(context.cwd, this.config.baseline_path);
46
+ // Find source files
47
+ const files = await FileScanner.findFiles({
48
+ cwd: context.cwd,
49
+ patterns: context.patterns || ['**/*.{ts,tsx,js,jsx,py}'],
50
+ ignore: [...(context.ignore || []), '**/node_modules/**', '**/dist/**', '**/*.test.*', '**/*.spec.*', '**/*.d.ts'],
51
+ });
52
+ if (files.length === 0)
53
+ return [];
54
+ // Load or create baseline
55
+ let baseline = null;
56
+ if (await fs.pathExists(baselinePath)) {
57
+ try {
58
+ baseline = await fs.readJson(baselinePath);
59
+ }
60
+ catch {
61
+ Logger.debug('Failed to load style baseline, will create new one');
62
+ }
63
+ }
64
+ if (!baseline) {
65
+ // First scan: create baseline from sampled files
66
+ const sampled = files.slice(0, this.config.sample_size);
67
+ baseline = await this.computeFingerprint(context, sampled);
68
+ baseline.createdAt = new Date().toISOString();
69
+ // Ensure directory exists and save baseline
70
+ await fs.ensureDir(path.dirname(baselinePath));
71
+ await fs.writeJson(baselinePath, baseline, { spaces: 2 });
72
+ Logger.info(`Style Drift: Created baseline from ${sampled.length} files → ${baselinePath}`);
73
+ return []; // No failures on first scan
74
+ }
75
+ // Subsequent scan: compare each file against baseline
76
+ const contents = await FileScanner.readFiles(context.cwd, files, context.fileCache);
77
+ for (const [file, content] of contents) {
78
+ const ext = path.extname(file);
79
+ if (!['.ts', '.tsx', '.js', '.jsx', '.py'].includes(ext))
80
+ continue;
81
+ const fileFingerprint = this.analyzeFile(content, ext);
82
+ const deviations = this.compareToBaseline(fileFingerprint, baseline);
83
+ for (const deviation of deviations) {
84
+ if (deviation.score > this.config.deviation_threshold) {
85
+ failures.push(this.createFailure(`Style drift in ${file}: ${deviation.dimension} deviates ${(deviation.score * 100).toFixed(0)}% from project baseline (${deviation.detail}).`, [file], `This file's ${deviation.dimension} doesn't match the project's established convention. ${deviation.suggestion}`, 'Style Drift', undefined, undefined, 'low'));
86
+ }
87
+ }
88
+ }
89
+ if (failures.length > 0) {
90
+ Logger.info(`Style Drift: Found ${failures.length} convention deviations`);
91
+ }
92
+ return failures;
93
+ }
94
+ // ─── Fingerprint Computation ─────────────────────────────────────
95
+ async computeFingerprint(context, files) {
96
+ const fingerprint = {
97
+ naming: {
98
+ functions: { camelCase: 0, snake_case: 0, PascalCase: 0, SCREAMING_SNAKE: 0 },
99
+ variables: { camelCase: 0, snake_case: 0, PascalCase: 0, SCREAMING_SNAKE: 0 },
100
+ },
101
+ errorHandling: { tryCatch: 0, promiseCatch: 0, resultType: 0 },
102
+ importStyle: { named: 0, default: 0, wildcard: 0, sideEffect: 0 },
103
+ quoteStyle: { single: 0, double: 0, backtick: 0 },
104
+ totalFilesAnalyzed: 0,
105
+ createdAt: '',
106
+ };
107
+ const contents = await FileScanner.readFiles(context.cwd, files, context.fileCache);
108
+ for (const [file, content] of contents) {
109
+ const ext = path.extname(file);
110
+ const fileAnalysis = this.analyzeFile(content, ext);
111
+ this.mergeIntoFingerprint(fingerprint, fileAnalysis);
112
+ fingerprint.totalFilesAnalyzed++;
113
+ }
114
+ return fingerprint;
115
+ }
116
+ analyzeFile(content, ext) {
117
+ const fp = {
118
+ naming: {
119
+ functions: { camelCase: 0, snake_case: 0, PascalCase: 0, SCREAMING_SNAKE: 0 },
120
+ variables: { camelCase: 0, snake_case: 0, PascalCase: 0, SCREAMING_SNAKE: 0 },
121
+ },
122
+ errorHandling: { tryCatch: 0, promiseCatch: 0, resultType: 0 },
123
+ importStyle: { named: 0, default: 0, wildcard: 0, sideEffect: 0 },
124
+ quoteStyle: { single: 0, double: 0, backtick: 0 },
125
+ totalFilesAnalyzed: 1,
126
+ createdAt: '',
127
+ };
128
+ const lines = content.split('\n');
129
+ for (const line of lines) {
130
+ // ── Naming conventions ──
131
+ // Function declarations
132
+ const fnMatch = line.match(/(?:function|async\s+function)\s+(\w+)/);
133
+ if (fnMatch)
134
+ this.classifyCasing(fnMatch[1], fp.naming.functions);
135
+ // Method definitions
136
+ const methodMatch = line.match(/^\s+(?:async\s+)?(\w+)\s*\([^)]*\)\s*[{:]/);
137
+ if (methodMatch && !['if', 'for', 'while', 'switch', 'catch', 'constructor'].includes(methodMatch[1])) {
138
+ this.classifyCasing(methodMatch[1], fp.naming.functions);
139
+ }
140
+ // Arrow function assignments
141
+ const arrowMatch = line.match(/(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?(?:\([^)]*\)|(\w+))\s*=>/);
142
+ if (arrowMatch)
143
+ this.classifyCasing(arrowMatch[1], fp.naming.functions);
144
+ // Variable declarations (non-function)
145
+ const varMatch = line.match(/(?:const|let|var)\s+(\w+)\s*=/);
146
+ if (varMatch && !arrowMatch)
147
+ this.classifyCasing(varMatch[1], fp.naming.variables);
148
+ // Python function/variable
149
+ if (ext === '.py') {
150
+ const pyFn = line.match(/def\s+(\w+)/);
151
+ if (pyFn)
152
+ this.classifyCasing(pyFn[1], fp.naming.functions);
153
+ const pyVar = line.match(/^(\w+)\s*=/);
154
+ if (pyVar && !pyFn)
155
+ this.classifyCasing(pyVar[1], fp.naming.variables);
156
+ }
157
+ // ── Error handling ──
158
+ if (/\btry\s*\{/.test(line) || /\btry\s*:/.test(line))
159
+ fp.errorHandling.tryCatch++;
160
+ if (/\.catch\s*\(/.test(line))
161
+ fp.errorHandling.promiseCatch++;
162
+ if (/Result<|Result\[|Err\(|Ok\(|Either</.test(line))
163
+ fp.errorHandling.resultType++;
164
+ // ── Import style ──
165
+ if (/^import\s+\{/.test(line.trim()))
166
+ fp.importStyle.named++;
167
+ else if (/^import\s+\*\s+as/.test(line.trim()))
168
+ fp.importStyle.wildcard++;
169
+ else if (/^import\s+['"]/.test(line.trim()))
170
+ fp.importStyle.sideEffect++;
171
+ else if (/^import\s+\w/.test(line.trim()))
172
+ fp.importStyle.default++;
173
+ // ── Quote style ──
174
+ // Count quotes in non-import lines (imports are already counted above)
175
+ if (!line.trim().startsWith('import')) {
176
+ const singles = (line.match(/'/g) || []).length;
177
+ const doubles = (line.match(/"/g) || []).length;
178
+ const backticks = (line.match(/`/g) || []).length;
179
+ fp.quoteStyle.single += singles;
180
+ fp.quoteStyle.double += doubles;
181
+ fp.quoteStyle.backtick += backticks;
182
+ }
183
+ }
184
+ return fp;
185
+ }
186
+ classifyCasing(name, dist) {
187
+ if (name.startsWith('_') || name.length <= 1)
188
+ return; // Skip private/single char
189
+ if (/^[A-Z][A-Z0-9_]+$/.test(name)) {
190
+ dist.SCREAMING_SNAKE++;
191
+ }
192
+ else if (/^[A-Z]/.test(name)) {
193
+ dist.PascalCase++;
194
+ }
195
+ else if (name.includes('_')) {
196
+ dist.snake_case++;
197
+ }
198
+ else {
199
+ dist.camelCase++;
200
+ }
201
+ }
202
+ mergeIntoFingerprint(target, source) {
203
+ // Naming
204
+ for (const key of Object.keys(target.naming.functions)) {
205
+ target.naming.functions[key] += source.naming.functions[key];
206
+ target.naming.variables[key] += source.naming.variables[key];
207
+ }
208
+ // Error handling
209
+ target.errorHandling.tryCatch += source.errorHandling.tryCatch;
210
+ target.errorHandling.promiseCatch += source.errorHandling.promiseCatch;
211
+ target.errorHandling.resultType += source.errorHandling.resultType;
212
+ // Import style
213
+ target.importStyle.named += source.importStyle.named;
214
+ target.importStyle.default += source.importStyle.default;
215
+ target.importStyle.wildcard += source.importStyle.wildcard;
216
+ target.importStyle.sideEffect += source.importStyle.sideEffect;
217
+ // Quote style
218
+ target.quoteStyle.single += source.quoteStyle.single;
219
+ target.quoteStyle.double += source.quoteStyle.double;
220
+ target.quoteStyle.backtick += source.quoteStyle.backtick;
221
+ }
222
+ // ─── Baseline Comparison ─────────────────────────────────────────
223
+ compareToBaseline(file, baseline) {
224
+ const deviations = [];
225
+ // Compare function naming
226
+ const fnDev = this.distributionDeviation(this.toRecord(file.naming.functions), this.toRecord(baseline.naming.functions));
227
+ if (fnDev.score > 0) {
228
+ deviations.push({
229
+ dimension: 'function naming',
230
+ score: fnDev.score,
231
+ detail: `file uses ${fnDev.filePredominant}, project uses ${fnDev.baselinePredominant}`,
232
+ suggestion: `Use ${fnDev.baselinePredominant} for function names to match project conventions.`,
233
+ });
234
+ }
235
+ // Compare variable naming
236
+ const varDev = this.distributionDeviation(this.toRecord(file.naming.variables), this.toRecord(baseline.naming.variables));
237
+ if (varDev.score > 0) {
238
+ deviations.push({
239
+ dimension: 'variable naming',
240
+ score: varDev.score,
241
+ detail: `file uses ${varDev.filePredominant}, project uses ${varDev.baselinePredominant}`,
242
+ suggestion: `Use ${varDev.baselinePredominant} for variable names to match project conventions.`,
243
+ });
244
+ }
245
+ // Compare error handling
246
+ const errDev = this.distributionDeviation(file.errorHandling, baseline.errorHandling);
247
+ if (errDev.score > 0 && this.hasSignificantData(file.errorHandling)) {
248
+ deviations.push({
249
+ dimension: 'error handling',
250
+ score: errDev.score,
251
+ detail: `file uses ${errDev.filePredominant}, project uses ${errDev.baselinePredominant}`,
252
+ suggestion: `Use ${errDev.baselinePredominant} error handling pattern to match project conventions.`,
253
+ });
254
+ }
255
+ // Compare import style
256
+ const impDev = this.distributionDeviation(file.importStyle, baseline.importStyle);
257
+ if (impDev.score > 0 && this.hasSignificantData(file.importStyle)) {
258
+ deviations.push({
259
+ dimension: 'import style',
260
+ score: impDev.score,
261
+ detail: `file uses ${impDev.filePredominant}, project uses ${impDev.baselinePredominant}`,
262
+ suggestion: `Use ${impDev.baselinePredominant} imports to match project conventions.`,
263
+ });
264
+ }
265
+ return deviations;
266
+ }
267
+ /**
268
+ * Compare two distributions and return a deviation score (0-1).
269
+ * 0 = perfect match, 1 = completely different predominant style.
270
+ *
271
+ * Method: find the predominant category in each distribution.
272
+ * If they differ, score = how far the file is from the baseline's predominant category.
273
+ */
274
+ distributionDeviation(file, baseline) {
275
+ const fileTotal = Object.values(file).reduce((a, b) => a + b, 0);
276
+ const baselineTotal = Object.values(baseline).reduce((a, b) => a + b, 0);
277
+ if (fileTotal < 3 || baselineTotal < 5) {
278
+ return { score: 0, filePredominant: 'N/A', baselinePredominant: 'N/A' };
279
+ }
280
+ // Find predominant category
281
+ const filePredominant = Object.entries(file).sort((a, b) => b[1] - a[1])[0][0];
282
+ const baselinePredominant = Object.entries(baseline).sort((a, b) => b[1] - a[1])[0][0];
283
+ if (filePredominant === baselinePredominant) {
284
+ return { score: 0, filePredominant, baselinePredominant };
285
+ }
286
+ // Calculate how much the file uses the baseline's predominant style
287
+ const fileUseOfBaseline = (file[baselinePredominant] || 0) / fileTotal;
288
+ const baselineUseOfBaseline = (baseline[baselinePredominant] || 0) / baselineTotal;
289
+ // Score = how far the file deviates from baseline's predominant ratio
290
+ const deviation = Math.max(0, baselineUseOfBaseline - fileUseOfBaseline);
291
+ return { score: deviation, filePredominant, baselinePredominant };
292
+ }
293
+ hasSignificantData(obj) {
294
+ return Object.values(obj).reduce((a, b) => a + b, 0) >= 3;
295
+ }
296
+ /** Convert a typed distribution to a generic Record for comparison */
297
+ toRecord(dist) {
298
+ return {
299
+ camelCase: dist.camelCase,
300
+ snake_case: dist.snake_case,
301
+ PascalCase: dist.PascalCase,
302
+ SCREAMING_SNAKE: dist.SCREAMING_SNAKE,
303
+ };
304
+ }
305
+ }
package/dist/index.d.ts CHANGED
@@ -23,3 +23,7 @@ export { openDatabase, isSQLiteAvailable, compactDatabase, getDatabaseSize, rese
23
23
  export type { RigourDB, CompactResult } from './storage/index.js';
24
24
  export { checkLocalPatterns, persistAndReinforce, getProjectStats } from './storage/index.js';
25
25
  export type { ProjectStats } from './storage/index.js';
26
+ export { generateTemporalDriftReport, formatDriftSummary } from './services/temporal-drift.js';
27
+ export type { TemporalDriftReport, ProvenanceStream, MonthlyBucket, WeeklyBucket, DriftDirection } from './services/temporal-drift.js';
28
+ export { getProvenanceTrends, getQualityTrend } from './services/adaptive-thresholds.js';
29
+ export type { ProvenanceTrends, ProvenanceRunData } from './services/adaptive-thresholds.js';
package/dist/index.js CHANGED
@@ -23,6 +23,10 @@ export { extractFacts, factsToPromptString } from './deep/fact-extractor.js';
23
23
  export { openDatabase, isSQLiteAvailable, compactDatabase, getDatabaseSize, resetDatabase, insertScan, insertFindings, getRecentScans, getScoreTrendFromDB, getTopIssues, reinforcePattern, getStrongPatterns } from './storage/index.js';
24
24
  // Local Project Memory (hybrid intelligence — SQLite-backed per-project learning)
25
25
  export { checkLocalPatterns, persistAndReinforce, getProjectStats } from './storage/index.js';
26
+ // Temporal Drift Engine (v5 — cross-session trend analysis + per-provenance EWMA)
27
+ export { generateTemporalDriftReport, formatDriftSummary } from './services/temporal-drift.js';
28
+ // Adaptive Thresholds (v5 — Z-score + per-provenance trends)
29
+ export { getProvenanceTrends, getQualityTrend } from './services/adaptive-thresholds.js';
26
30
  // Pattern Index is intentionally NOT exported here to prevent
27
31
  // native dependency issues (sharp/transformers) from leaking into
28
32
  // non-AI parts of the system.
@@ -1,16 +1,19 @@
1
1
  /**
2
- * Adaptive Thresholds Service
2
+ * Adaptive Thresholds Service (v2)
3
3
  *
4
4
  * Dynamically adjusts quality gate thresholds based on:
5
5
  * - Project maturity (age, commit count, file count)
6
- * - Historical failure rates
6
+ * - Historical failure rates with Z-score anomaly detection
7
7
  * - Complexity tier (hobby/startup/enterprise)
8
- * - Recent trends (improving/degrading)
8
+ * - Per-provenance trend analysis (ai-drift, structural, security separate)
9
9
  *
10
- * This enables Rigour to be "strict but fair" - new projects get
11
- * more lenient thresholds while mature codebases are held to higher standards.
10
+ * v2 upgrades:
11
+ * - Z-score replaces naive delta comparison for trend detection
12
+ * - Per-provenance failure tracking (AI drift vs structural vs security)
13
+ * - Statistical anomaly detection normalizes across project sizes
12
14
  *
13
- * @since v2.14.0
15
+ * @since v2.14.0 (original)
16
+ * @since v5.0.0 (Z-score + provenance-aware trends)
14
17
  */
15
18
  export type ComplexityTier = 'hobby' | 'startup' | 'enterprise';
16
19
  export type QualityTrend = 'improving' | 'stable' | 'degrading';
@@ -36,21 +39,62 @@ export interface ThresholdAdjustments {
36
39
  securityBlockLevel: 'critical' | 'high' | 'medium' | 'low';
37
40
  leniencyFactor: number;
38
41
  reasoning: string[];
42
+ /** Per-provenance trend breakdown (new in v5) */
43
+ provenanceTrends?: ProvenanceTrends;
44
+ }
45
+ export interface ProvenanceRunData {
46
+ aiDriftFailures: number;
47
+ structuralFailures: number;
48
+ securityFailures: number;
49
+ }
50
+ export interface ProvenanceTrends {
51
+ aiDrift: QualityTrend;
52
+ structural: QualityTrend;
53
+ security: QualityTrend;
54
+ aiDriftZScore: number;
55
+ structuralZScore: number;
56
+ securityZScore: number;
39
57
  }
40
58
  /**
41
- * Record a gate run for historical tracking
59
+ * Record a gate run for historical tracking.
60
+ * v5: accepts optional per-provenance breakdown.
42
61
  */
43
- export declare function recordGateRun(cwd: string, passedGates: number, failedGates: number, totalFailures: number): void;
62
+ export declare function recordGateRun(cwd: string, passedGates: number, failedGates: number, totalFailures: number, provenance?: ProvenanceRunData): void;
44
63
  /**
45
- * Get quality trend from historical data
64
+ * Get quality trend using Z-score analysis (v5).
65
+ *
66
+ * How it works:
67
+ * 1. Take the last N runs (baseline window, default 20)
68
+ * 2. Compute mean and std of failure counts
69
+ * 3. Take the most recent window (last 5 runs)
70
+ * 4. Compute the average failure count in the recent window
71
+ * 5. Z-score = (recent_avg - baseline_mean) / baseline_std
72
+ *
73
+ * Z > 2.0 → statistically abnormal spike → DEGRADING
74
+ * Z < -2.0 → statistically abnormal drop → IMPROVING
75
+ *
76
+ * Why better than delta: A project with 100 failures/run and a spike to 108
77
+ * is stable (Z ≈ 0.5). A project with 2 failures/run and a spike to 8
78
+ * is degrading (Z ≈ 3.0). Z-score normalizes for project size.
46
79
  */
47
80
  export declare function getQualityTrend(cwd: string): QualityTrend;
81
+ /**
82
+ * Get per-provenance trend analysis (v5).
83
+ *
84
+ * Runs separate Z-score analysis for each provenance category.
85
+ * This is the core differentiator: Rigour can tell you
86
+ * "your AI is getting worse" separately from "your code quality is dropping."
87
+ *
88
+ * Falls back gracefully for legacy history data without provenance.
89
+ */
90
+ export declare function getProvenanceTrends(cwd: string): ProvenanceTrends;
48
91
  /**
49
92
  * Detect project complexity tier based on metrics
50
93
  */
51
94
  export declare function detectComplexityTier(metrics: ProjectMetrics): ComplexityTier;
52
95
  /**
53
- * Calculate adaptive thresholds based on project state
96
+ * Calculate adaptive thresholds based on project state.
97
+ * v5: Uses Z-score trending and per-provenance analysis.
54
98
  */
55
99
  export declare function calculateAdaptiveThresholds(cwd: string, metrics: ProjectMetrics, config?: AdaptiveConfig): ThresholdAdjustments;
56
100
  /**