@gobing-ai/ts-rule-engine 0.2.5 → 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.d.ts +15 -5
- package/dist/config/loader.d.ts.map +1 -1
- package/dist/config/loader.js +127 -33
- package/dist/engine.d.ts +26 -1
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +79 -0
- package/dist/evaluators/coverage-gate-evaluator.d.ts +21 -0
- package/dist/evaluators/coverage-gate-evaluator.d.ts.map +1 -0
- package/dist/evaluators/coverage-gate-evaluator.js +103 -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/file-utils.d.ts +8 -0
- package/dist/evaluators/file-utils.d.ts.map +1 -1
- package/dist/evaluators/file-utils.js +40 -0
- 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 +32 -0
- package/dist/evaluators/test-location-evaluator.d.ts.map +1 -0
- package/dist/evaluators/test-location-evaluator.js +105 -0
- package/dist/evaluators/tsdoc-export-evaluator.d.ts +15 -0
- package/dist/evaluators/tsdoc-export-evaluator.d.ts.map +1 -0
- package/dist/evaluators/tsdoc-export-evaluator.js +91 -0
- 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 +17 -0
- 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 +140 -35
- package/src/engine.ts +99 -2
- package/src/evaluators/coverage-gate-evaluator.ts +137 -0
- package/src/evaluators/exit-code-evaluator.ts +27 -9
- package/src/evaluators/file-utils.ts +38 -0
- 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 +127 -0
- package/src/evaluators/tsdoc-export-evaluator.ts +111 -0
- package/src/fixers/fixers.ts +294 -0
- package/src/fixers/test-stub-fixer.ts +118 -0
- package/src/host/builtins.ts +22 -0
- 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
package/src/engine.ts
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import type { ProcessExecutor } from '@gobing-ai/ts-runtime';
|
|
2
|
+
import {
|
|
3
|
+
applyFixes as applyFixesImpl,
|
|
4
|
+
builtInFixers,
|
|
5
|
+
type EffectiveFix,
|
|
6
|
+
FIX_MODE_RANK,
|
|
7
|
+
type FixApplicationResult,
|
|
8
|
+
type RuleFixerProvider,
|
|
9
|
+
} from './fixers/fixers';
|
|
2
10
|
import { registerBuiltins } from './host/builtins';
|
|
3
11
|
import { RuleEngineHost } from './host/rule-engine-host';
|
|
4
|
-
import type { ConstraintFinding, ConstraintRule, RuleEngineResult, RuleEvaluator } from './types';
|
|
12
|
+
import type { ConstraintFinding, ConstraintRule, Fix, FixMode, RuleEngineResult, RuleEvaluator } from './types';
|
|
5
13
|
import { createFinding } from './types';
|
|
6
14
|
|
|
7
15
|
/** Options for constructing a RuleEngine. */
|
|
@@ -17,9 +25,13 @@ export class RuleEngine {
|
|
|
17
25
|
/** Capability host used by this engine. */
|
|
18
26
|
readonly host: RuleEngineHost;
|
|
19
27
|
|
|
28
|
+
/** Fixer providers keyed by evaluator type. */
|
|
29
|
+
private readonly fixers: Map<string, RuleFixerProvider>;
|
|
30
|
+
|
|
20
31
|
constructor(options: RuleEngineOptions = {}) {
|
|
21
32
|
this.host = options.host ?? new RuleEngineHost();
|
|
22
33
|
registerBuiltins(this.host, options.processExecutor);
|
|
34
|
+
this.fixers = builtInFixers(this.host, options.processExecutor);
|
|
23
35
|
}
|
|
24
36
|
|
|
25
37
|
/** Register or replace an evaluator. */
|
|
@@ -30,7 +42,7 @@ export class RuleEngine {
|
|
|
30
42
|
/** Evaluate all enabled rules against a working directory. */
|
|
31
43
|
async evaluate(rules: ConstraintRule[], workdir: string): Promise<RuleEngineResult> {
|
|
32
44
|
const findings: ConstraintFinding[] = [];
|
|
33
|
-
const fixes = [];
|
|
45
|
+
const fixes: Fix[] = [];
|
|
34
46
|
for (const rule of rules) {
|
|
35
47
|
if (rule.enabled === false) continue;
|
|
36
48
|
try {
|
|
@@ -41,10 +53,95 @@ export class RuleEngine {
|
|
|
41
53
|
findings.push(
|
|
42
54
|
createFinding(rule, error instanceof Error ? error.message : String(error), null, {
|
|
43
55
|
code: `evaluator:${rule.evaluator.type}`,
|
|
56
|
+
kind: 'error',
|
|
44
57
|
}),
|
|
45
58
|
);
|
|
46
59
|
}
|
|
47
60
|
}
|
|
48
61
|
return { findings, fixes };
|
|
49
62
|
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Evaluate all enabled rules and collect candidate fixes.
|
|
66
|
+
*
|
|
67
|
+
* For each rule with findings and a non-none fix mode, looks up the fixer
|
|
68
|
+
* provider by evaluator type and calls `createFixes`. The effective fix mode
|
|
69
|
+
* is the minimum of the rule's configured mode and `maxFixMode`.
|
|
70
|
+
*
|
|
71
|
+
* @param rules - Normalized rule definitions to evaluate.
|
|
72
|
+
* @param workdir - Working directory to scan.
|
|
73
|
+
* @param maxFixMode - Highest fix authority requested by the caller.
|
|
74
|
+
* @returns Findings plus fixes allowed by the requested authority.
|
|
75
|
+
*/
|
|
76
|
+
async evaluateWithFixes(
|
|
77
|
+
rules: ConstraintRule[],
|
|
78
|
+
workdir: string,
|
|
79
|
+
maxFixMode: FixMode = 'auto',
|
|
80
|
+
): Promise<RuleEngineResult> {
|
|
81
|
+
const findings: ConstraintFinding[] = [];
|
|
82
|
+
const fixes: Fix[] = [];
|
|
83
|
+
|
|
84
|
+
for (const rule of rules) {
|
|
85
|
+
if (rule.enabled === false) continue;
|
|
86
|
+
|
|
87
|
+
let ruleFindings: ConstraintFinding[] = [];
|
|
88
|
+
let ruleEvalFixes: Fix[] = [];
|
|
89
|
+
try {
|
|
90
|
+
const result = await this.host.evaluators.get(rule.evaluator.type).evaluate(rule, { rule, workdir });
|
|
91
|
+
ruleFindings = result.findings;
|
|
92
|
+
ruleEvalFixes = result.fixes;
|
|
93
|
+
} catch (error) {
|
|
94
|
+
ruleFindings = [
|
|
95
|
+
createFinding(rule, error instanceof Error ? error.message : String(error), null, {
|
|
96
|
+
code: `evaluator:${rule.evaluator.type}`,
|
|
97
|
+
kind: 'error',
|
|
98
|
+
}),
|
|
99
|
+
];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
findings.push(...ruleFindings);
|
|
103
|
+
fixes.push(...ruleEvalFixes);
|
|
104
|
+
|
|
105
|
+
const ruleMode = rule.fix?.mode ?? 'none';
|
|
106
|
+
const effectiveMode = effectiveFixMode(ruleMode, maxFixMode);
|
|
107
|
+
|
|
108
|
+
if (effectiveMode !== 'none' && ruleFindings.length > 0) {
|
|
109
|
+
const provider = this.fixers.get(rule.evaluator.type);
|
|
110
|
+
if (provider) {
|
|
111
|
+
const effectiveFix: EffectiveFix = {
|
|
112
|
+
mode: effectiveMode,
|
|
113
|
+
...(rule.fix?.replacement !== undefined ? { replacement: rule.fix.replacement } : {}),
|
|
114
|
+
...(rule.fix?.params !== undefined ? { params: rule.fix.params } : {}),
|
|
115
|
+
};
|
|
116
|
+
const providerFixes = await provider.createFixes({
|
|
117
|
+
rule,
|
|
118
|
+
context: { rule, workdir },
|
|
119
|
+
findings: ruleFindings,
|
|
120
|
+
fix: effectiveFix,
|
|
121
|
+
});
|
|
122
|
+
fixes.push(...providerFixes);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return { findings, fixes };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Apply or preview candidate byte-range fixes.
|
|
132
|
+
*
|
|
133
|
+
* @param workdir - Working directory that fix file paths are relative to.
|
|
134
|
+
* @param fixes - Fixes to apply.
|
|
135
|
+
* @param dryRun - When true, return a diff without writing files.
|
|
136
|
+
* @returns Application details and optional diff.
|
|
137
|
+
*/
|
|
138
|
+
async applyFixes(workdir: string, fixes: readonly Fix[], dryRun = false): Promise<FixApplicationResult> {
|
|
139
|
+
return applyFixesImpl(workdir, fixes, dryRun);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Return the lower-authority mode between what the rule requests and what the caller allows. */
|
|
144
|
+
function effectiveFixMode(ruleMode: FixMode, requestedMode: FixMode): FixMode {
|
|
145
|
+
if (requestedMode === 'none' || ruleMode === 'none') return 'none';
|
|
146
|
+
return FIX_MODE_RANK[ruleMode] <= FIX_MODE_RANK[requestedMode] ? ruleMode : requestedMode;
|
|
50
147
|
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { isAbsolute, relative, 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
|
+
import { matchesGlob } from './file-utils';
|
|
11
|
+
|
|
12
|
+
/** Single-file coverage extracted from an lcov record. */
|
|
13
|
+
interface FileCoverage {
|
|
14
|
+
linesFound: number;
|
|
15
|
+
linesHit: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Per-file threshold override with justification. */
|
|
19
|
+
interface CoverageExemption {
|
|
20
|
+
path: string;
|
|
21
|
+
threshold: number;
|
|
22
|
+
reason: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Evaluator config shape extracted from `rule.evaluator.config`. */
|
|
26
|
+
interface CoverageGateConfig {
|
|
27
|
+
lcovPath?: string;
|
|
28
|
+
threshold?: number;
|
|
29
|
+
include?: string[];
|
|
30
|
+
exclude?: string[];
|
|
31
|
+
exemptions?: CoverageExemption[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Coverage gate evaluator — reads an lcov tracefile and enforces per-file line
|
|
36
|
+
* coverage thresholds.
|
|
37
|
+
*
|
|
38
|
+
* Config (`evaluator.config`):
|
|
39
|
+
* - `lcovPath`: path to lcov.info (default: `coverage/lcov.info`)
|
|
40
|
+
* - `threshold`: default line-coverage percentage (default: `90`)
|
|
41
|
+
* - `include` / `exclude`: glob patterns scoping the check
|
|
42
|
+
* - `exemptions`: per-file threshold overrides with a reason
|
|
43
|
+
*
|
|
44
|
+
* A missing lcov file is reported as a single non-blocking finding rather than an
|
|
45
|
+
* error, so the gate degrades gracefully when coverage was not generated.
|
|
46
|
+
*/
|
|
47
|
+
export class CoverageGateEvaluator implements RuleEvaluator {
|
|
48
|
+
private readonly fs: NodeFileSystem;
|
|
49
|
+
|
|
50
|
+
constructor() {
|
|
51
|
+
this.fs = new NodeFileSystem();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Evaluate per-file coverage against the configured threshold. */
|
|
55
|
+
async evaluate(rule: ConstraintRule, context: RuleContext): Promise<RuleEvaluationResult> {
|
|
56
|
+
const config = (rule.evaluator.config ?? {}) as CoverageGateConfig;
|
|
57
|
+
const lcovPath = config.lcovPath
|
|
58
|
+
? resolve(context.workdir, config.lcovPath)
|
|
59
|
+
: resolve(context.workdir, 'coverage', 'lcov.info');
|
|
60
|
+
|
|
61
|
+
if (!(await this.fs.exists(lcovPath))) {
|
|
62
|
+
return {
|
|
63
|
+
findings: [
|
|
64
|
+
createFinding(rule, 'No lcov file found — coverage not generated. Skipping gate.', lcovPath, {
|
|
65
|
+
code: 'coverage:missing-lcov',
|
|
66
|
+
}),
|
|
67
|
+
],
|
|
68
|
+
fixes: [],
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const coverage = parseLcov(await this.fs.readFile(lcovPath));
|
|
73
|
+
const threshold = config.threshold ?? 90;
|
|
74
|
+
const exemptions = new Map((config.exemptions ?? []).map((entry) => [entry.path, entry]));
|
|
75
|
+
const findings = [];
|
|
76
|
+
|
|
77
|
+
for (const [rawPath, cov] of coverage) {
|
|
78
|
+
if (cov.linesFound === 0) continue;
|
|
79
|
+
const filePath = normalizeLcovSourcePath(context.workdir, rawPath);
|
|
80
|
+
if (config.include !== undefined && !config.include.some((p) => matchesGlob(filePath, p))) continue;
|
|
81
|
+
if (config.exclude?.some((p) => matchesGlob(filePath, p))) continue;
|
|
82
|
+
if (isAlwaysExcluded(filePath)) continue;
|
|
83
|
+
|
|
84
|
+
const pct = Math.round((cov.linesHit / cov.linesFound) * 100);
|
|
85
|
+
const exemption = exemptions.get(filePath);
|
|
86
|
+
const effectiveThreshold = exemption?.threshold ?? threshold;
|
|
87
|
+
if (pct >= effectiveThreshold) continue;
|
|
88
|
+
|
|
89
|
+
const message = exemption
|
|
90
|
+
? `${pct}% line coverage (exemption: ${effectiveThreshold}%, reason: ${exemption.reason})`
|
|
91
|
+
: `${pct}% line coverage (threshold: ${effectiveThreshold}%)`;
|
|
92
|
+
findings.push(createFinding(rule, message, filePath, { code: 'coverage:below-threshold' }));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return { findings, fixes: [] };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Parse an lcov tracefile into a map of source path → coverage counts. */
|
|
100
|
+
function parseLcov(raw: string): Map<string, FileCoverage> {
|
|
101
|
+
const result = new Map<string, FileCoverage>();
|
|
102
|
+
let file: string | null = null;
|
|
103
|
+
let linesFound = 0;
|
|
104
|
+
let linesHit = 0;
|
|
105
|
+
for (const line of raw.split('\n')) {
|
|
106
|
+
const trimmed = line.trim();
|
|
107
|
+
if (trimmed.startsWith('SF:')) {
|
|
108
|
+
file = trimmed.slice(3);
|
|
109
|
+
linesFound = 0;
|
|
110
|
+
linesHit = 0;
|
|
111
|
+
} else if (trimmed.startsWith('LF:')) {
|
|
112
|
+
linesFound = Number(trimmed.slice(3));
|
|
113
|
+
} else if (trimmed.startsWith('LH:')) {
|
|
114
|
+
linesHit = Number(trimmed.slice(3));
|
|
115
|
+
} else if (trimmed === 'end_of_record' && file !== null) {
|
|
116
|
+
result.set(file, { linesFound, linesHit });
|
|
117
|
+
file = null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return result;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Standard exclusions applied regardless of config (tests, generated, deps). */
|
|
124
|
+
function isAlwaysExcluded(filePath: string): boolean {
|
|
125
|
+
return (
|
|
126
|
+
filePath.includes('node_modules') ||
|
|
127
|
+
filePath.includes('.test.') ||
|
|
128
|
+
filePath.includes('.spec.') ||
|
|
129
|
+
filePath.includes('__tests__')
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Normalize an lcov `SF:` path to a workdir-relative forward-slash path. */
|
|
134
|
+
function normalizeLcovSourcePath(workdir: string, filePath: string): string {
|
|
135
|
+
const normalized = isAbsolute(filePath) ? relative(workdir, filePath) : filePath;
|
|
136
|
+
return normalized.replaceAll('\\', '/');
|
|
137
|
+
}
|
|
@@ -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
|
|
|
@@ -53,3 +53,41 @@ export function matchesAny(path: string, patterns: string[] | undefined): boolea
|
|
|
53
53
|
return clean.length === 0 || path.includes(clean) || path.endsWith(clean);
|
|
54
54
|
});
|
|
55
55
|
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Segment-aware glob matching with `**` (any depth) and `*` (single segment).
|
|
59
|
+
*
|
|
60
|
+
* Stricter than {@link matchesAny}: it anchors the whole path, so `apps/**` does
|
|
61
|
+
* not match `vendor/apps/x`. Used by evaluators that enforce path policies
|
|
62
|
+
* (coverage scoping, test-file location) where a loose match would change findings.
|
|
63
|
+
*/
|
|
64
|
+
export function matchesGlob(path: string, pattern: string): boolean {
|
|
65
|
+
const normalized = path.replaceAll('\\', '/');
|
|
66
|
+
if (pattern.startsWith('**/')) {
|
|
67
|
+
const suffix = pattern.slice(3);
|
|
68
|
+
if (suffix.indexOf('*') === -1) {
|
|
69
|
+
return normalized.endsWith(suffix) || normalized.endsWith(`/${suffix}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (pattern === normalized) return true;
|
|
73
|
+
return matchSegments(normalized.split('/'), pattern.split('/'), 0, 0);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Recursive segment-level glob matcher backing {@link matchesGlob}. */
|
|
77
|
+
function matchSegments(file: string[], pattern: string[], fi: number, pi: number): boolean {
|
|
78
|
+
if (pi >= pattern.length) return fi >= file.length;
|
|
79
|
+
if (fi >= file.length) return pattern.slice(pi).every((segment) => segment === '**');
|
|
80
|
+
const pat = pattern[pi] ?? '';
|
|
81
|
+
if (pat === '**') {
|
|
82
|
+
return matchSegments(file, pattern, fi, pi + 1) || matchSegments(file, pattern, fi + 1, pi);
|
|
83
|
+
}
|
|
84
|
+
if (!matchSegment(file[fi] ?? '', pat)) return false;
|
|
85
|
+
return matchSegments(file, pattern, fi + 1, pi + 1);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Match one path segment against a pattern segment where `*` matches any run of non-`/` chars. */
|
|
89
|
+
function matchSegment(segment: string, pattern: string): boolean {
|
|
90
|
+
if (pattern.indexOf('*') === -1) return segment === pattern;
|
|
91
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replaceAll('*', '[^/]*');
|
|
92
|
+
return new RegExp(`^${escaped}$`).test(segment);
|
|
93
|
+
}
|
|
@@ -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
|
+
}
|