@gobing-ai/ts-rule-engine 0.2.7 → 0.2.9
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 +565 -1
- package/dist/config/extensions.d.ts +7 -4
- package/dist/config/extensions.d.ts.map +1 -1
- package/dist/config/extensions.js +11 -6
- package/dist/config/loader.d.ts +29 -2
- package/dist/config/loader.d.ts.map +1 -1
- package/dist/config/loader.js +104 -34
- package/dist/engine.d.ts +8 -1
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +9 -19
- package/dist/evaluators/coverage-gate-evaluator.js +12 -3
- package/dist/evaluators/file-utils.d.ts +55 -0
- package/dist/evaluators/file-utils.d.ts.map +1 -1
- package/dist/evaluators/file-utils.js +49 -0
- package/dist/evaluators/forbidden-import-evaluator.d.ts +5 -0
- package/dist/evaluators/forbidden-import-evaluator.d.ts.map +1 -1
- package/dist/evaluators/forbidden-import-evaluator.js +14 -17
- package/dist/evaluators/import-boundary-evaluator.d.ts +5 -0
- package/dist/evaluators/import-boundary-evaluator.d.ts.map +1 -1
- package/dist/evaluators/import-boundary-evaluator.js +45 -15
- package/dist/evaluators/regex-evaluator.d.ts +9 -1
- package/dist/evaluators/regex-evaluator.d.ts.map +1 -1
- package/dist/evaluators/regex-evaluator.js +43 -14
- package/dist/evaluators/secrets-scanner-evaluator.d.ts +5 -0
- package/dist/evaluators/secrets-scanner-evaluator.d.ts.map +1 -1
- package/dist/evaluators/secrets-scanner-evaluator.js +13 -13
- package/dist/evaluators/tsdoc-export-evaluator.d.ts.map +1 -1
- package/dist/evaluators/tsdoc-export-evaluator.js +9 -11
- package/dist/formatters/json.d.ts.map +1 -1
- package/dist/formatters/json.js +2 -0
- package/dist/formatters/text.d.ts.map +1 -1
- package/dist/formatters/text.js +2 -0
- package/dist/resolvers/test-path-resolver.d.ts.map +1 -1
- package/dist/resolvers/test-path-resolver.js +5 -0
- package/dist/types.d.ts +28 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +37 -9
- package/package.json +6 -3
- package/schemas/preset.schema.json +54 -0
- package/schemas/rule-file.schema.json +49 -0
- package/src/config/extensions.ts +16 -8
- package/src/config/loader.ts +151 -34
- package/src/engine.ts +9 -19
- package/src/evaluators/coverage-gate-evaluator.ts +15 -5
- package/src/evaluators/file-utils.ts +92 -0
- package/src/evaluators/forbidden-import-evaluator.ts +14 -19
- package/src/evaluators/import-boundary-evaluator.ts +56 -40
- package/src/evaluators/regex-evaluator.ts +43 -13
- package/src/evaluators/secrets-scanner-evaluator.ts +13 -14
- package/src/evaluators/tsdoc-export-evaluator.ts +10 -9
- package/src/formatters/json.ts +2 -0
- package/src/formatters/text.ts +2 -0
- package/src/resolvers/test-path-resolver.ts +5 -0
- package/src/types.ts +45 -9
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://raw.githubusercontent.com/gobing-ai/ts-libs/main/packages/rule-engine/schemas/preset.schema.json",
|
|
4
|
+
"title": "@gobing-ai/ts-rule-engine Preset",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": ["name"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"$schema": { "type": "string" },
|
|
10
|
+
"name": { "type": "string", "minLength": 1 },
|
|
11
|
+
"extends": { "type": "array", "items": { "type": "string" } },
|
|
12
|
+
"disable": { "type": "array", "items": { "type": "string" } },
|
|
13
|
+
"overrides": {
|
|
14
|
+
"type": "object",
|
|
15
|
+
"additionalProperties": {
|
|
16
|
+
"type": "object",
|
|
17
|
+
"additionalProperties": false,
|
|
18
|
+
"properties": {
|
|
19
|
+
"fix": {
|
|
20
|
+
"type": "object",
|
|
21
|
+
"additionalProperties": false,
|
|
22
|
+
"properties": {
|
|
23
|
+
"mode": { "enum": ["none", "suggest", "auto"] }
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"extensions": {
|
|
30
|
+
"type": "object",
|
|
31
|
+
"additionalProperties": false,
|
|
32
|
+
"properties": {
|
|
33
|
+
"resolvers": { "type": "array", "items": { "$ref": "#/$defs/relativeExtensionPath" } },
|
|
34
|
+
"evaluators": { "type": "array", "items": { "$ref": "#/$defs/relativeExtensionPath" } },
|
|
35
|
+
"fixers": { "type": "array", "items": { "$ref": "#/$defs/relativeExtensionPath" } },
|
|
36
|
+
"formatters": { "type": "array", "items": { "$ref": "#/$defs/relativeExtensionPath" } }
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"$defs": {
|
|
41
|
+
"relativeExtensionPath": {
|
|
42
|
+
"type": "string",
|
|
43
|
+
"minLength": 1,
|
|
44
|
+
"description": "Relative module path; absolute paths and '..' traversal are forbidden.",
|
|
45
|
+
"not": {
|
|
46
|
+
"anyOf": [
|
|
47
|
+
{ "pattern": "^[/\\\\]" },
|
|
48
|
+
{ "pattern": "^[A-Za-z]:[/\\\\]" },
|
|
49
|
+
{ "pattern": "(^|[/\\\\])\\.\\.([/\\\\]|$)" }
|
|
50
|
+
]
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://raw.githubusercontent.com/gobing-ai/ts-libs/main/packages/rule-engine/schemas/rule-file.schema.json",
|
|
4
|
+
"title": "@gobing-ai/ts-rule-engine Rule File",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": ["rules"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"$schema": { "type": "string" },
|
|
10
|
+
"include": { "type": "array", "items": { "type": "string" } },
|
|
11
|
+
"exclude": { "type": "array", "items": { "type": "string" } },
|
|
12
|
+
"severity": { "enum": ["error", "warning", "info"] },
|
|
13
|
+
"rules": { "type": "array", "items": { "$ref": "#/$defs/rule" } }
|
|
14
|
+
},
|
|
15
|
+
"$defs": {
|
|
16
|
+
"rule": {
|
|
17
|
+
"type": "object",
|
|
18
|
+
"additionalProperties": false,
|
|
19
|
+
"required": ["id", "evaluator"],
|
|
20
|
+
"properties": {
|
|
21
|
+
"id": { "type": "string" },
|
|
22
|
+
"description": { "type": "string" },
|
|
23
|
+
"enabled": { "type": "boolean" },
|
|
24
|
+
"severity": { "enum": ["error", "warning", "info"] },
|
|
25
|
+
"include": { "type": "array", "items": { "type": "string" } },
|
|
26
|
+
"exclude": { "type": "array", "items": { "type": "string" } },
|
|
27
|
+
"evaluator": {
|
|
28
|
+
"type": "object",
|
|
29
|
+
"additionalProperties": false,
|
|
30
|
+
"required": ["type"],
|
|
31
|
+
"properties": {
|
|
32
|
+
"type": { "type": "string" },
|
|
33
|
+
"config": { "type": "object", "additionalProperties": true }
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"fix": { "$ref": "#/$defs/fix" }
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"fix": {
|
|
40
|
+
"type": "object",
|
|
41
|
+
"additionalProperties": false,
|
|
42
|
+
"properties": {
|
|
43
|
+
"mode": { "enum": ["none", "suggest", "auto"] },
|
|
44
|
+
"replacement": { "type": "string" },
|
|
45
|
+
"params": { "type": "object", "additionalProperties": true }
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/config/extensions.ts
CHANGED
|
@@ -24,6 +24,8 @@ export interface LoadExtensionsOptions {
|
|
|
24
24
|
allowExtensions?: boolean;
|
|
25
25
|
/** Optional sink for non-fatal warnings (e.g. built-in overrides). */
|
|
26
26
|
logger?: { warn: (message: string) => void };
|
|
27
|
+
/** Optional module loader seam for tests or embedders with custom import policy. */
|
|
28
|
+
moduleLoader?: (absPath: string) => Promise<Record<string, unknown>>;
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
/** Host registries that can receive extension capabilities (fixers live on the engine, not the host). */
|
|
@@ -34,21 +36,22 @@ const HOST_REGISTRY_BY_KIND: Partial<Record<ExtensionKind, 'resolvers' | 'evalua
|
|
|
34
36
|
};
|
|
35
37
|
|
|
36
38
|
/**
|
|
37
|
-
* Collect extension refs declared by a preset's `extensions` block.
|
|
39
|
+
* Collect extension refs declared by a preset's or rule file's `extensions` block.
|
|
38
40
|
*
|
|
39
|
-
* Paths are resolved relative to the
|
|
40
|
-
* refs with {@link loadExtensionsIntoHost}.
|
|
41
|
+
* Paths are resolved relative to the declaring file's directory. Use the returned
|
|
42
|
+
* refs with {@link loadExtensionsIntoHost}. Rule files and presets are treated
|
|
43
|
+
* identically — both flow through the same trust gate at load time.
|
|
41
44
|
*/
|
|
42
|
-
export function
|
|
43
|
-
|
|
44
|
-
|
|
45
|
+
export function collectExtensions(
|
|
46
|
+
sourceName: string,
|
|
47
|
+
sourceDir: string,
|
|
45
48
|
extensions: Partial<Record<ExtensionKind, string[] | undefined>> | undefined,
|
|
46
49
|
): ExtensionRef[] {
|
|
47
50
|
if (extensions === undefined) return [];
|
|
48
51
|
const refs: ExtensionRef[] = [];
|
|
49
52
|
for (const kind of ['resolvers', 'evaluators', 'fixers', 'formatters'] as ExtensionKind[]) {
|
|
50
53
|
for (const path of extensions[kind] ?? []) {
|
|
51
|
-
refs.push({ kind, presetName, absPath: resolve(
|
|
54
|
+
refs.push({ kind, presetName: sourceName, absPath: resolve(sourceDir, path) });
|
|
52
55
|
}
|
|
53
56
|
}
|
|
54
57
|
return refs;
|
|
@@ -79,8 +82,9 @@ export async function loadExtensionsIntoHost(
|
|
|
79
82
|
);
|
|
80
83
|
}
|
|
81
84
|
|
|
85
|
+
const loadModule = options.moduleLoader ?? defaultModuleLoader;
|
|
82
86
|
for (const ref of refs) {
|
|
83
|
-
const moduleExports =
|
|
87
|
+
const moduleExports = await loadModule(ref.absPath);
|
|
84
88
|
const candidate = moduleExports.default ?? moduleExports.extension;
|
|
85
89
|
if (
|
|
86
90
|
candidate === null ||
|
|
@@ -106,3 +110,7 @@ export async function loadExtensionsIntoHost(
|
|
|
106
110
|
registry.register(name, candidate, 'extension');
|
|
107
111
|
}
|
|
108
112
|
}
|
|
113
|
+
|
|
114
|
+
async function defaultModuleLoader(absPath: string): Promise<Record<string, unknown>> {
|
|
115
|
+
return (await import(absPath)) as Record<string, unknown>;
|
|
116
|
+
}
|
package/src/config/loader.ts
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
import { basename, dirname,
|
|
2
|
-
import { NodeFileSystem } from '@gobing-ai/ts-runtime';
|
|
3
|
-
import { parse } from 'yaml';
|
|
1
|
+
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
|
|
2
|
+
import { loadStructuredConfig, NodeFileSystem } from '@gobing-ai/ts-runtime';
|
|
4
3
|
import {
|
|
5
4
|
type ConstraintRule,
|
|
6
5
|
type ConstraintRuleFile,
|
|
7
6
|
ConstraintRuleFileSchema,
|
|
8
7
|
ConstraintRuleSchema,
|
|
8
|
+
type FixMode,
|
|
9
9
|
type PresetDefinition,
|
|
10
10
|
PresetDefinitionSchema,
|
|
11
11
|
} from '../types';
|
|
12
|
+
import { collectExtensions, type ExtensionRef } from './extensions';
|
|
12
13
|
|
|
13
14
|
/** Options for loading rule presets. */
|
|
14
15
|
export interface RuleLoaderOptions {
|
|
@@ -20,6 +21,25 @@ export interface RuleLoaderOptions {
|
|
|
20
21
|
* loader stays agnostic to any project layout convention.
|
|
21
22
|
*/
|
|
22
23
|
roots: string[];
|
|
24
|
+
/** When true, honor top-level `$schema` refs in preset and rule files. Defaults to true. */
|
|
25
|
+
validateSchema?: boolean;
|
|
26
|
+
/** Optional fetch implementation for remote HTTP(S) schema refs. */
|
|
27
|
+
fetch?: (input: string) => Promise<Response>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface RuleFileLoadOptions {
|
|
31
|
+
/** When true, honor top-level `$schema` refs. Defaults to true. */
|
|
32
|
+
validateSchema?: boolean;
|
|
33
|
+
/** Optional fetch implementation for remote HTTP(S) schema refs. */
|
|
34
|
+
fetch?: (input: string) => Promise<Response>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Loaded preset rules plus extension module refs declared by composed presets. */
|
|
38
|
+
export interface LoadedPreset {
|
|
39
|
+
/** Normalized rules after preset disable/override handling. */
|
|
40
|
+
readonly rules: ConstraintRule[];
|
|
41
|
+
/** Extension modules declared by the preset graph, resolved to absolute paths. */
|
|
42
|
+
readonly extensions: ExtensionRef[];
|
|
23
43
|
}
|
|
24
44
|
|
|
25
45
|
/** Merged view of rule roots: winning file per relative path, plus categories. */
|
|
@@ -37,44 +57,110 @@ interface MergedRoots {
|
|
|
37
57
|
* so a caller can layer project-local rules over shared/global rules and inherit
|
|
38
58
|
* the rest of a preset's categories from the lower-priority roots.
|
|
39
59
|
*/
|
|
40
|
-
export async function
|
|
60
|
+
export async function loadPreset(name: string, options: RuleLoaderOptions): Promise<LoadedPreset> {
|
|
41
61
|
const merged = await buildMergedRoots(options.roots.map((root) => resolve(root)));
|
|
42
62
|
const presetPath = findMergedPreset(merged, name);
|
|
43
|
-
if (presetPath === null) return [];
|
|
44
|
-
const preset = PresetDefinitionSchema.parse(await readStructuredFile(presetPath)) as PresetDefinition;
|
|
63
|
+
if (presetPath === null) return { rules: [], extensions: [] };
|
|
64
|
+
const preset = PresetDefinitionSchema.parse(await readStructuredFile(presetPath, options)) as PresetDefinition;
|
|
45
65
|
const rules: ConstraintRule[] = [];
|
|
66
|
+
const extensions = collectExtensions(preset.name, dirname(presetPath), preset.extensions);
|
|
46
67
|
for (const entry of preset.extends) {
|
|
47
|
-
|
|
68
|
+
const loaded = await loadPresetEntry(merged, entry, new Set([name]), options);
|
|
69
|
+
rules.push(...loaded.rules);
|
|
70
|
+
extensions.push(...loaded.extensions);
|
|
48
71
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
72
|
+
return { rules: applyPresetControls(preset.name, rules, preset.disable, preset.overrides), extensions };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Collapse rules sharing an id to a single entry; later definitions win (last-wins merge). */
|
|
76
|
+
function dedupeById(rules: readonly ConstraintRule[]): ConstraintRule[] {
|
|
77
|
+
const byId = new Map<string, ConstraintRule>();
|
|
78
|
+
for (const rule of rules) byId.set(rule.id, rule);
|
|
79
|
+
return [...byId.values()];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Apply a preset's local disable and override controls after composing its children. */
|
|
83
|
+
function applyPresetControls(
|
|
84
|
+
presetName: string,
|
|
85
|
+
rules: readonly ConstraintRule[],
|
|
86
|
+
disabledIds: readonly string[] | undefined,
|
|
87
|
+
overrides: PresetDefinition['overrides'],
|
|
88
|
+
): ConstraintRule[] {
|
|
89
|
+
const disabled = new Set(disabledIds ?? []);
|
|
90
|
+
const controlled = dedupeById(rules).filter((rule) => !disabled.has(rule.id));
|
|
91
|
+
for (const rule of controlled) {
|
|
92
|
+
const override = overrides?.[rule.id];
|
|
53
93
|
if (override?.fix !== undefined) {
|
|
94
|
+
assertFixModeNotPromoted(presetName, rule, override.fix.mode);
|
|
54
95
|
rule.fix = { ...(rule.fix ?? { mode: 'none' }), ...override.fix };
|
|
55
96
|
}
|
|
56
97
|
}
|
|
57
|
-
return
|
|
98
|
+
return controlled;
|
|
58
99
|
}
|
|
59
100
|
|
|
60
|
-
/**
|
|
61
|
-
|
|
62
|
-
|
|
101
|
+
/** Fix-mode authority ordering; an override may lower but never raise a rule's mode. */
|
|
102
|
+
const FIX_MODE_AUTHORITY: Record<FixMode, number> = { none: 0, suggest: 1, auto: 2 };
|
|
103
|
+
|
|
104
|
+
/** Throw when a preset override would escalate a rule's fix authority above its authored level. */
|
|
105
|
+
function assertFixModeNotPromoted(presetName: string, rule: ConstraintRule, overrideMode: FixMode): void {
|
|
106
|
+
const ruleMode = rule.fix?.mode ?? 'none';
|
|
107
|
+
if (FIX_MODE_AUTHORITY[overrideMode] > FIX_MODE_AUTHORITY[ruleMode]) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
`Preset "${presetName}" override for rule "${rule.id}" raises fix mode from "${ruleMode}" to "${overrideMode}"; overrides may only lower fix authority`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
63
112
|
}
|
|
64
113
|
|
|
65
|
-
async function
|
|
114
|
+
export async function loadPresetRules(name: string, options: RuleLoaderOptions): Promise<ConstraintRule[]> {
|
|
115
|
+
return (await loadPreset(name, options)).rules;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Load a direct rule file from disk.
|
|
120
|
+
*
|
|
121
|
+
* Returns the normalized rules plus any extension modules the file declares in an
|
|
122
|
+
* `extensions` block, resolved to absolute paths. Rule-file extensions are treated
|
|
123
|
+
* exactly like preset extensions — pass the refs to {@link loadExtensionsIntoHost},
|
|
124
|
+
* which enforces the same `allowExtensions` trust gate. A single-rule file (one rule
|
|
125
|
+
* object, not a `rules:` array) cannot declare extensions and yields `extensions: []`.
|
|
126
|
+
*/
|
|
127
|
+
export async function loadRuleFile(filePath: string, options: RuleFileLoadOptions = {}): Promise<LoadedPreset> {
|
|
128
|
+
const resolved = resolve(filePath);
|
|
129
|
+
const raw = await readStructuredFile(resolved, options);
|
|
130
|
+
const rules = normalizeRuleFile(raw, resolved);
|
|
131
|
+
const parsed = ConstraintRuleFileSchema.safeParse(raw);
|
|
132
|
+
const extensions = parsed.success
|
|
133
|
+
? collectExtensions(basename(resolved), dirname(resolved), parsed.data.extensions)
|
|
134
|
+
: [];
|
|
135
|
+
return { rules, extensions };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function loadPresetEntry(
|
|
139
|
+
merged: MergedRoots,
|
|
140
|
+
entry: string,
|
|
141
|
+
seen: Set<string>,
|
|
142
|
+
options: Pick<RuleLoaderOptions, 'validateSchema' | 'fetch'>,
|
|
143
|
+
): Promise<LoadedPreset> {
|
|
66
144
|
// Sub-preset reference — recurse, erroring on a genuine cycle.
|
|
67
145
|
const presetPath = findMergedPreset(merged, entry);
|
|
68
146
|
if (presetPath !== null) {
|
|
69
147
|
if (seen.has(entry)) {
|
|
70
148
|
throw new Error(`Circular preset dependency detected: ${[...seen, entry].join(' → ')}`);
|
|
71
149
|
}
|
|
72
|
-
seen
|
|
73
|
-
const preset = PresetDefinitionSchema.safeParse(await readStructuredFile(presetPath));
|
|
150
|
+
const nextSeen = new Set([...seen, entry]);
|
|
151
|
+
const preset = PresetDefinitionSchema.safeParse(await readStructuredFile(presetPath, options));
|
|
74
152
|
if (preset.success) {
|
|
75
153
|
const rules: ConstraintRule[] = [];
|
|
76
|
-
|
|
77
|
-
|
|
154
|
+
const extensions = collectExtensions(preset.data.name, dirname(presetPath), preset.data.extensions);
|
|
155
|
+
for (const child of preset.data.extends) {
|
|
156
|
+
const loaded = await loadPresetEntry(merged, child, nextSeen, options);
|
|
157
|
+
rules.push(...loaded.rules);
|
|
158
|
+
extensions.push(...loaded.extensions);
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
rules: applyPresetControls(preset.data.name, rules, preset.data.disable, preset.data.overrides),
|
|
162
|
+
extensions,
|
|
163
|
+
};
|
|
78
164
|
}
|
|
79
165
|
}
|
|
80
166
|
|
|
@@ -82,16 +168,20 @@ async function loadPresetEntry(merged: MergedRoots, entry: string, seen: Set<str
|
|
|
82
168
|
if (merged.categories.has(entry)) {
|
|
83
169
|
const rules: ConstraintRule[] = [];
|
|
84
170
|
for (const absPath of mergedFilesInCategory(merged, entry)) {
|
|
85
|
-
rules.push(...(await
|
|
171
|
+
rules.push(...normalizeRuleFile(await readStructuredFile(absPath, options), absPath));
|
|
86
172
|
}
|
|
87
|
-
return rules;
|
|
173
|
+
return { rules, extensions: [] };
|
|
88
174
|
}
|
|
89
175
|
|
|
90
176
|
// Sub-path reference — a single winning rule file within a category.
|
|
91
177
|
const subPath = findMergedFile(merged, entry);
|
|
92
|
-
if (subPath !== null)
|
|
178
|
+
if (subPath !== null)
|
|
179
|
+
return {
|
|
180
|
+
rules: normalizeRuleFile(await readStructuredFile(subPath, options), subPath),
|
|
181
|
+
extensions: [],
|
|
182
|
+
};
|
|
93
183
|
|
|
94
|
-
return [];
|
|
184
|
+
return { rules: [], extensions: [] };
|
|
95
185
|
}
|
|
96
186
|
|
|
97
187
|
/**
|
|
@@ -183,30 +273,57 @@ async function listImmediateDirs(fs: NodeFileSystem, dir: string): Promise<strin
|
|
|
183
273
|
return dirs;
|
|
184
274
|
}
|
|
185
275
|
|
|
186
|
-
async function readStructuredFile(
|
|
187
|
-
|
|
188
|
-
|
|
276
|
+
async function readStructuredFile(
|
|
277
|
+
path: string,
|
|
278
|
+
options: Pick<RuleLoaderOptions, 'validateSchema' | 'fetch'> = {},
|
|
279
|
+
): Promise<unknown> {
|
|
280
|
+
return await loadStructuredConfig(path, {
|
|
281
|
+
validateSchema: options.validateSchema,
|
|
282
|
+
fetch: options.fetch,
|
|
283
|
+
});
|
|
189
284
|
}
|
|
190
285
|
|
|
191
|
-
|
|
286
|
+
/** A rule as parsed by Zod: severity may be absent until normalization fills it. */
|
|
287
|
+
type ParsedRule = Omit<ConstraintRule, 'severity'> & { severity?: ConstraintRule['severity'] };
|
|
288
|
+
|
|
289
|
+
function normalizeRuleFile(raw: unknown, filePath: string): ConstraintRule[] {
|
|
290
|
+
const sourceDir = dirname(filePath);
|
|
192
291
|
const maybeFile = ConstraintRuleFileSchema.safeParse(raw);
|
|
193
292
|
if (maybeFile.success) return normalizeFileRules(maybeFile.data, sourceDir);
|
|
194
293
|
const maybeRule = ConstraintRuleSchema.safeParse(raw);
|
|
195
|
-
if (maybeRule.success) return [normalizeRule(maybeRule.data,
|
|
196
|
-
|
|
294
|
+
if (maybeRule.success) return [normalizeRule(maybeRule.data, sourceDir)];
|
|
295
|
+
// Surface the schema diagnostics that best fit the input: a `rules:` array means
|
|
296
|
+
// the author intended a rule file, so report against that schema; otherwise the
|
|
297
|
+
// single-rule schema. Include field paths so the offending key is obvious.
|
|
298
|
+
const isRuleFileShape = typeof raw === 'object' && raw !== null && 'rules' in raw;
|
|
299
|
+
const issues = (isRuleFileShape ? maybeFile.error : maybeRule.error).issues;
|
|
300
|
+
throw new Error(`Invalid rule file "${basename(filePath)}": ${formatIssues(issues)}`);
|
|
197
301
|
}
|
|
198
302
|
|
|
199
|
-
|
|
303
|
+
/** Render Zod issues as `path: message` fragments for actionable diagnostics. */
|
|
304
|
+
function formatIssues(issues: readonly { path: PropertyKey[]; message: string }[]): string {
|
|
305
|
+
return issues
|
|
306
|
+
.map((issue) => {
|
|
307
|
+
const path = issue.path.map(String).join('.');
|
|
308
|
+
return path.length > 0 ? `${path}: ${issue.message}` : issue.message;
|
|
309
|
+
})
|
|
310
|
+
.join('; ');
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** A rule file as parsed by Zod: rule severities may be absent until normalization. */
|
|
314
|
+
type ParsedRuleFile = Omit<ConstraintRuleFile, 'rules'> & { rules: ParsedRule[] };
|
|
315
|
+
|
|
316
|
+
function normalizeFileRules(file: ParsedRuleFile, sourceDir: string): ConstraintRule[] {
|
|
200
317
|
return file.rules.map((rule) =>
|
|
201
318
|
normalizeRule(
|
|
202
319
|
{
|
|
203
320
|
...rule,
|
|
204
|
-
|
|
321
|
+
// Rule-level severity wins, then the file-level default, then 'error'.
|
|
322
|
+
severity: rule.severity ?? file.severity,
|
|
205
323
|
include: rule.include ?? file.include,
|
|
206
324
|
// File-level excludes always apply; a rule's own excludes add to (not replace) them.
|
|
207
325
|
exclude: mergeExcludes(file.exclude, rule.exclude),
|
|
208
326
|
},
|
|
209
|
-
{},
|
|
210
327
|
sourceDir,
|
|
211
328
|
),
|
|
212
329
|
);
|
|
@@ -218,7 +335,7 @@ function mergeExcludes(fileExclude?: string[], ruleExclude?: string[]): string[]
|
|
|
218
335
|
return [...new Set([...(fileExclude ?? []), ...(ruleExclude ?? [])])];
|
|
219
336
|
}
|
|
220
337
|
|
|
221
|
-
function normalizeRule(rule:
|
|
338
|
+
function normalizeRule(rule: ParsedRule, _sourceDir: string): ConstraintRule {
|
|
222
339
|
return {
|
|
223
340
|
...rule,
|
|
224
341
|
enabled: rule.enabled ?? true,
|
package/src/engine.ts
CHANGED
|
@@ -39,26 +39,16 @@ export class RuleEngine {
|
|
|
39
39
|
this.host.evaluators.register(type, evaluator, 'extension');
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
/**
|
|
42
|
+
/**
|
|
43
|
+
* Evaluate all enabled rules against a working directory.
|
|
44
|
+
*
|
|
45
|
+
* Thin delegate to {@link evaluateWithFixes} with `maxFixMode = 'none'`; the fix
|
|
46
|
+
* branch in that path is short-circuited by `effectiveFixMode` so callers see only
|
|
47
|
+
* findings, never auto-generated fixes. Keeps the rule loop and error-finding
|
|
48
|
+
* semantics in one place.
|
|
49
|
+
*/
|
|
43
50
|
async evaluate(rules: ConstraintRule[], workdir: string): Promise<RuleEngineResult> {
|
|
44
|
-
|
|
45
|
-
const fixes: Fix[] = [];
|
|
46
|
-
for (const rule of rules) {
|
|
47
|
-
if (rule.enabled === false) continue;
|
|
48
|
-
try {
|
|
49
|
-
const result = await this.host.evaluators.get(rule.evaluator.type).evaluate(rule, { rule, workdir });
|
|
50
|
-
findings.push(...result.findings);
|
|
51
|
-
fixes.push(...result.fixes);
|
|
52
|
-
} catch (error) {
|
|
53
|
-
findings.push(
|
|
54
|
-
createFinding(rule, error instanceof Error ? error.message : String(error), null, {
|
|
55
|
-
code: `evaluator:${rule.evaluator.type}`,
|
|
56
|
-
kind: 'error',
|
|
57
|
-
}),
|
|
58
|
-
);
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
return { findings, fixes };
|
|
51
|
+
return this.evaluateWithFixes(rules, workdir, 'none');
|
|
62
52
|
}
|
|
63
53
|
|
|
64
54
|
/**
|
|
@@ -100,8 +100,8 @@ export class CoverageGateEvaluator implements RuleEvaluator {
|
|
|
100
100
|
function parseLcov(raw: string): Map<string, FileCoverage> {
|
|
101
101
|
const result = new Map<string, FileCoverage>();
|
|
102
102
|
let file: string | null = null;
|
|
103
|
-
let linesFound = 0;
|
|
104
|
-
let linesHit = 0;
|
|
103
|
+
let linesFound: number | null = 0;
|
|
104
|
+
let linesHit: number | null = 0;
|
|
105
105
|
for (const line of raw.split('\n')) {
|
|
106
106
|
const trimmed = line.trim();
|
|
107
107
|
if (trimmed.startsWith('SF:')) {
|
|
@@ -109,17 +109,27 @@ function parseLcov(raw: string): Map<string, FileCoverage> {
|
|
|
109
109
|
linesFound = 0;
|
|
110
110
|
linesHit = 0;
|
|
111
111
|
} else if (trimmed.startsWith('LF:')) {
|
|
112
|
-
linesFound =
|
|
112
|
+
linesFound = parseCount(trimmed.slice(3));
|
|
113
113
|
} else if (trimmed.startsWith('LH:')) {
|
|
114
|
-
linesHit =
|
|
114
|
+
linesHit = parseCount(trimmed.slice(3));
|
|
115
115
|
} else if (trimmed === 'end_of_record' && file !== null) {
|
|
116
|
-
|
|
116
|
+
// Skip records with malformed counts: a non-numeric LF:/LH: would
|
|
117
|
+
// otherwise yield NaN coverage and a spurious below-threshold finding.
|
|
118
|
+
if (linesFound !== null && linesHit !== null) {
|
|
119
|
+
result.set(file, { linesFound, linesHit });
|
|
120
|
+
}
|
|
117
121
|
file = null;
|
|
118
122
|
}
|
|
119
123
|
}
|
|
120
124
|
return result;
|
|
121
125
|
}
|
|
122
126
|
|
|
127
|
+
/** Parse an lcov count field; return null for non-finite or negative values. */
|
|
128
|
+
function parseCount(raw: string): number | null {
|
|
129
|
+
const value = Number(raw);
|
|
130
|
+
return Number.isFinite(value) && value >= 0 ? value : null;
|
|
131
|
+
}
|
|
132
|
+
|
|
123
133
|
/** Standard exclusions applied regardless of config (tests, generated, deps). */
|
|
124
134
|
function isAlwaysExcluded(filePath: string): boolean {
|
|
125
135
|
return (
|
|
@@ -34,6 +34,74 @@ export async function readWorkdirFile(workdir: string, filePath: string, fs = ne
|
|
|
34
34
|
return await fs.readFile(resolve(workdir, filePath));
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/** A discovered in-scope file paired with its contents. */
|
|
38
|
+
export interface ScannedFile {
|
|
39
|
+
/** Workdir-relative path. */
|
|
40
|
+
readonly file: string;
|
|
41
|
+
/** Full file contents. */
|
|
42
|
+
readonly content: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** How `scanFiles` matches `include` / `exclude` against discovered paths. */
|
|
46
|
+
export type ScanMatchMode = 'loose' | 'glob';
|
|
47
|
+
|
|
48
|
+
/** Options for {@link scanFiles}. */
|
|
49
|
+
export interface ScanFilesOptions {
|
|
50
|
+
/** Working directory to walk. */
|
|
51
|
+
workdir: string;
|
|
52
|
+
/** Include patterns; semantics depend on `matchMode`. Undefined/empty = all files. */
|
|
53
|
+
include?: string[];
|
|
54
|
+
/** Exclude patterns; semantics depend on `matchMode`. */
|
|
55
|
+
exclude?: string[];
|
|
56
|
+
/**
|
|
57
|
+
* Scope matching policy:
|
|
58
|
+
* - `loose` — substring/suffix fragments via {@link matchesAny} (back-compat for
|
|
59
|
+
* evaluators that historically accepted bare fragments like `.ts` or `src/`).
|
|
60
|
+
* - `glob` — anchored `**`/`*` globs via {@link matchesGlob}.
|
|
61
|
+
*
|
|
62
|
+
* The two are NOT interchangeable: a bare `src/` matches different sets under each.
|
|
63
|
+
* Each evaluator declares the mode that preserves its existing behavior.
|
|
64
|
+
*/
|
|
65
|
+
matchMode: ScanMatchMode;
|
|
66
|
+
/** Filesystem adapter. */
|
|
67
|
+
fs?: FileSystem;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Discover in-scope files and read each once — the shared scaffolding behind the
|
|
72
|
+
* line-scanning evaluators. Owns discovery, scope filtering (per `matchMode`), and
|
|
73
|
+
* reads, so each evaluator is left with only its own matcher.
|
|
74
|
+
*
|
|
75
|
+
* Scope is a parameter, not assumed one-per-rule: callers that scan under several
|
|
76
|
+
* scopes (e.g. import boundaries) pass no `include` here and apply their own globs to
|
|
77
|
+
* the returned paths.
|
|
78
|
+
*/
|
|
79
|
+
export async function scanFiles(options: ScanFilesOptions): Promise<ScannedFile[]> {
|
|
80
|
+
const fs = options.fs ?? new NodeFileSystem();
|
|
81
|
+
const files =
|
|
82
|
+
options.matchMode === 'loose'
|
|
83
|
+
? await discoverFiles({ workdir: options.workdir, include: options.include, exclude: options.exclude, fs })
|
|
84
|
+
: await discoverFilesByGlob(options.workdir, options.include, options.exclude, fs);
|
|
85
|
+
const scanned: ScannedFile[] = [];
|
|
86
|
+
for (const file of files) {
|
|
87
|
+
scanned.push({ file, content: await readWorkdirFile(options.workdir, file, fs) });
|
|
88
|
+
}
|
|
89
|
+
return scanned;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Discover files then filter with anchored globs (strict mode for {@link scanFiles}). */
|
|
93
|
+
async function discoverFilesByGlob(
|
|
94
|
+
workdir: string,
|
|
95
|
+
include: string[] | undefined,
|
|
96
|
+
exclude: string[] | undefined,
|
|
97
|
+
fs: FileSystem,
|
|
98
|
+
): Promise<string[]> {
|
|
99
|
+
const all = await discoverFiles({ workdir, fs });
|
|
100
|
+
return all
|
|
101
|
+
.filter((file) => include === undefined || include.length === 0 || include.some((g) => matchesGlob(file, g)))
|
|
102
|
+
.filter((file) => exclude === undefined || !exclude.some((g) => matchesGlob(file, g)));
|
|
103
|
+
}
|
|
104
|
+
|
|
37
105
|
/** Ensure a path is workdir-relative for findings. */
|
|
38
106
|
export function relativeToWorkdir(workdir: string, path: string): string {
|
|
39
107
|
return relative(workdir, resolve(path));
|
|
@@ -91,3 +159,27 @@ function matchSegment(segment: string, pattern: string): boolean {
|
|
|
91
159
|
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replaceAll('*', '[^/]*');
|
|
92
160
|
return new RegExp(`^${escaped}$`).test(segment);
|
|
93
161
|
}
|
|
162
|
+
|
|
163
|
+
/** Escape a string for safe literal use inside a `RegExp` source. */
|
|
164
|
+
export function escapeRegExp(value: string): string {
|
|
165
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Return the value as a `string[]` when every item is a string, otherwise undefined. */
|
|
169
|
+
export function stringArray(value: unknown): string[] | undefined {
|
|
170
|
+
return Array.isArray(value) && value.every((item) => typeof item === 'string') ? (value as string[]) : undefined;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Split a leading ripgrep/PCRE-style `(?flags)` inline group off a regex source.
|
|
175
|
+
*
|
|
176
|
+
* Returns the JS-relevant flags found in the group (filtered to `imsu`) and the
|
|
177
|
+
* remaining source with the group removed. When no leading group is present, returns
|
|
178
|
+
* empty flags and the source unchanged. Shared by evaluators that accept inline flags.
|
|
179
|
+
*/
|
|
180
|
+
export function parseInlineFlags(source: string): { flags: string; rest: string } {
|
|
181
|
+
const match = /^\(\?([a-z]+)\)/.exec(source);
|
|
182
|
+
if (!match) return { flags: '', rest: source };
|
|
183
|
+
const flags = [...(match[1] ?? '')].filter((flag) => 'imsu'.includes(flag)).join('');
|
|
184
|
+
return { flags, rest: source.slice(match[0].length) };
|
|
185
|
+
}
|