@gobing-ai/ts-rule-engine 0.3.20 → 0.3.21
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/loader.d.ts +3 -0
- package/dist/config/loader.d.ts.map +1 -1
- package/dist/config/loader.js +3 -4
- package/dist/engine.d.ts +8 -1
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +6 -3
- package/dist/evaluators/config-access.d.ts +38 -0
- package/dist/evaluators/config-access.d.ts.map +1 -0
- package/dist/evaluators/config-access.js +60 -0
- package/dist/evaluators/coverage-gate-evaluator.d.ts +1 -2
- package/dist/evaluators/coverage-gate-evaluator.d.ts.map +1 -1
- package/dist/evaluators/coverage-gate-evaluator.js +7 -6
- package/dist/evaluators/file-discovery.d.ts +72 -0
- package/dist/evaluators/file-discovery.d.ts.map +1 -0
- package/dist/evaluators/file-discovery.js +63 -0
- package/dist/evaluators/file-utils.d.ts +11 -118
- package/dist/evaluators/file-utils.d.ts.map +1 -1
- package/dist/evaluators/file-utils.js +11 -177
- package/dist/evaluators/forbidden-import-evaluator.d.ts +1 -0
- package/dist/evaluators/forbidden-import-evaluator.d.ts.map +1 -1
- package/dist/evaluators/forbidden-import-evaluator.js +9 -1
- package/dist/evaluators/glob-match.d.ts +34 -0
- package/dist/evaluators/glob-match.d.ts.map +1 -0
- package/dist/evaluators/glob-match.js +78 -0
- package/dist/evaluators/import-boundary-evaluator.d.ts.map +1 -1
- package/dist/evaluators/import-boundary-evaluator.js +3 -1
- package/dist/evaluators/path-evaluator.d.ts +1 -2
- package/dist/evaluators/path-evaluator.d.ts.map +1 -1
- package/dist/evaluators/path-evaluator.js +8 -6
- package/dist/evaluators/regex-evaluator.d.ts.map +1 -1
- package/dist/evaluators/regex-evaluator.js +3 -0
- package/dist/evaluators/schema-artifact-evaluator.d.ts +1 -2
- package/dist/evaluators/schema-artifact-evaluator.d.ts.map +1 -1
- package/dist/evaluators/schema-artifact-evaluator.js +8 -7
- package/dist/evaluators/secrets-scanner-evaluator.d.ts.map +1 -1
- package/dist/evaluators/secrets-scanner-evaluator.js +3 -1
- package/dist/evaluators/test-location-evaluator.d.ts.map +1 -1
- package/dist/evaluators/test-location-evaluator.js +3 -1
- package/dist/evaluators/tsdoc-export-evaluator.d.ts.map +1 -1
- package/dist/evaluators/tsdoc-export-evaluator.js +3 -1
- package/dist/fixers/fixers.d.ts +2 -4
- package/dist/fixers/fixers.d.ts.map +1 -1
- package/dist/fixers/fixers.js +9 -11
- package/dist/fixers/test-stub-fixer.d.ts +1 -1
- package/dist/fixers/test-stub-fixer.d.ts.map +1 -1
- package/dist/fixers/test-stub-fixer.js +2 -2
- package/dist/host/bundled-rules.d.ts +2 -2
- package/dist/host/bundled-rules.d.ts.map +1 -1
- package/dist/host/bundled-rules.js +24 -19
- package/dist/types.d.ts +8 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/config/loader.ts +14 -6
- package/src/engine.ts +12 -4
- package/src/evaluators/config-access.ts +76 -0
- package/src/evaluators/coverage-gate-evaluator.ts +7 -8
- package/src/evaluators/file-discovery.ts +132 -0
- package/src/evaluators/file-utils.ts +29 -258
- package/src/evaluators/forbidden-import-evaluator.ts +10 -2
- package/src/evaluators/glob-match.ts +76 -0
- package/src/evaluators/import-boundary-evaluator.ts +3 -1
- package/src/evaluators/path-evaluator.ts +8 -7
- package/src/evaluators/regex-evaluator.ts +3 -0
- package/src/evaluators/schema-artifact-evaluator.ts +9 -8
- package/src/evaluators/secrets-scanner-evaluator.ts +3 -1
- package/src/evaluators/test-location-evaluator.ts +5 -2
- package/src/evaluators/tsdoc-export-evaluator.ts +3 -1
- package/src/fixers/fixers.ts +11 -12
- package/src/fixers/test-stub-fixer.ts +3 -3
- package/src/host/bundled-rules.ts +23 -19
- package/src/types.ts +8 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File discovery and reading for evaluators — the shared scaffolding behind the
|
|
3
|
+
* line-scanning rules. Owns directory walks, scope filtering, and per-file reads
|
|
4
|
+
* so each evaluator is left with only its own matcher.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
createNodeFileSystem,
|
|
9
|
+
dirnamePath,
|
|
10
|
+
type FileSystem,
|
|
11
|
+
relativePath,
|
|
12
|
+
resolvePath,
|
|
13
|
+
walkDir,
|
|
14
|
+
} from '@gobing-ai/ts-runtime';
|
|
15
|
+
import { matchesAny, matchesGlob } from './glob-match';
|
|
16
|
+
|
|
17
|
+
/** Options for source-file discovery. */
|
|
18
|
+
export interface SourceDiscoveryOptions {
|
|
19
|
+
/** Working directory. */
|
|
20
|
+
workdir: string;
|
|
21
|
+
/** Include path fragments or suffixes. */
|
|
22
|
+
include?: string[];
|
|
23
|
+
/** Exclude path fragments. */
|
|
24
|
+
exclude?: string[];
|
|
25
|
+
/** Filesystem adapter. */
|
|
26
|
+
fs?: FileSystem;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Directory names pruned from every file walk — heavy or generated trees that no rule
|
|
31
|
+
* should scan. Shared so subprocess-backed evaluators (e.g. `sg`) can forward the same
|
|
32
|
+
* skip-list to the external tool instead of relying on each rule to remember it.
|
|
33
|
+
*/
|
|
34
|
+
export const DEFAULT_EXCLUDES = new Set(['.git', 'node_modules', 'dist', '.coverage', '.astro', '.wrangler']);
|
|
35
|
+
|
|
36
|
+
/** Resolve source files for evaluators using conservative path-fragment matching. */
|
|
37
|
+
export async function discoverFiles(options: SourceDiscoveryOptions): Promise<string[]> {
|
|
38
|
+
const fs = options.fs ?? createNodeFileSystem();
|
|
39
|
+
const allFiles = await walkDir(options.workdir, fs, DEFAULT_EXCLUDES);
|
|
40
|
+
return allFiles
|
|
41
|
+
.map((path) => relativePath(options.workdir, path))
|
|
42
|
+
.filter((path) => !path.split('/').some((segment) => DEFAULT_EXCLUDES.has(segment)))
|
|
43
|
+
.filter(
|
|
44
|
+
(path) =>
|
|
45
|
+
matchesAny(path, options.include) &&
|
|
46
|
+
(options.exclude === undefined || !matchesAny(path, options.exclude)),
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Read a file from a workdir-relative path. */
|
|
51
|
+
export async function readWorkdirFile(workdir: string, filePath: string, fs = createNodeFileSystem()): Promise<string> {
|
|
52
|
+
return await fs.readFile(resolvePath(workdir, filePath));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A discovered in-scope file paired with its contents. */
|
|
56
|
+
export interface ScannedFile {
|
|
57
|
+
/** Workdir-relative path. */
|
|
58
|
+
readonly file: string;
|
|
59
|
+
/** Full file contents. */
|
|
60
|
+
readonly content: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** How `scanFiles` matches `include` / `exclude` against discovered paths. */
|
|
64
|
+
export type ScanMatchMode = 'loose' | 'glob';
|
|
65
|
+
|
|
66
|
+
/** Options for {@link scanFiles}. */
|
|
67
|
+
export interface ScanFilesOptions {
|
|
68
|
+
/** Working directory to walk. */
|
|
69
|
+
workdir: string;
|
|
70
|
+
/** Include patterns; semantics depend on `matchMode`. Undefined/empty = all files. */
|
|
71
|
+
include?: string[];
|
|
72
|
+
/** Exclude patterns; semantics depend on `matchMode`. */
|
|
73
|
+
exclude?: string[];
|
|
74
|
+
/**
|
|
75
|
+
* Scope matching policy:
|
|
76
|
+
* - `loose` — substring/suffix fragments via {@link matchesAny} (back-compat for
|
|
77
|
+
* evaluators that historically accepted bare fragments like `.ts` or `src/`).
|
|
78
|
+
* - `glob` — anchored `**`/`*` globs via {@link matchesGlob}.
|
|
79
|
+
*
|
|
80
|
+
* The two are NOT interchangeable: a bare `src/` matches different sets under each.
|
|
81
|
+
* Each evaluator declares the mode that preserves its existing behavior.
|
|
82
|
+
*/
|
|
83
|
+
matchMode: ScanMatchMode;
|
|
84
|
+
/** Filesystem adapter. */
|
|
85
|
+
fs?: FileSystem;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Discover in-scope files and read each once — the shared scaffolding behind the
|
|
90
|
+
* line-scanning evaluators. Owns discovery, scope filtering (per `matchMode`), and
|
|
91
|
+
* reads, so each evaluator is left with only its own matcher.
|
|
92
|
+
*
|
|
93
|
+
* Scope is a parameter, not assumed one-per-rule: callers that scan under several
|
|
94
|
+
* scopes (e.g. import boundaries) pass no `include` here and apply their own globs to
|
|
95
|
+
* the returned paths.
|
|
96
|
+
*/
|
|
97
|
+
export async function scanFiles(options: ScanFilesOptions): Promise<ScannedFile[]> {
|
|
98
|
+
const fs = options.fs ?? createNodeFileSystem();
|
|
99
|
+
const files =
|
|
100
|
+
options.matchMode === 'loose'
|
|
101
|
+
? await discoverFiles({ workdir: options.workdir, include: options.include, exclude: options.exclude, fs })
|
|
102
|
+
: await discoverFilesByGlob(options.workdir, options.include, options.exclude, fs);
|
|
103
|
+
const scanned: ScannedFile[] = [];
|
|
104
|
+
for (const file of files) {
|
|
105
|
+
scanned.push({ file, content: await readWorkdirFile(options.workdir, file, fs) });
|
|
106
|
+
}
|
|
107
|
+
return scanned;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Discover files then filter with anchored globs (strict mode for {@link scanFiles}). */
|
|
111
|
+
async function discoverFilesByGlob(
|
|
112
|
+
workdir: string,
|
|
113
|
+
include: string[] | undefined,
|
|
114
|
+
exclude: string[] | undefined,
|
|
115
|
+
fs: FileSystem,
|
|
116
|
+
): Promise<string[]> {
|
|
117
|
+
const all = await discoverFiles({ workdir, fs });
|
|
118
|
+
return all
|
|
119
|
+
.filter((file) => include === undefined || include.length === 0 || include.some((g) => matchesGlob(file, g)))
|
|
120
|
+
.filter((file) => exclude === undefined || !exclude.some((g) => matchesGlob(file, g)));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Ensure a path is workdir-relative for findings. */
|
|
124
|
+
export function relativeToWorkdir(workdir: string, path: string): string {
|
|
125
|
+
return relativePath(workdir, resolvePath(path));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Return parent directory for a workdir-relative path. */
|
|
129
|
+
export function relativeParent(path: string): string {
|
|
130
|
+
const parent = dirnamePath(path);
|
|
131
|
+
return parent === '.' ? '' : parent;
|
|
132
|
+
}
|
|
@@ -1,263 +1,34 @@
|
|
|
1
|
-
import {
|
|
2
|
-
dirnamePath,
|
|
3
|
-
type LegacyFileSystem as FileSystem,
|
|
4
|
-
NodeFileSystem,
|
|
5
|
-
relativePath,
|
|
6
|
-
resolvePath,
|
|
7
|
-
walkDir,
|
|
8
|
-
} from '@gobing-ai/ts-runtime';
|
|
9
|
-
|
|
10
|
-
/** Options for source-file discovery. */
|
|
11
|
-
export interface SourceDiscoveryOptions {
|
|
12
|
-
/** Working directory. */
|
|
13
|
-
workdir: string;
|
|
14
|
-
/** Include path fragments or suffixes. */
|
|
15
|
-
include?: string[];
|
|
16
|
-
/** Exclude path fragments. */
|
|
17
|
-
exclude?: string[];
|
|
18
|
-
/** Filesystem adapter. */
|
|
19
|
-
fs?: FileSystem;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Directory names pruned from every file walk — heavy or generated trees that no rule
|
|
24
|
-
* should scan. Shared so subprocess-backed evaluators (e.g. `sg`) can forward the same
|
|
25
|
-
* skip-list to the external tool instead of relying on each rule to remember it.
|
|
26
|
-
*/
|
|
27
|
-
export const DEFAULT_EXCLUDES = new Set(['.git', 'node_modules', 'dist', '.coverage', '.astro', '.wrangler']);
|
|
28
|
-
|
|
29
|
-
/** Resolve source files for evaluators using conservative path-fragment matching. */
|
|
30
|
-
export async function discoverFiles(options: SourceDiscoveryOptions): Promise<string[]> {
|
|
31
|
-
const fs = options.fs ?? new NodeFileSystem();
|
|
32
|
-
const allFiles = await walkDir(options.workdir, fs, DEFAULT_EXCLUDES);
|
|
33
|
-
return allFiles
|
|
34
|
-
.map((path) => relativePath(options.workdir, path))
|
|
35
|
-
.filter((path) => !path.split('/').some((segment) => DEFAULT_EXCLUDES.has(segment)))
|
|
36
|
-
.filter(
|
|
37
|
-
(path) =>
|
|
38
|
-
matchesAny(path, options.include) &&
|
|
39
|
-
(options.exclude === undefined || !matchesAny(path, options.exclude)),
|
|
40
|
-
);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** Read a file from a workdir-relative path. */
|
|
44
|
-
export async function readWorkdirFile(workdir: string, filePath: string, fs = new NodeFileSystem()): Promise<string> {
|
|
45
|
-
return await fs.readFile(resolvePath(workdir, filePath));
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** A discovered in-scope file paired with its contents. */
|
|
49
|
-
export interface ScannedFile {
|
|
50
|
-
/** Workdir-relative path. */
|
|
51
|
-
readonly file: string;
|
|
52
|
-
/** Full file contents. */
|
|
53
|
-
readonly content: string;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** How `scanFiles` matches `include` / `exclude` against discovered paths. */
|
|
57
|
-
export type ScanMatchMode = 'loose' | 'glob';
|
|
58
|
-
|
|
59
|
-
/** Options for {@link scanFiles}. */
|
|
60
|
-
export interface ScanFilesOptions {
|
|
61
|
-
/** Working directory to walk. */
|
|
62
|
-
workdir: string;
|
|
63
|
-
/** Include patterns; semantics depend on `matchMode`. Undefined/empty = all files. */
|
|
64
|
-
include?: string[];
|
|
65
|
-
/** Exclude patterns; semantics depend on `matchMode`. */
|
|
66
|
-
exclude?: string[];
|
|
67
|
-
/**
|
|
68
|
-
* Scope matching policy:
|
|
69
|
-
* - `loose` — substring/suffix fragments via {@link matchesAny} (back-compat for
|
|
70
|
-
* evaluators that historically accepted bare fragments like `.ts` or `src/`).
|
|
71
|
-
* - `glob` — anchored `**`/`*` globs via {@link matchesGlob}.
|
|
72
|
-
*
|
|
73
|
-
* The two are NOT interchangeable: a bare `src/` matches different sets under each.
|
|
74
|
-
* Each evaluator declares the mode that preserves its existing behavior.
|
|
75
|
-
*/
|
|
76
|
-
matchMode: ScanMatchMode;
|
|
77
|
-
/** Filesystem adapter. */
|
|
78
|
-
fs?: FileSystem;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
1
|
/**
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
2
|
+
* Aggregating barrel for evaluator file utilities — kept so existing imports
|
|
3
|
+
* (`./file-utils`) stay stable. The implementation now lives in three focused
|
|
4
|
+
* modules by concern:
|
|
85
5
|
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*/
|
|
90
|
-
export async function scanFiles(options: ScanFilesOptions): Promise<ScannedFile[]> {
|
|
91
|
-
const fs = options.fs ?? new NodeFileSystem();
|
|
92
|
-
const files =
|
|
93
|
-
options.matchMode === 'loose'
|
|
94
|
-
? await discoverFiles({ workdir: options.workdir, include: options.include, exclude: options.exclude, fs })
|
|
95
|
-
: await discoverFilesByGlob(options.workdir, options.include, options.exclude, fs);
|
|
96
|
-
const scanned: ScannedFile[] = [];
|
|
97
|
-
for (const file of files) {
|
|
98
|
-
scanned.push({ file, content: await readWorkdirFile(options.workdir, file, fs) });
|
|
99
|
-
}
|
|
100
|
-
return scanned;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/** Discover files then filter with anchored globs (strict mode for {@link scanFiles}). */
|
|
104
|
-
async function discoverFilesByGlob(
|
|
105
|
-
workdir: string,
|
|
106
|
-
include: string[] | undefined,
|
|
107
|
-
exclude: string[] | undefined,
|
|
108
|
-
fs: FileSystem,
|
|
109
|
-
): Promise<string[]> {
|
|
110
|
-
const all = await discoverFiles({ workdir, fs });
|
|
111
|
-
return all
|
|
112
|
-
.filter((file) => include === undefined || include.length === 0 || include.some((g) => matchesGlob(file, g)))
|
|
113
|
-
.filter((file) => exclude === undefined || !exclude.some((g) => matchesGlob(file, g)));
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/** Ensure a path is workdir-relative for findings. */
|
|
117
|
-
export function relativeToWorkdir(workdir: string, path: string): string {
|
|
118
|
-
return relativePath(workdir, resolvePath(path));
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/** Return parent directory for a workdir-relative path. */
|
|
122
|
-
export function relativeParent(path: string): string {
|
|
123
|
-
const parent = dirnamePath(path);
|
|
124
|
-
return parent === '.' ? '' : parent;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* Return true when a path matches any supplied pattern.
|
|
129
|
-
*
|
|
130
|
-
* Each pattern is matched in two passes, most-specific first:
|
|
131
|
-
* 1. If it contains a glob metachar (`*`), try anchored {@link matchesGlob} —
|
|
132
|
-
* so `packages/**\/*.ts` resolves segment-by-segment instead of collapsing
|
|
133
|
-
* to a bare fragment (the latter silently matched nothing, leaving every
|
|
134
|
-
* glob-scoped loose-mode rule scanning zero files).
|
|
135
|
-
* 2. Otherwise (or if the glob did not match), fall back to the legacy
|
|
136
|
-
* fragment/suffix substring test, where stars are stripped — preserving
|
|
137
|
-
* bare fragments like `.ts`, `src/`, `/tests/` that loose-mode evaluators
|
|
138
|
-
* have always accepted, and keeping unanchored patterns like `*.ts`
|
|
139
|
-
* (→ suffix `.ts`) matching nested paths.
|
|
140
|
-
*/
|
|
141
|
-
export function matchesAny(path: string, patterns: string[] | undefined): boolean {
|
|
142
|
-
if (patterns === undefined || patterns.length === 0) return true;
|
|
143
|
-
const normalizedPath = path.replaceAll('\\', '/');
|
|
144
|
-
return patterns.some((pattern) => {
|
|
145
|
-
const normalized = pattern.replaceAll('\\', '/');
|
|
146
|
-
if (normalized.includes('*') && matchesGlob(normalizedPath, normalized)) return true;
|
|
147
|
-
const clean = normalized.replaceAll('**/', '').replaceAll('*', '');
|
|
148
|
-
return clean.length === 0 || normalizedPath.includes(clean) || normalizedPath.endsWith(clean);
|
|
149
|
-
});
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* Segment-aware glob matching with `**` (any depth) and `*` (single segment).
|
|
6
|
+
* - `./glob-match` — path-pattern matching (`matchesAny`, `matchesGlob`, `escapeRegExp`)
|
|
7
|
+
* - `./file-discovery` — directory walks, scope filtering, per-file reads
|
|
8
|
+
* - `./config-access` — typed `evaluator.config` accessors + inline-flag parsing
|
|
154
9
|
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
* (coverage scoping, test-file location) where a loose match would change findings.
|
|
158
|
-
*/
|
|
159
|
-
export function matchesGlob(path: string, pattern: string): boolean {
|
|
160
|
-
const normalized = path.replaceAll('\\', '/');
|
|
161
|
-
if (pattern.startsWith('**/')) {
|
|
162
|
-
const suffix = pattern.slice(3);
|
|
163
|
-
if (suffix.indexOf('*') === -1) {
|
|
164
|
-
return normalized.endsWith(suffix) || normalized.endsWith(`/${suffix}`);
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
if (pattern === normalized) return true;
|
|
168
|
-
return matchSegments(normalized.split('/'), pattern.split('/'), 0, 0);
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/** Recursive segment-level glob matcher backing {@link matchesGlob}. */
|
|
172
|
-
function matchSegments(file: string[], pattern: string[], fi: number, pi: number): boolean {
|
|
173
|
-
if (pi >= pattern.length) return fi >= file.length;
|
|
174
|
-
if (fi >= file.length) return pattern.slice(pi).every((segment) => segment === '**');
|
|
175
|
-
const pat = pattern[pi] ?? '';
|
|
176
|
-
if (pat === '**') {
|
|
177
|
-
return matchSegments(file, pattern, fi, pi + 1) || matchSegments(file, pattern, fi + 1, pi);
|
|
178
|
-
}
|
|
179
|
-
if (!matchSegment(file[fi] ?? '', pat)) return false;
|
|
180
|
-
return matchSegments(file, pattern, fi + 1, pi + 1);
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
/** Match one path segment against a pattern segment where `*` matches any run of non-`/` chars. */
|
|
184
|
-
function matchSegment(segment: string, pattern: string): boolean {
|
|
185
|
-
if (pattern.indexOf('*') === -1) return segment === pattern;
|
|
186
|
-
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replaceAll('*', '[^/]*');
|
|
187
|
-
return new RegExp(`^${escaped}$`).test(segment);
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/** Escape a string for safe literal use inside a `RegExp` source. */
|
|
191
|
-
export function escapeRegExp(value: string): string {
|
|
192
|
-
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/** Return the value as a `string[]` when every item is a string, otherwise undefined. */
|
|
196
|
-
export function stringArray(value: unknown): string[] | undefined {
|
|
197
|
-
return Array.isArray(value) && value.every((item) => typeof item === 'string') ? (value as string[]) : undefined;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
/** Optional accessor context — names the evaluator in "required config" errors for rule authors. */
|
|
201
|
-
export interface ConfigAccessorOptions {
|
|
202
|
-
/** Evaluator name surfaced in the error, e.g. `"regex evaluator requires string config \"pattern\""`. */
|
|
203
|
-
evaluator?: string;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
function requiredConfigError(kind: string, key: string, evaluator?: string): Error {
|
|
207
|
-
const who = evaluator !== undefined ? `${evaluator} evaluator` : 'evaluator';
|
|
208
|
-
return new Error(`${who} requires ${kind} config "${key}"`);
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
/**
|
|
212
|
-
* Read a string entry from a rule's evaluator config. Returns the value when it is a string,
|
|
213
|
-
* the `fallback` when one is supplied, otherwise throws — so required keys fail loudly.
|
|
214
|
-
*/
|
|
215
|
-
export function configString(
|
|
216
|
-
config: Record<string, unknown>,
|
|
217
|
-
key: string,
|
|
218
|
-
fallback?: string,
|
|
219
|
-
options: ConfigAccessorOptions = {},
|
|
220
|
-
): string {
|
|
221
|
-
const value = config[key];
|
|
222
|
-
if (typeof value === 'string') return value;
|
|
223
|
-
if (fallback !== undefined) return fallback;
|
|
224
|
-
throw requiredConfigError('string', key, options.evaluator);
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
/**
|
|
228
|
-
* Read a string-array entry from a rule's evaluator config. A bare string is coerced to a
|
|
229
|
-
* single-element array. Returns the `fallback` when one is supplied and the value is absent;
|
|
230
|
-
* otherwise throws — so required keys fail loudly.
|
|
10
|
+
* Prefer importing from the specific module in new code; this barrel exists for
|
|
11
|
+
* back-compat and as a convenience surface.
|
|
231
12
|
*/
|
|
232
|
-
export function configArray(
|
|
233
|
-
config: Record<string, unknown>,
|
|
234
|
-
key: string,
|
|
235
|
-
fallback?: string[],
|
|
236
|
-
options: ConfigAccessorOptions = {},
|
|
237
|
-
): string[] {
|
|
238
|
-
const value = config[key];
|
|
239
|
-
if (Array.isArray(value) && value.every((item) => typeof item === 'string')) return value as string[];
|
|
240
|
-
if (typeof value === 'string') return [value];
|
|
241
|
-
if (fallback !== undefined) return fallback;
|
|
242
|
-
throw requiredConfigError('string[]', key, options.evaluator);
|
|
243
|
-
}
|
|
244
13
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
14
|
+
export {
|
|
15
|
+
type ConfigAccessorOptions,
|
|
16
|
+
configArray,
|
|
17
|
+
configNumber,
|
|
18
|
+
configString,
|
|
19
|
+
parseInlineFlags,
|
|
20
|
+
stringArray,
|
|
21
|
+
} from './config-access';
|
|
22
|
+
export {
|
|
23
|
+
DEFAULT_EXCLUDES,
|
|
24
|
+
discoverFiles,
|
|
25
|
+
readWorkdirFile,
|
|
26
|
+
relativeParent,
|
|
27
|
+
relativeToWorkdir,
|
|
28
|
+
type ScanFilesOptions,
|
|
29
|
+
type ScanMatchMode,
|
|
30
|
+
type ScannedFile,
|
|
31
|
+
type SourceDiscoveryOptions,
|
|
32
|
+
scanFiles,
|
|
33
|
+
} from './file-discovery';
|
|
34
|
+
export { escapeRegExp, matchesAny, matchesGlob } from './glob-match';
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createNodeFileSystem } from '@gobing-ai/ts-runtime';
|
|
1
2
|
import {
|
|
2
3
|
type ConstraintRule,
|
|
3
4
|
createFinding,
|
|
@@ -36,6 +37,11 @@ interface ScanEntry {
|
|
|
36
37
|
*/
|
|
37
38
|
export class ForbiddenImportEvaluator implements RuleEvaluator {
|
|
38
39
|
/** Evaluate import/usage against the configured forbidden set. */
|
|
40
|
+
// Explicit constructor avoids a V8 function-coverage phantom (synthesized default ctor).
|
|
41
|
+
constructor() {
|
|
42
|
+
// no-op; all state comes from evaluate() context
|
|
43
|
+
}
|
|
44
|
+
|
|
39
45
|
async evaluate(rule: ConstraintRule, context: RuleContext): Promise<RuleEvaluationResult> {
|
|
40
46
|
const config = rule.evaluator.config ?? {};
|
|
41
47
|
return Array.isArray(config.forbidden)
|
|
@@ -50,11 +56,13 @@ export class ForbiddenImportEvaluator implements RuleEvaluator {
|
|
|
50
56
|
config: Record<string, unknown>,
|
|
51
57
|
): Promise<RuleEvaluationResult> {
|
|
52
58
|
const forbidden = configArray(config, 'patterns', undefined, { evaluator: 'forbidden-import' });
|
|
59
|
+
const fs = context.fileSystem ?? createNodeFileSystem();
|
|
53
60
|
const files = await scanFiles({
|
|
54
61
|
workdir: context.workdir,
|
|
55
62
|
include: rule.include ?? ['.ts', '.tsx', '.js', '.jsx'],
|
|
56
63
|
exclude: rule.exclude,
|
|
57
64
|
matchMode: 'loose',
|
|
65
|
+
fs,
|
|
58
66
|
});
|
|
59
67
|
const findings = [];
|
|
60
68
|
for (const { file, content } of files) {
|
|
@@ -89,10 +97,10 @@ export class ForbiddenImportEvaluator implements RuleEvaluator {
|
|
|
89
97
|
}
|
|
90
98
|
const exclude = stringArray(scope?.exclude) ?? [];
|
|
91
99
|
const entries = (config.forbidden as ForbiddenEntry[]).map(compileEntry);
|
|
100
|
+
const fs = context.fileSystem ?? createNodeFileSystem();
|
|
92
101
|
|
|
93
102
|
// Anchored `**`-glob scoping: scanFiles' 'glob' mode applies matchesGlob precisely.
|
|
94
|
-
const files = await scanFiles({ workdir: context.workdir, include, exclude, matchMode: 'glob' });
|
|
95
|
-
|
|
103
|
+
const files = await scanFiles({ workdir: context.workdir, include, exclude, matchMode: 'glob', fs });
|
|
96
104
|
const findings = [];
|
|
97
105
|
for (const { file, content } of files) {
|
|
98
106
|
const lines = content.split('\n');
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path-pattern matching for evaluators — two deliberately distinct algorithms.
|
|
3
|
+
*
|
|
4
|
+
* {@link matchesAny} is the loose, back-compatible fragment/suffix matcher;
|
|
5
|
+
* {@link matchesGlob} is the strict, anchored segment-aware glob matcher. They
|
|
6
|
+
* are NOT interchangeable (a bare `src/` matches different sets under each), so
|
|
7
|
+
* each evaluator picks the one that preserves its existing behavior.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Return true when a path matches any supplied pattern.
|
|
12
|
+
*
|
|
13
|
+
* Each pattern is matched in two passes, most-specific first:
|
|
14
|
+
* 1. If it contains a glob metachar (`*`), try anchored {@link matchesGlob} —
|
|
15
|
+
* so `packages/**\/*.ts` resolves segment-by-segment instead of collapsing
|
|
16
|
+
* to a bare fragment (the latter silently matched nothing, leaving every
|
|
17
|
+
* glob-scoped loose-mode rule scanning zero files).
|
|
18
|
+
* 2. Otherwise (or if the glob did not match), fall back to the legacy
|
|
19
|
+
* fragment/suffix substring test, where stars are stripped — preserving
|
|
20
|
+
* bare fragments like `.ts`, `src/`, `/tests/` that loose-mode evaluators
|
|
21
|
+
* have always accepted, and keeping unanchored patterns like `*.ts`
|
|
22
|
+
* (→ suffix `.ts`) matching nested paths.
|
|
23
|
+
*/
|
|
24
|
+
export function matchesAny(path: string, patterns: string[] | undefined): boolean {
|
|
25
|
+
if (patterns === undefined || patterns.length === 0) return true;
|
|
26
|
+
const normalizedPath = path.replaceAll('\\', '/');
|
|
27
|
+
return patterns.some((pattern) => {
|
|
28
|
+
const normalized = pattern.replaceAll('\\', '/');
|
|
29
|
+
if (normalized.includes('*') && matchesGlob(normalizedPath, normalized)) return true;
|
|
30
|
+
const clean = normalized.replaceAll('**/', '').replaceAll('*', '');
|
|
31
|
+
return clean.length === 0 || normalizedPath.includes(clean) || normalizedPath.endsWith(clean);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Segment-aware glob matching with `**` (any depth) and `*` (single segment).
|
|
37
|
+
*
|
|
38
|
+
* Stricter than {@link matchesAny}: it anchors the whole path, so `apps/**` does
|
|
39
|
+
* not match `vendor/apps/x`. Used by evaluators that enforce path policies
|
|
40
|
+
* (coverage scoping, test-file location) where a loose match would change findings.
|
|
41
|
+
*/
|
|
42
|
+
export function matchesGlob(path: string, pattern: string): boolean {
|
|
43
|
+
const normalized = path.replaceAll('\\', '/');
|
|
44
|
+
if (pattern.startsWith('**/')) {
|
|
45
|
+
const suffix = pattern.slice(3);
|
|
46
|
+
if (suffix.indexOf('*') === -1) {
|
|
47
|
+
return normalized.endsWith(suffix) || normalized.endsWith(`/${suffix}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (pattern === normalized) return true;
|
|
51
|
+
return matchSegments(normalized.split('/'), pattern.split('/'), 0, 0);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Recursive segment-level glob matcher backing {@link matchesGlob}. */
|
|
55
|
+
function matchSegments(file: string[], pattern: string[], fi: number, pi: number): boolean {
|
|
56
|
+
if (pi >= pattern.length) return fi >= file.length;
|
|
57
|
+
if (fi >= file.length) return pattern.slice(pi).every((segment) => segment === '**');
|
|
58
|
+
const pat = pattern[pi] ?? '';
|
|
59
|
+
if (pat === '**') {
|
|
60
|
+
return matchSegments(file, pattern, fi, pi + 1) || matchSegments(file, pattern, fi + 1, pi);
|
|
61
|
+
}
|
|
62
|
+
if (!matchSegment(file[fi] ?? '', pat)) return false;
|
|
63
|
+
return matchSegments(file, pattern, fi + 1, pi + 1);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Match one path segment against a pattern segment where `*` matches any run of non-`/` chars. */
|
|
67
|
+
function matchSegment(segment: string, pattern: string): boolean {
|
|
68
|
+
if (pattern.indexOf('*') === -1) return segment === pattern;
|
|
69
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replaceAll('*', '[^/]*');
|
|
70
|
+
return new RegExp(`^${escaped}$`).test(segment);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Escape a string for safe literal use inside a `RegExp` source. */
|
|
74
|
+
export function escapeRegExp(value: string): string {
|
|
75
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
76
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createNodeFileSystem } from '@gobing-ai/ts-runtime';
|
|
1
2
|
import {
|
|
2
3
|
type ConstraintRule,
|
|
3
4
|
createFinding,
|
|
@@ -44,7 +45,8 @@ export class ImportBoundaryEvaluator implements RuleEvaluator {
|
|
|
44
45
|
const compiled = boundaries.map((boundary, index) => compileBoundary(boundary, index));
|
|
45
46
|
|
|
46
47
|
// Scan all files once (read up front); apply each boundary's globs in-memory below.
|
|
47
|
-
const
|
|
48
|
+
const fs = context.fileSystem ?? createNodeFileSystem();
|
|
49
|
+
const allFiles = await scanFiles({ workdir: context.workdir, matchMode: 'glob', fs });
|
|
48
50
|
|
|
49
51
|
const findings = [];
|
|
50
52
|
for (const boundary of compiled) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createNodeFileSystem, joinPath } from '@gobing-ai/ts-runtime';
|
|
2
2
|
import {
|
|
3
3
|
type ConstraintRule,
|
|
4
4
|
createFinding,
|
|
@@ -19,13 +19,12 @@ import { configArray, configString, discoverFiles, matchesGlob } from './file-ut
|
|
|
19
19
|
* exact paths relative to the workdir (`require` = must exist, `forbid` = must not).
|
|
20
20
|
*/
|
|
21
21
|
export class PathEvaluator implements RuleEvaluator {
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
/** Evaluate required or forbidden paths in either config form. */
|
|
23
|
+
// Explicit constructor avoids a V8 function-coverage phantom (synthesized default ctor).
|
|
24
24
|
constructor() {
|
|
25
|
-
|
|
25
|
+
// no-op; all state comes from evaluate() context
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
/** Evaluate required or forbidden paths in either config form. */
|
|
29
28
|
async evaluate(rule: ConstraintRule, context: RuleContext): Promise<RuleEvaluationResult> {
|
|
30
29
|
const config = rule.evaluator.config ?? {};
|
|
31
30
|
const must = config.must;
|
|
@@ -43,7 +42,8 @@ export class PathEvaluator implements RuleEvaluator {
|
|
|
43
42
|
): Promise<RuleEvaluationResult> {
|
|
44
43
|
const include = rule.include ?? ['**'];
|
|
45
44
|
const exclude = rule.exclude ?? [];
|
|
46
|
-
const
|
|
45
|
+
const fs = context.fileSystem ?? createNodeFileSystem();
|
|
46
|
+
const files = await discoverFiles({ workdir: context.workdir, fs });
|
|
47
47
|
const inScope = (file: string) =>
|
|
48
48
|
include.some((glob) => matchesGlob(file, glob)) && !exclude.some((glob) => matchesGlob(file, glob));
|
|
49
49
|
const findings = [];
|
|
@@ -83,9 +83,10 @@ export class PathEvaluator implements RuleEvaluator {
|
|
|
83
83
|
): Promise<RuleEvaluationResult> {
|
|
84
84
|
const paths = configArray(config, 'paths', undefined, { evaluator: 'path' });
|
|
85
85
|
const mode = configString(config, 'mode', 'require');
|
|
86
|
+
const fs = context.fileSystem ?? createNodeFileSystem();
|
|
86
87
|
const findings = [];
|
|
87
88
|
for (const path of paths) {
|
|
88
|
-
const exists = await
|
|
89
|
+
const exists = await fs.exists(joinPath(context.workdir, path));
|
|
89
90
|
if (mode === 'forbid' && exists) {
|
|
90
91
|
findings.push(createFinding(rule, `Forbidden path exists: ${path}`, path, { code: 'path:forbidden' }));
|
|
91
92
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createNodeFileSystem } from '@gobing-ai/ts-runtime';
|
|
1
2
|
import {
|
|
2
3
|
type ConstraintRule,
|
|
3
4
|
createFinding,
|
|
@@ -31,11 +32,13 @@ export class RegexEvaluator implements RuleEvaluator {
|
|
|
31
32
|
);
|
|
32
33
|
const mode = configString(config, 'mode', 'forbid');
|
|
33
34
|
const regex = new RegExp(pattern, flags);
|
|
35
|
+
const fs = context.fileSystem ?? createNodeFileSystem();
|
|
34
36
|
const files = await scanFiles({
|
|
35
37
|
workdir: context.workdir,
|
|
36
38
|
include: rule.include,
|
|
37
39
|
exclude: rule.exclude,
|
|
38
40
|
matchMode: 'loose',
|
|
41
|
+
fs,
|
|
39
42
|
});
|
|
40
43
|
const findings = [];
|
|
41
44
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createNodeFileSystem, joinPath } from '@gobing-ai/ts-runtime';
|
|
2
2
|
import {
|
|
3
3
|
type ConstraintRule,
|
|
4
4
|
createFinding,
|
|
@@ -22,14 +22,14 @@ import { stringArray } from './file-utils';
|
|
|
22
22
|
* - `requireRequiredArray` — enforce that `required` is a non-empty array (default: `false`).
|
|
23
23
|
*/
|
|
24
24
|
export class SchemaArtifactEvaluator implements RuleEvaluator {
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
/** Evaluate the configured JSON schema artifact. */
|
|
26
|
+
// Explicit constructor avoids a V8 function-coverage phantom (synthesized default ctor).
|
|
27
27
|
constructor() {
|
|
28
|
-
|
|
28
|
+
// no-op; all state comes from evaluate() context
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
/** Evaluate the configured JSON schema artifact. */
|
|
32
31
|
async evaluate(rule: ConstraintRule, context: RuleContext): Promise<RuleEvaluationResult> {
|
|
32
|
+
const fs = context.fileSystem ?? createNodeFileSystem();
|
|
33
33
|
const config = rule.evaluator.config ?? {};
|
|
34
34
|
const file = config.file;
|
|
35
35
|
if (typeof file !== 'string' || file.length === 0) {
|
|
@@ -41,9 +41,10 @@ export class SchemaArtifactEvaluator implements RuleEvaluator {
|
|
|
41
41
|
const requiredDefs = stringArray(config.requiredDefs);
|
|
42
42
|
const requireRequiredArray = config.requireRequiredArray === true;
|
|
43
43
|
|
|
44
|
-
// Check existence.
|
|
45
44
|
const absolutePath = joinPath(context.workdir, file);
|
|
46
|
-
|
|
45
|
+
|
|
46
|
+
// Check existence.
|
|
47
|
+
const exists = await fs.exists(absolutePath);
|
|
47
48
|
if (!exists) {
|
|
48
49
|
return {
|
|
49
50
|
findings: [
|
|
@@ -58,7 +59,7 @@ export class SchemaArtifactEvaluator implements RuleEvaluator {
|
|
|
58
59
|
// Read and parse.
|
|
59
60
|
let schema: Record<string, unknown>;
|
|
60
61
|
try {
|
|
61
|
-
const raw = await
|
|
62
|
+
const raw = await fs.readFile(absolutePath);
|
|
62
63
|
schema = JSON.parse(raw) as Record<string, unknown>;
|
|
63
64
|
} catch (err) {
|
|
64
65
|
return {
|