@rigour-labs/core 2.0.0 → 2.2.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/discovery.js +12 -7
- package/dist/discovery.test.d.ts +1 -0
- package/dist/discovery.test.js +57 -0
- package/dist/environment.test.d.ts +1 -0
- package/dist/environment.test.js +97 -0
- package/dist/gates/ast-handlers/python.js +15 -1
- package/dist/gates/ast.js +6 -3
- package/dist/gates/base.d.ts +5 -1
- package/dist/gates/base.js +2 -2
- 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/coverage.js +6 -1
- package/dist/gates/dependency.js +40 -1
- 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/gates/safety.js +9 -3
- package/dist/safety.test.d.ts +1 -0
- package/dist/safety.test.js +42 -0
- 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 +9 -4
- package/dist/utils/scanner.test.d.ts +1 -0
- package/dist/utils/scanner.test.js +29 -0
- package/package.json +4 -2
- package/src/context.test.ts +73 -0
- package/src/discovery.test.ts +61 -0
- package/src/discovery.ts +11 -6
- package/src/environment.test.ts +115 -0
- package/src/gates/ast-handlers/python.ts +14 -1
- package/src/gates/ast.ts +7 -3
- package/src/gates/base.ts +6 -2
- package/src/gates/content.ts +5 -1
- package/src/gates/context.ts +55 -0
- package/src/gates/coverage.ts +5 -1
- package/src/gates/dependency.ts +65 -1
- package/src/gates/environment.ts +94 -0
- package/src/gates/file.ts +5 -1
- package/src/gates/runner.ts +23 -2
- package/src/gates/safety.ts +11 -4
- package/src/safety.test.ts +53 -0
- 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.test.ts +37 -0
- package/src/utils/scanner.ts +10 -4
package/src/discovery.ts
CHANGED
|
@@ -78,15 +78,20 @@ export class DiscoveryService {
|
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
private async findSourceFiles(cwd: string): Promise<string[]> {
|
|
81
|
-
// Find a few files to sample
|
|
82
81
|
const extensions = ['.ts', '.js', '.py', '.go', '.java', '.tf', 'package.json'];
|
|
83
82
|
const samples: string[] = [];
|
|
83
|
+
const commonDirs = ['.', 'src', 'app', 'lib', 'api', 'pkg'];
|
|
84
84
|
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
if (
|
|
88
|
-
|
|
89
|
-
|
|
85
|
+
for (const dir of commonDirs) {
|
|
86
|
+
const fullDir = path.join(cwd, dir);
|
|
87
|
+
if (!(await fs.pathExists(fullDir))) continue;
|
|
88
|
+
|
|
89
|
+
const files = await fs.readdir(fullDir);
|
|
90
|
+
for (const file of files) {
|
|
91
|
+
if (extensions.some(ext => file.endsWith(ext))) {
|
|
92
|
+
samples.push(path.join(fullDir, file));
|
|
93
|
+
if (samples.length >= 5) return samples;
|
|
94
|
+
}
|
|
90
95
|
}
|
|
91
96
|
}
|
|
92
97
|
return samples;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { GateRunner } from './gates/runner.js';
|
|
3
|
+
import { Config, RawConfig, ConfigSchema } from './types/index.js';
|
|
4
|
+
import fs from 'fs-extra';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
|
|
7
|
+
describe('Environment Alignment Gate', () => {
|
|
8
|
+
const testDir = path.join(process.cwd(), 'temp-test-env');
|
|
9
|
+
|
|
10
|
+
beforeEach(async () => {
|
|
11
|
+
await fs.ensureDir(testDir);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterEach(async () => {
|
|
15
|
+
await fs.remove(testDir);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('should detect tool version mismatch (Explicit)', async () => {
|
|
19
|
+
const rawConfig: RawConfig = {
|
|
20
|
+
version: 1,
|
|
21
|
+
gates: {
|
|
22
|
+
environment: {
|
|
23
|
+
enabled: true,
|
|
24
|
+
enforce_contracts: false,
|
|
25
|
+
tools: {
|
|
26
|
+
node: ">=99.0.0" // Guaranteed to fail
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const config = ConfigSchema.parse(rawConfig);
|
|
33
|
+
const runner = new GateRunner(config);
|
|
34
|
+
const report = await runner.run(testDir);
|
|
35
|
+
|
|
36
|
+
expect(report.status).toBe('FAIL');
|
|
37
|
+
const envFailure = report.failures.find(f => f.id === 'environment-alignment');
|
|
38
|
+
expect(envFailure).toBeDefined();
|
|
39
|
+
expect(envFailure?.details).toContain('node');
|
|
40
|
+
expect(envFailure?.details).toContain('version mismatch');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('should detect missing environment variables', async () => {
|
|
44
|
+
const rawConfig: RawConfig = {
|
|
45
|
+
version: 1,
|
|
46
|
+
gates: {
|
|
47
|
+
environment: {
|
|
48
|
+
enabled: true,
|
|
49
|
+
required_env: ["RIGOUR_TEST_VAR_MISSING"]
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const config = ConfigSchema.parse(rawConfig);
|
|
55
|
+
const runner = new GateRunner(config);
|
|
56
|
+
const report = await runner.run(testDir);
|
|
57
|
+
|
|
58
|
+
expect(report.status).toBe('FAIL');
|
|
59
|
+
expect(report.failures[0].details).toContain('RIGOUR_TEST_VAR_MISSING');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should discover contracts from pyproject.toml', async () => {
|
|
63
|
+
// Create mock pyproject.toml with a version that will surely fail
|
|
64
|
+
await fs.writeFile(path.join(testDir, 'pyproject.toml'), `
|
|
65
|
+
[tool.ruff]
|
|
66
|
+
ruff = ">=99.14.0"
|
|
67
|
+
`);
|
|
68
|
+
|
|
69
|
+
const rawConfig: RawConfig = {
|
|
70
|
+
version: 1,
|
|
71
|
+
gates: {
|
|
72
|
+
environment: {
|
|
73
|
+
enabled: true,
|
|
74
|
+
enforce_contracts: true,
|
|
75
|
+
tools: {} // Should discover ruff from file
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const config = ConfigSchema.parse(rawConfig);
|
|
81
|
+
const runner = new GateRunner(config);
|
|
82
|
+
const report = await runner.run(testDir);
|
|
83
|
+
|
|
84
|
+
// This might pass or fail depending on the local ruff version,
|
|
85
|
+
// but we want to check if the gate attempted to check ruff.
|
|
86
|
+
// If ruff is missing, it will fail with "is missing".
|
|
87
|
+
const ruffFailure = report.failures.find(f => f.details.includes('ruff'));
|
|
88
|
+
expect(ruffFailure).toBeDefined();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should prioritize environment gate and run it first', async () => {
|
|
92
|
+
const rawConfig: RawConfig = {
|
|
93
|
+
version: 1,
|
|
94
|
+
gates: {
|
|
95
|
+
max_file_lines: 1,
|
|
96
|
+
environment: {
|
|
97
|
+
enabled: true,
|
|
98
|
+
required_env: ["MANDATORY_SECRET_MISSING"]
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const config = ConfigSchema.parse(rawConfig);
|
|
104
|
+
|
|
105
|
+
// Create a file that would fail max_file_lines
|
|
106
|
+
await fs.writeFile(path.join(testDir, 'large.js'), 'line1\nline2\nline3');
|
|
107
|
+
|
|
108
|
+
const runner = new GateRunner(config);
|
|
109
|
+
const report = await runner.run(testDir);
|
|
110
|
+
|
|
111
|
+
// In a real priority system, we might want to stop after environment failure.
|
|
112
|
+
// Currently GateRunner runs all, but environment-alignment has been unshifted.
|
|
113
|
+
expect(Object.keys(report.summary)[0]).toBe('environment-alignment');
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -15,8 +15,21 @@ export class PythonHandler extends ASTHandler {
|
|
|
15
15
|
const failures: Failure[] = [];
|
|
16
16
|
const scriptPath = path.join(__dirname, 'python_parser.py');
|
|
17
17
|
|
|
18
|
+
// Dynamic command detection for cross-platform support (Mac/Linux usually python3, Windows usually python)
|
|
19
|
+
let pythonCmd = 'python3';
|
|
18
20
|
try {
|
|
19
|
-
|
|
21
|
+
await execa('python3', ['--version']);
|
|
22
|
+
} catch (e) {
|
|
23
|
+
try {
|
|
24
|
+
await execa('python', ['--version']);
|
|
25
|
+
pythonCmd = 'python';
|
|
26
|
+
} catch (e2) {
|
|
27
|
+
// Both missing - handled by main catch
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const { stdout } = await execa(pythonCmd, [scriptPath], {
|
|
20
33
|
input: context.content,
|
|
21
34
|
cwd: context.cwd
|
|
22
35
|
});
|
package/src/gates/ast.ts
CHANGED
|
@@ -21,10 +21,14 @@ export class ASTGate extends Gate {
|
|
|
21
21
|
async run(context: GateContext): Promise<Failure[]> {
|
|
22
22
|
const failures: Failure[] = [];
|
|
23
23
|
|
|
24
|
+
const patterns = (context.patterns || ['**/*.{ts,js,tsx,jsx,py,go,rs,cs,java,rb,c,cpp,php,swift,kt}']).map(p => p.replace(/\\/g, '/'));
|
|
25
|
+
const ignore = (context.ignore || ['node_modules/**', 'dist/**', 'build/**', '**/*.test.*', '**/*.spec.*', '**/__pycache__/**']).map(p => p.replace(/\\/g, '/'));
|
|
26
|
+
const normalizedCwd = context.cwd.replace(/\\/g, '/');
|
|
27
|
+
|
|
24
28
|
// Find all supported files
|
|
25
|
-
const files = await globby(
|
|
26
|
-
cwd:
|
|
27
|
-
ignore:
|
|
29
|
+
const files = await globby(patterns, {
|
|
30
|
+
cwd: normalizedCwd,
|
|
31
|
+
ignore: ignore,
|
|
28
32
|
});
|
|
29
33
|
|
|
30
34
|
for (const file of files) {
|
package/src/gates/base.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
+
import { GoldenRecord } from '../services/context-engine.js';
|
|
1
2
|
import { Failure } from '../types/index.js';
|
|
2
3
|
|
|
3
4
|
export interface GateContext {
|
|
4
5
|
cwd: string;
|
|
6
|
+
record?: GoldenRecord;
|
|
7
|
+
ignore?: string[];
|
|
8
|
+
patterns?: string[];
|
|
5
9
|
}
|
|
6
10
|
|
|
7
11
|
export abstract class Gate {
|
|
@@ -9,10 +13,10 @@ export abstract class Gate {
|
|
|
9
13
|
|
|
10
14
|
abstract run(context: GateContext): Promise<Failure[]>;
|
|
11
15
|
|
|
12
|
-
protected createFailure(details: string, files?: string[], hint?: string): Failure {
|
|
16
|
+
protected createFailure(details: string, files?: string[], hint?: string, title?: string): Failure {
|
|
13
17
|
return {
|
|
14
18
|
id: this.id,
|
|
15
|
-
title: this.title,
|
|
19
|
+
title: title || this.title,
|
|
16
20
|
details,
|
|
17
21
|
files,
|
|
18
22
|
hint,
|
package/src/gates/content.ts
CHANGED
|
@@ -19,7 +19,11 @@ export class ContentGate extends Gate {
|
|
|
19
19
|
|
|
20
20
|
if (patterns.length === 0) return [];
|
|
21
21
|
|
|
22
|
-
const files = await FileScanner.findFiles({
|
|
22
|
+
const files = await FileScanner.findFiles({
|
|
23
|
+
cwd: context.cwd,
|
|
24
|
+
ignore: context.ignore,
|
|
25
|
+
patterns: context.patterns
|
|
26
|
+
});
|
|
23
27
|
const contents = await FileScanner.readFiles(context.cwd, files);
|
|
24
28
|
|
|
25
29
|
const violations: string[] = [];
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Gate, GateContext } from './base.js';
|
|
2
|
+
import { Failure, Gates } from '../types/index.js';
|
|
3
|
+
import { FileScanner } from '../utils/scanner.js';
|
|
4
|
+
import fs from 'fs-extra';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
|
|
7
|
+
export class ContextGate extends Gate {
|
|
8
|
+
constructor(private config: Gates) {
|
|
9
|
+
super('context-drift', 'Context Awareness & Drift Detection');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async run(context: GateContext): Promise<Failure[]> {
|
|
13
|
+
const failures: Failure[] = [];
|
|
14
|
+
const record = context.record;
|
|
15
|
+
if (!record || !this.config.context?.enabled) return [];
|
|
16
|
+
|
|
17
|
+
const files = await FileScanner.findFiles({ cwd: context.cwd });
|
|
18
|
+
const envAnchors = record.anchors.filter(a => a.type === 'env' && a.confidence >= 1);
|
|
19
|
+
|
|
20
|
+
for (const file of files) {
|
|
21
|
+
try {
|
|
22
|
+
const content = await fs.readFile(path.join(context.cwd, file), 'utf-8');
|
|
23
|
+
|
|
24
|
+
// 1. Detect Redundant Suffixes (The Golden Example)
|
|
25
|
+
this.checkEnvDrift(content, file, envAnchors, failures);
|
|
26
|
+
|
|
27
|
+
} catch (e) { }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return failures;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
private checkEnvDrift(content: string, file: string, anchors: any[], failures: Failure[]) {
|
|
34
|
+
// Find all environment variable accesses in the content
|
|
35
|
+
const matches = content.matchAll(/process\.env(?:\.([A-Z0-9_]+)|\[['"]([A-Z0-9_]+)['"]\])/g);
|
|
36
|
+
|
|
37
|
+
for (const match of matches) {
|
|
38
|
+
const accessedVar = match[1] || match[2];
|
|
39
|
+
|
|
40
|
+
for (const anchor of anchors) {
|
|
41
|
+
// If the accessed variable contains the anchor but is not equal to it,
|
|
42
|
+
// it's a potential "invented" redundancy (e.g. CORE_URL vs CORE_URL_PROD)
|
|
43
|
+
if (accessedVar !== anchor.id && accessedVar.includes(anchor.id)) {
|
|
44
|
+
const deviation = accessedVar.replace(anchor.id, '').replace(/^_|_$/, '');
|
|
45
|
+
|
|
46
|
+
failures.push(this.createFailure(
|
|
47
|
+
`Context Drift: Redundant variation '${accessedVar}' detected in ${file}.`,
|
|
48
|
+
[file],
|
|
49
|
+
`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.`
|
|
50
|
+
));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
package/src/gates/coverage.ts
CHANGED
|
@@ -60,7 +60,11 @@ export class CoverageGate extends Gate {
|
|
|
60
60
|
currentFile = line.substring(3);
|
|
61
61
|
results[currentFile] = { found: 0, hit: 0, isComplex: false };
|
|
62
62
|
} else if (line.startsWith('LF:')) {
|
|
63
|
-
|
|
63
|
+
const found = parseInt(line.substring(3));
|
|
64
|
+
results[currentFile].found = found;
|
|
65
|
+
// SME Logic: If a file has > 100 logical lines, it's considered "Complex"
|
|
66
|
+
// and triggers the higher (80%) coverage requirement.
|
|
67
|
+
if (found > 100) results[currentFile].isComplex = true;
|
|
64
68
|
} else if (line.startsWith('LH:')) {
|
|
65
69
|
results[currentFile].hit = parseInt(line.substring(3));
|
|
66
70
|
}
|
package/src/gates/dependency.ts
CHANGED
|
@@ -32,13 +32,77 @@ export class DependencyGate extends Gate {
|
|
|
32
32
|
failures.push(this.createFailure(
|
|
33
33
|
`The package '${dep}' is forbidden by project standards.`,
|
|
34
34
|
['package.json'],
|
|
35
|
-
`Remove '${dep}' from package.json and use approved alternatives
|
|
35
|
+
`Remove '${dep}' from package.json and use approved alternatives.`,
|
|
36
|
+
'Forbidden Dependency'
|
|
36
37
|
));
|
|
37
38
|
}
|
|
38
39
|
}
|
|
39
40
|
} catch (e) { }
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
// 2. Scan Python (requirements.txt, pyproject.toml)
|
|
44
|
+
const reqPath = path.join(cwd, 'requirements.txt');
|
|
45
|
+
if (await fs.pathExists(reqPath)) {
|
|
46
|
+
const content = await fs.readFile(reqPath, 'utf-8');
|
|
47
|
+
for (const dep of forbidden) {
|
|
48
|
+
if (new RegExp(`^${dep}([=<>! ]|$)`, 'm').test(content)) {
|
|
49
|
+
failures.push(this.createFailure(
|
|
50
|
+
`The Python package '${dep}' is forbidden.`,
|
|
51
|
+
['requirements.txt'],
|
|
52
|
+
`Remove '${dep}' from requirements.txt.`,
|
|
53
|
+
'Forbidden Dependency'
|
|
54
|
+
));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const pyprojPath = path.join(cwd, 'pyproject.toml');
|
|
60
|
+
if (await fs.pathExists(pyprojPath)) {
|
|
61
|
+
const content = await fs.readFile(pyprojPath, 'utf-8');
|
|
62
|
+
for (const dep of forbidden) {
|
|
63
|
+
if (new RegExp(`^${dep}\\s*=`, 'm').test(content)) {
|
|
64
|
+
failures.push(this.createFailure(
|
|
65
|
+
`The Python package '${dep}' is forbidden in pyproject.toml.`,
|
|
66
|
+
['pyproject.toml'],
|
|
67
|
+
`Remove '${dep}' from pyproject.toml dependencies.`,
|
|
68
|
+
'Forbidden Dependency'
|
|
69
|
+
));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 3. Scan Go (go.mod)
|
|
75
|
+
const goModPath = path.join(cwd, 'go.mod');
|
|
76
|
+
if (await fs.pathExists(goModPath)) {
|
|
77
|
+
const content = await fs.readFile(goModPath, 'utf-8');
|
|
78
|
+
for (const dep of forbidden) {
|
|
79
|
+
if (content.includes(dep)) {
|
|
80
|
+
failures.push(this.createFailure(
|
|
81
|
+
`The Go module '${dep}' is forbidden.`,
|
|
82
|
+
['go.mod'],
|
|
83
|
+
`Remove '${dep}' from go.mod.`,
|
|
84
|
+
'Forbidden Dependency'
|
|
85
|
+
));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 4. Scan Java (pom.xml)
|
|
91
|
+
const pomPath = path.join(cwd, 'pom.xml');
|
|
92
|
+
if (await fs.pathExists(pomPath)) {
|
|
93
|
+
const content = await fs.readFile(pomPath, 'utf-8');
|
|
94
|
+
for (const dep of forbidden) {
|
|
95
|
+
if (content.includes(`<artifactId>${dep}</artifactId>`)) {
|
|
96
|
+
failures.push(this.createFailure(
|
|
97
|
+
`The Java artifact '${dep}' is forbidden.`,
|
|
98
|
+
['pom.xml'],
|
|
99
|
+
`Remove '${dep}' from pom.xml.`,
|
|
100
|
+
'Forbidden Dependency'
|
|
101
|
+
));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
42
106
|
return failures;
|
|
43
107
|
}
|
|
44
108
|
}
|
|
@@ -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';
|
package/src/gates/safety.ts
CHANGED
|
@@ -19,15 +19,22 @@ export class SafetyGate extends Gate {
|
|
|
19
19
|
// This is a "Safety Rail" - if an agent touched these, we fail.
|
|
20
20
|
const { stdout } = await execa('git', ['status', '--porcelain'], { cwd: context.cwd });
|
|
21
21
|
const modifiedFiles = stdout.split('\n')
|
|
22
|
-
.filter(line =>
|
|
23
|
-
|
|
22
|
+
.filter(line => {
|
|
23
|
+
const status = line.slice(0, 2);
|
|
24
|
+
// M: Modified, A: Added (staged), D: Deleted, R: Renamed
|
|
25
|
+
// We ignore ?? (Untracked) to allow rigour init and new doc creation
|
|
26
|
+
return /M|A|D|R/.test(status);
|
|
27
|
+
})
|
|
28
|
+
.map(line => line.slice(3).trim());
|
|
24
29
|
|
|
25
30
|
for (const file of modifiedFiles) {
|
|
26
31
|
if (this.isProtected(file, protectedPaths)) {
|
|
32
|
+
const message = `Protected file '${file}' was modified.`;
|
|
27
33
|
failures.push(this.createFailure(
|
|
28
|
-
|
|
34
|
+
message,
|
|
29
35
|
[file],
|
|
30
|
-
`Agents are forbidden from modifying files in ${protectedPaths.join(', ')}
|
|
36
|
+
`Agents are forbidden from modifying files in ${protectedPaths.join(', ')}.`,
|
|
37
|
+
message
|
|
31
38
|
));
|
|
32
39
|
}
|
|
33
40
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { SafetyGate } from './gates/safety.js';
|
|
3
|
+
import { Gates } from './types/index.js';
|
|
4
|
+
import { execa } from 'execa';
|
|
5
|
+
|
|
6
|
+
vi.mock('execa');
|
|
7
|
+
|
|
8
|
+
describe('SafetyGate', () => {
|
|
9
|
+
const config: Gates = {
|
|
10
|
+
safety: {
|
|
11
|
+
protected_paths: ['docs/'],
|
|
12
|
+
max_files_changed_per_cycle: 10
|
|
13
|
+
}
|
|
14
|
+
} as any;
|
|
15
|
+
|
|
16
|
+
it('should flag modified (M) protected files', async () => {
|
|
17
|
+
const gate = new SafetyGate(config);
|
|
18
|
+
vi.mocked(execa).mockResolvedValueOnce({ stdout: ' M docs/SPEC.md\n' } as any);
|
|
19
|
+
|
|
20
|
+
const failures = await gate.run({ cwd: '/test', record: {} as any });
|
|
21
|
+
expect(failures).toHaveLength(1);
|
|
22
|
+
expect(failures[0].title).toContain("Protected file 'docs/SPEC.md' was modified.");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('should flag added (A) protected files', async () => {
|
|
26
|
+
const gate = new SafetyGate(config);
|
|
27
|
+
vi.mocked(execa).mockResolvedValueOnce({ stdout: 'A docs/NEW.md\n' } as any);
|
|
28
|
+
|
|
29
|
+
const failures = await gate.run({ cwd: '/test', record: {} as any });
|
|
30
|
+
expect(failures).toHaveLength(1);
|
|
31
|
+
expect(failures[0].title).toContain("Protected file 'docs/NEW.md' was modified.");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('should NOT flag untracked (??) protected files', async () => {
|
|
35
|
+
const gate = new SafetyGate(config);
|
|
36
|
+
vi.mocked(execa).mockResolvedValueOnce({ stdout: '?? docs/UNTRAKED.md\n' } as any);
|
|
37
|
+
|
|
38
|
+
const failures = await gate.run({ cwd: '/test', record: {} as any });
|
|
39
|
+
expect(failures).toHaveLength(0);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should correctly handle multiple mixed statuses', async () => {
|
|
43
|
+
const gate = new SafetyGate(config);
|
|
44
|
+
vi.mocked(execa).mockResolvedValueOnce({
|
|
45
|
+
stdout: ' M docs/MODIFIED.md\n?? docs/NEW_UNTRACKED.md\n D docs/DELETED.md\n'
|
|
46
|
+
} as any);
|
|
47
|
+
|
|
48
|
+
const failures = await gate.run({ cwd: '/test', record: {} as any });
|
|
49
|
+
expect(failures).toHaveLength(2);
|
|
50
|
+
expect(failures.map(f => f.title)).toContain("Protected file 'docs/MODIFIED.md' was modified.");
|
|
51
|
+
expect(failures.map(f => f.title)).toContain("Protected file 'docs/DELETED.md' was modified.");
|
|
52
|
+
});
|
|
53
|
+
});
|