@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.
- package/dist/context.test.d.ts +1 -0
- package/dist/context.test.js +61 -0
- package/dist/environment.test.d.ts +1 -0
- package/dist/environment.test.js +97 -0
- package/dist/gates/ast.js +6 -3
- package/dist/gates/base.d.ts +4 -0
- package/dist/gates/content.js +5 -1
- package/dist/gates/context.d.ts +8 -0
- package/dist/gates/context.js +43 -0
- package/dist/gates/environment.d.ts +8 -0
- package/dist/gates/environment.js +73 -0
- package/dist/gates/file.js +5 -1
- package/dist/gates/runner.d.ts +1 -1
- package/dist/gates/runner.js +19 -2
- package/dist/services/context-engine.d.ts +22 -0
- package/dist/services/context-engine.js +78 -0
- package/dist/templates/index.js +13 -0
- package/dist/types/index.d.ts +147 -5
- package/dist/types/index.js +13 -0
- package/dist/utils/scanner.js +8 -4
- package/package.json +4 -2
- package/src/context.test.ts +73 -0
- package/src/environment.test.ts +115 -0
- package/src/gates/ast.ts +7 -3
- package/src/gates/base.ts +4 -0
- package/src/gates/content.ts +5 -1
- package/src/gates/context.ts +55 -0
- package/src/gates/environment.ts +94 -0
- package/src/gates/file.ts +5 -1
- package/src/gates/runner.ts +23 -2
- package/src/services/context-engine.ts +104 -0
- package/src/templates/index.ts +13 -0
- package/src/types/index.ts +17 -0
- package/src/utils/scanner.ts +9 -4
|
@@ -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({
|
|
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[] = [];
|
package/src/gates/runner.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/templates/index.ts
CHANGED
|
@@ -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
|
};
|
package/src/types/index.ts
CHANGED
|
@@ -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
|
|
package/src/utils/scanner.ts
CHANGED
|
@@ -20,16 +20,21 @@ export class FileScanner {
|
|
|
20
20
|
];
|
|
21
21
|
|
|
22
22
|
static async findFiles(options: ScannerOptions): Promise<string[]> {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
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;
|