@rigour-labs/core 2.1.0 → 2.3.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/discovery.js +12 -7
- package/dist/discovery.test.d.ts +1 -0
- package/dist/discovery.test.js +57 -0
- package/dist/gates/ast-handlers/python.js +15 -1
- package/dist/gates/base.d.ts +1 -1
- package/dist/gates/base.js +2 -2
- package/dist/gates/coverage.js +6 -1
- package/dist/gates/dependency.js +40 -1
- package/dist/gates/safety.js +9 -3
- package/dist/safety.test.d.ts +1 -0
- package/dist/safety.test.js +42 -0
- package/dist/utils/scanner.js +2 -1
- package/dist/utils/scanner.test.d.ts +1 -0
- package/dist/utils/scanner.test.js +29 -0
- package/package.json +1 -1
- package/src/discovery.test.ts +61 -0
- package/src/discovery.ts +11 -6
- package/src/gates/ast-handlers/python.ts +14 -1
- package/src/gates/base.ts +2 -2
- package/src/gates/coverage.ts +5 -1
- package/src/gates/dependency.ts +65 -1
- package/src/gates/safety.ts +11 -4
- package/src/safety.test.ts +53 -0
- package/src/utils/scanner.test.ts +37 -0
- package/src/utils/scanner.ts +2 -1
package/dist/discovery.js
CHANGED
|
@@ -61,15 +61,20 @@ export class DiscoveryService {
|
|
|
61
61
|
return false;
|
|
62
62
|
}
|
|
63
63
|
async findSourceFiles(cwd) {
|
|
64
|
-
// Find a few files to sample
|
|
65
64
|
const extensions = ['.ts', '.js', '.py', '.go', '.java', '.tf', 'package.json'];
|
|
66
65
|
const samples = [];
|
|
67
|
-
const
|
|
68
|
-
for (const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
66
|
+
const commonDirs = ['.', 'src', 'app', 'lib', 'api', 'pkg'];
|
|
67
|
+
for (const dir of commonDirs) {
|
|
68
|
+
const fullDir = path.join(cwd, dir);
|
|
69
|
+
if (!(await fs.pathExists(fullDir)))
|
|
70
|
+
continue;
|
|
71
|
+
const files = await fs.readdir(fullDir);
|
|
72
|
+
for (const file of files) {
|
|
73
|
+
if (extensions.some(ext => file.endsWith(ext))) {
|
|
74
|
+
samples.push(path.join(fullDir, file));
|
|
75
|
+
if (samples.length >= 5)
|
|
76
|
+
return samples;
|
|
77
|
+
}
|
|
73
78
|
}
|
|
74
79
|
}
|
|
75
80
|
return samples;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { DiscoveryService } from './discovery.js';
|
|
3
|
+
import fs from 'fs-extra';
|
|
4
|
+
vi.mock('fs-extra');
|
|
5
|
+
describe('DiscoveryService', () => {
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
vi.resetAllMocks();
|
|
8
|
+
});
|
|
9
|
+
it('should discover project marker in root directory', async () => {
|
|
10
|
+
const service = new DiscoveryService();
|
|
11
|
+
vi.mocked(fs.pathExists).mockImplementation(async (p) => p.includes('package.json'));
|
|
12
|
+
vi.mocked(fs.readdir).mockResolvedValue(['package.json']);
|
|
13
|
+
vi.mocked(fs.readFile).mockResolvedValue('{}');
|
|
14
|
+
const result = await service.discover('/test');
|
|
15
|
+
// If package.json doesn't match a specific role marker, it stays Universal.
|
|
16
|
+
// Let's mock a specific one like 'express'
|
|
17
|
+
vi.mocked(fs.pathExists).mockImplementation(async (p) => p.includes('express'));
|
|
18
|
+
const result2 = await service.discover('/test');
|
|
19
|
+
expect(result2.matches.preset?.name).toBe('api');
|
|
20
|
+
});
|
|
21
|
+
it('should discover project marker in src/ directory (Deep Detection)', async () => {
|
|
22
|
+
const service = new DiscoveryService();
|
|
23
|
+
vi.mocked(fs.pathExists).mockImplementation((async (p) => {
|
|
24
|
+
if (p.endsWith('src'))
|
|
25
|
+
return true;
|
|
26
|
+
if (p.includes('src/index.ts'))
|
|
27
|
+
return true;
|
|
28
|
+
return false;
|
|
29
|
+
}));
|
|
30
|
+
vi.mocked(fs.readdir).mockImplementation((async (p) => {
|
|
31
|
+
if (p.toString().endsWith('/test'))
|
|
32
|
+
return ['src'];
|
|
33
|
+
if (p.toString().endsWith('src'))
|
|
34
|
+
return ['index.ts'];
|
|
35
|
+
return [];
|
|
36
|
+
}));
|
|
37
|
+
vi.mocked(fs.readFile).mockResolvedValue('export const x = 1;');
|
|
38
|
+
const result = await service.discover('/test');
|
|
39
|
+
// Since UNIVERSAL_CONFIG has a default, we check if it found something extra or matches expectation
|
|
40
|
+
// Default is universal, but detecting .ts should tilt it towards node or similar if configured
|
|
41
|
+
// In our current templates, package.json is the node marker.
|
|
42
|
+
// Let's check for paradigm detection which uses content
|
|
43
|
+
expect(result.config).toBeDefined();
|
|
44
|
+
});
|
|
45
|
+
it('should identify OOP paradigm from content in subfolder', async () => {
|
|
46
|
+
const service = new DiscoveryService();
|
|
47
|
+
vi.mocked(fs.pathExists).mockImplementation((async (p) => p.endsWith('src') || p.endsWith('src/Service.ts')));
|
|
48
|
+
vi.mocked(fs.readdir).mockImplementation((async (p) => {
|
|
49
|
+
if (p.toString().endsWith('src'))
|
|
50
|
+
return ['Service.ts'];
|
|
51
|
+
return ['src'];
|
|
52
|
+
}));
|
|
53
|
+
vi.mocked(fs.readFile).mockResolvedValue('class MyService {}');
|
|
54
|
+
const result = await service.discover('/test');
|
|
55
|
+
expect(result.matches.paradigm?.name).toBe('oop');
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -10,8 +10,22 @@ export class PythonHandler extends ASTHandler {
|
|
|
10
10
|
async run(context) {
|
|
11
11
|
const failures = [];
|
|
12
12
|
const scriptPath = path.join(__dirname, 'python_parser.py');
|
|
13
|
+
// Dynamic command detection for cross-platform support (Mac/Linux usually python3, Windows usually python)
|
|
14
|
+
let pythonCmd = 'python3';
|
|
13
15
|
try {
|
|
14
|
-
|
|
16
|
+
await execa('python3', ['--version']);
|
|
17
|
+
}
|
|
18
|
+
catch (e) {
|
|
19
|
+
try {
|
|
20
|
+
await execa('python', ['--version']);
|
|
21
|
+
pythonCmd = 'python';
|
|
22
|
+
}
|
|
23
|
+
catch (e2) {
|
|
24
|
+
// Both missing - handled by main catch
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
const { stdout } = await execa(pythonCmd, [scriptPath], {
|
|
15
29
|
input: context.content,
|
|
16
30
|
cwd: context.cwd
|
|
17
31
|
});
|
package/dist/gates/base.d.ts
CHANGED
|
@@ -11,5 +11,5 @@ export declare abstract class Gate {
|
|
|
11
11
|
readonly title: string;
|
|
12
12
|
constructor(id: string, title: string);
|
|
13
13
|
abstract run(context: GateContext): Promise<Failure[]>;
|
|
14
|
-
protected createFailure(details: string, files?: string[], hint?: string): Failure;
|
|
14
|
+
protected createFailure(details: string, files?: string[], hint?: string, title?: string): Failure;
|
|
15
15
|
}
|
package/dist/gates/base.js
CHANGED
|
@@ -5,10 +5,10 @@ export class Gate {
|
|
|
5
5
|
this.id = id;
|
|
6
6
|
this.title = title;
|
|
7
7
|
}
|
|
8
|
-
createFailure(details, files, hint) {
|
|
8
|
+
createFailure(details, files, hint, title) {
|
|
9
9
|
return {
|
|
10
10
|
id: this.id,
|
|
11
|
-
title: this.title,
|
|
11
|
+
title: title || this.title,
|
|
12
12
|
details,
|
|
13
13
|
files,
|
|
14
14
|
hint,
|
package/dist/gates/coverage.js
CHANGED
|
@@ -51,7 +51,12 @@ export class CoverageGate extends Gate {
|
|
|
51
51
|
results[currentFile] = { found: 0, hit: 0, isComplex: false };
|
|
52
52
|
}
|
|
53
53
|
else if (line.startsWith('LF:')) {
|
|
54
|
-
|
|
54
|
+
const found = parseInt(line.substring(3));
|
|
55
|
+
results[currentFile].found = found;
|
|
56
|
+
// SME Logic: If a file has > 100 logical lines, it's considered "Complex"
|
|
57
|
+
// and triggers the higher (80%) coverage requirement.
|
|
58
|
+
if (found > 100)
|
|
59
|
+
results[currentFile].isComplex = true;
|
|
55
60
|
}
|
|
56
61
|
else if (line.startsWith('LH:')) {
|
|
57
62
|
results[currentFile].hit = parseInt(line.substring(3));
|
package/dist/gates/dependency.js
CHANGED
|
@@ -25,12 +25,51 @@ export class DependencyGate extends Gate {
|
|
|
25
25
|
};
|
|
26
26
|
for (const dep of forbidden) {
|
|
27
27
|
if (allDeps[dep]) {
|
|
28
|
-
failures.push(this.createFailure(`The package '${dep}' is forbidden by project standards.`, ['package.json'], `Remove '${dep}' from package.json and use approved alternatives
|
|
28
|
+
failures.push(this.createFailure(`The package '${dep}' is forbidden by project standards.`, ['package.json'], `Remove '${dep}' from package.json and use approved alternatives.`, 'Forbidden Dependency'));
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
catch (e) { }
|
|
33
33
|
}
|
|
34
|
+
// 2. Scan Python (requirements.txt, pyproject.toml)
|
|
35
|
+
const reqPath = path.join(cwd, 'requirements.txt');
|
|
36
|
+
if (await fs.pathExists(reqPath)) {
|
|
37
|
+
const content = await fs.readFile(reqPath, 'utf-8');
|
|
38
|
+
for (const dep of forbidden) {
|
|
39
|
+
if (new RegExp(`^${dep}([=<>! ]|$)`, 'm').test(content)) {
|
|
40
|
+
failures.push(this.createFailure(`The Python package '${dep}' is forbidden.`, ['requirements.txt'], `Remove '${dep}' from requirements.txt.`, 'Forbidden Dependency'));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const pyprojPath = path.join(cwd, 'pyproject.toml');
|
|
45
|
+
if (await fs.pathExists(pyprojPath)) {
|
|
46
|
+
const content = await fs.readFile(pyprojPath, 'utf-8');
|
|
47
|
+
for (const dep of forbidden) {
|
|
48
|
+
if (new RegExp(`^${dep}\\s*=`, 'm').test(content)) {
|
|
49
|
+
failures.push(this.createFailure(`The Python package '${dep}' is forbidden in pyproject.toml.`, ['pyproject.toml'], `Remove '${dep}' from pyproject.toml dependencies.`, 'Forbidden Dependency'));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// 3. Scan Go (go.mod)
|
|
54
|
+
const goModPath = path.join(cwd, 'go.mod');
|
|
55
|
+
if (await fs.pathExists(goModPath)) {
|
|
56
|
+
const content = await fs.readFile(goModPath, 'utf-8');
|
|
57
|
+
for (const dep of forbidden) {
|
|
58
|
+
if (content.includes(dep)) {
|
|
59
|
+
failures.push(this.createFailure(`The Go module '${dep}' is forbidden.`, ['go.mod'], `Remove '${dep}' from go.mod.`, 'Forbidden Dependency'));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// 4. Scan Java (pom.xml)
|
|
64
|
+
const pomPath = path.join(cwd, 'pom.xml');
|
|
65
|
+
if (await fs.pathExists(pomPath)) {
|
|
66
|
+
const content = await fs.readFile(pomPath, 'utf-8');
|
|
67
|
+
for (const dep of forbidden) {
|
|
68
|
+
if (content.includes(`<artifactId>${dep}</artifactId>`)) {
|
|
69
|
+
failures.push(this.createFailure(`The Java artifact '${dep}' is forbidden.`, ['pom.xml'], `Remove '${dep}' from pom.xml.`, 'Forbidden Dependency'));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
34
73
|
return failures;
|
|
35
74
|
}
|
|
36
75
|
}
|
package/dist/gates/safety.js
CHANGED
|
@@ -17,11 +17,17 @@ export class SafetyGate extends Gate {
|
|
|
17
17
|
// This is a "Safety Rail" - if an agent touched these, we fail.
|
|
18
18
|
const { stdout } = await execa('git', ['status', '--porcelain'], { cwd: context.cwd });
|
|
19
19
|
const modifiedFiles = stdout.split('\n')
|
|
20
|
-
.filter(line =>
|
|
21
|
-
|
|
20
|
+
.filter(line => {
|
|
21
|
+
const status = line.slice(0, 2);
|
|
22
|
+
// M: Modified, A: Added (staged), D: Deleted, R: Renamed
|
|
23
|
+
// We ignore ?? (Untracked) to allow rigour init and new doc creation
|
|
24
|
+
return /M|A|D|R/.test(status);
|
|
25
|
+
})
|
|
26
|
+
.map(line => line.slice(3).trim());
|
|
22
27
|
for (const file of modifiedFiles) {
|
|
23
28
|
if (this.isProtected(file, protectedPaths)) {
|
|
24
|
-
|
|
29
|
+
const message = `Protected file '${file}' was modified.`;
|
|
30
|
+
failures.push(this.createFailure(message, [file], `Agents are forbidden from modifying files in ${protectedPaths.join(', ')}.`, message));
|
|
25
31
|
}
|
|
26
32
|
}
|
|
27
33
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { SafetyGate } from './gates/safety.js';
|
|
3
|
+
import { execa } from 'execa';
|
|
4
|
+
vi.mock('execa');
|
|
5
|
+
describe('SafetyGate', () => {
|
|
6
|
+
const config = {
|
|
7
|
+
safety: {
|
|
8
|
+
protected_paths: ['docs/'],
|
|
9
|
+
max_files_changed_per_cycle: 10
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
it('should flag modified (M) protected files', async () => {
|
|
13
|
+
const gate = new SafetyGate(config);
|
|
14
|
+
vi.mocked(execa).mockResolvedValueOnce({ stdout: ' M docs/SPEC.md\n' });
|
|
15
|
+
const failures = await gate.run({ cwd: '/test', record: {} });
|
|
16
|
+
expect(failures).toHaveLength(1);
|
|
17
|
+
expect(failures[0].title).toContain("Protected file 'docs/SPEC.md' was modified.");
|
|
18
|
+
});
|
|
19
|
+
it('should flag added (A) protected files', async () => {
|
|
20
|
+
const gate = new SafetyGate(config);
|
|
21
|
+
vi.mocked(execa).mockResolvedValueOnce({ stdout: 'A docs/NEW.md\n' });
|
|
22
|
+
const failures = await gate.run({ cwd: '/test', record: {} });
|
|
23
|
+
expect(failures).toHaveLength(1);
|
|
24
|
+
expect(failures[0].title).toContain("Protected file 'docs/NEW.md' was modified.");
|
|
25
|
+
});
|
|
26
|
+
it('should NOT flag untracked (??) protected files', async () => {
|
|
27
|
+
const gate = new SafetyGate(config);
|
|
28
|
+
vi.mocked(execa).mockResolvedValueOnce({ stdout: '?? docs/UNTRAKED.md\n' });
|
|
29
|
+
const failures = await gate.run({ cwd: '/test', record: {} });
|
|
30
|
+
expect(failures).toHaveLength(0);
|
|
31
|
+
});
|
|
32
|
+
it('should correctly handle multiple mixed statuses', async () => {
|
|
33
|
+
const gate = new SafetyGate(config);
|
|
34
|
+
vi.mocked(execa).mockResolvedValueOnce({
|
|
35
|
+
stdout: ' M docs/MODIFIED.md\n?? docs/NEW_UNTRACKED.md\n D docs/DELETED.md\n'
|
|
36
|
+
});
|
|
37
|
+
const failures = await gate.run({ cwd: '/test', record: {} });
|
|
38
|
+
expect(failures).toHaveLength(2);
|
|
39
|
+
expect(failures.map(f => f.title)).toContain("Protected file 'docs/MODIFIED.md' was modified.");
|
|
40
|
+
expect(failures.map(f => f.title)).toContain("Protected file 'docs/DELETED.md' was modified.");
|
|
41
|
+
});
|
|
42
|
+
});
|
package/dist/utils/scanner.js
CHANGED
|
@@ -13,7 +13,8 @@ export class FileScanner {
|
|
|
13
13
|
];
|
|
14
14
|
static async findFiles(options) {
|
|
15
15
|
const patterns = (options.patterns || this.DEFAULT_PATTERNS).map(p => p.replace(/\\/g, '/'));
|
|
16
|
-
const
|
|
16
|
+
const userIgnore = options.ignore || [];
|
|
17
|
+
const ignore = [...new Set([...this.DEFAULT_IGNORE, ...userIgnore])].map(p => p.replace(/\\/g, '/'));
|
|
17
18
|
const normalizedCwd = options.cwd.replace(/\\/g, '/');
|
|
18
19
|
return globby(patterns, {
|
|
19
20
|
cwd: normalizedCwd,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { FileScanner } from './scanner.js';
|
|
3
|
+
import { globby } from 'globby';
|
|
4
|
+
vi.mock('globby', () => ({
|
|
5
|
+
globby: vi.fn(),
|
|
6
|
+
}));
|
|
7
|
+
describe('FileScanner', () => {
|
|
8
|
+
it('should merge default ignores with user ignores', async () => {
|
|
9
|
+
const options = {
|
|
10
|
+
cwd: '/test',
|
|
11
|
+
ignore: ['custom-ignore']
|
|
12
|
+
};
|
|
13
|
+
await FileScanner.findFiles(options);
|
|
14
|
+
const call = vi.mocked(globby).mock.calls[0];
|
|
15
|
+
const ignore = call[1].ignore;
|
|
16
|
+
expect(ignore).toContain('**/node_modules/**');
|
|
17
|
+
expect(ignore).toContain('custom-ignore');
|
|
18
|
+
});
|
|
19
|
+
it('should normalize paths to forward slashes', async () => {
|
|
20
|
+
const options = {
|
|
21
|
+
cwd: 'C:\\test\\path',
|
|
22
|
+
patterns: ['**\\*.ts']
|
|
23
|
+
};
|
|
24
|
+
await FileScanner.findFiles(options);
|
|
25
|
+
const call = vi.mocked(globby).mock.calls[1];
|
|
26
|
+
expect(call[0][0]).toBe('**/*.ts');
|
|
27
|
+
expect(call[1]?.cwd).toBe('C:/test/path');
|
|
28
|
+
});
|
|
29
|
+
});
|
package/package.json
CHANGED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { DiscoveryService } from './discovery.js';
|
|
3
|
+
import fs from 'fs-extra';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
|
|
6
|
+
vi.mock('fs-extra');
|
|
7
|
+
|
|
8
|
+
describe('DiscoveryService', () => {
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
vi.resetAllMocks();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('should discover project marker in root directory', async () => {
|
|
14
|
+
const service = new DiscoveryService();
|
|
15
|
+
vi.mocked(fs.pathExists).mockImplementation(async (p: string) => p.includes('package.json'));
|
|
16
|
+
vi.mocked(fs.readdir).mockResolvedValue(['package.json'] as any);
|
|
17
|
+
vi.mocked(fs.readFile).mockResolvedValue('{}' as any);
|
|
18
|
+
|
|
19
|
+
const result = await service.discover('/test');
|
|
20
|
+
// If package.json doesn't match a specific role marker, it stays Universal.
|
|
21
|
+
// Let's mock a specific one like 'express'
|
|
22
|
+
vi.mocked(fs.pathExists).mockImplementation(async (p: string) => p.includes('express'));
|
|
23
|
+
const result2 = await service.discover('/test');
|
|
24
|
+
expect(result2.matches.preset?.name).toBe('api');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('should discover project marker in src/ directory (Deep Detection)', async () => {
|
|
28
|
+
const service = new DiscoveryService();
|
|
29
|
+
vi.mocked(fs.pathExists).mockImplementation((async (p: string) => {
|
|
30
|
+
if (p.endsWith('src')) return true;
|
|
31
|
+
if (p.includes('src/index.ts')) return true;
|
|
32
|
+
return false;
|
|
33
|
+
}) as any);
|
|
34
|
+
vi.mocked(fs.readdir).mockImplementation((async (p: string) => {
|
|
35
|
+
if (p.toString().endsWith('/test')) return ['src'] as any;
|
|
36
|
+
if (p.toString().endsWith('src')) return ['index.ts'] as any;
|
|
37
|
+
return [] as any;
|
|
38
|
+
}) as any);
|
|
39
|
+
vi.mocked(fs.readFile).mockResolvedValue('export const x = 1;' as any);
|
|
40
|
+
|
|
41
|
+
const result = await service.discover('/test');
|
|
42
|
+
// Since UNIVERSAL_CONFIG has a default, we check if it found something extra or matches expectation
|
|
43
|
+
// Default is universal, but detecting .ts should tilt it towards node or similar if configured
|
|
44
|
+
// In our current templates, package.json is the node marker.
|
|
45
|
+
// Let's check for paradigm detection which uses content
|
|
46
|
+
expect(result.config).toBeDefined();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should identify OOP paradigm from content in subfolder', async () => {
|
|
50
|
+
const service = new DiscoveryService();
|
|
51
|
+
vi.mocked(fs.pathExists).mockImplementation((async (p: string) => p.endsWith('src') || p.endsWith('src/Service.ts')) as any);
|
|
52
|
+
vi.mocked(fs.readdir).mockImplementation((async (p: string) => {
|
|
53
|
+
if (p.toString().endsWith('src')) return ['Service.ts'] as any;
|
|
54
|
+
return ['src'] as any;
|
|
55
|
+
}) as any);
|
|
56
|
+
vi.mocked(fs.readFile).mockResolvedValue('class MyService {}' as any);
|
|
57
|
+
|
|
58
|
+
const result = await service.discover('/test');
|
|
59
|
+
expect(result.matches.paradigm?.name).toBe('oop');
|
|
60
|
+
});
|
|
61
|
+
});
|
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;
|
|
@@ -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/base.ts
CHANGED
|
@@ -13,10 +13,10 @@ export abstract class Gate {
|
|
|
13
13
|
|
|
14
14
|
abstract run(context: GateContext): Promise<Failure[]>;
|
|
15
15
|
|
|
16
|
-
protected createFailure(details: string, files?: string[], hint?: string): Failure {
|
|
16
|
+
protected createFailure(details: string, files?: string[], hint?: string, title?: string): Failure {
|
|
17
17
|
return {
|
|
18
18
|
id: this.id,
|
|
19
|
-
title: this.title,
|
|
19
|
+
title: title || this.title,
|
|
20
20
|
details,
|
|
21
21
|
files,
|
|
22
22
|
hint,
|
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
|
}
|
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
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { FileScanner } from './scanner.js';
|
|
3
|
+
import { globby } from 'globby';
|
|
4
|
+
|
|
5
|
+
vi.mock('globby', () => ({
|
|
6
|
+
globby: vi.fn(),
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
describe('FileScanner', () => {
|
|
10
|
+
it('should merge default ignores with user ignores', async () => {
|
|
11
|
+
const options = {
|
|
12
|
+
cwd: '/test',
|
|
13
|
+
ignore: ['custom-ignore']
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
await FileScanner.findFiles(options);
|
|
17
|
+
|
|
18
|
+
const call = vi.mocked(globby).mock.calls[0];
|
|
19
|
+
const ignore = (call[1] as any).ignore;
|
|
20
|
+
|
|
21
|
+
expect(ignore).toContain('**/node_modules/**');
|
|
22
|
+
expect(ignore).toContain('custom-ignore');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('should normalize paths to forward slashes', async () => {
|
|
26
|
+
const options = {
|
|
27
|
+
cwd: 'C:\\test\\path',
|
|
28
|
+
patterns: ['**\\*.ts']
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
await FileScanner.findFiles(options);
|
|
32
|
+
|
|
33
|
+
const call = vi.mocked(globby).mock.calls[1];
|
|
34
|
+
expect(call[0][0]).toBe('**/*.ts');
|
|
35
|
+
expect(call[1]?.cwd).toBe('C:/test/path');
|
|
36
|
+
});
|
|
37
|
+
});
|
package/src/utils/scanner.ts
CHANGED
|
@@ -21,7 +21,8 @@ export class FileScanner {
|
|
|
21
21
|
|
|
22
22
|
static async findFiles(options: ScannerOptions): Promise<string[]> {
|
|
23
23
|
const patterns = (options.patterns || this.DEFAULT_PATTERNS).map(p => p.replace(/\\/g, '/'));
|
|
24
|
-
const
|
|
24
|
+
const userIgnore = options.ignore || [];
|
|
25
|
+
const ignore = [...new Set([...this.DEFAULT_IGNORE, ...userIgnore])].map(p => p.replace(/\\/g, '/'));
|
|
25
26
|
const normalizedCwd = options.cwd.replace(/\\/g, '/');
|
|
26
27
|
|
|
27
28
|
return globby(patterns, {
|