@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,280 @@
1
+ /**
2
+ * Logic Drift Foundation Gate
3
+ *
4
+ * Detects when AI subtly changes business logic in functions:
5
+ * - Comparison operator mutations: >= became > (off-by-one)
6
+ * - Return statement additions/removals
7
+ * - Branch count changes (new if/else added or removed)
8
+ * - Call sequence changes (function calls reordered)
9
+ *
10
+ * This is the HARDEST drift to catch because:
11
+ * - Code still compiles
12
+ * - Tests might still pass (if they don't cover edge cases)
13
+ * - The change looks intentional ("AI refactored the function")
14
+ *
15
+ * Strategy: Collect baselines for critical functions, then detect
16
+ * mutations between scans. This foundation enables future LLM-powered
17
+ * deeper analysis (feeding baselines into DriftBench training).
18
+ *
19
+ * @since v5.1.0
20
+ */
21
+ import { Gate } from './base.js';
22
+ import { FileScanner } from '../utils/scanner.js';
23
+ import { Logger } from '../utils/logger.js';
24
+ import fs from 'fs-extra';
25
+ import path from 'path';
26
+ import crypto from 'crypto';
27
+ export class LogicDriftGate extends Gate {
28
+ config;
29
+ constructor(config = {}) {
30
+ super('logic-drift', 'Logic Drift Detection');
31
+ this.config = {
32
+ enabled: config.enabled ?? true,
33
+ baseline_path: config.baseline_path ?? '.rigour/logic-baseline.json',
34
+ track_operators: config.track_operators ?? true,
35
+ track_branches: config.track_branches ?? true,
36
+ track_returns: config.track_returns ?? true,
37
+ };
38
+ }
39
+ get provenance() { return 'ai-drift'; }
40
+ async run(context) {
41
+ if (!this.config.enabled)
42
+ return [];
43
+ const failures = [];
44
+ const baselinePath = path.join(context.cwd, this.config.baseline_path);
45
+ // Find source files
46
+ const files = await FileScanner.findFiles({
47
+ cwd: context.cwd,
48
+ patterns: context.patterns || ['**/*.{ts,tsx,js,jsx}'],
49
+ ignore: [...(context.ignore || []), '**/node_modules/**', '**/dist/**', '**/*.test.*', '**/*.spec.*', '**/*.d.ts'],
50
+ });
51
+ if (files.length === 0)
52
+ return [];
53
+ // Extract current function baselines
54
+ const currentFunctions = [];
55
+ const contents = await FileScanner.readFiles(context.cwd, files, context.fileCache);
56
+ for (const [file, content] of contents) {
57
+ const extracted = this.extractFunctionBaselines(content, file);
58
+ currentFunctions.push(...extracted);
59
+ }
60
+ // Load previous baseline
61
+ let previousBaseline = null;
62
+ if (await fs.pathExists(baselinePath)) {
63
+ try {
64
+ previousBaseline = await fs.readJson(baselinePath);
65
+ }
66
+ catch {
67
+ Logger.debug('Failed to load logic baseline');
68
+ }
69
+ }
70
+ if (!previousBaseline) {
71
+ // First scan: save baseline, no comparisons yet
72
+ const baseline = {
73
+ functions: currentFunctions,
74
+ createdAt: new Date().toISOString(),
75
+ lastUpdated: new Date().toISOString(),
76
+ scanCount: 1,
77
+ };
78
+ await fs.ensureDir(path.dirname(baselinePath));
79
+ await fs.writeJson(baselinePath, baseline, { spaces: 2 });
80
+ Logger.info(`Logic Drift: Created baseline with ${currentFunctions.length} functions → ${baselinePath}`);
81
+ return [];
82
+ }
83
+ // Compare current functions against previous baselines
84
+ const prevMap = new Map();
85
+ for (const fn of previousBaseline.functions) {
86
+ // Key by file:name for matching
87
+ prevMap.set(`${fn.file}:${fn.name}`, fn);
88
+ }
89
+ for (const current of currentFunctions) {
90
+ const key = `${current.file}:${current.name}`;
91
+ const prev = prevMap.get(key);
92
+ if (!prev)
93
+ continue; // New function, no baseline to compare
94
+ // Skip if body hash is identical (no changes at all)
95
+ if (current.bodyHash === prev.bodyHash)
96
+ continue;
97
+ // ── Operator Mutations ──
98
+ if (this.config.track_operators) {
99
+ const opChanges = this.detectOperatorMutations(prev.comparisonOps, current.comparisonOps);
100
+ for (const change of opChanges) {
101
+ failures.push(this.createFailure(`Logic drift: Comparison operator changed from '${change.from}' to '${change.to}' in function '${current.name}'.`, [current.file], `Operator mutation in ${current.file}:${current.line} — '${change.from}' → '${change.to}'. This could introduce off-by-one errors or change boundary conditions. Verify this change is intentional.`, 'Logic Drift: Operator Mutation', current.line, undefined, 'high'));
102
+ }
103
+ }
104
+ // ── Return Count Changes ──
105
+ if (this.config.track_returns && current.returnCount !== prev.returnCount) {
106
+ const diff = current.returnCount - prev.returnCount;
107
+ const direction = diff > 0 ? 'added' : 'removed';
108
+ failures.push(this.createFailure(`Logic drift: ${Math.abs(diff)} return statement(s) ${direction} in function '${current.name}' (was ${prev.returnCount}, now ${current.returnCount}).`, [current.file], `Return statement count changed in ${current.file}:${current.line}. This may alter control flow or error handling paths.`, 'Logic Drift: Return Change', current.line, undefined, 'medium'));
109
+ }
110
+ // ── Branch Count Changes ──
111
+ if (this.config.track_branches) {
112
+ const branchDiff = Math.abs(current.branchCount - prev.branchCount);
113
+ if (branchDiff >= 2) {
114
+ // Only alert on significant branch changes (±2+)
115
+ const direction = current.branchCount > prev.branchCount ? 'added' : 'removed';
116
+ failures.push(this.createFailure(`Logic drift: ${branchDiff} branch(es) ${direction} in function '${current.name}' (was ${prev.branchCount}, now ${current.branchCount}).`, [current.file], `Significant branch count change in ${current.file}:${current.line}. Review whether all code paths are still correct.`, 'Logic Drift: Branch Change', current.line, undefined, 'low'));
117
+ }
118
+ }
119
+ }
120
+ // Update baseline with current data
121
+ const updatedBaseline = {
122
+ functions: currentFunctions,
123
+ createdAt: previousBaseline.createdAt,
124
+ lastUpdated: new Date().toISOString(),
125
+ scanCount: previousBaseline.scanCount + 1,
126
+ };
127
+ await fs.writeJson(baselinePath, updatedBaseline, { spaces: 2 });
128
+ if (failures.length > 0) {
129
+ Logger.info(`Logic Drift: Found ${failures.length} logic mutations`);
130
+ }
131
+ return failures;
132
+ }
133
+ // ─── Function Baseline Extraction ────────────────────────────────
134
+ extractFunctionBaselines(content, file) {
135
+ const baselines = [];
136
+ const lines = content.split('\n');
137
+ const fnPatterns = [
138
+ /^(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\(/,
139
+ /^(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?(?:\([^)]*\)|(\w+))\s*=>/,
140
+ /^\s+(?:async\s+)?(\w+)\s*\([^)]*\)\s*\{/,
141
+ ];
142
+ for (let i = 0; i < lines.length; i++) {
143
+ const line = lines[i];
144
+ for (const pattern of fnPatterns) {
145
+ const match = line.match(pattern);
146
+ if (match) {
147
+ const name = match[1];
148
+ if (['if', 'for', 'while', 'switch', 'catch', 'constructor'].includes(name))
149
+ continue;
150
+ const body = this.extractBody(lines, i);
151
+ if (body.length < 3)
152
+ continue; // Skip trivial functions
153
+ const bodyText = body.join('\n');
154
+ const normalized = bodyText
155
+ .replace(/\/\/.*/g, '')
156
+ .replace(/\/\*[\s\S]*?\*\//g, '')
157
+ .replace(/\s+/g, ' ')
158
+ .trim();
159
+ baselines.push({
160
+ name,
161
+ file,
162
+ line: i + 1,
163
+ comparisonOps: this.extractComparisonOps(bodyText),
164
+ branchCount: this.countBranches(bodyText),
165
+ returnCount: this.countReturns(bodyText),
166
+ callSequence: this.extractCallSequence(bodyText),
167
+ bodyHash: crypto.createHash('md5').update(normalized).digest('hex'),
168
+ });
169
+ break;
170
+ }
171
+ }
172
+ }
173
+ return baselines;
174
+ }
175
+ extractBody(lines, startIndex) {
176
+ let braceDepth = 0;
177
+ let started = false;
178
+ const body = [];
179
+ for (let i = startIndex; i < lines.length; i++) {
180
+ const line = lines[i];
181
+ for (const ch of line) {
182
+ if (ch === '{') {
183
+ braceDepth++;
184
+ started = true;
185
+ }
186
+ if (ch === '}')
187
+ braceDepth--;
188
+ }
189
+ if (started)
190
+ body.push(line);
191
+ if (started && braceDepth === 0)
192
+ break;
193
+ }
194
+ return body;
195
+ }
196
+ /**
197
+ * Extract all comparison operators from function body in order.
198
+ * These are the most critical mutations: >= to > causes off-by-one.
199
+ */
200
+ extractComparisonOps(body) {
201
+ const ops = [];
202
+ const matches = body.matchAll(/(===|!==|==|!=|>=|<=|>(?!=)|<(?!=))/g);
203
+ for (const m of matches) {
204
+ ops.push(m[1]);
205
+ }
206
+ return ops;
207
+ }
208
+ countBranches(body) {
209
+ let count = 0;
210
+ // Count if, else if, else, switch, case, ternary
211
+ count += (body.match(/\bif\s*\(/g) || []).length;
212
+ count += (body.match(/\belse\s+if\s*\(/g) || []).length;
213
+ count += (body.match(/\belse\s*\{/g) || []).length;
214
+ count += (body.match(/\bswitch\s*\(/g) || []).length;
215
+ count += (body.match(/\bcase\s+/g) || []).length;
216
+ count += (body.match(/\?\s*[^?]/g) || []).length; // ternary (approximate)
217
+ return count;
218
+ }
219
+ countReturns(body) {
220
+ return (body.match(/\breturn\b/g) || []).length;
221
+ }
222
+ /**
223
+ * Extract ordered sequence of function calls.
224
+ * Useful for detecting when AI reorders operations.
225
+ */
226
+ extractCallSequence(body) {
227
+ const calls = [];
228
+ const matches = body.matchAll(/\b(\w+)\s*\(/g);
229
+ const keywords = new Set(['if', 'for', 'while', 'switch', 'catch', 'function', 'async', 'await', 'return', 'new', 'typeof', 'instanceof']);
230
+ for (const m of matches) {
231
+ if (!keywords.has(m[1])) {
232
+ calls.push(m[1]);
233
+ }
234
+ }
235
+ return calls;
236
+ }
237
+ // ─── Mutation Detection ──────────────────────────────────────────
238
+ /**
239
+ * Detect specific operator mutations between two ordered operator lists.
240
+ * Only reports CHANGED operators, not added/removed ones (those are
241
+ * covered by branch count changes).
242
+ *
243
+ * Example:
244
+ * prev: ['>=', '===', '!==']
245
+ * curr: ['>', '===', '!==']
246
+ * → [{from: '>=', to: '>'}]
247
+ */
248
+ detectOperatorMutations(prev, curr) {
249
+ const mutations = [];
250
+ // Use LCS-like alignment for best matching
251
+ const minLen = Math.min(prev.length, curr.length);
252
+ for (let i = 0; i < minLen; i++) {
253
+ if (prev[i] !== curr[i]) {
254
+ // Check if this is a "dangerous" mutation (same family, different strictness)
255
+ if (this.isDangerousMutation(prev[i], curr[i])) {
256
+ mutations.push({ from: prev[i], to: curr[i] });
257
+ }
258
+ }
259
+ }
260
+ return mutations;
261
+ }
262
+ /**
263
+ * Classify whether an operator change is "dangerous" (likely unintentional).
264
+ *
265
+ * Dangerous mutations:
266
+ * - >= to > (boundary change, off-by-one)
267
+ * - <= to < (boundary change)
268
+ * - === to == (type coercion change)
269
+ * - !== to != (type coercion change)
270
+ */
271
+ isDangerousMutation(from, to) {
272
+ const dangerous = new Set([
273
+ '>=:>', '>:>=', // Boundary mutations
274
+ '<=:<', '<:<=', // Boundary mutations
275
+ '===:==', '==:===', // Type coercion mutations
276
+ '!==:!=', '!=:!==', // Type coercion mutations
277
+ ]);
278
+ return dangerous.has(`${from}:${to}`);
279
+ }
280
+ }
@@ -1,6 +1,7 @@
1
1
  import { SEVERITY_WEIGHTS } from '../types/index.js';
2
2
  import { DeepAnalysisGate } from './deep-analysis.js';
3
3
  import { persistAndReinforce } from '../storage/local-memory.js';
4
+ import { recordGateRun } from '../services/adaptive-thresholds.js';
4
5
  import { FileGate } from './file.js';
5
6
  import { ContentGate } from './content.js';
6
7
  import { StructureGate } from './structure.js';
@@ -25,8 +26,11 @@ import { PhantomApisGate } from './phantom-apis.js';
25
26
  import { DeprecatedApisGate } from './deprecated-apis.js';
26
27
  import { TestQualityGate } from './test-quality.js';
27
28
  import { SideEffectAnalysisGate } from './side-effect-analysis.js';
29
+ import { StyleDriftGate } from './style-drift.js';
30
+ import { LogicDriftGate } from './logic-drift.js';
28
31
  import { execa } from 'execa';
29
32
  import { Logger } from '../utils/logger.js';
33
+ import { FileSystemCache } from '../services/filesystem-cache.js';
30
34
  export class GateRunner {
31
35
  config;
32
36
  gates = [];
@@ -102,6 +106,14 @@ export class GateRunner {
102
106
  if (this.config.gates.side_effect_analysis?.enabled !== false) {
103
107
  this.gates.push(new SideEffectAnalysisGate(this.config.gates.side_effect_analysis));
104
108
  }
109
+ // v5.0+ Style Drift Detection (enabled by default)
110
+ if (this.config.gates.style_drift?.enabled !== false) {
111
+ this.gates.push(new StyleDriftGate(this.config.gates.style_drift));
112
+ }
113
+ // v5.0+ Logic Drift Foundation (enabled by default)
114
+ if (this.config.gates.logic_drift?.enabled !== false) {
115
+ this.gates.push(new LogicDriftGate(this.config.gates.logic_drift));
116
+ }
105
117
  // Environment Alignment Gate (Should be prioritized)
106
118
  if (this.config.gates.environment?.enabled) {
107
119
  this.gates.unshift(new EnvironmentGate(this.config.gates));
@@ -124,10 +136,12 @@ export class GateRunner {
124
136
  const engine = new ContextEngine(this.config);
125
137
  record = await engine.discover(cwd);
126
138
  }
139
+ // Create shared file cache for all gates (solves memory bloat on large repos)
140
+ const fileCache = new FileSystemCache();
127
141
  // 1. Run internal gates
128
142
  for (const gate of this.gates) {
129
143
  try {
130
- const gateFailures = await gate.run({ cwd, record, ignore, patterns });
144
+ const gateFailures = await gate.run({ cwd, record, ignore, patterns, fileCache });
131
145
  if (gateFailures.length > 0) {
132
146
  failures.push(...gateFailures);
133
147
  summary[gate.id] = 'FAIL';
@@ -290,6 +304,20 @@ export class GateRunner {
290
304
  catch {
291
305
  // Silent — local memory is advisory, never blocks scans
292
306
  }
307
+ // v5: Record per-provenance data for adaptive thresholds + temporal drift
308
+ try {
309
+ const passedCount = Object.values(summary).filter(s => s === 'PASS').length;
310
+ const failedCount = Object.values(summary).filter(s => s === 'FAIL').length;
311
+ const provenanceData = {
312
+ aiDriftFailures: provenanceCounts['ai-drift'],
313
+ structuralFailures: provenanceCounts['traditional'],
314
+ securityFailures: provenanceCounts['security'],
315
+ };
316
+ recordGateRun(cwd, passedCount, failedCount, failures.length, provenanceData);
317
+ }
318
+ catch {
319
+ // Silent — adaptive history is advisory
320
+ }
293
321
  return report;
294
322
  }
295
323
  }
@@ -0,0 +1,53 @@
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, GateContext } from './base.js';
25
+ import { Failure, Provenance } from '../types/index.js';
26
+ export interface StyleDriftConfig {
27
+ enabled?: boolean;
28
+ deviation_threshold?: number;
29
+ sample_size?: number;
30
+ baseline_path?: string;
31
+ }
32
+ export declare class StyleDriftGate extends Gate {
33
+ private config;
34
+ constructor(config?: StyleDriftConfig);
35
+ protected get provenance(): Provenance;
36
+ run(context: GateContext): Promise<Failure[]>;
37
+ private computeFingerprint;
38
+ private analyzeFile;
39
+ private classifyCasing;
40
+ private mergeIntoFingerprint;
41
+ private compareToBaseline;
42
+ /**
43
+ * Compare two distributions and return a deviation score (0-1).
44
+ * 0 = perfect match, 1 = completely different predominant style.
45
+ *
46
+ * Method: find the predominant category in each distribution.
47
+ * If they differ, score = how far the file is from the baseline's predominant category.
48
+ */
49
+ private distributionDeviation;
50
+ private hasSignificantData;
51
+ /** Convert a typed distribution to a generic Record for comparison */
52
+ private toRecord;
53
+ }