@rigour-labs/core 2.0.0 → 2.1.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 @@
1
+ export {};
@@ -0,0 +1,61 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2
+ import { GateRunner } from '../src/gates/runner.js';
3
+ import fs from 'fs-extra';
4
+ import path from 'path';
5
+ import { fileURLToPath } from 'url';
6
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
+ const TEST_CWD = path.join(__dirname, '../temp-test-context');
8
+ describe('Context Awareness Engine', () => {
9
+ beforeAll(async () => {
10
+ await fs.ensureDir(TEST_CWD);
11
+ });
12
+ afterAll(async () => {
13
+ await fs.remove(TEST_CWD);
14
+ });
15
+ it('should detect context drift for redundant env suffixes (Golden Example)', async () => {
16
+ // Setup: Define standard GCP_PROJECT_ID
17
+ await fs.writeFile(path.join(TEST_CWD, '.env.example'), 'GCP_PROJECT_ID=my-project\n');
18
+ // Setup: Use drifted GCP_PROJECT_ID_PRODUCTION
19
+ await fs.writeFile(path.join(TEST_CWD, 'feature.js'), `
20
+ const id = process.env.GCP_PROJECT_ID_PRODUCTION;
21
+ console.log(id);
22
+ `);
23
+ const config = {
24
+ version: 1,
25
+ commands: {},
26
+ gates: {
27
+ context: {
28
+ enabled: true,
29
+ sensitivity: 0.8,
30
+ mining_depth: 10,
31
+ },
32
+ },
33
+ output: { report_path: 'rigour-report.json' }
34
+ };
35
+ const runner = new GateRunner(config);
36
+ const report = await runner.run(TEST_CWD);
37
+ const driftFailures = report.failures.filter(f => f.id === 'context-drift');
38
+ expect(driftFailures.length).toBeGreaterThan(0);
39
+ expect(driftFailures[0].details).toContain('GCP_PROJECT_ID_PRODUCTION');
40
+ expect(driftFailures[0].hint).toContain('GCP_PROJECT_ID');
41
+ });
42
+ it('should not flag valid environment variables', async () => {
43
+ await fs.writeFile(path.join(TEST_CWD, 'valid.js'), `
44
+ const id = process.env.GCP_PROJECT_ID;
45
+ `);
46
+ const config = {
47
+ version: 1,
48
+ commands: {},
49
+ gates: {
50
+ context: { enabled: true },
51
+ },
52
+ output: { report_path: 'rigour-report.json' }
53
+ };
54
+ const runner = new GateRunner(config);
55
+ const report = await runner.run(TEST_CWD);
56
+ const driftFailures = report.failures.filter(f => f.id === 'context-drift');
57
+ // Filter out failures from other files if they still exist in TEST_CWD
58
+ const specificFailures = driftFailures.filter(f => f.files?.includes('valid.js'));
59
+ expect(specificFailures.length).toBe(0);
60
+ });
61
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,97 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { GateRunner } from './gates/runner.js';
3
+ import { ConfigSchema } from './types/index.js';
4
+ import fs from 'fs-extra';
5
+ import path from 'path';
6
+ describe('Environment Alignment Gate', () => {
7
+ const testDir = path.join(process.cwd(), 'temp-test-env');
8
+ beforeEach(async () => {
9
+ await fs.ensureDir(testDir);
10
+ });
11
+ afterEach(async () => {
12
+ await fs.remove(testDir);
13
+ });
14
+ it('should detect tool version mismatch (Explicit)', async () => {
15
+ const rawConfig = {
16
+ version: 1,
17
+ gates: {
18
+ environment: {
19
+ enabled: true,
20
+ enforce_contracts: false,
21
+ tools: {
22
+ node: ">=99.0.0" // Guaranteed to fail
23
+ }
24
+ }
25
+ }
26
+ };
27
+ const config = ConfigSchema.parse(rawConfig);
28
+ const runner = new GateRunner(config);
29
+ const report = await runner.run(testDir);
30
+ expect(report.status).toBe('FAIL');
31
+ const envFailure = report.failures.find(f => f.id === 'environment-alignment');
32
+ expect(envFailure).toBeDefined();
33
+ expect(envFailure?.details).toContain('node');
34
+ expect(envFailure?.details).toContain('version mismatch');
35
+ });
36
+ it('should detect missing environment variables', async () => {
37
+ const rawConfig = {
38
+ version: 1,
39
+ gates: {
40
+ environment: {
41
+ enabled: true,
42
+ required_env: ["RIGOUR_TEST_VAR_MISSING"]
43
+ }
44
+ }
45
+ };
46
+ const config = ConfigSchema.parse(rawConfig);
47
+ const runner = new GateRunner(config);
48
+ const report = await runner.run(testDir);
49
+ expect(report.status).toBe('FAIL');
50
+ expect(report.failures[0].details).toContain('RIGOUR_TEST_VAR_MISSING');
51
+ });
52
+ it('should discover contracts from pyproject.toml', async () => {
53
+ // Create mock pyproject.toml with a version that will surely fail
54
+ await fs.writeFile(path.join(testDir, 'pyproject.toml'), `
55
+ [tool.ruff]
56
+ ruff = ">=99.14.0"
57
+ `);
58
+ const rawConfig = {
59
+ version: 1,
60
+ gates: {
61
+ environment: {
62
+ enabled: true,
63
+ enforce_contracts: true,
64
+ tools: {} // Should discover ruff from file
65
+ }
66
+ }
67
+ };
68
+ const config = ConfigSchema.parse(rawConfig);
69
+ const runner = new GateRunner(config);
70
+ const report = await runner.run(testDir);
71
+ // This might pass or fail depending on the local ruff version,
72
+ // but we want to check if the gate attempted to check ruff.
73
+ // If ruff is missing, it will fail with "is missing".
74
+ const ruffFailure = report.failures.find(f => f.details.includes('ruff'));
75
+ expect(ruffFailure).toBeDefined();
76
+ });
77
+ it('should prioritize environment gate and run it first', async () => {
78
+ const rawConfig = {
79
+ version: 1,
80
+ gates: {
81
+ max_file_lines: 1,
82
+ environment: {
83
+ enabled: true,
84
+ required_env: ["MANDATORY_SECRET_MISSING"]
85
+ }
86
+ }
87
+ };
88
+ const config = ConfigSchema.parse(rawConfig);
89
+ // Create a file that would fail max_file_lines
90
+ await fs.writeFile(path.join(testDir, 'large.js'), 'line1\nline2\nline3');
91
+ const runner = new GateRunner(config);
92
+ const report = await runner.run(testDir);
93
+ // In a real priority system, we might want to stop after environment failure.
94
+ // Currently GateRunner runs all, but environment-alignment has been unshifted.
95
+ expect(Object.keys(report.summary)[0]).toBe('environment-alignment');
96
+ });
97
+ });
package/dist/gates/ast.js CHANGED
@@ -17,10 +17,13 @@ export class ASTGate extends Gate {
17
17
  }
18
18
  async run(context) {
19
19
  const failures = [];
20
+ const patterns = (context.patterns || ['**/*.{ts,js,tsx,jsx,py,go,rs,cs,java,rb,c,cpp,php,swift,kt}']).map(p => p.replace(/\\/g, '/'));
21
+ const ignore = (context.ignore || ['node_modules/**', 'dist/**', 'build/**', '**/*.test.*', '**/*.spec.*', '**/__pycache__/**']).map(p => p.replace(/\\/g, '/'));
22
+ const normalizedCwd = context.cwd.replace(/\\/g, '/');
20
23
  // Find all supported files
21
- const files = await globby(['**/*.{ts,js,tsx,jsx,py,go,rs,cs,java,rb,c,cpp,php,swift,kt}'], {
22
- cwd: context.cwd,
23
- ignore: ['node_modules/**', 'dist/**', 'build/**', '**/*.test.*', '**/*.spec.*', '**/__pycache__/**'],
24
+ const files = await globby(patterns, {
25
+ cwd: normalizedCwd,
26
+ ignore: ignore,
24
27
  });
25
28
  for (const file of files) {
26
29
  const handler = this.handlers.find(h => h.supports(file));
@@ -1,6 +1,10 @@
1
+ import { GoldenRecord } from '../services/context-engine.js';
1
2
  import { Failure } from '../types/index.js';
2
3
  export interface GateContext {
3
4
  cwd: string;
5
+ record?: GoldenRecord;
6
+ ignore?: string[];
7
+ patterns?: string[];
4
8
  }
5
9
  export declare abstract class Gate {
6
10
  readonly id: string;
@@ -14,7 +14,11 @@ export class ContentGate extends Gate {
14
14
  patterns.push(/FIXME/i);
15
15
  if (patterns.length === 0)
16
16
  return [];
17
- const files = await FileScanner.findFiles({ cwd: context.cwd });
17
+ const files = await FileScanner.findFiles({
18
+ cwd: context.cwd,
19
+ ignore: context.ignore,
20
+ patterns: context.patterns
21
+ });
18
22
  const contents = await FileScanner.readFiles(context.cwd, files);
19
23
  const violations = [];
20
24
  for (const [file, content] of contents) {
@@ -0,0 +1,8 @@
1
+ import { Gate, GateContext } from './base.js';
2
+ import { Failure, Gates } from '../types/index.js';
3
+ export declare class ContextGate extends Gate {
4
+ private config;
5
+ constructor(config: Gates);
6
+ run(context: GateContext): Promise<Failure[]>;
7
+ private checkEnvDrift;
8
+ }
@@ -0,0 +1,43 @@
1
+ import { Gate } from './base.js';
2
+ import { FileScanner } from '../utils/scanner.js';
3
+ import fs from 'fs-extra';
4
+ import path from 'path';
5
+ export class ContextGate extends Gate {
6
+ config;
7
+ constructor(config) {
8
+ super('context-drift', 'Context Awareness & Drift Detection');
9
+ this.config = config;
10
+ }
11
+ async run(context) {
12
+ const failures = [];
13
+ const record = context.record;
14
+ if (!record || !this.config.context?.enabled)
15
+ return [];
16
+ const files = await FileScanner.findFiles({ cwd: context.cwd });
17
+ const envAnchors = record.anchors.filter(a => a.type === 'env' && a.confidence >= 1);
18
+ for (const file of files) {
19
+ try {
20
+ const content = await fs.readFile(path.join(context.cwd, file), 'utf-8');
21
+ // 1. Detect Redundant Suffixes (The Golden Example)
22
+ this.checkEnvDrift(content, file, envAnchors, failures);
23
+ }
24
+ catch (e) { }
25
+ }
26
+ return failures;
27
+ }
28
+ checkEnvDrift(content, file, anchors, failures) {
29
+ // Find all environment variable accesses in the content
30
+ const matches = content.matchAll(/process\.env(?:\.([A-Z0-9_]+)|\[['"]([A-Z0-9_]+)['"]\])/g);
31
+ for (const match of matches) {
32
+ const accessedVar = match[1] || match[2];
33
+ for (const anchor of anchors) {
34
+ // If the accessed variable contains the anchor but is not equal to it,
35
+ // it's a potential "invented" redundancy (e.g. CORE_URL vs CORE_URL_PROD)
36
+ if (accessedVar !== anchor.id && accessedVar.includes(anchor.id)) {
37
+ const deviation = accessedVar.replace(anchor.id, '').replace(/^_|_$/, '');
38
+ failures.push(this.createFailure(`Context Drift: Redundant variation '${accessedVar}' detected in ${file}.`, [file], `The project already uses '${anchor.id}' as a standard anchor. Avoid inventing variations like '${deviation}'. Reuse the existing anchor or align with established project patterns.`));
39
+ }
40
+ }
41
+ }
42
+ }
43
+ }
@@ -0,0 +1,8 @@
1
+ import { Gate, GateContext } from './base.js';
2
+ import { Failure, Gates } from '../types/index.js';
3
+ export declare class EnvironmentGate extends Gate {
4
+ private config;
5
+ constructor(config: Gates);
6
+ run(context: GateContext): Promise<Failure[]>;
7
+ private discoverContracts;
8
+ }
@@ -0,0 +1,73 @@
1
+ import { Gate } from './base.js';
2
+ import { execa } from 'execa';
3
+ import semver from 'semver';
4
+ import fs from 'fs-extra';
5
+ import path from 'path';
6
+ export class EnvironmentGate extends Gate {
7
+ config;
8
+ constructor(config) {
9
+ super('environment-alignment', 'Environment & Tooling Alignment');
10
+ this.config = config;
11
+ }
12
+ async run(context) {
13
+ const failures = [];
14
+ const envConfig = this.config.environment;
15
+ if (!envConfig || !envConfig.enabled)
16
+ return [];
17
+ const contracts = envConfig.enforce_contracts ? await this.discoverContracts(context.cwd) : {};
18
+ const toolsToCheck = { ...contracts, ...(envConfig.tools || {}) };
19
+ // 1. Verify Tool Versions
20
+ for (const [tool, range] of Object.entries(toolsToCheck)) {
21
+ // Ensure range is a string
22
+ const semverRange = String(range);
23
+ try {
24
+ const { stdout } = await execa(tool, ['--version'], { shell: true });
25
+ const versionMatch = stdout.match(/(\d+\.\d+\.\d+)/);
26
+ if (versionMatch) {
27
+ const version = versionMatch[1];
28
+ if (!semver.satisfies(version, semverRange)) {
29
+ failures.push(this.createFailure(`Environment Alignment: Tool '${tool}' version mismatch.`, [], `Project requires '${tool} ${semverRange}' (discovered from contract), but found version '${version}'. Please align your local environment to prevent drift.`));
30
+ }
31
+ }
32
+ else {
33
+ failures.push(this.createFailure(`Environment Alignment: Could not determine version for '${tool}'.`, [], `Ensure '${tool} --version' returns a standard SemVer string.`));
34
+ }
35
+ }
36
+ catch (e) {
37
+ failures.push(this.createFailure(`Environment Alignment: Required tool '${tool}' is missing.`, [], `Install '${tool}' and ensure it is in your $PATH.`));
38
+ }
39
+ }
40
+ // 2. Verify Required Env Vars
41
+ const requiredEnv = envConfig.required_env || [];
42
+ for (const envVar of requiredEnv) {
43
+ if (!process.env[envVar]) {
44
+ failures.push(this.createFailure(`Environment Alignment: Missing required environment variable '${envVar}'.`, [], `Ensure '${envVar}' is defined in your environment or .env file.`));
45
+ }
46
+ }
47
+ return failures;
48
+ }
49
+ async discoverContracts(cwd) {
50
+ const contracts = {};
51
+ // 1. Scan pyproject.toml (for ruff, mypy)
52
+ const pyprojectPath = path.join(cwd, 'pyproject.toml');
53
+ if (await fs.pathExists(pyprojectPath)) {
54
+ const content = await fs.readFile(pyprojectPath, 'utf-8');
55
+ // SME Logic: Look for ruff and mypy version constraints
56
+ // Handle both ruff = "^0.14.0" and ruff = { version = "^0.14.0" }
57
+ const ruffMatch = content.match(/ruff\s*=\s*(?:['"]([^'"]+)['"]|\{\s*version\s*=\s*['"]([^'"]+)['"]\s*\})/);
58
+ if (ruffMatch)
59
+ contracts['ruff'] = ruffMatch[1] || ruffMatch[2];
60
+ const mypyMatch = content.match(/mypy\s*=\s*(?:['"]([^'"]+)['"]|\{\s*version\s*=\s*['"]([^'"]+)['"]\s*\})/);
61
+ if (mypyMatch)
62
+ contracts['mypy'] = mypyMatch[1] || mypyMatch[2];
63
+ }
64
+ // 2. Scan package.json (for node/npm/pnpm)
65
+ const pkgPath = path.join(cwd, 'package.json');
66
+ if (await fs.pathExists(pkgPath)) {
67
+ const pkg = await fs.readJson(pkgPath);
68
+ if (pkg.engines?.node)
69
+ contracts['node'] = pkg.engines.node;
70
+ }
71
+ return contracts;
72
+ }
73
+ }
@@ -7,7 +7,11 @@ export class FileGate extends Gate {
7
7
  this.config = config;
8
8
  }
9
9
  async run(context) {
10
- const files = await FileScanner.findFiles({ cwd: context.cwd });
10
+ const files = await FileScanner.findFiles({
11
+ cwd: context.cwd,
12
+ ignore: context.ignore,
13
+ patterns: context.patterns
14
+ });
11
15
  const contents = await FileScanner.readFiles(context.cwd, files);
12
16
  const violations = [];
13
17
  for (const [file, content] of contents) {
@@ -9,5 +9,5 @@ export declare class GateRunner {
9
9
  * Allows adding custom gates dynamically (SOLID - Open/Closed Principle)
10
10
  */
11
11
  addGate(gate: Gate): void;
12
- run(cwd: string): Promise<Report>;
12
+ run(cwd: string, patterns?: string[]): Promise<Report>;
13
13
  }
@@ -5,6 +5,9 @@ import { ASTGate } from './ast.js';
5
5
  import { SafetyGate } from './safety.js';
6
6
  import { DependencyGate } from './dependency.js';
7
7
  import { CoverageGate } from './coverage.js';
8
+ import { ContextGate } from './context.js';
9
+ import { ContextEngine } from '../services/context-engine.js';
10
+ import { EnvironmentGate } from './environment.js'; // [NEW]
8
11
  import { execa } from 'execa';
9
12
  import { Logger } from '../utils/logger.js';
10
13
  export class GateRunner {
@@ -29,6 +32,13 @@ export class GateRunner {
29
32
  this.gates.push(new DependencyGate(this.config));
30
33
  this.gates.push(new SafetyGate(this.config.gates));
31
34
  this.gates.push(new CoverageGate(this.config.gates));
35
+ if (this.config.gates.context?.enabled) {
36
+ this.gates.push(new ContextGate(this.config.gates));
37
+ }
38
+ // Environment Alignment Gate (Should be prioritized)
39
+ if (this.config.gates.environment?.enabled) {
40
+ this.gates.unshift(new EnvironmentGate(this.config.gates));
41
+ }
32
42
  }
33
43
  /**
34
44
  * Allows adding custom gates dynamically (SOLID - Open/Closed Principle)
@@ -36,14 +46,21 @@ export class GateRunner {
36
46
  addGate(gate) {
37
47
  this.gates.push(gate);
38
48
  }
39
- async run(cwd) {
49
+ async run(cwd, patterns) {
40
50
  const start = Date.now();
41
51
  const failures = [];
42
52
  const summary = {};
53
+ const ignore = this.config.ignore;
54
+ // 0. Run Context Discovery
55
+ let record;
56
+ if (this.config.gates.context?.enabled) {
57
+ const engine = new ContextEngine(this.config);
58
+ record = await engine.discover(cwd);
59
+ }
43
60
  // 1. Run internal gates
44
61
  for (const gate of this.gates) {
45
62
  try {
46
- const gateFailures = await gate.run({ cwd });
63
+ const gateFailures = await gate.run({ cwd, record, ignore, patterns });
47
64
  if (gateFailures.length > 0) {
48
65
  failures.push(...gateFailures);
49
66
  summary[gate.id] = 'FAIL';
@@ -0,0 +1,22 @@
1
+ import { Config } from '../types/index.js';
2
+ export interface ProjectAnchor {
3
+ id: string;
4
+ type: 'env' | 'naming' | 'import';
5
+ pattern: string;
6
+ confidence: number;
7
+ occurrences: number;
8
+ }
9
+ export interface GoldenRecord {
10
+ anchors: ProjectAnchor[];
11
+ metadata: {
12
+ scannedFiles: number;
13
+ detectedCasing: 'camelCase' | 'snake_case' | 'PascalCase' | 'unknown';
14
+ };
15
+ }
16
+ export declare class ContextEngine {
17
+ private config;
18
+ constructor(config: Config);
19
+ discover(cwd: string): Promise<GoldenRecord>;
20
+ private mineEnvVars;
21
+ private incrementRegistry;
22
+ }
@@ -0,0 +1,78 @@
1
+ import { FileScanner } from '../utils/scanner.js';
2
+ import path from 'path';
3
+ import fs from 'fs-extra';
4
+ export class ContextEngine {
5
+ config;
6
+ constructor(config) {
7
+ this.config = config;
8
+ }
9
+ async discover(cwd) {
10
+ const anchors = [];
11
+ const files = await FileScanner.findFiles({
12
+ cwd,
13
+ patterns: [
14
+ '**/*.{ts,js,py,yaml,yml,json}',
15
+ '.env*',
16
+ '**/.env*',
17
+ '**/package.json',
18
+ '**/Dockerfile',
19
+ '**/*.tf'
20
+ ]
21
+ });
22
+ const limit = this.config.gates.context?.mining_depth || 100;
23
+ const samples = files.slice(0, limit);
24
+ const envVars = new Map();
25
+ let scannedFiles = 0;
26
+ for (const file of samples) {
27
+ try {
28
+ const content = await fs.readFile(path.join(cwd, file), 'utf-8');
29
+ scannedFiles++;
30
+ this.mineEnvVars(content, file, envVars);
31
+ }
32
+ catch (e) { }
33
+ }
34
+ console.log(`[ContextEngine] Discovered ${envVars.size} env anchors`);
35
+ // Convert envVars to anchors
36
+ for (const [name, count] of envVars.entries()) {
37
+ const confidence = count >= 2 ? 1 : 0.5;
38
+ anchors.push({
39
+ id: name,
40
+ type: 'env',
41
+ pattern: name,
42
+ occurrences: count,
43
+ confidence
44
+ });
45
+ }
46
+ return {
47
+ anchors,
48
+ metadata: {
49
+ scannedFiles,
50
+ detectedCasing: 'unknown', // TODO: Implement casing discovery
51
+ }
52
+ };
53
+ }
54
+ mineEnvVars(content, file, registry) {
55
+ const isAnchorSource = file.includes('.env') || file.includes('yml') || file.includes('yaml');
56
+ if (isAnchorSource) {
57
+ const matches = content.matchAll(/^\s*([A-Z0-9_]+)\s*=/gm);
58
+ for (const match of matches) {
59
+ // Anchors from .env count for more initially
60
+ registry.set(match[1], (registry.get(match[1]) || 0) + 2);
61
+ }
62
+ }
63
+ // Source code matches (process.env.VAR or process.env['VAR'])
64
+ const tsJsMatches = content.matchAll(/process\.env(?:\.([A-Z0-9_]+)|\[['"]([A-Z0-9_]+)['"]\])/g);
65
+ for (const match of tsJsMatches) {
66
+ const name = match[1] || match[2];
67
+ this.incrementRegistry(registry, name);
68
+ }
69
+ // Python matches (os.environ.get('VAR') or os.environ['VAR'])
70
+ const pyMatches = content.matchAll(/os\.environ(?:\.get\(|\[)['"]([A-Z0-9_]+)['"]/g);
71
+ for (const match of pyMatches) {
72
+ this.incrementRegistry(registry, match[1]);
73
+ }
74
+ }
75
+ incrementRegistry(registry, key) {
76
+ registry.set(key, (registry.get(key) || 0) + 1);
77
+ }
78
+ }
@@ -174,9 +174,22 @@ export const UNIVERSAL_CONFIG = {
174
174
  max_files_changed_per_cycle: 10,
175
175
  protected_paths: ['.github/**', 'docs/**', 'rigour.yml'],
176
176
  },
177
+ context: {
178
+ enabled: true,
179
+ sensitivity: 0.8,
180
+ mining_depth: 100,
181
+ ignored_patterns: [],
182
+ },
183
+ environment: {
184
+ enabled: true,
185
+ enforce_contracts: true,
186
+ tools: {},
187
+ required_env: [],
188
+ },
177
189
  },
178
190
  output: {
179
191
  report_path: 'rigour-report.json',
180
192
  },
181
193
  planned: [],
194
+ ignore: [],
182
195
  };