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

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gobing-ai/ts-rule-engine",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
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.5",
51
- "@gobing-ai/ts-runtime": "^0.2.5",
50
+ "@gobing-ai/ts-ai-runner": "^0.2.6",
51
+ "@gobing-ai/ts-runtime": "^0.2.6",
52
52
  "yaml": "^2.7.0",
53
53
  "zod": "^4.1.0"
54
54
  },
@@ -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
- /** Project working directory. */
16
- workdir: string;
17
- /** Rule root directory. Defaults to ".spur/rules". */
18
- rulesRoot?: string;
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
- /** Load and normalize a preset by name. */
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 fs = new NodeFileSystem();
24
- const root = resolve(options.workdir, options.rulesRoot ?? '.spur/rules');
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(root, entry, new Set([name]))));
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,124 @@ 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(root: string, entry: string, seen: Set<string>): Promise<ConstraintRule[]> {
50
- const presetPath = await findDefinitionPath(root, entry);
65
+ async function loadPresetEntry(merged: MergedRoots, entry: string, seen: Set<string>): Promise<ConstraintRule[]> {
66
+ // Sub-preset reference recurse (cycle-guarded).
67
+ const presetPath = findMergedPreset(merged, entry);
51
68
  if (presetPath !== null && !seen.has(entry)) {
52
69
  seen.add(entry);
53
70
  const preset = PresetDefinitionSchema.safeParse(await readStructuredFile(presetPath));
54
71
  if (preset.success) {
55
72
  const rules: ConstraintRule[] = [];
56
- for (const child of preset.data.extends) rules.push(...(await loadPresetEntry(root, child, seen)));
73
+ for (const child of preset.data.extends) rules.push(...(await loadPresetEntry(merged, child, seen)));
57
74
  return rules.filter((rule) => !(preset.data.disable ?? []).includes(rule.id));
58
75
  }
59
76
  }
60
77
 
61
- const categoryDir = resolve(root, entry);
62
- const fs = new NodeFileSystem();
63
- if (!(await fs.exists(categoryDir))) return [];
64
- const entries = (await fs.readDir(categoryDir)).filter((file) => /\.(ya?ml|json)$/i.test(file)).sort();
65
- const rules: ConstraintRule[] = [];
66
- for (const file of entries) {
67
- rules.push(...(await loadRuleFile(join(categoryDir, file))));
78
+ // Category folder reference — load every winning file under that prefix.
79
+ if (merged.categories.has(entry)) {
80
+ const rules: ConstraintRule[] = [];
81
+ for (const absPath of mergedFilesInCategory(merged, entry)) {
82
+ rules.push(...(await loadRuleFile(absPath)));
83
+ }
84
+ return rules;
68
85
  }
69
- return rules;
86
+
87
+ // Sub-path reference — a single winning rule file within a category.
88
+ const subPath = findMergedFile(merged, entry);
89
+ if (subPath !== null) return loadRuleFile(subPath);
90
+
91
+ return [];
70
92
  }
71
93
 
72
- async function findDefinitionPath(root: string, name: string): Promise<string | null> {
94
+ /**
95
+ * Build the merged view across ordered roots.
96
+ *
97
+ * Roots are processed in the order supplied (highest priority first). The first
98
+ * root to provide a given relative path owns that file; later roots are shadowed.
99
+ */
100
+ async function buildMergedRoots(roots: readonly string[]): Promise<MergedRoots> {
73
101
  const fs = new NodeFileSystem();
74
- const candidates = [
75
- resolve(root, `${name}.yaml`),
76
- resolve(root, `${name}.yml`),
77
- resolve(root, `${name}.json`),
78
- resolve(root, name, 'index.yaml'),
79
- resolve(root, name, 'index.yml'),
80
- resolve(root, name, 'index.json'),
81
- ];
82
- for (const candidate of candidates) {
83
- if (await fs.exists(candidate)) return candidate;
102
+ const files = new Map<string, string>();
103
+ const categories = new Set<string>();
104
+ for (const root of roots) {
105
+ for (const absPath of await walkYamlFiles(fs, root)) {
106
+ const relPath = relative(root, absPath).split(sep).join('/');
107
+ const slashIdx = relPath.indexOf('/');
108
+ if (slashIdx > 0) categories.add(relPath.slice(0, slashIdx));
109
+ if (!files.has(relPath)) files.set(relPath, absPath);
110
+ }
111
+ for (const dir of await listImmediateDirs(fs, root)) categories.add(dir);
112
+ }
113
+ return { files, categories };
114
+ }
115
+
116
+ /** Find a preset definition across roots: `<name>.{yaml,yml,json}` or `<name>/index.*`. */
117
+ function findMergedPreset(merged: MergedRoots, name: string): string | null {
118
+ return firstHit(merged, [
119
+ `${name}.yaml`,
120
+ `${name}.yml`,
121
+ `${name}.json`,
122
+ `${name}/index.yaml`,
123
+ `${name}/index.yml`,
124
+ `${name}/index.json`,
125
+ ]);
126
+ }
127
+
128
+ /** Find a single rule file by sub-path entry (e.g. `typescript/tsdoc-exports`). */
129
+ function findMergedFile(merged: MergedRoots, entry: string): string | null {
130
+ const hasExt = /\.(ya?ml|json)$/i.test(entry);
131
+ return firstHit(merged, hasExt ? [entry] : [`${entry}.yaml`, `${entry}.yml`, `${entry}.json`]);
132
+ }
133
+
134
+ /** Return the winning absolute path for the first matching relative candidate. */
135
+ function firstHit(merged: MergedRoots, relCandidates: readonly string[]): string | null {
136
+ for (const rel of relCandidates) {
137
+ const hit = merged.files.get(rel);
138
+ if (hit !== undefined) return hit;
84
139
  }
85
140
  return null;
86
141
  }
87
142
 
143
+ /** Winning files under a category prefix, sorted by relative path. */
144
+ function mergedFilesInCategory(merged: MergedRoots, category: string): string[] {
145
+ const prefix = `${category}/`;
146
+ return [...merged.files.entries()]
147
+ .filter(([relPath]) => relPath.startsWith(prefix))
148
+ .sort(([a], [b]) => a.localeCompare(b))
149
+ .map(([, absPath]) => absPath);
150
+ }
151
+
152
+ /** Recursively collect YAML/JSON file paths under a directory, skipping root-level `presets/`. */
153
+ async function walkYamlFiles(fs: NodeFileSystem, dir: string, depth = 0): Promise<string[]> {
154
+ const stat = await fs.stat(dir);
155
+ if (stat === null || !stat.isDirectory()) return [];
156
+ const acc: string[] = [];
157
+ for (const entry of (await fs.readDir(dir)).sort()) {
158
+ if (depth === 0 && entry === 'presets') continue;
159
+ const fullPath = join(dir, entry);
160
+ const entryStat = await fs.stat(fullPath);
161
+ if (entryStat?.isDirectory()) {
162
+ acc.push(...(await walkYamlFiles(fs, fullPath, depth + 1)));
163
+ } else if (entryStat?.isFile() && /\.(ya?ml|json)$/i.test(entry)) {
164
+ acc.push(fullPath);
165
+ }
166
+ }
167
+ return acc;
168
+ }
169
+
170
+ /** List immediate subdirectory names of a root (excluding `presets`). */
171
+ async function listImmediateDirs(fs: NodeFileSystem, dir: string): Promise<string[]> {
172
+ const stat = await fs.stat(dir);
173
+ if (stat === null || !stat.isDirectory()) return [];
174
+ const dirs: string[] = [];
175
+ for (const entry of await fs.readDir(dir)) {
176
+ if (entry === 'presets') continue;
177
+ const entryStat = await fs.stat(join(dir, entry));
178
+ if (entryStat?.isDirectory()) dirs.push(entry);
179
+ }
180
+ return dirs;
181
+ }
182
+
88
183
  async function readStructuredFile(path: string): Promise<unknown> {
89
184
  const content = await new NodeFileSystem().readFile(path);
90
185
  return extname(path) === '.json' ? JSON.parse(content) : parse(content);
@@ -0,0 +1,137 @@
1
+ import { isAbsolute, relative, 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
+ import { matchesGlob } from './file-utils';
11
+
12
+ /** Single-file coverage extracted from an lcov record. */
13
+ interface FileCoverage {
14
+ linesFound: number;
15
+ linesHit: number;
16
+ }
17
+
18
+ /** Per-file threshold override with justification. */
19
+ interface CoverageExemption {
20
+ path: string;
21
+ threshold: number;
22
+ reason: string;
23
+ }
24
+
25
+ /** Evaluator config shape extracted from `rule.evaluator.config`. */
26
+ interface CoverageGateConfig {
27
+ lcovPath?: string;
28
+ threshold?: number;
29
+ include?: string[];
30
+ exclude?: string[];
31
+ exemptions?: CoverageExemption[];
32
+ }
33
+
34
+ /**
35
+ * Coverage gate evaluator — reads an lcov tracefile and enforces per-file line
36
+ * coverage thresholds.
37
+ *
38
+ * Config (`evaluator.config`):
39
+ * - `lcovPath`: path to lcov.info (default: `coverage/lcov.info`)
40
+ * - `threshold`: default line-coverage percentage (default: `90`)
41
+ * - `include` / `exclude`: glob patterns scoping the check
42
+ * - `exemptions`: per-file threshold overrides with a reason
43
+ *
44
+ * A missing lcov file is reported as a single non-blocking finding rather than an
45
+ * error, so the gate degrades gracefully when coverage was not generated.
46
+ */
47
+ export class CoverageGateEvaluator implements RuleEvaluator {
48
+ private readonly fs: NodeFileSystem;
49
+
50
+ constructor() {
51
+ this.fs = new NodeFileSystem();
52
+ }
53
+
54
+ /** Evaluate per-file coverage against the configured threshold. */
55
+ async evaluate(rule: ConstraintRule, context: RuleContext): Promise<RuleEvaluationResult> {
56
+ const config = (rule.evaluator.config ?? {}) as CoverageGateConfig;
57
+ const lcovPath = config.lcovPath
58
+ ? resolve(context.workdir, config.lcovPath)
59
+ : resolve(context.workdir, 'coverage', 'lcov.info');
60
+
61
+ if (!(await this.fs.exists(lcovPath))) {
62
+ return {
63
+ findings: [
64
+ createFinding(rule, 'No lcov file found — coverage not generated. Skipping gate.', lcovPath, {
65
+ code: 'coverage:missing-lcov',
66
+ }),
67
+ ],
68
+ fixes: [],
69
+ };
70
+ }
71
+
72
+ const coverage = parseLcov(await this.fs.readFile(lcovPath));
73
+ const threshold = config.threshold ?? 90;
74
+ const exemptions = new Map((config.exemptions ?? []).map((entry) => [entry.path, entry]));
75
+ const findings = [];
76
+
77
+ for (const [rawPath, cov] of coverage) {
78
+ if (cov.linesFound === 0) continue;
79
+ const filePath = normalizeLcovSourcePath(context.workdir, rawPath);
80
+ if (config.include !== undefined && !config.include.some((p) => matchesGlob(filePath, p))) continue;
81
+ if (config.exclude?.some((p) => matchesGlob(filePath, p))) continue;
82
+ if (isAlwaysExcluded(filePath)) continue;
83
+
84
+ const pct = Math.round((cov.linesHit / cov.linesFound) * 100);
85
+ const exemption = exemptions.get(filePath);
86
+ const effectiveThreshold = exemption?.threshold ?? threshold;
87
+ if (pct >= effectiveThreshold) continue;
88
+
89
+ const message = exemption
90
+ ? `${pct}% line coverage (exemption: ${effectiveThreshold}%, reason: ${exemption.reason})`
91
+ : `${pct}% line coverage (threshold: ${effectiveThreshold}%)`;
92
+ findings.push(createFinding(rule, message, filePath, { code: 'coverage:below-threshold' }));
93
+ }
94
+
95
+ return { findings, fixes: [] };
96
+ }
97
+ }
98
+
99
+ /** Parse an lcov tracefile into a map of source path → coverage counts. */
100
+ function parseLcov(raw: string): Map<string, FileCoverage> {
101
+ const result = new Map<string, FileCoverage>();
102
+ let file: string | null = null;
103
+ let linesFound = 0;
104
+ let linesHit = 0;
105
+ for (const line of raw.split('\n')) {
106
+ const trimmed = line.trim();
107
+ if (trimmed.startsWith('SF:')) {
108
+ file = trimmed.slice(3);
109
+ linesFound = 0;
110
+ linesHit = 0;
111
+ } else if (trimmed.startsWith('LF:')) {
112
+ linesFound = Number(trimmed.slice(3));
113
+ } else if (trimmed.startsWith('LH:')) {
114
+ linesHit = Number(trimmed.slice(3));
115
+ } else if (trimmed === 'end_of_record' && file !== null) {
116
+ result.set(file, { linesFound, linesHit });
117
+ file = null;
118
+ }
119
+ }
120
+ return result;
121
+ }
122
+
123
+ /** Standard exclusions applied regardless of config (tests, generated, deps). */
124
+ function isAlwaysExcluded(filePath: string): boolean {
125
+ return (
126
+ filePath.includes('node_modules') ||
127
+ filePath.includes('.test.') ||
128
+ filePath.includes('.spec.') ||
129
+ filePath.includes('__tests__')
130
+ );
131
+ }
132
+
133
+ /** Normalize an lcov `SF:` path to a workdir-relative forward-slash path. */
134
+ function normalizeLcovSourcePath(workdir: string, filePath: string): string {
135
+ const normalized = isAbsolute(filePath) ? relative(workdir, filePath) : filePath;
136
+ return normalized.replaceAll('\\', '/');
137
+ }
@@ -53,3 +53,41 @@ export function matchesAny(path: string, patterns: string[] | undefined): boolea
53
53
  return clean.length === 0 || path.includes(clean) || path.endsWith(clean);
54
54
  });
55
55
  }
56
+
57
+ /**
58
+ * Segment-aware glob matching with `**` (any depth) and `*` (single segment).
59
+ *
60
+ * Stricter than {@link matchesAny}: it anchors the whole path, so `apps/**` does
61
+ * not match `vendor/apps/x`. Used by evaluators that enforce path policies
62
+ * (coverage scoping, test-file location) where a loose match would change findings.
63
+ */
64
+ export function matchesGlob(path: string, pattern: string): boolean {
65
+ const normalized = path.replaceAll('\\', '/');
66
+ if (pattern.startsWith('**/')) {
67
+ const suffix = pattern.slice(3);
68
+ if (suffix.indexOf('*') === -1) {
69
+ return normalized.endsWith(suffix) || normalized.endsWith(`/${suffix}`);
70
+ }
71
+ }
72
+ if (pattern === normalized) return true;
73
+ return matchSegments(normalized.split('/'), pattern.split('/'), 0, 0);
74
+ }
75
+
76
+ /** Recursive segment-level glob matcher backing {@link matchesGlob}. */
77
+ function matchSegments(file: string[], pattern: string[], fi: number, pi: number): boolean {
78
+ if (pi >= pattern.length) return fi >= file.length;
79
+ if (fi >= file.length) return pattern.slice(pi).every((segment) => segment === '**');
80
+ const pat = pattern[pi] ?? '';
81
+ if (pat === '**') {
82
+ return matchSegments(file, pattern, fi, pi + 1) || matchSegments(file, pattern, fi + 1, pi);
83
+ }
84
+ if (!matchSegment(file[fi] ?? '', pat)) return false;
85
+ return matchSegments(file, pattern, fi + 1, pi + 1);
86
+ }
87
+
88
+ /** Match one path segment against a pattern segment where `*` matches any run of non-`/` chars. */
89
+ function matchSegment(segment: string, pattern: string): boolean {
90
+ if (pattern.indexOf('*') === -1) return segment === pattern;
91
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replaceAll('*', '[^/]*');
92
+ return new RegExp(`^${escaped}$`).test(segment);
93
+ }
@@ -0,0 +1,115 @@
1
+ import {
2
+ type ConstraintRule,
3
+ createFinding,
4
+ type RuleContext,
5
+ type RuleEvaluationResult,
6
+ type RuleEvaluator,
7
+ } from '../types';
8
+ import { discoverFiles, matchesGlob } from './file-utils';
9
+
10
+ /** Evaluator config shape extracted from `rule.evaluator.config`. */
11
+ interface TestLocationConfig {
12
+ expected?: string;
13
+ forbid?: string[];
14
+ requireCorrespondingTest?: boolean;
15
+ }
16
+
17
+ /**
18
+ * Evaluator that enforces where test files live and, optionally, that every
19
+ * source file has a corresponding test.
20
+ *
21
+ * Config (`evaluator.config`):
22
+ * - `expected`: glob the test files must match (required)
23
+ * - `forbid`: globs where tests must not live (e.g. `**\/__tests__/**`)
24
+ * - `requireCorrespondingTest`: when true, flags source files (from `rule.include`)
25
+ * that lack a test at the TypeScript-conventional path
26
+ *
27
+ * Discovery walks the workdir and applies `**` globs precisely, so it stays
28
+ * self-contained (no `rg --files` shell-out).
29
+ */
30
+ export class TestLocationEvaluator implements RuleEvaluator {
31
+ /** Evaluate test-file placement and optional coverage of source files. */
32
+ async evaluate(rule: ConstraintRule, context: RuleContext): Promise<RuleEvaluationResult> {
33
+ const config = (rule.evaluator.config ?? {}) as TestLocationConfig;
34
+ const expected = config.expected;
35
+ if (typeof expected !== 'string' || expected.length === 0) {
36
+ throw new Error('test-location evaluator requires a non-empty "expected" config');
37
+ }
38
+ const forbid = config.forbid ?? [];
39
+ const include = rule.include ?? ['**/*.test.ts', '**/*.test.tsx', '**/*.spec.ts', '**/*.spec.tsx'];
40
+ const exclude = rule.exclude ?? [];
41
+ const allFiles = await discoverFiles({ workdir: context.workdir });
42
+ const findings = [];
43
+
44
+ const testPatterns = config.requireCorrespondingTest ? [expected] : include;
45
+ const testFiles = allFiles.filter((file) => testPatterns.some((pattern) => matchesGlob(file, pattern)));
46
+
47
+ for (const file of testFiles) {
48
+ if (exclude.some((pattern) => matchesGlob(file, pattern))) continue;
49
+ const violated = forbid.find((pattern) => matchesGlob(file, pattern));
50
+ if (violated !== undefined) {
51
+ findings.push(
52
+ createFinding(
53
+ rule,
54
+ `Test file "${file}" is in a forbidden location (matches "${violated}")`,
55
+ file,
56
+ {
57
+ code: 'test-location:forbidden',
58
+ },
59
+ ),
60
+ );
61
+ continue;
62
+ }
63
+ if (!matchesGlob(file, expected)) {
64
+ findings.push(
65
+ createFinding(rule, `Test file "${file}" does not match expected pattern "${expected}"`, file, {
66
+ code: 'test-location:unexpected',
67
+ }),
68
+ );
69
+ }
70
+ }
71
+
72
+ if (config.requireCorrespondingTest) {
73
+ const srcPatterns = rule.include ?? ['**/*.ts', '**/*.tsx'];
74
+ const testSet = new Set(testFiles);
75
+ for (const srcFile of allFiles) {
76
+ if (!srcPatterns.some((pattern) => matchesGlob(srcFile, pattern))) continue;
77
+ if (exclude.some((pattern) => matchesGlob(srcFile, pattern))) continue;
78
+ const testPath = resolveTestPath(srcFile);
79
+ if (testPath === srcFile) continue;
80
+ if (!testSet.has(testPath)) {
81
+ findings.push(
82
+ createFinding(
83
+ rule,
84
+ `Source file "${srcFile}" has no corresponding test → ${testPath}`,
85
+ srcFile,
86
+ {
87
+ code: 'test-location:missing',
88
+ },
89
+ ),
90
+ );
91
+ }
92
+ }
93
+ }
94
+
95
+ return { findings, fixes: [] };
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Map a TypeScript source path to its conventional test path.
101
+ *
102
+ * packages/core/src/foo/bar.ts → packages/core/tests/foo/bar.test.ts
103
+ * src/foo/bar.ts → tests/foo/bar.test.ts
104
+ */
105
+ function resolveTestPath(srcRelPath: string): string {
106
+ if (srcRelPath.includes('.test.') || srcRelPath.includes('.spec.')) return srcRelPath;
107
+ const srcIdx = srcRelPath.indexOf('/src/');
108
+ if (srcIdx !== -1) {
109
+ const pkg = srcRelPath.slice(0, srcIdx);
110
+ const rel = srcRelPath.slice(srcIdx + '/src/'.length).replace(/\.(ts|tsx|js|jsx)$/, '.test.ts');
111
+ return `${pkg}/tests/${rel}`;
112
+ }
113
+ const withoutExt = srcRelPath.replace(/\.(ts|tsx|js|jsx)$/, '');
114
+ return `tests/${withoutExt.replace(/^src\//, '')}.test.ts`;
115
+ }
@@ -0,0 +1,97 @@
1
+ import {
2
+ type ConstraintRule,
3
+ createFinding,
4
+ type RuleContext,
5
+ type RuleEvaluationResult,
6
+ type RuleEvaluator,
7
+ } from '../types';
8
+ import { discoverFiles, matchesGlob, readWorkdirFile } from './file-utils';
9
+
10
+ /** Export kinds this evaluator can check for a preceding JSDoc block. */
11
+ const VALID_KINDS = ['function', 'class', 'type', 'const', 'enum', 'interface'] as const;
12
+ type ExportKind = (typeof VALID_KINDS)[number];
13
+
14
+ /** A discovered exported declaration. */
15
+ interface ExportSite {
16
+ kind: ExportKind;
17
+ name: string;
18
+ /** One-based line number of the declaration. */
19
+ line: number;
20
+ }
21
+
22
+ const KIND_PATTERN: Record<ExportKind, RegExp> = {
23
+ function: /^export\s+(?:async\s+)?function\s+([A-Za-z0-9_$]+)/,
24
+ class: /^export\s+(?:abstract\s+)?class\s+([A-Za-z0-9_$]+)/,
25
+ interface: /^export\s+interface\s+([A-Za-z0-9_$]+)/,
26
+ type: /^export\s+type\s+([A-Za-z0-9_$]+)/,
27
+ const: /^export\s+const\s+([A-Za-z0-9_$]+)/,
28
+ enum: /^export\s+(?:const\s+)?enum\s+([A-Za-z0-9_$]+)/,
29
+ };
30
+
31
+ /**
32
+ * Evaluator that flags exported declarations missing a JSDoc block.
33
+ *
34
+ * Self-contained: it scans matching source files line-by-line and checks whether
35
+ * the line immediately preceding an export ends a JSDoc comment. Config
36
+ * (`evaluator.config.kinds`) selects which export kinds to check; `rule.include`
37
+ * and `rule.exclude` scope the files using full `**` globs.
38
+ */
39
+ export class TsdocExportEvaluator implements RuleEvaluator {
40
+ constructor() {
41
+ // V8 function coverage requires explicit constructor
42
+ }
43
+ /** Evaluate exports under the configured kinds for a preceding JSDoc block. */
44
+ async evaluate(rule: ConstraintRule, context: RuleContext): Promise<RuleEvaluationResult> {
45
+ const kinds = (rule.evaluator.config?.kinds as string[] | undefined) ?? [...VALID_KINDS];
46
+ for (const kind of kinds) {
47
+ if (!(VALID_KINDS as readonly string[]).includes(kind)) {
48
+ throw new Error(`Unknown export kind "${kind}"; expected one of: ${VALID_KINDS.join(', ')}`);
49
+ }
50
+ }
51
+ const requested = new Set(kinds as ExportKind[]);
52
+ const include = rule.include ?? ['**/*.ts', '**/*.tsx'];
53
+ const exclude = rule.exclude ?? [];
54
+ const files = await discoverFiles({ workdir: context.workdir, include: ['.ts', '.tsx'] });
55
+
56
+ const findings = [];
57
+ for (const file of files) {
58
+ if (!include.some((pattern) => matchesGlob(file, pattern))) continue;
59
+ if (exclude.some((pattern) => matchesGlob(file, pattern))) continue;
60
+ const lines = (await readWorkdirFile(context.workdir, file)).split('\n');
61
+ for (const site of findExports(lines, requested)) {
62
+ if (!precededByJsdoc(lines, site.line)) {
63
+ findings.push(
64
+ createFinding(rule, `Exported ${site.kind} "${site.name}" is missing a JSDoc comment`, file, {
65
+ line: site.line,
66
+ code: 'tsdoc:missing',
67
+ }),
68
+ );
69
+ }
70
+ }
71
+ }
72
+ return { findings, fixes: [] };
73
+ }
74
+ }
75
+
76
+ /** Find exported declarations of the requested kinds within a file's lines. */
77
+ function findExports(lines: string[], requested: ReadonlySet<ExportKind>): ExportSite[] {
78
+ const sites: ExportSite[] = [];
79
+ for (const [index, raw] of lines.entries()) {
80
+ const line = raw.trimStart();
81
+ if (!line.startsWith('export')) continue;
82
+ for (const kind of requested) {
83
+ const match = KIND_PATTERN[kind].exec(line);
84
+ if (match) {
85
+ sites.push({ kind, name: match[1] ?? 'unknown', line: index + 1 });
86
+ break;
87
+ }
88
+ }
89
+ }
90
+ return sites;
91
+ }
92
+
93
+ /** True when the line immediately above a declaration closes a JSDoc block. */
94
+ function precededByJsdoc(lines: string[], declarationLine: number): boolean {
95
+ const prev = lines[declarationLine - 2]?.trim();
96
+ return prev !== undefined && (prev.endsWith('*/') || prev.startsWith('/**'));
97
+ }
@@ -1,10 +1,13 @@
1
1
  import type { ProcessExecutor } from '@gobing-ai/ts-runtime';
2
2
  import { AgentDetectionEvaluator } from '../evaluators/agent-detection-evaluator';
3
+ import { CoverageGateEvaluator } from '../evaluators/coverage-gate-evaluator';
3
4
  import { ExitCodeEvaluator } from '../evaluators/exit-code-evaluator';
4
5
  import { ForbiddenImportEvaluator } from '../evaluators/forbidden-import-evaluator';
5
6
  import { PathEvaluator } from '../evaluators/path-evaluator';
6
7
  import { RegexEvaluator } from '../evaluators/regex-evaluator';
7
8
  import { SecretsScannerEvaluator } from '../evaluators/secrets-scanner-evaluator';
9
+ import { TestLocationEvaluator } from '../evaluators/test-location-evaluator';
10
+ import { TsdocExportEvaluator } from '../evaluators/tsdoc-export-evaluator';
8
11
  import { JsonFormatter } from '../formatters/json';
9
12
  import { TextFormatter } from '../formatters/text';
10
13
  import type { RuleEngineHost } from './rule-engine-host';
@@ -21,6 +24,9 @@ export function registerBuiltins(host: RuleEngineHost, executor?: ProcessExecuto
21
24
  host.evaluators.register('exit-code', new ExitCodeEvaluator(executor), 'builtin');
22
25
  host.evaluators.register('secrets-scanner', new SecretsScannerEvaluator(), 'builtin');
23
26
  host.evaluators.register('agent-detection', new AgentDetectionEvaluator(), 'builtin');
27
+ host.evaluators.register('coverage-gate', new CoverageGateEvaluator(), 'builtin');
28
+ host.evaluators.register('tsdoc-export', new TsdocExportEvaluator(), 'builtin');
29
+ host.evaluators.register('test-location', new TestLocationEvaluator(), 'builtin');
24
30
  host.formatters.register('text', new TextFormatter(), 'builtin');
25
31
  host.formatters.register('json', new JsonFormatter(), 'builtin');
26
32
  }