@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
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pluggable test-path resolution for the test-location evaluator.
|
|
3
|
+
*
|
|
4
|
+
* Each implementation maps a source file path to its conventional test file path
|
|
5
|
+
* for one language, so the same evaluator works across TypeScript, Python, Go, and
|
|
6
|
+
* Rust projects. Resolvers are registered on the host and selected per rule via
|
|
7
|
+
* `evaluator.config.resolver`.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* TypeScript conventions:
|
|
11
|
+
* src/foo/bar.ts → tests/foo/bar.test.ts
|
|
12
|
+
* packages/core/src/foo/bar.ts → packages/core/tests/foo/bar.test.ts
|
|
13
|
+
*/
|
|
14
|
+
export class TypeScriptTestPathResolver {
|
|
15
|
+
/** Registry key. */
|
|
16
|
+
name = 'typescript';
|
|
17
|
+
constructor() { }
|
|
18
|
+
/** Map a TS/JS source path to its `tests/…test.ts` counterpart. */
|
|
19
|
+
resolveTestPath(srcRelPath) {
|
|
20
|
+
if (srcRelPath.includes('.test.') || srcRelPath.includes('.spec.'))
|
|
21
|
+
return srcRelPath;
|
|
22
|
+
const srcIdx = srcRelPath.indexOf('/src/');
|
|
23
|
+
if (srcIdx !== -1) {
|
|
24
|
+
const pkg = srcRelPath.slice(0, srcIdx);
|
|
25
|
+
const rel = srcRelPath.slice(srcIdx + '/src/'.length).replace(/\.(ts|tsx|js|jsx)$/, '.test.ts');
|
|
26
|
+
return `${pkg}/tests/${rel}`;
|
|
27
|
+
}
|
|
28
|
+
const withoutExt = srcRelPath.replace(/\.(ts|tsx|js|jsx)$/, '');
|
|
29
|
+
return `tests/${withoutExt.replace(/^src\//, '')}.test.ts`;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Python conventions (pytest):
|
|
34
|
+
* src/foo/bar.py → tests/foo/test_bar.py
|
|
35
|
+
*/
|
|
36
|
+
export class PythonTestPathResolver {
|
|
37
|
+
/** Registry key. */
|
|
38
|
+
name = 'python';
|
|
39
|
+
constructor() { }
|
|
40
|
+
/** Map a Python source path to its `tests/…/test_*.py` counterpart. */
|
|
41
|
+
resolveTestPath(srcRelPath) {
|
|
42
|
+
if (!srcRelPath)
|
|
43
|
+
throw new Error('empty source path');
|
|
44
|
+
if (srcRelPath.endsWith('_test.py') || srcRelPath.includes('/test_') || srcRelPath.startsWith('test_')) {
|
|
45
|
+
return srcRelPath;
|
|
46
|
+
}
|
|
47
|
+
if (srcRelPath.startsWith('tests/'))
|
|
48
|
+
return srcRelPath;
|
|
49
|
+
if (!srcRelPath.endsWith('.py'))
|
|
50
|
+
throw new Error(`unsupported extension for python resolver: ${srcRelPath}`);
|
|
51
|
+
const srcIdx = srcRelPath.indexOf('/src/');
|
|
52
|
+
if (srcIdx !== -1) {
|
|
53
|
+
const pkg = srcRelPath.slice(0, srcIdx);
|
|
54
|
+
return `${pkg}/tests/${testify(srcRelPath.slice(srcIdx + '/src/'.length))}`;
|
|
55
|
+
}
|
|
56
|
+
if (srcRelPath.startsWith('src/'))
|
|
57
|
+
return `tests/${testify(srcRelPath.slice(4))}`;
|
|
58
|
+
return `tests/${testify(srcRelPath)}`;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Go conventions:
|
|
63
|
+
* foo/bar.go → foo/bar_test.go (sibling)
|
|
64
|
+
*/
|
|
65
|
+
export class GoTestPathResolver {
|
|
66
|
+
/** Registry key. */
|
|
67
|
+
name = 'go';
|
|
68
|
+
constructor() { }
|
|
69
|
+
/** Map a Go source path to its sibling `_test.go` file. */
|
|
70
|
+
resolveTestPath(srcRelPath) {
|
|
71
|
+
if (!srcRelPath)
|
|
72
|
+
throw new Error('empty source path');
|
|
73
|
+
if (srcRelPath.endsWith('_test.go'))
|
|
74
|
+
return srcRelPath;
|
|
75
|
+
if (!srcRelPath.endsWith('.go'))
|
|
76
|
+
throw new Error(`unsupported extension for go resolver: ${srcRelPath}`);
|
|
77
|
+
return srcRelPath.replace(/\.go$/, '_test.go');
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Rust conventions (Cargo integration tests):
|
|
82
|
+
* crate/src/foo.rs → crate/tests/foo.rs
|
|
83
|
+
*/
|
|
84
|
+
export class RustTestPathResolver {
|
|
85
|
+
/** Registry key. */
|
|
86
|
+
name = 'rust';
|
|
87
|
+
constructor() { }
|
|
88
|
+
/** Map a Rust source path to its `tests/` integration-test counterpart. */
|
|
89
|
+
resolveTestPath(srcRelPath) {
|
|
90
|
+
if (!srcRelPath)
|
|
91
|
+
throw new Error('empty source path');
|
|
92
|
+
if (srcRelPath.startsWith('tests/') || srcRelPath.includes('/tests/'))
|
|
93
|
+
return srcRelPath;
|
|
94
|
+
if (!srcRelPath.endsWith('.rs'))
|
|
95
|
+
throw new Error(`unsupported extension for rust resolver: ${srcRelPath}`);
|
|
96
|
+
const srcIdx = srcRelPath.indexOf('/src/');
|
|
97
|
+
if (srcIdx !== -1) {
|
|
98
|
+
const crate = srcRelPath.slice(0, srcIdx);
|
|
99
|
+
return `${crate}/tests/${srcRelPath.slice(srcIdx + '/src/'.length)}`;
|
|
100
|
+
}
|
|
101
|
+
if (srcRelPath.startsWith('src/'))
|
|
102
|
+
return `tests/${srcRelPath.slice(4)}`;
|
|
103
|
+
return `tests/${srcRelPath}`;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/** Prefix the basename of a Python source path with `test_`. */
|
|
107
|
+
function testify(rel) {
|
|
108
|
+
const lastSlash = rel.lastIndexOf('/');
|
|
109
|
+
const dir = lastSlash >= 0 ? rel.slice(0, lastSlash + 1) : '';
|
|
110
|
+
const base = lastSlash >= 0 ? rel.slice(lastSlash + 1) : rel;
|
|
111
|
+
return `${dir}test_${base}`;
|
|
112
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -49,6 +49,17 @@ export interface ConstraintRuleFile {
|
|
|
49
49
|
/** Rule definitions. */
|
|
50
50
|
rules: ConstraintRule[];
|
|
51
51
|
}
|
|
52
|
+
/** Relative module paths a preset contributes per capability kind. */
|
|
53
|
+
export interface PresetExtensions {
|
|
54
|
+
/** Test-path resolver module paths. */
|
|
55
|
+
resolvers?: string[];
|
|
56
|
+
/** Evaluator module paths. */
|
|
57
|
+
evaluators?: string[];
|
|
58
|
+
/** Fixer module paths. */
|
|
59
|
+
fixers?: string[];
|
|
60
|
+
/** Formatter module paths. */
|
|
61
|
+
formatters?: string[];
|
|
62
|
+
}
|
|
52
63
|
/** Preset definition that composes category folders or other presets. */
|
|
53
64
|
export interface PresetDefinition {
|
|
54
65
|
/** Preset name. */
|
|
@@ -63,6 +74,8 @@ export interface PresetDefinition {
|
|
|
63
74
|
mode: FixMode;
|
|
64
75
|
};
|
|
65
76
|
}>;
|
|
77
|
+
/** Custom capability modules contributed by this preset (opt-in to load). */
|
|
78
|
+
extensions?: PresetExtensions;
|
|
66
79
|
}
|
|
67
80
|
/** Candidate fix emitted by an evaluator or fixer. */
|
|
68
81
|
export interface Fix {
|
|
@@ -79,6 +92,15 @@ export interface Fix {
|
|
|
79
92
|
/** Whether this fix may be applied automatically. */
|
|
80
93
|
mode: Exclude<FixMode, 'none'>;
|
|
81
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* What a finding represents.
|
|
97
|
+
*
|
|
98
|
+
* - `violation`: the rule ran and the project breached its policy (the default).
|
|
99
|
+
* - `error`: the rule could not run — a misconfiguration or runtime fault in the
|
|
100
|
+
* evaluator itself. These are not policy breaches and callers may surface them
|
|
101
|
+
* separately (e.g. "rule misconfigured") rather than as project violations.
|
|
102
|
+
*/
|
|
103
|
+
export type FindingKind = 'violation' | 'error';
|
|
82
104
|
/** Finding emitted by a constraint rule. */
|
|
83
105
|
export interface ConstraintFinding {
|
|
84
106
|
/** Rule identifier. */
|
|
@@ -95,6 +117,8 @@ export interface ConstraintFinding {
|
|
|
95
117
|
column?: number;
|
|
96
118
|
/** Machine-readable evaluator/source code. */
|
|
97
119
|
code?: string;
|
|
120
|
+
/** Whether this is a policy violation or an evaluator error. Absent means `violation`. */
|
|
121
|
+
kind?: FindingKind;
|
|
98
122
|
}
|
|
99
123
|
/** Aggregate result returned by a rule evaluator. */
|
|
100
124
|
export interface RuleEvaluationResult {
|
|
@@ -214,5 +238,11 @@ export declare const PresetDefinitionSchema: z.ZodObject<{
|
|
|
214
238
|
}>;
|
|
215
239
|
}, z.core.$strip>>;
|
|
216
240
|
}, z.core.$strip>>>;
|
|
241
|
+
extensions: z.ZodOptional<z.ZodObject<{
|
|
242
|
+
resolvers: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
243
|
+
evaluators: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
244
|
+
fixers: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
245
|
+
formatters: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
246
|
+
}, z.core.$strip>>;
|
|
217
247
|
}, z.core.$strip>;
|
|
218
248
|
//# sourceMappingURL=types.d.ts.map
|
package/dist/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,mDAAmD;AACnD,MAAM,MAAM,YAAY,GAAG,OAAO,GAAG,SAAS,GAAG,MAAM,CAAC;AAExD,+CAA+C;AAC/C,MAAM,MAAM,OAAO,GAAG,MAAM,GAAG,SAAS,GAAG,MAAM,CAAC;AAElD,qDAAqD;AACrD,MAAM,WAAW,mBAAmB;IAChC,iDAAiD;IACjD,IAAI,EAAE,MAAM,CAAC;IACb,kCAAkC;IAClC,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACpC;AAED,2DAA2D;AAC3D,MAAM,WAAW,cAAc;IAC3B,8BAA8B;IAC9B,EAAE,EAAE,MAAM,CAAC;IACX,kCAAkC;IAClC,WAAW,EAAE,MAAM,CAAC;IACpB,mCAAmC;IACnC,OAAO,EAAE,OAAO,CAAC;IACjB,6CAA6C;IAC7C,QAAQ,EAAE,YAAY,CAAC;IACvB,+BAA+B;IAC/B,SAAS,EAAE,mBAAmB,CAAC;IAC/B,uCAAuC;IACvC,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IACnB,uCAAuC;IACvC,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IACnB,6BAA6B;IAC7B,GAAG,CAAC,EAAE,aAAa,CAAC;CACvB;AAED,4CAA4C;AAC5C,MAAM,WAAW,aAAa;IAC1B,4CAA4C;IAC5C,IAAI,EAAE,OAAO,CAAC;IACd,uDAAuD;IACvD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,0CAA0C;IAC1C,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACpC;AAED,4CAA4C;AAC5C,MAAM,WAAW,kBAAkB;IAC/B,2CAA2C;IAC3C,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IACnB,2CAA2C;IAC3C,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IACnB,mCAAmC;IACnC,QAAQ,CAAC,EAAE,YAAY,CAAC;IACxB,wBAAwB;IACxB,KAAK,EAAE,cAAc,EAAE,CAAC;CAC3B;AAED,yEAAyE;AACzE,MAAM,WAAW,gBAAgB;IAC7B,mBAAmB;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,mDAAmD;IACnD,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,2BAA2B;IAC3B,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IACnB,0BAA0B;IAC1B,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE;QAAE,GAAG,CAAC,EAAE;YAAE,IAAI,EAAE,OAAO,CAAA;SAAE,CAAA;KAAE,CAAC,CAAC;
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
|
package/dist/types.js
CHANGED
|
@@ -46,4 +46,12 @@ export const PresetDefinitionSchema = z.object({
|
|
|
46
46
|
overrides: z
|
|
47
47
|
.record(z.string(), z.object({ fix: z.object({ mode: z.enum(['none', 'suggest', 'auto']) }).optional() }))
|
|
48
48
|
.optional(),
|
|
49
|
+
extensions: z
|
|
50
|
+
.object({
|
|
51
|
+
resolvers: z.array(z.string()).optional(),
|
|
52
|
+
evaluators: z.array(z.string()).optional(),
|
|
53
|
+
fixers: z.array(z.string()).optional(),
|
|
54
|
+
formatters: z.array(z.string()).optional(),
|
|
55
|
+
})
|
|
56
|
+
.optional(),
|
|
49
57
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gobing-ai/ts-rule-engine",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.7",
|
|
4
4
|
"description": "@gobing-ai/ts-rule-engine — Constraint rule schemas, loading, evaluation, and result formatting.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"typescript",
|
|
@@ -47,8 +47,8 @@
|
|
|
47
47
|
"release": "echo 'Manual publish is disabled. Releases go through GitHub Actions via Trusted Publishing — push a tag: git tag @gobing-ai/ts-rule-engine-v<version> && git push --tags' && exit 1"
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
|
-
"@gobing-ai/ts-ai-runner": "^0.2.
|
|
51
|
-
"@gobing-ai/ts-runtime": "^0.2.
|
|
50
|
+
"@gobing-ai/ts-ai-runner": "^0.2.7",
|
|
51
|
+
"@gobing-ai/ts-runtime": "^0.2.7",
|
|
52
52
|
"yaml": "^2.7.0",
|
|
53
53
|
"zod": "^4.1.0"
|
|
54
54
|
},
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import type { RuleEngineHost } from '../host/rule-engine-host';
|
|
3
|
+
|
|
4
|
+
/** A capability kind a preset extension can contribute. */
|
|
5
|
+
export type ExtensionKind = 'resolvers' | 'evaluators' | 'fixers' | 'formatters';
|
|
6
|
+
|
|
7
|
+
/** A single extension module reference, resolved to an absolute path. */
|
|
8
|
+
export interface ExtensionRef {
|
|
9
|
+
/** Capability registry the module registers into. */
|
|
10
|
+
readonly kind: ExtensionKind;
|
|
11
|
+
/** Absolute path to the module to import. */
|
|
12
|
+
readonly absPath: string;
|
|
13
|
+
/** Name of the preset that declared this extension (for diagnostics). */
|
|
14
|
+
readonly presetName: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Options controlling preset-extension loading. */
|
|
18
|
+
export interface LoadExtensionsOptions {
|
|
19
|
+
/**
|
|
20
|
+
* Whether to actually import extension modules. Defaults to `false`: loading
|
|
21
|
+
* arbitrary code referenced by a preset is a trust decision the caller must
|
|
22
|
+
* make explicitly. When refs exist and this is false, loading throws.
|
|
23
|
+
*/
|
|
24
|
+
allowExtensions?: boolean;
|
|
25
|
+
/** Optional sink for non-fatal warnings (e.g. built-in overrides). */
|
|
26
|
+
logger?: { warn: (message: string) => void };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Host registries that can receive extension capabilities (fixers live on the engine, not the host). */
|
|
30
|
+
const HOST_REGISTRY_BY_KIND: Partial<Record<ExtensionKind, 'resolvers' | 'evaluators' | 'formatters'>> = {
|
|
31
|
+
resolvers: 'resolvers',
|
|
32
|
+
evaluators: 'evaluators',
|
|
33
|
+
formatters: 'formatters',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Collect extension refs declared by a preset's `extensions` block.
|
|
38
|
+
*
|
|
39
|
+
* Paths are resolved relative to the preset file's directory. Use the returned
|
|
40
|
+
* refs with {@link loadExtensionsIntoHost}.
|
|
41
|
+
*/
|
|
42
|
+
export function collectPresetExtensions(
|
|
43
|
+
presetName: string,
|
|
44
|
+
presetDir: string,
|
|
45
|
+
extensions: Partial<Record<ExtensionKind, string[] | undefined>> | undefined,
|
|
46
|
+
): ExtensionRef[] {
|
|
47
|
+
if (extensions === undefined) return [];
|
|
48
|
+
const refs: ExtensionRef[] = [];
|
|
49
|
+
for (const kind of ['resolvers', 'evaluators', 'fixers', 'formatters'] as ExtensionKind[]) {
|
|
50
|
+
for (const path of extensions[kind] ?? []) {
|
|
51
|
+
refs.push({ kind, presetName, absPath: resolve(presetDir, path) });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return refs;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Import each extension module and register its export on the matching host
|
|
59
|
+
* registry.
|
|
60
|
+
*
|
|
61
|
+
* A module must default-export (or named-export `extension`) an object with a
|
|
62
|
+
* `name: string` and the capability implementation. Loading is gated by
|
|
63
|
+
* {@link LoadExtensionsOptions.allowExtensions}; when refs are present but
|
|
64
|
+
* loading is not allowed, this throws so the requirement is never silently dropped.
|
|
65
|
+
*
|
|
66
|
+
* @throws When extensions are present but `allowExtensions` is not true, or when
|
|
67
|
+
* a module cannot be imported or lacks a valid `name`.
|
|
68
|
+
*/
|
|
69
|
+
export async function loadExtensionsIntoHost(
|
|
70
|
+
host: RuleEngineHost,
|
|
71
|
+
refs: readonly ExtensionRef[],
|
|
72
|
+
options: LoadExtensionsOptions = {},
|
|
73
|
+
): Promise<void> {
|
|
74
|
+
if (refs.length === 0) return;
|
|
75
|
+
if (options.allowExtensions !== true) {
|
|
76
|
+
const first = refs[0] as ExtensionRef;
|
|
77
|
+
throw new Error(
|
|
78
|
+
`preset "${first.presetName}" declares ${first.kind} extension "${first.absPath}", but extensions are disabled — pass allowExtensions: true to load preset extension modules`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
for (const ref of refs) {
|
|
83
|
+
const moduleExports = (await import(ref.absPath)) as Record<string, unknown>;
|
|
84
|
+
const candidate = moduleExports.default ?? moduleExports.extension;
|
|
85
|
+
if (
|
|
86
|
+
candidate === null ||
|
|
87
|
+
typeof candidate !== 'object' ||
|
|
88
|
+
typeof (candidate as { name?: unknown }).name !== 'string'
|
|
89
|
+
) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
`preset "${ref.presetName}" extension "${ref.absPath}" must export an object with a string "name"`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
const name = (candidate as { name: string }).name;
|
|
95
|
+
const registryKey = HOST_REGISTRY_BY_KIND[ref.kind];
|
|
96
|
+
if (registryKey === undefined) {
|
|
97
|
+
throw new Error(`preset "${ref.presetName}" ${ref.kind} extensions are not supported`);
|
|
98
|
+
}
|
|
99
|
+
const registry = host[registryKey] as unknown as {
|
|
100
|
+
register: (name: string, impl: unknown, origin: 'builtin' | 'extension') => void;
|
|
101
|
+
has?: (name: string) => boolean;
|
|
102
|
+
};
|
|
103
|
+
if (options.logger && registry.has?.(name)) {
|
|
104
|
+
options.logger.warn(`preset "${ref.presetName}" ${ref.kind} extension overrides existing "${name}"`);
|
|
105
|
+
}
|
|
106
|
+
registry.register(name, candidate, 'extension');
|
|
107
|
+
}
|
|
108
|
+
}
|
package/src/config/loader.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { basename, dirname, extname, join, resolve } from 'node:path';
|
|
1
|
+
import { basename, dirname, extname, join, relative, resolve, sep } from 'node:path';
|
|
2
2
|
import { NodeFileSystem } from '@gobing-ai/ts-runtime';
|
|
3
3
|
import { parse } from 'yaml';
|
|
4
4
|
import {
|
|
@@ -12,22 +12,39 @@ import {
|
|
|
12
12
|
|
|
13
13
|
/** Options for loading rule presets. */
|
|
14
14
|
export interface RuleLoaderOptions {
|
|
15
|
-
/**
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Ordered rule root directories, highest priority first. Presets, category
|
|
17
|
+
* folders, and rule files are resolved across all roots: the highest-priority
|
|
18
|
+
* root that provides a given relative path wins, and gaps are filled from
|
|
19
|
+
* lower-priority roots. The caller owns root discovery and ordering — this
|
|
20
|
+
* loader stays agnostic to any project layout convention.
|
|
21
|
+
*/
|
|
22
|
+
roots: string[];
|
|
19
23
|
}
|
|
20
24
|
|
|
21
|
-
/**
|
|
25
|
+
/** Merged view of rule roots: winning file per relative path, plus categories. */
|
|
26
|
+
interface MergedRoots {
|
|
27
|
+
/** Normalized relative path (e.g. `quality/coverage-gate.yaml`) → winning absolute path. */
|
|
28
|
+
readonly files: ReadonlyMap<string, string>;
|
|
29
|
+
/** Category folder names visible across all roots. */
|
|
30
|
+
readonly categories: ReadonlySet<string>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Load and normalize a preset by name, resolving across one or more rule roots.
|
|
35
|
+
*
|
|
36
|
+
* Roots are merged in order: the first root to provide a relative path owns it,
|
|
37
|
+
* so a caller can layer project-local rules over shared/global rules and inherit
|
|
38
|
+
* the rest of a preset's categories from the lower-priority roots.
|
|
39
|
+
*/
|
|
22
40
|
export async function loadPresetRules(name: string, options: RuleLoaderOptions): Promise<ConstraintRule[]> {
|
|
23
|
-
const
|
|
24
|
-
const
|
|
25
|
-
const presetPath = await findDefinitionPath(root, name);
|
|
41
|
+
const merged = await buildMergedRoots(options.roots.map((root) => resolve(root)));
|
|
42
|
+
const presetPath = findMergedPreset(merged, name);
|
|
26
43
|
if (presetPath === null) return [];
|
|
27
44
|
const preset = PresetDefinitionSchema.parse(await readStructuredFile(presetPath)) as PresetDefinition;
|
|
28
45
|
const rules: ConstraintRule[] = [];
|
|
29
46
|
for (const entry of preset.extends) {
|
|
30
|
-
rules.push(...(await loadPresetEntry(
|
|
47
|
+
rules.push(...(await loadPresetEntry(merged, entry, new Set([name]))));
|
|
31
48
|
}
|
|
32
49
|
const disabled = new Set(preset.disable ?? []);
|
|
33
50
|
const normalized = rules.filter((rule) => !disabled.has(rule.id));
|
|
@@ -37,7 +54,6 @@ export async function loadPresetRules(name: string, options: RuleLoaderOptions):
|
|
|
37
54
|
rule.fix = { ...(rule.fix ?? { mode: 'none' }), ...override.fix };
|
|
38
55
|
}
|
|
39
56
|
}
|
|
40
|
-
await fs.exists(root);
|
|
41
57
|
return normalized;
|
|
42
58
|
}
|
|
43
59
|
|
|
@@ -46,45 +62,127 @@ export async function loadRuleFile(filePath: string): Promise<ConstraintRule[]>
|
|
|
46
62
|
return normalizeRuleFile(await readStructuredFile(resolve(filePath)), dirname(resolve(filePath)));
|
|
47
63
|
}
|
|
48
64
|
|
|
49
|
-
async function loadPresetEntry(
|
|
50
|
-
|
|
51
|
-
|
|
65
|
+
async function loadPresetEntry(merged: MergedRoots, entry: string, seen: Set<string>): Promise<ConstraintRule[]> {
|
|
66
|
+
// Sub-preset reference — recurse, erroring on a genuine cycle.
|
|
67
|
+
const presetPath = findMergedPreset(merged, entry);
|
|
68
|
+
if (presetPath !== null) {
|
|
69
|
+
if (seen.has(entry)) {
|
|
70
|
+
throw new Error(`Circular preset dependency detected: ${[...seen, entry].join(' → ')}`);
|
|
71
|
+
}
|
|
52
72
|
seen.add(entry);
|
|
53
73
|
const preset = PresetDefinitionSchema.safeParse(await readStructuredFile(presetPath));
|
|
54
74
|
if (preset.success) {
|
|
55
75
|
const rules: ConstraintRule[] = [];
|
|
56
|
-
for (const child of preset.data.extends) rules.push(...(await loadPresetEntry(
|
|
76
|
+
for (const child of preset.data.extends) rules.push(...(await loadPresetEntry(merged, child, seen)));
|
|
57
77
|
return rules.filter((rule) => !(preset.data.disable ?? []).includes(rule.id));
|
|
58
78
|
}
|
|
59
79
|
}
|
|
60
80
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
rules
|
|
81
|
+
// Category folder reference — load every winning file under that prefix.
|
|
82
|
+
if (merged.categories.has(entry)) {
|
|
83
|
+
const rules: ConstraintRule[] = [];
|
|
84
|
+
for (const absPath of mergedFilesInCategory(merged, entry)) {
|
|
85
|
+
rules.push(...(await loadRuleFile(absPath)));
|
|
86
|
+
}
|
|
87
|
+
return rules;
|
|
68
88
|
}
|
|
69
|
-
|
|
89
|
+
|
|
90
|
+
// Sub-path reference — a single winning rule file within a category.
|
|
91
|
+
const subPath = findMergedFile(merged, entry);
|
|
92
|
+
if (subPath !== null) return loadRuleFile(subPath);
|
|
93
|
+
|
|
94
|
+
return [];
|
|
70
95
|
}
|
|
71
96
|
|
|
72
|
-
|
|
97
|
+
/**
|
|
98
|
+
* Build the merged view across ordered roots.
|
|
99
|
+
*
|
|
100
|
+
* Roots are processed in the order supplied (highest priority first). The first
|
|
101
|
+
* root to provide a given relative path owns that file; later roots are shadowed.
|
|
102
|
+
*/
|
|
103
|
+
async function buildMergedRoots(roots: readonly string[]): Promise<MergedRoots> {
|
|
73
104
|
const fs = new NodeFileSystem();
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
105
|
+
const files = new Map<string, string>();
|
|
106
|
+
const categories = new Set<string>();
|
|
107
|
+
for (const root of roots) {
|
|
108
|
+
for (const absPath of await walkYamlFiles(fs, root)) {
|
|
109
|
+
const relPath = relative(root, absPath).split(sep).join('/');
|
|
110
|
+
const slashIdx = relPath.indexOf('/');
|
|
111
|
+
if (slashIdx > 0) categories.add(relPath.slice(0, slashIdx));
|
|
112
|
+
if (!files.has(relPath)) files.set(relPath, absPath);
|
|
113
|
+
}
|
|
114
|
+
for (const dir of await listImmediateDirs(fs, root)) categories.add(dir);
|
|
115
|
+
}
|
|
116
|
+
return { files, categories };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Find a preset definition across roots: `<name>.{yaml,yml,json}` or `<name>/index.*`. */
|
|
120
|
+
function findMergedPreset(merged: MergedRoots, name: string): string | null {
|
|
121
|
+
return firstHit(merged, [
|
|
122
|
+
`${name}.yaml`,
|
|
123
|
+
`${name}.yml`,
|
|
124
|
+
`${name}.json`,
|
|
125
|
+
`${name}/index.yaml`,
|
|
126
|
+
`${name}/index.yml`,
|
|
127
|
+
`${name}/index.json`,
|
|
128
|
+
]);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Find a single rule file by sub-path entry (e.g. `typescript/tsdoc-exports`). */
|
|
132
|
+
function findMergedFile(merged: MergedRoots, entry: string): string | null {
|
|
133
|
+
const hasExt = /\.(ya?ml|json)$/i.test(entry);
|
|
134
|
+
return firstHit(merged, hasExt ? [entry] : [`${entry}.yaml`, `${entry}.yml`, `${entry}.json`]);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Return the winning absolute path for the first matching relative candidate. */
|
|
138
|
+
function firstHit(merged: MergedRoots, relCandidates: readonly string[]): string | null {
|
|
139
|
+
for (const rel of relCandidates) {
|
|
140
|
+
const hit = merged.files.get(rel);
|
|
141
|
+
if (hit !== undefined) return hit;
|
|
84
142
|
}
|
|
85
143
|
return null;
|
|
86
144
|
}
|
|
87
145
|
|
|
146
|
+
/** Winning files under a category prefix, sorted by relative path. */
|
|
147
|
+
function mergedFilesInCategory(merged: MergedRoots, category: string): string[] {
|
|
148
|
+
const prefix = `${category}/`;
|
|
149
|
+
return [...merged.files.entries()]
|
|
150
|
+
.filter(([relPath]) => relPath.startsWith(prefix))
|
|
151
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
152
|
+
.map(([, absPath]) => absPath);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Recursively collect YAML/JSON file paths under a directory, skipping root-level `presets/`. */
|
|
156
|
+
async function walkYamlFiles(fs: NodeFileSystem, dir: string, depth = 0): Promise<string[]> {
|
|
157
|
+
const stat = await fs.stat(dir);
|
|
158
|
+
if (stat === null || !stat.isDirectory()) return [];
|
|
159
|
+
const acc: string[] = [];
|
|
160
|
+
for (const entry of (await fs.readDir(dir)).sort()) {
|
|
161
|
+
if (depth === 0 && entry === 'presets') continue;
|
|
162
|
+
const fullPath = join(dir, entry);
|
|
163
|
+
const entryStat = await fs.stat(fullPath);
|
|
164
|
+
if (entryStat?.isDirectory()) {
|
|
165
|
+
acc.push(...(await walkYamlFiles(fs, fullPath, depth + 1)));
|
|
166
|
+
} else if (entryStat?.isFile() && /\.(ya?ml|json)$/i.test(entry)) {
|
|
167
|
+
acc.push(fullPath);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return acc;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** List immediate subdirectory names of a root (excluding `presets`). */
|
|
174
|
+
async function listImmediateDirs(fs: NodeFileSystem, dir: string): Promise<string[]> {
|
|
175
|
+
const stat = await fs.stat(dir);
|
|
176
|
+
if (stat === null || !stat.isDirectory()) return [];
|
|
177
|
+
const dirs: string[] = [];
|
|
178
|
+
for (const entry of await fs.readDir(dir)) {
|
|
179
|
+
if (entry === 'presets') continue;
|
|
180
|
+
const entryStat = await fs.stat(join(dir, entry));
|
|
181
|
+
if (entryStat?.isDirectory()) dirs.push(entry);
|
|
182
|
+
}
|
|
183
|
+
return dirs;
|
|
184
|
+
}
|
|
185
|
+
|
|
88
186
|
async function readStructuredFile(path: string): Promise<unknown> {
|
|
89
187
|
const content = await new NodeFileSystem().readFile(path);
|
|
90
188
|
return extname(path) === '.json' ? JSON.parse(content) : parse(content);
|
|
@@ -105,7 +203,8 @@ function normalizeFileRules(file: ConstraintRuleFile, sourceDir: string): Constr
|
|
|
105
203
|
...rule,
|
|
106
204
|
severity: rule.severity ?? file.severity ?? 'error',
|
|
107
205
|
include: rule.include ?? file.include,
|
|
108
|
-
|
|
206
|
+
// File-level excludes always apply; a rule's own excludes add to (not replace) them.
|
|
207
|
+
exclude: mergeExcludes(file.exclude, rule.exclude),
|
|
109
208
|
},
|
|
110
209
|
{},
|
|
111
210
|
sourceDir,
|
|
@@ -113,6 +212,12 @@ function normalizeFileRules(file: ConstraintRuleFile, sourceDir: string): Constr
|
|
|
113
212
|
);
|
|
114
213
|
}
|
|
115
214
|
|
|
215
|
+
/** Union of file-level and rule-level excludes, de-duplicated. Returns undefined when both empty. */
|
|
216
|
+
function mergeExcludes(fileExclude?: string[], ruleExclude?: string[]): string[] | undefined {
|
|
217
|
+
if (fileExclude === undefined && ruleExclude === undefined) return undefined;
|
|
218
|
+
return [...new Set([...(fileExclude ?? []), ...(ruleExclude ?? [])])];
|
|
219
|
+
}
|
|
220
|
+
|
|
116
221
|
function normalizeRule(rule: ConstraintRule, _defaults: Partial<ConstraintRule>, _sourceDir: string): ConstraintRule {
|
|
117
222
|
return {
|
|
118
223
|
...rule,
|