@gobing-ai/ts-rule-engine 0.2.5 → 0.2.7

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 (87) hide show
  1. package/dist/config/extensions.d.ts +46 -0
  2. package/dist/config/extensions.d.ts.map +1 -0
  3. package/dist/config/extensions.js +63 -0
  4. package/dist/config/loader.d.ts +15 -5
  5. package/dist/config/loader.d.ts.map +1 -1
  6. package/dist/config/loader.js +127 -33
  7. package/dist/engine.d.ts +26 -1
  8. package/dist/engine.d.ts.map +1 -1
  9. package/dist/engine.js +79 -0
  10. package/dist/evaluators/coverage-gate-evaluator.d.ts +21 -0
  11. package/dist/evaluators/coverage-gate-evaluator.d.ts.map +1 -0
  12. package/dist/evaluators/coverage-gate-evaluator.js +103 -0
  13. package/dist/evaluators/exit-code-evaluator.d.ts +1 -1
  14. package/dist/evaluators/exit-code-evaluator.d.ts.map +1 -1
  15. package/dist/evaluators/exit-code-evaluator.js +22 -9
  16. package/dist/evaluators/file-utils.d.ts +8 -0
  17. package/dist/evaluators/file-utils.d.ts.map +1 -1
  18. package/dist/evaluators/file-utils.js +40 -0
  19. package/dist/evaluators/forbidden-import-evaluator.d.ts +16 -3
  20. package/dist/evaluators/forbidden-import-evaluator.d.ts.map +1 -1
  21. package/dist/evaluators/forbidden-import-evaluator.js +71 -6
  22. package/dist/evaluators/import-boundary-evaluator.d.ts +19 -0
  23. package/dist/evaluators/import-boundary-evaluator.d.ts.map +1 -0
  24. package/dist/evaluators/import-boundary-evaluator.js +85 -0
  25. package/dist/evaluators/path-evaluator.d.ts +15 -2
  26. package/dist/evaluators/path-evaluator.d.ts.map +1 -1
  27. package/dist/evaluators/path-evaluator.js +49 -3
  28. package/dist/evaluators/regex-evaluator.d.ts.map +1 -1
  29. package/dist/evaluators/regex-evaluator.js +43 -8
  30. package/dist/evaluators/schema-artifact-evaluator.d.ts +21 -0
  31. package/dist/evaluators/schema-artifact-evaluator.d.ts.map +1 -0
  32. package/dist/evaluators/schema-artifact-evaluator.js +102 -0
  33. package/dist/evaluators/secrets-scanner-evaluator.d.ts +13 -2
  34. package/dist/evaluators/secrets-scanner-evaluator.d.ts.map +1 -1
  35. package/dist/evaluators/secrets-scanner-evaluator.js +72 -9
  36. package/dist/evaluators/sg-evaluator.d.ts +19 -0
  37. package/dist/evaluators/sg-evaluator.d.ts.map +1 -0
  38. package/dist/evaluators/sg-evaluator.js +112 -0
  39. package/dist/evaluators/test-location-evaluator.d.ts +32 -0
  40. package/dist/evaluators/test-location-evaluator.d.ts.map +1 -0
  41. package/dist/evaluators/test-location-evaluator.js +105 -0
  42. package/dist/evaluators/tsdoc-export-evaluator.d.ts +15 -0
  43. package/dist/evaluators/tsdoc-export-evaluator.d.ts.map +1 -0
  44. package/dist/evaluators/tsdoc-export-evaluator.js +91 -0
  45. package/dist/fixers/fixers.d.ts +86 -0
  46. package/dist/fixers/fixers.d.ts.map +1 -0
  47. package/dist/fixers/fixers.js +230 -0
  48. package/dist/fixers/test-stub-fixer.d.ts +49 -0
  49. package/dist/fixers/test-stub-fixer.d.ts.map +1 -0
  50. package/dist/fixers/test-stub-fixer.js +91 -0
  51. package/dist/host/builtins.d.ts.map +1 -1
  52. package/dist/host/builtins.js +17 -0
  53. package/dist/host/rule-engine-host.d.ts +3 -0
  54. package/dist/host/rule-engine-host.d.ts.map +1 -1
  55. package/dist/host/rule-engine-host.js +3 -0
  56. package/dist/index.d.ts +4 -0
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +4 -0
  59. package/dist/resolvers/test-path-resolver.d.ts +72 -0
  60. package/dist/resolvers/test-path-resolver.d.ts.map +1 -0
  61. package/dist/resolvers/test-path-resolver.js +112 -0
  62. package/dist/types.d.ts +30 -0
  63. package/dist/types.d.ts.map +1 -1
  64. package/dist/types.js +8 -0
  65. package/package.json +3 -3
  66. package/src/config/extensions.ts +108 -0
  67. package/src/config/loader.ts +140 -35
  68. package/src/engine.ts +99 -2
  69. package/src/evaluators/coverage-gate-evaluator.ts +137 -0
  70. package/src/evaluators/exit-code-evaluator.ts +27 -9
  71. package/src/evaluators/file-utils.ts +38 -0
  72. package/src/evaluators/forbidden-import-evaluator.ts +101 -7
  73. package/src/evaluators/import-boundary-evaluator.ts +135 -0
  74. package/src/evaluators/path-evaluator.ts +66 -3
  75. package/src/evaluators/regex-evaluator.ts +53 -12
  76. package/src/evaluators/schema-artifact-evaluator.ts +134 -0
  77. package/src/evaluators/secrets-scanner-evaluator.ts +89 -11
  78. package/src/evaluators/sg-evaluator.ts +133 -0
  79. package/src/evaluators/test-location-evaluator.ts +127 -0
  80. package/src/evaluators/tsdoc-export-evaluator.ts +111 -0
  81. package/src/fixers/fixers.ts +294 -0
  82. package/src/fixers/test-stub-fixer.ts +118 -0
  83. package/src/host/builtins.ts +22 -0
  84. package/src/host/rule-engine-host.ts +4 -0
  85. package/src/index.ts +4 -0
  86. package/src/resolvers/test-path-resolver.ts +133 -0
  87. package/src/types.ts +34 -0
@@ -0,0 +1,46 @@
1
+ import type { RuleEngineHost } from '../host/rule-engine-host';
2
+ /** A capability kind a preset extension can contribute. */
3
+ export type ExtensionKind = 'resolvers' | 'evaluators' | 'fixers' | 'formatters';
4
+ /** A single extension module reference, resolved to an absolute path. */
5
+ export interface ExtensionRef {
6
+ /** Capability registry the module registers into. */
7
+ readonly kind: ExtensionKind;
8
+ /** Absolute path to the module to import. */
9
+ readonly absPath: string;
10
+ /** Name of the preset that declared this extension (for diagnostics). */
11
+ readonly presetName: string;
12
+ }
13
+ /** Options controlling preset-extension loading. */
14
+ export interface LoadExtensionsOptions {
15
+ /**
16
+ * Whether to actually import extension modules. Defaults to `false`: loading
17
+ * arbitrary code referenced by a preset is a trust decision the caller must
18
+ * make explicitly. When refs exist and this is false, loading throws.
19
+ */
20
+ allowExtensions?: boolean;
21
+ /** Optional sink for non-fatal warnings (e.g. built-in overrides). */
22
+ logger?: {
23
+ warn: (message: string) => void;
24
+ };
25
+ }
26
+ /**
27
+ * Collect extension refs declared by a preset's `extensions` block.
28
+ *
29
+ * Paths are resolved relative to the preset file's directory. Use the returned
30
+ * refs with {@link loadExtensionsIntoHost}.
31
+ */
32
+ export declare function collectPresetExtensions(presetName: string, presetDir: string, extensions: Partial<Record<ExtensionKind, string[] | undefined>> | undefined): ExtensionRef[];
33
+ /**
34
+ * Import each extension module and register its export on the matching host
35
+ * registry.
36
+ *
37
+ * A module must default-export (or named-export `extension`) an object with a
38
+ * `name: string` and the capability implementation. Loading is gated by
39
+ * {@link LoadExtensionsOptions.allowExtensions}; when refs are present but
40
+ * loading is not allowed, this throws so the requirement is never silently dropped.
41
+ *
42
+ * @throws When extensions are present but `allowExtensions` is not true, or when
43
+ * a module cannot be imported or lacks a valid `name`.
44
+ */
45
+ export declare function loadExtensionsIntoHost(host: RuleEngineHost, refs: readonly ExtensionRef[], options?: LoadExtensionsOptions): Promise<void>;
46
+ //# sourceMappingURL=extensions.d.ts.map
@@ -0,0 +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;CAChD;AASD;;;;;GAKG;AACH,wBAAgB,uBAAuB,CACnC,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,CAmCf"}
@@ -0,0 +1,63 @@
1
+ import { resolve } from 'node:path';
2
+ /** Host registries that can receive extension capabilities (fixers live on the engine, not the host). */
3
+ const HOST_REGISTRY_BY_KIND = {
4
+ resolvers: 'resolvers',
5
+ evaluators: 'evaluators',
6
+ formatters: 'formatters',
7
+ };
8
+ /**
9
+ * Collect extension refs declared by a preset's `extensions` block.
10
+ *
11
+ * Paths are resolved relative to the preset file's directory. Use the returned
12
+ * refs with {@link loadExtensionsIntoHost}.
13
+ */
14
+ export function collectPresetExtensions(presetName, presetDir, extensions) {
15
+ if (extensions === undefined)
16
+ return [];
17
+ const refs = [];
18
+ for (const kind of ['resolvers', 'evaluators', 'fixers', 'formatters']) {
19
+ for (const path of extensions[kind] ?? []) {
20
+ refs.push({ kind, presetName, absPath: resolve(presetDir, path) });
21
+ }
22
+ }
23
+ return refs;
24
+ }
25
+ /**
26
+ * Import each extension module and register its export on the matching host
27
+ * registry.
28
+ *
29
+ * A module must default-export (or named-export `extension`) an object with a
30
+ * `name: string` and the capability implementation. Loading is gated by
31
+ * {@link LoadExtensionsOptions.allowExtensions}; when refs are present but
32
+ * loading is not allowed, this throws so the requirement is never silently dropped.
33
+ *
34
+ * @throws When extensions are present but `allowExtensions` is not true, or when
35
+ * a module cannot be imported or lacks a valid `name`.
36
+ */
37
+ export async function loadExtensionsIntoHost(host, refs, options = {}) {
38
+ if (refs.length === 0)
39
+ return;
40
+ if (options.allowExtensions !== true) {
41
+ const first = refs[0];
42
+ throw new Error(`preset "${first.presetName}" declares ${first.kind} extension "${first.absPath}", but extensions are disabled — pass allowExtensions: true to load preset extension modules`);
43
+ }
44
+ for (const ref of refs) {
45
+ const moduleExports = (await import(ref.absPath));
46
+ const candidate = moduleExports.default ?? moduleExports.extension;
47
+ if (candidate === null ||
48
+ typeof candidate !== 'object' ||
49
+ typeof candidate.name !== 'string') {
50
+ throw new Error(`preset "${ref.presetName}" extension "${ref.absPath}" must export an object with a string "name"`);
51
+ }
52
+ const name = candidate.name;
53
+ const registryKey = HOST_REGISTRY_BY_KIND[ref.kind];
54
+ if (registryKey === undefined) {
55
+ throw new Error(`preset "${ref.presetName}" ${ref.kind} extensions are not supported`);
56
+ }
57
+ const registry = host[registryKey];
58
+ if (options.logger && registry.has?.(name)) {
59
+ options.logger.warn(`preset "${ref.presetName}" ${ref.kind} extension overrides existing "${name}"`);
60
+ }
61
+ registry.register(name, candidate, 'extension');
62
+ }
63
+ }
@@ -1,12 +1,22 @@
1
1
  import { type ConstraintRule } from '../types';
2
2
  /** Options for loading rule presets. */
3
3
  export interface RuleLoaderOptions {
4
- /** Project working directory. */
5
- workdir: string;
6
- /** Rule root directory. Defaults to ".spur/rules". */
7
- rulesRoot?: string;
4
+ /**
5
+ * Ordered rule root directories, highest priority first. Presets, category
6
+ * folders, and rule files are resolved across all roots: the highest-priority
7
+ * root that provides a given relative path wins, and gaps are filled from
8
+ * lower-priority roots. The caller owns root discovery and ordering — this
9
+ * loader stays agnostic to any project layout convention.
10
+ */
11
+ roots: string[];
8
12
  }
9
- /** Load and normalize a preset by name. */
13
+ /**
14
+ * Load and normalize a preset by name, resolving across one or more rule roots.
15
+ *
16
+ * Roots are merged in order: the first root to provide a relative path owns it,
17
+ * so a caller can layer project-local rules over shared/global rules and inherit
18
+ * the rest of a preset's categories from the lower-priority roots.
19
+ */
10
20
  export declare function loadPresetRules(name: string, options: RuleLoaderOptions): Promise<ConstraintRule[]>;
11
21
  /** Load a direct rule file from disk. */
12
22
  export declare function loadRuleFile(filePath: string): Promise<ConstraintRule[]>;
@@ -1 +1 @@
1
- {"version":3,"file":"loader.d.ts","sourceRoot":"","sources":["../../src/config/loader.ts"],"names":[],"mappings":"AAGA,OAAO,EACH,KAAK,cAAc,EAMtB,MAAM,UAAU,CAAC;AAElB,wCAAwC;AACxC,MAAM,WAAW,iBAAiB;IAC9B,iCAAiC;IACjC,OAAO,EAAE,MAAM,CAAC;IAChB,sDAAsD;IACtD,SAAS,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,2CAA2C;AAC3C,wBAAsB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC,cAAc,EAAE,CAAC,CAoBzG;AAED,yCAAyC;AACzC,wBAAsB,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,EAAE,CAAC,CAE9E"}
1
+ {"version":3,"file":"loader.d.ts","sourceRoot":"","sources":["../../src/config/loader.ts"],"names":[],"mappings":"AAGA,OAAO,EACH,KAAK,cAAc,EAMtB,MAAM,UAAU,CAAC;AAElB,wCAAwC;AACxC,MAAM,WAAW,iBAAiB;IAC9B;;;;;;OAMG;IACH,KAAK,EAAE,MAAM,EAAE,CAAC;CACnB;AAUD;;;;;;GAMG;AACH,wBAAsB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC,cAAc,EAAE,CAAC,CAkBzG;AAED,yCAAyC;AACzC,wBAAsB,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,EAAE,CAAC,CAE9E"}
@@ -1,18 +1,23 @@
1
- import { basename, dirname, extname, join, resolve } from 'node:path';
1
+ import { basename, dirname, extname, join, relative, resolve, sep } from 'node:path';
2
2
  import { NodeFileSystem } from '@gobing-ai/ts-runtime';
3
3
  import { parse } from 'yaml';
4
4
  import { ConstraintRuleFileSchema, ConstraintRuleSchema, PresetDefinitionSchema, } from '../types.js';
5
- /** Load and normalize a preset by name. */
5
+ /**
6
+ * Load and normalize a preset by name, resolving across one or more rule roots.
7
+ *
8
+ * Roots are merged in order: the first root to provide a relative path owns it,
9
+ * so a caller can layer project-local rules over shared/global rules and inherit
10
+ * the rest of a preset's categories from the lower-priority roots.
11
+ */
6
12
  export async function loadPresetRules(name, options) {
7
- const fs = new NodeFileSystem();
8
- const root = resolve(options.workdir, options.rulesRoot ?? '.spur/rules');
9
- const presetPath = await findDefinitionPath(root, name);
13
+ const merged = await buildMergedRoots(options.roots.map((root) => resolve(root)));
14
+ const presetPath = findMergedPreset(merged, name);
10
15
  if (presetPath === null)
11
16
  return [];
12
17
  const preset = PresetDefinitionSchema.parse(await readStructuredFile(presetPath));
13
18
  const rules = [];
14
19
  for (const entry of preset.extends) {
15
- rules.push(...(await loadPresetEntry(root, entry, new Set([name]))));
20
+ rules.push(...(await loadPresetEntry(merged, entry, new Set([name]))));
16
21
  }
17
22
  const disabled = new Set(preset.disable ?? []);
18
23
  const normalized = rules.filter((rule) => !disabled.has(rule.id));
@@ -22,52 +27,134 @@ export async function loadPresetRules(name, options) {
22
27
  rule.fix = { ...(rule.fix ?? { mode: 'none' }), ...override.fix };
23
28
  }
24
29
  }
25
- await fs.exists(root);
26
30
  return normalized;
27
31
  }
28
32
  /** Load a direct rule file from disk. */
29
33
  export async function loadRuleFile(filePath) {
30
34
  return normalizeRuleFile(await readStructuredFile(resolve(filePath)), dirname(resolve(filePath)));
31
35
  }
32
- async function loadPresetEntry(root, entry, seen) {
33
- const presetPath = await findDefinitionPath(root, entry);
34
- if (presetPath !== null && !seen.has(entry)) {
36
+ async function loadPresetEntry(merged, entry, seen) {
37
+ // Sub-preset reference recurse, erroring on a genuine cycle.
38
+ const presetPath = findMergedPreset(merged, entry);
39
+ if (presetPath !== null) {
40
+ if (seen.has(entry)) {
41
+ throw new Error(`Circular preset dependency detected: ${[...seen, entry].join(' → ')}`);
42
+ }
35
43
  seen.add(entry);
36
44
  const preset = PresetDefinitionSchema.safeParse(await readStructuredFile(presetPath));
37
45
  if (preset.success) {
38
46
  const rules = [];
39
47
  for (const child of preset.data.extends)
40
- rules.push(...(await loadPresetEntry(root, child, seen)));
48
+ rules.push(...(await loadPresetEntry(merged, child, seen)));
41
49
  return rules.filter((rule) => !(preset.data.disable ?? []).includes(rule.id));
42
50
  }
43
51
  }
44
- const categoryDir = resolve(root, entry);
45
- const fs = new NodeFileSystem();
46
- if (!(await fs.exists(categoryDir)))
47
- return [];
48
- const entries = (await fs.readDir(categoryDir)).filter((file) => /\.(ya?ml|json)$/i.test(file)).sort();
49
- const rules = [];
50
- for (const file of entries) {
51
- rules.push(...(await loadRuleFile(join(categoryDir, file))));
52
+ // Category folder reference — load every winning file under that prefix.
53
+ if (merged.categories.has(entry)) {
54
+ const rules = [];
55
+ for (const absPath of mergedFilesInCategory(merged, entry)) {
56
+ rules.push(...(await loadRuleFile(absPath)));
57
+ }
58
+ return rules;
52
59
  }
53
- return rules;
60
+ // Sub-path reference — a single winning rule file within a category.
61
+ const subPath = findMergedFile(merged, entry);
62
+ if (subPath !== null)
63
+ return loadRuleFile(subPath);
64
+ return [];
54
65
  }
55
- async function findDefinitionPath(root, name) {
66
+ /**
67
+ * Build the merged view across ordered roots.
68
+ *
69
+ * Roots are processed in the order supplied (highest priority first). The first
70
+ * root to provide a given relative path owns that file; later roots are shadowed.
71
+ */
72
+ async function buildMergedRoots(roots) {
56
73
  const fs = new NodeFileSystem();
57
- const candidates = [
58
- resolve(root, `${name}.yaml`),
59
- resolve(root, `${name}.yml`),
60
- resolve(root, `${name}.json`),
61
- resolve(root, name, 'index.yaml'),
62
- resolve(root, name, 'index.yml'),
63
- resolve(root, name, 'index.json'),
64
- ];
65
- for (const candidate of candidates) {
66
- if (await fs.exists(candidate))
67
- return candidate;
74
+ const files = new Map();
75
+ const categories = new Set();
76
+ for (const root of roots) {
77
+ for (const absPath of await walkYamlFiles(fs, root)) {
78
+ const relPath = relative(root, absPath).split(sep).join('/');
79
+ const slashIdx = relPath.indexOf('/');
80
+ if (slashIdx > 0)
81
+ categories.add(relPath.slice(0, slashIdx));
82
+ if (!files.has(relPath))
83
+ files.set(relPath, absPath);
84
+ }
85
+ for (const dir of await listImmediateDirs(fs, root))
86
+ categories.add(dir);
87
+ }
88
+ return { files, categories };
89
+ }
90
+ /** Find a preset definition across roots: `<name>.{yaml,yml,json}` or `<name>/index.*`. */
91
+ function findMergedPreset(merged, name) {
92
+ return firstHit(merged, [
93
+ `${name}.yaml`,
94
+ `${name}.yml`,
95
+ `${name}.json`,
96
+ `${name}/index.yaml`,
97
+ `${name}/index.yml`,
98
+ `${name}/index.json`,
99
+ ]);
100
+ }
101
+ /** Find a single rule file by sub-path entry (e.g. `typescript/tsdoc-exports`). */
102
+ function findMergedFile(merged, entry) {
103
+ const hasExt = /\.(ya?ml|json)$/i.test(entry);
104
+ return firstHit(merged, hasExt ? [entry] : [`${entry}.yaml`, `${entry}.yml`, `${entry}.json`]);
105
+ }
106
+ /** Return the winning absolute path for the first matching relative candidate. */
107
+ function firstHit(merged, relCandidates) {
108
+ for (const rel of relCandidates) {
109
+ const hit = merged.files.get(rel);
110
+ if (hit !== undefined)
111
+ return hit;
68
112
  }
69
113
  return null;
70
114
  }
115
+ /** Winning files under a category prefix, sorted by relative path. */
116
+ function mergedFilesInCategory(merged, category) {
117
+ const prefix = `${category}/`;
118
+ return [...merged.files.entries()]
119
+ .filter(([relPath]) => relPath.startsWith(prefix))
120
+ .sort(([a], [b]) => a.localeCompare(b))
121
+ .map(([, absPath]) => absPath);
122
+ }
123
+ /** Recursively collect YAML/JSON file paths under a directory, skipping root-level `presets/`. */
124
+ async function walkYamlFiles(fs, dir, depth = 0) {
125
+ const stat = await fs.stat(dir);
126
+ if (stat === null || !stat.isDirectory())
127
+ return [];
128
+ const acc = [];
129
+ for (const entry of (await fs.readDir(dir)).sort()) {
130
+ if (depth === 0 && entry === 'presets')
131
+ continue;
132
+ const fullPath = join(dir, entry);
133
+ const entryStat = await fs.stat(fullPath);
134
+ if (entryStat?.isDirectory()) {
135
+ acc.push(...(await walkYamlFiles(fs, fullPath, depth + 1)));
136
+ }
137
+ else if (entryStat?.isFile() && /\.(ya?ml|json)$/i.test(entry)) {
138
+ acc.push(fullPath);
139
+ }
140
+ }
141
+ return acc;
142
+ }
143
+ /** List immediate subdirectory names of a root (excluding `presets`). */
144
+ async function listImmediateDirs(fs, dir) {
145
+ const stat = await fs.stat(dir);
146
+ if (stat === null || !stat.isDirectory())
147
+ return [];
148
+ const dirs = [];
149
+ for (const entry of await fs.readDir(dir)) {
150
+ if (entry === 'presets')
151
+ continue;
152
+ const entryStat = await fs.stat(join(dir, entry));
153
+ if (entryStat?.isDirectory())
154
+ dirs.push(entry);
155
+ }
156
+ return dirs;
157
+ }
71
158
  async function readStructuredFile(path) {
72
159
  const content = await new NodeFileSystem().readFile(path);
73
160
  return extname(path) === '.json' ? JSON.parse(content) : parse(content);
@@ -86,9 +173,16 @@ function normalizeFileRules(file, sourceDir) {
86
173
  ...rule,
87
174
  severity: rule.severity ?? file.severity ?? 'error',
88
175
  include: rule.include ?? file.include,
89
- exclude: rule.exclude ?? file.exclude,
176
+ // File-level excludes always apply; a rule's own excludes add to (not replace) them.
177
+ exclude: mergeExcludes(file.exclude, rule.exclude),
90
178
  }, {}, sourceDir));
91
179
  }
180
+ /** Union of file-level and rule-level excludes, de-duplicated. Returns undefined when both empty. */
181
+ function mergeExcludes(fileExclude, ruleExclude) {
182
+ if (fileExclude === undefined && ruleExclude === undefined)
183
+ return undefined;
184
+ return [...new Set([...(fileExclude ?? []), ...(ruleExclude ?? [])])];
185
+ }
92
186
  function normalizeRule(rule, _defaults, _sourceDir) {
93
187
  return {
94
188
  ...rule,
package/dist/engine.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { ProcessExecutor } from '@gobing-ai/ts-runtime';
2
+ import { type FixApplicationResult } from './fixers/fixers';
2
3
  import { RuleEngineHost } from './host/rule-engine-host';
3
- import type { ConstraintRule, RuleEngineResult, RuleEvaluator } from './types';
4
+ import type { ConstraintRule, Fix, FixMode, RuleEngineResult, RuleEvaluator } from './types';
4
5
  /** Options for constructing a RuleEngine. */
5
6
  export interface RuleEngineOptions {
6
7
  /** Optional executor supplied to process-backed evaluators. */
@@ -12,10 +13,34 @@ export interface RuleEngineOptions {
12
13
  export declare class RuleEngine {
13
14
  /** Capability host used by this engine. */
14
15
  readonly host: RuleEngineHost;
16
+ /** Fixer providers keyed by evaluator type. */
17
+ private readonly fixers;
15
18
  constructor(options?: RuleEngineOptions);
16
19
  /** Register or replace an evaluator. */
17
20
  registerEvaluator(type: string, evaluator: RuleEvaluator): void;
18
21
  /** Evaluate all enabled rules against a working directory. */
19
22
  evaluate(rules: ConstraintRule[], workdir: string): Promise<RuleEngineResult>;
23
+ /**
24
+ * Evaluate all enabled rules and collect candidate fixes.
25
+ *
26
+ * For each rule with findings and a non-none fix mode, looks up the fixer
27
+ * provider by evaluator type and calls `createFixes`. The effective fix mode
28
+ * is the minimum of the rule's configured mode and `maxFixMode`.
29
+ *
30
+ * @param rules - Normalized rule definitions to evaluate.
31
+ * @param workdir - Working directory to scan.
32
+ * @param maxFixMode - Highest fix authority requested by the caller.
33
+ * @returns Findings plus fixes allowed by the requested authority.
34
+ */
35
+ evaluateWithFixes(rules: ConstraintRule[], workdir: string, maxFixMode?: FixMode): Promise<RuleEngineResult>;
36
+ /**
37
+ * Apply or preview candidate byte-range fixes.
38
+ *
39
+ * @param workdir - Working directory that fix file paths are relative to.
40
+ * @param fixes - Fixes to apply.
41
+ * @param dryRun - When true, return a diff without writing files.
42
+ * @returns Application details and optional diff.
43
+ */
44
+ applyFixes(workdir: string, fixes: readonly Fix[], dryRun?: boolean): Promise<FixApplicationResult>;
20
45
  }
21
46
  //# sourceMappingURL=engine.d.ts.map
@@ -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;AAE7D,OAAO,EAAE,cAAc,EAAE,MAAM,yBAAyB,CAAC;AACzD,OAAO,KAAK,EAAqB,cAAc,EAAE,gBAAgB,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AAGlG,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;gBAElB,OAAO,GAAE,iBAAsB;IAK3C,wCAAwC;IACxC,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,aAAa,GAAG,IAAI;IAI/D,8DAA8D;IACxD,QAAQ,CAAC,KAAK,EAAE,cAAc,EAAE,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,CAAC;CAmBtF"}
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,8DAA8D;IACxD,QAAQ,CAAC,KAAK,EAAE,cAAc,EAAE,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,CAAC;IAqBnF;;;;;;;;;;;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
@@ -1,3 +1,4 @@
1
+ import { applyFixes as applyFixesImpl, builtInFixers, FIX_MODE_RANK, } from './fixers/fixers.js';
1
2
  import { registerBuiltins } from './host/builtins.js';
2
3
  import { RuleEngineHost } from './host/rule-engine-host.js';
3
4
  import { createFinding } from './types.js';
@@ -5,9 +6,12 @@ import { createFinding } from './types.js';
5
6
  export class RuleEngine {
6
7
  /** Capability host used by this engine. */
7
8
  host;
9
+ /** Fixer providers keyed by evaluator type. */
10
+ fixers;
8
11
  constructor(options = {}) {
9
12
  this.host = options.host ?? new RuleEngineHost();
10
13
  registerBuiltins(this.host, options.processExecutor);
14
+ this.fixers = builtInFixers(this.host, options.processExecutor);
11
15
  }
12
16
  /** Register or replace an evaluator. */
13
17
  registerEvaluator(type, evaluator) {
@@ -28,9 +32,84 @@ export class RuleEngine {
28
32
  catch (error) {
29
33
  findings.push(createFinding(rule, error instanceof Error ? error.message : String(error), null, {
30
34
  code: `evaluator:${rule.evaluator.type}`,
35
+ kind: 'error',
31
36
  }));
32
37
  }
33
38
  }
34
39
  return { findings, fixes };
35
40
  }
41
+ /**
42
+ * Evaluate all enabled rules and collect candidate fixes.
43
+ *
44
+ * For each rule with findings and a non-none fix mode, looks up the fixer
45
+ * provider by evaluator type and calls `createFixes`. The effective fix mode
46
+ * is the minimum of the rule's configured mode and `maxFixMode`.
47
+ *
48
+ * @param rules - Normalized rule definitions to evaluate.
49
+ * @param workdir - Working directory to scan.
50
+ * @param maxFixMode - Highest fix authority requested by the caller.
51
+ * @returns Findings plus fixes allowed by the requested authority.
52
+ */
53
+ async evaluateWithFixes(rules, workdir, maxFixMode = 'auto') {
54
+ const findings = [];
55
+ const fixes = [];
56
+ for (const rule of rules) {
57
+ if (rule.enabled === false)
58
+ continue;
59
+ let ruleFindings = [];
60
+ let ruleEvalFixes = [];
61
+ try {
62
+ const result = await this.host.evaluators.get(rule.evaluator.type).evaluate(rule, { rule, workdir });
63
+ ruleFindings = result.findings;
64
+ ruleEvalFixes = result.fixes;
65
+ }
66
+ catch (error) {
67
+ ruleFindings = [
68
+ createFinding(rule, error instanceof Error ? error.message : String(error), null, {
69
+ code: `evaluator:${rule.evaluator.type}`,
70
+ kind: 'error',
71
+ }),
72
+ ];
73
+ }
74
+ findings.push(...ruleFindings);
75
+ fixes.push(...ruleEvalFixes);
76
+ const ruleMode = rule.fix?.mode ?? 'none';
77
+ const effectiveMode = effectiveFixMode(ruleMode, maxFixMode);
78
+ if (effectiveMode !== 'none' && ruleFindings.length > 0) {
79
+ const provider = this.fixers.get(rule.evaluator.type);
80
+ if (provider) {
81
+ const effectiveFix = {
82
+ mode: effectiveMode,
83
+ ...(rule.fix?.replacement !== undefined ? { replacement: rule.fix.replacement } : {}),
84
+ ...(rule.fix?.params !== undefined ? { params: rule.fix.params } : {}),
85
+ };
86
+ const providerFixes = await provider.createFixes({
87
+ rule,
88
+ context: { rule, workdir },
89
+ findings: ruleFindings,
90
+ fix: effectiveFix,
91
+ });
92
+ fixes.push(...providerFixes);
93
+ }
94
+ }
95
+ }
96
+ return { findings, fixes };
97
+ }
98
+ /**
99
+ * Apply or preview candidate byte-range fixes.
100
+ *
101
+ * @param workdir - Working directory that fix file paths are relative to.
102
+ * @param fixes - Fixes to apply.
103
+ * @param dryRun - When true, return a diff without writing files.
104
+ * @returns Application details and optional diff.
105
+ */
106
+ async applyFixes(workdir, fixes, dryRun = false) {
107
+ return applyFixesImpl(workdir, fixes, dryRun);
108
+ }
109
+ }
110
+ /** Return the lower-authority mode between what the rule requests and what the caller allows. */
111
+ function effectiveFixMode(ruleMode, requestedMode) {
112
+ if (requestedMode === 'none' || ruleMode === 'none')
113
+ return 'none';
114
+ return FIX_MODE_RANK[ruleMode] <= FIX_MODE_RANK[requestedMode] ? ruleMode : requestedMode;
36
115
  }
@@ -0,0 +1,21 @@
1
+ import { type ConstraintRule, type RuleContext, type RuleEvaluationResult, type RuleEvaluator } from '../types';
2
+ /**
3
+ * Coverage gate evaluator — reads an lcov tracefile and enforces per-file line
4
+ * coverage thresholds.
5
+ *
6
+ * Config (`evaluator.config`):
7
+ * - `lcovPath`: path to lcov.info (default: `coverage/lcov.info`)
8
+ * - `threshold`: default line-coverage percentage (default: `90`)
9
+ * - `include` / `exclude`: glob patterns scoping the check
10
+ * - `exemptions`: per-file threshold overrides with a reason
11
+ *
12
+ * A missing lcov file is reported as a single non-blocking finding rather than an
13
+ * error, so the gate degrades gracefully when coverage was not generated.
14
+ */
15
+ export declare class CoverageGateEvaluator implements RuleEvaluator {
16
+ private readonly fs;
17
+ constructor();
18
+ /** Evaluate per-file coverage against the configured threshold. */
19
+ evaluate(rule: ConstraintRule, context: RuleContext): Promise<RuleEvaluationResult>;
20
+ }
21
+ //# sourceMappingURL=coverage-gate-evaluator.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"coverage-gate-evaluator.d.ts","sourceRoot":"","sources":["../../src/evaluators/coverage-gate-evaluator.ts"],"names":[],"mappings":"AAEA,OAAO,EACH,KAAK,cAAc,EAEnB,KAAK,WAAW,EAChB,KAAK,oBAAoB,EACzB,KAAK,aAAa,EACrB,MAAM,UAAU,CAAC;AAyBlB;;;;;;;;;;;;GAYG;AACH,qBAAa,qBAAsB,YAAW,aAAa;IACvD,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAiB;;IAMpC,mEAAmE;IAC7D,QAAQ,CAAC,IAAI,EAAE,cAAc,EAAE,OAAO,EAAE,WAAW,GAAG,OAAO,CAAC,oBAAoB,CAAC;CA0C5F"}
@@ -0,0 +1,103 @@
1
+ import { isAbsolute, relative, resolve } from 'node:path';
2
+ import { NodeFileSystem } from '@gobing-ai/ts-runtime';
3
+ import { createFinding, } from '../types.js';
4
+ import { matchesGlob } from './file-utils.js';
5
+ /**
6
+ * Coverage gate evaluator — reads an lcov tracefile and enforces per-file line
7
+ * coverage thresholds.
8
+ *
9
+ * Config (`evaluator.config`):
10
+ * - `lcovPath`: path to lcov.info (default: `coverage/lcov.info`)
11
+ * - `threshold`: default line-coverage percentage (default: `90`)
12
+ * - `include` / `exclude`: glob patterns scoping the check
13
+ * - `exemptions`: per-file threshold overrides with a reason
14
+ *
15
+ * A missing lcov file is reported as a single non-blocking finding rather than an
16
+ * error, so the gate degrades gracefully when coverage was not generated.
17
+ */
18
+ export class CoverageGateEvaluator {
19
+ fs;
20
+ constructor() {
21
+ this.fs = new NodeFileSystem();
22
+ }
23
+ /** Evaluate per-file coverage against the configured threshold. */
24
+ async evaluate(rule, context) {
25
+ const config = (rule.evaluator.config ?? {});
26
+ const lcovPath = config.lcovPath
27
+ ? resolve(context.workdir, config.lcovPath)
28
+ : resolve(context.workdir, 'coverage', 'lcov.info');
29
+ if (!(await this.fs.exists(lcovPath))) {
30
+ return {
31
+ findings: [
32
+ createFinding(rule, 'No lcov file found — coverage not generated. Skipping gate.', lcovPath, {
33
+ code: 'coverage:missing-lcov',
34
+ }),
35
+ ],
36
+ fixes: [],
37
+ };
38
+ }
39
+ const coverage = parseLcov(await this.fs.readFile(lcovPath));
40
+ const threshold = config.threshold ?? 90;
41
+ const exemptions = new Map((config.exemptions ?? []).map((entry) => [entry.path, entry]));
42
+ const findings = [];
43
+ for (const [rawPath, cov] of coverage) {
44
+ if (cov.linesFound === 0)
45
+ continue;
46
+ const filePath = normalizeLcovSourcePath(context.workdir, rawPath);
47
+ if (config.include !== undefined && !config.include.some((p) => matchesGlob(filePath, p)))
48
+ continue;
49
+ if (config.exclude?.some((p) => matchesGlob(filePath, p)))
50
+ continue;
51
+ if (isAlwaysExcluded(filePath))
52
+ continue;
53
+ const pct = Math.round((cov.linesHit / cov.linesFound) * 100);
54
+ const exemption = exemptions.get(filePath);
55
+ const effectiveThreshold = exemption?.threshold ?? threshold;
56
+ if (pct >= effectiveThreshold)
57
+ continue;
58
+ const message = exemption
59
+ ? `${pct}% line coverage (exemption: ${effectiveThreshold}%, reason: ${exemption.reason})`
60
+ : `${pct}% line coverage (threshold: ${effectiveThreshold}%)`;
61
+ findings.push(createFinding(rule, message, filePath, { code: 'coverage:below-threshold' }));
62
+ }
63
+ return { findings, fixes: [] };
64
+ }
65
+ }
66
+ /** Parse an lcov tracefile into a map of source path → coverage counts. */
67
+ function parseLcov(raw) {
68
+ const result = new Map();
69
+ let file = null;
70
+ let linesFound = 0;
71
+ let linesHit = 0;
72
+ for (const line of raw.split('\n')) {
73
+ const trimmed = line.trim();
74
+ if (trimmed.startsWith('SF:')) {
75
+ file = trimmed.slice(3);
76
+ linesFound = 0;
77
+ linesHit = 0;
78
+ }
79
+ else if (trimmed.startsWith('LF:')) {
80
+ linesFound = Number(trimmed.slice(3));
81
+ }
82
+ else if (trimmed.startsWith('LH:')) {
83
+ linesHit = Number(trimmed.slice(3));
84
+ }
85
+ else if (trimmed === 'end_of_record' && file !== null) {
86
+ result.set(file, { linesFound, linesHit });
87
+ file = null;
88
+ }
89
+ }
90
+ return result;
91
+ }
92
+ /** Standard exclusions applied regardless of config (tests, generated, deps). */
93
+ function isAlwaysExcluded(filePath) {
94
+ return (filePath.includes('node_modules') ||
95
+ filePath.includes('.test.') ||
96
+ filePath.includes('.spec.') ||
97
+ filePath.includes('__tests__'));
98
+ }
99
+ /** Normalize an lcov `SF:` path to a workdir-relative forward-slash path. */
100
+ function normalizeLcovSourcePath(workdir, filePath) {
101
+ const normalized = isAbsolute(filePath) ? relative(workdir, filePath) : filePath;
102
+ return normalized.replaceAll('\\', '/');
103
+ }
@@ -4,7 +4,7 @@ import { type ConstraintRule, type RuleContext, type RuleEvaluationResult, type
4
4
  export declare class ExitCodeEvaluator implements RuleEvaluator {
5
5
  private readonly executor;
6
6
  constructor(executor?: ProcessExecutor);
7
- /** Run configured command and emit a finding on non-zero exit. */
7
+ /** Run configured command and emit a finding unless the exit code matches `successCode`. */
8
8
  evaluate(rule: ConstraintRule, context: RuleContext): Promise<RuleEvaluationResult>;
9
9
  }
10
10
  //# sourceMappingURL=exit-code-evaluator.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"exit-code-evaluator.d.ts","sourceRoot":"","sources":["../../src/evaluators/exit-code-evaluator.ts"],"names":[],"mappings":"AAAA,OAAO,EAAuB,KAAK,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAClF,OAAO,EACH,KAAK,cAAc,EAEnB,KAAK,WAAW,EAChB,KAAK,oBAAoB,EACzB,KAAK,aAAa,EACrB,MAAM,UAAU,CAAC;AAElB,2EAA2E;AAC3E,qBAAa,iBAAkB,YAAW,aAAa;IACvC,OAAO,CAAC,QAAQ,CAAC,QAAQ;gBAAR,QAAQ,GAAE,eAA2C;IAElF,kEAAkE;IAC5D,QAAQ,CAAC,IAAI,EAAE,cAAc,EAAE,OAAO,EAAE,WAAW,GAAG,OAAO,CAAC,oBAAoB,CAAC;CAe5F"}
1
+ {"version":3,"file":"exit-code-evaluator.d.ts","sourceRoot":"","sources":["../../src/evaluators/exit-code-evaluator.ts"],"names":[],"mappings":"AAAA,OAAO,EAAuB,KAAK,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAClF,OAAO,EACH,KAAK,cAAc,EAEnB,KAAK,WAAW,EAChB,KAAK,oBAAoB,EACzB,KAAK,aAAa,EACrB,MAAM,UAAU,CAAC;AAElB,2EAA2E;AAC3E,qBAAa,iBAAkB,YAAW,aAAa;IACvC,OAAO,CAAC,QAAQ,CAAC,QAAQ;gBAAR,QAAQ,GAAE,eAA2C;IAElF,4FAA4F;IACtF,QAAQ,CAAC,IAAI,EAAE,cAAc,EAAE,OAAO,EAAE,WAAW,GAAG,OAAO,CAAC,oBAAoB,CAAC;CA2B5F"}