@gobing-ai/ts-rule-engine 0.2.6 → 0.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/config/extensions.d.ts +46 -0
- package/dist/config/extensions.d.ts.map +1 -0
- package/dist/config/extensions.js +63 -0
- package/dist/config/loader.js +13 -3
- package/dist/engine.d.ts +26 -1
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +79 -0
- package/dist/evaluators/exit-code-evaluator.d.ts +1 -1
- package/dist/evaluators/exit-code-evaluator.d.ts.map +1 -1
- package/dist/evaluators/exit-code-evaluator.js +22 -9
- package/dist/evaluators/forbidden-import-evaluator.d.ts +16 -3
- package/dist/evaluators/forbidden-import-evaluator.d.ts.map +1 -1
- package/dist/evaluators/forbidden-import-evaluator.js +71 -6
- package/dist/evaluators/import-boundary-evaluator.d.ts +19 -0
- package/dist/evaluators/import-boundary-evaluator.d.ts.map +1 -0
- package/dist/evaluators/import-boundary-evaluator.js +85 -0
- package/dist/evaluators/path-evaluator.d.ts +15 -2
- package/dist/evaluators/path-evaluator.d.ts.map +1 -1
- package/dist/evaluators/path-evaluator.js +49 -3
- package/dist/evaluators/regex-evaluator.d.ts.map +1 -1
- package/dist/evaluators/regex-evaluator.js +43 -8
- package/dist/evaluators/schema-artifact-evaluator.d.ts +21 -0
- package/dist/evaluators/schema-artifact-evaluator.d.ts.map +1 -0
- package/dist/evaluators/schema-artifact-evaluator.js +102 -0
- package/dist/evaluators/secrets-scanner-evaluator.d.ts +13 -2
- package/dist/evaluators/secrets-scanner-evaluator.d.ts.map +1 -1
- package/dist/evaluators/secrets-scanner-evaluator.js +72 -9
- package/dist/evaluators/sg-evaluator.d.ts +19 -0
- package/dist/evaluators/sg-evaluator.d.ts.map +1 -0
- package/dist/evaluators/sg-evaluator.js +112 -0
- package/dist/evaluators/test-location-evaluator.d.ts +14 -1
- package/dist/evaluators/test-location-evaluator.d.ts.map +1 -1
- package/dist/evaluators/test-location-evaluator.js +42 -22
- package/dist/evaluators/tsdoc-export-evaluator.js +19 -5
- package/dist/fixers/fixers.d.ts +86 -0
- package/dist/fixers/fixers.d.ts.map +1 -0
- package/dist/fixers/fixers.js +230 -0
- package/dist/fixers/test-stub-fixer.d.ts +49 -0
- package/dist/fixers/test-stub-fixer.d.ts.map +1 -0
- package/dist/fixers/test-stub-fixer.js +91 -0
- package/dist/host/builtins.d.ts.map +1 -1
- package/dist/host/builtins.js +12 -1
- package/dist/host/rule-engine-host.d.ts +3 -0
- package/dist/host/rule-engine-host.d.ts.map +1 -1
- package/dist/host/rule-engine-host.js +3 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/resolvers/test-path-resolver.d.ts +72 -0
- package/dist/resolvers/test-path-resolver.d.ts.map +1 -0
- package/dist/resolvers/test-path-resolver.js +112 -0
- package/dist/types.d.ts +30 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +8 -0
- package/package.json +3 -3
- package/src/config/extensions.ts +108 -0
- package/src/config/loader.ts +13 -3
- package/src/engine.ts +99 -2
- package/src/evaluators/exit-code-evaluator.ts +27 -9
- package/src/evaluators/forbidden-import-evaluator.ts +101 -7
- package/src/evaluators/import-boundary-evaluator.ts +135 -0
- package/src/evaluators/path-evaluator.ts +66 -3
- package/src/evaluators/regex-evaluator.ts +53 -12
- package/src/evaluators/schema-artifact-evaluator.ts +134 -0
- package/src/evaluators/secrets-scanner-evaluator.ts +89 -11
- package/src/evaluators/sg-evaluator.ts +133 -0
- package/src/evaluators/test-location-evaluator.ts +47 -35
- package/src/evaluators/tsdoc-export-evaluator.ts +19 -5
- package/src/fixers/fixers.ts +294 -0
- package/src/fixers/test-stub-fixer.ts +118 -0
- package/src/host/builtins.ts +17 -1
- package/src/host/rule-engine-host.ts +4 -0
- package/src/index.ts +4 -0
- package/src/resolvers/test-path-resolver.ts +133 -0
- package/src/types.ts +34 -0
|
@@ -7,28 +7,106 @@ import {
|
|
|
7
7
|
} from '../types';
|
|
8
8
|
import { discoverFiles, readWorkdirFile } from './file-utils';
|
|
9
9
|
|
|
10
|
-
/**
|
|
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
|
|
50
|
+
/** Evaluate files against the selected secret categories and custom patterns. */
|
|
15
51
|
async evaluate(rule: ConstraintRule, context: RuleContext): Promise<RuleEvaluationResult> {
|
|
16
|
-
const
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { CapabilityRegistry } from '../host/capability-registry';
|
|
2
|
+
import type { TestPathResolver } from '../resolvers/test-path-resolver';
|
|
1
3
|
import {
|
|
2
4
|
type ConstraintRule,
|
|
3
5
|
createFinding,
|
|
@@ -12,6 +14,7 @@ interface TestLocationConfig {
|
|
|
12
14
|
expected?: string;
|
|
13
15
|
forbid?: string[];
|
|
14
16
|
requireCorrespondingTest?: boolean;
|
|
17
|
+
resolver?: string;
|
|
15
18
|
}
|
|
16
19
|
|
|
17
20
|
/**
|
|
@@ -22,12 +25,16 @@ interface TestLocationConfig {
|
|
|
22
25
|
* - `expected`: glob the test files must match (required)
|
|
23
26
|
* - `forbid`: globs where tests must not live (e.g. `**\/__tests__/**`)
|
|
24
27
|
* - `requireCorrespondingTest`: when true, flags source files (from `rule.include`)
|
|
25
|
-
* that lack a test at the
|
|
28
|
+
* that lack a test at the resolver's conventional path
|
|
29
|
+
* - `resolver`: language resolver name (`typescript` default, or `python`/`go`/`rust`)
|
|
26
30
|
*
|
|
27
31
|
* Discovery walks the workdir and applies `**` globs precisely, so it stays
|
|
28
32
|
* self-contained (no `rg --files` shell-out).
|
|
29
33
|
*/
|
|
30
34
|
export class TestLocationEvaluator implements RuleEvaluator {
|
|
35
|
+
/** Optional resolver registry; when absent, the TypeScript convention is used. */
|
|
36
|
+
constructor(private readonly resolvers?: CapabilityRegistry<TestPathResolver>) {}
|
|
37
|
+
|
|
31
38
|
/** Evaluate test-file placement and optional coverage of source files. */
|
|
32
39
|
async evaluate(rule: ConstraintRule, context: RuleContext): Promise<RuleEvaluationResult> {
|
|
33
40
|
const config = (rule.evaluator.config ?? {}) as TestLocationConfig;
|
|
@@ -49,20 +56,15 @@ export class TestLocationEvaluator implements RuleEvaluator {
|
|
|
49
56
|
const violated = forbid.find((pattern) => matchesGlob(file, pattern));
|
|
50
57
|
if (violated !== undefined) {
|
|
51
58
|
findings.push(
|
|
52
|
-
createFinding(
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
file,
|
|
56
|
-
{
|
|
57
|
-
code: 'test-location:forbidden',
|
|
58
|
-
},
|
|
59
|
-
),
|
|
59
|
+
createFinding(rule, `test file in forbidden location (matches "${violated}")`, file, {
|
|
60
|
+
code: 'test-location:forbidden',
|
|
61
|
+
}),
|
|
60
62
|
);
|
|
61
63
|
continue;
|
|
62
64
|
}
|
|
63
65
|
if (!matchesGlob(file, expected)) {
|
|
64
66
|
findings.push(
|
|
65
|
-
createFinding(rule, `
|
|
67
|
+
createFinding(rule, `test file outside expected location (expected "${expected}")`, file, {
|
|
66
68
|
code: 'test-location:unexpected',
|
|
67
69
|
}),
|
|
68
70
|
);
|
|
@@ -70,23 +72,19 @@ export class TestLocationEvaluator implements RuleEvaluator {
|
|
|
70
72
|
}
|
|
71
73
|
|
|
72
74
|
if (config.requireCorrespondingTest) {
|
|
75
|
+
const resolver = this.selectResolver(config.resolver);
|
|
73
76
|
const srcPatterns = rule.include ?? ['**/*.ts', '**/*.tsx'];
|
|
74
77
|
const testSet = new Set(testFiles);
|
|
75
78
|
for (const srcFile of allFiles) {
|
|
76
79
|
if (!srcPatterns.some((pattern) => matchesGlob(srcFile, pattern))) continue;
|
|
77
80
|
if (exclude.some((pattern) => matchesGlob(srcFile, pattern))) continue;
|
|
78
|
-
const testPath = resolveTestPath(srcFile);
|
|
81
|
+
const testPath = resolver.resolveTestPath(srcFile);
|
|
79
82
|
if (testPath === srcFile) continue;
|
|
80
83
|
if (!testSet.has(testPath)) {
|
|
81
84
|
findings.push(
|
|
82
|
-
createFinding(
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
srcFile,
|
|
86
|
-
{
|
|
87
|
-
code: 'test-location:missing',
|
|
88
|
-
},
|
|
89
|
-
),
|
|
85
|
+
createFinding(rule, `no corresponding test → ${testPath}`, srcFile, {
|
|
86
|
+
code: 'test-location:missing',
|
|
87
|
+
}),
|
|
90
88
|
);
|
|
91
89
|
}
|
|
92
90
|
}
|
|
@@ -94,22 +92,36 @@ export class TestLocationEvaluator implements RuleEvaluator {
|
|
|
94
92
|
|
|
95
93
|
return { findings, fixes: [] };
|
|
96
94
|
}
|
|
97
|
-
}
|
|
98
95
|
|
|
99
|
-
/**
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
96
|
+
/**
|
|
97
|
+
* Pick the resolver named in config (default `typescript`).
|
|
98
|
+
*
|
|
99
|
+
* Falls back to the built-in TypeScript convention when no registry was
|
|
100
|
+
* injected; throws if a named resolver is requested but not registered.
|
|
101
|
+
*/
|
|
102
|
+
private selectResolver(name = 'typescript'): TestPathResolver {
|
|
103
|
+
if (this.resolvers === undefined) {
|
|
104
|
+
if (name !== 'typescript') {
|
|
105
|
+
throw new Error(`test-location resolver "${name}" requested but no resolver registry is available`);
|
|
106
|
+
}
|
|
107
|
+
return TYPESCRIPT_FALLBACK;
|
|
108
|
+
}
|
|
109
|
+
return this.resolvers.get(name);
|
|
112
110
|
}
|
|
113
|
-
const withoutExt = srcRelPath.replace(/\.(ts|tsx|js|jsx)$/, '');
|
|
114
|
-
return `tests/${withoutExt.replace(/^src\//, '')}.test.ts`;
|
|
115
111
|
}
|
|
112
|
+
|
|
113
|
+
/** Built-in TypeScript convention used when no resolver registry is injected. */
|
|
114
|
+
const TYPESCRIPT_FALLBACK: TestPathResolver = {
|
|
115
|
+
name: 'typescript',
|
|
116
|
+
resolveTestPath(srcRelPath: string): string {
|
|
117
|
+
if (srcRelPath.includes('.test.') || srcRelPath.includes('.spec.')) return srcRelPath;
|
|
118
|
+
const srcIdx = srcRelPath.indexOf('/src/');
|
|
119
|
+
if (srcIdx !== -1) {
|
|
120
|
+
const pkg = srcRelPath.slice(0, srcIdx);
|
|
121
|
+
const rel = srcRelPath.slice(srcIdx + '/src/'.length).replace(/\.(ts|tsx|js|jsx)$/, '.test.ts');
|
|
122
|
+
return `${pkg}/tests/${rel}`;
|
|
123
|
+
}
|
|
124
|
+
const withoutExt = srcRelPath.replace(/\.(ts|tsx|js|jsx)$/, '');
|
|
125
|
+
return `tests/${withoutExt.replace(/^src\//, '')}.test.ts`;
|
|
126
|
+
},
|
|
127
|
+
};
|
|
@@ -20,8 +20,8 @@ interface ExportSite {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
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_$]+)/,
|
|
23
|
+
function: /^export\s+(?:default\s+)?(?:async\s+)?function\s*\*?\s+([A-Za-z0-9_$]+)/,
|
|
24
|
+
class: /^export\s+(?:default\s+)?(?:abstract\s+)?class\s+([A-Za-z0-9_$]+)/,
|
|
25
25
|
interface: /^export\s+interface\s+([A-Za-z0-9_$]+)/,
|
|
26
26
|
type: /^export\s+type\s+([A-Za-z0-9_$]+)/,
|
|
27
27
|
const: /^export\s+const\s+([A-Za-z0-9_$]+)/,
|
|
@@ -90,8 +90,22 @@ function findExports(lines: string[], requested: ReadonlySet<ExportKind>): Expor
|
|
|
90
90
|
return sites;
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
/**
|
|
93
|
+
/**
|
|
94
|
+
* True when a JSDoc block precedes a declaration.
|
|
95
|
+
*
|
|
96
|
+
* Skips decorator lines (`@Component(...)`) so a documented but decorated class
|
|
97
|
+
* is not falsely flagged, then checks whether the nearest preceding non-decorator
|
|
98
|
+
* line closes (or is) a JSDoc comment.
|
|
99
|
+
*/
|
|
94
100
|
function precededByJsdoc(lines: string[], declarationLine: number): boolean {
|
|
95
|
-
|
|
96
|
-
|
|
101
|
+
let cursor = declarationLine - 2; // zero-based line above the declaration
|
|
102
|
+
while (cursor >= 0) {
|
|
103
|
+
const prev = lines[cursor]?.trim() ?? '';
|
|
104
|
+
if (prev.startsWith('@')) {
|
|
105
|
+
cursor -= 1; // decorator — keep walking up
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
return prev.endsWith('*/') || prev.startsWith('/**');
|
|
109
|
+
}
|
|
110
|
+
return false;
|
|
97
111
|
}
|