@gobing-ai/ts-rule-engine 0.2.8 → 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 +3 -1
- package/dist/config/extensions.d.ts +5 -4
- package/dist/config/extensions.d.ts.map +1 -1
- package/dist/config/extensions.js +6 -5
- package/dist/config/loader.d.ts +10 -2
- package/dist/config/loader.d.ts.map +1 -1
- package/dist/config/loader.js +73 -19
- 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 +22 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +35 -9
- package/package.json +5 -3
- package/schemas/preset.schema.json +19 -5
- package/src/config/extensions.ts +8 -7
- package/src/config/loader.ts +92 -21
- 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 +39 -9
|
@@ -5,10 +5,20 @@ import {
|
|
|
5
5
|
type RuleEvaluationResult,
|
|
6
6
|
type RuleEvaluator,
|
|
7
7
|
} from '../types';
|
|
8
|
-
import {
|
|
8
|
+
import { parseInlineFlags, scanFiles } from './file-utils';
|
|
9
9
|
|
|
10
|
-
/**
|
|
10
|
+
/**
|
|
11
|
+
* Evaluates whether source files match or avoid a regex pattern.
|
|
12
|
+
*
|
|
13
|
+
* Trust assumption: rule config is trusted input. The `pattern` is compiled with
|
|
14
|
+
* `new RegExp` and run per line without a backtracking bound, so a
|
|
15
|
+
* catastrophic-backtracking pattern is the rule author's responsibility. Runtime
|
|
16
|
+
* ReDoS hardening is deferred until rule packs are distributed across a wider trust
|
|
17
|
+
* boundary (see task 0003).
|
|
18
|
+
*/
|
|
11
19
|
export class RegexEvaluator implements RuleEvaluator {
|
|
20
|
+
// Explicit constructor: V8 function coverage counts only declared functions, so
|
|
21
|
+
// this method-light class needs it to clear the coverage-gate function threshold.
|
|
12
22
|
constructor() {}
|
|
13
23
|
|
|
14
24
|
/** Evaluate regex-based presence or absence constraints. */
|
|
@@ -21,11 +31,15 @@ export class RegexEvaluator implements RuleEvaluator {
|
|
|
21
31
|
);
|
|
22
32
|
const mode = stringConfig(config, 'mode', 'forbid');
|
|
23
33
|
const regex = new RegExp(pattern, flags);
|
|
24
|
-
const files = await
|
|
34
|
+
const files = await scanFiles({
|
|
35
|
+
workdir: context.workdir,
|
|
36
|
+
include: rule.include,
|
|
37
|
+
exclude: rule.exclude,
|
|
38
|
+
matchMode: 'loose',
|
|
39
|
+
});
|
|
25
40
|
const findings = [];
|
|
26
41
|
|
|
27
|
-
for (const file of files) {
|
|
28
|
-
const content = await readWorkdirFile(context.workdir, file);
|
|
42
|
+
for (const { file, content } of files) {
|
|
29
43
|
if (mode === 'require') {
|
|
30
44
|
regex.lastIndex = 0;
|
|
31
45
|
if (!regex.test(content)) {
|
|
@@ -35,6 +49,19 @@ export class RegexEvaluator implements RuleEvaluator {
|
|
|
35
49
|
}
|
|
36
50
|
continue;
|
|
37
51
|
}
|
|
52
|
+
if (config.multiline === true) {
|
|
53
|
+
const globalRegex = new RegExp(pattern, flags.includes('g') ? flags : `${flags}g`);
|
|
54
|
+
for (const match of content.matchAll(globalRegex)) {
|
|
55
|
+
if (match.index === undefined) continue;
|
|
56
|
+
findings.push(
|
|
57
|
+
createFinding(rule, `forbidden pattern found: ${pattern}`, file, {
|
|
58
|
+
line: lineForOffset(content, match.index),
|
|
59
|
+
code: 'regex:found',
|
|
60
|
+
}),
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
38
65
|
// forbid: report each matching line so findings carry precise locations.
|
|
39
66
|
for (const [index, line] of content.split('\n').entries()) {
|
|
40
67
|
regex.lastIndex = 0;
|
|
@@ -70,14 +97,8 @@ function normalizePattern(
|
|
|
70
97
|
for (const flag of rawFlags) {
|
|
71
98
|
if ('gimsuy'.includes(flag)) flagSet.add(flag);
|
|
72
99
|
}
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
if (inline) {
|
|
76
|
-
for (const flag of inline[1] ?? '') {
|
|
77
|
-
if ('imsu'.includes(flag)) flagSet.add(flag);
|
|
78
|
-
}
|
|
79
|
-
pattern = pattern.slice(inline[0].length);
|
|
80
|
-
}
|
|
100
|
+
const { flags: inlineFlags, rest: pattern } = parseInlineFlags(rawPattern);
|
|
101
|
+
for (const flag of inlineFlags) flagSet.add(flag);
|
|
81
102
|
if (multiline) flagSet.add('s');
|
|
82
103
|
return { pattern, flags: [...flagSet].join('') };
|
|
83
104
|
}
|
|
@@ -88,3 +109,12 @@ function stringConfig(config: Record<string, unknown>, key: string, fallback?: s
|
|
|
88
109
|
if (fallback !== undefined) return fallback;
|
|
89
110
|
throw new Error(`regex evaluator requires string config "${key}"`);
|
|
90
111
|
}
|
|
112
|
+
|
|
113
|
+
/** Return the one-based line containing a string offset. */
|
|
114
|
+
function lineForOffset(content: string, offset: number): number {
|
|
115
|
+
let line = 1;
|
|
116
|
+
for (let index = 0; index < offset; index += 1) {
|
|
117
|
+
if (content.charCodeAt(index) === 10) line += 1;
|
|
118
|
+
}
|
|
119
|
+
return line;
|
|
120
|
+
}
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
type RuleEvaluationResult,
|
|
6
6
|
type RuleEvaluator,
|
|
7
7
|
} from '../types';
|
|
8
|
-
import {
|
|
8
|
+
import { parseInlineFlags, scanFiles, stringArray } from './file-utils';
|
|
9
9
|
|
|
10
10
|
/** Built-in secret category names. */
|
|
11
11
|
export type SecretsCategory = 'api-key' | 'private-key' | 'password' | 'token' | 'connection-string';
|
|
@@ -43,8 +43,15 @@ interface ScanPattern {
|
|
|
43
43
|
* - `customPatterns`: extra `{ name, pattern }` entries to scan for.
|
|
44
44
|
* - `scope`: `{ include, exclude }` globs; falls back to the rule's `include` /
|
|
45
45
|
* `exclude` when omitted.
|
|
46
|
+
*
|
|
47
|
+
* Trust assumption: rule config (including `customPatterns`) is trusted input.
|
|
48
|
+
* Patterns are compiled with `new RegExp` and run per line without a backtracking
|
|
49
|
+
* bound, so a catastrophic-backtracking custom pattern is the rule author's
|
|
50
|
+
* responsibility. Runtime ReDoS hardening is deferred (see task 0003).
|
|
46
51
|
*/
|
|
47
52
|
export class SecretsScannerEvaluator implements RuleEvaluator {
|
|
53
|
+
// Explicit constructor: V8 function coverage counts only declared functions, so
|
|
54
|
+
// this method-light class needs it to clear the coverage-gate function threshold.
|
|
48
55
|
constructor() {}
|
|
49
56
|
|
|
50
57
|
/** Evaluate files against the selected secret categories and custom patterns. */
|
|
@@ -54,11 +61,11 @@ export class SecretsScannerEvaluator implements RuleEvaluator {
|
|
|
54
61
|
const scope = config.scope as { include?: unknown; exclude?: unknown } | undefined;
|
|
55
62
|
const include = stringArray(scope?.include) ?? rule.include;
|
|
56
63
|
const exclude = stringArray(scope?.exclude) ?? rule.exclude;
|
|
57
|
-
const files = await
|
|
64
|
+
const files = await scanFiles({ workdir: context.workdir, include, exclude, matchMode: 'loose' });
|
|
58
65
|
|
|
59
66
|
const findings = [];
|
|
60
|
-
for (const file of files) {
|
|
61
|
-
const lines =
|
|
67
|
+
for (const { file, content } of files) {
|
|
68
|
+
const lines = content.split('\n');
|
|
62
69
|
for (const [index, line] of lines.entries()) {
|
|
63
70
|
for (const pattern of patterns) {
|
|
64
71
|
pattern.regex.lastIndex = 0;
|
|
@@ -99,14 +106,6 @@ function buildPatterns(config: Record<string, unknown>): ScanPattern[] {
|
|
|
99
106
|
|
|
100
107
|
/** Compile a pattern, folding a leading `(?i)` group into the JS `i` flag. */
|
|
101
108
|
function compile(source: string): RegExp {
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
const flags = [...(inline[1] ?? '')].filter((flag) => 'imsu'.includes(flag)).join('');
|
|
105
|
-
return new RegExp(source.slice(inline[0].length), flags);
|
|
106
|
-
}
|
|
107
|
-
return new RegExp(source);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function stringArray(value: unknown): string[] | undefined {
|
|
111
|
-
return Array.isArray(value) && value.every((item) => typeof item === 'string') ? (value as string[]) : undefined;
|
|
109
|
+
const { flags, rest } = parseInlineFlags(source);
|
|
110
|
+
return new RegExp(rest, flags);
|
|
112
111
|
}
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
type RuleEvaluationResult,
|
|
6
6
|
type RuleEvaluator,
|
|
7
7
|
} from '../types';
|
|
8
|
-
import {
|
|
8
|
+
import { scanFiles } from './file-utils';
|
|
9
9
|
|
|
10
10
|
/** Export kinds this evaluator can check for a preceding JSDoc block. */
|
|
11
11
|
const VALID_KINDS = ['function', 'class', 'type', 'const', 'enum', 'interface'] as const;
|
|
@@ -37,9 +37,10 @@ const KIND_PATTERN: Record<ExportKind, RegExp> = {
|
|
|
37
37
|
* and `rule.exclude` scope the files using full `**` globs.
|
|
38
38
|
*/
|
|
39
39
|
export class TsdocExportEvaluator implements RuleEvaluator {
|
|
40
|
-
constructor
|
|
41
|
-
|
|
42
|
-
}
|
|
40
|
+
// Explicit constructor: V8 function coverage counts only declared functions, so
|
|
41
|
+
// this method-light class needs it to clear the coverage-gate function threshold.
|
|
42
|
+
constructor() {}
|
|
43
|
+
|
|
43
44
|
/** Evaluate exports under the configured kinds for a preceding JSDoc block. */
|
|
44
45
|
async evaluate(rule: ConstraintRule, context: RuleContext): Promise<RuleEvaluationResult> {
|
|
45
46
|
const kinds = (rule.evaluator.config?.kinds as string[] | undefined) ?? [...VALID_KINDS];
|
|
@@ -51,13 +52,13 @@ export class TsdocExportEvaluator implements RuleEvaluator {
|
|
|
51
52
|
const requested = new Set(kinds as ExportKind[]);
|
|
52
53
|
const include = rule.include ?? ['**/*.ts', '**/*.tsx'];
|
|
53
54
|
const exclude = rule.exclude ?? [];
|
|
54
|
-
|
|
55
|
+
// Single, strict glob scoping — collapses the previous double-scoping (loose
|
|
56
|
+
// discoverFiles prefilter + per-file matchesGlob) into one pass.
|
|
57
|
+
const files = await scanFiles({ workdir: context.workdir, include, exclude, matchMode: 'glob' });
|
|
55
58
|
|
|
56
59
|
const findings = [];
|
|
57
|
-
for (const file of files) {
|
|
58
|
-
|
|
59
|
-
if (exclude.some((pattern) => matchesGlob(file, pattern))) continue;
|
|
60
|
-
const lines = (await readWorkdirFile(context.workdir, file)).split('\n');
|
|
60
|
+
for (const { file, content } of files) {
|
|
61
|
+
const lines = content.split('\n');
|
|
61
62
|
for (const site of findExports(lines, requested)) {
|
|
62
63
|
if (!precededByJsdoc(lines, site.line)) {
|
|
63
64
|
findings.push(
|
package/src/formatters/json.ts
CHANGED
|
@@ -2,6 +2,8 @@ import type { ResultFormatter, RuleEngineResult } from '../types';
|
|
|
2
2
|
|
|
3
3
|
/** JSON formatter for rule-engine results. */
|
|
4
4
|
export class JsonFormatter implements ResultFormatter {
|
|
5
|
+
// Explicit constructor: V8 function coverage counts only declared functions, so
|
|
6
|
+
// a method-light class needs it to clear the coverage-gate function threshold.
|
|
5
7
|
constructor() {}
|
|
6
8
|
|
|
7
9
|
/** Format the full result as pretty JSON. */
|
package/src/formatters/text.ts
CHANGED
|
@@ -2,6 +2,8 @@ import type { ResultFormatter, RuleEngineResult } from '../types';
|
|
|
2
2
|
|
|
3
3
|
/** Text formatter for human CLI output. */
|
|
4
4
|
export class TextFormatter implements ResultFormatter {
|
|
5
|
+
// Explicit constructor: V8 function coverage counts only declared functions, so
|
|
6
|
+
// a method-light class needs it to clear the coverage-gate function threshold.
|
|
5
7
|
constructor() {}
|
|
6
8
|
|
|
7
9
|
/** Format findings as concise path-prefixed lines. */
|
|
@@ -36,6 +36,8 @@ export class TypeScriptTestPathResolver implements TestPathResolver {
|
|
|
36
36
|
/** Registry key. */
|
|
37
37
|
readonly name = 'typescript';
|
|
38
38
|
|
|
39
|
+
// Explicit constructor: V8 function coverage counts only declared functions, so
|
|
40
|
+
// a single-method class needs it to clear the coverage-gate function threshold.
|
|
39
41
|
constructor() {}
|
|
40
42
|
|
|
41
43
|
/** Map a TS/JS source path to its `tests/…test.ts` counterpart. */
|
|
@@ -60,6 +62,7 @@ export class PythonTestPathResolver implements TestPathResolver {
|
|
|
60
62
|
/** Registry key. */
|
|
61
63
|
readonly name = 'python';
|
|
62
64
|
|
|
65
|
+
// Explicit constructor: see TypeScriptTestPathResolver — V8 function-coverage gate.
|
|
63
66
|
constructor() {}
|
|
64
67
|
|
|
65
68
|
/** Map a Python source path to its `tests/…/test_*.py` counterpart. */
|
|
@@ -88,6 +91,7 @@ export class GoTestPathResolver implements TestPathResolver {
|
|
|
88
91
|
/** Registry key. */
|
|
89
92
|
readonly name = 'go';
|
|
90
93
|
|
|
94
|
+
// Explicit constructor: see TypeScriptTestPathResolver — V8 function-coverage gate.
|
|
91
95
|
constructor() {}
|
|
92
96
|
|
|
93
97
|
/** Map a Go source path to its sibling `_test.go` file. */
|
|
@@ -107,6 +111,7 @@ export class RustTestPathResolver implements TestPathResolver {
|
|
|
107
111
|
/** Registry key. */
|
|
108
112
|
readonly name = 'rust';
|
|
109
113
|
|
|
114
|
+
// Explicit constructor: see TypeScriptTestPathResolver — V8 function-coverage gate.
|
|
110
115
|
constructor() {}
|
|
111
116
|
|
|
112
117
|
/** Map a Rust source path to its `tests/` integration-test counterpart. */
|
package/src/types.ts
CHANGED
|
@@ -56,6 +56,8 @@ export interface ConstraintRuleFile {
|
|
|
56
56
|
severity?: RuleSeverity;
|
|
57
57
|
/** Rule definitions. */
|
|
58
58
|
rules: ConstraintRule[];
|
|
59
|
+
/** Custom capability modules contributed by this rule file (opt-in to load). */
|
|
60
|
+
extensions?: PresetExtensions;
|
|
59
61
|
}
|
|
60
62
|
|
|
61
63
|
/** Relative module paths a preset contributes per capability kind. */
|
|
@@ -198,7 +200,10 @@ export const ConstraintRuleSchema = z.object({
|
|
|
198
200
|
id: z.string().min(1),
|
|
199
201
|
description: z.string().default(''),
|
|
200
202
|
enabled: z.boolean().default(true),
|
|
201
|
-
|
|
203
|
+
// Severity is intentionally NOT defaulted here: an omitted rule severity must
|
|
204
|
+
// stay absent at parse time so the loader can apply the file-level default
|
|
205
|
+
// (`rule.severity ?? file.severity ?? 'error'`). Normalization always fills it.
|
|
206
|
+
severity: z.enum(['error', 'warning', 'info']).optional(),
|
|
202
207
|
evaluator: z.object({
|
|
203
208
|
type: z.string().min(1),
|
|
204
209
|
config: z.record(z.string(), z.unknown()).optional(),
|
|
@@ -208,6 +213,37 @@ export const ConstraintRuleSchema = z.object({
|
|
|
208
213
|
fix: RuleFixConfigSchema.optional(),
|
|
209
214
|
});
|
|
210
215
|
|
|
216
|
+
/**
|
|
217
|
+
* A relative module path a preset or rule file may load as an extension.
|
|
218
|
+
*
|
|
219
|
+
* Rejects absolute paths and `..` traversal: extension declarations are data, and a
|
|
220
|
+
* path that escapes the declaring file's directory is a trust-boundary violation even
|
|
221
|
+
* when extension loading is explicitly allowed.
|
|
222
|
+
*/
|
|
223
|
+
const relativeExtensionPath = z
|
|
224
|
+
.string()
|
|
225
|
+
.min(1)
|
|
226
|
+
.refine((value) => !/^([/\\]|[A-Za-z]:[/\\])/.test(value), {
|
|
227
|
+
message: 'extension path must be relative (no absolute paths)',
|
|
228
|
+
})
|
|
229
|
+
.refine((value) => !value.split(/[/\\]/).includes('..'), {
|
|
230
|
+
message: 'extension path must not contain ".." traversal',
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Shared zod schema for an `extensions` block, used by both preset and rule-file
|
|
235
|
+
* schemas so they validate identically. `.strict()` makes a typo'd or misplaced key
|
|
236
|
+
* a hard error rather than a silently-ignored field.
|
|
237
|
+
*/
|
|
238
|
+
export const ExtensionsSchema = z
|
|
239
|
+
.object({
|
|
240
|
+
resolvers: z.array(relativeExtensionPath).optional(),
|
|
241
|
+
evaluators: z.array(relativeExtensionPath).optional(),
|
|
242
|
+
fixers: z.array(relativeExtensionPath).optional(),
|
|
243
|
+
formatters: z.array(relativeExtensionPath).optional(),
|
|
244
|
+
})
|
|
245
|
+
.strict();
|
|
246
|
+
|
|
211
247
|
/** Zod schema for a constraint rule file. */
|
|
212
248
|
export const ConstraintRuleFileSchema = z.object({
|
|
213
249
|
$schema: z.string().optional(),
|
|
@@ -215,6 +251,7 @@ export const ConstraintRuleFileSchema = z.object({
|
|
|
215
251
|
exclude: z.array(z.string()).optional(),
|
|
216
252
|
severity: z.enum(['error', 'warning', 'info']).optional(),
|
|
217
253
|
rules: z.array(ConstraintRuleSchema),
|
|
254
|
+
extensions: ExtensionsSchema.optional(),
|
|
218
255
|
});
|
|
219
256
|
|
|
220
257
|
/** Zod schema for a preset definition. */
|
|
@@ -226,12 +263,5 @@ export const PresetDefinitionSchema = z.object({
|
|
|
226
263
|
overrides: z
|
|
227
264
|
.record(z.string(), z.object({ fix: z.object({ mode: z.enum(['none', 'suggest', 'auto']) }).optional() }))
|
|
228
265
|
.optional(),
|
|
229
|
-
extensions:
|
|
230
|
-
.object({
|
|
231
|
-
resolvers: z.array(z.string()).optional(),
|
|
232
|
-
evaluators: z.array(z.string()).optional(),
|
|
233
|
-
fixers: z.array(z.string()).optional(),
|
|
234
|
-
formatters: z.array(z.string()).optional(),
|
|
235
|
-
})
|
|
236
|
-
.optional(),
|
|
266
|
+
extensions: ExtensionsSchema.optional(),
|
|
237
267
|
});
|