@gobing-ai/ts-rule-engine 0.2.1
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/README.md +3 -0
- package/dist/config/loader.d.ts +13 -0
- package/dist/config/loader.d.ts.map +1 -0
- package/dist/config/loader.js +98 -0
- package/dist/engine.d.ts +21 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +36 -0
- package/dist/evaluators/agent-detection-evaluator.d.ts +10 -0
- package/dist/evaluators/agent-detection-evaluator.d.ts.map +1 -0
- package/dist/evaluators/agent-detection-evaluator.js +33 -0
- package/dist/evaluators/exit-code-evaluator.d.ts +10 -0
- package/dist/evaluators/exit-code-evaluator.d.ts.map +1 -0
- package/dist/evaluators/exit-code-evaluator.js +38 -0
- package/dist/evaluators/file-utils.d.ts +23 -0
- package/dist/evaluators/file-utils.d.ts.map +1 -0
- package/dist/evaluators/file-utils.js +35 -0
- package/dist/evaluators/forbidden-import-evaluator.d.ts +8 -0
- package/dist/evaluators/forbidden-import-evaluator.d.ts.map +1 -0
- package/dist/evaluators/forbidden-import-evaluator.js +42 -0
- package/dist/evaluators/path-evaluator.d.ts +9 -0
- package/dist/evaluators/path-evaluator.d.ts.map +1 -0
- package/dist/evaluators/path-evaluator.js +39 -0
- package/dist/evaluators/regex-evaluator.d.ts +8 -0
- package/dist/evaluators/regex-evaluator.d.ts.map +1 -0
- package/dist/evaluators/regex-evaluator.js +36 -0
- package/dist/evaluators/secrets-scanner-evaluator.d.ts +8 -0
- package/dist/evaluators/secrets-scanner-evaluator.d.ts.map +1 -0
- package/dist/evaluators/secrets-scanner-evaluator.js +24 -0
- package/dist/formatters/json.d.ts +8 -0
- package/dist/formatters/json.d.ts.map +1 -0
- package/dist/formatters/json.js +8 -0
- package/dist/formatters/text.d.ts +8 -0
- package/dist/formatters/text.d.ts.map +1 -0
- package/dist/formatters/text.js +17 -0
- package/dist/host/builtins.d.ts +5 -0
- package/dist/host/builtins.d.ts.map +1 -0
- package/dist/host/builtins.js +23 -0
- package/dist/host/capability-registry.d.ts +24 -0
- package/dist/host/capability-registry.d.ts.map +1 -0
- package/dist/host/capability-registry.js +28 -0
- package/dist/host/rule-engine-host.d.ts +11 -0
- package/dist/host/rule-engine-host.d.ts.map +1 -0
- package/dist/host/rule-engine-host.js +12 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/types.d.ts +218 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +49 -0
- package/package.json +61 -0
- package/src/config/loader.ts +122 -0
- package/src/engine.ts +50 -0
- package/src/evaluators/agent-detection-evaluator.ts +37 -0
- package/src/evaluators/exit-code-evaluator.ts +42 -0
- package/src/evaluators/file-utils.ts +55 -0
- package/src/evaluators/forbidden-import-evaluator.ts +50 -0
- package/src/evaluators/path-evaluator.ts +48 -0
- package/src/evaluators/regex-evaluator.ts +49 -0
- package/src/evaluators/secrets-scanner-evaluator.ts +34 -0
- package/src/formatters/json.ts +11 -0
- package/src/formatters/text.ts +20 -0
- package/src/host/builtins.ts +26 -0
- package/src/host/capability-registry.ts +41 -0
- package/src/host/rule-engine-host.ts +15 -0
- package/src/index.ts +7 -0
- package/src/types.ts +197 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { AgentDetector, type AgentName, isAgentName } from '@gobing-ai/ts-ai-runner';
|
|
2
|
+
import {
|
|
3
|
+
type ConstraintRule,
|
|
4
|
+
createFinding,
|
|
5
|
+
type RuleContext,
|
|
6
|
+
type RuleEvaluationResult,
|
|
7
|
+
type RuleEvaluator,
|
|
8
|
+
} from '../types';
|
|
9
|
+
|
|
10
|
+
/** Evaluates local availability of configured coding agents. */
|
|
11
|
+
export class AgentDetectionEvaluator implements RuleEvaluator {
|
|
12
|
+
constructor(private readonly detector = new AgentDetector()) {}
|
|
13
|
+
|
|
14
|
+
/** Probe required agents and emit findings for missing CLIs. */
|
|
15
|
+
async evaluate(rule: ConstraintRule, _context: RuleContext): Promise<RuleEvaluationResult> {
|
|
16
|
+
const agents = arrayConfig(rule.evaluator.config ?? {}, 'agents');
|
|
17
|
+
const findings = [];
|
|
18
|
+
for (const agent of agents) {
|
|
19
|
+
if (!isAgentName(agent)) {
|
|
20
|
+
findings.push(createFinding(rule, `Unknown agent: ${agent}`, null, { code: 'agent:unknown' }));
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
const detected = await this.detector.detectOne(agent as AgentName);
|
|
24
|
+
if (!detected.installed) {
|
|
25
|
+
findings.push(createFinding(rule, `Agent unavailable: ${agent}`, null, { code: 'agent:missing' }));
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return { findings, fixes: [] };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function arrayConfig(config: Record<string, unknown>, key: string): string[] {
|
|
33
|
+
const value = config[key];
|
|
34
|
+
if (Array.isArray(value) && value.every((item) => typeof item === 'string')) return value;
|
|
35
|
+
if (typeof value === 'string') return [value];
|
|
36
|
+
throw new Error(`agent-detection evaluator requires string[] config "${key}"`);
|
|
37
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { NodeProcessExecutor, type ProcessExecutor } from '@gobing-ai/ts-runtime';
|
|
2
|
+
import {
|
|
3
|
+
type ConstraintRule,
|
|
4
|
+
createFinding,
|
|
5
|
+
type RuleContext,
|
|
6
|
+
type RuleEvaluationResult,
|
|
7
|
+
type RuleEvaluator,
|
|
8
|
+
} from '../types';
|
|
9
|
+
|
|
10
|
+
/** Evaluates a rule by running a subprocess and checking its exit code. */
|
|
11
|
+
export class ExitCodeEvaluator implements RuleEvaluator {
|
|
12
|
+
constructor(private readonly executor: ProcessExecutor = new NodeProcessExecutor()) {}
|
|
13
|
+
|
|
14
|
+
/** Run configured command and emit a finding on non-zero exit. */
|
|
15
|
+
async evaluate(rule: ConstraintRule, context: RuleContext): Promise<RuleEvaluationResult> {
|
|
16
|
+
const config = rule.evaluator.config ?? {};
|
|
17
|
+
const command = stringConfig(config, 'command');
|
|
18
|
+
const args = arrayConfig(config, 'args', []);
|
|
19
|
+
const result = await this.executor.run({ command, args, cwd: context.workdir, rejectOnError: false });
|
|
20
|
+
if (result.exitCode === 0) return { findings: [], fixes: [] };
|
|
21
|
+
return {
|
|
22
|
+
findings: [
|
|
23
|
+
createFinding(rule, `Command failed: ${command} ${args.join(' ')}`.trim(), null, {
|
|
24
|
+
code: 'exit-code:failed',
|
|
25
|
+
}),
|
|
26
|
+
],
|
|
27
|
+
fixes: [],
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function stringConfig(config: Record<string, unknown>, key: string): string {
|
|
33
|
+
const value = config[key];
|
|
34
|
+
if (typeof value === 'string') return value;
|
|
35
|
+
throw new Error(`exit-code evaluator requires string config "${key}"`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function arrayConfig(config: Record<string, unknown>, key: string, fallback: string[]): string[] {
|
|
39
|
+
const value = config[key];
|
|
40
|
+
if (Array.isArray(value) && value.every((item) => typeof item === 'string')) return value;
|
|
41
|
+
return fallback;
|
|
42
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { dirname, relative, resolve } from 'node:path';
|
|
2
|
+
import { type FileSystem, NodeFileSystem, walkDir } from '@gobing-ai/ts-runtime';
|
|
3
|
+
|
|
4
|
+
/** Options for source-file discovery. */
|
|
5
|
+
export interface SourceDiscoveryOptions {
|
|
6
|
+
/** Working directory. */
|
|
7
|
+
workdir: string;
|
|
8
|
+
/** Include path fragments or suffixes. */
|
|
9
|
+
include?: string[];
|
|
10
|
+
/** Exclude path fragments. */
|
|
11
|
+
exclude?: string[];
|
|
12
|
+
/** Filesystem adapter. */
|
|
13
|
+
fs?: FileSystem;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const DEFAULT_EXCLUDES = new Set(['.git', 'node_modules', 'dist', '.coverage', '.astro', '.wrangler']);
|
|
17
|
+
|
|
18
|
+
/** Resolve source files for evaluators using conservative path-fragment matching. */
|
|
19
|
+
export async function discoverFiles(options: SourceDiscoveryOptions): Promise<string[]> {
|
|
20
|
+
const fs = options.fs ?? new NodeFileSystem();
|
|
21
|
+
const allFiles = await walkDir(options.workdir, fs);
|
|
22
|
+
return allFiles
|
|
23
|
+
.map((path) => relative(options.workdir, path))
|
|
24
|
+
.filter((path) => !path.split('/').some((segment) => DEFAULT_EXCLUDES.has(segment)))
|
|
25
|
+
.filter(
|
|
26
|
+
(path) =>
|
|
27
|
+
matchesAny(path, options.include) &&
|
|
28
|
+
(options.exclude === undefined || !matchesAny(path, options.exclude)),
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Read a file from a workdir-relative path. */
|
|
33
|
+
export async function readWorkdirFile(workdir: string, filePath: string, fs = new NodeFileSystem()): Promise<string> {
|
|
34
|
+
return await fs.readFile(resolve(workdir, filePath));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Ensure a path is workdir-relative for findings. */
|
|
38
|
+
export function relativeToWorkdir(workdir: string, path: string): string {
|
|
39
|
+
return relative(workdir, resolve(path));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Return parent directory for a workdir-relative path. */
|
|
43
|
+
export function relativeParent(path: string): string {
|
|
44
|
+
const parent = dirname(path);
|
|
45
|
+
return parent === '.' ? '' : parent;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Return true when a path matches any supplied fragment or suffix. */
|
|
49
|
+
export function matchesAny(path: string, patterns: string[] | undefined): boolean {
|
|
50
|
+
if (patterns === undefined || patterns.length === 0) return true;
|
|
51
|
+
return patterns.some((pattern) => {
|
|
52
|
+
const clean = pattern.replaceAll('\\', '/').replaceAll('**/', '').replaceAll('*', '');
|
|
53
|
+
return clean.length === 0 || path.includes(clean) || path.endsWith(clean);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ConstraintRule,
|
|
3
|
+
createFinding,
|
|
4
|
+
type RuleContext,
|
|
5
|
+
type RuleEvaluationResult,
|
|
6
|
+
type RuleEvaluator,
|
|
7
|
+
} from '../types';
|
|
8
|
+
import { discoverFiles, readWorkdirFile } from './file-utils';
|
|
9
|
+
|
|
10
|
+
/** Detects imports matching forbidden package or path prefixes. */
|
|
11
|
+
export class ForbiddenImportEvaluator implements RuleEvaluator {
|
|
12
|
+
constructor() {}
|
|
13
|
+
|
|
14
|
+
/** Evaluate import declarations against configured forbidden prefixes. */
|
|
15
|
+
async evaluate(rule: ConstraintRule, context: RuleContext): Promise<RuleEvaluationResult> {
|
|
16
|
+
const config = rule.evaluator.config ?? {};
|
|
17
|
+
const forbidden = arrayConfig(config, 'patterns');
|
|
18
|
+
const files = await discoverFiles({
|
|
19
|
+
workdir: context.workdir,
|
|
20
|
+
include: rule.include ?? ['.ts', '.tsx', '.js', '.jsx'],
|
|
21
|
+
exclude: rule.exclude,
|
|
22
|
+
});
|
|
23
|
+
const findings = [];
|
|
24
|
+
for (const file of files) {
|
|
25
|
+
const lines = (await readWorkdirFile(context.workdir, file)).split('\n');
|
|
26
|
+
for (const [index, line] of lines.entries()) {
|
|
27
|
+
const imported = /(?:from\s+|import\s*\(|^\s*import\s*)['"](?<specifier>[^'"]+)['"]/.exec(line)?.groups
|
|
28
|
+
?.specifier;
|
|
29
|
+
if (imported === undefined) continue;
|
|
30
|
+
const matched = forbidden.find((pattern) => imported.includes(pattern));
|
|
31
|
+
if (matched !== undefined) {
|
|
32
|
+
findings.push(
|
|
33
|
+
createFinding(rule, `Forbidden import "${imported}" matched "${matched}"`, file, {
|
|
34
|
+
line: index + 1,
|
|
35
|
+
code: 'import:forbidden',
|
|
36
|
+
}),
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return { findings, fixes: [] };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function arrayConfig(config: Record<string, unknown>, key: string): string[] {
|
|
46
|
+
const value = config[key];
|
|
47
|
+
if (Array.isArray(value) && value.every((item) => typeof item === 'string')) return value;
|
|
48
|
+
if (typeof value === 'string') return [value];
|
|
49
|
+
throw new Error(`forbidden-import evaluator requires string[] config "${key}"`);
|
|
50
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { NodeFileSystem } from '@gobing-ai/ts-runtime';
|
|
3
|
+
import {
|
|
4
|
+
type ConstraintRule,
|
|
5
|
+
createFinding,
|
|
6
|
+
type RuleContext,
|
|
7
|
+
type RuleEvaluationResult,
|
|
8
|
+
type RuleEvaluator,
|
|
9
|
+
} from '../types';
|
|
10
|
+
|
|
11
|
+
/** Evaluates file or directory existence constraints. */
|
|
12
|
+
export class PathEvaluator implements RuleEvaluator {
|
|
13
|
+
private readonly fs: NodeFileSystem;
|
|
14
|
+
|
|
15
|
+
constructor() {
|
|
16
|
+
this.fs = new NodeFileSystem();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Evaluate required or forbidden paths. */
|
|
20
|
+
async evaluate(rule: ConstraintRule, context: RuleContext): Promise<RuleEvaluationResult> {
|
|
21
|
+
const config = rule.evaluator.config ?? {};
|
|
22
|
+
const paths = arrayConfig(config, 'paths');
|
|
23
|
+
const mode = stringConfig(config, 'mode', 'require');
|
|
24
|
+
const findings = [];
|
|
25
|
+
for (const path of paths) {
|
|
26
|
+
const exists = await this.fs.exists(resolve(context.workdir, path));
|
|
27
|
+
if (mode === 'forbid' && exists) {
|
|
28
|
+
findings.push(createFinding(rule, `Forbidden path exists: ${path}`, path, { code: 'path:forbidden' }));
|
|
29
|
+
}
|
|
30
|
+
if (mode !== 'forbid' && !exists) {
|
|
31
|
+
findings.push(createFinding(rule, `Required path missing: ${path}`, path, { code: 'path:missing' }));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return { findings, fixes: [] };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function arrayConfig(config: Record<string, unknown>, key: string): string[] {
|
|
39
|
+
const value = config[key];
|
|
40
|
+
if (Array.isArray(value) && value.every((item) => typeof item === 'string')) return value;
|
|
41
|
+
if (typeof value === 'string') return [value];
|
|
42
|
+
throw new Error(`path evaluator requires string[] config "${key}"`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function stringConfig(config: Record<string, unknown>, key: string, fallback: string): string {
|
|
46
|
+
const value = config[key];
|
|
47
|
+
return typeof value === 'string' ? value : fallback;
|
|
48
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ConstraintRule,
|
|
3
|
+
createFinding,
|
|
4
|
+
type RuleContext,
|
|
5
|
+
type RuleEvaluationResult,
|
|
6
|
+
type RuleEvaluator,
|
|
7
|
+
} from '../types';
|
|
8
|
+
import { discoverFiles, readWorkdirFile } from './file-utils';
|
|
9
|
+
|
|
10
|
+
/** Evaluates whether source files match or avoid a regex pattern. */
|
|
11
|
+
export class RegexEvaluator implements RuleEvaluator {
|
|
12
|
+
constructor() {}
|
|
13
|
+
|
|
14
|
+
/** Evaluate regex-based presence or absence constraints. */
|
|
15
|
+
async evaluate(rule: ConstraintRule, context: RuleContext): Promise<RuleEvaluationResult> {
|
|
16
|
+
const config = rule.evaluator.config ?? {};
|
|
17
|
+
const pattern = stringConfig(config, 'pattern');
|
|
18
|
+
const mode = stringConfig(config, 'mode', 'forbid');
|
|
19
|
+
const flags = stringConfig(config, 'flags', 'm');
|
|
20
|
+
const regex = new RegExp(pattern, flags);
|
|
21
|
+
const files = await discoverFiles({ workdir: context.workdir, include: rule.include, exclude: rule.exclude });
|
|
22
|
+
const findings = [];
|
|
23
|
+
|
|
24
|
+
for (const file of files) {
|
|
25
|
+
const content = await readWorkdirFile(context.workdir, file);
|
|
26
|
+
regex.lastIndex = 0;
|
|
27
|
+
const match = regex.exec(content);
|
|
28
|
+
if (mode === 'require' && match === null) {
|
|
29
|
+
findings.push(
|
|
30
|
+
createFinding(rule, `Required pattern not found: ${pattern}`, file, { code: 'regex:missing' }),
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
if (mode !== 'require' && match !== null) {
|
|
34
|
+
findings.push(
|
|
35
|
+
createFinding(rule, `Forbidden pattern found: ${pattern}`, file, { code: 'regex:found' }),
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return { findings, fixes: [] };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function stringConfig(config: Record<string, unknown>, key: string, fallback?: string): string {
|
|
45
|
+
const value = config[key];
|
|
46
|
+
if (typeof value === 'string') return value;
|
|
47
|
+
if (fallback !== undefined) return fallback;
|
|
48
|
+
throw new Error(`regex evaluator requires string config "${key}"`);
|
|
49
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ConstraintRule,
|
|
3
|
+
createFinding,
|
|
4
|
+
type RuleContext,
|
|
5
|
+
type RuleEvaluationResult,
|
|
6
|
+
type RuleEvaluator,
|
|
7
|
+
} from '../types';
|
|
8
|
+
import { discoverFiles, readWorkdirFile } from './file-utils';
|
|
9
|
+
|
|
10
|
+
/** Scans text files for high-confidence secret-like tokens. */
|
|
11
|
+
export class SecretsScannerEvaluator implements RuleEvaluator {
|
|
12
|
+
constructor() {}
|
|
13
|
+
|
|
14
|
+
/** Evaluate source files against bundled secret patterns. */
|
|
15
|
+
async evaluate(rule: ConstraintRule, context: RuleContext): Promise<RuleEvaluationResult> {
|
|
16
|
+
const files = await discoverFiles({ workdir: context.workdir, include: rule.include, exclude: rule.exclude });
|
|
17
|
+
const findings = [];
|
|
18
|
+
const secretPattern = /(sk-[A-Za-z0-9_-]{20,}|AKIA[0-9A-Z]{16}|BEGIN (?:RSA |OPENSSH )?PRIVATE KEY)/;
|
|
19
|
+
for (const file of files) {
|
|
20
|
+
const lines = (await readWorkdirFile(context.workdir, file)).split('\n');
|
|
21
|
+
for (const [index, line] of lines.entries()) {
|
|
22
|
+
if (secretPattern.test(line)) {
|
|
23
|
+
findings.push(
|
|
24
|
+
createFinding(rule, 'Potential secret token found', file, {
|
|
25
|
+
line: index + 1,
|
|
26
|
+
code: 'secret:token',
|
|
27
|
+
}),
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return { findings, fixes: [] };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ResultFormatter, RuleEngineResult } from '../types';
|
|
2
|
+
|
|
3
|
+
/** JSON formatter for rule-engine results. */
|
|
4
|
+
export class JsonFormatter implements ResultFormatter {
|
|
5
|
+
constructor() {}
|
|
6
|
+
|
|
7
|
+
/** Format the full result as pretty JSON. */
|
|
8
|
+
format(result: RuleEngineResult): string {
|
|
9
|
+
return JSON.stringify(result, null, 2);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ResultFormatter, RuleEngineResult } from '../types';
|
|
2
|
+
|
|
3
|
+
/** Text formatter for human CLI output. */
|
|
4
|
+
export class TextFormatter implements ResultFormatter {
|
|
5
|
+
constructor() {}
|
|
6
|
+
|
|
7
|
+
/** Format findings as concise path-prefixed lines. */
|
|
8
|
+
format(result: RuleEngineResult): string {
|
|
9
|
+
if (result.findings.length === 0) return 'No rule findings.';
|
|
10
|
+
return result.findings
|
|
11
|
+
.map((finding) => {
|
|
12
|
+
const location =
|
|
13
|
+
finding.filePath === null
|
|
14
|
+
? '<workspace>'
|
|
15
|
+
: `${finding.filePath}${finding.line ? `:${finding.line}` : ''}`;
|
|
16
|
+
return `${finding.severity.toUpperCase()} ${finding.ruleId} ${location} ${finding.message}`;
|
|
17
|
+
})
|
|
18
|
+
.join('\n');
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ProcessExecutor } from '@gobing-ai/ts-runtime';
|
|
2
|
+
import { AgentDetectionEvaluator } from '../evaluators/agent-detection-evaluator';
|
|
3
|
+
import { ExitCodeEvaluator } from '../evaluators/exit-code-evaluator';
|
|
4
|
+
import { ForbiddenImportEvaluator } from '../evaluators/forbidden-import-evaluator';
|
|
5
|
+
import { PathEvaluator } from '../evaluators/path-evaluator';
|
|
6
|
+
import { RegexEvaluator } from '../evaluators/regex-evaluator';
|
|
7
|
+
import { SecretsScannerEvaluator } from '../evaluators/secrets-scanner-evaluator';
|
|
8
|
+
import { JsonFormatter } from '../formatters/json';
|
|
9
|
+
import { TextFormatter } from '../formatters/text';
|
|
10
|
+
import type { RuleEngineHost } from './rule-engine-host';
|
|
11
|
+
|
|
12
|
+
/** Register bundled evaluators and formatters on a host. */
|
|
13
|
+
export function registerBuiltins(host: RuleEngineHost, executor?: ProcessExecutor): void {
|
|
14
|
+
const regex = new RegexEvaluator();
|
|
15
|
+
const path = new PathEvaluator();
|
|
16
|
+
host.evaluators.register('regex', regex, 'builtin');
|
|
17
|
+
host.evaluators.register('rg', regex, 'builtin');
|
|
18
|
+
host.evaluators.register('path', path, 'builtin');
|
|
19
|
+
host.evaluators.register('file-exist', path, 'builtin');
|
|
20
|
+
host.evaluators.register('forbidden-import', new ForbiddenImportEvaluator(), 'builtin');
|
|
21
|
+
host.evaluators.register('exit-code', new ExitCodeEvaluator(executor), 'builtin');
|
|
22
|
+
host.evaluators.register('secrets-scanner', new SecretsScannerEvaluator(), 'builtin');
|
|
23
|
+
host.evaluators.register('agent-detection', new AgentDetectionEvaluator(), 'builtin');
|
|
24
|
+
host.formatters.register('text', new TextFormatter(), 'builtin');
|
|
25
|
+
host.formatters.register('json', new JsonFormatter(), 'builtin');
|
|
26
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** Registry origin for a host capability. */
|
|
2
|
+
export type CapabilityOrigin = 'builtin' | 'extension';
|
|
3
|
+
|
|
4
|
+
/** Registry entry metadata. */
|
|
5
|
+
export interface CapabilityEntry<TCapability> {
|
|
6
|
+
/** Capability implementation. */
|
|
7
|
+
capability: TCapability;
|
|
8
|
+
/** Registration origin. */
|
|
9
|
+
origin: CapabilityOrigin;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Typed registry used by the rule engine host. */
|
|
13
|
+
export class CapabilityRegistry<TCapability> {
|
|
14
|
+
private readonly capabilities = new Map<string, CapabilityEntry<TCapability>>();
|
|
15
|
+
|
|
16
|
+
constructor(private readonly kind: string) {}
|
|
17
|
+
|
|
18
|
+
/** Register or replace a capability. */
|
|
19
|
+
register(name: string, capability: TCapability, origin: CapabilityOrigin = 'extension'): void {
|
|
20
|
+
this.capabilities.set(name, { capability, origin });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Return true when a capability exists. */
|
|
24
|
+
has(name: string): boolean {
|
|
25
|
+
return this.capabilities.has(name);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Get a registered capability or throw a clear error. */
|
|
29
|
+
get(name: string): TCapability {
|
|
30
|
+
const entry = this.capabilities.get(name);
|
|
31
|
+
if (entry === undefined) {
|
|
32
|
+
throw new Error(`Unknown ${this.kind}: ${name}`);
|
|
33
|
+
}
|
|
34
|
+
return entry.capability;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** List registered capability names. */
|
|
38
|
+
list(): string[] {
|
|
39
|
+
return [...this.capabilities.keys()];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ResultFormatter, RuleEvaluator } from '../types';
|
|
2
|
+
import { CapabilityRegistry } from './capability-registry';
|
|
3
|
+
|
|
4
|
+
/** Host container for rule-engine capabilities. */
|
|
5
|
+
export class RuleEngineHost {
|
|
6
|
+
/** Evaluator registry keyed by evaluator type. */
|
|
7
|
+
readonly evaluators: CapabilityRegistry<RuleEvaluator>;
|
|
8
|
+
/** Formatter registry keyed by formatter name. */
|
|
9
|
+
readonly formatters: CapabilityRegistry<ResultFormatter>;
|
|
10
|
+
|
|
11
|
+
constructor() {
|
|
12
|
+
this.evaluators = new CapabilityRegistry<RuleEvaluator>('evaluator');
|
|
13
|
+
this.formatters = new CapabilityRegistry<ResultFormatter>('formatter');
|
|
14
|
+
}
|
|
15
|
+
}
|
package/src/index.ts
ADDED
package/src/types.ts
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
/** Finding severity emitted by the rule engine. */
|
|
4
|
+
export type RuleSeverity = 'error' | 'warning' | 'info';
|
|
5
|
+
|
|
6
|
+
/** Fix authority level for candidate fixes. */
|
|
7
|
+
export type FixMode = 'none' | 'suggest' | 'auto';
|
|
8
|
+
|
|
9
|
+
/** Structured configuration for a rule evaluator. */
|
|
10
|
+
export interface RuleEvaluatorConfig {
|
|
11
|
+
/** Evaluator type key registered in the host. */
|
|
12
|
+
type: string;
|
|
13
|
+
/** Evaluator-specific options. */
|
|
14
|
+
config?: Record<string, unknown>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Constraint rule definition loaded from YAML or JSON. */
|
|
18
|
+
export interface ConstraintRule {
|
|
19
|
+
/** Stable rule identifier. */
|
|
20
|
+
id: string;
|
|
21
|
+
/** Human-readable description. */
|
|
22
|
+
description: string;
|
|
23
|
+
/** Whether this rule is active. */
|
|
24
|
+
enabled: boolean;
|
|
25
|
+
/** Finding severity emitted by this rule. */
|
|
26
|
+
severity: RuleSeverity;
|
|
27
|
+
/** Evaluator configuration. */
|
|
28
|
+
evaluator: RuleEvaluatorConfig;
|
|
29
|
+
/** Optional include globs or paths. */
|
|
30
|
+
include?: string[];
|
|
31
|
+
/** Optional exclude globs or paths. */
|
|
32
|
+
exclude?: string[];
|
|
33
|
+
/** Optional fix metadata. */
|
|
34
|
+
fix?: RuleFixConfig;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Fix configuration authored on a rule. */
|
|
38
|
+
export interface RuleFixConfig {
|
|
39
|
+
/** Maximum fix mode allowed by the rule. */
|
|
40
|
+
mode: FixMode;
|
|
41
|
+
/** Optional replacement text used by simple fixers. */
|
|
42
|
+
replacement?: string;
|
|
43
|
+
/** Optional fixer-specific parameters. */
|
|
44
|
+
params?: Record<string, unknown>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Rule file shape before normalization. */
|
|
48
|
+
export interface ConstraintRuleFile {
|
|
49
|
+
/** File-level default include patterns. */
|
|
50
|
+
include?: string[];
|
|
51
|
+
/** File-level default exclude patterns. */
|
|
52
|
+
exclude?: string[];
|
|
53
|
+
/** File-level default severity. */
|
|
54
|
+
severity?: RuleSeverity;
|
|
55
|
+
/** Rule definitions. */
|
|
56
|
+
rules: ConstraintRule[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Preset definition that composes category folders or other presets. */
|
|
60
|
+
export interface PresetDefinition {
|
|
61
|
+
/** Preset name. */
|
|
62
|
+
name: string;
|
|
63
|
+
/** Category folders or preset names to compose. */
|
|
64
|
+
extends: string[];
|
|
65
|
+
/** Rule IDs to disable. */
|
|
66
|
+
disable?: string[];
|
|
67
|
+
/** Per-rule overrides. */
|
|
68
|
+
overrides?: Record<string, { fix?: { mode: FixMode } }>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Candidate fix emitted by an evaluator or fixer. */
|
|
72
|
+
export interface Fix {
|
|
73
|
+
/** Rule that produced this fix. */
|
|
74
|
+
ruleId: string;
|
|
75
|
+
/** Relative or absolute target file path. */
|
|
76
|
+
filePath: string;
|
|
77
|
+
/** Byte start offset. */
|
|
78
|
+
start: number;
|
|
79
|
+
/** Byte end offset. */
|
|
80
|
+
end: number;
|
|
81
|
+
/** Replacement content. */
|
|
82
|
+
replacement: string;
|
|
83
|
+
/** Whether this fix may be applied automatically. */
|
|
84
|
+
mode: Exclude<FixMode, 'none'>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Finding emitted by a constraint rule. */
|
|
88
|
+
export interface ConstraintFinding {
|
|
89
|
+
/** Rule identifier. */
|
|
90
|
+
ruleId: string;
|
|
91
|
+
/** Finding severity. */
|
|
92
|
+
severity: RuleSeverity;
|
|
93
|
+
/** Finding message. */
|
|
94
|
+
message: string;
|
|
95
|
+
/** Relative or absolute path involved in the finding. */
|
|
96
|
+
filePath: string | null;
|
|
97
|
+
/** Optional one-based line number. */
|
|
98
|
+
line?: number;
|
|
99
|
+
/** Optional column number. */
|
|
100
|
+
column?: number;
|
|
101
|
+
/** Machine-readable evaluator/source code. */
|
|
102
|
+
code?: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Aggregate result returned by a rule evaluator. */
|
|
106
|
+
export interface RuleEvaluationResult {
|
|
107
|
+
/** Findings emitted by the evaluator. */
|
|
108
|
+
findings: ConstraintFinding[];
|
|
109
|
+
/** Candidate fixes emitted by the evaluator. */
|
|
110
|
+
fixes: Fix[];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Context passed to evaluator implementations. */
|
|
114
|
+
export interface RuleContext {
|
|
115
|
+
/** Working directory being evaluated. */
|
|
116
|
+
workdir: string;
|
|
117
|
+
/** Rule being evaluated. */
|
|
118
|
+
rule: ConstraintRule;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Evaluator implementation contract. */
|
|
122
|
+
export interface RuleEvaluator {
|
|
123
|
+
/** Evaluate a rule against the supplied context. */
|
|
124
|
+
evaluate(rule: ConstraintRule, context: RuleContext): Promise<RuleEvaluationResult>;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Formatter implementation contract. */
|
|
128
|
+
export interface ResultFormatter {
|
|
129
|
+
/** Format an engine result for CLI or machine output. */
|
|
130
|
+
format(result: RuleEngineResult): string;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Engine-level evaluation result. */
|
|
134
|
+
export interface RuleEngineResult {
|
|
135
|
+
/** Findings emitted by enabled rules. */
|
|
136
|
+
findings: ConstraintFinding[];
|
|
137
|
+
/** Candidate fixes emitted by enabled rules. */
|
|
138
|
+
fixes: Fix[];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Create a finding with inherited rule severity. */
|
|
142
|
+
export function createFinding(
|
|
143
|
+
rule: ConstraintRule,
|
|
144
|
+
message: string,
|
|
145
|
+
filePath: string | null,
|
|
146
|
+
extras: Omit<Partial<ConstraintFinding>, 'ruleId' | 'severity' | 'message' | 'filePath'> = {},
|
|
147
|
+
): ConstraintFinding {
|
|
148
|
+
return {
|
|
149
|
+
ruleId: rule.id,
|
|
150
|
+
severity: rule.severity,
|
|
151
|
+
message,
|
|
152
|
+
filePath,
|
|
153
|
+
...extras,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Zod schema for rule fix configuration. */
|
|
158
|
+
export const RuleFixConfigSchema = z
|
|
159
|
+
.object({
|
|
160
|
+
mode: z.enum(['none', 'suggest', 'auto']).default('none'),
|
|
161
|
+
replacement: z.string().optional(),
|
|
162
|
+
params: z.record(z.string(), z.unknown()).optional(),
|
|
163
|
+
})
|
|
164
|
+
.default({ mode: 'none' });
|
|
165
|
+
|
|
166
|
+
/** Zod schema for a single constraint rule. */
|
|
167
|
+
export const ConstraintRuleSchema = z.object({
|
|
168
|
+
id: z.string().min(1),
|
|
169
|
+
description: z.string().default(''),
|
|
170
|
+
enabled: z.boolean().default(true),
|
|
171
|
+
severity: z.enum(['error', 'warning', 'info']).default('error'),
|
|
172
|
+
evaluator: z.object({
|
|
173
|
+
type: z.string().min(1),
|
|
174
|
+
config: z.record(z.string(), z.unknown()).optional(),
|
|
175
|
+
}),
|
|
176
|
+
include: z.array(z.string()).optional(),
|
|
177
|
+
exclude: z.array(z.string()).optional(),
|
|
178
|
+
fix: RuleFixConfigSchema.optional(),
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
/** Zod schema for a constraint rule file. */
|
|
182
|
+
export const ConstraintRuleFileSchema = z.object({
|
|
183
|
+
include: z.array(z.string()).optional(),
|
|
184
|
+
exclude: z.array(z.string()).optional(),
|
|
185
|
+
severity: z.enum(['error', 'warning', 'info']).optional(),
|
|
186
|
+
rules: z.array(ConstraintRuleSchema),
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
/** Zod schema for a preset definition. */
|
|
190
|
+
export const PresetDefinitionSchema = z.object({
|
|
191
|
+
name: z.string().min(1),
|
|
192
|
+
extends: z.array(z.string()).default([]),
|
|
193
|
+
disable: z.array(z.string()).optional(),
|
|
194
|
+
overrides: z
|
|
195
|
+
.record(z.string(), z.object({ fix: z.object({ mode: z.enum(['none', 'suggest', 'auto']) }).optional() }))
|
|
196
|
+
.optional(),
|
|
197
|
+
});
|