@gobing-ai/ts-rule-engine 0.3.1 → 0.3.3

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 (86) hide show
  1. package/README.md +328 -58
  2. package/dist/config/extensions.d.ts +10 -7
  3. package/dist/config/extensions.d.ts.map +1 -1
  4. package/dist/config/extensions.js +48 -23
  5. package/dist/config/loader.d.ts +7 -0
  6. package/dist/config/loader.d.ts.map +1 -1
  7. package/dist/config/loader.js +17 -12
  8. package/dist/engine.d.ts +13 -2
  9. package/dist/engine.d.ts.map +1 -1
  10. package/dist/engine.js +107 -45
  11. package/dist/evaluators/agent-detection-evaluator.d.ts.map +1 -1
  12. package/dist/evaluators/agent-detection-evaluator.js +2 -9
  13. package/dist/evaluators/coverage-gate-evaluator.d.ts.map +1 -1
  14. package/dist/evaluators/coverage-gate-evaluator.js +4 -5
  15. package/dist/evaluators/exit-code-evaluator.d.ts.map +1 -1
  16. package/dist/evaluators/exit-code-evaluator.js +6 -23
  17. package/dist/evaluators/file-utils.d.ts +25 -1
  18. package/dist/evaluators/file-utils.d.ts.map +1 -1
  19. package/dist/evaluators/file-utils.js +48 -8
  20. package/dist/evaluators/forbidden-import-evaluator.js +2 -10
  21. package/dist/evaluators/path-evaluator.d.ts.map +1 -1
  22. package/dist/evaluators/path-evaluator.js +5 -18
  23. package/dist/evaluators/regex-evaluator.js +3 -11
  24. package/dist/evaluators/ripgrep-evaluator.d.ts +50 -0
  25. package/dist/evaluators/ripgrep-evaluator.d.ts.map +1 -0
  26. package/dist/evaluators/ripgrep-evaluator.js +145 -0
  27. package/dist/evaluators/schema-artifact-evaluator.d.ts.map +1 -1
  28. package/dist/evaluators/schema-artifact-evaluator.js +3 -7
  29. package/dist/evaluators/sg-evaluator.d.ts +10 -2
  30. package/dist/evaluators/sg-evaluator.d.ts.map +1 -1
  31. package/dist/evaluators/sg-evaluator.js +21 -4
  32. package/dist/evaluators/test-location-evaluator.d.ts +2 -2
  33. package/dist/evaluators/test-location-evaluator.d.ts.map +1 -1
  34. package/dist/evaluators/test-location-evaluator.js +2 -15
  35. package/dist/events.d.ts +33 -0
  36. package/dist/events.d.ts.map +1 -0
  37. package/dist/events.js +0 -0
  38. package/dist/fixers/fixers.d.ts +1 -1
  39. package/dist/fixers/fixers.d.ts.map +1 -1
  40. package/dist/fixers/fixers.js +4 -5
  41. package/dist/fixers/test-stub-fixer.d.ts +1 -1
  42. package/dist/fixers/test-stub-fixer.d.ts.map +1 -1
  43. package/dist/fixers/test-stub-fixer.js +3 -4
  44. package/dist/host/builtins.d.ts.map +1 -1
  45. package/dist/host/builtins.js +5 -3
  46. package/dist/host/bundled-rules.d.ts +1 -1
  47. package/dist/host/bundled-rules.js +1 -1
  48. package/dist/host/rule-engine-host.d.ts +1 -1
  49. package/dist/host/rule-engine-host.d.ts.map +1 -1
  50. package/dist/host/rule-engine-host.js +1 -1
  51. package/dist/index.d.ts +3 -1
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +7 -1
  54. package/dist/types.d.ts +2 -0
  55. package/dist/types.d.ts.map +1 -1
  56. package/dist/types.js +6 -0
  57. package/package.json +4 -5
  58. package/rules/example.yaml +13 -0
  59. package/src/config/extensions.ts +58 -29
  60. package/src/config/loader.ts +27 -12
  61. package/src/engine.ts +132 -47
  62. package/src/evaluators/agent-detection-evaluator.ts +2 -8
  63. package/src/evaluators/coverage-gate-evaluator.ts +4 -5
  64. package/src/evaluators/exit-code-evaluator.ts +6 -23
  65. package/src/evaluators/file-utils.ts +70 -8
  66. package/src/evaluators/forbidden-import-evaluator.ts +2 -9
  67. package/src/evaluators/path-evaluator.ts +5 -18
  68. package/src/evaluators/regex-evaluator.ts +4 -11
  69. package/src/evaluators/ripgrep-evaluator.ts +167 -0
  70. package/src/evaluators/schema-artifact-evaluator.ts +3 -8
  71. package/src/evaluators/sg-evaluator.ts +21 -4
  72. package/src/evaluators/test-location-evaluator.ts +3 -16
  73. package/src/events.ts +13 -0
  74. package/src/fixers/fixers.ts +12 -6
  75. package/src/fixers/test-stub-fixer.ts +4 -5
  76. package/src/host/builtins.ts +5 -3
  77. package/src/host/bundled-rules.ts +1 -1
  78. package/src/host/rule-engine-host.ts +1 -1
  79. package/src/index.ts +8 -1
  80. package/src/types.ts +7 -0
  81. package/dist/host/capability-registry.d.ts +0 -10
  82. package/dist/host/capability-registry.d.ts.map +0 -1
  83. package/dist/host/capability-registry.js +0 -9
  84. package/rules/recommended.yaml +0 -10
  85. package/rules/spur-dev.yaml +0 -6
  86. package/src/host/capability-registry.ts +0 -9
package/src/engine.ts CHANGED
@@ -1,4 +1,7 @@
1
+ import type { EventBus, Logger } from '@gobing-ai/ts-infra';
2
+ import { addSpanEvent, getLogger, traceAsync } from '@gobing-ai/ts-infra';
1
3
  import type { ProcessExecutor } from '@gobing-ai/ts-runtime';
4
+ import type { RuleEngineEvents } from './events';
2
5
  import {
3
6
  applyFixes as applyFixesImpl,
4
7
  builtInFixers,
@@ -10,7 +13,7 @@ import {
10
13
  import { registerBuiltins } from './host/builtins';
11
14
  import { RuleEngineHost } from './host/rule-engine-host';
12
15
  import type { ConstraintFinding, ConstraintRule, Fix, FixMode, RuleEngineResult, RuleEvaluator } from './types';
13
- import { createFinding } from './types';
16
+ import { createFinding, SEVERITY_RANK } from './types';
14
17
 
15
18
  /** Options for constructing a RuleEngine. */
16
19
  export interface RuleEngineOptions {
@@ -18,6 +21,10 @@ export interface RuleEngineOptions {
18
21
  processExecutor?: ProcessExecutor;
19
22
  /** Optional preconfigured host. */
20
23
  host?: RuleEngineHost;
24
+ /** Optional event bus for structured run observability (R-A4). */
25
+ events?: EventBus<RuleEngineEvents>;
26
+ /** Optional logger; defaults to the shared `rule-engine` category logger. */
27
+ logger?: Logger;
21
28
  }
22
29
 
23
30
  /** Orchestrates enabled constraint rules through a typed evaluator host. */
@@ -27,11 +34,15 @@ export class RuleEngine {
27
34
 
28
35
  /** Fixer providers keyed by evaluator type. */
29
36
  private readonly fixers: Map<string, RuleFixerProvider>;
37
+ private readonly events: EventBus<RuleEngineEvents> | undefined;
38
+ private readonly logger: Logger;
30
39
 
31
40
  constructor(options: RuleEngineOptions = {}) {
32
41
  this.host = options.host ?? new RuleEngineHost();
33
42
  registerBuiltins(this.host, options.processExecutor);
34
43
  this.fixers = builtInFixers(this.host, options.processExecutor);
44
+ this.events = options.events;
45
+ this.logger = options.logger ?? getLogger('rule-engine');
35
46
  }
36
47
 
37
48
  /** Register or replace an evaluator. */
@@ -47,8 +58,12 @@ export class RuleEngine {
47
58
  * findings, never auto-generated fixes. Keeps the rule loop and error-finding
48
59
  * semantics in one place.
49
60
  */
50
- async evaluate(rules: ConstraintRule[], workdir: string): Promise<RuleEngineResult> {
51
- return this.evaluateWithFixes(rules, workdir, 'none');
61
+ async evaluate(
62
+ rules: ConstraintRule[],
63
+ workdir: string,
64
+ stopOnFirst?: 'error' | 'warning' | 'info',
65
+ ): Promise<RuleEngineResult> {
66
+ return this.evaluateWithFixes(rules, workdir, 'none', stopOnFirst);
52
67
  }
53
68
 
54
69
  /**
@@ -61,60 +76,130 @@ export class RuleEngine {
61
76
  * @param rules - Normalized rule definitions to evaluate.
62
77
  * @param workdir - Working directory to scan.
63
78
  * @param maxFixMode - Highest fix authority requested by the caller.
79
+ * @param stopOnFirst - When set, stop evaluating rules after the first rule
80
+ * whose findings meet/exceed this severity threshold. Undefined = exhaustive
81
+ * (today's behavior, zero breaking change).
64
82
  * @returns Findings plus fixes allowed by the requested authority.
65
83
  */
66
84
  async evaluateWithFixes(
67
85
  rules: ConstraintRule[],
68
86
  workdir: string,
69
87
  maxFixMode: FixMode = 'auto',
88
+ stopOnFirst?: 'error' | 'warning' | 'info',
70
89
  ): Promise<RuleEngineResult> {
71
- const findings: ConstraintFinding[] = [];
72
- const fixes: Fix[] = [];
73
-
74
- for (const rule of rules) {
75
- if (rule.enabled === false) continue;
76
-
77
- let ruleFindings: ConstraintFinding[] = [];
78
- let ruleEvalFixes: Fix[] = [];
79
- try {
80
- const result = await this.host.evaluators.get(rule.evaluator.type).evaluate(rule, { rule, workdir });
81
- ruleFindings = result.findings;
82
- ruleEvalFixes = result.fixes;
83
- } catch (error) {
84
- ruleFindings = [
85
- createFinding(rule, error instanceof Error ? error.message : String(error), null, {
86
- code: `evaluator:${rule.evaluator.type}`,
87
- kind: 'error',
88
- }),
89
- ];
90
- }
91
-
92
- findings.push(...ruleFindings);
93
- fixes.push(...ruleEvalFixes);
94
-
95
- const ruleMode = rule.fix?.mode ?? 'none';
96
- const effectiveMode = effectiveFixMode(ruleMode, maxFixMode);
97
-
98
- if (effectiveMode !== 'none' && ruleFindings.length > 0) {
99
- const provider = this.fixers.get(rule.evaluator.type);
100
- if (provider) {
101
- const effectiveFix: EffectiveFix = {
102
- mode: effectiveMode,
103
- ...(rule.fix?.replacement !== undefined ? { replacement: rule.fix.replacement } : {}),
104
- ...(rule.fix?.params !== undefined ? { params: rule.fix.params } : {}),
105
- };
106
- const providerFixes = await provider.createFixes({
107
- rule,
108
- context: { rule, workdir },
109
- findings: ruleFindings,
110
- fix: effectiveFix,
90
+ const enabledRules = rules.filter((r) => r.enabled !== false);
91
+ const runStartMs = Date.now();
92
+
93
+ return await traceAsync(
94
+ 'rule.run',
95
+ async () => {
96
+ this.logger.info('rule run started', { enabled: enabledRules.length, total: rules.length });
97
+ addSpanEvent('rule.run.start', { rules: enabledRules.length, total: rules.length });
98
+ void this.events?.emit('rule.run.start', { rules: enabledRules.length, total: rules.length });
99
+
100
+ const findings: ConstraintFinding[] = [];
101
+ const fixes: Fix[] = [];
102
+ let index = 0;
103
+ let stoppedEarlyLocal = false;
104
+
105
+ for (const rule of rules) {
106
+ if (rule.enabled === false) continue;
107
+ index++;
108
+
109
+ const evalStartMs = Date.now();
110
+ this.logger.debug('eval start', { ruleId: rule.id, index, total: enabledRules.length });
111
+ addSpanEvent('rule.eval.start', { ruleId: rule.id, index, total: enabledRules.length });
112
+ void this.events?.emit('rule.eval.start', { ruleId: rule.id, index, total: enabledRules.length });
113
+
114
+ let ruleFindings: ConstraintFinding[] = [];
115
+ let ruleEvalFixes: Fix[] = [];
116
+ try {
117
+ const result = await this.host.evaluators
118
+ .get(rule.evaluator.type)
119
+ .evaluate(rule, { rule, workdir });
120
+ ruleFindings = result.findings;
121
+ ruleEvalFixes = result.fixes;
122
+ } catch (error) {
123
+ const message = error instanceof Error ? error.message : String(error);
124
+ ruleFindings = [
125
+ createFinding(rule, message, null, {
126
+ code: `evaluator:${rule.evaluator.type}`,
127
+ kind: 'error',
128
+ }),
129
+ ];
130
+ addSpanEvent('rule.eval.error', { ruleId: rule.id, error: message });
131
+ void this.events?.emit('rule.eval.error', { ruleId: rule.id, error: message });
132
+ }
133
+
134
+ const durationMs = Date.now() - evalStartMs;
135
+ addSpanEvent('rule.eval.done', {
136
+ ruleId: rule.id,
137
+ findings: ruleFindings.length,
138
+ durationMs,
139
+ });
140
+ void this.events?.emit('rule.eval.done', {
141
+ ruleId: rule.id,
142
+ findings: ruleFindings.length,
143
+ durationMs,
111
144
  });
112
- fixes.push(...providerFixes);
145
+
146
+ findings.push(...ruleFindings);
147
+ fixes.push(...ruleEvalFixes);
148
+
149
+ const ruleMode = rule.fix?.mode ?? 'none';
150
+ const effectiveMode = effectiveFixMode(ruleMode, maxFixMode);
151
+
152
+ if (effectiveMode !== 'none' && ruleFindings.length > 0) {
153
+ const provider = this.fixers.get(rule.evaluator.type);
154
+ if (provider) {
155
+ const effectiveFix: EffectiveFix = {
156
+ mode: effectiveMode,
157
+ ...(rule.fix?.replacement !== undefined ? { replacement: rule.fix.replacement } : {}),
158
+ ...(rule.fix?.params !== undefined ? { params: rule.fix.params } : {}),
159
+ };
160
+ const providerFixes = await provider.createFixes({
161
+ rule,
162
+ context: { rule, workdir },
163
+ findings: ruleFindings,
164
+ fix: effectiveFix,
165
+ });
166
+ fixes.push(...providerFixes);
167
+ }
168
+ }
169
+
170
+ if (
171
+ stopOnFirst &&
172
+ ruleFindings.some((f) => SEVERITY_RANK[f.severity] >= SEVERITY_RANK[stopOnFirst])
173
+ ) {
174
+ stoppedEarlyLocal = true;
175
+ break;
176
+ }
113
177
  }
114
- }
115
- }
116
178
 
117
- return { findings, fixes };
179
+ const runDurationMs = Date.now() - runStartMs;
180
+ this.logger.info('rule run done', {
181
+ rules: enabledRules.length,
182
+ findings: findings.length,
183
+ durationMs: runDurationMs,
184
+ stoppedEarly: stoppedEarlyLocal,
185
+ });
186
+ addSpanEvent('rule.run.done', {
187
+ rules: enabledRules.length,
188
+ findings: findings.length,
189
+ durationMs: runDurationMs,
190
+ stoppedEarly: stoppedEarlyLocal,
191
+ });
192
+ void this.events?.emit('rule.run.done', {
193
+ rules: enabledRules.length,
194
+ findings: findings.length,
195
+ durationMs: runDurationMs,
196
+ stoppedEarly: stoppedEarlyLocal,
197
+ });
198
+
199
+ return { findings, fixes };
200
+ },
201
+ { attributes: { 'rule.count': enabledRules.length } },
202
+ );
118
203
  }
119
204
 
120
205
  /**
@@ -6,6 +6,7 @@ import {
6
6
  type RuleEvaluationResult,
7
7
  type RuleEvaluator,
8
8
  } from '../types';
9
+ import { configArray } from './file-utils';
9
10
 
10
11
  /** Evaluates local availability of configured coding agents. */
11
12
  export class AgentDetectionEvaluator implements RuleEvaluator {
@@ -13,7 +14,7 @@ export class AgentDetectionEvaluator implements RuleEvaluator {
13
14
 
14
15
  /** Probe required agents and emit findings for missing CLIs. */
15
16
  async evaluate(rule: ConstraintRule, _context: RuleContext): Promise<RuleEvaluationResult> {
16
- const agents = arrayConfig(rule.evaluator.config ?? {}, 'agents');
17
+ const agents = configArray(rule.evaluator.config ?? {}, 'agents', undefined, { evaluator: 'agent-detection' });
17
18
  const findings = [];
18
19
  for (const agent of agents) {
19
20
  if (!isAgentName(agent)) {
@@ -28,10 +29,3 @@ export class AgentDetectionEvaluator implements RuleEvaluator {
28
29
  return { findings, fixes: [] };
29
30
  }
30
31
  }
31
-
32
- function arrayConfig(config: Record<string, unknown>, key: string): string[] {
33
- const value = config[key];
34
- if (Array.isArray(value) && value.every((item) => typeof item === 'string')) return value;
35
- if (typeof value === 'string') return [value];
36
- throw new Error(`agent-detection evaluator requires string[] config "${key}"`);
37
- }
@@ -1,5 +1,4 @@
1
- import { isAbsolute, relative, resolve } from 'node:path';
2
- import { NodeFileSystem } from '@gobing-ai/ts-runtime';
1
+ import { isAbsolutePath, NodeFileSystem, relativePath, resolvePath } from '@gobing-ai/ts-runtime';
3
2
  import {
4
3
  type ConstraintRule,
5
4
  createFinding,
@@ -55,8 +54,8 @@ export class CoverageGateEvaluator implements RuleEvaluator {
55
54
  async evaluate(rule: ConstraintRule, context: RuleContext): Promise<RuleEvaluationResult> {
56
55
  const config = (rule.evaluator.config ?? {}) as CoverageGateConfig;
57
56
  const lcovPath = config.lcovPath
58
- ? resolve(context.workdir, config.lcovPath)
59
- : resolve(context.workdir, 'coverage', 'lcov.info');
57
+ ? resolvePath(context.workdir, config.lcovPath)
58
+ : resolvePath(context.workdir, 'coverage', 'lcov.info');
60
59
 
61
60
  if (!(await this.fs.exists(lcovPath))) {
62
61
  return {
@@ -142,6 +141,6 @@ function isAlwaysExcluded(filePath: string): boolean {
142
141
 
143
142
  /** Normalize an lcov `SF:` path to a workdir-relative forward-slash path. */
144
143
  function normalizeLcovSourcePath(workdir: string, filePath: string): string {
145
- const normalized = isAbsolute(filePath) ? relative(workdir, filePath) : filePath;
144
+ const normalized = isAbsolutePath(filePath) ? relativePath(workdir, filePath) : filePath;
146
145
  return normalized.replaceAll('\\', '/');
147
146
  }
@@ -6,6 +6,7 @@ import {
6
6
  type RuleEvaluationResult,
7
7
  type RuleEvaluator,
8
8
  } from '../types';
9
+ import { configArray, configNumber, configString } from './file-utils';
9
10
 
10
11
  /** Evaluates a rule by running a subprocess and checking its exit code. */
11
12
  export class ExitCodeEvaluator implements RuleEvaluator {
@@ -14,10 +15,10 @@ export class ExitCodeEvaluator implements RuleEvaluator {
14
15
  /** Run configured command and emit a finding unless the exit code matches `successCode`. */
15
16
  async evaluate(rule: ConstraintRule, context: RuleContext): Promise<RuleEvaluationResult> {
16
17
  const config = rule.evaluator.config ?? {};
17
- const command = stringConfig(config, 'command');
18
- const args = arrayConfig(config, 'args', []);
19
- const successCode = numberConfig(config, 'successCode', 0);
20
- const timeout = numberConfig(config, 'timeout', 60_000);
18
+ const command = configString(config, 'command', undefined, { evaluator: 'exit-code' });
19
+ const args = configArray(config, 'args', []);
20
+ const successCode = configNumber(config, 'successCode', 0);
21
+ const timeout = configNumber(config, 'timeout', 60_000);
21
22
  const result = await this.executor.run({
22
23
  command,
23
24
  args,
@@ -28,7 +29,7 @@ export class ExitCodeEvaluator implements RuleEvaluator {
28
29
  });
29
30
  if (result.exitCode === successCode) return { findings: [], fixes: [] };
30
31
 
31
- const template = stringConfig(
32
+ const template = configString(
32
33
  config,
33
34
  'message',
34
35
  `Command failed (exit {code}): ${command} ${args.join(' ')}`.trim(),
@@ -40,21 +41,3 @@ export class ExitCodeEvaluator implements RuleEvaluator {
40
41
  };
41
42
  }
42
43
  }
43
-
44
- function numberConfig(config: Record<string, unknown>, key: string, fallback: number): number {
45
- const value = config[key];
46
- return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
47
- }
48
-
49
- function stringConfig(config: Record<string, unknown>, key: string, fallback?: string): string {
50
- const value = config[key];
51
- if (typeof value === 'string') return value;
52
- if (fallback !== undefined) return fallback;
53
- throw new Error(`exit-code evaluator requires string config "${key}"`);
54
- }
55
-
56
- function arrayConfig(config: Record<string, unknown>, key: string, fallback: string[]): string[] {
57
- const value = config[key];
58
- if (Array.isArray(value) && value.every((item) => typeof item === 'string')) return value;
59
- return fallback;
60
- }
@@ -1,5 +1,11 @@
1
- import { dirname, relative, resolve } from 'node:path';
2
- import { type FileSystem, NodeFileSystem, walkDir } from '@gobing-ai/ts-runtime';
1
+ import {
2
+ dirnamePath,
3
+ type LegacyFileSystem as FileSystem,
4
+ NodeFileSystem,
5
+ relativePath,
6
+ resolvePath,
7
+ walkDir,
8
+ } from '@gobing-ai/ts-runtime';
3
9
 
4
10
  /** Options for source-file discovery. */
5
11
  export interface SourceDiscoveryOptions {
@@ -13,14 +19,19 @@ export interface SourceDiscoveryOptions {
13
19
  fs?: FileSystem;
14
20
  }
15
21
 
16
- const DEFAULT_EXCLUDES = new Set(['.git', 'node_modules', 'dist', '.coverage', '.astro', '.wrangler']);
22
+ /**
23
+ * Directory names pruned from every file walk — heavy or generated trees that no rule
24
+ * should scan. Shared so subprocess-backed evaluators (e.g. `sg`) can forward the same
25
+ * skip-list to the external tool instead of relying on each rule to remember it.
26
+ */
27
+ export const DEFAULT_EXCLUDES = new Set(['.git', 'node_modules', 'dist', '.coverage', '.astro', '.wrangler']);
17
28
 
18
29
  /** Resolve source files for evaluators using conservative path-fragment matching. */
19
30
  export async function discoverFiles(options: SourceDiscoveryOptions): Promise<string[]> {
20
31
  const fs = options.fs ?? new NodeFileSystem();
21
- const allFiles = await walkDir(options.workdir, fs);
32
+ const allFiles = await walkDir(options.workdir, fs, DEFAULT_EXCLUDES);
22
33
  return allFiles
23
- .map((path) => relative(options.workdir, path))
34
+ .map((path) => relativePath(options.workdir, path))
24
35
  .filter((path) => !path.split('/').some((segment) => DEFAULT_EXCLUDES.has(segment)))
25
36
  .filter(
26
37
  (path) =>
@@ -31,7 +42,7 @@ export async function discoverFiles(options: SourceDiscoveryOptions): Promise<st
31
42
 
32
43
  /** Read a file from a workdir-relative path. */
33
44
  export async function readWorkdirFile(workdir: string, filePath: string, fs = new NodeFileSystem()): Promise<string> {
34
- return await fs.readFile(resolve(workdir, filePath));
45
+ return await fs.readFile(resolvePath(workdir, filePath));
35
46
  }
36
47
 
37
48
  /** A discovered in-scope file paired with its contents. */
@@ -104,12 +115,12 @@ async function discoverFilesByGlob(
104
115
 
105
116
  /** Ensure a path is workdir-relative for findings. */
106
117
  export function relativeToWorkdir(workdir: string, path: string): string {
107
- return relative(workdir, resolve(path));
118
+ return relativePath(workdir, resolvePath(path));
108
119
  }
109
120
 
110
121
  /** Return parent directory for a workdir-relative path. */
111
122
  export function relativeParent(path: string): string {
112
- const parent = dirname(path);
123
+ const parent = dirnamePath(path);
113
124
  return parent === '.' ? '' : parent;
114
125
  }
115
126
 
@@ -170,6 +181,57 @@ export function stringArray(value: unknown): string[] | undefined {
170
181
  return Array.isArray(value) && value.every((item) => typeof item === 'string') ? (value as string[]) : undefined;
171
182
  }
172
183
 
184
+ /** Optional accessor context — names the evaluator in "required config" errors for rule authors. */
185
+ export interface ConfigAccessorOptions {
186
+ /** Evaluator name surfaced in the error, e.g. `"regex evaluator requires string config \"pattern\""`. */
187
+ evaluator?: string;
188
+ }
189
+
190
+ function requiredConfigError(kind: string, key: string, evaluator?: string): Error {
191
+ const who = evaluator !== undefined ? `${evaluator} evaluator` : 'evaluator';
192
+ return new Error(`${who} requires ${kind} config "${key}"`);
193
+ }
194
+
195
+ /**
196
+ * Read a string entry from a rule's evaluator config. Returns the value when it is a string,
197
+ * the `fallback` when one is supplied, otherwise throws — so required keys fail loudly.
198
+ */
199
+ export function configString(
200
+ config: Record<string, unknown>,
201
+ key: string,
202
+ fallback?: string,
203
+ options: ConfigAccessorOptions = {},
204
+ ): string {
205
+ const value = config[key];
206
+ if (typeof value === 'string') return value;
207
+ if (fallback !== undefined) return fallback;
208
+ throw requiredConfigError('string', key, options.evaluator);
209
+ }
210
+
211
+ /**
212
+ * Read a string-array entry from a rule's evaluator config. A bare string is coerced to a
213
+ * single-element array. Returns the `fallback` when one is supplied and the value is absent;
214
+ * otherwise throws — so required keys fail loudly.
215
+ */
216
+ export function configArray(
217
+ config: Record<string, unknown>,
218
+ key: string,
219
+ fallback?: string[],
220
+ options: ConfigAccessorOptions = {},
221
+ ): string[] {
222
+ const value = config[key];
223
+ if (Array.isArray(value) && value.every((item) => typeof item === 'string')) return value as string[];
224
+ if (typeof value === 'string') return [value];
225
+ if (fallback !== undefined) return fallback;
226
+ throw requiredConfigError('string[]', key, options.evaluator);
227
+ }
228
+
229
+ /** Read a finite-number entry from a rule's evaluator config, falling back when absent or invalid. */
230
+ export function configNumber(config: Record<string, unknown>, key: string, fallback: number): number {
231
+ const value = config[key];
232
+ return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
233
+ }
234
+
173
235
  /**
174
236
  * Split a leading ripgrep/PCRE-style `(?flags)` inline group off a regex source.
175
237
  *
@@ -5,7 +5,7 @@ import {
5
5
  type RuleEvaluationResult,
6
6
  type RuleEvaluator,
7
7
  } from '../types';
8
- import { escapeRegExp, scanFiles, stringArray } from './file-utils';
8
+ import { configArray, escapeRegExp, scanFiles, stringArray } from './file-utils';
9
9
 
10
10
  /** A forbidden entry: either an exact import specifier or a raw source pattern. */
11
11
  type ForbiddenEntry =
@@ -49,7 +49,7 @@ export class ForbiddenImportEvaluator implements RuleEvaluator {
49
49
  context: RuleContext,
50
50
  config: Record<string, unknown>,
51
51
  ): Promise<RuleEvaluationResult> {
52
- const forbidden = arrayConfig(config, 'patterns');
52
+ const forbidden = configArray(config, 'patterns', undefined, { evaluator: 'forbidden-import' });
53
53
  const files = await scanFiles({
54
54
  workdir: context.workdir,
55
55
  include: rule.include ?? ['.ts', '.tsx', '.js', '.jsx'],
@@ -130,10 +130,3 @@ function compileEntry(entry: ForbiddenEntry): ScanEntry {
130
130
  }
131
131
  return { regex: new RegExp(entry.pattern), label: entry.pattern };
132
132
  }
133
-
134
- function arrayConfig(config: Record<string, unknown>, key: string): string[] {
135
- const value = config[key];
136
- if (Array.isArray(value) && value.every((item) => typeof item === 'string')) return value;
137
- if (typeof value === 'string') return [value];
138
- throw new Error(`forbidden-import evaluator requires string[] config "${key}"`);
139
- }
@@ -1,5 +1,4 @@
1
- import { resolve } from 'node:path';
2
- import { NodeFileSystem } from '@gobing-ai/ts-runtime';
1
+ import { joinPath, NodeFileSystem } from '@gobing-ai/ts-runtime';
3
2
  import {
4
3
  type ConstraintRule,
5
4
  createFinding,
@@ -7,7 +6,7 @@ import {
7
6
  type RuleEvaluationResult,
8
7
  type RuleEvaluator,
9
8
  } from '../types';
10
- import { discoverFiles, matchesGlob } from './file-utils';
9
+ import { configArray, configString, discoverFiles, matchesGlob } from './file-utils';
11
10
 
12
11
  /**
13
12
  * Evaluates file/directory presence constraints.
@@ -82,11 +81,11 @@ export class PathEvaluator implements RuleEvaluator {
82
81
  context: RuleContext,
83
82
  config: Record<string, unknown>,
84
83
  ): Promise<RuleEvaluationResult> {
85
- const paths = arrayConfig(config, 'paths');
86
- const mode = stringConfig(config, 'mode', 'require');
84
+ const paths = configArray(config, 'paths', undefined, { evaluator: 'path' });
85
+ const mode = configString(config, 'mode', 'require');
87
86
  const findings = [];
88
87
  for (const path of paths) {
89
- const exists = await this.fs.exists(resolve(context.workdir, path));
88
+ const exists = await this.fs.exists(joinPath(context.workdir, path));
90
89
  if (mode === 'forbid' && exists) {
91
90
  findings.push(createFinding(rule, `Forbidden path exists: ${path}`, path, { code: 'path:forbidden' }));
92
91
  }
@@ -97,15 +96,3 @@ export class PathEvaluator implements RuleEvaluator {
97
96
  return { findings, fixes: [] };
98
97
  }
99
98
  }
100
-
101
- function arrayConfig(config: Record<string, unknown>, key: string): string[] {
102
- const value = config[key];
103
- if (Array.isArray(value) && value.every((item) => typeof item === 'string')) return value;
104
- if (typeof value === 'string') return [value];
105
- throw new Error(`path evaluator requires config "${key}" (string[]) or "must" (present|absent)`);
106
- }
107
-
108
- function stringConfig(config: Record<string, unknown>, key: string, fallback: string): string {
109
- const value = config[key];
110
- return typeof value === 'string' ? value : fallback;
111
- }
@@ -5,7 +5,7 @@ import {
5
5
  type RuleEvaluationResult,
6
6
  type RuleEvaluator,
7
7
  } from '../types';
8
- import { parseInlineFlags, scanFiles } from './file-utils';
8
+ import { configString, parseInlineFlags, scanFiles } from './file-utils';
9
9
 
10
10
  /**
11
11
  * Evaluates whether source files match or avoid a regex pattern.
@@ -25,11 +25,11 @@ export class RegexEvaluator implements RuleEvaluator {
25
25
  async evaluate(rule: ConstraintRule, context: RuleContext): Promise<RuleEvaluationResult> {
26
26
  const config = rule.evaluator.config ?? {};
27
27
  const { pattern, flags } = normalizePattern(
28
- stringConfig(config, 'pattern'),
29
- stringConfig(config, 'flags', ''),
28
+ configString(config, 'pattern', undefined, { evaluator: 'regex' }),
29
+ configString(config, 'flags', ''),
30
30
  config.multiline === true,
31
31
  );
32
- const mode = stringConfig(config, 'mode', 'forbid');
32
+ const mode = configString(config, 'mode', 'forbid');
33
33
  const regex = new RegExp(pattern, flags);
34
34
  const files = await scanFiles({
35
35
  workdir: context.workdir,
@@ -103,13 +103,6 @@ function normalizePattern(
103
103
  return { pattern, flags: [...flagSet].join('') };
104
104
  }
105
105
 
106
- function stringConfig(config: Record<string, unknown>, key: string, fallback?: string): string {
107
- const value = config[key];
108
- if (typeof value === 'string') return value;
109
- if (fallback !== undefined) return fallback;
110
- throw new Error(`regex evaluator requires string config "${key}"`);
111
- }
112
-
113
106
  /** Return the one-based line containing a string offset. */
114
107
  function lineForOffset(content: string, offset: number): number {
115
108
  let line = 1;