@gobing-ai/ts-rule-engine 0.3.1 → 0.3.3
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 +328 -58
- package/dist/config/extensions.d.ts +10 -7
- package/dist/config/extensions.d.ts.map +1 -1
- package/dist/config/extensions.js +48 -23
- package/dist/config/loader.d.ts +7 -0
- package/dist/config/loader.d.ts.map +1 -1
- package/dist/config/loader.js +17 -12
- package/dist/engine.d.ts +13 -2
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +107 -45
- package/dist/evaluators/agent-detection-evaluator.d.ts.map +1 -1
- package/dist/evaluators/agent-detection-evaluator.js +2 -9
- package/dist/evaluators/coverage-gate-evaluator.d.ts.map +1 -1
- package/dist/evaluators/coverage-gate-evaluator.js +4 -5
- package/dist/evaluators/exit-code-evaluator.d.ts.map +1 -1
- package/dist/evaluators/exit-code-evaluator.js +6 -23
- package/dist/evaluators/file-utils.d.ts +25 -1
- package/dist/evaluators/file-utils.d.ts.map +1 -1
- package/dist/evaluators/file-utils.js +48 -8
- package/dist/evaluators/forbidden-import-evaluator.js +2 -10
- package/dist/evaluators/path-evaluator.d.ts.map +1 -1
- package/dist/evaluators/path-evaluator.js +5 -18
- package/dist/evaluators/regex-evaluator.js +3 -11
- package/dist/evaluators/ripgrep-evaluator.d.ts +50 -0
- package/dist/evaluators/ripgrep-evaluator.d.ts.map +1 -0
- package/dist/evaluators/ripgrep-evaluator.js +145 -0
- package/dist/evaluators/schema-artifact-evaluator.d.ts.map +1 -1
- package/dist/evaluators/schema-artifact-evaluator.js +3 -7
- package/dist/evaluators/sg-evaluator.d.ts +10 -2
- package/dist/evaluators/sg-evaluator.d.ts.map +1 -1
- package/dist/evaluators/sg-evaluator.js +21 -4
- package/dist/evaluators/test-location-evaluator.d.ts +2 -2
- package/dist/evaluators/test-location-evaluator.d.ts.map +1 -1
- package/dist/evaluators/test-location-evaluator.js +2 -15
- package/dist/events.d.ts +33 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +0 -0
- package/dist/fixers/fixers.d.ts +1 -1
- package/dist/fixers/fixers.d.ts.map +1 -1
- package/dist/fixers/fixers.js +4 -5
- package/dist/fixers/test-stub-fixer.d.ts +1 -1
- package/dist/fixers/test-stub-fixer.d.ts.map +1 -1
- package/dist/fixers/test-stub-fixer.js +3 -4
- package/dist/host/builtins.d.ts.map +1 -1
- package/dist/host/builtins.js +5 -3
- package/dist/host/bundled-rules.d.ts +1 -1
- package/dist/host/bundled-rules.js +1 -1
- package/dist/host/rule-engine-host.d.ts +1 -1
- package/dist/host/rule-engine-host.d.ts.map +1 -1
- package/dist/host/rule-engine-host.js +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -1
- package/dist/types.d.ts +2 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +6 -0
- package/package.json +4 -5
- package/rules/example.yaml +13 -0
- package/src/config/extensions.ts +58 -29
- package/src/config/loader.ts +27 -12
- package/src/engine.ts +132 -47
- package/src/evaluators/agent-detection-evaluator.ts +2 -8
- package/src/evaluators/coverage-gate-evaluator.ts +4 -5
- package/src/evaluators/exit-code-evaluator.ts +6 -23
- package/src/evaluators/file-utils.ts +70 -8
- package/src/evaluators/forbidden-import-evaluator.ts +2 -9
- package/src/evaluators/path-evaluator.ts +5 -18
- package/src/evaluators/regex-evaluator.ts +4 -11
- package/src/evaluators/ripgrep-evaluator.ts +167 -0
- package/src/evaluators/schema-artifact-evaluator.ts +3 -8
- package/src/evaluators/sg-evaluator.ts +21 -4
- package/src/evaluators/test-location-evaluator.ts +3 -16
- package/src/events.ts +13 -0
- package/src/fixers/fixers.ts +12 -6
- package/src/fixers/test-stub-fixer.ts +4 -5
- package/src/host/builtins.ts +5 -3
- package/src/host/bundled-rules.ts +1 -1
- package/src/host/rule-engine-host.ts +1 -1
- package/src/index.ts +8 -1
- package/src/types.ts +7 -0
- package/dist/host/capability-registry.d.ts +0 -10
- package/dist/host/capability-registry.d.ts.map +0 -1
- package/dist/host/capability-registry.js +0 -9
- package/rules/recommended.yaml +0 -10
- package/rules/spur-dev.yaml +0 -6
- package/src/host/capability-registry.ts +0 -9
package/src/engine.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import type { EventBus, Logger } from '@gobing-ai/ts-infra';
|
|
2
|
+
import { addSpanEvent, getLogger, traceAsync } from '@gobing-ai/ts-infra';
|
|
1
3
|
import type { ProcessExecutor } from '@gobing-ai/ts-runtime';
|
|
4
|
+
import type { RuleEngineEvents } from './events';
|
|
2
5
|
import {
|
|
3
6
|
applyFixes as applyFixesImpl,
|
|
4
7
|
builtInFixers,
|
|
@@ -10,7 +13,7 @@ import {
|
|
|
10
13
|
import { registerBuiltins } from './host/builtins';
|
|
11
14
|
import { RuleEngineHost } from './host/rule-engine-host';
|
|
12
15
|
import type { ConstraintFinding, ConstraintRule, Fix, FixMode, RuleEngineResult, RuleEvaluator } from './types';
|
|
13
|
-
import { createFinding } from './types';
|
|
16
|
+
import { createFinding, SEVERITY_RANK } from './types';
|
|
14
17
|
|
|
15
18
|
/** Options for constructing a RuleEngine. */
|
|
16
19
|
export interface RuleEngineOptions {
|
|
@@ -18,6 +21,10 @@ export interface RuleEngineOptions {
|
|
|
18
21
|
processExecutor?: ProcessExecutor;
|
|
19
22
|
/** Optional preconfigured host. */
|
|
20
23
|
host?: RuleEngineHost;
|
|
24
|
+
/** Optional event bus for structured run observability (R-A4). */
|
|
25
|
+
events?: EventBus<RuleEngineEvents>;
|
|
26
|
+
/** Optional logger; defaults to the shared `rule-engine` category logger. */
|
|
27
|
+
logger?: Logger;
|
|
21
28
|
}
|
|
22
29
|
|
|
23
30
|
/** Orchestrates enabled constraint rules through a typed evaluator host. */
|
|
@@ -27,11 +34,15 @@ export class RuleEngine {
|
|
|
27
34
|
|
|
28
35
|
/** Fixer providers keyed by evaluator type. */
|
|
29
36
|
private readonly fixers: Map<string, RuleFixerProvider>;
|
|
37
|
+
private readonly events: EventBus<RuleEngineEvents> | undefined;
|
|
38
|
+
private readonly logger: Logger;
|
|
30
39
|
|
|
31
40
|
constructor(options: RuleEngineOptions = {}) {
|
|
32
41
|
this.host = options.host ?? new RuleEngineHost();
|
|
33
42
|
registerBuiltins(this.host, options.processExecutor);
|
|
34
43
|
this.fixers = builtInFixers(this.host, options.processExecutor);
|
|
44
|
+
this.events = options.events;
|
|
45
|
+
this.logger = options.logger ?? getLogger('rule-engine');
|
|
35
46
|
}
|
|
36
47
|
|
|
37
48
|
/** Register or replace an evaluator. */
|
|
@@ -47,8 +58,12 @@ export class RuleEngine {
|
|
|
47
58
|
* findings, never auto-generated fixes. Keeps the rule loop and error-finding
|
|
48
59
|
* semantics in one place.
|
|
49
60
|
*/
|
|
50
|
-
async evaluate(
|
|
51
|
-
|
|
61
|
+
async evaluate(
|
|
62
|
+
rules: ConstraintRule[],
|
|
63
|
+
workdir: string,
|
|
64
|
+
stopOnFirst?: 'error' | 'warning' | 'info',
|
|
65
|
+
): Promise<RuleEngineResult> {
|
|
66
|
+
return this.evaluateWithFixes(rules, workdir, 'none', stopOnFirst);
|
|
52
67
|
}
|
|
53
68
|
|
|
54
69
|
/**
|
|
@@ -61,60 +76,130 @@ export class RuleEngine {
|
|
|
61
76
|
* @param rules - Normalized rule definitions to evaluate.
|
|
62
77
|
* @param workdir - Working directory to scan.
|
|
63
78
|
* @param maxFixMode - Highest fix authority requested by the caller.
|
|
79
|
+
* @param stopOnFirst - When set, stop evaluating rules after the first rule
|
|
80
|
+
* whose findings meet/exceed this severity threshold. Undefined = exhaustive
|
|
81
|
+
* (today's behavior, zero breaking change).
|
|
64
82
|
* @returns Findings plus fixes allowed by the requested authority.
|
|
65
83
|
*/
|
|
66
84
|
async evaluateWithFixes(
|
|
67
85
|
rules: ConstraintRule[],
|
|
68
86
|
workdir: string,
|
|
69
87
|
maxFixMode: FixMode = 'auto',
|
|
88
|
+
stopOnFirst?: 'error' | 'warning' | 'info',
|
|
70
89
|
): Promise<RuleEngineResult> {
|
|
71
|
-
const
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
90
|
+
const enabledRules = rules.filter((r) => r.enabled !== false);
|
|
91
|
+
const runStartMs = Date.now();
|
|
92
|
+
|
|
93
|
+
return await traceAsync(
|
|
94
|
+
'rule.run',
|
|
95
|
+
async () => {
|
|
96
|
+
this.logger.info('rule run started', { enabled: enabledRules.length, total: rules.length });
|
|
97
|
+
addSpanEvent('rule.run.start', { rules: enabledRules.length, total: rules.length });
|
|
98
|
+
void this.events?.emit('rule.run.start', { rules: enabledRules.length, total: rules.length });
|
|
99
|
+
|
|
100
|
+
const findings: ConstraintFinding[] = [];
|
|
101
|
+
const fixes: Fix[] = [];
|
|
102
|
+
let index = 0;
|
|
103
|
+
let stoppedEarlyLocal = false;
|
|
104
|
+
|
|
105
|
+
for (const rule of rules) {
|
|
106
|
+
if (rule.enabled === false) continue;
|
|
107
|
+
index++;
|
|
108
|
+
|
|
109
|
+
const evalStartMs = Date.now();
|
|
110
|
+
this.logger.debug('eval start', { ruleId: rule.id, index, total: enabledRules.length });
|
|
111
|
+
addSpanEvent('rule.eval.start', { ruleId: rule.id, index, total: enabledRules.length });
|
|
112
|
+
void this.events?.emit('rule.eval.start', { ruleId: rule.id, index, total: enabledRules.length });
|
|
113
|
+
|
|
114
|
+
let ruleFindings: ConstraintFinding[] = [];
|
|
115
|
+
let ruleEvalFixes: Fix[] = [];
|
|
116
|
+
try {
|
|
117
|
+
const result = await this.host.evaluators
|
|
118
|
+
.get(rule.evaluator.type)
|
|
119
|
+
.evaluate(rule, { rule, workdir });
|
|
120
|
+
ruleFindings = result.findings;
|
|
121
|
+
ruleEvalFixes = result.fixes;
|
|
122
|
+
} catch (error) {
|
|
123
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
124
|
+
ruleFindings = [
|
|
125
|
+
createFinding(rule, message, null, {
|
|
126
|
+
code: `evaluator:${rule.evaluator.type}`,
|
|
127
|
+
kind: 'error',
|
|
128
|
+
}),
|
|
129
|
+
];
|
|
130
|
+
addSpanEvent('rule.eval.error', { ruleId: rule.id, error: message });
|
|
131
|
+
void this.events?.emit('rule.eval.error', { ruleId: rule.id, error: message });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const durationMs = Date.now() - evalStartMs;
|
|
135
|
+
addSpanEvent('rule.eval.done', {
|
|
136
|
+
ruleId: rule.id,
|
|
137
|
+
findings: ruleFindings.length,
|
|
138
|
+
durationMs,
|
|
139
|
+
});
|
|
140
|
+
void this.events?.emit('rule.eval.done', {
|
|
141
|
+
ruleId: rule.id,
|
|
142
|
+
findings: ruleFindings.length,
|
|
143
|
+
durationMs,
|
|
111
144
|
});
|
|
112
|
-
|
|
145
|
+
|
|
146
|
+
findings.push(...ruleFindings);
|
|
147
|
+
fixes.push(...ruleEvalFixes);
|
|
148
|
+
|
|
149
|
+
const ruleMode = rule.fix?.mode ?? 'none';
|
|
150
|
+
const effectiveMode = effectiveFixMode(ruleMode, maxFixMode);
|
|
151
|
+
|
|
152
|
+
if (effectiveMode !== 'none' && ruleFindings.length > 0) {
|
|
153
|
+
const provider = this.fixers.get(rule.evaluator.type);
|
|
154
|
+
if (provider) {
|
|
155
|
+
const effectiveFix: EffectiveFix = {
|
|
156
|
+
mode: effectiveMode,
|
|
157
|
+
...(rule.fix?.replacement !== undefined ? { replacement: rule.fix.replacement } : {}),
|
|
158
|
+
...(rule.fix?.params !== undefined ? { params: rule.fix.params } : {}),
|
|
159
|
+
};
|
|
160
|
+
const providerFixes = await provider.createFixes({
|
|
161
|
+
rule,
|
|
162
|
+
context: { rule, workdir },
|
|
163
|
+
findings: ruleFindings,
|
|
164
|
+
fix: effectiveFix,
|
|
165
|
+
});
|
|
166
|
+
fixes.push(...providerFixes);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (
|
|
171
|
+
stopOnFirst &&
|
|
172
|
+
ruleFindings.some((f) => SEVERITY_RANK[f.severity] >= SEVERITY_RANK[stopOnFirst])
|
|
173
|
+
) {
|
|
174
|
+
stoppedEarlyLocal = true;
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
113
177
|
}
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
178
|
|
|
117
|
-
|
|
179
|
+
const runDurationMs = Date.now() - runStartMs;
|
|
180
|
+
this.logger.info('rule run done', {
|
|
181
|
+
rules: enabledRules.length,
|
|
182
|
+
findings: findings.length,
|
|
183
|
+
durationMs: runDurationMs,
|
|
184
|
+
stoppedEarly: stoppedEarlyLocal,
|
|
185
|
+
});
|
|
186
|
+
addSpanEvent('rule.run.done', {
|
|
187
|
+
rules: enabledRules.length,
|
|
188
|
+
findings: findings.length,
|
|
189
|
+
durationMs: runDurationMs,
|
|
190
|
+
stoppedEarly: stoppedEarlyLocal,
|
|
191
|
+
});
|
|
192
|
+
void this.events?.emit('rule.run.done', {
|
|
193
|
+
rules: enabledRules.length,
|
|
194
|
+
findings: findings.length,
|
|
195
|
+
durationMs: runDurationMs,
|
|
196
|
+
stoppedEarly: stoppedEarlyLocal,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
return { findings, fixes };
|
|
200
|
+
},
|
|
201
|
+
{ attributes: { 'rule.count': enabledRules.length } },
|
|
202
|
+
);
|
|
118
203
|
}
|
|
119
204
|
|
|
120
205
|
/**
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
type RuleEvaluationResult,
|
|
7
7
|
type RuleEvaluator,
|
|
8
8
|
} from '../types';
|
|
9
|
+
import { configArray } from './file-utils';
|
|
9
10
|
|
|
10
11
|
/** Evaluates local availability of configured coding agents. */
|
|
11
12
|
export class AgentDetectionEvaluator implements RuleEvaluator {
|
|
@@ -13,7 +14,7 @@ export class AgentDetectionEvaluator implements RuleEvaluator {
|
|
|
13
14
|
|
|
14
15
|
/** Probe required agents and emit findings for missing CLIs. */
|
|
15
16
|
async evaluate(rule: ConstraintRule, _context: RuleContext): Promise<RuleEvaluationResult> {
|
|
16
|
-
const agents =
|
|
17
|
+
const agents = configArray(rule.evaluator.config ?? {}, 'agents', undefined, { evaluator: 'agent-detection' });
|
|
17
18
|
const findings = [];
|
|
18
19
|
for (const agent of agents) {
|
|
19
20
|
if (!isAgentName(agent)) {
|
|
@@ -28,10 +29,3 @@ export class AgentDetectionEvaluator implements RuleEvaluator {
|
|
|
28
29
|
return { findings, fixes: [] };
|
|
29
30
|
}
|
|
30
31
|
}
|
|
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
|
-
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { NodeFileSystem } from '@gobing-ai/ts-runtime';
|
|
1
|
+
import { isAbsolutePath, NodeFileSystem, relativePath, resolvePath } from '@gobing-ai/ts-runtime';
|
|
3
2
|
import {
|
|
4
3
|
type ConstraintRule,
|
|
5
4
|
createFinding,
|
|
@@ -55,8 +54,8 @@ export class CoverageGateEvaluator implements RuleEvaluator {
|
|
|
55
54
|
async evaluate(rule: ConstraintRule, context: RuleContext): Promise<RuleEvaluationResult> {
|
|
56
55
|
const config = (rule.evaluator.config ?? {}) as CoverageGateConfig;
|
|
57
56
|
const lcovPath = config.lcovPath
|
|
58
|
-
?
|
|
59
|
-
:
|
|
57
|
+
? resolvePath(context.workdir, config.lcovPath)
|
|
58
|
+
: resolvePath(context.workdir, 'coverage', 'lcov.info');
|
|
60
59
|
|
|
61
60
|
if (!(await this.fs.exists(lcovPath))) {
|
|
62
61
|
return {
|
|
@@ -142,6 +141,6 @@ function isAlwaysExcluded(filePath: string): boolean {
|
|
|
142
141
|
|
|
143
142
|
/** Normalize an lcov `SF:` path to a workdir-relative forward-slash path. */
|
|
144
143
|
function normalizeLcovSourcePath(workdir: string, filePath: string): string {
|
|
145
|
-
const normalized =
|
|
144
|
+
const normalized = isAbsolutePath(filePath) ? relativePath(workdir, filePath) : filePath;
|
|
146
145
|
return normalized.replaceAll('\\', '/');
|
|
147
146
|
}
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
type RuleEvaluationResult,
|
|
7
7
|
type RuleEvaluator,
|
|
8
8
|
} from '../types';
|
|
9
|
+
import { configArray, configNumber, configString } from './file-utils';
|
|
9
10
|
|
|
10
11
|
/** Evaluates a rule by running a subprocess and checking its exit code. */
|
|
11
12
|
export class ExitCodeEvaluator implements RuleEvaluator {
|
|
@@ -14,10 +15,10 @@ export class ExitCodeEvaluator implements RuleEvaluator {
|
|
|
14
15
|
/** Run configured command and emit a finding unless the exit code matches `successCode`. */
|
|
15
16
|
async evaluate(rule: ConstraintRule, context: RuleContext): Promise<RuleEvaluationResult> {
|
|
16
17
|
const config = rule.evaluator.config ?? {};
|
|
17
|
-
const command =
|
|
18
|
-
const args =
|
|
19
|
-
const successCode =
|
|
20
|
-
const timeout =
|
|
18
|
+
const command = configString(config, 'command', undefined, { evaluator: 'exit-code' });
|
|
19
|
+
const args = configArray(config, 'args', []);
|
|
20
|
+
const successCode = configNumber(config, 'successCode', 0);
|
|
21
|
+
const timeout = configNumber(config, 'timeout', 60_000);
|
|
21
22
|
const result = await this.executor.run({
|
|
22
23
|
command,
|
|
23
24
|
args,
|
|
@@ -28,7 +29,7 @@ export class ExitCodeEvaluator implements RuleEvaluator {
|
|
|
28
29
|
});
|
|
29
30
|
if (result.exitCode === successCode) return { findings: [], fixes: [] };
|
|
30
31
|
|
|
31
|
-
const template =
|
|
32
|
+
const template = configString(
|
|
32
33
|
config,
|
|
33
34
|
'message',
|
|
34
35
|
`Command failed (exit {code}): ${command} ${args.join(' ')}`.trim(),
|
|
@@ -40,21 +41,3 @@ export class ExitCodeEvaluator implements RuleEvaluator {
|
|
|
40
41
|
};
|
|
41
42
|
}
|
|
42
43
|
}
|
|
43
|
-
|
|
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 {
|
|
50
|
-
const value = config[key];
|
|
51
|
-
if (typeof value === 'string') return value;
|
|
52
|
-
if (fallback !== undefined) return fallback;
|
|
53
|
-
throw new Error(`exit-code evaluator requires string config "${key}"`);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function arrayConfig(config: Record<string, unknown>, key: string, fallback: string[]): string[] {
|
|
57
|
-
const value = config[key];
|
|
58
|
-
if (Array.isArray(value) && value.every((item) => typeof item === 'string')) return value;
|
|
59
|
-
return fallback;
|
|
60
|
-
}
|
|
@@ -1,5 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
dirnamePath,
|
|
3
|
+
type LegacyFileSystem as FileSystem,
|
|
4
|
+
NodeFileSystem,
|
|
5
|
+
relativePath,
|
|
6
|
+
resolvePath,
|
|
7
|
+
walkDir,
|
|
8
|
+
} from '@gobing-ai/ts-runtime';
|
|
3
9
|
|
|
4
10
|
/** Options for source-file discovery. */
|
|
5
11
|
export interface SourceDiscoveryOptions {
|
|
@@ -13,14 +19,19 @@ export interface SourceDiscoveryOptions {
|
|
|
13
19
|
fs?: FileSystem;
|
|
14
20
|
}
|
|
15
21
|
|
|
16
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Directory names pruned from every file walk — heavy or generated trees that no rule
|
|
24
|
+
* should scan. Shared so subprocess-backed evaluators (e.g. `sg`) can forward the same
|
|
25
|
+
* skip-list to the external tool instead of relying on each rule to remember it.
|
|
26
|
+
*/
|
|
27
|
+
export const DEFAULT_EXCLUDES = new Set(['.git', 'node_modules', 'dist', '.coverage', '.astro', '.wrangler']);
|
|
17
28
|
|
|
18
29
|
/** Resolve source files for evaluators using conservative path-fragment matching. */
|
|
19
30
|
export async function discoverFiles(options: SourceDiscoveryOptions): Promise<string[]> {
|
|
20
31
|
const fs = options.fs ?? new NodeFileSystem();
|
|
21
|
-
const allFiles = await walkDir(options.workdir, fs);
|
|
32
|
+
const allFiles = await walkDir(options.workdir, fs, DEFAULT_EXCLUDES);
|
|
22
33
|
return allFiles
|
|
23
|
-
.map((path) =>
|
|
34
|
+
.map((path) => relativePath(options.workdir, path))
|
|
24
35
|
.filter((path) => !path.split('/').some((segment) => DEFAULT_EXCLUDES.has(segment)))
|
|
25
36
|
.filter(
|
|
26
37
|
(path) =>
|
|
@@ -31,7 +42,7 @@ export async function discoverFiles(options: SourceDiscoveryOptions): Promise<st
|
|
|
31
42
|
|
|
32
43
|
/** Read a file from a workdir-relative path. */
|
|
33
44
|
export async function readWorkdirFile(workdir: string, filePath: string, fs = new NodeFileSystem()): Promise<string> {
|
|
34
|
-
return await fs.readFile(
|
|
45
|
+
return await fs.readFile(resolvePath(workdir, filePath));
|
|
35
46
|
}
|
|
36
47
|
|
|
37
48
|
/** A discovered in-scope file paired with its contents. */
|
|
@@ -104,12 +115,12 @@ async function discoverFilesByGlob(
|
|
|
104
115
|
|
|
105
116
|
/** Ensure a path is workdir-relative for findings. */
|
|
106
117
|
export function relativeToWorkdir(workdir: string, path: string): string {
|
|
107
|
-
return
|
|
118
|
+
return relativePath(workdir, resolvePath(path));
|
|
108
119
|
}
|
|
109
120
|
|
|
110
121
|
/** Return parent directory for a workdir-relative path. */
|
|
111
122
|
export function relativeParent(path: string): string {
|
|
112
|
-
const parent =
|
|
123
|
+
const parent = dirnamePath(path);
|
|
113
124
|
return parent === '.' ? '' : parent;
|
|
114
125
|
}
|
|
115
126
|
|
|
@@ -170,6 +181,57 @@ export function stringArray(value: unknown): string[] | undefined {
|
|
|
170
181
|
return Array.isArray(value) && value.every((item) => typeof item === 'string') ? (value as string[]) : undefined;
|
|
171
182
|
}
|
|
172
183
|
|
|
184
|
+
/** Optional accessor context — names the evaluator in "required config" errors for rule authors. */
|
|
185
|
+
export interface ConfigAccessorOptions {
|
|
186
|
+
/** Evaluator name surfaced in the error, e.g. `"regex evaluator requires string config \"pattern\""`. */
|
|
187
|
+
evaluator?: string;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function requiredConfigError(kind: string, key: string, evaluator?: string): Error {
|
|
191
|
+
const who = evaluator !== undefined ? `${evaluator} evaluator` : 'evaluator';
|
|
192
|
+
return new Error(`${who} requires ${kind} config "${key}"`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Read a string entry from a rule's evaluator config. Returns the value when it is a string,
|
|
197
|
+
* the `fallback` when one is supplied, otherwise throws — so required keys fail loudly.
|
|
198
|
+
*/
|
|
199
|
+
export function configString(
|
|
200
|
+
config: Record<string, unknown>,
|
|
201
|
+
key: string,
|
|
202
|
+
fallback?: string,
|
|
203
|
+
options: ConfigAccessorOptions = {},
|
|
204
|
+
): string {
|
|
205
|
+
const value = config[key];
|
|
206
|
+
if (typeof value === 'string') return value;
|
|
207
|
+
if (fallback !== undefined) return fallback;
|
|
208
|
+
throw requiredConfigError('string', key, options.evaluator);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Read a string-array entry from a rule's evaluator config. A bare string is coerced to a
|
|
213
|
+
* single-element array. Returns the `fallback` when one is supplied and the value is absent;
|
|
214
|
+
* otherwise throws — so required keys fail loudly.
|
|
215
|
+
*/
|
|
216
|
+
export function configArray(
|
|
217
|
+
config: Record<string, unknown>,
|
|
218
|
+
key: string,
|
|
219
|
+
fallback?: string[],
|
|
220
|
+
options: ConfigAccessorOptions = {},
|
|
221
|
+
): string[] {
|
|
222
|
+
const value = config[key];
|
|
223
|
+
if (Array.isArray(value) && value.every((item) => typeof item === 'string')) return value as string[];
|
|
224
|
+
if (typeof value === 'string') return [value];
|
|
225
|
+
if (fallback !== undefined) return fallback;
|
|
226
|
+
throw requiredConfigError('string[]', key, options.evaluator);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Read a finite-number entry from a rule's evaluator config, falling back when absent or invalid. */
|
|
230
|
+
export function configNumber(config: Record<string, unknown>, key: string, fallback: number): number {
|
|
231
|
+
const value = config[key];
|
|
232
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
|
233
|
+
}
|
|
234
|
+
|
|
173
235
|
/**
|
|
174
236
|
* Split a leading ripgrep/PCRE-style `(?flags)` inline group off a regex source.
|
|
175
237
|
*
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
type RuleEvaluationResult,
|
|
6
6
|
type RuleEvaluator,
|
|
7
7
|
} from '../types';
|
|
8
|
-
import { escapeRegExp, scanFiles, stringArray } from './file-utils';
|
|
8
|
+
import { configArray, escapeRegExp, scanFiles, stringArray } from './file-utils';
|
|
9
9
|
|
|
10
10
|
/** A forbidden entry: either an exact import specifier or a raw source pattern. */
|
|
11
11
|
type ForbiddenEntry =
|
|
@@ -49,7 +49,7 @@ export class ForbiddenImportEvaluator implements RuleEvaluator {
|
|
|
49
49
|
context: RuleContext,
|
|
50
50
|
config: Record<string, unknown>,
|
|
51
51
|
): Promise<RuleEvaluationResult> {
|
|
52
|
-
const forbidden =
|
|
52
|
+
const forbidden = configArray(config, 'patterns', undefined, { evaluator: 'forbidden-import' });
|
|
53
53
|
const files = await scanFiles({
|
|
54
54
|
workdir: context.workdir,
|
|
55
55
|
include: rule.include ?? ['.ts', '.tsx', '.js', '.jsx'],
|
|
@@ -130,10 +130,3 @@ function compileEntry(entry: ForbiddenEntry): ScanEntry {
|
|
|
130
130
|
}
|
|
131
131
|
return { regex: new RegExp(entry.pattern), label: entry.pattern };
|
|
132
132
|
}
|
|
133
|
-
|
|
134
|
-
function arrayConfig(config: Record<string, unknown>, key: string): string[] {
|
|
135
|
-
const value = config[key];
|
|
136
|
-
if (Array.isArray(value) && value.every((item) => typeof item === 'string')) return value;
|
|
137
|
-
if (typeof value === 'string') return [value];
|
|
138
|
-
throw new Error(`forbidden-import evaluator requires string[] config "${key}"`);
|
|
139
|
-
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { NodeFileSystem } from '@gobing-ai/ts-runtime';
|
|
1
|
+
import { joinPath, NodeFileSystem } from '@gobing-ai/ts-runtime';
|
|
3
2
|
import {
|
|
4
3
|
type ConstraintRule,
|
|
5
4
|
createFinding,
|
|
@@ -7,7 +6,7 @@ import {
|
|
|
7
6
|
type RuleEvaluationResult,
|
|
8
7
|
type RuleEvaluator,
|
|
9
8
|
} from '../types';
|
|
10
|
-
import { discoverFiles, matchesGlob } from './file-utils';
|
|
9
|
+
import { configArray, configString, discoverFiles, matchesGlob } from './file-utils';
|
|
11
10
|
|
|
12
11
|
/**
|
|
13
12
|
* Evaluates file/directory presence constraints.
|
|
@@ -82,11 +81,11 @@ export class PathEvaluator implements RuleEvaluator {
|
|
|
82
81
|
context: RuleContext,
|
|
83
82
|
config: Record<string, unknown>,
|
|
84
83
|
): Promise<RuleEvaluationResult> {
|
|
85
|
-
const paths =
|
|
86
|
-
const mode =
|
|
84
|
+
const paths = configArray(config, 'paths', undefined, { evaluator: 'path' });
|
|
85
|
+
const mode = configString(config, 'mode', 'require');
|
|
87
86
|
const findings = [];
|
|
88
87
|
for (const path of paths) {
|
|
89
|
-
const exists = await this.fs.exists(
|
|
88
|
+
const exists = await this.fs.exists(joinPath(context.workdir, path));
|
|
90
89
|
if (mode === 'forbid' && exists) {
|
|
91
90
|
findings.push(createFinding(rule, `Forbidden path exists: ${path}`, path, { code: 'path:forbidden' }));
|
|
92
91
|
}
|
|
@@ -97,15 +96,3 @@ export class PathEvaluator implements RuleEvaluator {
|
|
|
97
96
|
return { findings, fixes: [] };
|
|
98
97
|
}
|
|
99
98
|
}
|
|
100
|
-
|
|
101
|
-
function arrayConfig(config: Record<string, unknown>, key: string): string[] {
|
|
102
|
-
const value = config[key];
|
|
103
|
-
if (Array.isArray(value) && value.every((item) => typeof item === 'string')) return value;
|
|
104
|
-
if (typeof value === 'string') return [value];
|
|
105
|
-
throw new Error(`path evaluator requires config "${key}" (string[]) or "must" (present|absent)`);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function stringConfig(config: Record<string, unknown>, key: string, fallback: string): string {
|
|
109
|
-
const value = config[key];
|
|
110
|
-
return typeof value === 'string' ? value : fallback;
|
|
111
|
-
}
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
type RuleEvaluationResult,
|
|
6
6
|
type RuleEvaluator,
|
|
7
7
|
} from '../types';
|
|
8
|
-
import { parseInlineFlags, scanFiles } from './file-utils';
|
|
8
|
+
import { configString, parseInlineFlags, scanFiles } from './file-utils';
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* Evaluates whether source files match or avoid a regex pattern.
|
|
@@ -25,11 +25,11 @@ export class RegexEvaluator implements RuleEvaluator {
|
|
|
25
25
|
async evaluate(rule: ConstraintRule, context: RuleContext): Promise<RuleEvaluationResult> {
|
|
26
26
|
const config = rule.evaluator.config ?? {};
|
|
27
27
|
const { pattern, flags } = normalizePattern(
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
configString(config, 'pattern', undefined, { evaluator: 'regex' }),
|
|
29
|
+
configString(config, 'flags', ''),
|
|
30
30
|
config.multiline === true,
|
|
31
31
|
);
|
|
32
|
-
const mode =
|
|
32
|
+
const mode = configString(config, 'mode', 'forbid');
|
|
33
33
|
const regex = new RegExp(pattern, flags);
|
|
34
34
|
const files = await scanFiles({
|
|
35
35
|
workdir: context.workdir,
|
|
@@ -103,13 +103,6 @@ function normalizePattern(
|
|
|
103
103
|
return { pattern, flags: [...flagSet].join('') };
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
function stringConfig(config: Record<string, unknown>, key: string, fallback?: string): string {
|
|
107
|
-
const value = config[key];
|
|
108
|
-
if (typeof value === 'string') return value;
|
|
109
|
-
if (fallback !== undefined) return fallback;
|
|
110
|
-
throw new Error(`regex evaluator requires string config "${key}"`);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
106
|
/** Return the one-based line containing a string offset. */
|
|
114
107
|
function lineForOffset(content: string, offset: number): number {
|
|
115
108
|
let line = 1;
|