@gobing-ai/ts-rule-engine 0.2.6 → 0.2.8

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.
Files changed (80) hide show
  1. package/README.md +563 -1
  2. package/dist/config/extensions.d.ts +48 -0
  3. package/dist/config/extensions.d.ts.map +1 -0
  4. package/dist/config/extensions.js +67 -0
  5. package/dist/config/loader.d.ts +20 -1
  6. package/dist/config/loader.d.ts.map +1 -1
  7. package/dist/config/loader.js +52 -26
  8. package/dist/engine.d.ts +26 -1
  9. package/dist/engine.d.ts.map +1 -1
  10. package/dist/engine.js +79 -0
  11. package/dist/evaluators/exit-code-evaluator.d.ts +1 -1
  12. package/dist/evaluators/exit-code-evaluator.d.ts.map +1 -1
  13. package/dist/evaluators/exit-code-evaluator.js +22 -9
  14. package/dist/evaluators/forbidden-import-evaluator.d.ts +16 -3
  15. package/dist/evaluators/forbidden-import-evaluator.d.ts.map +1 -1
  16. package/dist/evaluators/forbidden-import-evaluator.js +71 -6
  17. package/dist/evaluators/import-boundary-evaluator.d.ts +19 -0
  18. package/dist/evaluators/import-boundary-evaluator.d.ts.map +1 -0
  19. package/dist/evaluators/import-boundary-evaluator.js +85 -0
  20. package/dist/evaluators/path-evaluator.d.ts +15 -2
  21. package/dist/evaluators/path-evaluator.d.ts.map +1 -1
  22. package/dist/evaluators/path-evaluator.js +49 -3
  23. package/dist/evaluators/regex-evaluator.d.ts.map +1 -1
  24. package/dist/evaluators/regex-evaluator.js +43 -8
  25. package/dist/evaluators/schema-artifact-evaluator.d.ts +21 -0
  26. package/dist/evaluators/schema-artifact-evaluator.d.ts.map +1 -0
  27. package/dist/evaluators/schema-artifact-evaluator.js +102 -0
  28. package/dist/evaluators/secrets-scanner-evaluator.d.ts +13 -2
  29. package/dist/evaluators/secrets-scanner-evaluator.d.ts.map +1 -1
  30. package/dist/evaluators/secrets-scanner-evaluator.js +72 -9
  31. package/dist/evaluators/sg-evaluator.d.ts +19 -0
  32. package/dist/evaluators/sg-evaluator.d.ts.map +1 -0
  33. package/dist/evaluators/sg-evaluator.js +112 -0
  34. package/dist/evaluators/test-location-evaluator.d.ts +14 -1
  35. package/dist/evaluators/test-location-evaluator.d.ts.map +1 -1
  36. package/dist/evaluators/test-location-evaluator.js +42 -22
  37. package/dist/evaluators/tsdoc-export-evaluator.js +19 -5
  38. package/dist/fixers/fixers.d.ts +86 -0
  39. package/dist/fixers/fixers.d.ts.map +1 -0
  40. package/dist/fixers/fixers.js +230 -0
  41. package/dist/fixers/test-stub-fixer.d.ts +49 -0
  42. package/dist/fixers/test-stub-fixer.d.ts.map +1 -0
  43. package/dist/fixers/test-stub-fixer.js +91 -0
  44. package/dist/host/builtins.d.ts.map +1 -1
  45. package/dist/host/builtins.js +12 -1
  46. package/dist/host/rule-engine-host.d.ts +3 -0
  47. package/dist/host/rule-engine-host.d.ts.map +1 -1
  48. package/dist/host/rule-engine-host.js +3 -0
  49. package/dist/index.d.ts +4 -0
  50. package/dist/index.d.ts.map +1 -1
  51. package/dist/index.js +4 -0
  52. package/dist/resolvers/test-path-resolver.d.ts +72 -0
  53. package/dist/resolvers/test-path-resolver.d.ts.map +1 -0
  54. package/dist/resolvers/test-path-resolver.js +112 -0
  55. package/dist/types.d.ts +36 -0
  56. package/dist/types.d.ts.map +1 -1
  57. package/dist/types.js +10 -0
  58. package/package.json +4 -3
  59. package/schemas/preset.schema.json +40 -0
  60. package/schemas/rule-file.schema.json +49 -0
  61. package/src/config/extensions.ts +115 -0
  62. package/src/config/loader.ts +81 -25
  63. package/src/engine.ts +99 -2
  64. package/src/evaluators/exit-code-evaluator.ts +27 -9
  65. package/src/evaluators/forbidden-import-evaluator.ts +101 -7
  66. package/src/evaluators/import-boundary-evaluator.ts +135 -0
  67. package/src/evaluators/path-evaluator.ts +66 -3
  68. package/src/evaluators/regex-evaluator.ts +53 -12
  69. package/src/evaluators/schema-artifact-evaluator.ts +134 -0
  70. package/src/evaluators/secrets-scanner-evaluator.ts +89 -11
  71. package/src/evaluators/sg-evaluator.ts +133 -0
  72. package/src/evaluators/test-location-evaluator.ts +47 -35
  73. package/src/evaluators/tsdoc-export-evaluator.ts +19 -5
  74. package/src/fixers/fixers.ts +294 -0
  75. package/src/fixers/test-stub-fixer.ts +118 -0
  76. package/src/host/builtins.ts +17 -1
  77. package/src/host/rule-engine-host.ts +4 -0
  78. package/src/index.ts +4 -0
  79. package/src/resolvers/test-path-resolver.ts +133 -0
  80. package/src/types.ts +40 -0
@@ -7,8 +7,18 @@ import {
7
7
  type RuleEvaluationResult,
8
8
  type RuleEvaluator,
9
9
  } from '../types';
10
+ import { discoverFiles, matchesGlob } from './file-utils';
10
11
 
11
- /** Evaluates file or directory existence constraints. */
12
+ /**
13
+ * Evaluates file/directory presence constraints.
14
+ *
15
+ * Two config shapes are supported:
16
+ * - Glob form: `{ must: 'present' | 'absent' }` scoped by the rule's `include` /
17
+ * `exclude` globs. `present` flags each include glob that matches zero files;
18
+ * `absent` flags each in-scope file that exists.
19
+ * - Explicit form: `{ paths: string[], mode?: 'require' | 'forbid' }` — checks the
20
+ * exact paths relative to the workdir (`require` = must exist, `forbid` = must not).
21
+ */
12
22
  export class PathEvaluator implements RuleEvaluator {
13
23
  private readonly fs: NodeFileSystem;
14
24
 
@@ -16,9 +26,62 @@ export class PathEvaluator implements RuleEvaluator {
16
26
  this.fs = new NodeFileSystem();
17
27
  }
18
28
 
19
- /** Evaluate required or forbidden paths. */
29
+ /** Evaluate required or forbidden paths in either config form. */
20
30
  async evaluate(rule: ConstraintRule, context: RuleContext): Promise<RuleEvaluationResult> {
21
31
  const config = rule.evaluator.config ?? {};
32
+ const must = config.must;
33
+ if (must === 'present' || must === 'absent') {
34
+ return this.evaluateGlob(rule, context, must);
35
+ }
36
+ return this.evaluateExplicit(rule, context, config);
37
+ }
38
+
39
+ /** Glob form driven by `must` + the rule's include/exclude globs. */
40
+ private async evaluateGlob(
41
+ rule: ConstraintRule,
42
+ context: RuleContext,
43
+ must: 'present' | 'absent',
44
+ ): Promise<RuleEvaluationResult> {
45
+ const include = rule.include ?? ['**'];
46
+ const exclude = rule.exclude ?? [];
47
+ const files = await discoverFiles({ workdir: context.workdir });
48
+ const inScope = (file: string) =>
49
+ include.some((glob) => matchesGlob(file, glob)) && !exclude.some((glob) => matchesGlob(file, glob));
50
+ const findings = [];
51
+
52
+ if (must === 'present') {
53
+ for (const pattern of include) {
54
+ const present = files.some(
55
+ (file) => matchesGlob(file, pattern) && !exclude.some((g) => matchesGlob(file, g)),
56
+ );
57
+ if (!present) {
58
+ findings.push(
59
+ createFinding(rule, `expected files matching "${pattern}", but none found`, pattern, {
60
+ code: 'path:missing',
61
+ }),
62
+ );
63
+ }
64
+ }
65
+ } else {
66
+ for (const file of files) {
67
+ if (inScope(file)) {
68
+ findings.push(
69
+ createFinding(rule, 'file should be absent (forbidden by rule)', file, {
70
+ code: 'path:forbidden',
71
+ }),
72
+ );
73
+ }
74
+ }
75
+ }
76
+ return { findings, fixes: [] };
77
+ }
78
+
79
+ /** Explicit form: exact `paths` checked with `mode` require/forbid. */
80
+ private async evaluateExplicit(
81
+ rule: ConstraintRule,
82
+ context: RuleContext,
83
+ config: Record<string, unknown>,
84
+ ): Promise<RuleEvaluationResult> {
22
85
  const paths = arrayConfig(config, 'paths');
23
86
  const mode = stringConfig(config, 'mode', 'require');
24
87
  const findings = [];
@@ -39,7 +102,7 @@ function arrayConfig(config: Record<string, unknown>, key: string): string[] {
39
102
  const value = config[key];
40
103
  if (Array.isArray(value) && value.every((item) => typeof item === 'string')) return value;
41
104
  if (typeof value === 'string') return [value];
42
- throw new Error(`path evaluator requires string[] config "${key}"`);
105
+ throw new Error(`path evaluator requires config "${key}" (string[]) or "must" (present|absent)`);
43
106
  }
44
107
 
45
108
  function stringConfig(config: Record<string, unknown>, key: string, fallback: string): string {
@@ -14,26 +14,38 @@ export class RegexEvaluator implements RuleEvaluator {
14
14
  /** Evaluate regex-based presence or absence constraints. */
15
15
  async evaluate(rule: ConstraintRule, context: RuleContext): Promise<RuleEvaluationResult> {
16
16
  const config = rule.evaluator.config ?? {};
17
- const pattern = stringConfig(config, 'pattern');
17
+ const { pattern, flags } = normalizePattern(
18
+ stringConfig(config, 'pattern'),
19
+ stringConfig(config, 'flags', ''),
20
+ config.multiline === true,
21
+ );
18
22
  const mode = stringConfig(config, 'mode', 'forbid');
19
- const flags = stringConfig(config, 'flags', 'm');
20
23
  const regex = new RegExp(pattern, flags);
21
24
  const files = await discoverFiles({ workdir: context.workdir, include: rule.include, exclude: rule.exclude });
22
25
  const findings = [];
23
26
 
24
27
  for (const file of files) {
25
28
  const content = await readWorkdirFile(context.workdir, file);
26
- regex.lastIndex = 0;
27
- const match = regex.exec(content);
28
- if (mode === 'require' && match === null) {
29
- findings.push(
30
- createFinding(rule, `Required pattern not found: ${pattern}`, file, { code: 'regex:missing' }),
31
- );
29
+ if (mode === 'require') {
30
+ regex.lastIndex = 0;
31
+ if (!regex.test(content)) {
32
+ findings.push(
33
+ createFinding(rule, `required pattern not found: ${pattern}`, file, { code: 'regex:missing' }),
34
+ );
35
+ }
36
+ continue;
32
37
  }
33
- if (mode !== 'require' && match !== null) {
34
- findings.push(
35
- createFinding(rule, `Forbidden pattern found: ${pattern}`, file, { code: 'regex:found' }),
36
- );
38
+ // forbid: report each matching line so findings carry precise locations.
39
+ for (const [index, line] of content.split('\n').entries()) {
40
+ regex.lastIndex = 0;
41
+ if (regex.test(line)) {
42
+ findings.push(
43
+ createFinding(rule, `forbidden pattern found: ${pattern}`, file, {
44
+ line: index + 1,
45
+ code: 'regex:found',
46
+ }),
47
+ );
48
+ }
37
49
  }
38
50
  }
39
51
 
@@ -41,6 +53,35 @@ export class RegexEvaluator implements RuleEvaluator {
41
53
  }
42
54
  }
43
55
 
56
+ /**
57
+ * Normalize a pattern + flags for JS `RegExp`.
58
+ *
59
+ * Accepts a leading `(?i)`/`(?im)` inline flag group (ripgrep/PCRE style) and
60
+ * folds it into the JS flags. `multiline` adds the `s` (dotAll) flag so `.`
61
+ * spans newlines, matching the old `--multiline` behavior. `m` is always set so
62
+ * `^`/`$` work per line.
63
+ */
64
+ function normalizePattern(
65
+ rawPattern: string,
66
+ rawFlags: string,
67
+ multiline: boolean,
68
+ ): { pattern: string; flags: string } {
69
+ const flagSet = new Set<string>(['m']);
70
+ for (const flag of rawFlags) {
71
+ if ('gimsuy'.includes(flag)) flagSet.add(flag);
72
+ }
73
+ let pattern = rawPattern;
74
+ const inline = /^\(\?([a-z]+)\)/.exec(pattern);
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
+ }
81
+ if (multiline) flagSet.add('s');
82
+ return { pattern, flags: [...flagSet].join('') };
83
+ }
84
+
44
85
  function stringConfig(config: Record<string, unknown>, key: string, fallback?: string): string {
45
86
  const value = config[key];
46
87
  if (typeof value === 'string') return value;
@@ -0,0 +1,134 @@
1
+ import { 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
+
11
+ /**
12
+ * Evaluates JSON schema artifact files for structural integrity.
13
+ *
14
+ * Pure JS — no subprocess. Validates JSON validity, title, required properties,
15
+ * `$defs` / `definitions` entries, and the presence of a top-level `required` array.
16
+ *
17
+ * ## Options (in `evaluator.config`)
18
+ * - `file` — path to the JSON file relative to the workdir (required).
19
+ * - `requiredTitle` — expected `title` value (optional).
20
+ * - `requiredProperties` — top-level `properties` keys that must exist (optional).
21
+ * - `requiredDefs` — `$defs` or `definitions` keys that must exist (optional).
22
+ * - `requireRequiredArray` — enforce that `required` is a non-empty array (default: `false`).
23
+ */
24
+ export class SchemaArtifactEvaluator implements RuleEvaluator {
25
+ private readonly fs: NodeFileSystem;
26
+
27
+ constructor() {
28
+ this.fs = new NodeFileSystem();
29
+ }
30
+
31
+ /** Evaluate the configured JSON schema artifact. */
32
+ async evaluate(rule: ConstraintRule, context: RuleContext): Promise<RuleEvaluationResult> {
33
+ const config = rule.evaluator.config ?? {};
34
+ const file = config.file;
35
+ if (typeof file !== 'string' || file.length === 0) {
36
+ throw new Error('schema-artifact evaluator requires string config "file"');
37
+ }
38
+
39
+ const requiredTitle = typeof config.requiredTitle === 'string' ? config.requiredTitle : undefined;
40
+ const requiredProperties = stringArray(config.requiredProperties);
41
+ const requiredDefs = stringArray(config.requiredDefs);
42
+ const requireRequiredArray = config.requireRequiredArray === true;
43
+
44
+ // Check existence.
45
+ const absolutePath = resolve(context.workdir, file);
46
+ const exists = await this.fs.exists(absolutePath);
47
+ if (!exists) {
48
+ return {
49
+ findings: [
50
+ createFinding(rule, 'schema artifact not found', file, {
51
+ code: 'schema-artifact:missing',
52
+ }),
53
+ ],
54
+ fixes: [],
55
+ };
56
+ }
57
+
58
+ // Read and parse.
59
+ let schema: Record<string, unknown>;
60
+ try {
61
+ const raw = await this.fs.readFile(absolutePath);
62
+ schema = JSON.parse(raw) as Record<string, unknown>;
63
+ } catch (err) {
64
+ return {
65
+ findings: [
66
+ createFinding(rule, `invalid JSON: ${(err as Error).message}`, file, {
67
+ code: 'schema-artifact:invalid',
68
+ }),
69
+ ],
70
+ fixes: [],
71
+ };
72
+ }
73
+
74
+ const findings = [];
75
+
76
+ // Validate title.
77
+ if (requiredTitle !== undefined && schema.title !== requiredTitle) {
78
+ findings.push(
79
+ createFinding(
80
+ rule,
81
+ `${file} title expected '${requiredTitle}', got '${(schema.title as string | undefined) ?? '(missing)'}'`,
82
+ file,
83
+ { code: 'schema-artifact:violation' },
84
+ ),
85
+ );
86
+ }
87
+
88
+ // Validate required array.
89
+ if (requireRequiredArray && !Array.isArray(schema.required)) {
90
+ findings.push(
91
+ createFinding(rule, `${file} missing 'required' array at top level`, file, {
92
+ code: 'schema-artifact:violation',
93
+ }),
94
+ );
95
+ }
96
+
97
+ // Validate properties.
98
+ if (requiredProperties !== undefined) {
99
+ const props = schema.properties as Record<string, unknown> | undefined;
100
+ for (const prop of requiredProperties) {
101
+ if (!props || !(prop in props)) {
102
+ findings.push(
103
+ createFinding(rule, `${file} missing properties.${prop}`, file, {
104
+ code: 'schema-artifact:violation',
105
+ }),
106
+ );
107
+ }
108
+ }
109
+ }
110
+
111
+ // Validate $defs / definitions.
112
+ if (requiredDefs !== undefined) {
113
+ const defs =
114
+ (schema.$defs as Record<string, unknown> | undefined) ??
115
+ (schema.definitions as Record<string, unknown> | undefined);
116
+ for (const def of requiredDefs) {
117
+ if (!defs || !(def in defs)) {
118
+ findings.push(
119
+ createFinding(rule, `${file} missing $defs.${def}`, file, {
120
+ code: 'schema-artifact:violation',
121
+ }),
122
+ );
123
+ }
124
+ }
125
+ }
126
+
127
+ return { findings, fixes: [] };
128
+ }
129
+ }
130
+
131
+ /** Return a string array if value is a string array, otherwise undefined. */
132
+ function stringArray(value: unknown): string[] | undefined {
133
+ return Array.isArray(value) && value.every((item) => typeof item === 'string') ? (value as string[]) : undefined;
134
+ }
@@ -7,28 +7,106 @@ import {
7
7
  } from '../types';
8
8
  import { discoverFiles, readWorkdirFile } from './file-utils';
9
9
 
10
- /** Scans text files for high-confidence secret-like tokens. */
10
+ /** Built-in secret category names. */
11
+ export type SecretsCategory = 'api-key' | 'private-key' | 'password' | 'token' | 'connection-string';
12
+
13
+ // Patterns are assembled from a keyword group plus a value-shape suffix rather
14
+ // than written as literal `keyword: "value"` lines, so this source file does not
15
+ // trip the secrets-scanner when it scans itself.
16
+ const ASSIGN = '\\s*[=:]\\s*';
17
+ const QUOTED = (body: string) => `["'\`]${body}["'\`]`;
18
+ const keyworded = (keywords: string, value: string) => `(?i)(${keywords})${ASSIGN}${QUOTED(value)}`;
19
+
20
+ /** Built-in category → regex source. A leading `(?i)` is folded into the JS `i` flag. */
21
+ const BUILTIN_PATTERNS: Record<SecretsCategory, string> = {
22
+ 'api-key': `${keyworded('api[_-]?key|apikey|api_secret|access[_-]?token|auth[_-]?token|secret[_-]?key', '[A-Za-z0-9_/+=.-]{8,}')}|sk-[A-Za-z0-9_-]{20,}|AKIA[0-9A-Z]{16}`,
23
+ 'private-key': 'PRIVATE[_-]?KEY|-----BEGIN\\s+(?:RSA |OPENSSH |EC )?PRIVATE\\s+KEY',
24
+ password: keyworded('passw[o]rd|passwd|pwd|pass', '[^"\'`]{4,}'),
25
+ token: keyworded('bearer[_-]?token|refresh[_-]?token|session[_-]?token', '[A-Za-z0-9_\\-./+=]{8,}'),
26
+ 'connection-string': '(?i)(mongodb|postgres|mysql|redis)://[^\\s"\'`]{8,}',
27
+ };
28
+
29
+ const ALL_CATEGORIES = Object.keys(BUILTIN_PATTERNS) as SecretsCategory[];
30
+
31
+ /** A compiled scanner pattern with its reporting label. */
32
+ interface ScanPattern {
33
+ regex: RegExp;
34
+ label: string;
35
+ }
36
+
37
+ /**
38
+ * Scans text files for secret-like tokens.
39
+ *
40
+ * Config (`evaluator.config`, all optional):
41
+ * - `categories`: subset of built-in categories to scan (default: all five —
42
+ * `api-key`, `private-key`, `password`, `token`, `connection-string`).
43
+ * - `customPatterns`: extra `{ name, pattern }` entries to scan for.
44
+ * - `scope`: `{ include, exclude }` globs; falls back to the rule's `include` /
45
+ * `exclude` when omitted.
46
+ */
11
47
  export class SecretsScannerEvaluator implements RuleEvaluator {
12
48
  constructor() {}
13
49
 
14
- /** Evaluate source files against bundled secret patterns. */
50
+ /** Evaluate files against the selected secret categories and custom patterns. */
15
51
  async evaluate(rule: ConstraintRule, context: RuleContext): Promise<RuleEvaluationResult> {
16
- const files = await discoverFiles({ workdir: context.workdir, include: rule.include, exclude: rule.exclude });
52
+ const config = rule.evaluator.config ?? {};
53
+ const patterns = buildPatterns(config);
54
+ const scope = config.scope as { include?: unknown; exclude?: unknown } | undefined;
55
+ const include = stringArray(scope?.include) ?? rule.include;
56
+ const exclude = stringArray(scope?.exclude) ?? rule.exclude;
57
+ const files = await discoverFiles({ workdir: context.workdir, include, exclude });
58
+
17
59
  const findings = [];
18
- const secretPattern = /(sk-[A-Za-z0-9_-]{20,}|AKIA[0-9A-Z]{16}|BEGIN (?:RSA |OPENSSH )?PRIVATE KEY)/;
19
60
  for (const file of files) {
20
61
  const lines = (await readWorkdirFile(context.workdir, file)).split('\n');
21
62
  for (const [index, line] of lines.entries()) {
22
- if (secretPattern.test(line)) {
23
- findings.push(
24
- createFinding(rule, 'Potential secret token found', file, {
25
- line: index + 1,
26
- code: 'secret:token',
27
- }),
28
- );
63
+ for (const pattern of patterns) {
64
+ pattern.regex.lastIndex = 0;
65
+ if (pattern.regex.test(line)) {
66
+ findings.push(
67
+ createFinding(rule, `potential secret (${pattern.label})`, file, {
68
+ line: index + 1,
69
+ code: `secret:${pattern.label}`,
70
+ }),
71
+ );
72
+ break;
73
+ }
29
74
  }
30
75
  }
31
76
  }
32
77
  return { findings, fixes: [] };
33
78
  }
34
79
  }
80
+
81
+ /** Compile the selected built-in categories plus any custom patterns. */
82
+ function buildPatterns(config: Record<string, unknown>): ScanPattern[] {
83
+ const categories = stringArray(config.categories) ?? ALL_CATEGORIES;
84
+ const patterns: ScanPattern[] = [];
85
+ for (const category of categories) {
86
+ const source = BUILTIN_PATTERNS[category as SecretsCategory];
87
+ if (source !== undefined) patterns.push({ regex: compile(source), label: category });
88
+ }
89
+ const custom = config.customPatterns;
90
+ if (Array.isArray(custom)) {
91
+ for (const entry of custom as { name?: unknown; pattern?: unknown }[]) {
92
+ if (typeof entry.name === 'string' && typeof entry.pattern === 'string') {
93
+ patterns.push({ regex: compile(entry.pattern), label: entry.name });
94
+ }
95
+ }
96
+ }
97
+ return patterns;
98
+ }
99
+
100
+ /** Compile a pattern, folding a leading `(?i)` group into the JS `i` flag. */
101
+ function compile(source: string): RegExp {
102
+ const inline = /^\(\?([a-z]+)\)/.exec(source);
103
+ if (inline) {
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;
112
+ }
@@ -0,0 +1,133 @@
1
+ import { NodeProcessExecutor, type ProcessExecutor } from '@gobing-ai/ts-runtime';
2
+ import {
3
+ type ConstraintRule,
4
+ createFinding,
5
+ type RuleContext,
6
+ type RuleEvaluationResult,
7
+ type RuleEvaluator,
8
+ } from '../types';
9
+ import { matchesGlob } from './file-utils';
10
+
11
+ /**
12
+ * Evaluates source code against an ast-grep pattern using the `sg` CLI.
13
+ *
14
+ * ## Options (in `evaluator.config`)
15
+ * - `pattern` — ast-grep pattern to search for (required).
16
+ * - `language` — language for ast-grep parsing (default: `typescript`).
17
+ *
18
+ * Include globs from the rule are forwarded to sg via `--glob` arguments.
19
+ * Exclude globs from the rule are applied in-process after parsing sg output.
20
+ */
21
+ export class SgEvaluator implements RuleEvaluator {
22
+ private readonly executor: ProcessExecutor;
23
+
24
+ constructor(executor: ProcessExecutor = new NodeProcessExecutor()) {
25
+ this.executor = executor;
26
+ }
27
+
28
+ /** Run sg and emit one finding per matched AST node. */
29
+ async evaluate(rule: ConstraintRule, context: RuleContext): Promise<RuleEvaluationResult> {
30
+ const config = rule.evaluator.config ?? {};
31
+ const pattern = config.pattern;
32
+ if (typeof pattern !== 'string' || pattern.length === 0) {
33
+ throw new Error('sg evaluator requires string config "pattern"');
34
+ }
35
+
36
+ const language = typeof config.language === 'string' ? config.language : 'typescript';
37
+ const include = rule.include ?? [];
38
+ const exclude = rule.exclude ?? [];
39
+
40
+ const args: string[] = ['run', '--pattern', pattern, '--lang', language, '--json'];
41
+ for (const glob of include) {
42
+ args.push(`--glob=${glob}`);
43
+ }
44
+
45
+ const result = await this.executor.run({
46
+ command: 'sg',
47
+ args,
48
+ cwd: context.workdir,
49
+ timeout: 60_000,
50
+ rejectOnError: false,
51
+ label: 'sg',
52
+ });
53
+
54
+ const stdout = result.stdout.trim();
55
+
56
+ if (stdout.length === 0) {
57
+ if (result.exitCode !== 0 && result.exitCode !== null && result.stderr.trim().length > 0) {
58
+ throw new Error(`sg failed: ${result.stderr.trim()}`);
59
+ }
60
+ return { findings: [], fixes: [] };
61
+ }
62
+
63
+ const matches = parseSgJson(stdout);
64
+ const findings = [];
65
+ for (const match of matches) {
66
+ if (exclude.some((glob) => matchesGlob(match.file, glob))) continue;
67
+ findings.push(
68
+ createFinding(rule, 'matched sg pattern', match.file, {
69
+ line: match.line,
70
+ code: 'sg:match',
71
+ }),
72
+ );
73
+ }
74
+
75
+ return { findings, fixes: [] };
76
+ }
77
+ }
78
+
79
+ /** Parsed sg match entry. */
80
+ interface SgMatch {
81
+ file: string;
82
+ line: number;
83
+ text: string;
84
+ }
85
+
86
+ /**
87
+ * Parse `sg --json` output.
88
+ *
89
+ * Handles both a JSON array (sg >= 0.40) and newline-delimited JSON objects
90
+ * (older sg or `--json=stream`). Returns workdir-relative file paths as-is
91
+ * since sg emits paths relative to cwd.
92
+ */
93
+ function parseSgJson(stdout: string): SgMatch[] {
94
+ // Try JSON array first (newer sg).
95
+ try {
96
+ const parsed = JSON.parse(stdout);
97
+ if (Array.isArray(parsed)) {
98
+ return parsed.flatMap((event) => {
99
+ const file = typeof event.file === 'string' ? event.file : '';
100
+ if (!file) return [];
101
+ return [
102
+ {
103
+ file,
104
+ line: (event.range?.start?.line ?? 0) + 1,
105
+ text: typeof event.text === 'string' ? event.text.trim() : '',
106
+ },
107
+ ];
108
+ });
109
+ }
110
+ } catch {
111
+ // Fall through to line-delimited parsing.
112
+ }
113
+
114
+ // Fallback: newline-delimited JSON.
115
+ const results: SgMatch[] = [];
116
+ for (const line of stdout.split('\n')) {
117
+ const trimmed = line.trim();
118
+ if (!trimmed) continue;
119
+ try {
120
+ const event = JSON.parse(trimmed);
121
+ const file = typeof event.file === 'string' ? event.file : '';
122
+ if (!file) continue;
123
+ results.push({
124
+ file,
125
+ line: (event.range?.start?.line ?? 0) + 1,
126
+ text: typeof event.text === 'string' ? event.text.trim() : '',
127
+ });
128
+ } catch {
129
+ // Skip unparseable lines.
130
+ }
131
+ }
132
+ return results;
133
+ }