@gobing-ai/ts-rule-engine 0.2.8 → 0.2.9
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/README.md +3 -1
- package/dist/config/extensions.d.ts +5 -4
- package/dist/config/extensions.d.ts.map +1 -1
- package/dist/config/extensions.js +6 -5
- package/dist/config/loader.d.ts +10 -2
- package/dist/config/loader.d.ts.map +1 -1
- package/dist/config/loader.js +73 -19
- package/dist/engine.d.ts +8 -1
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +9 -19
- package/dist/evaluators/coverage-gate-evaluator.js +12 -3
- package/dist/evaluators/file-utils.d.ts +55 -0
- package/dist/evaluators/file-utils.d.ts.map +1 -1
- package/dist/evaluators/file-utils.js +49 -0
- package/dist/evaluators/forbidden-import-evaluator.d.ts +5 -0
- package/dist/evaluators/forbidden-import-evaluator.d.ts.map +1 -1
- package/dist/evaluators/forbidden-import-evaluator.js +14 -17
- package/dist/evaluators/import-boundary-evaluator.d.ts +5 -0
- package/dist/evaluators/import-boundary-evaluator.d.ts.map +1 -1
- package/dist/evaluators/import-boundary-evaluator.js +45 -15
- package/dist/evaluators/regex-evaluator.d.ts +9 -1
- package/dist/evaluators/regex-evaluator.d.ts.map +1 -1
- package/dist/evaluators/regex-evaluator.js +43 -14
- package/dist/evaluators/secrets-scanner-evaluator.d.ts +5 -0
- package/dist/evaluators/secrets-scanner-evaluator.d.ts.map +1 -1
- package/dist/evaluators/secrets-scanner-evaluator.js +13 -13
- package/dist/evaluators/tsdoc-export-evaluator.d.ts.map +1 -1
- package/dist/evaluators/tsdoc-export-evaluator.js +9 -11
- package/dist/formatters/json.d.ts.map +1 -1
- package/dist/formatters/json.js +2 -0
- package/dist/formatters/text.d.ts.map +1 -1
- package/dist/formatters/text.js +2 -0
- package/dist/resolvers/test-path-resolver.d.ts.map +1 -1
- package/dist/resolvers/test-path-resolver.js +5 -0
- package/dist/types.d.ts +22 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +35 -9
- package/package.json +5 -3
- package/schemas/preset.schema.json +19 -5
- package/src/config/extensions.ts +8 -7
- package/src/config/loader.ts +92 -21
- package/src/engine.ts +9 -19
- package/src/evaluators/coverage-gate-evaluator.ts +15 -5
- package/src/evaluators/file-utils.ts +92 -0
- package/src/evaluators/forbidden-import-evaluator.ts +14 -19
- package/src/evaluators/import-boundary-evaluator.ts +56 -40
- package/src/evaluators/regex-evaluator.ts +43 -13
- package/src/evaluators/secrets-scanner-evaluator.ts +13 -14
- package/src/evaluators/tsdoc-export-evaluator.ts +10 -9
- package/src/formatters/json.ts +2 -0
- package/src/formatters/text.ts +2 -0
- package/src/resolvers/test-path-resolver.ts +5 -0
- package/src/types.ts +39 -9
package/src/config/extensions.ts
CHANGED
|
@@ -36,21 +36,22 @@ const HOST_REGISTRY_BY_KIND: Partial<Record<ExtensionKind, 'resolvers' | 'evalua
|
|
|
36
36
|
};
|
|
37
37
|
|
|
38
38
|
/**
|
|
39
|
-
* Collect extension refs declared by a preset's `extensions` block.
|
|
39
|
+
* Collect extension refs declared by a preset's or rule file's `extensions` block.
|
|
40
40
|
*
|
|
41
|
-
* Paths are resolved relative to the
|
|
42
|
-
* refs with {@link loadExtensionsIntoHost}.
|
|
41
|
+
* Paths are resolved relative to the declaring file's directory. Use the returned
|
|
42
|
+
* refs with {@link loadExtensionsIntoHost}. Rule files and presets are treated
|
|
43
|
+
* identically — both flow through the same trust gate at load time.
|
|
43
44
|
*/
|
|
44
|
-
export function
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
export function collectExtensions(
|
|
46
|
+
sourceName: string,
|
|
47
|
+
sourceDir: string,
|
|
47
48
|
extensions: Partial<Record<ExtensionKind, string[] | undefined>> | undefined,
|
|
48
49
|
): ExtensionRef[] {
|
|
49
50
|
if (extensions === undefined) return [];
|
|
50
51
|
const refs: ExtensionRef[] = [];
|
|
51
52
|
for (const kind of ['resolvers', 'evaluators', 'fixers', 'formatters'] as ExtensionKind[]) {
|
|
52
53
|
for (const path of extensions[kind] ?? []) {
|
|
53
|
-
refs.push({ kind, presetName, absPath: resolve(
|
|
54
|
+
refs.push({ kind, presetName: sourceName, absPath: resolve(sourceDir, path) });
|
|
54
55
|
}
|
|
55
56
|
}
|
|
56
57
|
return refs;
|
package/src/config/loader.ts
CHANGED
|
@@ -5,10 +5,11 @@ import {
|
|
|
5
5
|
type ConstraintRuleFile,
|
|
6
6
|
ConstraintRuleFileSchema,
|
|
7
7
|
ConstraintRuleSchema,
|
|
8
|
+
type FixMode,
|
|
8
9
|
type PresetDefinition,
|
|
9
10
|
PresetDefinitionSchema,
|
|
10
11
|
} from '../types';
|
|
11
|
-
import {
|
|
12
|
+
import { collectExtensions, type ExtensionRef } from './extensions';
|
|
12
13
|
|
|
13
14
|
/** Options for loading rule presets. */
|
|
14
15
|
export interface RuleLoaderOptions {
|
|
@@ -62,31 +63,76 @@ export async function loadPreset(name: string, options: RuleLoaderOptions): Prom
|
|
|
62
63
|
if (presetPath === null) return { rules: [], extensions: [] };
|
|
63
64
|
const preset = PresetDefinitionSchema.parse(await readStructuredFile(presetPath, options)) as PresetDefinition;
|
|
64
65
|
const rules: ConstraintRule[] = [];
|
|
65
|
-
const extensions =
|
|
66
|
+
const extensions = collectExtensions(preset.name, dirname(presetPath), preset.extensions);
|
|
66
67
|
for (const entry of preset.extends) {
|
|
67
68
|
const loaded = await loadPresetEntry(merged, entry, new Set([name]), options);
|
|
68
69
|
rules.push(...loaded.rules);
|
|
69
70
|
extensions.push(...loaded.extensions);
|
|
70
71
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
72
|
+
return { rules: applyPresetControls(preset.name, rules, preset.disable, preset.overrides), extensions };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Collapse rules sharing an id to a single entry; later definitions win (last-wins merge). */
|
|
76
|
+
function dedupeById(rules: readonly ConstraintRule[]): ConstraintRule[] {
|
|
77
|
+
const byId = new Map<string, ConstraintRule>();
|
|
78
|
+
for (const rule of rules) byId.set(rule.id, rule);
|
|
79
|
+
return [...byId.values()];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Apply a preset's local disable and override controls after composing its children. */
|
|
83
|
+
function applyPresetControls(
|
|
84
|
+
presetName: string,
|
|
85
|
+
rules: readonly ConstraintRule[],
|
|
86
|
+
disabledIds: readonly string[] | undefined,
|
|
87
|
+
overrides: PresetDefinition['overrides'],
|
|
88
|
+
): ConstraintRule[] {
|
|
89
|
+
const disabled = new Set(disabledIds ?? []);
|
|
90
|
+
const controlled = dedupeById(rules).filter((rule) => !disabled.has(rule.id));
|
|
91
|
+
for (const rule of controlled) {
|
|
92
|
+
const override = overrides?.[rule.id];
|
|
75
93
|
if (override?.fix !== undefined) {
|
|
94
|
+
assertFixModeNotPromoted(presetName, rule, override.fix.mode);
|
|
76
95
|
rule.fix = { ...(rule.fix ?? { mode: 'none' }), ...override.fix };
|
|
77
96
|
}
|
|
78
97
|
}
|
|
79
|
-
return
|
|
98
|
+
return controlled;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Fix-mode authority ordering; an override may lower but never raise a rule's mode. */
|
|
102
|
+
const FIX_MODE_AUTHORITY: Record<FixMode, number> = { none: 0, suggest: 1, auto: 2 };
|
|
103
|
+
|
|
104
|
+
/** Throw when a preset override would escalate a rule's fix authority above its authored level. */
|
|
105
|
+
function assertFixModeNotPromoted(presetName: string, rule: ConstraintRule, overrideMode: FixMode): void {
|
|
106
|
+
const ruleMode = rule.fix?.mode ?? 'none';
|
|
107
|
+
if (FIX_MODE_AUTHORITY[overrideMode] > FIX_MODE_AUTHORITY[ruleMode]) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
`Preset "${presetName}" override for rule "${rule.id}" raises fix mode from "${ruleMode}" to "${overrideMode}"; overrides may only lower fix authority`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
80
112
|
}
|
|
81
113
|
|
|
82
114
|
export async function loadPresetRules(name: string, options: RuleLoaderOptions): Promise<ConstraintRule[]> {
|
|
83
115
|
return (await loadPreset(name, options)).rules;
|
|
84
116
|
}
|
|
85
117
|
|
|
86
|
-
/**
|
|
87
|
-
|
|
118
|
+
/**
|
|
119
|
+
* Load a direct rule file from disk.
|
|
120
|
+
*
|
|
121
|
+
* Returns the normalized rules plus any extension modules the file declares in an
|
|
122
|
+
* `extensions` block, resolved to absolute paths. Rule-file extensions are treated
|
|
123
|
+
* exactly like preset extensions — pass the refs to {@link loadExtensionsIntoHost},
|
|
124
|
+
* which enforces the same `allowExtensions` trust gate. A single-rule file (one rule
|
|
125
|
+
* object, not a `rules:` array) cannot declare extensions and yields `extensions: []`.
|
|
126
|
+
*/
|
|
127
|
+
export async function loadRuleFile(filePath: string, options: RuleFileLoadOptions = {}): Promise<LoadedPreset> {
|
|
88
128
|
const resolved = resolve(filePath);
|
|
89
|
-
|
|
129
|
+
const raw = await readStructuredFile(resolved, options);
|
|
130
|
+
const rules = normalizeRuleFile(raw, resolved);
|
|
131
|
+
const parsed = ConstraintRuleFileSchema.safeParse(raw);
|
|
132
|
+
const extensions = parsed.success
|
|
133
|
+
? collectExtensions(basename(resolved), dirname(resolved), parsed.data.extensions)
|
|
134
|
+
: [];
|
|
135
|
+
return { rules, extensions };
|
|
90
136
|
}
|
|
91
137
|
|
|
92
138
|
async function loadPresetEntry(
|
|
@@ -105,13 +151,16 @@ async function loadPresetEntry(
|
|
|
105
151
|
const preset = PresetDefinitionSchema.safeParse(await readStructuredFile(presetPath, options));
|
|
106
152
|
if (preset.success) {
|
|
107
153
|
const rules: ConstraintRule[] = [];
|
|
108
|
-
const extensions =
|
|
154
|
+
const extensions = collectExtensions(preset.data.name, dirname(presetPath), preset.data.extensions);
|
|
109
155
|
for (const child of preset.data.extends) {
|
|
110
156
|
const loaded = await loadPresetEntry(merged, child, nextSeen, options);
|
|
111
157
|
rules.push(...loaded.rules);
|
|
112
158
|
extensions.push(...loaded.extensions);
|
|
113
159
|
}
|
|
114
|
-
return {
|
|
160
|
+
return {
|
|
161
|
+
rules: applyPresetControls(preset.data.name, rules, preset.data.disable, preset.data.overrides),
|
|
162
|
+
extensions,
|
|
163
|
+
};
|
|
115
164
|
}
|
|
116
165
|
}
|
|
117
166
|
|
|
@@ -119,7 +168,7 @@ async function loadPresetEntry(
|
|
|
119
168
|
if (merged.categories.has(entry)) {
|
|
120
169
|
const rules: ConstraintRule[] = [];
|
|
121
170
|
for (const absPath of mergedFilesInCategory(merged, entry)) {
|
|
122
|
-
rules.push(...normalizeRuleFile(await readStructuredFile(absPath, options),
|
|
171
|
+
rules.push(...normalizeRuleFile(await readStructuredFile(absPath, options), absPath));
|
|
123
172
|
}
|
|
124
173
|
return { rules, extensions: [] };
|
|
125
174
|
}
|
|
@@ -128,7 +177,7 @@ async function loadPresetEntry(
|
|
|
128
177
|
const subPath = findMergedFile(merged, entry);
|
|
129
178
|
if (subPath !== null)
|
|
130
179
|
return {
|
|
131
|
-
rules: normalizeRuleFile(await readStructuredFile(subPath, options),
|
|
180
|
+
rules: normalizeRuleFile(await readStructuredFile(subPath, options), subPath),
|
|
132
181
|
extensions: [],
|
|
133
182
|
};
|
|
134
183
|
|
|
@@ -234,25 +283,47 @@ async function readStructuredFile(
|
|
|
234
283
|
});
|
|
235
284
|
}
|
|
236
285
|
|
|
237
|
-
|
|
286
|
+
/** A rule as parsed by Zod: severity may be absent until normalization fills it. */
|
|
287
|
+
type ParsedRule = Omit<ConstraintRule, 'severity'> & { severity?: ConstraintRule['severity'] };
|
|
288
|
+
|
|
289
|
+
function normalizeRuleFile(raw: unknown, filePath: string): ConstraintRule[] {
|
|
290
|
+
const sourceDir = dirname(filePath);
|
|
238
291
|
const maybeFile = ConstraintRuleFileSchema.safeParse(raw);
|
|
239
292
|
if (maybeFile.success) return normalizeFileRules(maybeFile.data, sourceDir);
|
|
240
293
|
const maybeRule = ConstraintRuleSchema.safeParse(raw);
|
|
241
|
-
if (maybeRule.success) return [normalizeRule(maybeRule.data,
|
|
242
|
-
|
|
294
|
+
if (maybeRule.success) return [normalizeRule(maybeRule.data, sourceDir)];
|
|
295
|
+
// Surface the schema diagnostics that best fit the input: a `rules:` array means
|
|
296
|
+
// the author intended a rule file, so report against that schema; otherwise the
|
|
297
|
+
// single-rule schema. Include field paths so the offending key is obvious.
|
|
298
|
+
const isRuleFileShape = typeof raw === 'object' && raw !== null && 'rules' in raw;
|
|
299
|
+
const issues = (isRuleFileShape ? maybeFile.error : maybeRule.error).issues;
|
|
300
|
+
throw new Error(`Invalid rule file "${basename(filePath)}": ${formatIssues(issues)}`);
|
|
243
301
|
}
|
|
244
302
|
|
|
245
|
-
|
|
303
|
+
/** Render Zod issues as `path: message` fragments for actionable diagnostics. */
|
|
304
|
+
function formatIssues(issues: readonly { path: PropertyKey[]; message: string }[]): string {
|
|
305
|
+
return issues
|
|
306
|
+
.map((issue) => {
|
|
307
|
+
const path = issue.path.map(String).join('.');
|
|
308
|
+
return path.length > 0 ? `${path}: ${issue.message}` : issue.message;
|
|
309
|
+
})
|
|
310
|
+
.join('; ');
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** A rule file as parsed by Zod: rule severities may be absent until normalization. */
|
|
314
|
+
type ParsedRuleFile = Omit<ConstraintRuleFile, 'rules'> & { rules: ParsedRule[] };
|
|
315
|
+
|
|
316
|
+
function normalizeFileRules(file: ParsedRuleFile, sourceDir: string): ConstraintRule[] {
|
|
246
317
|
return file.rules.map((rule) =>
|
|
247
318
|
normalizeRule(
|
|
248
319
|
{
|
|
249
320
|
...rule,
|
|
250
|
-
|
|
321
|
+
// Rule-level severity wins, then the file-level default, then 'error'.
|
|
322
|
+
severity: rule.severity ?? file.severity,
|
|
251
323
|
include: rule.include ?? file.include,
|
|
252
324
|
// File-level excludes always apply; a rule's own excludes add to (not replace) them.
|
|
253
325
|
exclude: mergeExcludes(file.exclude, rule.exclude),
|
|
254
326
|
},
|
|
255
|
-
{},
|
|
256
327
|
sourceDir,
|
|
257
328
|
),
|
|
258
329
|
);
|
|
@@ -264,7 +335,7 @@ function mergeExcludes(fileExclude?: string[], ruleExclude?: string[]): string[]
|
|
|
264
335
|
return [...new Set([...(fileExclude ?? []), ...(ruleExclude ?? [])])];
|
|
265
336
|
}
|
|
266
337
|
|
|
267
|
-
function normalizeRule(rule:
|
|
338
|
+
function normalizeRule(rule: ParsedRule, _sourceDir: string): ConstraintRule {
|
|
268
339
|
return {
|
|
269
340
|
...rule,
|
|
270
341
|
enabled: rule.enabled ?? true,
|
package/src/engine.ts
CHANGED
|
@@ -39,26 +39,16 @@ export class RuleEngine {
|
|
|
39
39
|
this.host.evaluators.register(type, evaluator, 'extension');
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
/**
|
|
42
|
+
/**
|
|
43
|
+
* Evaluate all enabled rules against a working directory.
|
|
44
|
+
*
|
|
45
|
+
* Thin delegate to {@link evaluateWithFixes} with `maxFixMode = 'none'`; the fix
|
|
46
|
+
* branch in that path is short-circuited by `effectiveFixMode` so callers see only
|
|
47
|
+
* findings, never auto-generated fixes. Keeps the rule loop and error-finding
|
|
48
|
+
* semantics in one place.
|
|
49
|
+
*/
|
|
43
50
|
async evaluate(rules: ConstraintRule[], workdir: string): Promise<RuleEngineResult> {
|
|
44
|
-
|
|
45
|
-
const fixes: Fix[] = [];
|
|
46
|
-
for (const rule of rules) {
|
|
47
|
-
if (rule.enabled === false) continue;
|
|
48
|
-
try {
|
|
49
|
-
const result = await this.host.evaluators.get(rule.evaluator.type).evaluate(rule, { rule, workdir });
|
|
50
|
-
findings.push(...result.findings);
|
|
51
|
-
fixes.push(...result.fixes);
|
|
52
|
-
} catch (error) {
|
|
53
|
-
findings.push(
|
|
54
|
-
createFinding(rule, error instanceof Error ? error.message : String(error), null, {
|
|
55
|
-
code: `evaluator:${rule.evaluator.type}`,
|
|
56
|
-
kind: 'error',
|
|
57
|
-
}),
|
|
58
|
-
);
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
return { findings, fixes };
|
|
51
|
+
return this.evaluateWithFixes(rules, workdir, 'none');
|
|
62
52
|
}
|
|
63
53
|
|
|
64
54
|
/**
|
|
@@ -100,8 +100,8 @@ export class CoverageGateEvaluator implements RuleEvaluator {
|
|
|
100
100
|
function parseLcov(raw: string): Map<string, FileCoverage> {
|
|
101
101
|
const result = new Map<string, FileCoverage>();
|
|
102
102
|
let file: string | null = null;
|
|
103
|
-
let linesFound = 0;
|
|
104
|
-
let linesHit = 0;
|
|
103
|
+
let linesFound: number | null = 0;
|
|
104
|
+
let linesHit: number | null = 0;
|
|
105
105
|
for (const line of raw.split('\n')) {
|
|
106
106
|
const trimmed = line.trim();
|
|
107
107
|
if (trimmed.startsWith('SF:')) {
|
|
@@ -109,17 +109,27 @@ function parseLcov(raw: string): Map<string, FileCoverage> {
|
|
|
109
109
|
linesFound = 0;
|
|
110
110
|
linesHit = 0;
|
|
111
111
|
} else if (trimmed.startsWith('LF:')) {
|
|
112
|
-
linesFound =
|
|
112
|
+
linesFound = parseCount(trimmed.slice(3));
|
|
113
113
|
} else if (trimmed.startsWith('LH:')) {
|
|
114
|
-
linesHit =
|
|
114
|
+
linesHit = parseCount(trimmed.slice(3));
|
|
115
115
|
} else if (trimmed === 'end_of_record' && file !== null) {
|
|
116
|
-
|
|
116
|
+
// Skip records with malformed counts: a non-numeric LF:/LH: would
|
|
117
|
+
// otherwise yield NaN coverage and a spurious below-threshold finding.
|
|
118
|
+
if (linesFound !== null && linesHit !== null) {
|
|
119
|
+
result.set(file, { linesFound, linesHit });
|
|
120
|
+
}
|
|
117
121
|
file = null;
|
|
118
122
|
}
|
|
119
123
|
}
|
|
120
124
|
return result;
|
|
121
125
|
}
|
|
122
126
|
|
|
127
|
+
/** Parse an lcov count field; return null for non-finite or negative values. */
|
|
128
|
+
function parseCount(raw: string): number | null {
|
|
129
|
+
const value = Number(raw);
|
|
130
|
+
return Number.isFinite(value) && value >= 0 ? value : null;
|
|
131
|
+
}
|
|
132
|
+
|
|
123
133
|
/** Standard exclusions applied regardless of config (tests, generated, deps). */
|
|
124
134
|
function isAlwaysExcluded(filePath: string): boolean {
|
|
125
135
|
return (
|
|
@@ -34,6 +34,74 @@ export async function readWorkdirFile(workdir: string, filePath: string, fs = ne
|
|
|
34
34
|
return await fs.readFile(resolve(workdir, filePath));
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/** A discovered in-scope file paired with its contents. */
|
|
38
|
+
export interface ScannedFile {
|
|
39
|
+
/** Workdir-relative path. */
|
|
40
|
+
readonly file: string;
|
|
41
|
+
/** Full file contents. */
|
|
42
|
+
readonly content: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** How `scanFiles` matches `include` / `exclude` against discovered paths. */
|
|
46
|
+
export type ScanMatchMode = 'loose' | 'glob';
|
|
47
|
+
|
|
48
|
+
/** Options for {@link scanFiles}. */
|
|
49
|
+
export interface ScanFilesOptions {
|
|
50
|
+
/** Working directory to walk. */
|
|
51
|
+
workdir: string;
|
|
52
|
+
/** Include patterns; semantics depend on `matchMode`. Undefined/empty = all files. */
|
|
53
|
+
include?: string[];
|
|
54
|
+
/** Exclude patterns; semantics depend on `matchMode`. */
|
|
55
|
+
exclude?: string[];
|
|
56
|
+
/**
|
|
57
|
+
* Scope matching policy:
|
|
58
|
+
* - `loose` — substring/suffix fragments via {@link matchesAny} (back-compat for
|
|
59
|
+
* evaluators that historically accepted bare fragments like `.ts` or `src/`).
|
|
60
|
+
* - `glob` — anchored `**`/`*` globs via {@link matchesGlob}.
|
|
61
|
+
*
|
|
62
|
+
* The two are NOT interchangeable: a bare `src/` matches different sets under each.
|
|
63
|
+
* Each evaluator declares the mode that preserves its existing behavior.
|
|
64
|
+
*/
|
|
65
|
+
matchMode: ScanMatchMode;
|
|
66
|
+
/** Filesystem adapter. */
|
|
67
|
+
fs?: FileSystem;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Discover in-scope files and read each once — the shared scaffolding behind the
|
|
72
|
+
* line-scanning evaluators. Owns discovery, scope filtering (per `matchMode`), and
|
|
73
|
+
* reads, so each evaluator is left with only its own matcher.
|
|
74
|
+
*
|
|
75
|
+
* Scope is a parameter, not assumed one-per-rule: callers that scan under several
|
|
76
|
+
* scopes (e.g. import boundaries) pass no `include` here and apply their own globs to
|
|
77
|
+
* the returned paths.
|
|
78
|
+
*/
|
|
79
|
+
export async function scanFiles(options: ScanFilesOptions): Promise<ScannedFile[]> {
|
|
80
|
+
const fs = options.fs ?? new NodeFileSystem();
|
|
81
|
+
const files =
|
|
82
|
+
options.matchMode === 'loose'
|
|
83
|
+
? await discoverFiles({ workdir: options.workdir, include: options.include, exclude: options.exclude, fs })
|
|
84
|
+
: await discoverFilesByGlob(options.workdir, options.include, options.exclude, fs);
|
|
85
|
+
const scanned: ScannedFile[] = [];
|
|
86
|
+
for (const file of files) {
|
|
87
|
+
scanned.push({ file, content: await readWorkdirFile(options.workdir, file, fs) });
|
|
88
|
+
}
|
|
89
|
+
return scanned;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Discover files then filter with anchored globs (strict mode for {@link scanFiles}). */
|
|
93
|
+
async function discoverFilesByGlob(
|
|
94
|
+
workdir: string,
|
|
95
|
+
include: string[] | undefined,
|
|
96
|
+
exclude: string[] | undefined,
|
|
97
|
+
fs: FileSystem,
|
|
98
|
+
): Promise<string[]> {
|
|
99
|
+
const all = await discoverFiles({ workdir, fs });
|
|
100
|
+
return all
|
|
101
|
+
.filter((file) => include === undefined || include.length === 0 || include.some((g) => matchesGlob(file, g)))
|
|
102
|
+
.filter((file) => exclude === undefined || !exclude.some((g) => matchesGlob(file, g)));
|
|
103
|
+
}
|
|
104
|
+
|
|
37
105
|
/** Ensure a path is workdir-relative for findings. */
|
|
38
106
|
export function relativeToWorkdir(workdir: string, path: string): string {
|
|
39
107
|
return relative(workdir, resolve(path));
|
|
@@ -91,3 +159,27 @@ function matchSegment(segment: string, pattern: string): boolean {
|
|
|
91
159
|
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replaceAll('*', '[^/]*');
|
|
92
160
|
return new RegExp(`^${escaped}$`).test(segment);
|
|
93
161
|
}
|
|
162
|
+
|
|
163
|
+
/** Escape a string for safe literal use inside a `RegExp` source. */
|
|
164
|
+
export function escapeRegExp(value: string): string {
|
|
165
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Return the value as a `string[]` when every item is a string, otherwise undefined. */
|
|
169
|
+
export function stringArray(value: unknown): string[] | undefined {
|
|
170
|
+
return Array.isArray(value) && value.every((item) => typeof item === 'string') ? (value as string[]) : undefined;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Split a leading ripgrep/PCRE-style `(?flags)` inline group off a regex source.
|
|
175
|
+
*
|
|
176
|
+
* Returns the JS-relevant flags found in the group (filtered to `imsu`) and the
|
|
177
|
+
* remaining source with the group removed. When no leading group is present, returns
|
|
178
|
+
* empty flags and the source unchanged. Shared by evaluators that accept inline flags.
|
|
179
|
+
*/
|
|
180
|
+
export function parseInlineFlags(source: string): { flags: string; rest: string } {
|
|
181
|
+
const match = /^\(\?([a-z]+)\)/.exec(source);
|
|
182
|
+
if (!match) return { flags: '', rest: source };
|
|
183
|
+
const flags = [...(match[1] ?? '')].filter((flag) => 'imsu'.includes(flag)).join('');
|
|
184
|
+
return { flags, rest: source.slice(match[0].length) };
|
|
185
|
+
}
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
type RuleEvaluationResult,
|
|
6
6
|
type RuleEvaluator,
|
|
7
7
|
} from '../types';
|
|
8
|
-
import {
|
|
8
|
+
import { escapeRegExp, scanFiles, stringArray } from './file-utils';
|
|
9
9
|
|
|
10
10
|
/** A forbidden entry: either an exact import specifier or a raw source pattern. */
|
|
11
11
|
type ForbiddenEntry =
|
|
@@ -28,6 +28,11 @@ interface ScanEntry {
|
|
|
28
28
|
* entry is an exact `specifier` (also matching require/dynamic forms unless
|
|
29
29
|
* `includeRequire:false`) or a raw `pattern`, scoped by `scope.include` /
|
|
30
30
|
* `scope.exclude` globs.
|
|
31
|
+
*
|
|
32
|
+
* Trust assumption: rule config is trusted input. A raw `pattern` is compiled with
|
|
33
|
+
* `new RegExp` and run per line without a backtracking bound, so a
|
|
34
|
+
* catastrophic-backtracking pattern is the rule author's responsibility. Runtime
|
|
35
|
+
* ReDoS hardening is deferred (see task 0003).
|
|
31
36
|
*/
|
|
32
37
|
export class ForbiddenImportEvaluator implements RuleEvaluator {
|
|
33
38
|
/** Evaluate import/usage against the configured forbidden set. */
|
|
@@ -45,14 +50,15 @@ export class ForbiddenImportEvaluator implements RuleEvaluator {
|
|
|
45
50
|
config: Record<string, unknown>,
|
|
46
51
|
): Promise<RuleEvaluationResult> {
|
|
47
52
|
const forbidden = arrayConfig(config, 'patterns');
|
|
48
|
-
const files = await
|
|
53
|
+
const files = await scanFiles({
|
|
49
54
|
workdir: context.workdir,
|
|
50
55
|
include: rule.include ?? ['.ts', '.tsx', '.js', '.jsx'],
|
|
51
56
|
exclude: rule.exclude,
|
|
57
|
+
matchMode: 'loose',
|
|
52
58
|
});
|
|
53
59
|
const findings = [];
|
|
54
|
-
for (const file of files) {
|
|
55
|
-
const lines =
|
|
60
|
+
for (const { file, content } of files) {
|
|
61
|
+
const lines = content.split('\n');
|
|
56
62
|
for (const [index, line] of lines.entries()) {
|
|
57
63
|
const imported = importSpecifier(line);
|
|
58
64
|
if (imported === undefined) continue;
|
|
@@ -84,15 +90,12 @@ export class ForbiddenImportEvaluator implements RuleEvaluator {
|
|
|
84
90
|
const exclude = stringArray(scope?.exclude) ?? [];
|
|
85
91
|
const entries = (config.forbidden as ForbiddenEntry[]).map(compileEntry);
|
|
86
92
|
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
const files = (await discoverFiles({ workdir: context.workdir }))
|
|
90
|
-
.filter((file) => include.some((glob) => matchesGlob(file, glob)))
|
|
91
|
-
.filter((file) => !exclude.some((glob) => matchesGlob(file, glob)));
|
|
93
|
+
// Anchored `**`-glob scoping: scanFiles' 'glob' mode applies matchesGlob precisely.
|
|
94
|
+
const files = await scanFiles({ workdir: context.workdir, include, exclude, matchMode: 'glob' });
|
|
92
95
|
|
|
93
96
|
const findings = [];
|
|
94
|
-
for (const file of files) {
|
|
95
|
-
const lines =
|
|
97
|
+
for (const { file, content } of files) {
|
|
98
|
+
const lines = content.split('\n');
|
|
96
99
|
for (const [index, line] of lines.entries()) {
|
|
97
100
|
const hit = entries.find((entry) => entry.regex.test(line));
|
|
98
101
|
if (hit !== undefined) {
|
|
@@ -128,17 +131,9 @@ function compileEntry(entry: ForbiddenEntry): ScanEntry {
|
|
|
128
131
|
return { regex: new RegExp(entry.pattern), label: entry.pattern };
|
|
129
132
|
}
|
|
130
133
|
|
|
131
|
-
function escapeRegExp(value: string): string {
|
|
132
|
-
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
133
|
-
}
|
|
134
|
-
|
|
135
134
|
function arrayConfig(config: Record<string, unknown>, key: string): string[] {
|
|
136
135
|
const value = config[key];
|
|
137
136
|
if (Array.isArray(value) && value.every((item) => typeof item === 'string')) return value;
|
|
138
137
|
if (typeof value === 'string') return [value];
|
|
139
138
|
throw new Error(`forbidden-import evaluator requires string[] config "${key}"`);
|
|
140
139
|
}
|
|
141
|
-
|
|
142
|
-
function stringArray(value: unknown): string[] | undefined {
|
|
143
|
-
return Array.isArray(value) && value.every((item) => typeof item === 'string') ? (value as string[]) : undefined;
|
|
144
|
-
}
|
|
@@ -5,25 +5,7 @@ import {
|
|
|
5
5
|
type RuleEvaluationResult,
|
|
6
6
|
type RuleEvaluator,
|
|
7
7
|
} from '../types';
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* A forbidden entry within a boundary declaration.
|
|
12
|
-
*
|
|
13
|
-
* - String form: substring match against any import/export/require/dynamic-import specifier.
|
|
14
|
-
* - Object form: regex `pattern` matched against the full line (mode `usage`) or import lines
|
|
15
|
-
* only (mode `import`).
|
|
16
|
-
*/
|
|
17
|
-
type ForbiddenEntry =
|
|
18
|
-
| string
|
|
19
|
-
| {
|
|
20
|
-
/** Regex pattern to match against lines. */
|
|
21
|
-
pattern: string;
|
|
22
|
-
/** `import` = restrict to import/export/require lines; `usage` = any line. Default: `import`. */
|
|
23
|
-
mode?: 'import' | 'usage';
|
|
24
|
-
/** Explicit syntax hint (informational, not enforced differently from `mode`). */
|
|
25
|
-
syntax?: string;
|
|
26
|
-
};
|
|
8
|
+
import { escapeRegExp, matchesGlob, scanFiles } from './file-utils';
|
|
27
9
|
|
|
28
10
|
/** A compiled boundary ready for file scanning. */
|
|
29
11
|
interface CompiledBoundary {
|
|
@@ -44,6 +26,11 @@ interface CompiledBoundary {
|
|
|
44
26
|
* - `scope` — glob pattern selecting files this boundary applies to.
|
|
45
27
|
* - `forbidden` — array of strings or `{ pattern, mode?, syntax? }` objects.
|
|
46
28
|
* - `exclude` — optional globs within the scope to ignore.
|
|
29
|
+
*
|
|
30
|
+
* Trust assumption: rule config is trusted input. A `pattern` is compiled with
|
|
31
|
+
* `new RegExp` and run per line without a backtracking bound, so a
|
|
32
|
+
* catastrophic-backtracking pattern is the rule author's responsibility. Runtime
|
|
33
|
+
* ReDoS hardening is deferred (see task 0003).
|
|
47
34
|
*/
|
|
48
35
|
export class ImportBoundaryEvaluator implements RuleEvaluator {
|
|
49
36
|
/** Evaluate import boundaries across all in-scope files. */
|
|
@@ -54,19 +41,18 @@ export class ImportBoundaryEvaluator implements RuleEvaluator {
|
|
|
54
41
|
throw new Error('import-boundary evaluator requires non-empty array config "boundaries"');
|
|
55
42
|
}
|
|
56
43
|
|
|
57
|
-
const compiled =
|
|
44
|
+
const compiled = boundaries.map((boundary, index) => compileBoundary(boundary, index));
|
|
58
45
|
|
|
59
|
-
//
|
|
60
|
-
const allFiles = await
|
|
46
|
+
// Scan all files once (read up front); apply each boundary's globs in-memory below.
|
|
47
|
+
const allFiles = await scanFiles({ workdir: context.workdir, matchMode: 'glob' });
|
|
61
48
|
|
|
62
49
|
const findings = [];
|
|
63
50
|
for (const boundary of compiled) {
|
|
64
51
|
const inScope = allFiles
|
|
65
|
-
.filter((file) => matchesGlob(file, boundary.scope))
|
|
66
|
-
.filter((file) => !boundary.excludePatterns.some((ex) => matchesGlob(file, ex)));
|
|
52
|
+
.filter(({ file }) => matchesGlob(file, boundary.scope))
|
|
53
|
+
.filter(({ file }) => !boundary.excludePatterns.some((ex) => matchesGlob(file, ex)));
|
|
67
54
|
|
|
68
|
-
for (const file of inScope) {
|
|
69
|
-
const content = await readWorkdirFile(context.workdir, file);
|
|
55
|
+
for (const { file, content } of inScope) {
|
|
70
56
|
const lines = content.split('\n');
|
|
71
57
|
for (const [index, line] of lines.entries()) {
|
|
72
58
|
for (const entry of boundary.forbidden) {
|
|
@@ -88,24 +74,37 @@ export class ImportBoundaryEvaluator implements RuleEvaluator {
|
|
|
88
74
|
}
|
|
89
75
|
}
|
|
90
76
|
|
|
91
|
-
/** Raw shape of one boundary declaration from the config. */
|
|
92
|
-
interface BoundaryDecl {
|
|
93
|
-
scope: string;
|
|
94
|
-
forbidden: ForbiddenEntry[];
|
|
95
|
-
exclude?: string[];
|
|
96
|
-
}
|
|
97
|
-
|
|
98
77
|
/** Compile a raw boundary declaration into a scan-ready form. */
|
|
99
|
-
function compileBoundary(decl:
|
|
78
|
+
function compileBoundary(decl: unknown, index: number): CompiledBoundary {
|
|
79
|
+
if (!isRecord(decl)) {
|
|
80
|
+
throw new Error(`import-boundary evaluator requires object config "boundaries[${index}]"`);
|
|
81
|
+
}
|
|
82
|
+
const scope = decl.scope;
|
|
83
|
+
if (typeof scope !== 'string' || scope.length === 0) {
|
|
84
|
+
throw new Error(`import-boundary evaluator requires string config "boundaries[${index}].scope"`);
|
|
85
|
+
}
|
|
86
|
+
const forbidden = decl.forbidden;
|
|
87
|
+
if (!Array.isArray(forbidden) || forbidden.length === 0) {
|
|
88
|
+
throw new Error(`import-boundary evaluator requires non-empty array config "boundaries[${index}].forbidden"`);
|
|
89
|
+
}
|
|
90
|
+
const exclude = decl.exclude;
|
|
91
|
+
if (exclude !== undefined && !isStringArray(exclude)) {
|
|
92
|
+
throw new Error(`import-boundary evaluator requires string[] config "boundaries[${index}].exclude"`);
|
|
93
|
+
}
|
|
94
|
+
|
|
100
95
|
return {
|
|
101
|
-
scope
|
|
102
|
-
excludePatterns:
|
|
103
|
-
forbidden:
|
|
96
|
+
scope,
|
|
97
|
+
excludePatterns: exclude ?? [],
|
|
98
|
+
forbidden: forbidden.map((entry, entryIndex) => compileEntry(entry, index, entryIndex)),
|
|
104
99
|
};
|
|
105
100
|
}
|
|
106
101
|
|
|
107
102
|
/** Compile one forbidden entry into a regex + metadata. */
|
|
108
|
-
function compileEntry(
|
|
103
|
+
function compileEntry(
|
|
104
|
+
entry: unknown,
|
|
105
|
+
boundaryIndex: number,
|
|
106
|
+
entryIndex: number,
|
|
107
|
+
): { regex: RegExp; label: string; importOnly: boolean } {
|
|
109
108
|
if (typeof entry === 'string') {
|
|
110
109
|
// String form: match as an import specifier substring.
|
|
111
110
|
const escaped = escapeRegExp(entry);
|
|
@@ -116,6 +115,17 @@ function compileEntry(entry: ForbiddenEntry): { regex: RegExp; label: string; im
|
|
|
116
115
|
};
|
|
117
116
|
}
|
|
118
117
|
|
|
118
|
+
if (!isRecord(entry) || typeof entry.pattern !== 'string' || entry.pattern.length === 0) {
|
|
119
|
+
throw new Error(
|
|
120
|
+
`import-boundary evaluator requires string config "boundaries[${boundaryIndex}].forbidden[${entryIndex}].pattern"`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
if (entry.mode !== undefined && entry.mode !== 'import' && entry.mode !== 'usage') {
|
|
124
|
+
throw new Error(
|
|
125
|
+
`import-boundary evaluator requires "import" or "usage" config "boundaries[${boundaryIndex}].forbidden[${entryIndex}].mode"`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
119
129
|
// Object form with `pattern`.
|
|
120
130
|
const importOnly = (entry.mode ?? 'import') !== 'usage';
|
|
121
131
|
return {
|
|
@@ -130,6 +140,12 @@ function isImportLine(line: string): boolean {
|
|
|
130
140
|
return /(?:^\s*import\b|^\s*export\b.*\bfrom\b|(?:from|require|import)\s*\(?\s*['"])/.test(line);
|
|
131
141
|
}
|
|
132
142
|
|
|
133
|
-
|
|
134
|
-
|
|
143
|
+
/** Return true when value is a plain object-ish config record. */
|
|
144
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
145
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Return true when every array item is a string. */
|
|
149
|
+
function isStringArray(value: unknown): value is string[] {
|
|
150
|
+
return Array.isArray(value) && value.every((item) => typeof item === 'string');
|
|
135
151
|
}
|