@gobing-ai/ts-rule-engine 0.2.6 → 0.2.7
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/config/extensions.d.ts +46 -0
- package/dist/config/extensions.d.ts.map +1 -0
- package/dist/config/extensions.js +63 -0
- package/dist/config/loader.js +13 -3
- package/dist/engine.d.ts +26 -1
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +79 -0
- package/dist/evaluators/exit-code-evaluator.d.ts +1 -1
- package/dist/evaluators/exit-code-evaluator.d.ts.map +1 -1
- package/dist/evaluators/exit-code-evaluator.js +22 -9
- package/dist/evaluators/forbidden-import-evaluator.d.ts +16 -3
- package/dist/evaluators/forbidden-import-evaluator.d.ts.map +1 -1
- package/dist/evaluators/forbidden-import-evaluator.js +71 -6
- package/dist/evaluators/import-boundary-evaluator.d.ts +19 -0
- package/dist/evaluators/import-boundary-evaluator.d.ts.map +1 -0
- package/dist/evaluators/import-boundary-evaluator.js +85 -0
- package/dist/evaluators/path-evaluator.d.ts +15 -2
- package/dist/evaluators/path-evaluator.d.ts.map +1 -1
- package/dist/evaluators/path-evaluator.js +49 -3
- package/dist/evaluators/regex-evaluator.d.ts.map +1 -1
- package/dist/evaluators/regex-evaluator.js +43 -8
- package/dist/evaluators/schema-artifact-evaluator.d.ts +21 -0
- package/dist/evaluators/schema-artifact-evaluator.d.ts.map +1 -0
- package/dist/evaluators/schema-artifact-evaluator.js +102 -0
- package/dist/evaluators/secrets-scanner-evaluator.d.ts +13 -2
- package/dist/evaluators/secrets-scanner-evaluator.d.ts.map +1 -1
- package/dist/evaluators/secrets-scanner-evaluator.js +72 -9
- package/dist/evaluators/sg-evaluator.d.ts +19 -0
- package/dist/evaluators/sg-evaluator.d.ts.map +1 -0
- package/dist/evaluators/sg-evaluator.js +112 -0
- package/dist/evaluators/test-location-evaluator.d.ts +14 -1
- package/dist/evaluators/test-location-evaluator.d.ts.map +1 -1
- package/dist/evaluators/test-location-evaluator.js +42 -22
- package/dist/evaluators/tsdoc-export-evaluator.js +19 -5
- package/dist/fixers/fixers.d.ts +86 -0
- package/dist/fixers/fixers.d.ts.map +1 -0
- package/dist/fixers/fixers.js +230 -0
- package/dist/fixers/test-stub-fixer.d.ts +49 -0
- package/dist/fixers/test-stub-fixer.d.ts.map +1 -0
- package/dist/fixers/test-stub-fixer.js +91 -0
- package/dist/host/builtins.d.ts.map +1 -1
- package/dist/host/builtins.js +12 -1
- package/dist/host/rule-engine-host.d.ts +3 -0
- package/dist/host/rule-engine-host.d.ts.map +1 -1
- package/dist/host/rule-engine-host.js +3 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/resolvers/test-path-resolver.d.ts +72 -0
- package/dist/resolvers/test-path-resolver.d.ts.map +1 -0
- package/dist/resolvers/test-path-resolver.js +112 -0
- package/dist/types.d.ts +30 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +8 -0
- package/package.json +3 -3
- package/src/config/extensions.ts +108 -0
- package/src/config/loader.ts +13 -3
- package/src/engine.ts +99 -2
- package/src/evaluators/exit-code-evaluator.ts +27 -9
- package/src/evaluators/forbidden-import-evaluator.ts +101 -7
- package/src/evaluators/import-boundary-evaluator.ts +135 -0
- package/src/evaluators/path-evaluator.ts +66 -3
- package/src/evaluators/regex-evaluator.ts +53 -12
- package/src/evaluators/schema-artifact-evaluator.ts +134 -0
- package/src/evaluators/secrets-scanner-evaluator.ts +89 -11
- package/src/evaluators/sg-evaluator.ts +133 -0
- package/src/evaluators/test-location-evaluator.ts +47 -35
- package/src/evaluators/tsdoc-export-evaluator.ts +19 -5
- package/src/fixers/fixers.ts +294 -0
- package/src/fixers/test-stub-fixer.ts +118 -0
- package/src/host/builtins.ts +17 -1
- package/src/host/rule-engine-host.ts +4 -0
- package/src/index.ts +4 -0
- package/src/resolvers/test-path-resolver.ts +133 -0
- package/src/types.ts +34 -0
|
@@ -11,27 +11,45 @@ import {
|
|
|
11
11
|
export class ExitCodeEvaluator implements RuleEvaluator {
|
|
12
12
|
constructor(private readonly executor: ProcessExecutor = new NodeProcessExecutor()) {}
|
|
13
13
|
|
|
14
|
-
/** Run configured command and emit a finding
|
|
14
|
+
/** Run configured command and emit a finding unless the exit code matches `successCode`. */
|
|
15
15
|
async evaluate(rule: ConstraintRule, context: RuleContext): Promise<RuleEvaluationResult> {
|
|
16
16
|
const config = rule.evaluator.config ?? {};
|
|
17
17
|
const command = stringConfig(config, 'command');
|
|
18
18
|
const args = arrayConfig(config, 'args', []);
|
|
19
|
-
const
|
|
20
|
-
|
|
19
|
+
const successCode = numberConfig(config, 'successCode', 0);
|
|
20
|
+
const timeout = numberConfig(config, 'timeout', 60_000);
|
|
21
|
+
const result = await this.executor.run({
|
|
22
|
+
command,
|
|
23
|
+
args,
|
|
24
|
+
cwd: context.workdir,
|
|
25
|
+
timeout,
|
|
26
|
+
rejectOnError: false,
|
|
27
|
+
label: 'exit-code',
|
|
28
|
+
});
|
|
29
|
+
if (result.exitCode === successCode) return { findings: [], fixes: [] };
|
|
30
|
+
|
|
31
|
+
const template = stringConfig(
|
|
32
|
+
config,
|
|
33
|
+
'message',
|
|
34
|
+
`Command failed (exit {code}): ${command} ${args.join(' ')}`.trim(),
|
|
35
|
+
);
|
|
36
|
+
const message = template.replaceAll('{code}', String(result.exitCode));
|
|
21
37
|
return {
|
|
22
|
-
findings: [
|
|
23
|
-
createFinding(rule, `Command failed: ${command} ${args.join(' ')}`.trim(), null, {
|
|
24
|
-
code: 'exit-code:failed',
|
|
25
|
-
}),
|
|
26
|
-
],
|
|
38
|
+
findings: [createFinding(rule, message, null, { code: 'exit-code:failed' })],
|
|
27
39
|
fixes: [],
|
|
28
40
|
};
|
|
29
41
|
}
|
|
30
42
|
}
|
|
31
43
|
|
|
32
|
-
function
|
|
44
|
+
function numberConfig(config: Record<string, unknown>, key: string, fallback: number): number {
|
|
45
|
+
const value = config[key];
|
|
46
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function stringConfig(config: Record<string, unknown>, key: string, fallback?: string): string {
|
|
33
50
|
const value = config[key];
|
|
34
51
|
if (typeof value === 'string') return value;
|
|
52
|
+
if (fallback !== undefined) return fallback;
|
|
35
53
|
throw new Error(`exit-code evaluator requires string config "${key}"`);
|
|
36
54
|
}
|
|
37
55
|
|
|
@@ -5,15 +5,45 @@ import {
|
|
|
5
5
|
type RuleEvaluationResult,
|
|
6
6
|
type RuleEvaluator,
|
|
7
7
|
} from '../types';
|
|
8
|
-
import { discoverFiles, readWorkdirFile } from './file-utils';
|
|
8
|
+
import { discoverFiles, matchesGlob, readWorkdirFile } from './file-utils';
|
|
9
9
|
|
|
10
|
-
/**
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
/** A forbidden entry: either an exact import specifier or a raw source pattern. */
|
|
11
|
+
type ForbiddenEntry =
|
|
12
|
+
| { specifier: string; includeRequire?: boolean }
|
|
13
|
+
| { pattern: string; matchMode?: 'import' | 'usage' };
|
|
14
|
+
|
|
15
|
+
/** Compiled scan entry derived from config. */
|
|
16
|
+
interface ScanEntry {
|
|
17
|
+
regex: RegExp;
|
|
18
|
+
label: string;
|
|
19
|
+
}
|
|
13
20
|
|
|
14
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Detects forbidden imports / API usage.
|
|
23
|
+
*
|
|
24
|
+
* Two config shapes are supported:
|
|
25
|
+
* - Simple: `{ patterns: string[] }` — substring match against any import specifier,
|
|
26
|
+
* scoped by the rule's own `include` / `exclude`.
|
|
27
|
+
* - Structured: `{ forbidden: [...], scope: { include, exclude } }` — each forbidden
|
|
28
|
+
* entry is an exact `specifier` (also matching require/dynamic forms unless
|
|
29
|
+
* `includeRequire:false`) or a raw `pattern`, scoped by `scope.include` /
|
|
30
|
+
* `scope.exclude` globs.
|
|
31
|
+
*/
|
|
32
|
+
export class ForbiddenImportEvaluator implements RuleEvaluator {
|
|
33
|
+
/** Evaluate import/usage against the configured forbidden set. */
|
|
15
34
|
async evaluate(rule: ConstraintRule, context: RuleContext): Promise<RuleEvaluationResult> {
|
|
16
35
|
const config = rule.evaluator.config ?? {};
|
|
36
|
+
return Array.isArray(config.forbidden)
|
|
37
|
+
? this.evaluateStructured(rule, context, config)
|
|
38
|
+
: this.evaluateSimple(rule, context, config);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Legacy path: `{ patterns: string[] }`, substring-matched against import specifiers. */
|
|
42
|
+
private async evaluateSimple(
|
|
43
|
+
rule: ConstraintRule,
|
|
44
|
+
context: RuleContext,
|
|
45
|
+
config: Record<string, unknown>,
|
|
46
|
+
): Promise<RuleEvaluationResult> {
|
|
17
47
|
const forbidden = arrayConfig(config, 'patterns');
|
|
18
48
|
const files = await discoverFiles({
|
|
19
49
|
workdir: context.workdir,
|
|
@@ -24,8 +54,7 @@ export class ForbiddenImportEvaluator implements RuleEvaluator {
|
|
|
24
54
|
for (const file of files) {
|
|
25
55
|
const lines = (await readWorkdirFile(context.workdir, file)).split('\n');
|
|
26
56
|
for (const [index, line] of lines.entries()) {
|
|
27
|
-
const imported =
|
|
28
|
-
?.specifier;
|
|
57
|
+
const imported = importSpecifier(line);
|
|
29
58
|
if (imported === undefined) continue;
|
|
30
59
|
const matched = forbidden.find((pattern) => imported.includes(pattern));
|
|
31
60
|
if (matched !== undefined) {
|
|
@@ -40,6 +69,67 @@ export class ForbiddenImportEvaluator implements RuleEvaluator {
|
|
|
40
69
|
}
|
|
41
70
|
return { findings, fixes: [] };
|
|
42
71
|
}
|
|
72
|
+
|
|
73
|
+
/** Structured path: `{ forbidden: [...], scope: { include, exclude } }`. */
|
|
74
|
+
private async evaluateStructured(
|
|
75
|
+
rule: ConstraintRule,
|
|
76
|
+
context: RuleContext,
|
|
77
|
+
config: Record<string, unknown>,
|
|
78
|
+
): Promise<RuleEvaluationResult> {
|
|
79
|
+
const scope = config.scope as { include?: unknown; exclude?: unknown } | undefined;
|
|
80
|
+
const include = stringArray(scope?.include);
|
|
81
|
+
if (include === undefined) {
|
|
82
|
+
throw new Error('forbidden-import evaluator requires string[] config "scope.include"');
|
|
83
|
+
}
|
|
84
|
+
const exclude = stringArray(scope?.exclude) ?? [];
|
|
85
|
+
const entries = (config.forbidden as ForbiddenEntry[]).map(compileEntry);
|
|
86
|
+
|
|
87
|
+
// Discover all source files, then apply scope globs precisely (discoverFiles'
|
|
88
|
+
// include matching is intentionally loose, so it cannot do `**`-anchored scoping).
|
|
89
|
+
const files = (await discoverFiles({ workdir: context.workdir }))
|
|
90
|
+
.filter((file) => include.some((glob) => matchesGlob(file, glob)))
|
|
91
|
+
.filter((file) => !exclude.some((glob) => matchesGlob(file, glob)));
|
|
92
|
+
|
|
93
|
+
const findings = [];
|
|
94
|
+
for (const file of files) {
|
|
95
|
+
const lines = (await readWorkdirFile(context.workdir, file)).split('\n');
|
|
96
|
+
for (const [index, line] of lines.entries()) {
|
|
97
|
+
const hit = entries.find((entry) => entry.regex.test(line));
|
|
98
|
+
if (hit !== undefined) {
|
|
99
|
+
findings.push(
|
|
100
|
+
createFinding(rule, `Forbidden import/usage of "${hit.label}"`, file, {
|
|
101
|
+
line: index + 1,
|
|
102
|
+
code: 'import:forbidden',
|
|
103
|
+
}),
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return { findings, fixes: [] };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Extract the specifier from an import/require/dynamic-import line, if any. */
|
|
113
|
+
function importSpecifier(line: string): string | undefined {
|
|
114
|
+
return /(?:from\s+|import\s*\(|^\s*import\s*)['"](?<specifier>[^'"]+)['"]/.exec(line)?.groups?.specifier;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Compile a forbidden entry into a line-matching regex. */
|
|
118
|
+
function compileEntry(entry: ForbiddenEntry): ScanEntry {
|
|
119
|
+
if ('specifier' in entry) {
|
|
120
|
+
const spec = escapeRegExp(entry.specifier);
|
|
121
|
+
const boundary = `(?:/|['"])`;
|
|
122
|
+
const source =
|
|
123
|
+
entry.includeRequire === false
|
|
124
|
+
? `from\\s+['"]${spec}${boundary}`
|
|
125
|
+
: `(?:from\\s+|require\\(\\s*|import\\(\\s*)['"]${spec}${boundary}`;
|
|
126
|
+
return { regex: new RegExp(source), label: entry.specifier };
|
|
127
|
+
}
|
|
128
|
+
return { regex: new RegExp(entry.pattern), label: entry.pattern };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function escapeRegExp(value: string): string {
|
|
132
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
43
133
|
}
|
|
44
134
|
|
|
45
135
|
function arrayConfig(config: Record<string, unknown>, key: string): string[] {
|
|
@@ -48,3 +138,7 @@ function arrayConfig(config: Record<string, unknown>, key: string): string[] {
|
|
|
48
138
|
if (typeof value === 'string') return [value];
|
|
49
139
|
throw new Error(`forbidden-import evaluator requires string[] config "${key}"`);
|
|
50
140
|
}
|
|
141
|
+
|
|
142
|
+
function stringArray(value: unknown): string[] | undefined {
|
|
143
|
+
return Array.isArray(value) && value.every((item) => typeof item === 'string') ? (value as string[]) : undefined;
|
|
144
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ConstraintRule,
|
|
3
|
+
createFinding,
|
|
4
|
+
type RuleContext,
|
|
5
|
+
type RuleEvaluationResult,
|
|
6
|
+
type RuleEvaluator,
|
|
7
|
+
} from '../types';
|
|
8
|
+
import { discoverFiles, matchesGlob, readWorkdirFile } from './file-utils';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A forbidden entry within a boundary declaration.
|
|
12
|
+
*
|
|
13
|
+
* - String form: substring match against any import/export/require/dynamic-import specifier.
|
|
14
|
+
* - Object form: regex `pattern` matched against the full line (mode `usage`) or import lines
|
|
15
|
+
* only (mode `import`).
|
|
16
|
+
*/
|
|
17
|
+
type ForbiddenEntry =
|
|
18
|
+
| string
|
|
19
|
+
| {
|
|
20
|
+
/** Regex pattern to match against lines. */
|
|
21
|
+
pattern: string;
|
|
22
|
+
/** `import` = restrict to import/export/require lines; `usage` = any line. Default: `import`. */
|
|
23
|
+
mode?: 'import' | 'usage';
|
|
24
|
+
/** Explicit syntax hint (informational, not enforced differently from `mode`). */
|
|
25
|
+
syntax?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** A compiled boundary ready for file scanning. */
|
|
29
|
+
interface CompiledBoundary {
|
|
30
|
+
scope: string;
|
|
31
|
+
excludePatterns: string[];
|
|
32
|
+
forbidden: Array<{ regex: RegExp; label: string; importOnly: boolean }>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Enforces architectural import boundaries without spawning a subprocess.
|
|
37
|
+
*
|
|
38
|
+
* Files matching a boundary's `scope` glob are scanned in-memory. Each forbidden
|
|
39
|
+
* entry is either a string (matched as an import-specifier substring) or an object
|
|
40
|
+
* with a `pattern` regex and an optional `mode` (`import` | `usage`).
|
|
41
|
+
*
|
|
42
|
+
* ## Options (in `evaluator.config`)
|
|
43
|
+
* - `boundaries` — non-empty array of boundary declarations:
|
|
44
|
+
* - `scope` — glob pattern selecting files this boundary applies to.
|
|
45
|
+
* - `forbidden` — array of strings or `{ pattern, mode?, syntax? }` objects.
|
|
46
|
+
* - `exclude` — optional globs within the scope to ignore.
|
|
47
|
+
*/
|
|
48
|
+
export class ImportBoundaryEvaluator implements RuleEvaluator {
|
|
49
|
+
/** Evaluate import boundaries across all in-scope files. */
|
|
50
|
+
async evaluate(rule: ConstraintRule, context: RuleContext): Promise<RuleEvaluationResult> {
|
|
51
|
+
const config = rule.evaluator.config ?? {};
|
|
52
|
+
const boundaries = config.boundaries;
|
|
53
|
+
if (!Array.isArray(boundaries) || boundaries.length === 0) {
|
|
54
|
+
throw new Error('import-boundary evaluator requires non-empty array config "boundaries"');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const compiled = (boundaries as unknown as BoundaryDecl[]).map((b) => compileBoundary(b));
|
|
58
|
+
|
|
59
|
+
// Discover all files once; filter per boundary below.
|
|
60
|
+
const allFiles = await discoverFiles({ workdir: context.workdir });
|
|
61
|
+
|
|
62
|
+
const findings = [];
|
|
63
|
+
for (const boundary of compiled) {
|
|
64
|
+
const inScope = allFiles
|
|
65
|
+
.filter((file) => matchesGlob(file, boundary.scope))
|
|
66
|
+
.filter((file) => !boundary.excludePatterns.some((ex) => matchesGlob(file, ex)));
|
|
67
|
+
|
|
68
|
+
for (const file of inScope) {
|
|
69
|
+
const content = await readWorkdirFile(context.workdir, file);
|
|
70
|
+
const lines = content.split('\n');
|
|
71
|
+
for (const [index, line] of lines.entries()) {
|
|
72
|
+
for (const entry of boundary.forbidden) {
|
|
73
|
+
if (entry.importOnly && !isImportLine(line)) continue;
|
|
74
|
+
if (entry.regex.test(line)) {
|
|
75
|
+
findings.push(
|
|
76
|
+
createFinding(rule, `forbidden in boundary "${boundary.scope}": ${entry.label}`, file, {
|
|
77
|
+
line: index + 1,
|
|
78
|
+
code: 'import-boundary:violation',
|
|
79
|
+
}),
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return { findings, fixes: [] };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Raw shape of one boundary declaration from the config. */
|
|
92
|
+
interface BoundaryDecl {
|
|
93
|
+
scope: string;
|
|
94
|
+
forbidden: ForbiddenEntry[];
|
|
95
|
+
exclude?: string[];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Compile a raw boundary declaration into a scan-ready form. */
|
|
99
|
+
function compileBoundary(decl: BoundaryDecl): CompiledBoundary {
|
|
100
|
+
return {
|
|
101
|
+
scope: decl.scope,
|
|
102
|
+
excludePatterns: decl.exclude ?? [],
|
|
103
|
+
forbidden: decl.forbidden.map((entry) => compileEntry(entry)),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Compile one forbidden entry into a regex + metadata. */
|
|
108
|
+
function compileEntry(entry: ForbiddenEntry): { regex: RegExp; label: string; importOnly: boolean } {
|
|
109
|
+
if (typeof entry === 'string') {
|
|
110
|
+
// String form: match as an import specifier substring.
|
|
111
|
+
const escaped = escapeRegExp(entry);
|
|
112
|
+
return {
|
|
113
|
+
regex: new RegExp(`(?:from\\s+|require\\(\\s*|import\\(\\s*)['"](?:[^'"]*)?${escaped}(?:[^'"]*)?['"]`),
|
|
114
|
+
label: entry,
|
|
115
|
+
importOnly: true,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Object form with `pattern`.
|
|
120
|
+
const importOnly = (entry.mode ?? 'import') !== 'usage';
|
|
121
|
+
return {
|
|
122
|
+
regex: new RegExp(entry.pattern),
|
|
123
|
+
label: entry.pattern,
|
|
124
|
+
importOnly,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Return true when a source line is an import/export/require/dynamic-import statement. */
|
|
129
|
+
function isImportLine(line: string): boolean {
|
|
130
|
+
return /(?:^\s*import\b|^\s*export\b.*\bfrom\b|(?:from|require|import)\s*\(?\s*['"])/.test(line);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function escapeRegExp(value: string): string {
|
|
134
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
135
|
+
}
|
|
@@ -7,8 +7,18 @@ import {
|
|
|
7
7
|
type RuleEvaluationResult,
|
|
8
8
|
type RuleEvaluator,
|
|
9
9
|
} from '../types';
|
|
10
|
+
import { discoverFiles, matchesGlob } from './file-utils';
|
|
10
11
|
|
|
11
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* Evaluates file/directory presence constraints.
|
|
14
|
+
*
|
|
15
|
+
* Two config shapes are supported:
|
|
16
|
+
* - Glob form: `{ must: 'present' | 'absent' }` scoped by the rule's `include` /
|
|
17
|
+
* `exclude` globs. `present` flags each include glob that matches zero files;
|
|
18
|
+
* `absent` flags each in-scope file that exists.
|
|
19
|
+
* - Explicit form: `{ paths: string[], mode?: 'require' | 'forbid' }` — checks the
|
|
20
|
+
* exact paths relative to the workdir (`require` = must exist, `forbid` = must not).
|
|
21
|
+
*/
|
|
12
22
|
export class PathEvaluator implements RuleEvaluator {
|
|
13
23
|
private readonly fs: NodeFileSystem;
|
|
14
24
|
|
|
@@ -16,9 +26,62 @@ export class PathEvaluator implements RuleEvaluator {
|
|
|
16
26
|
this.fs = new NodeFileSystem();
|
|
17
27
|
}
|
|
18
28
|
|
|
19
|
-
/** Evaluate required or forbidden paths. */
|
|
29
|
+
/** Evaluate required or forbidden paths in either config form. */
|
|
20
30
|
async evaluate(rule: ConstraintRule, context: RuleContext): Promise<RuleEvaluationResult> {
|
|
21
31
|
const config = rule.evaluator.config ?? {};
|
|
32
|
+
const must = config.must;
|
|
33
|
+
if (must === 'present' || must === 'absent') {
|
|
34
|
+
return this.evaluateGlob(rule, context, must);
|
|
35
|
+
}
|
|
36
|
+
return this.evaluateExplicit(rule, context, config);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Glob form driven by `must` + the rule's include/exclude globs. */
|
|
40
|
+
private async evaluateGlob(
|
|
41
|
+
rule: ConstraintRule,
|
|
42
|
+
context: RuleContext,
|
|
43
|
+
must: 'present' | 'absent',
|
|
44
|
+
): Promise<RuleEvaluationResult> {
|
|
45
|
+
const include = rule.include ?? ['**'];
|
|
46
|
+
const exclude = rule.exclude ?? [];
|
|
47
|
+
const files = await discoverFiles({ workdir: context.workdir });
|
|
48
|
+
const inScope = (file: string) =>
|
|
49
|
+
include.some((glob) => matchesGlob(file, glob)) && !exclude.some((glob) => matchesGlob(file, glob));
|
|
50
|
+
const findings = [];
|
|
51
|
+
|
|
52
|
+
if (must === 'present') {
|
|
53
|
+
for (const pattern of include) {
|
|
54
|
+
const present = files.some(
|
|
55
|
+
(file) => matchesGlob(file, pattern) && !exclude.some((g) => matchesGlob(file, g)),
|
|
56
|
+
);
|
|
57
|
+
if (!present) {
|
|
58
|
+
findings.push(
|
|
59
|
+
createFinding(rule, `expected files matching "${pattern}", but none found`, pattern, {
|
|
60
|
+
code: 'path:missing',
|
|
61
|
+
}),
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
} else {
|
|
66
|
+
for (const file of files) {
|
|
67
|
+
if (inScope(file)) {
|
|
68
|
+
findings.push(
|
|
69
|
+
createFinding(rule, 'file should be absent (forbidden by rule)', file, {
|
|
70
|
+
code: 'path:forbidden',
|
|
71
|
+
}),
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return { findings, fixes: [] };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Explicit form: exact `paths` checked with `mode` require/forbid. */
|
|
80
|
+
private async evaluateExplicit(
|
|
81
|
+
rule: ConstraintRule,
|
|
82
|
+
context: RuleContext,
|
|
83
|
+
config: Record<string, unknown>,
|
|
84
|
+
): Promise<RuleEvaluationResult> {
|
|
22
85
|
const paths = arrayConfig(config, 'paths');
|
|
23
86
|
const mode = stringConfig(config, 'mode', 'require');
|
|
24
87
|
const findings = [];
|
|
@@ -39,7 +102,7 @@ function arrayConfig(config: Record<string, unknown>, key: string): string[] {
|
|
|
39
102
|
const value = config[key];
|
|
40
103
|
if (Array.isArray(value) && value.every((item) => typeof item === 'string')) return value;
|
|
41
104
|
if (typeof value === 'string') return [value];
|
|
42
|
-
throw new Error(`path evaluator requires
|
|
105
|
+
throw new Error(`path evaluator requires config "${key}" (string[]) or "must" (present|absent)`);
|
|
43
106
|
}
|
|
44
107
|
|
|
45
108
|
function stringConfig(config: Record<string, unknown>, key: string, fallback: string): string {
|
|
@@ -14,26 +14,38 @@ export class RegexEvaluator implements RuleEvaluator {
|
|
|
14
14
|
/** Evaluate regex-based presence or absence constraints. */
|
|
15
15
|
async evaluate(rule: ConstraintRule, context: RuleContext): Promise<RuleEvaluationResult> {
|
|
16
16
|
const config = rule.evaluator.config ?? {};
|
|
17
|
-
const pattern =
|
|
17
|
+
const { pattern, flags } = normalizePattern(
|
|
18
|
+
stringConfig(config, 'pattern'),
|
|
19
|
+
stringConfig(config, 'flags', ''),
|
|
20
|
+
config.multiline === true,
|
|
21
|
+
);
|
|
18
22
|
const mode = stringConfig(config, 'mode', 'forbid');
|
|
19
|
-
const flags = stringConfig(config, 'flags', 'm');
|
|
20
23
|
const regex = new RegExp(pattern, flags);
|
|
21
24
|
const files = await discoverFiles({ workdir: context.workdir, include: rule.include, exclude: rule.exclude });
|
|
22
25
|
const findings = [];
|
|
23
26
|
|
|
24
27
|
for (const file of files) {
|
|
25
28
|
const content = await readWorkdirFile(context.workdir, file);
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
if (mode === 'require') {
|
|
30
|
+
regex.lastIndex = 0;
|
|
31
|
+
if (!regex.test(content)) {
|
|
32
|
+
findings.push(
|
|
33
|
+
createFinding(rule, `required pattern not found: ${pattern}`, file, { code: 'regex:missing' }),
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
continue;
|
|
32
37
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
)
|
|
38
|
+
// forbid: report each matching line so findings carry precise locations.
|
|
39
|
+
for (const [index, line] of content.split('\n').entries()) {
|
|
40
|
+
regex.lastIndex = 0;
|
|
41
|
+
if (regex.test(line)) {
|
|
42
|
+
findings.push(
|
|
43
|
+
createFinding(rule, `forbidden pattern found: ${pattern}`, file, {
|
|
44
|
+
line: index + 1,
|
|
45
|
+
code: 'regex:found',
|
|
46
|
+
}),
|
|
47
|
+
);
|
|
48
|
+
}
|
|
37
49
|
}
|
|
38
50
|
}
|
|
39
51
|
|
|
@@ -41,6 +53,35 @@ export class RegexEvaluator implements RuleEvaluator {
|
|
|
41
53
|
}
|
|
42
54
|
}
|
|
43
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Normalize a pattern + flags for JS `RegExp`.
|
|
58
|
+
*
|
|
59
|
+
* Accepts a leading `(?i)`/`(?im)` inline flag group (ripgrep/PCRE style) and
|
|
60
|
+
* folds it into the JS flags. `multiline` adds the `s` (dotAll) flag so `.`
|
|
61
|
+
* spans newlines, matching the old `--multiline` behavior. `m` is always set so
|
|
62
|
+
* `^`/`$` work per line.
|
|
63
|
+
*/
|
|
64
|
+
function normalizePattern(
|
|
65
|
+
rawPattern: string,
|
|
66
|
+
rawFlags: string,
|
|
67
|
+
multiline: boolean,
|
|
68
|
+
): { pattern: string; flags: string } {
|
|
69
|
+
const flagSet = new Set<string>(['m']);
|
|
70
|
+
for (const flag of rawFlags) {
|
|
71
|
+
if ('gimsuy'.includes(flag)) flagSet.add(flag);
|
|
72
|
+
}
|
|
73
|
+
let pattern = rawPattern;
|
|
74
|
+
const inline = /^\(\?([a-z]+)\)/.exec(pattern);
|
|
75
|
+
if (inline) {
|
|
76
|
+
for (const flag of inline[1] ?? '') {
|
|
77
|
+
if ('imsu'.includes(flag)) flagSet.add(flag);
|
|
78
|
+
}
|
|
79
|
+
pattern = pattern.slice(inline[0].length);
|
|
80
|
+
}
|
|
81
|
+
if (multiline) flagSet.add('s');
|
|
82
|
+
return { pattern, flags: [...flagSet].join('') };
|
|
83
|
+
}
|
|
84
|
+
|
|
44
85
|
function stringConfig(config: Record<string, unknown>, key: string, fallback?: string): string {
|
|
45
86
|
const value = config[key];
|
|
46
87
|
if (typeof value === 'string') return value;
|
|
@@ -0,0 +1,134 @@
|
|
|
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
|
+
/**
|
|
12
|
+
* Evaluates JSON schema artifact files for structural integrity.
|
|
13
|
+
*
|
|
14
|
+
* Pure JS — no subprocess. Validates JSON validity, title, required properties,
|
|
15
|
+
* `$defs` / `definitions` entries, and the presence of a top-level `required` array.
|
|
16
|
+
*
|
|
17
|
+
* ## Options (in `evaluator.config`)
|
|
18
|
+
* - `file` — path to the JSON file relative to the workdir (required).
|
|
19
|
+
* - `requiredTitle` — expected `title` value (optional).
|
|
20
|
+
* - `requiredProperties` — top-level `properties` keys that must exist (optional).
|
|
21
|
+
* - `requiredDefs` — `$defs` or `definitions` keys that must exist (optional).
|
|
22
|
+
* - `requireRequiredArray` — enforce that `required` is a non-empty array (default: `false`).
|
|
23
|
+
*/
|
|
24
|
+
export class SchemaArtifactEvaluator implements RuleEvaluator {
|
|
25
|
+
private readonly fs: NodeFileSystem;
|
|
26
|
+
|
|
27
|
+
constructor() {
|
|
28
|
+
this.fs = new NodeFileSystem();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Evaluate the configured JSON schema artifact. */
|
|
32
|
+
async evaluate(rule: ConstraintRule, context: RuleContext): Promise<RuleEvaluationResult> {
|
|
33
|
+
const config = rule.evaluator.config ?? {};
|
|
34
|
+
const file = config.file;
|
|
35
|
+
if (typeof file !== 'string' || file.length === 0) {
|
|
36
|
+
throw new Error('schema-artifact evaluator requires string config "file"');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const requiredTitle = typeof config.requiredTitle === 'string' ? config.requiredTitle : undefined;
|
|
40
|
+
const requiredProperties = stringArray(config.requiredProperties);
|
|
41
|
+
const requiredDefs = stringArray(config.requiredDefs);
|
|
42
|
+
const requireRequiredArray = config.requireRequiredArray === true;
|
|
43
|
+
|
|
44
|
+
// Check existence.
|
|
45
|
+
const absolutePath = resolve(context.workdir, file);
|
|
46
|
+
const exists = await this.fs.exists(absolutePath);
|
|
47
|
+
if (!exists) {
|
|
48
|
+
return {
|
|
49
|
+
findings: [
|
|
50
|
+
createFinding(rule, 'schema artifact not found', file, {
|
|
51
|
+
code: 'schema-artifact:missing',
|
|
52
|
+
}),
|
|
53
|
+
],
|
|
54
|
+
fixes: [],
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Read and parse.
|
|
59
|
+
let schema: Record<string, unknown>;
|
|
60
|
+
try {
|
|
61
|
+
const raw = await this.fs.readFile(absolutePath);
|
|
62
|
+
schema = JSON.parse(raw) as Record<string, unknown>;
|
|
63
|
+
} catch (err) {
|
|
64
|
+
return {
|
|
65
|
+
findings: [
|
|
66
|
+
createFinding(rule, `invalid JSON: ${(err as Error).message}`, file, {
|
|
67
|
+
code: 'schema-artifact:invalid',
|
|
68
|
+
}),
|
|
69
|
+
],
|
|
70
|
+
fixes: [],
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const findings = [];
|
|
75
|
+
|
|
76
|
+
// Validate title.
|
|
77
|
+
if (requiredTitle !== undefined && schema.title !== requiredTitle) {
|
|
78
|
+
findings.push(
|
|
79
|
+
createFinding(
|
|
80
|
+
rule,
|
|
81
|
+
`${file} title expected '${requiredTitle}', got '${(schema.title as string | undefined) ?? '(missing)'}'`,
|
|
82
|
+
file,
|
|
83
|
+
{ code: 'schema-artifact:violation' },
|
|
84
|
+
),
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Validate required array.
|
|
89
|
+
if (requireRequiredArray && !Array.isArray(schema.required)) {
|
|
90
|
+
findings.push(
|
|
91
|
+
createFinding(rule, `${file} missing 'required' array at top level`, file, {
|
|
92
|
+
code: 'schema-artifact:violation',
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Validate properties.
|
|
98
|
+
if (requiredProperties !== undefined) {
|
|
99
|
+
const props = schema.properties as Record<string, unknown> | undefined;
|
|
100
|
+
for (const prop of requiredProperties) {
|
|
101
|
+
if (!props || !(prop in props)) {
|
|
102
|
+
findings.push(
|
|
103
|
+
createFinding(rule, `${file} missing properties.${prop}`, file, {
|
|
104
|
+
code: 'schema-artifact:violation',
|
|
105
|
+
}),
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Validate $defs / definitions.
|
|
112
|
+
if (requiredDefs !== undefined) {
|
|
113
|
+
const defs =
|
|
114
|
+
(schema.$defs as Record<string, unknown> | undefined) ??
|
|
115
|
+
(schema.definitions as Record<string, unknown> | undefined);
|
|
116
|
+
for (const def of requiredDefs) {
|
|
117
|
+
if (!defs || !(def in defs)) {
|
|
118
|
+
findings.push(
|
|
119
|
+
createFinding(rule, `${file} missing $defs.${def}`, file, {
|
|
120
|
+
code: 'schema-artifact:violation',
|
|
121
|
+
}),
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return { findings, fixes: [] };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Return a string array if value is a string array, otherwise undefined. */
|
|
132
|
+
function stringArray(value: unknown): string[] | undefined {
|
|
133
|
+
return Array.isArray(value) && value.every((item) => typeof item === 'string') ? (value as string[]) : undefined;
|
|
134
|
+
}
|