@rigour-labs/core 1.7.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/discovery.js +12 -19
- package/dist/environment.test.d.ts +1 -0
- package/dist/environment.test.js +97 -0
- package/dist/gates/ast-handlers/base.d.ts +12 -0
- package/dist/gates/ast-handlers/base.js +6 -0
- package/dist/gates/ast-handlers/python.d.ts +6 -0
- package/dist/gates/ast-handlers/python.js +64 -0
- package/dist/gates/ast-handlers/typescript.d.ts +9 -0
- package/dist/gates/ast-handlers/typescript.js +110 -0
- package/dist/gates/ast-handlers/universal.d.ts +8 -0
- package/dist/gates/ast-handlers/universal.js +156 -0
- package/dist/gates/ast.d.ts +1 -3
- package/dist/gates/ast.js +34 -110
- package/dist/gates/base.d.ts +4 -0
- package/dist/gates/base.js +1 -5
- package/dist/gates/content.js +9 -9
- package/dist/gates/context.d.ts +8 -0
- package/dist/gates/context.js +43 -0
- package/dist/gates/coverage.d.ts +8 -0
- package/dist/gates/coverage.js +62 -0
- package/dist/gates/dependency.js +7 -14
- package/dist/gates/environment.d.ts +8 -0
- package/dist/gates/environment.js +73 -0
- package/dist/gates/file.js +9 -9
- package/dist/gates/runner.d.ts +1 -1
- package/dist/gates/runner.js +41 -24
- package/dist/gates/safety.js +4 -8
- package/dist/gates/structure.js +6 -13
- package/dist/index.js +8 -26
- package/dist/services/context-engine.d.ts +22 -0
- package/dist/services/context-engine.js +78 -0
- package/dist/services/fix-packet-service.js +3 -7
- package/dist/services/state-service.js +9 -16
- package/dist/smoke.test.js +6 -8
- package/dist/templates/index.js +16 -6
- package/dist/types/fix-packet.js +22 -25
- package/dist/types/index.d.ts +151 -4
- package/dist/types/index.js +67 -56
- package/dist/utils/logger.js +8 -15
- package/dist/utils/scanner.js +13 -16
- package/package.json +6 -2
- package/src/context.test.ts +73 -0
- package/src/environment.test.ts +115 -0
- package/src/gates/ast-handlers/base.ts +13 -0
- package/src/gates/ast-handlers/python.ts +71 -0
- package/src/gates/ast-handlers/python_parser.py +60 -0
- package/src/gates/ast-handlers/typescript.ts +125 -0
- package/src/gates/ast-handlers/universal.ts +184 -0
- package/src/gates/ast.ts +32 -128
- package/src/gates/base.ts +4 -0
- package/src/gates/content.ts +5 -1
- package/src/gates/context.ts +55 -0
- package/src/gates/coverage.ts +70 -0
- package/src/gates/environment.ts +94 -0
- package/src/gates/file.ts +5 -1
- package/src/gates/runner.ts +27 -2
- package/src/services/context-engine.ts +104 -0
- package/src/templates/index.ts +13 -0
- package/src/types/index.ts +18 -0
- package/src/utils/scanner.ts +9 -4
package/dist/types/index.js
CHANGED
|
@@ -1,73 +1,84 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
forbid_fixme: zod_1.z.boolean().optional().default(true),
|
|
9
|
-
forbid_paths: zod_1.z.array(zod_1.z.string()).optional().default([]),
|
|
10
|
-
required_files: zod_1.z.array(zod_1.z.string()).optional().default([
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export const GatesSchema = z.object({
|
|
3
|
+
max_file_lines: z.number().optional().default(500),
|
|
4
|
+
forbid_todos: z.boolean().optional().default(true),
|
|
5
|
+
forbid_fixme: z.boolean().optional().default(true),
|
|
6
|
+
forbid_paths: z.array(z.string()).optional().default([]),
|
|
7
|
+
required_files: z.array(z.string()).optional().default([
|
|
11
8
|
'docs/SPEC.md',
|
|
12
9
|
'docs/ARCH.md',
|
|
13
10
|
'docs/DECISIONS.md',
|
|
14
11
|
'docs/TASKS.md',
|
|
15
12
|
]),
|
|
16
|
-
ast:
|
|
17
|
-
complexity:
|
|
18
|
-
max_methods:
|
|
19
|
-
max_params:
|
|
20
|
-
max_nesting:
|
|
21
|
-
max_inheritance_depth:
|
|
22
|
-
max_class_dependencies:
|
|
23
|
-
max_function_lines:
|
|
13
|
+
ast: z.object({
|
|
14
|
+
complexity: z.number().optional().default(10),
|
|
15
|
+
max_methods: z.number().optional().default(10),
|
|
16
|
+
max_params: z.number().optional().default(5),
|
|
17
|
+
max_nesting: z.number().optional().default(4),
|
|
18
|
+
max_inheritance_depth: z.number().optional().default(3),
|
|
19
|
+
max_class_dependencies: z.number().optional().default(5),
|
|
20
|
+
max_function_lines: z.number().optional().default(50),
|
|
24
21
|
}).optional().default({}),
|
|
25
|
-
dependencies:
|
|
26
|
-
forbid:
|
|
27
|
-
trusted_registry:
|
|
22
|
+
dependencies: z.object({
|
|
23
|
+
forbid: z.array(z.string()).optional().default([]),
|
|
24
|
+
trusted_registry: z.string().optional(),
|
|
28
25
|
}).optional().default({}),
|
|
29
|
-
architecture:
|
|
30
|
-
boundaries:
|
|
31
|
-
from:
|
|
32
|
-
to:
|
|
33
|
-
mode:
|
|
26
|
+
architecture: z.object({
|
|
27
|
+
boundaries: z.array(z.object({
|
|
28
|
+
from: z.string(),
|
|
29
|
+
to: z.string(),
|
|
30
|
+
mode: z.enum(['allow', 'deny']).default('deny'),
|
|
34
31
|
})).optional().default([]),
|
|
35
32
|
}).optional().default({}),
|
|
36
|
-
safety:
|
|
37
|
-
max_files_changed_per_cycle:
|
|
38
|
-
protected_paths:
|
|
33
|
+
safety: z.object({
|
|
34
|
+
max_files_changed_per_cycle: z.number().optional().default(10),
|
|
35
|
+
protected_paths: z.array(z.string()).optional().default(['.github/**', 'docs/**', 'rigour.yml']),
|
|
36
|
+
}).optional().default({}),
|
|
37
|
+
context: z.object({
|
|
38
|
+
enabled: z.boolean().optional().default(true),
|
|
39
|
+
sensitivity: z.number().min(0).max(1).optional().default(0.8), // 0.8 correlation threshold
|
|
40
|
+
mining_depth: z.number().optional().default(100), // Number of files to sample
|
|
41
|
+
ignored_patterns: z.array(z.string()).optional().default([]),
|
|
42
|
+
}).optional().default({}),
|
|
43
|
+
environment: z.object({
|
|
44
|
+
enabled: z.boolean().optional().default(true),
|
|
45
|
+
enforce_contracts: z.boolean().optional().default(true), // Auto-discovery of versions from truth sources
|
|
46
|
+
tools: z.record(z.string()).optional().default({}), // Explicit overrides
|
|
47
|
+
required_env: z.array(z.string()).optional().default([]),
|
|
39
48
|
}).optional().default({}),
|
|
40
49
|
});
|
|
41
|
-
|
|
42
|
-
format:
|
|
43
|
-
lint:
|
|
44
|
-
typecheck:
|
|
45
|
-
test:
|
|
50
|
+
export const CommandsSchema = z.object({
|
|
51
|
+
format: z.string().optional(),
|
|
52
|
+
lint: z.string().optional(),
|
|
53
|
+
typecheck: z.string().optional(),
|
|
54
|
+
test: z.string().optional(),
|
|
46
55
|
});
|
|
47
|
-
|
|
48
|
-
version:
|
|
49
|
-
preset:
|
|
50
|
-
paradigm:
|
|
51
|
-
commands:
|
|
52
|
-
gates:
|
|
53
|
-
output:
|
|
54
|
-
report_path:
|
|
56
|
+
export const ConfigSchema = z.object({
|
|
57
|
+
version: z.number().default(1),
|
|
58
|
+
preset: z.string().optional(),
|
|
59
|
+
paradigm: z.string().optional(),
|
|
60
|
+
commands: CommandsSchema.optional().default({}),
|
|
61
|
+
gates: GatesSchema.optional().default({}),
|
|
62
|
+
output: z.object({
|
|
63
|
+
report_path: z.string().default('rigour-report.json'),
|
|
55
64
|
}).optional().default({}),
|
|
56
|
-
planned:
|
|
65
|
+
planned: z.array(z.string()).optional().default([]),
|
|
66
|
+
ignore: z.array(z.string()).optional().default([]),
|
|
57
67
|
});
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
id:
|
|
61
|
-
title:
|
|
62
|
-
details:
|
|
63
|
-
files:
|
|
64
|
-
hint:
|
|
68
|
+
export const StatusSchema = z.enum(['PASS', 'FAIL', 'SKIP', 'ERROR']);
|
|
69
|
+
export const FailureSchema = z.object({
|
|
70
|
+
id: z.string(),
|
|
71
|
+
title: z.string(),
|
|
72
|
+
details: z.string(),
|
|
73
|
+
files: z.array(z.string()).optional(),
|
|
74
|
+
hint: z.string().optional(),
|
|
65
75
|
});
|
|
66
|
-
|
|
67
|
-
status:
|
|
68
|
-
summary:
|
|
69
|
-
failures:
|
|
70
|
-
stats:
|
|
71
|
-
duration_ms:
|
|
76
|
+
export const ReportSchema = z.object({
|
|
77
|
+
status: StatusSchema,
|
|
78
|
+
summary: z.record(StatusSchema),
|
|
79
|
+
failures: z.array(FailureSchema),
|
|
80
|
+
stats: z.object({
|
|
81
|
+
duration_ms: z.number(),
|
|
82
|
+
score: z.number().optional(),
|
|
72
83
|
}),
|
|
73
84
|
});
|
package/dist/utils/logger.js
CHANGED
|
@@ -1,35 +1,29 @@
|
|
|
1
|
-
|
|
2
|
-
var
|
|
3
|
-
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
-
};
|
|
5
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.Logger = exports.LogLevel = void 0;
|
|
7
|
-
const chalk_1 = __importDefault(require("chalk"));
|
|
8
|
-
var LogLevel;
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
export var LogLevel;
|
|
9
3
|
(function (LogLevel) {
|
|
10
4
|
LogLevel[LogLevel["DEBUG"] = 0] = "DEBUG";
|
|
11
5
|
LogLevel[LogLevel["INFO"] = 1] = "INFO";
|
|
12
6
|
LogLevel[LogLevel["WARN"] = 2] = "WARN";
|
|
13
7
|
LogLevel[LogLevel["ERROR"] = 3] = "ERROR";
|
|
14
|
-
})(LogLevel || (
|
|
15
|
-
class Logger {
|
|
8
|
+
})(LogLevel || (LogLevel = {}));
|
|
9
|
+
export class Logger {
|
|
16
10
|
static level = LogLevel.INFO;
|
|
17
11
|
static setLevel(level) {
|
|
18
12
|
this.level = level;
|
|
19
13
|
}
|
|
20
14
|
static info(message) {
|
|
21
15
|
if (this.level <= LogLevel.INFO) {
|
|
22
|
-
console.log(
|
|
16
|
+
console.log(chalk.blue('info: ') + message);
|
|
23
17
|
}
|
|
24
18
|
}
|
|
25
19
|
static warn(message) {
|
|
26
20
|
if (this.level <= LogLevel.WARN) {
|
|
27
|
-
console.log(
|
|
21
|
+
console.log(chalk.yellow('warn: ') + message);
|
|
28
22
|
}
|
|
29
23
|
}
|
|
30
24
|
static error(message, error) {
|
|
31
25
|
if (this.level <= LogLevel.ERROR) {
|
|
32
|
-
console.error(
|
|
26
|
+
console.error(chalk.red('error: ') + message);
|
|
33
27
|
if (error) {
|
|
34
28
|
console.error(error);
|
|
35
29
|
}
|
|
@@ -37,8 +31,7 @@ class Logger {
|
|
|
37
31
|
}
|
|
38
32
|
static debug(message) {
|
|
39
33
|
if (this.level <= LogLevel.DEBUG) {
|
|
40
|
-
console.log(
|
|
34
|
+
console.log(chalk.dim('debug: ') + message);
|
|
41
35
|
}
|
|
42
36
|
}
|
|
43
37
|
}
|
|
44
|
-
exports.Logger = Logger;
|
package/dist/utils/scanner.js
CHANGED
|
@@ -1,13 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.FileScanner = void 0;
|
|
7
|
-
const globby_1 = require("globby");
|
|
8
|
-
const fs_extra_1 = __importDefault(require("fs-extra"));
|
|
9
|
-
const path_1 = __importDefault(require("path"));
|
|
10
|
-
class FileScanner {
|
|
1
|
+
import { globby } from 'globby';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
export class FileScanner {
|
|
11
5
|
static DEFAULT_PATTERNS = ['**/*.{ts,js,py,css,html,md}'];
|
|
12
6
|
static DEFAULT_IGNORE = [
|
|
13
7
|
'**/node_modules/**',
|
|
@@ -18,18 +12,21 @@ class FileScanner {
|
|
|
18
12
|
'rigour-report.json'
|
|
19
13
|
];
|
|
20
14
|
static async findFiles(options) {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
15
|
+
const patterns = (options.patterns || this.DEFAULT_PATTERNS).map(p => p.replace(/\\/g, '/'));
|
|
16
|
+
const ignore = (options.ignore || this.DEFAULT_IGNORE).map(p => p.replace(/\\/g, '/'));
|
|
17
|
+
const normalizedCwd = options.cwd.replace(/\\/g, '/');
|
|
18
|
+
return globby(patterns, {
|
|
19
|
+
cwd: normalizedCwd,
|
|
20
|
+
ignore: ignore,
|
|
24
21
|
});
|
|
25
22
|
}
|
|
26
23
|
static async readFiles(cwd, files) {
|
|
27
24
|
const contents = new Map();
|
|
28
25
|
for (const file of files) {
|
|
29
|
-
const
|
|
30
|
-
|
|
26
|
+
const normalizedFile = file.replace(/\//g, path.sep);
|
|
27
|
+
const filePath = path.isAbsolute(normalizedFile) ? normalizedFile : path.join(cwd, normalizedFile);
|
|
28
|
+
contents.set(file, await fs.readFile(filePath, 'utf-8'));
|
|
31
29
|
}
|
|
32
30
|
return contents;
|
|
33
31
|
}
|
|
34
32
|
}
|
|
35
|
-
exports.FileScanner = FileScanner;
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rigour-labs/core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "2.1.0",
|
|
4
|
+
"type": "module",
|
|
4
5
|
"main": "dist/index.js",
|
|
5
6
|
"types": "dist/index.d.ts",
|
|
6
7
|
"repository": {
|
|
@@ -17,14 +18,17 @@
|
|
|
17
18
|
"fs-extra": "^11.3.3",
|
|
18
19
|
"globby": "^14.1.0",
|
|
19
20
|
"micromatch": "^4.0.8",
|
|
21
|
+
"semver": "^7.7.3",
|
|
20
22
|
"typescript": "^5.9.3",
|
|
23
|
+
"web-tree-sitter": "^0.26.3",
|
|
21
24
|
"yaml": "^2.3.4",
|
|
22
25
|
"zod": "^3.22.4"
|
|
23
26
|
},
|
|
24
27
|
"devDependencies": {
|
|
25
28
|
"@types/fs-extra": "^11.0.4",
|
|
26
29
|
"@types/micromatch": "^4.0.10",
|
|
27
|
-
"@types/node": "^25.0.3"
|
|
30
|
+
"@types/node": "^25.0.3",
|
|
31
|
+
"@types/semver": "^7.7.1"
|
|
28
32
|
},
|
|
29
33
|
"scripts": {
|
|
30
34
|
"build": "tsc",
|
|
@@ -0,0 +1,73 @@
|
|
|
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
|
+
|
|
7
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const TEST_CWD = path.join(__dirname, '../temp-test-context');
|
|
9
|
+
|
|
10
|
+
describe('Context Awareness Engine', () => {
|
|
11
|
+
beforeAll(async () => {
|
|
12
|
+
await fs.ensureDir(TEST_CWD);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterAll(async () => {
|
|
16
|
+
await fs.remove(TEST_CWD);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('should detect context drift for redundant env suffixes (Golden Example)', async () => {
|
|
20
|
+
// Setup: Define standard GCP_PROJECT_ID
|
|
21
|
+
await fs.writeFile(path.join(TEST_CWD, '.env.example'), 'GCP_PROJECT_ID=my-project\n');
|
|
22
|
+
|
|
23
|
+
// Setup: Use drifted GCP_PROJECT_ID_PRODUCTION
|
|
24
|
+
await fs.writeFile(path.join(TEST_CWD, 'feature.js'), `
|
|
25
|
+
const id = process.env.GCP_PROJECT_ID_PRODUCTION;
|
|
26
|
+
console.log(id);
|
|
27
|
+
`);
|
|
28
|
+
|
|
29
|
+
const config = {
|
|
30
|
+
version: 1,
|
|
31
|
+
commands: {},
|
|
32
|
+
gates: {
|
|
33
|
+
context: {
|
|
34
|
+
enabled: true,
|
|
35
|
+
sensitivity: 0.8,
|
|
36
|
+
mining_depth: 10,
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
output: { report_path: 'rigour-report.json' }
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const runner = new GateRunner(config as any);
|
|
43
|
+
const report = await runner.run(TEST_CWD);
|
|
44
|
+
|
|
45
|
+
const driftFailures = report.failures.filter(f => f.id === 'context-drift');
|
|
46
|
+
expect(driftFailures.length).toBeGreaterThan(0);
|
|
47
|
+
expect(driftFailures[0].details).toContain('GCP_PROJECT_ID_PRODUCTION');
|
|
48
|
+
expect(driftFailures[0].hint).toContain('GCP_PROJECT_ID');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should not flag valid environment variables', async () => {
|
|
52
|
+
await fs.writeFile(path.join(TEST_CWD, 'valid.js'), `
|
|
53
|
+
const id = process.env.GCP_PROJECT_ID;
|
|
54
|
+
`);
|
|
55
|
+
|
|
56
|
+
const config = {
|
|
57
|
+
version: 1,
|
|
58
|
+
commands: {},
|
|
59
|
+
gates: {
|
|
60
|
+
context: { enabled: true },
|
|
61
|
+
},
|
|
62
|
+
output: { report_path: 'rigour-report.json' }
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const runner = new GateRunner(config as any);
|
|
66
|
+
const report = await runner.run(TEST_CWD);
|
|
67
|
+
|
|
68
|
+
const driftFailures = report.failures.filter(f => f.id === 'context-drift');
|
|
69
|
+
// Filter out failures from other files if they still exist in TEST_CWD
|
|
70
|
+
const specificFailures = driftFailures.filter(f => f.files?.includes('valid.js'));
|
|
71
|
+
expect(specificFailures.length).toBe(0);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Failure, Gates } from '../../types/index.js';
|
|
2
|
+
|
|
3
|
+
export interface ASTHandlerContext {
|
|
4
|
+
cwd: string;
|
|
5
|
+
file: string;
|
|
6
|
+
content: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export abstract class ASTHandler {
|
|
10
|
+
constructor(protected config: Gates) { }
|
|
11
|
+
abstract supports(file: string): boolean;
|
|
12
|
+
abstract run(context: ASTHandlerContext): Promise<Failure[]>;
|
|
13
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { execa } from 'execa';
|
|
2
|
+
import { ASTHandler, ASTHandlerContext } from './base.js';
|
|
3
|
+
import { Failure } from '../../types/index.js';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
|
|
7
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
|
|
9
|
+
export class PythonHandler extends ASTHandler {
|
|
10
|
+
supports(file: string): boolean {
|
|
11
|
+
return /\.py$/.test(file);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async run(context: ASTHandlerContext): Promise<Failure[]> {
|
|
15
|
+
const failures: Failure[] = [];
|
|
16
|
+
const scriptPath = path.join(__dirname, 'python_parser.py');
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
const { stdout } = await execa('python3', [scriptPath], {
|
|
20
|
+
input: context.content,
|
|
21
|
+
cwd: context.cwd
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const metrics = JSON.parse(stdout);
|
|
25
|
+
if (metrics.error) return [];
|
|
26
|
+
|
|
27
|
+
const astConfig = this.config.ast || {};
|
|
28
|
+
const maxComplexity = astConfig.complexity || 10;
|
|
29
|
+
const maxParams = astConfig.max_params || 5;
|
|
30
|
+
const maxMethods = astConfig.max_methods || 10;
|
|
31
|
+
|
|
32
|
+
for (const item of metrics) {
|
|
33
|
+
if (item.type === 'function') {
|
|
34
|
+
if (item.parameters > maxParams) {
|
|
35
|
+
failures.push({
|
|
36
|
+
id: 'AST_MAX_PARAMS',
|
|
37
|
+
title: `Function '${item.name}' has ${item.parameters} parameters (max: ${maxParams})`,
|
|
38
|
+
details: `High parameter count detected in ${context.file} at line ${item.lineno}`,
|
|
39
|
+
files: [context.file],
|
|
40
|
+
hint: `Reduce number of parameters or use an options object.`
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
if (item.complexity > maxComplexity) {
|
|
44
|
+
failures.push({
|
|
45
|
+
id: 'AST_COMPLEXITY',
|
|
46
|
+
title: `Function '${item.name}' has complexity of ${item.complexity} (max: ${maxComplexity})`,
|
|
47
|
+
details: `High complexity detected in ${context.file} at line ${item.lineno}`,
|
|
48
|
+
files: [context.file],
|
|
49
|
+
hint: `Refactor '${item.name}' into smaller, more focused functions.`
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
} else if (item.type === 'class') {
|
|
53
|
+
if (item.methods > maxMethods) {
|
|
54
|
+
failures.push({
|
|
55
|
+
id: 'AST_MAX_METHODS',
|
|
56
|
+
title: `Class '${item.name}' has ${item.methods} methods (max: ${maxMethods})`,
|
|
57
|
+
details: `God Object pattern detected in ${context.file} at line ${item.lineno}`,
|
|
58
|
+
files: [context.file],
|
|
59
|
+
hint: `Split class '${item.name}' into smaller services.`
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
} catch (e: any) {
|
|
66
|
+
// If python3 is missing, we skip AST but other gates still run
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return failures;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
import sys
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
class MetricsVisitor(ast.NodeVisitor):
|
|
6
|
+
def __init__(self):
|
|
7
|
+
self.metrics = []
|
|
8
|
+
|
|
9
|
+
def visit_FunctionDef(self, node):
|
|
10
|
+
self.analyze_function(node)
|
|
11
|
+
self.generic_visit(node)
|
|
12
|
+
|
|
13
|
+
def visit_AsyncFunctionDef(self, node):
|
|
14
|
+
self.analyze_function(node)
|
|
15
|
+
self.generic_visit(node)
|
|
16
|
+
|
|
17
|
+
def visit_ClassDef(self, node):
|
|
18
|
+
methods = [n for n in node.body if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))]
|
|
19
|
+
self.metrics.append({
|
|
20
|
+
"type": "class",
|
|
21
|
+
"name": node.name,
|
|
22
|
+
"methods": len(methods),
|
|
23
|
+
"lineno": node.lineno
|
|
24
|
+
})
|
|
25
|
+
self.generic_visit(node)
|
|
26
|
+
|
|
27
|
+
def analyze_function(self, node):
|
|
28
|
+
complexity = 1
|
|
29
|
+
for n in ast.walk(node):
|
|
30
|
+
if isinstance(n, (ast.If, ast.While, ast.For, ast.AsyncFor, ast.Try, ast.ExceptHandler, ast.With, ast.AsyncWith)):
|
|
31
|
+
complexity += 1
|
|
32
|
+
elif isinstance(n, ast.BoolOp):
|
|
33
|
+
complexity += len(n.values) - 1
|
|
34
|
+
elif isinstance(n, ast.IfExp):
|
|
35
|
+
complexity += 1
|
|
36
|
+
|
|
37
|
+
params = len(node.args.args) + len(node.args.kwonlyargs)
|
|
38
|
+
if node.args.vararg: params += 1
|
|
39
|
+
if node.args.kwarg: params += 1
|
|
40
|
+
|
|
41
|
+
self.metrics.append({
|
|
42
|
+
"type": "function",
|
|
43
|
+
"name": node.name,
|
|
44
|
+
"complexity": complexity,
|
|
45
|
+
"parameters": params,
|
|
46
|
+
"lineno": node.lineno
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
def analyze_code(content):
|
|
50
|
+
try:
|
|
51
|
+
tree = ast.parse(content)
|
|
52
|
+
visitor = MetricsVisitor()
|
|
53
|
+
visitor.visit(tree)
|
|
54
|
+
return visitor.metrics
|
|
55
|
+
except Exception as e:
|
|
56
|
+
return {"error": str(e)}
|
|
57
|
+
|
|
58
|
+
if __name__ == "__main__":
|
|
59
|
+
content = sys.stdin.read()
|
|
60
|
+
print(json.dumps(analyze_code(content)))
|