@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.
Files changed (67) hide show
  1. package/README.md +3 -1
  2. package/dist/config/extensions.d.ts +5 -4
  3. package/dist/config/extensions.d.ts.map +1 -1
  4. package/dist/config/extensions.js +6 -5
  5. package/dist/config/loader.d.ts +10 -2
  6. package/dist/config/loader.d.ts.map +1 -1
  7. package/dist/config/loader.js +73 -19
  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/host/bundled-rules.d.ts +26 -0
  34. package/dist/host/bundled-rules.d.ts.map +1 -0
  35. package/dist/host/bundled-rules.js +76 -0
  36. package/dist/index.d.ts +1 -0
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +1 -0
  39. package/dist/resolvers/test-path-resolver.d.ts.map +1 -1
  40. package/dist/resolvers/test-path-resolver.js +5 -0
  41. package/dist/types.d.ts +22 -3
  42. package/dist/types.d.ts.map +1 -1
  43. package/dist/types.js +35 -9
  44. package/package.json +6 -3
  45. package/rules/quality/coverage-gate.yaml +21 -0
  46. package/rules/recommended.yaml +10 -0
  47. package/rules/spur-dev.yaml +6 -0
  48. package/rules/structure/test-location.yaml +38 -0
  49. package/rules/typescript/no-biome-suppressions.yaml +23 -0
  50. package/rules/typescript/tsdoc-exports.yaml +24 -0
  51. package/schemas/preset.schema.json +19 -5
  52. package/src/config/extensions.ts +8 -7
  53. package/src/config/loader.ts +92 -21
  54. package/src/engine.ts +9 -19
  55. package/src/evaluators/coverage-gate-evaluator.ts +15 -5
  56. package/src/evaluators/file-utils.ts +92 -0
  57. package/src/evaluators/forbidden-import-evaluator.ts +14 -19
  58. package/src/evaluators/import-boundary-evaluator.ts +56 -40
  59. package/src/evaluators/regex-evaluator.ts +43 -13
  60. package/src/evaluators/secrets-scanner-evaluator.ts +13 -14
  61. package/src/evaluators/tsdoc-export-evaluator.ts +10 -9
  62. package/src/formatters/json.ts +2 -0
  63. package/src/formatters/text.ts +2 -0
  64. package/src/host/bundled-rules.ts +78 -0
  65. package/src/index.ts +1 -0
  66. package/src/resolvers/test-path-resolver.ts +5 -0
  67. package/src/types.ts +39 -9
package/dist/types.js CHANGED
@@ -22,7 +22,10 @@ export const ConstraintRuleSchema = z.object({
22
22
  id: z.string().min(1),
23
23
  description: z.string().default(''),
24
24
  enabled: z.boolean().default(true),
25
- severity: z.enum(['error', 'warning', 'info']).default('error'),
25
+ // Severity is intentionally NOT defaulted here: an omitted rule severity must
26
+ // stay absent at parse time so the loader can apply the file-level default
27
+ // (`rule.severity ?? file.severity ?? 'error'`). Normalization always fills it.
28
+ severity: z.enum(['error', 'warning', 'info']).optional(),
26
29
  evaluator: z.object({
27
30
  type: z.string().min(1),
28
31
  config: z.record(z.string(), z.unknown()).optional(),
@@ -31,6 +34,35 @@ export const ConstraintRuleSchema = z.object({
31
34
  exclude: z.array(z.string()).optional(),
32
35
  fix: RuleFixConfigSchema.optional(),
33
36
  });
37
+ /**
38
+ * A relative module path a preset or rule file may load as an extension.
39
+ *
40
+ * Rejects absolute paths and `..` traversal: extension declarations are data, and a
41
+ * path that escapes the declaring file's directory is a trust-boundary violation even
42
+ * when extension loading is explicitly allowed.
43
+ */
44
+ const relativeExtensionPath = z
45
+ .string()
46
+ .min(1)
47
+ .refine((value) => !/^([/\\]|[A-Za-z]:[/\\])/.test(value), {
48
+ message: 'extension path must be relative (no absolute paths)',
49
+ })
50
+ .refine((value) => !value.split(/[/\\]/).includes('..'), {
51
+ message: 'extension path must not contain ".." traversal',
52
+ });
53
+ /**
54
+ * Shared zod schema for an `extensions` block, used by both preset and rule-file
55
+ * schemas so they validate identically. `.strict()` makes a typo'd or misplaced key
56
+ * a hard error rather than a silently-ignored field.
57
+ */
58
+ export const ExtensionsSchema = z
59
+ .object({
60
+ resolvers: z.array(relativeExtensionPath).optional(),
61
+ evaluators: z.array(relativeExtensionPath).optional(),
62
+ fixers: z.array(relativeExtensionPath).optional(),
63
+ formatters: z.array(relativeExtensionPath).optional(),
64
+ })
65
+ .strict();
34
66
  /** Zod schema for a constraint rule file. */
35
67
  export const ConstraintRuleFileSchema = z.object({
36
68
  $schema: z.string().optional(),
@@ -38,6 +70,7 @@ export const ConstraintRuleFileSchema = z.object({
38
70
  exclude: z.array(z.string()).optional(),
39
71
  severity: z.enum(['error', 'warning', 'info']).optional(),
40
72
  rules: z.array(ConstraintRuleSchema),
73
+ extensions: ExtensionsSchema.optional(),
41
74
  });
42
75
  /** Zod schema for a preset definition. */
43
76
  export const PresetDefinitionSchema = z.object({
@@ -48,12 +81,5 @@ export const PresetDefinitionSchema = z.object({
48
81
  overrides: z
49
82
  .record(z.string(), z.object({ fix: z.object({ mode: z.enum(['none', 'suggest', 'auto']) }).optional() }))
50
83
  .optional(),
51
- extensions: z
52
- .object({
53
- resolvers: z.array(z.string()).optional(),
54
- evaluators: z.array(z.string()).optional(),
55
- fixers: z.array(z.string()).optional(),
56
- formatters: z.array(z.string()).optional(),
57
- })
58
- .optional(),
84
+ extensions: ExtensionsSchema.optional(),
59
85
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gobing-ai/ts-rule-engine",
3
- "version": "0.2.8",
3
+ "version": "0.3.0",
4
4
  "description": "@gobing-ai/ts-rule-engine — Constraint rule schemas, loading, evaluation, and result formatting.",
5
5
  "keywords": [
6
6
  "typescript",
@@ -32,6 +32,7 @@
32
32
  },
33
33
  "files": [
34
34
  "dist",
35
+ "rules",
35
36
  "schemas",
36
37
  "src",
37
38
  "README.md"
@@ -48,8 +49,10 @@
48
49
  "release": "echo 'Manual publish is disabled. Releases go through GitHub Actions via Trusted Publishing — push a tag: git tag @gobing-ai/ts-rule-engine-v<version> && git push --tags' && exit 1"
49
50
  },
50
51
  "dependencies": {
51
- "@gobing-ai/ts-ai-runner": "^0.2.8",
52
- "@gobing-ai/ts-runtime": "^0.2.8",
52
+ "@gobing-ai/ts-ai-runner": "^0.3.0",
53
+ "@gobing-ai/ts-db": "^0.3.0",
54
+ "@gobing-ai/ts-runtime": "^0.3.0",
55
+ "@gobing-ai/ts-utils": "^0.3.0",
53
56
  "yaml": "^2.7.0",
54
57
  "zod": "^4.1.0"
55
58
  },
@@ -0,0 +1,21 @@
1
+ # Coverage gate — per-file line coverage must meet threshold.
2
+ rules:
3
+ - id: coverage-gate
4
+ description: "Per-file line coverage meets threshold (read from lcov)"
5
+ severity: error
6
+ evaluator:
7
+ type: coverage-gate
8
+ config:
9
+ lcovPath: coverage/lcov.info
10
+ threshold: 90
11
+ include:
12
+ - "apps/**"
13
+ - "packages/**"
14
+ exclude:
15
+ - "**/*.test.ts"
16
+ - "**/*.test.tsx"
17
+ - "**/*.spec.ts"
18
+ - "**/*.spec.tsx"
19
+ - "**/node_modules/**"
20
+ - "**/drizzle/**"
21
+ - "scripts/**"
@@ -0,0 +1,10 @@
1
+ # Recommended preset — portable rule categories for TypeScript projects.
2
+ # Use with: spur rule run --preset recommended
3
+ #
4
+ # Bundled with @gobing-ai/ts-rule-engine. A project may override individual rule
5
+ # files by placing same-named files under its local .spur/rules/ root.
6
+ name: recommended
7
+ extends:
8
+ - typescript
9
+ - structure
10
+ - quality
@@ -0,0 +1,6 @@
1
+ # Development preset — stricter rules for development workflow.
2
+ # Use with: spur rule run --preset spur-dev --rule coverage-gate --fail-on warning
3
+ name: spur-dev
4
+ extends:
5
+ - typescript
6
+ - quality/coverage-gate
@@ -0,0 +1,38 @@
1
+ # Test location rules — tests must live in tests/ directories mirroring src/.
2
+ # No __tests__ directories, no .test.ts files under src/.
3
+ rules:
4
+ - id: no-tests-dir
5
+ description: "Tests must live in tests/ directories at the same level as src/, not in __tests__."
6
+ severity: error
7
+ evaluator:
8
+ type: test-location
9
+ config:
10
+ expected: "**/*.test.*"
11
+ forbid:
12
+ - "**/__tests__/**"
13
+ - "**/src/**/*.test.ts"
14
+ - "**/src/**/*.test.tsx"
15
+ requireCorrespondingTest: false
16
+ include:
17
+ - "packages/**/tests/**/*.test.ts"
18
+ - "apps/**/tests/**/*.test.ts"
19
+ - "packages/**/tests/**/*.test.tsx"
20
+ - "apps/**/tests/**/*.test.tsx"
21
+
22
+ - id: require-corresponding-test
23
+ description: "Every source file must have a corresponding test file in tests/."
24
+ severity: warning
25
+ evaluator:
26
+ type: test-location
27
+ config:
28
+ expected: "**/*.test.*"
29
+ requireCorrespondingTest: true
30
+ include:
31
+ - "packages/**/src/**/*.ts"
32
+ - "apps/**/src/**/*.ts"
33
+ exclude:
34
+ - "**/index.ts"
35
+ - "**/*.d.ts"
36
+ - "**/types.ts"
37
+ - "**/node_modules/**"
38
+ - "**/tests/**"
@@ -0,0 +1,23 @@
1
+ # No Biome suppression comments — disallow biome-ignore comments.
2
+ # Lint issues must be fixed at the source, not silenced.
3
+ include:
4
+ - "packages/**/src/**/*.ts"
5
+ - "packages/**/src/**/*.tsx"
6
+ - "apps/**/src/**/*.ts"
7
+ - "apps/**/src/**/*.tsx"
8
+ - "packages/**/tests/**/*.ts"
9
+ - "packages/**/tests/**/*.tsx"
10
+ - "apps/**/tests/**/*.ts"
11
+ - "apps/**/tests/**/*.tsx"
12
+ exclude:
13
+ - "**/node_modules/**"
14
+ rules:
15
+ - id: no-biome-suppressions
16
+ description: >
17
+ Do not use biome-ignore or biome-ignore-all comments to suppress lint
18
+ rules. Fix the underlying issue instead.
19
+ severity: error
20
+ evaluator:
21
+ type: regex
22
+ config:
23
+ pattern: "biome-ignore"
@@ -0,0 +1,24 @@
1
+ # TSDoc export rules — every public export must carry a JSDoc comment.
2
+ rules:
3
+ - id: every-export-has-tsdoc
4
+ description: "Every export (function, class, interface, type, const, enum) must have a /** ... */ JSDoc comment describing what it is and why."
5
+ severity: error
6
+ evaluator:
7
+ type: tsdoc-export
8
+ config:
9
+ kinds:
10
+ - function
11
+ - class
12
+ - interface
13
+ - type
14
+ - const
15
+ - enum
16
+ include:
17
+ - "packages/**/src/**/*.ts"
18
+ - "apps/**/src/**/*.ts"
19
+ exclude:
20
+ - "**/index.ts"
21
+ - "**/node_modules/**"
22
+ - "**/tests/**"
23
+ - "**/*.test.ts"
24
+ - "**/*.test.tsx"
@@ -7,7 +7,7 @@
7
7
  "required": ["name"],
8
8
  "properties": {
9
9
  "$schema": { "type": "string" },
10
- "name": { "type": "string" },
10
+ "name": { "type": "string", "minLength": 1 },
11
11
  "extends": { "type": "array", "items": { "type": "string" } },
12
12
  "disable": { "type": "array", "items": { "type": "string" } },
13
13
  "overrides": {
@@ -30,10 +30,24 @@
30
30
  "type": "object",
31
31
  "additionalProperties": false,
32
32
  "properties": {
33
- "resolvers": { "type": "array", "items": { "type": "string" } },
34
- "evaluators": { "type": "array", "items": { "type": "string" } },
35
- "fixers": { "type": "array", "items": { "type": "string" } },
36
- "formatters": { "type": "array", "items": { "type": "string" } }
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
+ ]
37
51
  }
38
52
  }
39
53
  }
@@ -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 preset file's directory. Use the returned
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 collectPresetExtensions(
45
- presetName: string,
46
- presetDir: string,
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(presetDir, path) });
54
+ refs.push({ kind, presetName: sourceName, absPath: resolve(sourceDir, path) });
54
55
  }
55
56
  }
56
57
  return refs;
@@ -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 { collectPresetExtensions, type ExtensionRef } from './extensions';
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 = collectPresetExtensions(preset.name, dirname(presetPath), preset.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
- const disabled = new Set(preset.disable ?? []);
72
- const normalized = rules.filter((rule) => !disabled.has(rule.id));
73
- for (const rule of normalized) {
74
- 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];
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 { rules: normalized, extensions };
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
- /** Load a direct rule file from disk. */
87
- export async function loadRuleFile(filePath: string, options: RuleFileLoadOptions = {}): Promise<ConstraintRule[]> {
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
- return normalizeRuleFile(await readStructuredFile(resolved, options), dirname(resolved));
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 = collectPresetExtensions(preset.data.name, dirname(presetPath), preset.data.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 { rules: rules.filter((rule) => !(preset.data.disable ?? []).includes(rule.id)), extensions };
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), dirname(absPath)));
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), dirname(subPath)),
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
- 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);
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, {}, sourceDir)];
242
- 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)}`);
243
301
  }
244
302
 
245
- 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[] {
246
317
  return file.rules.map((rule) =>
247
318
  normalizeRule(
248
319
  {
249
320
  ...rule,
250
- severity: rule.severity ?? file.severity ?? 'error',
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: ConstraintRule, _defaults: Partial<ConstraintRule>, _sourceDir: string): ConstraintRule {
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
- /** 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 (