@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,94 @@
1
+ import { Gate, GateContext } from './base.js';
2
+ import { Failure, Gates } from '../types/index.js';
3
+ import { execa } from 'execa';
4
+ import semver from 'semver';
5
+ import fs from 'fs-extra';
6
+ import path from 'path';
7
+
8
+ export class EnvironmentGate extends Gate {
9
+ constructor(private config: Gates) {
10
+ super('environment-alignment', 'Environment & Tooling Alignment');
11
+ }
12
+
13
+ async run(context: GateContext): Promise<Failure[]> {
14
+ const failures: Failure[] = [];
15
+ const envConfig = this.config.environment;
16
+ if (!envConfig || !envConfig.enabled) return [];
17
+
18
+ const contracts = envConfig.enforce_contracts ? await this.discoverContracts(context.cwd) : {};
19
+ const toolsToCheck = { ...contracts, ...(envConfig.tools || {}) };
20
+
21
+ // 1. Verify Tool Versions
22
+ for (const [tool, range] of Object.entries(toolsToCheck)) {
23
+ // Ensure range is a string
24
+ const semverRange = String(range);
25
+ try {
26
+ const { stdout } = await execa(tool, ['--version'], { shell: true });
27
+ const versionMatch = stdout.match(/(\d+\.\d+\.\d+)/);
28
+
29
+ if (versionMatch) {
30
+ const version = versionMatch[1];
31
+ if (!semver.satisfies(version, semverRange)) {
32
+ failures.push(this.createFailure(
33
+ `Environment Alignment: Tool '${tool}' version mismatch.`,
34
+ [],
35
+ `Project requires '${tool} ${semverRange}' (discovered from contract), but found version '${version}'. Please align your local environment to prevent drift.`
36
+ ));
37
+ }
38
+ } else {
39
+ failures.push(this.createFailure(
40
+ `Environment Alignment: Could not determine version for '${tool}'.`,
41
+ [],
42
+ `Ensure '${tool} --version' returns a standard SemVer string.`
43
+ ));
44
+ }
45
+ } catch (e) {
46
+ failures.push(this.createFailure(
47
+ `Environment Alignment: Required tool '${tool}' is missing.`,
48
+ [],
49
+ `Install '${tool}' and ensure it is in your $PATH.`
50
+ ));
51
+ }
52
+ }
53
+
54
+ // 2. Verify Required Env Vars
55
+ const requiredEnv = envConfig.required_env || [];
56
+ for (const envVar of requiredEnv) {
57
+ if (!process.env[envVar]) {
58
+ failures.push(this.createFailure(
59
+ `Environment Alignment: Missing required environment variable '${envVar}'.`,
60
+ [],
61
+ `Ensure '${envVar}' is defined in your environment or .env file.`
62
+ ));
63
+ }
64
+ }
65
+
66
+ return failures;
67
+ }
68
+
69
+ private async discoverContracts(cwd: string): Promise<Record<string, string>> {
70
+ const contracts: Record<string, string> = {};
71
+
72
+ // 1. Scan pyproject.toml (for ruff, mypy)
73
+ const pyprojectPath = path.join(cwd, 'pyproject.toml');
74
+ if (await fs.pathExists(pyprojectPath)) {
75
+ const content = await fs.readFile(pyprojectPath, 'utf-8');
76
+ // SME Logic: Look for ruff and mypy version constraints
77
+ // Handle both ruff = "^0.14.0" and ruff = { version = "^0.14.0" }
78
+ const ruffMatch = content.match(/ruff\s*=\s*(?:['"]([^'"]+)['"]|\{\s*version\s*=\s*['"]([^'"]+)['"]\s*\})/);
79
+ if (ruffMatch) contracts['ruff'] = ruffMatch[1] || ruffMatch[2];
80
+
81
+ const mypyMatch = content.match(/mypy\s*=\s*(?:['"]([^'"]+)['"]|\{\s*version\s*=\s*['"]([^'"]+)['"]\s*\})/);
82
+ if (mypyMatch) contracts['mypy'] = mypyMatch[1] || mypyMatch[2];
83
+ }
84
+
85
+ // 2. Scan package.json (for node/npm/pnpm)
86
+ const pkgPath = path.join(cwd, 'package.json');
87
+ if (await fs.pathExists(pkgPath)) {
88
+ const pkg = await fs.readJson(pkgPath);
89
+ if (pkg.engines?.node) contracts['node'] = pkg.engines.node;
90
+ }
91
+
92
+ return contracts;
93
+ }
94
+ }
package/src/gates/file.ts CHANGED
@@ -12,7 +12,11 @@ export class FileGate extends Gate {
12
12
  }
13
13
 
14
14
  async run(context: GateContext): Promise<Failure[]> {
15
- const files = await FileScanner.findFiles({ cwd: context.cwd });
15
+ const files = await FileScanner.findFiles({
16
+ cwd: context.cwd,
17
+ ignore: context.ignore,
18
+ patterns: context.patterns
19
+ });
16
20
  const contents = await FileScanner.readFiles(context.cwd, files);
17
21
 
18
22
  const violations: string[] = [];
@@ -7,6 +7,9 @@ import { ASTGate } from './ast.js';
7
7
  import { SafetyGate } from './safety.js';
8
8
  import { DependencyGate } from './dependency.js';
9
9
  import { CoverageGate } from './coverage.js';
10
+ import { ContextGate } from './context.js';
11
+ import { ContextEngine } from '../services/context-engine.js';
12
+ import { EnvironmentGate } from './environment.js'; // [NEW]
10
13
  import { execa } from 'execa';
11
14
  import { Logger } from '../utils/logger.js';
12
15
 
@@ -34,6 +37,15 @@ export class GateRunner {
34
37
  this.gates.push(new DependencyGate(this.config));
35
38
  this.gates.push(new SafetyGate(this.config.gates));
36
39
  this.gates.push(new CoverageGate(this.config.gates));
40
+
41
+ if (this.config.gates.context?.enabled) {
42
+ this.gates.push(new ContextGate(this.config.gates));
43
+ }
44
+
45
+ // Environment Alignment Gate (Should be prioritized)
46
+ if (this.config.gates.environment?.enabled) {
47
+ this.gates.unshift(new EnvironmentGate(this.config.gates));
48
+ }
37
49
  }
38
50
 
39
51
  /**
@@ -43,15 +55,24 @@ export class GateRunner {
43
55
  this.gates.push(gate);
44
56
  }
45
57
 
46
- async run(cwd: string): Promise<Report> {
58
+ async run(cwd: string, patterns?: string[]): Promise<Report> {
47
59
  const start = Date.now();
48
60
  const failures: Failure[] = [];
49
61
  const summary: Record<string, Status> = {};
50
62
 
63
+ const ignore = this.config.ignore;
64
+
65
+ // 0. Run Context Discovery
66
+ let record;
67
+ if (this.config.gates.context?.enabled) {
68
+ const engine = new ContextEngine(this.config);
69
+ record = await engine.discover(cwd);
70
+ }
71
+
51
72
  // 1. Run internal gates
52
73
  for (const gate of this.gates) {
53
74
  try {
54
- const gateFailures = await gate.run({ cwd });
75
+ const gateFailures = await gate.run({ cwd, record, ignore, patterns });
55
76
  if (gateFailures.length > 0) {
56
77
  failures.push(...gateFailures);
57
78
  summary[gate.id] = 'FAIL';
@@ -0,0 +1,104 @@
1
+ import { FileScanner } from '../utils/scanner.js';
2
+ import { Config } from '../types/index.js';
3
+ import path from 'path';
4
+ import fs from 'fs-extra';
5
+
6
+ export interface ProjectAnchor {
7
+ id: string;
8
+ type: 'env' | 'naming' | 'import';
9
+ pattern: string;
10
+ confidence: number;
11
+ occurrences: number;
12
+ }
13
+
14
+ export interface GoldenRecord {
15
+ anchors: ProjectAnchor[];
16
+ metadata: {
17
+ scannedFiles: number;
18
+ detectedCasing: 'camelCase' | 'snake_case' | 'PascalCase' | 'unknown';
19
+ };
20
+ }
21
+
22
+ export class ContextEngine {
23
+ constructor(private config: Config) { }
24
+
25
+ async discover(cwd: string): Promise<GoldenRecord> {
26
+ const anchors: ProjectAnchor[] = [];
27
+ const files = await FileScanner.findFiles({
28
+ cwd,
29
+ patterns: [
30
+ '**/*.{ts,js,py,yaml,yml,json}',
31
+ '.env*',
32
+ '**/.env*',
33
+ '**/package.json',
34
+ '**/Dockerfile',
35
+ '**/*.tf'
36
+ ]
37
+ });
38
+
39
+ const limit = this.config.gates.context?.mining_depth || 100;
40
+ const samples = files.slice(0, limit);
41
+
42
+ const envVars = new Map<string, number>();
43
+ let scannedFiles = 0;
44
+
45
+ for (const file of samples) {
46
+ try {
47
+ const content = await fs.readFile(path.join(cwd, file), 'utf-8');
48
+ scannedFiles++;
49
+ this.mineEnvVars(content, file, envVars);
50
+ } catch (e) { }
51
+ }
52
+
53
+ console.log(`[ContextEngine] Discovered ${envVars.size} env anchors`);
54
+
55
+ // Convert envVars to anchors
56
+ for (const [name, count] of envVars.entries()) {
57
+ const confidence = count >= 2 ? 1 : 0.5;
58
+ anchors.push({
59
+ id: name,
60
+ type: 'env',
61
+ pattern: name,
62
+ occurrences: count,
63
+ confidence
64
+ });
65
+ }
66
+
67
+ return {
68
+ anchors,
69
+ metadata: {
70
+ scannedFiles,
71
+ detectedCasing: 'unknown', // TODO: Implement casing discovery
72
+ }
73
+ };
74
+ }
75
+
76
+ private mineEnvVars(content: string, file: string, registry: Map<string, number>) {
77
+ const isAnchorSource = file.includes('.env') || file.includes('yml') || file.includes('yaml');
78
+
79
+ if (isAnchorSource) {
80
+ const matches = content.matchAll(/^\s*([A-Z0-9_]+)\s*=/gm);
81
+ for (const match of matches) {
82
+ // Anchors from .env count for more initially
83
+ registry.set(match[1], (registry.get(match[1]) || 0) + 2);
84
+ }
85
+ }
86
+
87
+ // Source code matches (process.env.VAR or process.env['VAR'])
88
+ const tsJsMatches = content.matchAll(/process\.env(?:\.([A-Z0-9_]+)|\[['"]([A-Z0-9_]+)['"]\])/g);
89
+ for (const match of tsJsMatches) {
90
+ const name = match[1] || match[2];
91
+ this.incrementRegistry(registry, name);
92
+ }
93
+
94
+ // Python matches (os.environ.get('VAR') or os.environ['VAR'])
95
+ const pyMatches = content.matchAll(/os\.environ(?:\.get\(|\[)['"]([A-Z0-9_]+)['"]/g);
96
+ for (const match of pyMatches) {
97
+ this.incrementRegistry(registry, match[1]);
98
+ }
99
+ }
100
+
101
+ private incrementRegistry(registry: Map<string, number>, key: string) {
102
+ registry.set(key, (registry.get(key) || 0) + 1);
103
+ }
104
+ }
@@ -190,9 +190,22 @@ export const UNIVERSAL_CONFIG: Config = {
190
190
  max_files_changed_per_cycle: 10,
191
191
  protected_paths: ['.github/**', 'docs/**', 'rigour.yml'],
192
192
  },
193
+ context: {
194
+ enabled: true,
195
+ sensitivity: 0.8,
196
+ mining_depth: 100,
197
+ ignored_patterns: [],
198
+ },
199
+ environment: {
200
+ enabled: true,
201
+ enforce_contracts: true,
202
+ tools: {},
203
+ required_env: [],
204
+ },
193
205
  },
194
206
  output: {
195
207
  report_path: 'rigour-report.json',
196
208
  },
197
209
  planned: [],
210
+ ignore: [],
198
211
  };
@@ -35,6 +35,18 @@ export const GatesSchema = z.object({
35
35
  max_files_changed_per_cycle: z.number().optional().default(10),
36
36
  protected_paths: z.array(z.string()).optional().default(['.github/**', 'docs/**', 'rigour.yml']),
37
37
  }).optional().default({}),
38
+ context: z.object({
39
+ enabled: z.boolean().optional().default(true),
40
+ sensitivity: z.number().min(0).max(1).optional().default(0.8), // 0.8 correlation threshold
41
+ mining_depth: z.number().optional().default(100), // Number of files to sample
42
+ ignored_patterns: z.array(z.string()).optional().default([]),
43
+ }).optional().default({}),
44
+ environment: z.object({
45
+ enabled: z.boolean().optional().default(true),
46
+ enforce_contracts: z.boolean().optional().default(true), // Auto-discovery of versions from truth sources
47
+ tools: z.record(z.string()).optional().default({}), // Explicit overrides
48
+ required_env: z.array(z.string()).optional().default([]),
49
+ }).optional().default({}),
38
50
  });
39
51
 
40
52
  export const CommandsSchema = z.object({
@@ -54,12 +66,17 @@ export const ConfigSchema = z.object({
54
66
  report_path: z.string().default('rigour-report.json'),
55
67
  }).optional().default({}),
56
68
  planned: z.array(z.string()).optional().default([]),
69
+ ignore: z.array(z.string()).optional().default([]),
57
70
  });
58
71
 
59
72
  export type Gates = z.infer<typeof GatesSchema>;
60
73
  export type Commands = z.infer<typeof CommandsSchema>;
61
74
  export type Config = z.infer<typeof ConfigSchema>;
62
75
 
76
+ export type RawGates = z.input<typeof GatesSchema>;
77
+ export type RawCommands = z.input<typeof CommandsSchema>;
78
+ export type RawConfig = z.input<typeof ConfigSchema>;
79
+
63
80
  export const StatusSchema = z.enum(['PASS', 'FAIL', 'SKIP', 'ERROR']);
64
81
  export type Status = z.infer<typeof StatusSchema>;
65
82
 
@@ -20,16 +20,21 @@ export class FileScanner {
20
20
  ];
21
21
 
22
22
  static async findFiles(options: ScannerOptions): Promise<string[]> {
23
- return globby(options.patterns || this.DEFAULT_PATTERNS, {
24
- cwd: options.cwd,
25
- ignore: options.ignore || this.DEFAULT_IGNORE,
23
+ const patterns = (options.patterns || this.DEFAULT_PATTERNS).map(p => p.replace(/\\/g, '/'));
24
+ const ignore = (options.ignore || this.DEFAULT_IGNORE).map(p => p.replace(/\\/g, '/'));
25
+ const normalizedCwd = options.cwd.replace(/\\/g, '/');
26
+
27
+ return globby(patterns, {
28
+ cwd: normalizedCwd,
29
+ ignore: ignore,
26
30
  });
27
31
  }
28
32
 
29
33
  static async readFiles(cwd: string, files: string[]): Promise<Map<string, string>> {
30
34
  const contents = new Map<string, string>();
31
35
  for (const file of files) {
32
- const filePath = path.join(cwd, file);
36
+ const normalizedFile = file.replace(/\//g, path.sep);
37
+ const filePath = path.isAbsolute(normalizedFile) ? normalizedFile : path.join(cwd, normalizedFile);
33
38
  contents.set(file, await fs.readFile(filePath, 'utf-8'));
34
39
  }
35
40
  return contents;