@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.
Files changed (87) hide show
  1. package/dist/config/extensions.d.ts +46 -0
  2. package/dist/config/extensions.d.ts.map +1 -0
  3. package/dist/config/extensions.js +63 -0
  4. package/dist/config/loader.d.ts +15 -5
  5. package/dist/config/loader.d.ts.map +1 -1
  6. package/dist/config/loader.js +127 -33
  7. package/dist/engine.d.ts +26 -1
  8. package/dist/engine.d.ts.map +1 -1
  9. package/dist/engine.js +79 -0
  10. package/dist/evaluators/coverage-gate-evaluator.d.ts +21 -0
  11. package/dist/evaluators/coverage-gate-evaluator.d.ts.map +1 -0
  12. package/dist/evaluators/coverage-gate-evaluator.js +103 -0
  13. package/dist/evaluators/exit-code-evaluator.d.ts +1 -1
  14. package/dist/evaluators/exit-code-evaluator.d.ts.map +1 -1
  15. package/dist/evaluators/exit-code-evaluator.js +22 -9
  16. package/dist/evaluators/file-utils.d.ts +8 -0
  17. package/dist/evaluators/file-utils.d.ts.map +1 -1
  18. package/dist/evaluators/file-utils.js +40 -0
  19. package/dist/evaluators/forbidden-import-evaluator.d.ts +16 -3
  20. package/dist/evaluators/forbidden-import-evaluator.d.ts.map +1 -1
  21. package/dist/evaluators/forbidden-import-evaluator.js +71 -6
  22. package/dist/evaluators/import-boundary-evaluator.d.ts +19 -0
  23. package/dist/evaluators/import-boundary-evaluator.d.ts.map +1 -0
  24. package/dist/evaluators/import-boundary-evaluator.js +85 -0
  25. package/dist/evaluators/path-evaluator.d.ts +15 -2
  26. package/dist/evaluators/path-evaluator.d.ts.map +1 -1
  27. package/dist/evaluators/path-evaluator.js +49 -3
  28. package/dist/evaluators/regex-evaluator.d.ts.map +1 -1
  29. package/dist/evaluators/regex-evaluator.js +43 -8
  30. package/dist/evaluators/schema-artifact-evaluator.d.ts +21 -0
  31. package/dist/evaluators/schema-artifact-evaluator.d.ts.map +1 -0
  32. package/dist/evaluators/schema-artifact-evaluator.js +102 -0
  33. package/dist/evaluators/secrets-scanner-evaluator.d.ts +13 -2
  34. package/dist/evaluators/secrets-scanner-evaluator.d.ts.map +1 -1
  35. package/dist/evaluators/secrets-scanner-evaluator.js +72 -9
  36. package/dist/evaluators/sg-evaluator.d.ts +19 -0
  37. package/dist/evaluators/sg-evaluator.d.ts.map +1 -0
  38. package/dist/evaluators/sg-evaluator.js +112 -0
  39. package/dist/evaluators/test-location-evaluator.d.ts +32 -0
  40. package/dist/evaluators/test-location-evaluator.d.ts.map +1 -0
  41. package/dist/evaluators/test-location-evaluator.js +105 -0
  42. package/dist/evaluators/tsdoc-export-evaluator.d.ts +15 -0
  43. package/dist/evaluators/tsdoc-export-evaluator.d.ts.map +1 -0
  44. package/dist/evaluators/tsdoc-export-evaluator.js +91 -0
  45. package/dist/fixers/fixers.d.ts +86 -0
  46. package/dist/fixers/fixers.d.ts.map +1 -0
  47. package/dist/fixers/fixers.js +230 -0
  48. package/dist/fixers/test-stub-fixer.d.ts +49 -0
  49. package/dist/fixers/test-stub-fixer.d.ts.map +1 -0
  50. package/dist/fixers/test-stub-fixer.js +91 -0
  51. package/dist/host/builtins.d.ts.map +1 -1
  52. package/dist/host/builtins.js +17 -0
  53. package/dist/host/rule-engine-host.d.ts +3 -0
  54. package/dist/host/rule-engine-host.d.ts.map +1 -1
  55. package/dist/host/rule-engine-host.js +3 -0
  56. package/dist/index.d.ts +4 -0
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +4 -0
  59. package/dist/resolvers/test-path-resolver.d.ts +72 -0
  60. package/dist/resolvers/test-path-resolver.d.ts.map +1 -0
  61. package/dist/resolvers/test-path-resolver.js +112 -0
  62. package/dist/types.d.ts +30 -0
  63. package/dist/types.d.ts.map +1 -1
  64. package/dist/types.js +8 -0
  65. package/package.json +3 -3
  66. package/src/config/extensions.ts +108 -0
  67. package/src/config/loader.ts +140 -35
  68. package/src/engine.ts +99 -2
  69. package/src/evaluators/coverage-gate-evaluator.ts +137 -0
  70. package/src/evaluators/exit-code-evaluator.ts +27 -9
  71. package/src/evaluators/file-utils.ts +38 -0
  72. package/src/evaluators/forbidden-import-evaluator.ts +101 -7
  73. package/src/evaluators/import-boundary-evaluator.ts +135 -0
  74. package/src/evaluators/path-evaluator.ts +66 -3
  75. package/src/evaluators/regex-evaluator.ts +53 -12
  76. package/src/evaluators/schema-artifact-evaluator.ts +134 -0
  77. package/src/evaluators/secrets-scanner-evaluator.ts +89 -11
  78. package/src/evaluators/sg-evaluator.ts +133 -0
  79. package/src/evaluators/test-location-evaluator.ts +127 -0
  80. package/src/evaluators/tsdoc-export-evaluator.ts +111 -0
  81. package/src/fixers/fixers.ts +294 -0
  82. package/src/fixers/test-stub-fixer.ts +118 -0
  83. package/src/host/builtins.ts +22 -0
  84. package/src/host/rule-engine-host.ts +4 -0
  85. package/src/index.ts +4 -0
  86. package/src/resolvers/test-path-resolver.ts +133 -0
  87. package/src/types.ts +34 -0
@@ -0,0 +1,102 @@
1
+ import { resolve } from 'node:path';
2
+ import { NodeFileSystem } from '@gobing-ai/ts-runtime';
3
+ import { createFinding, } from '../types.js';
4
+ /**
5
+ * Evaluates JSON schema artifact files for structural integrity.
6
+ *
7
+ * Pure JS — no subprocess. Validates JSON validity, title, required properties,
8
+ * `$defs` / `definitions` entries, and the presence of a top-level `required` array.
9
+ *
10
+ * ## Options (in `evaluator.config`)
11
+ * - `file` — path to the JSON file relative to the workdir (required).
12
+ * - `requiredTitle` — expected `title` value (optional).
13
+ * - `requiredProperties` — top-level `properties` keys that must exist (optional).
14
+ * - `requiredDefs` — `$defs` or `definitions` keys that must exist (optional).
15
+ * - `requireRequiredArray` — enforce that `required` is a non-empty array (default: `false`).
16
+ */
17
+ export class SchemaArtifactEvaluator {
18
+ fs;
19
+ constructor() {
20
+ this.fs = new NodeFileSystem();
21
+ }
22
+ /** Evaluate the configured JSON schema artifact. */
23
+ async evaluate(rule, context) {
24
+ const config = rule.evaluator.config ?? {};
25
+ const file = config.file;
26
+ if (typeof file !== 'string' || file.length === 0) {
27
+ throw new Error('schema-artifact evaluator requires string config "file"');
28
+ }
29
+ const requiredTitle = typeof config.requiredTitle === 'string' ? config.requiredTitle : undefined;
30
+ const requiredProperties = stringArray(config.requiredProperties);
31
+ const requiredDefs = stringArray(config.requiredDefs);
32
+ const requireRequiredArray = config.requireRequiredArray === true;
33
+ // Check existence.
34
+ const absolutePath = resolve(context.workdir, file);
35
+ const exists = await this.fs.exists(absolutePath);
36
+ if (!exists) {
37
+ return {
38
+ findings: [
39
+ createFinding(rule, 'schema artifact not found', file, {
40
+ code: 'schema-artifact:missing',
41
+ }),
42
+ ],
43
+ fixes: [],
44
+ };
45
+ }
46
+ // Read and parse.
47
+ let schema;
48
+ try {
49
+ const raw = await this.fs.readFile(absolutePath);
50
+ schema = JSON.parse(raw);
51
+ }
52
+ catch (err) {
53
+ return {
54
+ findings: [
55
+ createFinding(rule, `invalid JSON: ${err.message}`, file, {
56
+ code: 'schema-artifact:invalid',
57
+ }),
58
+ ],
59
+ fixes: [],
60
+ };
61
+ }
62
+ const findings = [];
63
+ // Validate title.
64
+ if (requiredTitle !== undefined && schema.title !== requiredTitle) {
65
+ findings.push(createFinding(rule, `${file} title expected '${requiredTitle}', got '${schema.title ?? '(missing)'}'`, file, { code: 'schema-artifact:violation' }));
66
+ }
67
+ // Validate required array.
68
+ if (requireRequiredArray && !Array.isArray(schema.required)) {
69
+ findings.push(createFinding(rule, `${file} missing 'required' array at top level`, file, {
70
+ code: 'schema-artifact:violation',
71
+ }));
72
+ }
73
+ // Validate properties.
74
+ if (requiredProperties !== undefined) {
75
+ const props = schema.properties;
76
+ for (const prop of requiredProperties) {
77
+ if (!props || !(prop in props)) {
78
+ findings.push(createFinding(rule, `${file} missing properties.${prop}`, file, {
79
+ code: 'schema-artifact:violation',
80
+ }));
81
+ }
82
+ }
83
+ }
84
+ // Validate $defs / definitions.
85
+ if (requiredDefs !== undefined) {
86
+ const defs = schema.$defs ??
87
+ schema.definitions;
88
+ for (const def of requiredDefs) {
89
+ if (!defs || !(def in defs)) {
90
+ findings.push(createFinding(rule, `${file} missing $defs.${def}`, file, {
91
+ code: 'schema-artifact:violation',
92
+ }));
93
+ }
94
+ }
95
+ }
96
+ return { findings, fixes: [] };
97
+ }
98
+ }
99
+ /** Return a string array if value is a string array, otherwise undefined. */
100
+ function stringArray(value) {
101
+ return Array.isArray(value) && value.every((item) => typeof item === 'string') ? value : undefined;
102
+ }
@@ -1,8 +1,19 @@
1
1
  import { type ConstraintRule, type RuleContext, type RuleEvaluationResult, type RuleEvaluator } from '../types';
2
- /** Scans text files for high-confidence secret-like tokens. */
2
+ /** Built-in secret category names. */
3
+ export type SecretsCategory = 'api-key' | 'private-key' | 'password' | 'token' | 'connection-string';
4
+ /**
5
+ * Scans text files for secret-like tokens.
6
+ *
7
+ * Config (`evaluator.config`, all optional):
8
+ * - `categories`: subset of built-in categories to scan (default: all five —
9
+ * `api-key`, `private-key`, `password`, `token`, `connection-string`).
10
+ * - `customPatterns`: extra `{ name, pattern }` entries to scan for.
11
+ * - `scope`: `{ include, exclude }` globs; falls back to the rule's `include` /
12
+ * `exclude` when omitted.
13
+ */
3
14
  export declare class SecretsScannerEvaluator implements RuleEvaluator {
4
15
  constructor();
5
- /** Evaluate source files against bundled secret patterns. */
16
+ /** Evaluate files against the selected secret categories and custom patterns. */
6
17
  evaluate(rule: ConstraintRule, context: RuleContext): Promise<RuleEvaluationResult>;
7
18
  }
8
19
  //# sourceMappingURL=secrets-scanner-evaluator.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"secrets-scanner-evaluator.d.ts","sourceRoot":"","sources":["../../src/evaluators/secrets-scanner-evaluator.ts"],"names":[],"mappings":"AAAA,OAAO,EACH,KAAK,cAAc,EAEnB,KAAK,WAAW,EAChB,KAAK,oBAAoB,EACzB,KAAK,aAAa,EACrB,MAAM,UAAU,CAAC;AAGlB,+DAA+D;AAC/D,qBAAa,uBAAwB,YAAW,aAAa;;IAGzD,6DAA6D;IACvD,QAAQ,CAAC,IAAI,EAAE,cAAc,EAAE,OAAO,EAAE,WAAW,GAAG,OAAO,CAAC,oBAAoB,CAAC;CAmB5F"}
1
+ {"version":3,"file":"secrets-scanner-evaluator.d.ts","sourceRoot":"","sources":["../../src/evaluators/secrets-scanner-evaluator.ts"],"names":[],"mappings":"AAAA,OAAO,EACH,KAAK,cAAc,EAEnB,KAAK,WAAW,EAChB,KAAK,oBAAoB,EACzB,KAAK,aAAa,EACrB,MAAM,UAAU,CAAC;AAGlB,sCAAsC;AACtC,MAAM,MAAM,eAAe,GAAG,SAAS,GAAG,aAAa,GAAG,UAAU,GAAG,OAAO,GAAG,mBAAmB,CAAC;AA0BrG;;;;;;;;;GASG;AACH,qBAAa,uBAAwB,YAAW,aAAa;;IAGzD,iFAAiF;IAC3E,QAAQ,CAAC,IAAI,EAAE,cAAc,EAAE,OAAO,EAAE,WAAW,GAAG,OAAO,CAAC,oBAAoB,CAAC;CA4B5F"}
@@ -1,24 +1,87 @@
1
1
  import { createFinding, } from '../types.js';
2
2
  import { discoverFiles, readWorkdirFile } from './file-utils.js';
3
- /** Scans text files for high-confidence secret-like tokens. */
3
+ // Patterns are assembled from a keyword group plus a value-shape suffix rather
4
+ // than written as literal `keyword: "value"` lines, so this source file does not
5
+ // trip the secrets-scanner when it scans itself.
6
+ const ASSIGN = '\\s*[=:]\\s*';
7
+ const QUOTED = (body) => `["'\`]${body}["'\`]`;
8
+ const keyworded = (keywords, value) => `(?i)(${keywords})${ASSIGN}${QUOTED(value)}`;
9
+ /** Built-in category → regex source. A leading `(?i)` is folded into the JS `i` flag. */
10
+ const BUILTIN_PATTERNS = {
11
+ '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}`,
12
+ 'private-key': 'PRIVATE[_-]?KEY|-----BEGIN\\s+(?:RSA |OPENSSH |EC )?PRIVATE\\s+KEY',
13
+ password: keyworded('passw[o]rd|passwd|pwd|pass', '[^"\'`]{4,}'),
14
+ token: keyworded('bearer[_-]?token|refresh[_-]?token|session[_-]?token', '[A-Za-z0-9_\\-./+=]{8,}'),
15
+ 'connection-string': '(?i)(mongodb|postgres|mysql|redis)://[^\\s"\'`]{8,}',
16
+ };
17
+ const ALL_CATEGORIES = Object.keys(BUILTIN_PATTERNS);
18
+ /**
19
+ * Scans text files for secret-like tokens.
20
+ *
21
+ * Config (`evaluator.config`, all optional):
22
+ * - `categories`: subset of built-in categories to scan (default: all five —
23
+ * `api-key`, `private-key`, `password`, `token`, `connection-string`).
24
+ * - `customPatterns`: extra `{ name, pattern }` entries to scan for.
25
+ * - `scope`: `{ include, exclude }` globs; falls back to the rule's `include` /
26
+ * `exclude` when omitted.
27
+ */
4
28
  export class SecretsScannerEvaluator {
5
29
  constructor() { }
6
- /** Evaluate source files against bundled secret patterns. */
30
+ /** Evaluate files against the selected secret categories and custom patterns. */
7
31
  async evaluate(rule, context) {
8
- const files = await discoverFiles({ workdir: context.workdir, include: rule.include, exclude: rule.exclude });
32
+ const config = rule.evaluator.config ?? {};
33
+ const patterns = buildPatterns(config);
34
+ const scope = config.scope;
35
+ const include = stringArray(scope?.include) ?? rule.include;
36
+ const exclude = stringArray(scope?.exclude) ?? rule.exclude;
37
+ const files = await discoverFiles({ workdir: context.workdir, include, exclude });
9
38
  const findings = [];
10
- const secretPattern = /(sk-[A-Za-z0-9_-]{20,}|AKIA[0-9A-Z]{16}|BEGIN (?:RSA |OPENSSH )?PRIVATE KEY)/;
11
39
  for (const file of files) {
12
40
  const lines = (await readWorkdirFile(context.workdir, file)).split('\n');
13
41
  for (const [index, line] of lines.entries()) {
14
- if (secretPattern.test(line)) {
15
- findings.push(createFinding(rule, 'Potential secret token found', file, {
16
- line: index + 1,
17
- code: 'secret:token',
18
- }));
42
+ for (const pattern of patterns) {
43
+ pattern.regex.lastIndex = 0;
44
+ if (pattern.regex.test(line)) {
45
+ findings.push(createFinding(rule, `potential secret (${pattern.label})`, file, {
46
+ line: index + 1,
47
+ code: `secret:${pattern.label}`,
48
+ }));
49
+ break;
50
+ }
19
51
  }
20
52
  }
21
53
  }
22
54
  return { findings, fixes: [] };
23
55
  }
24
56
  }
57
+ /** Compile the selected built-in categories plus any custom patterns. */
58
+ function buildPatterns(config) {
59
+ const categories = stringArray(config.categories) ?? ALL_CATEGORIES;
60
+ const patterns = [];
61
+ for (const category of categories) {
62
+ const source = BUILTIN_PATTERNS[category];
63
+ if (source !== undefined)
64
+ patterns.push({ regex: compile(source), label: category });
65
+ }
66
+ const custom = config.customPatterns;
67
+ if (Array.isArray(custom)) {
68
+ for (const entry of custom) {
69
+ if (typeof entry.name === 'string' && typeof entry.pattern === 'string') {
70
+ patterns.push({ regex: compile(entry.pattern), label: entry.name });
71
+ }
72
+ }
73
+ }
74
+ return patterns;
75
+ }
76
+ /** Compile a pattern, folding a leading `(?i)` group into the JS `i` flag. */
77
+ function compile(source) {
78
+ const inline = /^\(\?([a-z]+)\)/.exec(source);
79
+ if (inline) {
80
+ const flags = [...(inline[1] ?? '')].filter((flag) => 'imsu'.includes(flag)).join('');
81
+ return new RegExp(source.slice(inline[0].length), flags);
82
+ }
83
+ return new RegExp(source);
84
+ }
85
+ function stringArray(value) {
86
+ return Array.isArray(value) && value.every((item) => typeof item === 'string') ? value : undefined;
87
+ }
@@ -0,0 +1,19 @@
1
+ import { type ProcessExecutor } from '@gobing-ai/ts-runtime';
2
+ import { type ConstraintRule, type RuleContext, type RuleEvaluationResult, type RuleEvaluator } from '../types';
3
+ /**
4
+ * Evaluates source code against an ast-grep pattern using the `sg` CLI.
5
+ *
6
+ * ## Options (in `evaluator.config`)
7
+ * - `pattern` — ast-grep pattern to search for (required).
8
+ * - `language` — language for ast-grep parsing (default: `typescript`).
9
+ *
10
+ * Include globs from the rule are forwarded to sg via `--glob` arguments.
11
+ * Exclude globs from the rule are applied in-process after parsing sg output.
12
+ */
13
+ export declare class SgEvaluator implements RuleEvaluator {
14
+ private readonly executor;
15
+ constructor(executor?: ProcessExecutor);
16
+ /** Run sg and emit one finding per matched AST node. */
17
+ evaluate(rule: ConstraintRule, context: RuleContext): Promise<RuleEvaluationResult>;
18
+ }
19
+ //# sourceMappingURL=sg-evaluator.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sg-evaluator.d.ts","sourceRoot":"","sources":["../../src/evaluators/sg-evaluator.ts"],"names":[],"mappings":"AAAA,OAAO,EAAuB,KAAK,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAClF,OAAO,EACH,KAAK,cAAc,EAEnB,KAAK,WAAW,EAChB,KAAK,oBAAoB,EACzB,KAAK,aAAa,EACrB,MAAM,UAAU,CAAC;AAGlB;;;;;;;;;GASG;AACH,qBAAa,WAAY,YAAW,aAAa;IAC7C,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAkB;gBAE/B,QAAQ,GAAE,eAA2C;IAIjE,wDAAwD;IAClD,QAAQ,CAAC,IAAI,EAAE,cAAc,EAAE,OAAO,EAAE,WAAW,GAAG,OAAO,CAAC,oBAAoB,CAAC;CAgD5F"}
@@ -0,0 +1,112 @@
1
+ import { NodeProcessExecutor } from '@gobing-ai/ts-runtime';
2
+ import { createFinding, } from '../types.js';
3
+ import { matchesGlob } from './file-utils.js';
4
+ /**
5
+ * Evaluates source code against an ast-grep pattern using the `sg` CLI.
6
+ *
7
+ * ## Options (in `evaluator.config`)
8
+ * - `pattern` — ast-grep pattern to search for (required).
9
+ * - `language` — language for ast-grep parsing (default: `typescript`).
10
+ *
11
+ * Include globs from the rule are forwarded to sg via `--glob` arguments.
12
+ * Exclude globs from the rule are applied in-process after parsing sg output.
13
+ */
14
+ export class SgEvaluator {
15
+ executor;
16
+ constructor(executor = new NodeProcessExecutor()) {
17
+ this.executor = executor;
18
+ }
19
+ /** Run sg and emit one finding per matched AST node. */
20
+ async evaluate(rule, context) {
21
+ const config = rule.evaluator.config ?? {};
22
+ const pattern = config.pattern;
23
+ if (typeof pattern !== 'string' || pattern.length === 0) {
24
+ throw new Error('sg evaluator requires string config "pattern"');
25
+ }
26
+ const language = typeof config.language === 'string' ? config.language : 'typescript';
27
+ const include = rule.include ?? [];
28
+ const exclude = rule.exclude ?? [];
29
+ const args = ['run', '--pattern', pattern, '--lang', language, '--json'];
30
+ for (const glob of include) {
31
+ args.push(`--glob=${glob}`);
32
+ }
33
+ const result = await this.executor.run({
34
+ command: 'sg',
35
+ args,
36
+ cwd: context.workdir,
37
+ timeout: 60_000,
38
+ rejectOnError: false,
39
+ label: 'sg',
40
+ });
41
+ const stdout = result.stdout.trim();
42
+ if (stdout.length === 0) {
43
+ if (result.exitCode !== 0 && result.exitCode !== null && result.stderr.trim().length > 0) {
44
+ throw new Error(`sg failed: ${result.stderr.trim()}`);
45
+ }
46
+ return { findings: [], fixes: [] };
47
+ }
48
+ const matches = parseSgJson(stdout);
49
+ const findings = [];
50
+ for (const match of matches) {
51
+ if (exclude.some((glob) => matchesGlob(match.file, glob)))
52
+ continue;
53
+ findings.push(createFinding(rule, 'matched sg pattern', match.file, {
54
+ line: match.line,
55
+ code: 'sg:match',
56
+ }));
57
+ }
58
+ return { findings, fixes: [] };
59
+ }
60
+ }
61
+ /**
62
+ * Parse `sg --json` output.
63
+ *
64
+ * Handles both a JSON array (sg >= 0.40) and newline-delimited JSON objects
65
+ * (older sg or `--json=stream`). Returns workdir-relative file paths as-is
66
+ * since sg emits paths relative to cwd.
67
+ */
68
+ function parseSgJson(stdout) {
69
+ // Try JSON array first (newer sg).
70
+ try {
71
+ const parsed = JSON.parse(stdout);
72
+ if (Array.isArray(parsed)) {
73
+ return parsed.flatMap((event) => {
74
+ const file = typeof event.file === 'string' ? event.file : '';
75
+ if (!file)
76
+ return [];
77
+ return [
78
+ {
79
+ file,
80
+ line: (event.range?.start?.line ?? 0) + 1,
81
+ text: typeof event.text === 'string' ? event.text.trim() : '',
82
+ },
83
+ ];
84
+ });
85
+ }
86
+ }
87
+ catch {
88
+ // Fall through to line-delimited parsing.
89
+ }
90
+ // Fallback: newline-delimited JSON.
91
+ const results = [];
92
+ for (const line of stdout.split('\n')) {
93
+ const trimmed = line.trim();
94
+ if (!trimmed)
95
+ continue;
96
+ try {
97
+ const event = JSON.parse(trimmed);
98
+ const file = typeof event.file === 'string' ? event.file : '';
99
+ if (!file)
100
+ continue;
101
+ results.push({
102
+ file,
103
+ line: (event.range?.start?.line ?? 0) + 1,
104
+ text: typeof event.text === 'string' ? event.text.trim() : '',
105
+ });
106
+ }
107
+ catch {
108
+ // Skip unparseable lines.
109
+ }
110
+ }
111
+ return results;
112
+ }
@@ -0,0 +1,32 @@
1
+ import type { CapabilityRegistry } from '../host/capability-registry';
2
+ import type { TestPathResolver } from '../resolvers/test-path-resolver';
3
+ import { type ConstraintRule, type RuleContext, type RuleEvaluationResult, type RuleEvaluator } from '../types';
4
+ /**
5
+ * Evaluator that enforces where test files live and, optionally, that every
6
+ * source file has a corresponding test.
7
+ *
8
+ * Config (`evaluator.config`):
9
+ * - `expected`: glob the test files must match (required)
10
+ * - `forbid`: globs where tests must not live (e.g. `**\/__tests__/**`)
11
+ * - `requireCorrespondingTest`: when true, flags source files (from `rule.include`)
12
+ * that lack a test at the resolver's conventional path
13
+ * - `resolver`: language resolver name (`typescript` default, or `python`/`go`/`rust`)
14
+ *
15
+ * Discovery walks the workdir and applies `**` globs precisely, so it stays
16
+ * self-contained (no `rg --files` shell-out).
17
+ */
18
+ export declare class TestLocationEvaluator implements RuleEvaluator {
19
+ private readonly resolvers?;
20
+ /** Optional resolver registry; when absent, the TypeScript convention is used. */
21
+ constructor(resolvers?: CapabilityRegistry<TestPathResolver> | undefined);
22
+ /** Evaluate test-file placement and optional coverage of source files. */
23
+ evaluate(rule: ConstraintRule, context: RuleContext): Promise<RuleEvaluationResult>;
24
+ /**
25
+ * Pick the resolver named in config (default `typescript`).
26
+ *
27
+ * Falls back to the built-in TypeScript convention when no registry was
28
+ * injected; throws if a named resolver is requested but not registered.
29
+ */
30
+ private selectResolver;
31
+ }
32
+ //# sourceMappingURL=test-location-evaluator.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"test-location-evaluator.d.ts","sourceRoot":"","sources":["../../src/evaluators/test-location-evaluator.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,6BAA6B,CAAC;AACtE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,iCAAiC,CAAC;AACxE,OAAO,EACH,KAAK,cAAc,EAEnB,KAAK,WAAW,EAChB,KAAK,oBAAoB,EACzB,KAAK,aAAa,EACrB,MAAM,UAAU,CAAC;AAWlB;;;;;;;;;;;;;GAaG;AACH,qBAAa,qBAAsB,YAAW,aAAa;IAE3C,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAC;IADvC,kFAAkF;gBACrD,SAAS,CAAC,EAAE,kBAAkB,CAAC,gBAAgB,CAAC,YAAA;IAE7E,0EAA0E;IACpE,QAAQ,CAAC,IAAI,EAAE,cAAc,EAAE,OAAO,EAAE,WAAW,GAAG,OAAO,CAAC,oBAAoB,CAAC;IAyDzF;;;;;OAKG;IACH,OAAO,CAAC,cAAc;CASzB"}
@@ -0,0 +1,105 @@
1
+ import { createFinding, } from '../types.js';
2
+ import { discoverFiles, matchesGlob } from './file-utils.js';
3
+ /**
4
+ * Evaluator that enforces where test files live and, optionally, that every
5
+ * source file has a corresponding test.
6
+ *
7
+ * Config (`evaluator.config`):
8
+ * - `expected`: glob the test files must match (required)
9
+ * - `forbid`: globs where tests must not live (e.g. `**\/__tests__/**`)
10
+ * - `requireCorrespondingTest`: when true, flags source files (from `rule.include`)
11
+ * that lack a test at the resolver's conventional path
12
+ * - `resolver`: language resolver name (`typescript` default, or `python`/`go`/`rust`)
13
+ *
14
+ * Discovery walks the workdir and applies `**` globs precisely, so it stays
15
+ * self-contained (no `rg --files` shell-out).
16
+ */
17
+ export class TestLocationEvaluator {
18
+ resolvers;
19
+ /** Optional resolver registry; when absent, the TypeScript convention is used. */
20
+ constructor(resolvers) {
21
+ this.resolvers = resolvers;
22
+ }
23
+ /** Evaluate test-file placement and optional coverage of source files. */
24
+ async evaluate(rule, context) {
25
+ const config = (rule.evaluator.config ?? {});
26
+ const expected = config.expected;
27
+ if (typeof expected !== 'string' || expected.length === 0) {
28
+ throw new Error('test-location evaluator requires a non-empty "expected" config');
29
+ }
30
+ const forbid = config.forbid ?? [];
31
+ const include = rule.include ?? ['**/*.test.ts', '**/*.test.tsx', '**/*.spec.ts', '**/*.spec.tsx'];
32
+ const exclude = rule.exclude ?? [];
33
+ const allFiles = await discoverFiles({ workdir: context.workdir });
34
+ const findings = [];
35
+ const testPatterns = config.requireCorrespondingTest ? [expected] : include;
36
+ const testFiles = allFiles.filter((file) => testPatterns.some((pattern) => matchesGlob(file, pattern)));
37
+ for (const file of testFiles) {
38
+ if (exclude.some((pattern) => matchesGlob(file, pattern)))
39
+ continue;
40
+ const violated = forbid.find((pattern) => matchesGlob(file, pattern));
41
+ if (violated !== undefined) {
42
+ findings.push(createFinding(rule, `test file in forbidden location (matches "${violated}")`, file, {
43
+ code: 'test-location:forbidden',
44
+ }));
45
+ continue;
46
+ }
47
+ if (!matchesGlob(file, expected)) {
48
+ findings.push(createFinding(rule, `test file outside expected location (expected "${expected}")`, file, {
49
+ code: 'test-location:unexpected',
50
+ }));
51
+ }
52
+ }
53
+ if (config.requireCorrespondingTest) {
54
+ const resolver = this.selectResolver(config.resolver);
55
+ const srcPatterns = rule.include ?? ['**/*.ts', '**/*.tsx'];
56
+ const testSet = new Set(testFiles);
57
+ for (const srcFile of allFiles) {
58
+ if (!srcPatterns.some((pattern) => matchesGlob(srcFile, pattern)))
59
+ continue;
60
+ if (exclude.some((pattern) => matchesGlob(srcFile, pattern)))
61
+ continue;
62
+ const testPath = resolver.resolveTestPath(srcFile);
63
+ if (testPath === srcFile)
64
+ continue;
65
+ if (!testSet.has(testPath)) {
66
+ findings.push(createFinding(rule, `no corresponding test → ${testPath}`, srcFile, {
67
+ code: 'test-location:missing',
68
+ }));
69
+ }
70
+ }
71
+ }
72
+ return { findings, fixes: [] };
73
+ }
74
+ /**
75
+ * Pick the resolver named in config (default `typescript`).
76
+ *
77
+ * Falls back to the built-in TypeScript convention when no registry was
78
+ * injected; throws if a named resolver is requested but not registered.
79
+ */
80
+ selectResolver(name = 'typescript') {
81
+ if (this.resolvers === undefined) {
82
+ if (name !== 'typescript') {
83
+ throw new Error(`test-location resolver "${name}" requested but no resolver registry is available`);
84
+ }
85
+ return TYPESCRIPT_FALLBACK;
86
+ }
87
+ return this.resolvers.get(name);
88
+ }
89
+ }
90
+ /** Built-in TypeScript convention used when no resolver registry is injected. */
91
+ const TYPESCRIPT_FALLBACK = {
92
+ name: 'typescript',
93
+ resolveTestPath(srcRelPath) {
94
+ if (srcRelPath.includes('.test.') || srcRelPath.includes('.spec.'))
95
+ return srcRelPath;
96
+ const srcIdx = srcRelPath.indexOf('/src/');
97
+ if (srcIdx !== -1) {
98
+ const pkg = srcRelPath.slice(0, srcIdx);
99
+ const rel = srcRelPath.slice(srcIdx + '/src/'.length).replace(/\.(ts|tsx|js|jsx)$/, '.test.ts');
100
+ return `${pkg}/tests/${rel}`;
101
+ }
102
+ const withoutExt = srcRelPath.replace(/\.(ts|tsx|js|jsx)$/, '');
103
+ return `tests/${withoutExt.replace(/^src\//, '')}.test.ts`;
104
+ },
105
+ };
@@ -0,0 +1,15 @@
1
+ import { type ConstraintRule, type RuleContext, type RuleEvaluationResult, type RuleEvaluator } from '../types';
2
+ /**
3
+ * Evaluator that flags exported declarations missing a JSDoc block.
4
+ *
5
+ * Self-contained: it scans matching source files line-by-line and checks whether
6
+ * the line immediately preceding an export ends a JSDoc comment. Config
7
+ * (`evaluator.config.kinds`) selects which export kinds to check; `rule.include`
8
+ * and `rule.exclude` scope the files using full `**` globs.
9
+ */
10
+ export declare class TsdocExportEvaluator implements RuleEvaluator {
11
+ constructor();
12
+ /** Evaluate exports under the configured kinds for a preceding JSDoc block. */
13
+ evaluate(rule: ConstraintRule, context: RuleContext): Promise<RuleEvaluationResult>;
14
+ }
15
+ //# sourceMappingURL=tsdoc-export-evaluator.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tsdoc-export-evaluator.d.ts","sourceRoot":"","sources":["../../src/evaluators/tsdoc-export-evaluator.ts"],"names":[],"mappings":"AAAA,OAAO,EACH,KAAK,cAAc,EAEnB,KAAK,WAAW,EAChB,KAAK,oBAAoB,EACzB,KAAK,aAAa,EACrB,MAAM,UAAU,CAAC;AAwBlB;;;;;;;GAOG;AACH,qBAAa,oBAAqB,YAAW,aAAa;;IAItD,+EAA+E;IACzE,QAAQ,CAAC,IAAI,EAAE,cAAc,EAAE,OAAO,EAAE,WAAW,GAAG,OAAO,CAAC,oBAAoB,CAAC;CA8B5F"}
@@ -0,0 +1,91 @@
1
+ import { createFinding, } from '../types.js';
2
+ import { discoverFiles, matchesGlob, readWorkdirFile } from './file-utils.js';
3
+ /** Export kinds this evaluator can check for a preceding JSDoc block. */
4
+ const VALID_KINDS = ['function', 'class', 'type', 'const', 'enum', 'interface'];
5
+ const KIND_PATTERN = {
6
+ function: /^export\s+(?:default\s+)?(?:async\s+)?function\s*\*?\s+([A-Za-z0-9_$]+)/,
7
+ class: /^export\s+(?:default\s+)?(?:abstract\s+)?class\s+([A-Za-z0-9_$]+)/,
8
+ interface: /^export\s+interface\s+([A-Za-z0-9_$]+)/,
9
+ type: /^export\s+type\s+([A-Za-z0-9_$]+)/,
10
+ const: /^export\s+const\s+([A-Za-z0-9_$]+)/,
11
+ enum: /^export\s+(?:const\s+)?enum\s+([A-Za-z0-9_$]+)/,
12
+ };
13
+ /**
14
+ * Evaluator that flags exported declarations missing a JSDoc block.
15
+ *
16
+ * Self-contained: it scans matching source files line-by-line and checks whether
17
+ * the line immediately preceding an export ends a JSDoc comment. Config
18
+ * (`evaluator.config.kinds`) selects which export kinds to check; `rule.include`
19
+ * and `rule.exclude` scope the files using full `**` globs.
20
+ */
21
+ export class TsdocExportEvaluator {
22
+ constructor() {
23
+ // V8 function coverage requires explicit constructor
24
+ }
25
+ /** Evaluate exports under the configured kinds for a preceding JSDoc block. */
26
+ async evaluate(rule, context) {
27
+ const kinds = rule.evaluator.config?.kinds ?? [...VALID_KINDS];
28
+ for (const kind of kinds) {
29
+ if (!VALID_KINDS.includes(kind)) {
30
+ throw new Error(`Unknown export kind "${kind}"; expected one of: ${VALID_KINDS.join(', ')}`);
31
+ }
32
+ }
33
+ const requested = new Set(kinds);
34
+ const include = rule.include ?? ['**/*.ts', '**/*.tsx'];
35
+ const exclude = rule.exclude ?? [];
36
+ const files = await discoverFiles({ workdir: context.workdir, include: ['.ts', '.tsx'] });
37
+ const findings = [];
38
+ for (const file of files) {
39
+ if (!include.some((pattern) => matchesGlob(file, pattern)))
40
+ continue;
41
+ if (exclude.some((pattern) => matchesGlob(file, pattern)))
42
+ continue;
43
+ const lines = (await readWorkdirFile(context.workdir, file)).split('\n');
44
+ for (const site of findExports(lines, requested)) {
45
+ if (!precededByJsdoc(lines, site.line)) {
46
+ findings.push(createFinding(rule, `Exported ${site.kind} "${site.name}" is missing a JSDoc comment`, file, {
47
+ line: site.line,
48
+ code: 'tsdoc:missing',
49
+ }));
50
+ }
51
+ }
52
+ }
53
+ return { findings, fixes: [] };
54
+ }
55
+ }
56
+ /** Find exported declarations of the requested kinds within a file's lines. */
57
+ function findExports(lines, requested) {
58
+ const sites = [];
59
+ for (const [index, raw] of lines.entries()) {
60
+ const line = raw.trimStart();
61
+ if (!line.startsWith('export'))
62
+ continue;
63
+ for (const kind of requested) {
64
+ const match = KIND_PATTERN[kind].exec(line);
65
+ if (match) {
66
+ sites.push({ kind, name: match[1] ?? 'unknown', line: index + 1 });
67
+ break;
68
+ }
69
+ }
70
+ }
71
+ return sites;
72
+ }
73
+ /**
74
+ * True when a JSDoc block precedes a declaration.
75
+ *
76
+ * Skips decorator lines (`@Component(...)`) so a documented but decorated class
77
+ * is not falsely flagged, then checks whether the nearest preceding non-decorator
78
+ * line closes (or is) a JSDoc comment.
79
+ */
80
+ function precededByJsdoc(lines, declarationLine) {
81
+ let cursor = declarationLine - 2; // zero-based line above the declaration
82
+ while (cursor >= 0) {
83
+ const prev = lines[cursor]?.trim() ?? '';
84
+ if (prev.startsWith('@')) {
85
+ cursor -= 1; // decorator — keep walking up
86
+ continue;
87
+ }
88
+ return prev.endsWith('*/') || prev.startsWith('/**');
89
+ }
90
+ return false;
91
+ }