@gobing-ai/ts-rule-engine 0.2.7 → 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.
Files changed (54) hide show
  1. package/README.md +565 -1
  2. package/dist/config/extensions.d.ts +7 -4
  3. package/dist/config/extensions.d.ts.map +1 -1
  4. package/dist/config/extensions.js +11 -6
  5. package/dist/config/loader.d.ts +29 -2
  6. package/dist/config/loader.d.ts.map +1 -1
  7. package/dist/config/loader.js +104 -34
  8. package/dist/engine.d.ts +8 -1
  9. package/dist/engine.d.ts.map +1 -1
  10. package/dist/engine.js +9 -19
  11. package/dist/evaluators/coverage-gate-evaluator.js +12 -3
  12. package/dist/evaluators/file-utils.d.ts +55 -0
  13. package/dist/evaluators/file-utils.d.ts.map +1 -1
  14. package/dist/evaluators/file-utils.js +49 -0
  15. package/dist/evaluators/forbidden-import-evaluator.d.ts +5 -0
  16. package/dist/evaluators/forbidden-import-evaluator.d.ts.map +1 -1
  17. package/dist/evaluators/forbidden-import-evaluator.js +14 -17
  18. package/dist/evaluators/import-boundary-evaluator.d.ts +5 -0
  19. package/dist/evaluators/import-boundary-evaluator.d.ts.map +1 -1
  20. package/dist/evaluators/import-boundary-evaluator.js +45 -15
  21. package/dist/evaluators/regex-evaluator.d.ts +9 -1
  22. package/dist/evaluators/regex-evaluator.d.ts.map +1 -1
  23. package/dist/evaluators/regex-evaluator.js +43 -14
  24. package/dist/evaluators/secrets-scanner-evaluator.d.ts +5 -0
  25. package/dist/evaluators/secrets-scanner-evaluator.d.ts.map +1 -1
  26. package/dist/evaluators/secrets-scanner-evaluator.js +13 -13
  27. package/dist/evaluators/tsdoc-export-evaluator.d.ts.map +1 -1
  28. package/dist/evaluators/tsdoc-export-evaluator.js +9 -11
  29. package/dist/formatters/json.d.ts.map +1 -1
  30. package/dist/formatters/json.js +2 -0
  31. package/dist/formatters/text.d.ts.map +1 -1
  32. package/dist/formatters/text.js +2 -0
  33. package/dist/resolvers/test-path-resolver.d.ts.map +1 -1
  34. package/dist/resolvers/test-path-resolver.js +5 -0
  35. package/dist/types.d.ts +28 -3
  36. package/dist/types.d.ts.map +1 -1
  37. package/dist/types.js +37 -9
  38. package/package.json +6 -3
  39. package/schemas/preset.schema.json +54 -0
  40. package/schemas/rule-file.schema.json +49 -0
  41. package/src/config/extensions.ts +16 -8
  42. package/src/config/loader.ts +151 -34
  43. package/src/engine.ts +9 -19
  44. package/src/evaluators/coverage-gate-evaluator.ts +15 -5
  45. package/src/evaluators/file-utils.ts +92 -0
  46. package/src/evaluators/forbidden-import-evaluator.ts +14 -19
  47. package/src/evaluators/import-boundary-evaluator.ts +56 -40
  48. package/src/evaluators/regex-evaluator.ts +43 -13
  49. package/src/evaluators/secrets-scanner-evaluator.ts +13 -14
  50. package/src/evaluators/tsdoc-export-evaluator.ts +10 -9
  51. package/src/formatters/json.ts +2 -0
  52. package/src/formatters/text.ts +2 -0
  53. package/src/resolvers/test-path-resolver.ts +5 -0
  54. package/src/types.ts +45 -9
@@ -0,0 +1,54 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://raw.githubusercontent.com/gobing-ai/ts-libs/main/packages/rule-engine/schemas/preset.schema.json",
4
+ "title": "@gobing-ai/ts-rule-engine Preset",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": ["name"],
8
+ "properties": {
9
+ "$schema": { "type": "string" },
10
+ "name": { "type": "string", "minLength": 1 },
11
+ "extends": { "type": "array", "items": { "type": "string" } },
12
+ "disable": { "type": "array", "items": { "type": "string" } },
13
+ "overrides": {
14
+ "type": "object",
15
+ "additionalProperties": {
16
+ "type": "object",
17
+ "additionalProperties": false,
18
+ "properties": {
19
+ "fix": {
20
+ "type": "object",
21
+ "additionalProperties": false,
22
+ "properties": {
23
+ "mode": { "enum": ["none", "suggest", "auto"] }
24
+ }
25
+ }
26
+ }
27
+ }
28
+ },
29
+ "extensions": {
30
+ "type": "object",
31
+ "additionalProperties": false,
32
+ "properties": {
33
+ "resolvers": { "type": "array", "items": { "$ref": "#/$defs/relativeExtensionPath" } },
34
+ "evaluators": { "type": "array", "items": { "$ref": "#/$defs/relativeExtensionPath" } },
35
+ "fixers": { "type": "array", "items": { "$ref": "#/$defs/relativeExtensionPath" } },
36
+ "formatters": { "type": "array", "items": { "$ref": "#/$defs/relativeExtensionPath" } }
37
+ }
38
+ }
39
+ },
40
+ "$defs": {
41
+ "relativeExtensionPath": {
42
+ "type": "string",
43
+ "minLength": 1,
44
+ "description": "Relative module path; absolute paths and '..' traversal are forbidden.",
45
+ "not": {
46
+ "anyOf": [
47
+ { "pattern": "^[/\\\\]" },
48
+ { "pattern": "^[A-Za-z]:[/\\\\]" },
49
+ { "pattern": "(^|[/\\\\])\\.\\.([/\\\\]|$)" }
50
+ ]
51
+ }
52
+ }
53
+ }
54
+ }
@@ -0,0 +1,49 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://raw.githubusercontent.com/gobing-ai/ts-libs/main/packages/rule-engine/schemas/rule-file.schema.json",
4
+ "title": "@gobing-ai/ts-rule-engine Rule File",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": ["rules"],
8
+ "properties": {
9
+ "$schema": { "type": "string" },
10
+ "include": { "type": "array", "items": { "type": "string" } },
11
+ "exclude": { "type": "array", "items": { "type": "string" } },
12
+ "severity": { "enum": ["error", "warning", "info"] },
13
+ "rules": { "type": "array", "items": { "$ref": "#/$defs/rule" } }
14
+ },
15
+ "$defs": {
16
+ "rule": {
17
+ "type": "object",
18
+ "additionalProperties": false,
19
+ "required": ["id", "evaluator"],
20
+ "properties": {
21
+ "id": { "type": "string" },
22
+ "description": { "type": "string" },
23
+ "enabled": { "type": "boolean" },
24
+ "severity": { "enum": ["error", "warning", "info"] },
25
+ "include": { "type": "array", "items": { "type": "string" } },
26
+ "exclude": { "type": "array", "items": { "type": "string" } },
27
+ "evaluator": {
28
+ "type": "object",
29
+ "additionalProperties": false,
30
+ "required": ["type"],
31
+ "properties": {
32
+ "type": { "type": "string" },
33
+ "config": { "type": "object", "additionalProperties": true }
34
+ }
35
+ },
36
+ "fix": { "$ref": "#/$defs/fix" }
37
+ }
38
+ },
39
+ "fix": {
40
+ "type": "object",
41
+ "additionalProperties": false,
42
+ "properties": {
43
+ "mode": { "enum": ["none", "suggest", "auto"] },
44
+ "replacement": { "type": "string" },
45
+ "params": { "type": "object", "additionalProperties": true }
46
+ }
47
+ }
48
+ }
49
+ }
@@ -24,6 +24,8 @@ export interface LoadExtensionsOptions {
24
24
  allowExtensions?: boolean;
25
25
  /** Optional sink for non-fatal warnings (e.g. built-in overrides). */
26
26
  logger?: { warn: (message: string) => void };
27
+ /** Optional module loader seam for tests or embedders with custom import policy. */
28
+ moduleLoader?: (absPath: string) => Promise<Record<string, unknown>>;
27
29
  }
28
30
 
29
31
  /** Host registries that can receive extension capabilities (fixers live on the engine, not the host). */
@@ -34,21 +36,22 @@ const HOST_REGISTRY_BY_KIND: Partial<Record<ExtensionKind, 'resolvers' | 'evalua
34
36
  };
35
37
 
36
38
  /**
37
- * 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.
38
40
  *
39
- * Paths are resolved relative to the preset file's directory. Use the returned
40
- * 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.
41
44
  */
42
- export function collectPresetExtensions(
43
- presetName: string,
44
- presetDir: string,
45
+ export function collectExtensions(
46
+ sourceName: string,
47
+ sourceDir: string,
45
48
  extensions: Partial<Record<ExtensionKind, string[] | undefined>> | undefined,
46
49
  ): ExtensionRef[] {
47
50
  if (extensions === undefined) return [];
48
51
  const refs: ExtensionRef[] = [];
49
52
  for (const kind of ['resolvers', 'evaluators', 'fixers', 'formatters'] as ExtensionKind[]) {
50
53
  for (const path of extensions[kind] ?? []) {
51
- refs.push({ kind, presetName, absPath: resolve(presetDir, path) });
54
+ refs.push({ kind, presetName: sourceName, absPath: resolve(sourceDir, path) });
52
55
  }
53
56
  }
54
57
  return refs;
@@ -79,8 +82,9 @@ export async function loadExtensionsIntoHost(
79
82
  );
80
83
  }
81
84
 
85
+ const loadModule = options.moduleLoader ?? defaultModuleLoader;
82
86
  for (const ref of refs) {
83
- const moduleExports = (await import(ref.absPath)) as Record<string, unknown>;
87
+ const moduleExports = await loadModule(ref.absPath);
84
88
  const candidate = moduleExports.default ?? moduleExports.extension;
85
89
  if (
86
90
  candidate === null ||
@@ -106,3 +110,7 @@ export async function loadExtensionsIntoHost(
106
110
  registry.register(name, candidate, 'extension');
107
111
  }
108
112
  }
113
+
114
+ async function defaultModuleLoader(absPath: string): Promise<Record<string, unknown>> {
115
+ return (await import(absPath)) as Record<string, unknown>;
116
+ }
@@ -1,14 +1,15 @@
1
- import { basename, dirname, extname, join, relative, resolve, sep } from 'node:path';
2
- import { NodeFileSystem } from '@gobing-ai/ts-runtime';
3
- import { parse } from 'yaml';
1
+ import { basename, dirname, join, relative, resolve, sep } from 'node:path';
2
+ import { loadStructuredConfig, NodeFileSystem } from '@gobing-ai/ts-runtime';
4
3
  import {
5
4
  type ConstraintRule,
6
5
  type ConstraintRuleFile,
7
6
  ConstraintRuleFileSchema,
8
7
  ConstraintRuleSchema,
8
+ type FixMode,
9
9
  type PresetDefinition,
10
10
  PresetDefinitionSchema,
11
11
  } from '../types';
12
+ import { collectExtensions, type ExtensionRef } from './extensions';
12
13
 
13
14
  /** Options for loading rule presets. */
14
15
  export interface RuleLoaderOptions {
@@ -20,6 +21,25 @@ export interface RuleLoaderOptions {
20
21
  * loader stays agnostic to any project layout convention.
21
22
  */
22
23
  roots: string[];
24
+ /** When true, honor top-level `$schema` refs in preset and rule files. Defaults to true. */
25
+ validateSchema?: boolean;
26
+ /** Optional fetch implementation for remote HTTP(S) schema refs. */
27
+ fetch?: (input: string) => Promise<Response>;
28
+ }
29
+
30
+ export interface RuleFileLoadOptions {
31
+ /** When true, honor top-level `$schema` refs. Defaults to true. */
32
+ validateSchema?: boolean;
33
+ /** Optional fetch implementation for remote HTTP(S) schema refs. */
34
+ fetch?: (input: string) => Promise<Response>;
35
+ }
36
+
37
+ /** Loaded preset rules plus extension module refs declared by composed presets. */
38
+ export interface LoadedPreset {
39
+ /** Normalized rules after preset disable/override handling. */
40
+ readonly rules: ConstraintRule[];
41
+ /** Extension modules declared by the preset graph, resolved to absolute paths. */
42
+ readonly extensions: ExtensionRef[];
23
43
  }
24
44
 
25
45
  /** Merged view of rule roots: winning file per relative path, plus categories. */
@@ -37,44 +57,110 @@ interface MergedRoots {
37
57
  * so a caller can layer project-local rules over shared/global rules and inherit
38
58
  * the rest of a preset's categories from the lower-priority roots.
39
59
  */
40
- export async function loadPresetRules(name: string, options: RuleLoaderOptions): Promise<ConstraintRule[]> {
60
+ export async function loadPreset(name: string, options: RuleLoaderOptions): Promise<LoadedPreset> {
41
61
  const merged = await buildMergedRoots(options.roots.map((root) => resolve(root)));
42
62
  const presetPath = findMergedPreset(merged, name);
43
- if (presetPath === null) return [];
44
- const preset = PresetDefinitionSchema.parse(await readStructuredFile(presetPath)) as PresetDefinition;
63
+ if (presetPath === null) return { rules: [], extensions: [] };
64
+ const preset = PresetDefinitionSchema.parse(await readStructuredFile(presetPath, options)) as PresetDefinition;
45
65
  const rules: ConstraintRule[] = [];
66
+ const extensions = collectExtensions(preset.name, dirname(presetPath), preset.extensions);
46
67
  for (const entry of preset.extends) {
47
- rules.push(...(await loadPresetEntry(merged, entry, new Set([name]))));
68
+ const loaded = await loadPresetEntry(merged, entry, new Set([name]), options);
69
+ rules.push(...loaded.rules);
70
+ extensions.push(...loaded.extensions);
48
71
  }
49
- const disabled = new Set(preset.disable ?? []);
50
- const normalized = rules.filter((rule) => !disabled.has(rule.id));
51
- for (const rule of normalized) {
52
- const override = preset.overrides?.[rule.id];
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];
53
93
  if (override?.fix !== undefined) {
94
+ assertFixModeNotPromoted(presetName, rule, override.fix.mode);
54
95
  rule.fix = { ...(rule.fix ?? { mode: 'none' }), ...override.fix };
55
96
  }
56
97
  }
57
- return normalized;
98
+ return controlled;
58
99
  }
59
100
 
60
- /** Load a direct rule file from disk. */
61
- export async function loadRuleFile(filePath: string): Promise<ConstraintRule[]> {
62
- return normalizeRuleFile(await readStructuredFile(resolve(filePath)), dirname(resolve(filePath)));
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
+ }
63
112
  }
64
113
 
65
- async function loadPresetEntry(merged: MergedRoots, entry: string, seen: Set<string>): Promise<ConstraintRule[]> {
114
+ export async function loadPresetRules(name: string, options: RuleLoaderOptions): Promise<ConstraintRule[]> {
115
+ return (await loadPreset(name, options)).rules;
116
+ }
117
+
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> {
128
+ const resolved = resolve(filePath);
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 };
136
+ }
137
+
138
+ async function loadPresetEntry(
139
+ merged: MergedRoots,
140
+ entry: string,
141
+ seen: Set<string>,
142
+ options: Pick<RuleLoaderOptions, 'validateSchema' | 'fetch'>,
143
+ ): Promise<LoadedPreset> {
66
144
  // Sub-preset reference — recurse, erroring on a genuine cycle.
67
145
  const presetPath = findMergedPreset(merged, entry);
68
146
  if (presetPath !== null) {
69
147
  if (seen.has(entry)) {
70
148
  throw new Error(`Circular preset dependency detected: ${[...seen, entry].join(' → ')}`);
71
149
  }
72
- seen.add(entry);
73
- const preset = PresetDefinitionSchema.safeParse(await readStructuredFile(presetPath));
150
+ const nextSeen = new Set([...seen, entry]);
151
+ const preset = PresetDefinitionSchema.safeParse(await readStructuredFile(presetPath, options));
74
152
  if (preset.success) {
75
153
  const rules: ConstraintRule[] = [];
76
- for (const child of preset.data.extends) rules.push(...(await loadPresetEntry(merged, child, seen)));
77
- return rules.filter((rule) => !(preset.data.disable ?? []).includes(rule.id));
154
+ const extensions = collectExtensions(preset.data.name, dirname(presetPath), preset.data.extensions);
155
+ for (const child of preset.data.extends) {
156
+ const loaded = await loadPresetEntry(merged, child, nextSeen, options);
157
+ rules.push(...loaded.rules);
158
+ extensions.push(...loaded.extensions);
159
+ }
160
+ return {
161
+ rules: applyPresetControls(preset.data.name, rules, preset.data.disable, preset.data.overrides),
162
+ extensions,
163
+ };
78
164
  }
79
165
  }
80
166
 
@@ -82,16 +168,20 @@ async function loadPresetEntry(merged: MergedRoots, entry: string, seen: Set<str
82
168
  if (merged.categories.has(entry)) {
83
169
  const rules: ConstraintRule[] = [];
84
170
  for (const absPath of mergedFilesInCategory(merged, entry)) {
85
- rules.push(...(await loadRuleFile(absPath)));
171
+ rules.push(...normalizeRuleFile(await readStructuredFile(absPath, options), absPath));
86
172
  }
87
- return rules;
173
+ return { rules, extensions: [] };
88
174
  }
89
175
 
90
176
  // Sub-path reference — a single winning rule file within a category.
91
177
  const subPath = findMergedFile(merged, entry);
92
- if (subPath !== null) return loadRuleFile(subPath);
178
+ if (subPath !== null)
179
+ return {
180
+ rules: normalizeRuleFile(await readStructuredFile(subPath, options), subPath),
181
+ extensions: [],
182
+ };
93
183
 
94
- return [];
184
+ return { rules: [], extensions: [] };
95
185
  }
96
186
 
97
187
  /**
@@ -183,30 +273,57 @@ async function listImmediateDirs(fs: NodeFileSystem, dir: string): Promise<strin
183
273
  return dirs;
184
274
  }
185
275
 
186
- async function readStructuredFile(path: string): Promise<unknown> {
187
- const content = await new NodeFileSystem().readFile(path);
188
- return extname(path) === '.json' ? JSON.parse(content) : parse(content);
276
+ async function readStructuredFile(
277
+ path: string,
278
+ options: Pick<RuleLoaderOptions, 'validateSchema' | 'fetch'> = {},
279
+ ): Promise<unknown> {
280
+ return await loadStructuredConfig(path, {
281
+ validateSchema: options.validateSchema,
282
+ fetch: options.fetch,
283
+ });
189
284
  }
190
285
 
191
- function normalizeRuleFile(raw: unknown, sourceDir: string): ConstraintRule[] {
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);
192
291
  const maybeFile = ConstraintRuleFileSchema.safeParse(raw);
193
292
  if (maybeFile.success) return normalizeFileRules(maybeFile.data, sourceDir);
194
293
  const maybeRule = ConstraintRuleSchema.safeParse(raw);
195
- if (maybeRule.success) return [normalizeRule(maybeRule.data, {}, sourceDir)];
196
- throw new Error(`Invalid rule file: ${basename(sourceDir)}`);
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)}`);
197
301
  }
198
302
 
199
- function normalizeFileRules(file: ConstraintRuleFile, sourceDir: string): ConstraintRule[] {
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[] {
200
317
  return file.rules.map((rule) =>
201
318
  normalizeRule(
202
319
  {
203
320
  ...rule,
204
- severity: rule.severity ?? file.severity ?? 'error',
321
+ // Rule-level severity wins, then the file-level default, then 'error'.
322
+ severity: rule.severity ?? file.severity,
205
323
  include: rule.include ?? file.include,
206
324
  // File-level excludes always apply; a rule's own excludes add to (not replace) them.
207
325
  exclude: mergeExcludes(file.exclude, rule.exclude),
208
326
  },
209
- {},
210
327
  sourceDir,
211
328
  ),
212
329
  );
@@ -218,7 +335,7 @@ function mergeExcludes(fileExclude?: string[], ruleExclude?: string[]): string[]
218
335
  return [...new Set([...(fileExclude ?? []), ...(ruleExclude ?? [])])];
219
336
  }
220
337
 
221
- function normalizeRule(rule: ConstraintRule, _defaults: Partial<ConstraintRule>, _sourceDir: string): ConstraintRule {
338
+ function normalizeRule(rule: ParsedRule, _sourceDir: string): ConstraintRule {
222
339
  return {
223
340
  ...rule,
224
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
- /** Evaluate all enabled rules against a working directory. */
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
- const findings: ConstraintFinding[] = [];
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 = Number(trimmed.slice(3));
112
+ linesFound = parseCount(trimmed.slice(3));
113
113
  } else if (trimmed.startsWith('LH:')) {
114
- linesHit = Number(trimmed.slice(3));
114
+ linesHit = parseCount(trimmed.slice(3));
115
115
  } else if (trimmed === 'end_of_record' && file !== null) {
116
- result.set(file, { linesFound, linesHit });
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
+ }