@gobing-ai/ts-rule-engine 0.2.8 → 0.3.0
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/host/bundled-rules.d.ts +26 -0
- package/dist/host/bundled-rules.d.ts.map +1 -0
- package/dist/host/bundled-rules.js +76 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -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 +6 -3
- package/rules/quality/coverage-gate.yaml +21 -0
- package/rules/recommended.yaml +10 -0
- package/rules/spur-dev.yaml +6 -0
- package/rules/structure/test-location.yaml +38 -0
- package/rules/typescript/no-biome-suppressions.yaml +23 -0
- package/rules/typescript/tsdoc-exports.yaml +24 -0
- 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/host/bundled-rules.ts +78 -0
- package/src/index.ts +1 -0
- package/src/resolvers/test-path-resolver.ts +5 -0
- package/src/types.ts +39 -9
package/README.md
CHANGED
|
@@ -136,7 +136,9 @@ Load a rule file directly:
|
|
|
136
136
|
```ts
|
|
137
137
|
import { loadRuleFile } from '@gobing-ai/ts-rule-engine';
|
|
138
138
|
|
|
139
|
-
|
|
139
|
+
// Returns { rules, extensions } — same shape as loadPreset(). A rule file may
|
|
140
|
+
// declare an `extensions` block; the refs are gated by allowExtensions at load time.
|
|
141
|
+
const { rules, extensions } = await loadRuleFile('.rules/typescript.yaml');
|
|
140
142
|
```
|
|
141
143
|
|
|
142
144
|
## Presets
|
|
@@ -26,12 +26,13 @@ export interface LoadExtensionsOptions {
|
|
|
26
26
|
moduleLoader?: (absPath: string) => Promise<Record<string, unknown>>;
|
|
27
27
|
}
|
|
28
28
|
/**
|
|
29
|
-
* Collect extension refs declared by a preset's `extensions` block.
|
|
29
|
+
* Collect extension refs declared by a preset's or rule file's `extensions` block.
|
|
30
30
|
*
|
|
31
|
-
* Paths are resolved relative to the
|
|
32
|
-
* refs with {@link loadExtensionsIntoHost}.
|
|
31
|
+
* Paths are resolved relative to the declaring file's directory. Use the returned
|
|
32
|
+
* refs with {@link loadExtensionsIntoHost}. Rule files and presets are treated
|
|
33
|
+
* identically — both flow through the same trust gate at load time.
|
|
33
34
|
*/
|
|
34
|
-
export declare function
|
|
35
|
+
export declare function collectExtensions(sourceName: string, sourceDir: string, extensions: Partial<Record<ExtensionKind, string[] | undefined>> | undefined): ExtensionRef[];
|
|
35
36
|
/**
|
|
36
37
|
* Import each extension module and register its export on the matching host
|
|
37
38
|
* registry.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"extensions.d.ts","sourceRoot":"","sources":["../../src/config/extensions.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAE/D,2DAA2D;AAC3D,MAAM,MAAM,aAAa,GAAG,WAAW,GAAG,YAAY,GAAG,QAAQ,GAAG,YAAY,CAAC;AAEjF,yEAAyE;AACzE,MAAM,WAAW,YAAY;IACzB,qDAAqD;IACrD,QAAQ,CAAC,IAAI,EAAE,aAAa,CAAC;IAC7B,6CAA6C;IAC7C,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,yEAAyE;IACzE,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;CAC/B;AAED,oDAAoD;AACpD,MAAM,WAAW,qBAAqB;IAClC;;;;OAIG;IACH,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,sEAAsE;IACtE,MAAM,CAAC,EAAE;QAAE,IAAI,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAA;KAAE,CAAC;IAC7C,oFAAoF;IACpF,YAAY,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;CACxE;AASD
|
|
1
|
+
{"version":3,"file":"extensions.d.ts","sourceRoot":"","sources":["../../src/config/extensions.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAE/D,2DAA2D;AAC3D,MAAM,MAAM,aAAa,GAAG,WAAW,GAAG,YAAY,GAAG,QAAQ,GAAG,YAAY,CAAC;AAEjF,yEAAyE;AACzE,MAAM,WAAW,YAAY;IACzB,qDAAqD;IACrD,QAAQ,CAAC,IAAI,EAAE,aAAa,CAAC;IAC7B,6CAA6C;IAC7C,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,yEAAyE;IACzE,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;CAC/B;AAED,oDAAoD;AACpD,MAAM,WAAW,qBAAqB;IAClC;;;;OAIG;IACH,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,sEAAsE;IACtE,MAAM,CAAC,EAAE;QAAE,IAAI,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAA;KAAE,CAAC;IAC7C,oFAAoF;IACpF,YAAY,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;CACxE;AASD;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAC7B,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,OAAO,CAAC,MAAM,CAAC,aAAa,EAAE,MAAM,EAAE,GAAG,SAAS,CAAC,CAAC,GAAG,SAAS,GAC7E,YAAY,EAAE,CAShB;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,sBAAsB,CACxC,IAAI,EAAE,cAAc,EACpB,IAAI,EAAE,SAAS,YAAY,EAAE,EAC7B,OAAO,GAAE,qBAA0B,GACpC,OAAO,CAAC,IAAI,CAAC,CAoCf"}
|
|
@@ -6,18 +6,19 @@ const HOST_REGISTRY_BY_KIND = {
|
|
|
6
6
|
formatters: 'formatters',
|
|
7
7
|
};
|
|
8
8
|
/**
|
|
9
|
-
* Collect extension refs declared by a preset's `extensions` block.
|
|
9
|
+
* Collect extension refs declared by a preset's or rule file's `extensions` block.
|
|
10
10
|
*
|
|
11
|
-
* Paths are resolved relative to the
|
|
12
|
-
* refs with {@link loadExtensionsIntoHost}.
|
|
11
|
+
* Paths are resolved relative to the declaring file's directory. Use the returned
|
|
12
|
+
* refs with {@link loadExtensionsIntoHost}. Rule files and presets are treated
|
|
13
|
+
* identically — both flow through the same trust gate at load time.
|
|
13
14
|
*/
|
|
14
|
-
export function
|
|
15
|
+
export function collectExtensions(sourceName, sourceDir, extensions) {
|
|
15
16
|
if (extensions === undefined)
|
|
16
17
|
return [];
|
|
17
18
|
const refs = [];
|
|
18
19
|
for (const kind of ['resolvers', 'evaluators', 'fixers', 'formatters']) {
|
|
19
20
|
for (const path of extensions[kind] ?? []) {
|
|
20
|
-
refs.push({ kind, presetName, absPath: resolve(
|
|
21
|
+
refs.push({ kind, presetName: sourceName, absPath: resolve(sourceDir, path) });
|
|
21
22
|
}
|
|
22
23
|
}
|
|
23
24
|
return refs;
|
package/dist/config/loader.d.ts
CHANGED
|
@@ -37,6 +37,14 @@ export interface LoadedPreset {
|
|
|
37
37
|
*/
|
|
38
38
|
export declare function loadPreset(name: string, options: RuleLoaderOptions): Promise<LoadedPreset>;
|
|
39
39
|
export declare function loadPresetRules(name: string, options: RuleLoaderOptions): Promise<ConstraintRule[]>;
|
|
40
|
-
/**
|
|
41
|
-
|
|
40
|
+
/**
|
|
41
|
+
* Load a direct rule file from disk.
|
|
42
|
+
*
|
|
43
|
+
* Returns the normalized rules plus any extension modules the file declares in an
|
|
44
|
+
* `extensions` block, resolved to absolute paths. Rule-file extensions are treated
|
|
45
|
+
* exactly like preset extensions — pass the refs to {@link loadExtensionsIntoHost},
|
|
46
|
+
* which enforces the same `allowExtensions` trust gate. A single-rule file (one rule
|
|
47
|
+
* object, not a `rules:` array) cannot declare extensions and yields `extensions: []`.
|
|
48
|
+
*/
|
|
49
|
+
export declare function loadRuleFile(filePath: string, options?: RuleFileLoadOptions): Promise<LoadedPreset>;
|
|
42
50
|
//# sourceMappingURL=loader.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"loader.d.ts","sourceRoot":"","sources":["../../src/config/loader.ts"],"names":[],"mappings":"AAEA,OAAO,EACH,KAAK,cAAc,
|
|
1
|
+
{"version":3,"file":"loader.d.ts","sourceRoot":"","sources":["../../src/config/loader.ts"],"names":[],"mappings":"AAEA,OAAO,EACH,KAAK,cAAc,EAOtB,MAAM,UAAU,CAAC;AAClB,OAAO,EAAqB,KAAK,YAAY,EAAE,MAAM,cAAc,CAAC;AAEpE,wCAAwC;AACxC,MAAM,WAAW,iBAAiB;IAC9B;;;;;;OAMG;IACH,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,4FAA4F;IAC5F,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,oEAAoE;IACpE,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;CAChD;AAED,MAAM,WAAW,mBAAmB;IAChC,mEAAmE;IACnE,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,oEAAoE;IACpE,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;CAChD;AAED,mFAAmF;AACnF,MAAM,WAAW,YAAY;IACzB,+DAA+D;IAC/D,QAAQ,CAAC,KAAK,EAAE,cAAc,EAAE,CAAC;IACjC,kFAAkF;IAClF,QAAQ,CAAC,UAAU,EAAE,YAAY,EAAE,CAAC;CACvC;AAUD;;;;;;GAMG;AACH,wBAAsB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC,YAAY,CAAC,CAahG;AAyCD,wBAAsB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC,cAAc,EAAE,CAAC,CAEzG;AAED;;;;;;;;GAQG;AACH,wBAAsB,YAAY,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,GAAE,mBAAwB,GAAG,OAAO,CAAC,YAAY,CAAC,CAS7G"}
|
package/dist/config/loader.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
|
|
2
2
|
import { loadStructuredConfig, NodeFileSystem } from '@gobing-ai/ts-runtime';
|
|
3
3
|
import { ConstraintRuleFileSchema, ConstraintRuleSchema, PresetDefinitionSchema, } from '../types.js';
|
|
4
|
-
import {
|
|
4
|
+
import { collectExtensions } from './extensions.js';
|
|
5
5
|
/**
|
|
6
6
|
* Load and normalize a preset by name, resolving across one or more rule roots.
|
|
7
7
|
*
|
|
@@ -16,29 +16,64 @@ export async function loadPreset(name, options) {
|
|
|
16
16
|
return { rules: [], extensions: [] };
|
|
17
17
|
const preset = PresetDefinitionSchema.parse(await readStructuredFile(presetPath, options));
|
|
18
18
|
const rules = [];
|
|
19
|
-
const extensions =
|
|
19
|
+
const extensions = collectExtensions(preset.name, dirname(presetPath), preset.extensions);
|
|
20
20
|
for (const entry of preset.extends) {
|
|
21
21
|
const loaded = await loadPresetEntry(merged, entry, new Set([name]), options);
|
|
22
22
|
rules.push(...loaded.rules);
|
|
23
23
|
extensions.push(...loaded.extensions);
|
|
24
24
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
return { rules: applyPresetControls(preset.name, rules, preset.disable, preset.overrides), extensions };
|
|
26
|
+
}
|
|
27
|
+
/** Collapse rules sharing an id to a single entry; later definitions win (last-wins merge). */
|
|
28
|
+
function dedupeById(rules) {
|
|
29
|
+
const byId = new Map();
|
|
30
|
+
for (const rule of rules)
|
|
31
|
+
byId.set(rule.id, rule);
|
|
32
|
+
return [...byId.values()];
|
|
33
|
+
}
|
|
34
|
+
/** Apply a preset's local disable and override controls after composing its children. */
|
|
35
|
+
function applyPresetControls(presetName, rules, disabledIds, overrides) {
|
|
36
|
+
const disabled = new Set(disabledIds ?? []);
|
|
37
|
+
const controlled = dedupeById(rules).filter((rule) => !disabled.has(rule.id));
|
|
38
|
+
for (const rule of controlled) {
|
|
39
|
+
const override = overrides?.[rule.id];
|
|
29
40
|
if (override?.fix !== undefined) {
|
|
41
|
+
assertFixModeNotPromoted(presetName, rule, override.fix.mode);
|
|
30
42
|
rule.fix = { ...(rule.fix ?? { mode: 'none' }), ...override.fix };
|
|
31
43
|
}
|
|
32
44
|
}
|
|
33
|
-
return
|
|
45
|
+
return controlled;
|
|
46
|
+
}
|
|
47
|
+
/** Fix-mode authority ordering; an override may lower but never raise a rule's mode. */
|
|
48
|
+
const FIX_MODE_AUTHORITY = { none: 0, suggest: 1, auto: 2 };
|
|
49
|
+
/** Throw when a preset override would escalate a rule's fix authority above its authored level. */
|
|
50
|
+
function assertFixModeNotPromoted(presetName, rule, overrideMode) {
|
|
51
|
+
const ruleMode = rule.fix?.mode ?? 'none';
|
|
52
|
+
if (FIX_MODE_AUTHORITY[overrideMode] > FIX_MODE_AUTHORITY[ruleMode]) {
|
|
53
|
+
throw new Error(`Preset "${presetName}" override for rule "${rule.id}" raises fix mode from "${ruleMode}" to "${overrideMode}"; overrides may only lower fix authority`);
|
|
54
|
+
}
|
|
34
55
|
}
|
|
35
56
|
export async function loadPresetRules(name, options) {
|
|
36
57
|
return (await loadPreset(name, options)).rules;
|
|
37
58
|
}
|
|
38
|
-
/**
|
|
59
|
+
/**
|
|
60
|
+
* Load a direct rule file from disk.
|
|
61
|
+
*
|
|
62
|
+
* Returns the normalized rules plus any extension modules the file declares in an
|
|
63
|
+
* `extensions` block, resolved to absolute paths. Rule-file extensions are treated
|
|
64
|
+
* exactly like preset extensions — pass the refs to {@link loadExtensionsIntoHost},
|
|
65
|
+
* which enforces the same `allowExtensions` trust gate. A single-rule file (one rule
|
|
66
|
+
* object, not a `rules:` array) cannot declare extensions and yields `extensions: []`.
|
|
67
|
+
*/
|
|
39
68
|
export async function loadRuleFile(filePath, options = {}) {
|
|
40
69
|
const resolved = resolve(filePath);
|
|
41
|
-
|
|
70
|
+
const raw = await readStructuredFile(resolved, options);
|
|
71
|
+
const rules = normalizeRuleFile(raw, resolved);
|
|
72
|
+
const parsed = ConstraintRuleFileSchema.safeParse(raw);
|
|
73
|
+
const extensions = parsed.success
|
|
74
|
+
? collectExtensions(basename(resolved), dirname(resolved), parsed.data.extensions)
|
|
75
|
+
: [];
|
|
76
|
+
return { rules, extensions };
|
|
42
77
|
}
|
|
43
78
|
async function loadPresetEntry(merged, entry, seen, options) {
|
|
44
79
|
// Sub-preset reference — recurse, erroring on a genuine cycle.
|
|
@@ -51,20 +86,23 @@ async function loadPresetEntry(merged, entry, seen, options) {
|
|
|
51
86
|
const preset = PresetDefinitionSchema.safeParse(await readStructuredFile(presetPath, options));
|
|
52
87
|
if (preset.success) {
|
|
53
88
|
const rules = [];
|
|
54
|
-
const extensions =
|
|
89
|
+
const extensions = collectExtensions(preset.data.name, dirname(presetPath), preset.data.extensions);
|
|
55
90
|
for (const child of preset.data.extends) {
|
|
56
91
|
const loaded = await loadPresetEntry(merged, child, nextSeen, options);
|
|
57
92
|
rules.push(...loaded.rules);
|
|
58
93
|
extensions.push(...loaded.extensions);
|
|
59
94
|
}
|
|
60
|
-
return {
|
|
95
|
+
return {
|
|
96
|
+
rules: applyPresetControls(preset.data.name, rules, preset.data.disable, preset.data.overrides),
|
|
97
|
+
extensions,
|
|
98
|
+
};
|
|
61
99
|
}
|
|
62
100
|
}
|
|
63
101
|
// Category folder reference — load every winning file under that prefix.
|
|
64
102
|
if (merged.categories.has(entry)) {
|
|
65
103
|
const rules = [];
|
|
66
104
|
for (const absPath of mergedFilesInCategory(merged, entry)) {
|
|
67
|
-
rules.push(...normalizeRuleFile(await readStructuredFile(absPath, options),
|
|
105
|
+
rules.push(...normalizeRuleFile(await readStructuredFile(absPath, options), absPath));
|
|
68
106
|
}
|
|
69
107
|
return { rules, extensions: [] };
|
|
70
108
|
}
|
|
@@ -72,7 +110,7 @@ async function loadPresetEntry(merged, entry, seen, options) {
|
|
|
72
110
|
const subPath = findMergedFile(merged, entry);
|
|
73
111
|
if (subPath !== null)
|
|
74
112
|
return {
|
|
75
|
-
rules: normalizeRuleFile(await readStructuredFile(subPath, options),
|
|
113
|
+
rules: normalizeRuleFile(await readStructuredFile(subPath, options), subPath),
|
|
76
114
|
extensions: [],
|
|
77
115
|
};
|
|
78
116
|
return { rules: [], extensions: [] };
|
|
@@ -175,23 +213,39 @@ async function readStructuredFile(path, options = {}) {
|
|
|
175
213
|
fetch: options.fetch,
|
|
176
214
|
});
|
|
177
215
|
}
|
|
178
|
-
function normalizeRuleFile(raw,
|
|
216
|
+
function normalizeRuleFile(raw, filePath) {
|
|
217
|
+
const sourceDir = dirname(filePath);
|
|
179
218
|
const maybeFile = ConstraintRuleFileSchema.safeParse(raw);
|
|
180
219
|
if (maybeFile.success)
|
|
181
220
|
return normalizeFileRules(maybeFile.data, sourceDir);
|
|
182
221
|
const maybeRule = ConstraintRuleSchema.safeParse(raw);
|
|
183
222
|
if (maybeRule.success)
|
|
184
|
-
return [normalizeRule(maybeRule.data,
|
|
185
|
-
|
|
223
|
+
return [normalizeRule(maybeRule.data, sourceDir)];
|
|
224
|
+
// Surface the schema diagnostics that best fit the input: a `rules:` array means
|
|
225
|
+
// the author intended a rule file, so report against that schema; otherwise the
|
|
226
|
+
// single-rule schema. Include field paths so the offending key is obvious.
|
|
227
|
+
const isRuleFileShape = typeof raw === 'object' && raw !== null && 'rules' in raw;
|
|
228
|
+
const issues = (isRuleFileShape ? maybeFile.error : maybeRule.error).issues;
|
|
229
|
+
throw new Error(`Invalid rule file "${basename(filePath)}": ${formatIssues(issues)}`);
|
|
230
|
+
}
|
|
231
|
+
/** Render Zod issues as `path: message` fragments for actionable diagnostics. */
|
|
232
|
+
function formatIssues(issues) {
|
|
233
|
+
return issues
|
|
234
|
+
.map((issue) => {
|
|
235
|
+
const path = issue.path.map(String).join('.');
|
|
236
|
+
return path.length > 0 ? `${path}: ${issue.message}` : issue.message;
|
|
237
|
+
})
|
|
238
|
+
.join('; ');
|
|
186
239
|
}
|
|
187
240
|
function normalizeFileRules(file, sourceDir) {
|
|
188
241
|
return file.rules.map((rule) => normalizeRule({
|
|
189
242
|
...rule,
|
|
190
|
-
|
|
243
|
+
// Rule-level severity wins, then the file-level default, then 'error'.
|
|
244
|
+
severity: rule.severity ?? file.severity,
|
|
191
245
|
include: rule.include ?? file.include,
|
|
192
246
|
// File-level excludes always apply; a rule's own excludes add to (not replace) them.
|
|
193
247
|
exclude: mergeExcludes(file.exclude, rule.exclude),
|
|
194
|
-
},
|
|
248
|
+
}, sourceDir));
|
|
195
249
|
}
|
|
196
250
|
/** Union of file-level and rule-level excludes, de-duplicated. Returns undefined when both empty. */
|
|
197
251
|
function mergeExcludes(fileExclude, ruleExclude) {
|
|
@@ -199,7 +253,7 @@ function mergeExcludes(fileExclude, ruleExclude) {
|
|
|
199
253
|
return undefined;
|
|
200
254
|
return [...new Set([...(fileExclude ?? []), ...(ruleExclude ?? [])])];
|
|
201
255
|
}
|
|
202
|
-
function normalizeRule(rule,
|
|
256
|
+
function normalizeRule(rule, _sourceDir) {
|
|
203
257
|
return {
|
|
204
258
|
...rule,
|
|
205
259
|
enabled: rule.enabled ?? true,
|
package/dist/engine.d.ts
CHANGED
|
@@ -18,7 +18,14 @@ export declare class RuleEngine {
|
|
|
18
18
|
constructor(options?: RuleEngineOptions);
|
|
19
19
|
/** Register or replace an evaluator. */
|
|
20
20
|
registerEvaluator(type: string, evaluator: RuleEvaluator): void;
|
|
21
|
-
/**
|
|
21
|
+
/**
|
|
22
|
+
* Evaluate all enabled rules against a working directory.
|
|
23
|
+
*
|
|
24
|
+
* Thin delegate to {@link evaluateWithFixes} with `maxFixMode = 'none'`; the fix
|
|
25
|
+
* branch in that path is short-circuited by `effectiveFixMode` so callers see only
|
|
26
|
+
* findings, never auto-generated fixes. Keeps the rule loop and error-finding
|
|
27
|
+
* semantics in one place.
|
|
28
|
+
*/
|
|
22
29
|
evaluate(rules: ConstraintRule[], workdir: string): Promise<RuleEngineResult>;
|
|
23
30
|
/**
|
|
24
31
|
* Evaluate all enabled rules and collect candidate fixes.
|
package/dist/engine.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAC7D,OAAO,EAKH,KAAK,oBAAoB,EAE5B,MAAM,iBAAiB,CAAC;AAEzB,OAAO,EAAE,cAAc,EAAE,MAAM,yBAAyB,CAAC;AACzD,OAAO,KAAK,EAAqB,cAAc,EAAE,GAAG,EAAE,OAAO,EAAE,gBAAgB,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AAGhH,6CAA6C;AAC7C,MAAM,WAAW,iBAAiB;IAC9B,+DAA+D;IAC/D,eAAe,CAAC,EAAE,eAAe,CAAC;IAClC,mCAAmC;IACnC,IAAI,CAAC,EAAE,cAAc,CAAC;CACzB;AAED,4EAA4E;AAC5E,qBAAa,UAAU;IACnB,2CAA2C;IAC3C,QAAQ,CAAC,IAAI,EAAE,cAAc,CAAC;IAE9B,+CAA+C;IAC/C,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAiC;gBAE5C,OAAO,GAAE,iBAAsB;IAM3C,wCAAwC;IACxC,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,aAAa,GAAG,IAAI;IAI/D
|
|
1
|
+
{"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAC7D,OAAO,EAKH,KAAK,oBAAoB,EAE5B,MAAM,iBAAiB,CAAC;AAEzB,OAAO,EAAE,cAAc,EAAE,MAAM,yBAAyB,CAAC;AACzD,OAAO,KAAK,EAAqB,cAAc,EAAE,GAAG,EAAE,OAAO,EAAE,gBAAgB,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AAGhH,6CAA6C;AAC7C,MAAM,WAAW,iBAAiB;IAC9B,+DAA+D;IAC/D,eAAe,CAAC,EAAE,eAAe,CAAC;IAClC,mCAAmC;IACnC,IAAI,CAAC,EAAE,cAAc,CAAC;CACzB;AAED,4EAA4E;AAC5E,qBAAa,UAAU;IACnB,2CAA2C;IAC3C,QAAQ,CAAC,IAAI,EAAE,cAAc,CAAC;IAE9B,+CAA+C;IAC/C,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAiC;gBAE5C,OAAO,GAAE,iBAAsB;IAM3C,wCAAwC;IACxC,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,aAAa,GAAG,IAAI;IAI/D;;;;;;;OAOG;IACG,QAAQ,CAAC,KAAK,EAAE,cAAc,EAAE,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,CAAC;IAInF;;;;;;;;;;;OAWG;IACG,iBAAiB,CACnB,KAAK,EAAE,cAAc,EAAE,EACvB,OAAO,EAAE,MAAM,EACf,UAAU,GAAE,OAAgB,GAC7B,OAAO,CAAC,gBAAgB,CAAC;IAkD5B;;;;;;;OAOG;IACG,UAAU,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,GAAG,EAAE,EAAE,MAAM,UAAQ,GAAG,OAAO,CAAC,oBAAoB,CAAC;CAG1G"}
|
package/dist/engine.js
CHANGED
|
@@ -17,26 +17,16 @@ export class RuleEngine {
|
|
|
17
17
|
registerEvaluator(type, evaluator) {
|
|
18
18
|
this.host.evaluators.register(type, evaluator, 'extension');
|
|
19
19
|
}
|
|
20
|
-
/**
|
|
20
|
+
/**
|
|
21
|
+
* Evaluate all enabled rules against a working directory.
|
|
22
|
+
*
|
|
23
|
+
* Thin delegate to {@link evaluateWithFixes} with `maxFixMode = 'none'`; the fix
|
|
24
|
+
* branch in that path is short-circuited by `effectiveFixMode` so callers see only
|
|
25
|
+
* findings, never auto-generated fixes. Keeps the rule loop and error-finding
|
|
26
|
+
* semantics in one place.
|
|
27
|
+
*/
|
|
21
28
|
async evaluate(rules, workdir) {
|
|
22
|
-
|
|
23
|
-
const fixes = [];
|
|
24
|
-
for (const rule of rules) {
|
|
25
|
-
if (rule.enabled === false)
|
|
26
|
-
continue;
|
|
27
|
-
try {
|
|
28
|
-
const result = await this.host.evaluators.get(rule.evaluator.type).evaluate(rule, { rule, workdir });
|
|
29
|
-
findings.push(...result.findings);
|
|
30
|
-
fixes.push(...result.fixes);
|
|
31
|
-
}
|
|
32
|
-
catch (error) {
|
|
33
|
-
findings.push(createFinding(rule, error instanceof Error ? error.message : String(error), null, {
|
|
34
|
-
code: `evaluator:${rule.evaluator.type}`,
|
|
35
|
-
kind: 'error',
|
|
36
|
-
}));
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
return { findings, fixes };
|
|
29
|
+
return this.evaluateWithFixes(rules, workdir, 'none');
|
|
40
30
|
}
|
|
41
31
|
/**
|
|
42
32
|
* Evaluate all enabled rules and collect candidate fixes.
|
|
@@ -77,18 +77,27 @@ function parseLcov(raw) {
|
|
|
77
77
|
linesHit = 0;
|
|
78
78
|
}
|
|
79
79
|
else if (trimmed.startsWith('LF:')) {
|
|
80
|
-
linesFound =
|
|
80
|
+
linesFound = parseCount(trimmed.slice(3));
|
|
81
81
|
}
|
|
82
82
|
else if (trimmed.startsWith('LH:')) {
|
|
83
|
-
linesHit =
|
|
83
|
+
linesHit = parseCount(trimmed.slice(3));
|
|
84
84
|
}
|
|
85
85
|
else if (trimmed === 'end_of_record' && file !== null) {
|
|
86
|
-
|
|
86
|
+
// Skip records with malformed counts: a non-numeric LF:/LH: would
|
|
87
|
+
// otherwise yield NaN coverage and a spurious below-threshold finding.
|
|
88
|
+
if (linesFound !== null && linesHit !== null) {
|
|
89
|
+
result.set(file, { linesFound, linesHit });
|
|
90
|
+
}
|
|
87
91
|
file = null;
|
|
88
92
|
}
|
|
89
93
|
}
|
|
90
94
|
return result;
|
|
91
95
|
}
|
|
96
|
+
/** Parse an lcov count field; return null for non-finite or negative values. */
|
|
97
|
+
function parseCount(raw) {
|
|
98
|
+
const value = Number(raw);
|
|
99
|
+
return Number.isFinite(value) && value >= 0 ? value : null;
|
|
100
|
+
}
|
|
92
101
|
/** Standard exclusions applied regardless of config (tests, generated, deps). */
|
|
93
102
|
function isAlwaysExcluded(filePath) {
|
|
94
103
|
return (filePath.includes('node_modules') ||
|
|
@@ -14,6 +14,46 @@ export interface SourceDiscoveryOptions {
|
|
|
14
14
|
export declare function discoverFiles(options: SourceDiscoveryOptions): Promise<string[]>;
|
|
15
15
|
/** Read a file from a workdir-relative path. */
|
|
16
16
|
export declare function readWorkdirFile(workdir: string, filePath: string, fs?: NodeFileSystem): Promise<string>;
|
|
17
|
+
/** A discovered in-scope file paired with its contents. */
|
|
18
|
+
export interface ScannedFile {
|
|
19
|
+
/** Workdir-relative path. */
|
|
20
|
+
readonly file: string;
|
|
21
|
+
/** Full file contents. */
|
|
22
|
+
readonly content: string;
|
|
23
|
+
}
|
|
24
|
+
/** How `scanFiles` matches `include` / `exclude` against discovered paths. */
|
|
25
|
+
export type ScanMatchMode = 'loose' | 'glob';
|
|
26
|
+
/** Options for {@link scanFiles}. */
|
|
27
|
+
export interface ScanFilesOptions {
|
|
28
|
+
/** Working directory to walk. */
|
|
29
|
+
workdir: string;
|
|
30
|
+
/** Include patterns; semantics depend on `matchMode`. Undefined/empty = all files. */
|
|
31
|
+
include?: string[];
|
|
32
|
+
/** Exclude patterns; semantics depend on `matchMode`. */
|
|
33
|
+
exclude?: string[];
|
|
34
|
+
/**
|
|
35
|
+
* Scope matching policy:
|
|
36
|
+
* - `loose` — substring/suffix fragments via {@link matchesAny} (back-compat for
|
|
37
|
+
* evaluators that historically accepted bare fragments like `.ts` or `src/`).
|
|
38
|
+
* - `glob` — anchored `**`/`*` globs via {@link matchesGlob}.
|
|
39
|
+
*
|
|
40
|
+
* The two are NOT interchangeable: a bare `src/` matches different sets under each.
|
|
41
|
+
* Each evaluator declares the mode that preserves its existing behavior.
|
|
42
|
+
*/
|
|
43
|
+
matchMode: ScanMatchMode;
|
|
44
|
+
/** Filesystem adapter. */
|
|
45
|
+
fs?: FileSystem;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Discover in-scope files and read each once — the shared scaffolding behind the
|
|
49
|
+
* line-scanning evaluators. Owns discovery, scope filtering (per `matchMode`), and
|
|
50
|
+
* reads, so each evaluator is left with only its own matcher.
|
|
51
|
+
*
|
|
52
|
+
* Scope is a parameter, not assumed one-per-rule: callers that scan under several
|
|
53
|
+
* scopes (e.g. import boundaries) pass no `include` here and apply their own globs to
|
|
54
|
+
* the returned paths.
|
|
55
|
+
*/
|
|
56
|
+
export declare function scanFiles(options: ScanFilesOptions): Promise<ScannedFile[]>;
|
|
17
57
|
/** Ensure a path is workdir-relative for findings. */
|
|
18
58
|
export declare function relativeToWorkdir(workdir: string, path: string): string;
|
|
19
59
|
/** Return parent directory for a workdir-relative path. */
|
|
@@ -28,4 +68,19 @@ export declare function matchesAny(path: string, patterns: string[] | undefined)
|
|
|
28
68
|
* (coverage scoping, test-file location) where a loose match would change findings.
|
|
29
69
|
*/
|
|
30
70
|
export declare function matchesGlob(path: string, pattern: string): boolean;
|
|
71
|
+
/** Escape a string for safe literal use inside a `RegExp` source. */
|
|
72
|
+
export declare function escapeRegExp(value: string): string;
|
|
73
|
+
/** Return the value as a `string[]` when every item is a string, otherwise undefined. */
|
|
74
|
+
export declare function stringArray(value: unknown): string[] | undefined;
|
|
75
|
+
/**
|
|
76
|
+
* Split a leading ripgrep/PCRE-style `(?flags)` inline group off a regex source.
|
|
77
|
+
*
|
|
78
|
+
* Returns the JS-relevant flags found in the group (filtered to `imsu`) and the
|
|
79
|
+
* remaining source with the group removed. When no leading group is present, returns
|
|
80
|
+
* empty flags and the source unchanged. Shared by evaluators that accept inline flags.
|
|
81
|
+
*/
|
|
82
|
+
export declare function parseInlineFlags(source: string): {
|
|
83
|
+
flags: string;
|
|
84
|
+
rest: string;
|
|
85
|
+
};
|
|
31
86
|
//# sourceMappingURL=file-utils.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"file-utils.d.ts","sourceRoot":"","sources":["../../src/evaluators/file-utils.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,KAAK,UAAU,EAAE,cAAc,EAAW,MAAM,uBAAuB,CAAC;AAEjF,yCAAyC;AACzC,MAAM,WAAW,sBAAsB;IACnC,yBAAyB;IACzB,OAAO,EAAE,MAAM,CAAC;IAChB,0CAA0C;IAC1C,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IACnB,8BAA8B;IAC9B,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IACnB,0BAA0B;IAC1B,EAAE,CAAC,EAAE,UAAU,CAAC;CACnB;AAID,qFAAqF;AACrF,wBAAsB,aAAa,CAAC,OAAO,EAAE,sBAAsB,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAWtF;AAED,gDAAgD;AAChD,wBAAsB,eAAe,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,iBAAuB,GAAG,OAAO,CAAC,MAAM,CAAC,CAEnH;AAED,sDAAsD;AACtD,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAEvE;AAED,2DAA2D;AAC3D,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAGnD;AAED,uEAAuE;AACvE,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,GAAG,SAAS,GAAG,OAAO,CAMhF;AAED;;;;;;GAMG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAUlE"}
|
|
1
|
+
{"version":3,"file":"file-utils.d.ts","sourceRoot":"","sources":["../../src/evaluators/file-utils.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,KAAK,UAAU,EAAE,cAAc,EAAW,MAAM,uBAAuB,CAAC;AAEjF,yCAAyC;AACzC,MAAM,WAAW,sBAAsB;IACnC,yBAAyB;IACzB,OAAO,EAAE,MAAM,CAAC;IAChB,0CAA0C;IAC1C,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IACnB,8BAA8B;IAC9B,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IACnB,0BAA0B;IAC1B,EAAE,CAAC,EAAE,UAAU,CAAC;CACnB;AAID,qFAAqF;AACrF,wBAAsB,aAAa,CAAC,OAAO,EAAE,sBAAsB,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAWtF;AAED,gDAAgD;AAChD,wBAAsB,eAAe,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,iBAAuB,GAAG,OAAO,CAAC,MAAM,CAAC,CAEnH;AAED,2DAA2D;AAC3D,MAAM,WAAW,WAAW;IACxB,6BAA6B;IAC7B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,0BAA0B;IAC1B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC5B;AAED,8EAA8E;AAC9E,MAAM,MAAM,aAAa,GAAG,OAAO,GAAG,MAAM,CAAC;AAE7C,qCAAqC;AACrC,MAAM,WAAW,gBAAgB;IAC7B,iCAAiC;IACjC,OAAO,EAAE,MAAM,CAAC;IAChB,sFAAsF;IACtF,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IACnB,yDAAyD;IACzD,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IACnB;;;;;;;;OAQG;IACH,SAAS,EAAE,aAAa,CAAC;IACzB,0BAA0B;IAC1B,EAAE,CAAC,EAAE,UAAU,CAAC;CACnB;AAED;;;;;;;;GAQG;AACH,wBAAsB,SAAS,CAAC,OAAO,EAAE,gBAAgB,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC,CAWjF;AAeD,sDAAsD;AACtD,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAEvE;AAED,2DAA2D;AAC3D,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAGnD;AAED,uEAAuE;AACvE,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,GAAG,SAAS,GAAG,OAAO,CAMhF;AAED;;;;;;GAMG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAUlE;AAqBD,qEAAqE;AACrE,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAElD;AAED,yFAAyF;AACzF,wBAAgB,WAAW,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,EAAE,GAAG,SAAS,CAEhE;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,MAAM,GAAG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAKhF"}
|
|
@@ -15,6 +15,33 @@ export async function discoverFiles(options) {
|
|
|
15
15
|
export async function readWorkdirFile(workdir, filePath, fs = new NodeFileSystem()) {
|
|
16
16
|
return await fs.readFile(resolve(workdir, filePath));
|
|
17
17
|
}
|
|
18
|
+
/**
|
|
19
|
+
* Discover in-scope files and read each once — the shared scaffolding behind the
|
|
20
|
+
* line-scanning evaluators. Owns discovery, scope filtering (per `matchMode`), and
|
|
21
|
+
* reads, so each evaluator is left with only its own matcher.
|
|
22
|
+
*
|
|
23
|
+
* Scope is a parameter, not assumed one-per-rule: callers that scan under several
|
|
24
|
+
* scopes (e.g. import boundaries) pass no `include` here and apply their own globs to
|
|
25
|
+
* the returned paths.
|
|
26
|
+
*/
|
|
27
|
+
export async function scanFiles(options) {
|
|
28
|
+
const fs = options.fs ?? new NodeFileSystem();
|
|
29
|
+
const files = options.matchMode === 'loose'
|
|
30
|
+
? await discoverFiles({ workdir: options.workdir, include: options.include, exclude: options.exclude, fs })
|
|
31
|
+
: await discoverFilesByGlob(options.workdir, options.include, options.exclude, fs);
|
|
32
|
+
const scanned = [];
|
|
33
|
+
for (const file of files) {
|
|
34
|
+
scanned.push({ file, content: await readWorkdirFile(options.workdir, file, fs) });
|
|
35
|
+
}
|
|
36
|
+
return scanned;
|
|
37
|
+
}
|
|
38
|
+
/** Discover files then filter with anchored globs (strict mode for {@link scanFiles}). */
|
|
39
|
+
async function discoverFilesByGlob(workdir, include, exclude, fs) {
|
|
40
|
+
const all = await discoverFiles({ workdir, fs });
|
|
41
|
+
return all
|
|
42
|
+
.filter((file) => include === undefined || include.length === 0 || include.some((g) => matchesGlob(file, g)))
|
|
43
|
+
.filter((file) => exclude === undefined || !exclude.some((g) => matchesGlob(file, g)));
|
|
44
|
+
}
|
|
18
45
|
/** Ensure a path is workdir-relative for findings. */
|
|
19
46
|
export function relativeToWorkdir(workdir, path) {
|
|
20
47
|
return relative(workdir, resolve(path));
|
|
@@ -73,3 +100,25 @@ function matchSegment(segment, pattern) {
|
|
|
73
100
|
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replaceAll('*', '[^/]*');
|
|
74
101
|
return new RegExp(`^${escaped}$`).test(segment);
|
|
75
102
|
}
|
|
103
|
+
/** Escape a string for safe literal use inside a `RegExp` source. */
|
|
104
|
+
export function escapeRegExp(value) {
|
|
105
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
106
|
+
}
|
|
107
|
+
/** Return the value as a `string[]` when every item is a string, otherwise undefined. */
|
|
108
|
+
export function stringArray(value) {
|
|
109
|
+
return Array.isArray(value) && value.every((item) => typeof item === 'string') ? value : undefined;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Split a leading ripgrep/PCRE-style `(?flags)` inline group off a regex source.
|
|
113
|
+
*
|
|
114
|
+
* Returns the JS-relevant flags found in the group (filtered to `imsu`) and the
|
|
115
|
+
* remaining source with the group removed. When no leading group is present, returns
|
|
116
|
+
* empty flags and the source unchanged. Shared by evaluators that accept inline flags.
|
|
117
|
+
*/
|
|
118
|
+
export function parseInlineFlags(source) {
|
|
119
|
+
const match = /^\(\?([a-z]+)\)/.exec(source);
|
|
120
|
+
if (!match)
|
|
121
|
+
return { flags: '', rest: source };
|
|
122
|
+
const flags = [...(match[1] ?? '')].filter((flag) => 'imsu'.includes(flag)).join('');
|
|
123
|
+
return { flags, rest: source.slice(match[0].length) };
|
|
124
|
+
}
|
|
@@ -9,6 +9,11 @@ import { type ConstraintRule, type RuleContext, type RuleEvaluationResult, type
|
|
|
9
9
|
* entry is an exact `specifier` (also matching require/dynamic forms unless
|
|
10
10
|
* `includeRequire:false`) or a raw `pattern`, scoped by `scope.include` /
|
|
11
11
|
* `scope.exclude` globs.
|
|
12
|
+
*
|
|
13
|
+
* Trust assumption: rule config is trusted input. A raw `pattern` is compiled with
|
|
14
|
+
* `new RegExp` and run per line without a backtracking bound, so a
|
|
15
|
+
* catastrophic-backtracking pattern is the rule author's responsibility. Runtime
|
|
16
|
+
* ReDoS hardening is deferred (see task 0003).
|
|
12
17
|
*/
|
|
13
18
|
export declare class ForbiddenImportEvaluator implements RuleEvaluator {
|
|
14
19
|
/** Evaluate import/usage against the configured forbidden set. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"forbidden-import-evaluator.d.ts","sourceRoot":"","sources":["../../src/evaluators/forbidden-import-evaluator.ts"],"names":[],"mappings":"AAAA,OAAO,EACH,KAAK,cAAc,EAEnB,KAAK,WAAW,EAChB,KAAK,oBAAoB,EACzB,KAAK,aAAa,EACrB,MAAM,UAAU,CAAC;AAclB
|
|
1
|
+
{"version":3,"file":"forbidden-import-evaluator.d.ts","sourceRoot":"","sources":["../../src/evaluators/forbidden-import-evaluator.ts"],"names":[],"mappings":"AAAA,OAAO,EACH,KAAK,cAAc,EAEnB,KAAK,WAAW,EAChB,KAAK,oBAAoB,EACzB,KAAK,aAAa,EACrB,MAAM,UAAU,CAAC;AAclB;;;;;;;;;;;;;;;GAeG;AACH,qBAAa,wBAAyB,YAAW,aAAa;IAC1D,kEAAkE;IAC5D,QAAQ,CAAC,IAAI,EAAE,cAAc,EAAE,OAAO,EAAE,WAAW,GAAG,OAAO,CAAC,oBAAoB,CAAC;IAOzF,0FAA0F;YAC5E,cAAc;IAgC5B,4EAA4E;YAC9D,kBAAkB;CAiCnC"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createFinding, } from '../types.js';
|
|
2
|
-
import {
|
|
2
|
+
import { escapeRegExp, scanFiles, stringArray } from './file-utils.js';
|
|
3
3
|
/**
|
|
4
4
|
* Detects forbidden imports / API usage.
|
|
5
5
|
*
|
|
@@ -10,6 +10,11 @@ import { discoverFiles, matchesGlob, readWorkdirFile } from './file-utils.js';
|
|
|
10
10
|
* entry is an exact `specifier` (also matching require/dynamic forms unless
|
|
11
11
|
* `includeRequire:false`) or a raw `pattern`, scoped by `scope.include` /
|
|
12
12
|
* `scope.exclude` globs.
|
|
13
|
+
*
|
|
14
|
+
* Trust assumption: rule config is trusted input. A raw `pattern` is compiled with
|
|
15
|
+
* `new RegExp` and run per line without a backtracking bound, so a
|
|
16
|
+
* catastrophic-backtracking pattern is the rule author's responsibility. Runtime
|
|
17
|
+
* ReDoS hardening is deferred (see task 0003).
|
|
13
18
|
*/
|
|
14
19
|
export class ForbiddenImportEvaluator {
|
|
15
20
|
/** Evaluate import/usage against the configured forbidden set. */
|
|
@@ -22,14 +27,15 @@ export class ForbiddenImportEvaluator {
|
|
|
22
27
|
/** Legacy path: `{ patterns: string[] }`, substring-matched against import specifiers. */
|
|
23
28
|
async evaluateSimple(rule, context, config) {
|
|
24
29
|
const forbidden = arrayConfig(config, 'patterns');
|
|
25
|
-
const files = await
|
|
30
|
+
const files = await scanFiles({
|
|
26
31
|
workdir: context.workdir,
|
|
27
32
|
include: rule.include ?? ['.ts', '.tsx', '.js', '.jsx'],
|
|
28
33
|
exclude: rule.exclude,
|
|
34
|
+
matchMode: 'loose',
|
|
29
35
|
});
|
|
30
36
|
const findings = [];
|
|
31
|
-
for (const file of files) {
|
|
32
|
-
const lines =
|
|
37
|
+
for (const { file, content } of files) {
|
|
38
|
+
const lines = content.split('\n');
|
|
33
39
|
for (const [index, line] of lines.entries()) {
|
|
34
40
|
const imported = importSpecifier(line);
|
|
35
41
|
if (imported === undefined)
|
|
@@ -54,14 +60,11 @@ export class ForbiddenImportEvaluator {
|
|
|
54
60
|
}
|
|
55
61
|
const exclude = stringArray(scope?.exclude) ?? [];
|
|
56
62
|
const entries = config.forbidden.map(compileEntry);
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
const files = (await discoverFiles({ workdir: context.workdir }))
|
|
60
|
-
.filter((file) => include.some((glob) => matchesGlob(file, glob)))
|
|
61
|
-
.filter((file) => !exclude.some((glob) => matchesGlob(file, glob)));
|
|
63
|
+
// Anchored `**`-glob scoping: scanFiles' 'glob' mode applies matchesGlob precisely.
|
|
64
|
+
const files = await scanFiles({ workdir: context.workdir, include, exclude, matchMode: 'glob' });
|
|
62
65
|
const findings = [];
|
|
63
|
-
for (const file of files) {
|
|
64
|
-
const lines =
|
|
66
|
+
for (const { file, content } of files) {
|
|
67
|
+
const lines = content.split('\n');
|
|
65
68
|
for (const [index, line] of lines.entries()) {
|
|
66
69
|
const hit = entries.find((entry) => entry.regex.test(line));
|
|
67
70
|
if (hit !== undefined) {
|
|
@@ -91,9 +94,6 @@ function compileEntry(entry) {
|
|
|
91
94
|
}
|
|
92
95
|
return { regex: new RegExp(entry.pattern), label: entry.pattern };
|
|
93
96
|
}
|
|
94
|
-
function escapeRegExp(value) {
|
|
95
|
-
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
96
|
-
}
|
|
97
97
|
function arrayConfig(config, key) {
|
|
98
98
|
const value = config[key];
|
|
99
99
|
if (Array.isArray(value) && value.every((item) => typeof item === 'string'))
|
|
@@ -102,6 +102,3 @@ function arrayConfig(config, key) {
|
|
|
102
102
|
return [value];
|
|
103
103
|
throw new Error(`forbidden-import evaluator requires string[] config "${key}"`);
|
|
104
104
|
}
|
|
105
|
-
function stringArray(value) {
|
|
106
|
-
return Array.isArray(value) && value.every((item) => typeof item === 'string') ? value : undefined;
|
|
107
|
-
}
|
|
@@ -11,6 +11,11 @@ import { type ConstraintRule, type RuleContext, type RuleEvaluationResult, type
|
|
|
11
11
|
* - `scope` — glob pattern selecting files this boundary applies to.
|
|
12
12
|
* - `forbidden` — array of strings or `{ pattern, mode?, syntax? }` objects.
|
|
13
13
|
* - `exclude` — optional globs within the scope to ignore.
|
|
14
|
+
*
|
|
15
|
+
* Trust assumption: rule config is trusted input. A `pattern` is compiled with
|
|
16
|
+
* `new RegExp` and run per line without a backtracking bound, so a
|
|
17
|
+
* catastrophic-backtracking pattern is the rule author's responsibility. Runtime
|
|
18
|
+
* ReDoS hardening is deferred (see task 0003).
|
|
14
19
|
*/
|
|
15
20
|
export declare class ImportBoundaryEvaluator implements RuleEvaluator {
|
|
16
21
|
/** Evaluate import boundaries across all in-scope files. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"import-boundary-evaluator.d.ts","sourceRoot":"","sources":["../../src/evaluators/import-boundary-evaluator.ts"],"names":[],"mappings":"AAAA,OAAO,EACH,KAAK,cAAc,EAEnB,KAAK,WAAW,EAChB,KAAK,oBAAoB,EACzB,KAAK,aAAa,EACrB,MAAM,UAAU,CAAC;
|
|
1
|
+
{"version":3,"file":"import-boundary-evaluator.d.ts","sourceRoot":"","sources":["../../src/evaluators/import-boundary-evaluator.ts"],"names":[],"mappings":"AAAA,OAAO,EACH,KAAK,cAAc,EAEnB,KAAK,WAAW,EAChB,KAAK,oBAAoB,EACzB,KAAK,aAAa,EACrB,MAAM,UAAU,CAAC;AAUlB;;;;;;;;;;;;;;;;;GAiBG;AACH,qBAAa,uBAAwB,YAAW,aAAa;IACzD,4DAA4D;IACtD,QAAQ,CAAC,IAAI,EAAE,cAAc,EAAE,OAAO,EAAE,WAAW,GAAG,OAAO,CAAC,oBAAoB,CAAC;CAsC5F"}
|