@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,112 @@
1
+ /**
2
+ * Pluggable test-path resolution for the test-location evaluator.
3
+ *
4
+ * Each implementation maps a source file path to its conventional test file path
5
+ * for one language, so the same evaluator works across TypeScript, Python, Go, and
6
+ * Rust projects. Resolvers are registered on the host and selected per rule via
7
+ * `evaluator.config.resolver`.
8
+ */
9
+ /**
10
+ * TypeScript conventions:
11
+ * src/foo/bar.ts → tests/foo/bar.test.ts
12
+ * packages/core/src/foo/bar.ts → packages/core/tests/foo/bar.test.ts
13
+ */
14
+ export class TypeScriptTestPathResolver {
15
+ /** Registry key. */
16
+ name = 'typescript';
17
+ constructor() { }
18
+ /** Map a TS/JS source path to its `tests/…test.ts` counterpart. */
19
+ resolveTestPath(srcRelPath) {
20
+ if (srcRelPath.includes('.test.') || srcRelPath.includes('.spec.'))
21
+ return srcRelPath;
22
+ const srcIdx = srcRelPath.indexOf('/src/');
23
+ if (srcIdx !== -1) {
24
+ const pkg = srcRelPath.slice(0, srcIdx);
25
+ const rel = srcRelPath.slice(srcIdx + '/src/'.length).replace(/\.(ts|tsx|js|jsx)$/, '.test.ts');
26
+ return `${pkg}/tests/${rel}`;
27
+ }
28
+ const withoutExt = srcRelPath.replace(/\.(ts|tsx|js|jsx)$/, '');
29
+ return `tests/${withoutExt.replace(/^src\//, '')}.test.ts`;
30
+ }
31
+ }
32
+ /**
33
+ * Python conventions (pytest):
34
+ * src/foo/bar.py → tests/foo/test_bar.py
35
+ */
36
+ export class PythonTestPathResolver {
37
+ /** Registry key. */
38
+ name = 'python';
39
+ constructor() { }
40
+ /** Map a Python source path to its `tests/…/test_*.py` counterpart. */
41
+ resolveTestPath(srcRelPath) {
42
+ if (!srcRelPath)
43
+ throw new Error('empty source path');
44
+ if (srcRelPath.endsWith('_test.py') || srcRelPath.includes('/test_') || srcRelPath.startsWith('test_')) {
45
+ return srcRelPath;
46
+ }
47
+ if (srcRelPath.startsWith('tests/'))
48
+ return srcRelPath;
49
+ if (!srcRelPath.endsWith('.py'))
50
+ throw new Error(`unsupported extension for python resolver: ${srcRelPath}`);
51
+ const srcIdx = srcRelPath.indexOf('/src/');
52
+ if (srcIdx !== -1) {
53
+ const pkg = srcRelPath.slice(0, srcIdx);
54
+ return `${pkg}/tests/${testify(srcRelPath.slice(srcIdx + '/src/'.length))}`;
55
+ }
56
+ if (srcRelPath.startsWith('src/'))
57
+ return `tests/${testify(srcRelPath.slice(4))}`;
58
+ return `tests/${testify(srcRelPath)}`;
59
+ }
60
+ }
61
+ /**
62
+ * Go conventions:
63
+ * foo/bar.go → foo/bar_test.go (sibling)
64
+ */
65
+ export class GoTestPathResolver {
66
+ /** Registry key. */
67
+ name = 'go';
68
+ constructor() { }
69
+ /** Map a Go source path to its sibling `_test.go` file. */
70
+ resolveTestPath(srcRelPath) {
71
+ if (!srcRelPath)
72
+ throw new Error('empty source path');
73
+ if (srcRelPath.endsWith('_test.go'))
74
+ return srcRelPath;
75
+ if (!srcRelPath.endsWith('.go'))
76
+ throw new Error(`unsupported extension for go resolver: ${srcRelPath}`);
77
+ return srcRelPath.replace(/\.go$/, '_test.go');
78
+ }
79
+ }
80
+ /**
81
+ * Rust conventions (Cargo integration tests):
82
+ * crate/src/foo.rs → crate/tests/foo.rs
83
+ */
84
+ export class RustTestPathResolver {
85
+ /** Registry key. */
86
+ name = 'rust';
87
+ constructor() { }
88
+ /** Map a Rust source path to its `tests/` integration-test counterpart. */
89
+ resolveTestPath(srcRelPath) {
90
+ if (!srcRelPath)
91
+ throw new Error('empty source path');
92
+ if (srcRelPath.startsWith('tests/') || srcRelPath.includes('/tests/'))
93
+ return srcRelPath;
94
+ if (!srcRelPath.endsWith('.rs'))
95
+ throw new Error(`unsupported extension for rust resolver: ${srcRelPath}`);
96
+ const srcIdx = srcRelPath.indexOf('/src/');
97
+ if (srcIdx !== -1) {
98
+ const crate = srcRelPath.slice(0, srcIdx);
99
+ return `${crate}/tests/${srcRelPath.slice(srcIdx + '/src/'.length)}`;
100
+ }
101
+ if (srcRelPath.startsWith('src/'))
102
+ return `tests/${srcRelPath.slice(4)}`;
103
+ return `tests/${srcRelPath}`;
104
+ }
105
+ }
106
+ /** Prefix the basename of a Python source path with `test_`. */
107
+ function testify(rel) {
108
+ const lastSlash = rel.lastIndexOf('/');
109
+ const dir = lastSlash >= 0 ? rel.slice(0, lastSlash + 1) : '';
110
+ const base = lastSlash >= 0 ? rel.slice(lastSlash + 1) : rel;
111
+ return `${dir}test_${base}`;
112
+ }
package/dist/types.d.ts CHANGED
@@ -49,6 +49,17 @@ export interface ConstraintRuleFile {
49
49
  /** Rule definitions. */
50
50
  rules: ConstraintRule[];
51
51
  }
52
+ /** Relative module paths a preset contributes per capability kind. */
53
+ export interface PresetExtensions {
54
+ /** Test-path resolver module paths. */
55
+ resolvers?: string[];
56
+ /** Evaluator module paths. */
57
+ evaluators?: string[];
58
+ /** Fixer module paths. */
59
+ fixers?: string[];
60
+ /** Formatter module paths. */
61
+ formatters?: string[];
62
+ }
52
63
  /** Preset definition that composes category folders or other presets. */
53
64
  export interface PresetDefinition {
54
65
  /** Preset name. */
@@ -63,6 +74,8 @@ export interface PresetDefinition {
63
74
  mode: FixMode;
64
75
  };
65
76
  }>;
77
+ /** Custom capability modules contributed by this preset (opt-in to load). */
78
+ extensions?: PresetExtensions;
66
79
  }
67
80
  /** Candidate fix emitted by an evaluator or fixer. */
68
81
  export interface Fix {
@@ -79,6 +92,15 @@ export interface Fix {
79
92
  /** Whether this fix may be applied automatically. */
80
93
  mode: Exclude<FixMode, 'none'>;
81
94
  }
95
+ /**
96
+ * What a finding represents.
97
+ *
98
+ * - `violation`: the rule ran and the project breached its policy (the default).
99
+ * - `error`: the rule could not run — a misconfiguration or runtime fault in the
100
+ * evaluator itself. These are not policy breaches and callers may surface them
101
+ * separately (e.g. "rule misconfigured") rather than as project violations.
102
+ */
103
+ export type FindingKind = 'violation' | 'error';
82
104
  /** Finding emitted by a constraint rule. */
83
105
  export interface ConstraintFinding {
84
106
  /** Rule identifier. */
@@ -95,6 +117,8 @@ export interface ConstraintFinding {
95
117
  column?: number;
96
118
  /** Machine-readable evaluator/source code. */
97
119
  code?: string;
120
+ /** Whether this is a policy violation or an evaluator error. Absent means `violation`. */
121
+ kind?: FindingKind;
98
122
  }
99
123
  /** Aggregate result returned by a rule evaluator. */
100
124
  export interface RuleEvaluationResult {
@@ -214,5 +238,11 @@ export declare const PresetDefinitionSchema: z.ZodObject<{
214
238
  }>;
215
239
  }, z.core.$strip>>;
216
240
  }, z.core.$strip>>>;
241
+ extensions: z.ZodOptional<z.ZodObject<{
242
+ resolvers: z.ZodOptional<z.ZodArray<z.ZodString>>;
243
+ evaluators: z.ZodOptional<z.ZodArray<z.ZodString>>;
244
+ fixers: z.ZodOptional<z.ZodArray<z.ZodString>>;
245
+ formatters: z.ZodOptional<z.ZodArray<z.ZodString>>;
246
+ }, z.core.$strip>>;
217
247
  }, z.core.$strip>;
218
248
  //# sourceMappingURL=types.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
package/dist/types.js CHANGED
@@ -46,4 +46,12 @@ export const PresetDefinitionSchema = z.object({
46
46
  overrides: z
47
47
  .record(z.string(), z.object({ fix: z.object({ mode: z.enum(['none', 'suggest', 'auto']) }).optional() }))
48
48
  .optional(),
49
+ extensions: z
50
+ .object({
51
+ resolvers: z.array(z.string()).optional(),
52
+ evaluators: z.array(z.string()).optional(),
53
+ fixers: z.array(z.string()).optional(),
54
+ formatters: z.array(z.string()).optional(),
55
+ })
56
+ .optional(),
49
57
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gobing-ai/ts-rule-engine",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "description": "@gobing-ai/ts-rule-engine — Constraint rule schemas, loading, evaluation, and result formatting.",
5
5
  "keywords": [
6
6
  "typescript",
@@ -47,8 +47,8 @@
47
47
  "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"
48
48
  },
49
49
  "dependencies": {
50
- "@gobing-ai/ts-ai-runner": "^0.2.5",
51
- "@gobing-ai/ts-runtime": "^0.2.5",
50
+ "@gobing-ai/ts-ai-runner": "^0.2.7",
51
+ "@gobing-ai/ts-runtime": "^0.2.7",
52
52
  "yaml": "^2.7.0",
53
53
  "zod": "^4.1.0"
54
54
  },
@@ -0,0 +1,108 @@
1
+ import { resolve } from 'node:path';
2
+ import type { RuleEngineHost } from '../host/rule-engine-host';
3
+
4
+ /** A capability kind a preset extension can contribute. */
5
+ export type ExtensionKind = 'resolvers' | 'evaluators' | 'fixers' | 'formatters';
6
+
7
+ /** A single extension module reference, resolved to an absolute path. */
8
+ export interface ExtensionRef {
9
+ /** Capability registry the module registers into. */
10
+ readonly kind: ExtensionKind;
11
+ /** Absolute path to the module to import. */
12
+ readonly absPath: string;
13
+ /** Name of the preset that declared this extension (for diagnostics). */
14
+ readonly presetName: string;
15
+ }
16
+
17
+ /** Options controlling preset-extension loading. */
18
+ export interface LoadExtensionsOptions {
19
+ /**
20
+ * Whether to actually import extension modules. Defaults to `false`: loading
21
+ * arbitrary code referenced by a preset is a trust decision the caller must
22
+ * make explicitly. When refs exist and this is false, loading throws.
23
+ */
24
+ allowExtensions?: boolean;
25
+ /** Optional sink for non-fatal warnings (e.g. built-in overrides). */
26
+ logger?: { warn: (message: string) => void };
27
+ }
28
+
29
+ /** Host registries that can receive extension capabilities (fixers live on the engine, not the host). */
30
+ const HOST_REGISTRY_BY_KIND: Partial<Record<ExtensionKind, 'resolvers' | 'evaluators' | 'formatters'>> = {
31
+ resolvers: 'resolvers',
32
+ evaluators: 'evaluators',
33
+ formatters: 'formatters',
34
+ };
35
+
36
+ /**
37
+ * Collect extension refs declared by a preset's `extensions` block.
38
+ *
39
+ * Paths are resolved relative to the preset file's directory. Use the returned
40
+ * refs with {@link loadExtensionsIntoHost}.
41
+ */
42
+ export function collectPresetExtensions(
43
+ presetName: string,
44
+ presetDir: string,
45
+ extensions: Partial<Record<ExtensionKind, string[] | undefined>> | undefined,
46
+ ): ExtensionRef[] {
47
+ if (extensions === undefined) return [];
48
+ const refs: ExtensionRef[] = [];
49
+ for (const kind of ['resolvers', 'evaluators', 'fixers', 'formatters'] as ExtensionKind[]) {
50
+ for (const path of extensions[kind] ?? []) {
51
+ refs.push({ kind, presetName, absPath: resolve(presetDir, path) });
52
+ }
53
+ }
54
+ return refs;
55
+ }
56
+
57
+ /**
58
+ * Import each extension module and register its export on the matching host
59
+ * registry.
60
+ *
61
+ * A module must default-export (or named-export `extension`) an object with a
62
+ * `name: string` and the capability implementation. Loading is gated by
63
+ * {@link LoadExtensionsOptions.allowExtensions}; when refs are present but
64
+ * loading is not allowed, this throws so the requirement is never silently dropped.
65
+ *
66
+ * @throws When extensions are present but `allowExtensions` is not true, or when
67
+ * a module cannot be imported or lacks a valid `name`.
68
+ */
69
+ export async function loadExtensionsIntoHost(
70
+ host: RuleEngineHost,
71
+ refs: readonly ExtensionRef[],
72
+ options: LoadExtensionsOptions = {},
73
+ ): Promise<void> {
74
+ if (refs.length === 0) return;
75
+ if (options.allowExtensions !== true) {
76
+ const first = refs[0] as ExtensionRef;
77
+ throw new Error(
78
+ `preset "${first.presetName}" declares ${first.kind} extension "${first.absPath}", but extensions are disabled — pass allowExtensions: true to load preset extension modules`,
79
+ );
80
+ }
81
+
82
+ for (const ref of refs) {
83
+ const moduleExports = (await import(ref.absPath)) as Record<string, unknown>;
84
+ const candidate = moduleExports.default ?? moduleExports.extension;
85
+ if (
86
+ candidate === null ||
87
+ typeof candidate !== 'object' ||
88
+ typeof (candidate as { name?: unknown }).name !== 'string'
89
+ ) {
90
+ throw new Error(
91
+ `preset "${ref.presetName}" extension "${ref.absPath}" must export an object with a string "name"`,
92
+ );
93
+ }
94
+ const name = (candidate as { name: string }).name;
95
+ const registryKey = HOST_REGISTRY_BY_KIND[ref.kind];
96
+ if (registryKey === undefined) {
97
+ throw new Error(`preset "${ref.presetName}" ${ref.kind} extensions are not supported`);
98
+ }
99
+ const registry = host[registryKey] as unknown as {
100
+ register: (name: string, impl: unknown, origin: 'builtin' | 'extension') => void;
101
+ has?: (name: string) => boolean;
102
+ };
103
+ if (options.logger && registry.has?.(name)) {
104
+ options.logger.warn(`preset "${ref.presetName}" ${ref.kind} extension overrides existing "${name}"`);
105
+ }
106
+ registry.register(name, candidate, 'extension');
107
+ }
108
+ }
@@ -1,4 +1,4 @@
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 {
@@ -12,22 +12,39 @@ import {
12
12
 
13
13
  /** Options for loading rule presets. */
14
14
  export interface RuleLoaderOptions {
15
- /** Project working directory. */
16
- workdir: string;
17
- /** Rule root directory. Defaults to ".spur/rules". */
18
- rulesRoot?: string;
15
+ /**
16
+ * Ordered rule root directories, highest priority first. Presets, category
17
+ * folders, and rule files are resolved across all roots: the highest-priority
18
+ * root that provides a given relative path wins, and gaps are filled from
19
+ * lower-priority roots. The caller owns root discovery and ordering — this
20
+ * loader stays agnostic to any project layout convention.
21
+ */
22
+ roots: string[];
19
23
  }
20
24
 
21
- /** Load and normalize a preset by name. */
25
+ /** Merged view of rule roots: winning file per relative path, plus categories. */
26
+ interface MergedRoots {
27
+ /** Normalized relative path (e.g. `quality/coverage-gate.yaml`) → winning absolute path. */
28
+ readonly files: ReadonlyMap<string, string>;
29
+ /** Category folder names visible across all roots. */
30
+ readonly categories: ReadonlySet<string>;
31
+ }
32
+
33
+ /**
34
+ * Load and normalize a preset by name, resolving across one or more rule roots.
35
+ *
36
+ * Roots are merged in order: the first root to provide a relative path owns it,
37
+ * so a caller can layer project-local rules over shared/global rules and inherit
38
+ * the rest of a preset's categories from the lower-priority roots.
39
+ */
22
40
  export async function loadPresetRules(name: string, options: RuleLoaderOptions): Promise<ConstraintRule[]> {
23
- const fs = new NodeFileSystem();
24
- const root = resolve(options.workdir, options.rulesRoot ?? '.spur/rules');
25
- const presetPath = await findDefinitionPath(root, name);
41
+ const merged = await buildMergedRoots(options.roots.map((root) => resolve(root)));
42
+ const presetPath = findMergedPreset(merged, name);
26
43
  if (presetPath === null) return [];
27
44
  const preset = PresetDefinitionSchema.parse(await readStructuredFile(presetPath)) as PresetDefinition;
28
45
  const rules: ConstraintRule[] = [];
29
46
  for (const entry of preset.extends) {
30
- rules.push(...(await loadPresetEntry(root, entry, new Set([name]))));
47
+ rules.push(...(await loadPresetEntry(merged, entry, new Set([name]))));
31
48
  }
32
49
  const disabled = new Set(preset.disable ?? []);
33
50
  const normalized = rules.filter((rule) => !disabled.has(rule.id));
@@ -37,7 +54,6 @@ export async function loadPresetRules(name: string, options: RuleLoaderOptions):
37
54
  rule.fix = { ...(rule.fix ?? { mode: 'none' }), ...override.fix };
38
55
  }
39
56
  }
40
- await fs.exists(root);
41
57
  return normalized;
42
58
  }
43
59
 
@@ -46,45 +62,127 @@ export async function loadRuleFile(filePath: string): Promise<ConstraintRule[]>
46
62
  return normalizeRuleFile(await readStructuredFile(resolve(filePath)), dirname(resolve(filePath)));
47
63
  }
48
64
 
49
- async function loadPresetEntry(root: string, entry: string, seen: Set<string>): Promise<ConstraintRule[]> {
50
- const presetPath = await findDefinitionPath(root, entry);
51
- if (presetPath !== null && !seen.has(entry)) {
65
+ async function loadPresetEntry(merged: MergedRoots, entry: string, seen: Set<string>): Promise<ConstraintRule[]> {
66
+ // Sub-preset reference recurse, erroring on a genuine cycle.
67
+ const presetPath = findMergedPreset(merged, entry);
68
+ if (presetPath !== null) {
69
+ if (seen.has(entry)) {
70
+ throw new Error(`Circular preset dependency detected: ${[...seen, entry].join(' → ')}`);
71
+ }
52
72
  seen.add(entry);
53
73
  const preset = PresetDefinitionSchema.safeParse(await readStructuredFile(presetPath));
54
74
  if (preset.success) {
55
75
  const rules: ConstraintRule[] = [];
56
- for (const child of preset.data.extends) rules.push(...(await loadPresetEntry(root, child, seen)));
76
+ for (const child of preset.data.extends) rules.push(...(await loadPresetEntry(merged, child, seen)));
57
77
  return rules.filter((rule) => !(preset.data.disable ?? []).includes(rule.id));
58
78
  }
59
79
  }
60
80
 
61
- const categoryDir = resolve(root, entry);
62
- const fs = new NodeFileSystem();
63
- if (!(await fs.exists(categoryDir))) return [];
64
- const entries = (await fs.readDir(categoryDir)).filter((file) => /\.(ya?ml|json)$/i.test(file)).sort();
65
- const rules: ConstraintRule[] = [];
66
- for (const file of entries) {
67
- rules.push(...(await loadRuleFile(join(categoryDir, file))));
81
+ // Category folder reference — load every winning file under that prefix.
82
+ if (merged.categories.has(entry)) {
83
+ const rules: ConstraintRule[] = [];
84
+ for (const absPath of mergedFilesInCategory(merged, entry)) {
85
+ rules.push(...(await loadRuleFile(absPath)));
86
+ }
87
+ return rules;
68
88
  }
69
- return rules;
89
+
90
+ // Sub-path reference — a single winning rule file within a category.
91
+ const subPath = findMergedFile(merged, entry);
92
+ if (subPath !== null) return loadRuleFile(subPath);
93
+
94
+ return [];
70
95
  }
71
96
 
72
- async function findDefinitionPath(root: string, name: string): Promise<string | null> {
97
+ /**
98
+ * Build the merged view across ordered roots.
99
+ *
100
+ * Roots are processed in the order supplied (highest priority first). The first
101
+ * root to provide a given relative path owns that file; later roots are shadowed.
102
+ */
103
+ async function buildMergedRoots(roots: readonly string[]): Promise<MergedRoots> {
73
104
  const fs = new NodeFileSystem();
74
- const candidates = [
75
- resolve(root, `${name}.yaml`),
76
- resolve(root, `${name}.yml`),
77
- resolve(root, `${name}.json`),
78
- resolve(root, name, 'index.yaml'),
79
- resolve(root, name, 'index.yml'),
80
- resolve(root, name, 'index.json'),
81
- ];
82
- for (const candidate of candidates) {
83
- if (await fs.exists(candidate)) return candidate;
105
+ const files = new Map<string, string>();
106
+ const categories = new Set<string>();
107
+ for (const root of roots) {
108
+ for (const absPath of await walkYamlFiles(fs, root)) {
109
+ const relPath = relative(root, absPath).split(sep).join('/');
110
+ const slashIdx = relPath.indexOf('/');
111
+ if (slashIdx > 0) categories.add(relPath.slice(0, slashIdx));
112
+ if (!files.has(relPath)) files.set(relPath, absPath);
113
+ }
114
+ for (const dir of await listImmediateDirs(fs, root)) categories.add(dir);
115
+ }
116
+ return { files, categories };
117
+ }
118
+
119
+ /** Find a preset definition across roots: `<name>.{yaml,yml,json}` or `<name>/index.*`. */
120
+ function findMergedPreset(merged: MergedRoots, name: string): string | null {
121
+ return firstHit(merged, [
122
+ `${name}.yaml`,
123
+ `${name}.yml`,
124
+ `${name}.json`,
125
+ `${name}/index.yaml`,
126
+ `${name}/index.yml`,
127
+ `${name}/index.json`,
128
+ ]);
129
+ }
130
+
131
+ /** Find a single rule file by sub-path entry (e.g. `typescript/tsdoc-exports`). */
132
+ function findMergedFile(merged: MergedRoots, entry: string): string | null {
133
+ const hasExt = /\.(ya?ml|json)$/i.test(entry);
134
+ return firstHit(merged, hasExt ? [entry] : [`${entry}.yaml`, `${entry}.yml`, `${entry}.json`]);
135
+ }
136
+
137
+ /** Return the winning absolute path for the first matching relative candidate. */
138
+ function firstHit(merged: MergedRoots, relCandidates: readonly string[]): string | null {
139
+ for (const rel of relCandidates) {
140
+ const hit = merged.files.get(rel);
141
+ if (hit !== undefined) return hit;
84
142
  }
85
143
  return null;
86
144
  }
87
145
 
146
+ /** Winning files under a category prefix, sorted by relative path. */
147
+ function mergedFilesInCategory(merged: MergedRoots, category: string): string[] {
148
+ const prefix = `${category}/`;
149
+ return [...merged.files.entries()]
150
+ .filter(([relPath]) => relPath.startsWith(prefix))
151
+ .sort(([a], [b]) => a.localeCompare(b))
152
+ .map(([, absPath]) => absPath);
153
+ }
154
+
155
+ /** Recursively collect YAML/JSON file paths under a directory, skipping root-level `presets/`. */
156
+ async function walkYamlFiles(fs: NodeFileSystem, dir: string, depth = 0): Promise<string[]> {
157
+ const stat = await fs.stat(dir);
158
+ if (stat === null || !stat.isDirectory()) return [];
159
+ const acc: string[] = [];
160
+ for (const entry of (await fs.readDir(dir)).sort()) {
161
+ if (depth === 0 && entry === 'presets') continue;
162
+ const fullPath = join(dir, entry);
163
+ const entryStat = await fs.stat(fullPath);
164
+ if (entryStat?.isDirectory()) {
165
+ acc.push(...(await walkYamlFiles(fs, fullPath, depth + 1)));
166
+ } else if (entryStat?.isFile() && /\.(ya?ml|json)$/i.test(entry)) {
167
+ acc.push(fullPath);
168
+ }
169
+ }
170
+ return acc;
171
+ }
172
+
173
+ /** List immediate subdirectory names of a root (excluding `presets`). */
174
+ async function listImmediateDirs(fs: NodeFileSystem, dir: string): Promise<string[]> {
175
+ const stat = await fs.stat(dir);
176
+ if (stat === null || !stat.isDirectory()) return [];
177
+ const dirs: string[] = [];
178
+ for (const entry of await fs.readDir(dir)) {
179
+ if (entry === 'presets') continue;
180
+ const entryStat = await fs.stat(join(dir, entry));
181
+ if (entryStat?.isDirectory()) dirs.push(entry);
182
+ }
183
+ return dirs;
184
+ }
185
+
88
186
  async function readStructuredFile(path: string): Promise<unknown> {
89
187
  const content = await new NodeFileSystem().readFile(path);
90
188
  return extname(path) === '.json' ? JSON.parse(content) : parse(content);
@@ -105,7 +203,8 @@ function normalizeFileRules(file: ConstraintRuleFile, sourceDir: string): Constr
105
203
  ...rule,
106
204
  severity: rule.severity ?? file.severity ?? 'error',
107
205
  include: rule.include ?? file.include,
108
- exclude: rule.exclude ?? file.exclude,
206
+ // File-level excludes always apply; a rule's own excludes add to (not replace) them.
207
+ exclude: mergeExcludes(file.exclude, rule.exclude),
109
208
  },
110
209
  {},
111
210
  sourceDir,
@@ -113,6 +212,12 @@ function normalizeFileRules(file: ConstraintRuleFile, sourceDir: string): Constr
113
212
  );
114
213
  }
115
214
 
215
+ /** Union of file-level and rule-level excludes, de-duplicated. Returns undefined when both empty. */
216
+ function mergeExcludes(fileExclude?: string[], ruleExclude?: string[]): string[] | undefined {
217
+ if (fileExclude === undefined && ruleExclude === undefined) return undefined;
218
+ return [...new Set([...(fileExclude ?? []), ...(ruleExclude ?? [])])];
219
+ }
220
+
116
221
  function normalizeRule(rule: ConstraintRule, _defaults: Partial<ConstraintRule>, _sourceDir: string): ConstraintRule {
117
222
  return {
118
223
  ...rule,