@gobing-ai/ts-rule-engine 0.3.0 → 0.3.2

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 (82) 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/rule-engine-host.d.ts +1 -1
  47. package/dist/host/rule-engine-host.d.ts.map +1 -1
  48. package/dist/host/rule-engine-host.js +1 -1
  49. package/dist/index.d.ts +3 -1
  50. package/dist/index.d.ts.map +1 -1
  51. package/dist/index.js +7 -1
  52. package/dist/types.d.ts +2 -0
  53. package/dist/types.d.ts.map +1 -1
  54. package/dist/types.js +20 -6
  55. package/package.json +4 -5
  56. package/rules/spur-dev.yaml +1 -1
  57. package/src/config/extensions.ts +58 -29
  58. package/src/config/loader.ts +27 -12
  59. package/src/engine.ts +132 -47
  60. package/src/evaluators/agent-detection-evaluator.ts +2 -8
  61. package/src/evaluators/coverage-gate-evaluator.ts +4 -5
  62. package/src/evaluators/exit-code-evaluator.ts +6 -23
  63. package/src/evaluators/file-utils.ts +70 -8
  64. package/src/evaluators/forbidden-import-evaluator.ts +2 -9
  65. package/src/evaluators/path-evaluator.ts +5 -18
  66. package/src/evaluators/regex-evaluator.ts +4 -11
  67. package/src/evaluators/ripgrep-evaluator.ts +167 -0
  68. package/src/evaluators/schema-artifact-evaluator.ts +3 -8
  69. package/src/evaluators/sg-evaluator.ts +21 -4
  70. package/src/evaluators/test-location-evaluator.ts +3 -16
  71. package/src/events.ts +13 -0
  72. package/src/fixers/fixers.ts +12 -6
  73. package/src/fixers/test-stub-fixer.ts +4 -5
  74. package/src/host/builtins.ts +5 -3
  75. package/src/host/rule-engine-host.ts +1 -1
  76. package/src/index.ts +8 -1
  77. package/src/types.ts +20 -6
  78. package/dist/host/capability-registry.d.ts +0 -24
  79. package/dist/host/capability-registry.d.ts.map +0 -1
  80. package/dist/host/capability-registry.js +0 -28
  81. package/src/host/capability-registry.ts +0 -41
  82. /package/rules/{typescript → quality}/tsdoc-exports.yaml +0 -0
package/README.md CHANGED
@@ -1,8 +1,12 @@
1
1
  # @gobing-ai/ts-rule-engine
2
2
 
3
- Constraint rule loading, evaluation, formatting, and fix generation for Bun/TypeScript projects.
3
+ Constraint rule loading, evaluation, formatting, and fix generation for Bun/TypeScript projects. Rules are defined as declarative YAML/JSON, evaluated through a typed evaluator host, and results are rendered or converted into candidate fixes. Preset composition, extension loading, and bundled rule categories make the engine zero-config ready out of the box.
4
4
 
5
- This package is a library. It does not ship a CLI. Downstream tools can use it to load rule presets, evaluate a workspace, format findings, collect fix candidates, and optionally apply those fixes.
5
+ ## Install
6
+
7
+ ```bash
8
+ bun add @gobing-ai/ts-rule-engine
9
+ ```
6
10
 
7
11
  ## Briefing
8
12
 
@@ -11,64 +15,152 @@ This package is a library. It does not ship a CLI. Downstream tools can use it t
11
15
  ```mermaid
12
16
  erDiagram
13
17
  RULE_ENGINE ||--|| RULE_ENGINE_HOST : uses
18
+ RULE_ENGINE_HOST ||--o{ CAPABILITY_REGISTRY : owns
19
+ CAPABILITY_REGISTRY ||--o{ EVALUATOR : registers
20
+ CAPABILITY_REGISTRY ||--o{ RESOLVER : registers
21
+ CAPABILITY_REGISTRY ||--o{ FORMATTER : registers
14
22
  RULE_ENGINE ||--o{ CONSTRAINT_RULE : evaluates
15
- RULE_ENGINE_HOST ||--o{ EVALUATOR : registers
16
- RULE_ENGINE_HOST ||--o{ RESOLVER : registers
17
- RULE_ENGINE_HOST ||--o{ FORMATTER : registers
18
- RULE_ENGINE ||--o{ FIXER : owns
23
+ RULE_ENGINE ||--o{ FIXER_PROVIDER : owns
19
24
  PRESET ||--o{ CONSTRAINT_RULE : composes
20
- PRESET ||--o{ EXTENSION : declares
21
- EXTENSION }o--|| RULE_ENGINE_HOST : loads_into
25
+ PRESET ||--o{ EXTENSION_REF : declares
26
+ EXTENSION_REF }o--|| RULE_ENGINE_HOST : loads_into
22
27
  EVALUATOR ||--o{ FINDING : emits
23
- FIXER ||--o{ FIX : emits
28
+ FIXER_PROVIDER ||--o{ FIX : emits
24
29
  FORMATTER ||--|| RESULT : renders
25
30
  ```
26
31
 
27
32
  | Entity | One-line Description |
28
33
  |--------|----------------------|
29
- | `RuleEngine` | Orchestrates the evaluation workflow for enabled rules in a target workspace. |
30
- | `RuleEngineHost` | Holds named capability registries for evaluators, resolvers, and formatters. |
31
- | `ConstraintRule` | Declarative policy unit with matching scope, severity, evaluator config, and optional fix config. |
32
- | `Preset` | YAML/JSON composition unit that collects rule categories, other presets, disabled rules, and extension refs. |
33
- | `Evaluator` | Executes one rule type and emits findings plus any evaluator-native fixes. |
34
- | `Resolver` | Maps source paths to related test paths or skeletons for rules such as `test-location`. |
35
- | `Formatter` | Renders a `RuleEngineResult` as text, JSON, or a downstream custom report format. |
36
- | `Fixer` | Produces file edits from findings when a rule opts into manual or automatic fix modes. |
37
- | `Extension` | Trusted local module declared by presets to register custom resolvers, evaluators, or formatters. |
34
+ | `RuleEngine` | Orchestrates evaluation of enabled rules against a workspace directory. Supports opt-in early exit via `stopOnFirst` parameter. Accepts an optional `EventBus<RuleEngineEvents>` for structured in-process observability. |
35
+ | `RuleEngineHost` | Capability host backed by `CapabilityRegistry` from `@gobing-ai/ts-runtime/plugin` — holds evaluators, resolvers, and formatters, each with origin tracking for safe override detection. |
36
+ | `CapabilityRegistry<T>` | Generic named registry (shared with `ts-rule-engine`, `ts-dual-workflow-engine`) that tags each entry with its `origin` (`'builtin'`, `'extension'`, `'caller'`). |
37
+ | `ConstraintRule` | Declarative policy unit: id, severity, include/exclude globs, evaluator type + config, and optional fix config. |
38
+ | `Preset` | YAML/JSON composition: extends rule categories and other presets, declares `disable`/`overrides`, and exposes extension modules. |
39
+ | `ExtensionRef` | Resolved extension: a capability kind (`resolvers` / `evaluators` / `fixers` / `formatters`), an absolute module path, and its source preset name. |
40
+ | `Evaluator` | Implements one rule type (e.g. `regex`, `coverage-gate`, `import-boundary`), emitting findings and optional evaluator-native fixes. |
41
+ | `Resolver` | Maps a source file path to an expected test path for rules such as `test-location`; supports TypeScript, Python, Go, and Rust conventions. |
42
+ | `Formatter` | Renders a `RuleEngineResult` as text (for CLI) or JSON (for automation). |
43
+ | `FixerProvider` | Produces byte-range file edits from findings and a fix config; invoked when a rule's fix mode is non-`none` and the caller's authority allows it. |
38
44
  | `Finding` | Structured policy violation or evaluator error with severity, location, and machine-readable code. |
39
- | `Fix` | Candidate byte-range or file-level edit returned separately from findings and applied only on request. |
40
- | `RuleEngineResult` | Aggregate output containing all findings and fixes for one evaluation run. |
41
-
42
- ## Install
43
-
44
- ```bash
45
- bun add @gobing-ai/ts-rule-engine
46
- ```
47
-
45
+ | `Fix` | Candidate byte-range replacement; collected separately from findings, written only on explicit `applyFixes()`. |
46
+ | `RuleEngineResult` | Aggregate `{ findings, fixes }` returned from a single evaluation run. |
47
+ | `RuleEngineEvents` | Typed event map for rule-engine observability. All events prefixed `rule.` — see [Observability](#observability). |
48
+ | `bundledRulesRoot()` | Resolves the absolute path to the bundled `rules/` directory shipped with this package — portable defaults usable as the lowest-priority preset root. |
48
49
  ## Mental Model
49
50
 
50
51
  ```mermaid
51
52
  flowchart TD
52
- Files["YAML / JSON rule files"] --> Loader["loadRuleFile() / loadPreset()"]
53
+ Bundled["bundled rules/ directory"] --> Loader["loadRuleFile() / loadPreset()"]
54
+ Files["project .spur/rules/ YAML/JSON"] --> Loader
53
55
  Loader --> Rules["ConstraintRule[]"]
54
- Rules --> Engine["RuleEngine"]
55
- Engine --> Host["RuleEngineHost registries"]
56
- Host --> Evaluators["evaluators by type"]
57
- Host --> Resolvers["test path resolvers"]
58
- Host --> Formatters["text / json formatters"]
56
+ Presets["Preset extends + extensions"] --> Extensions["loadExtensionsIntoHost()"]
57
+ Extensions --> Host["RuleEngineHost registries"]
58
+ Rules --> Engine["RuleEngine.evaluateWithFixes()"]
59
+ Host --> Engine
59
60
  Engine --> Result["{ findings, fixes }"]
60
61
  Result --> Formatter["TextFormatter / JsonFormatter"]
61
- Result --> Apply["applyFixes()"]
62
+ Result --> Apply["engine.applyFixes()"]
62
63
  ```
63
64
 
64
65
  Core concepts:
65
66
 
66
67
  - `ConstraintRule`: one policy check. It has an `id`, `severity`, evaluator type/config, optional include/exclude globs, and optional fix config.
67
- - `RuleEngine`: runs enabled rules against a `workdir`.
68
- - `RuleEngineHost`: registry container for evaluators, formatters, and test-path resolvers.
68
+ - `RuleEngine`: runs enabled rules against a `workdir`. The constructor auto-registers built-in evaluators, formatters, and resolvers.
69
+ - `RuleEngineHost`: capability container backed by three `CapabilityRegistry` instances from `@gobing-ai/ts-runtime/plugin`. Each entry tracks its origin (`'builtin'` / `'extension'` / `'caller'`) so the engine can detect and report conflicting registrations.
69
70
  - `RuleEvaluator`: implementation of one rule type, such as `regex`, `path`, or `coverage-gate`.
70
- - `Fix`: byte-range replacement candidate. Fixes are collected separately from findings and are only written when you call `applyFixes()`.
71
+ - `Fix`: byte-range replacement candidate. Fixes are collected separately from findings and only written when you call `applyFixes()`.
71
72
  - Preset: YAML/JSON file that composes rule categories and can expose extension modules.
73
+ - `bundledRulesRoot()`: resolves the path to the `rules/` directory shipped with this package. Pass it as the lowest-priority root to `loadPreset()` so project-local and user-global roots shadow individual files while inheriting the rest.
74
+
75
+ ## Execution Flow
76
+
77
+ When a rule is evaluated, the following sequence shows how the loader, host, engine, evaluator, and (optionally) the fixer pipeline cooperate.
78
+
79
+ ```mermaid
80
+ sequenceDiagram
81
+ participant Caller
82
+ participant Loader as loadPreset / loadRuleFile
83
+ participant Engine as RuleEngine
84
+ participant Host as RuleEngineHost
85
+ participant Registry as CapabilityRegistry
86
+ participant Evaluator as RuleEvaluator
87
+ participant Fixer as RuleFixerProvider
88
+ participant FS as FileSystem
89
+
90
+ Note over Caller,Loader: 1. Rule loading
91
+ Caller->>Loader: loadPreset("recommended", { roots })
92
+ Loader->>Loader: merge roots (project → user-global → bundled)
93
+ Loader->>Loader: walk categories, read YAML/JSON
94
+ Loader->>Loader: Zod-validate, normalize severities, dedupe
95
+ Loader-->>Caller: { rules: ConstraintRule[], extensions: ExtensionRef[] }
96
+
97
+ Note over Caller,Host: 2. Extension registration (optional)
98
+ Caller->>Host: loadExtensionsIntoHost(host, extensions, { allowExtensions })
99
+ Host->>Host: import each extension module
100
+ Host->>Registry: register(name, impl, "extension")
101
+ Registry-->>Host: (origin-tracked entry)
102
+
103
+ Note over Caller,FS: 3. Evaluation
104
+ Caller->>Engine: evaluateWithFixes(rules, workdir, maxFixMode, stopOnFirst?)
105
+ loop For each enabled rule (exits early when stopOnFirst threshold met)
106
+ Engine->>Host: host.evaluators.get(evaluator.type)
107
+ Host->>Registry: get(type)
108
+ Registry-->>Engine: RuleEvaluator
109
+ Engine->>Evaluator: evaluate(rule, { rule, workdir })
110
+ Evaluator->>FS: scan files, read content
111
+ FS-->>Evaluator: file content / paths
112
+ Evaluator-->>Engine: { findings: Finding[], fixes: Fix[] }
113
+
114
+ opt rule.fix mode ≠ none and findings exist
115
+ Engine->>Fixer: fixers.get(evaluatorType)
116
+ Fixer->>Fixer: resolve effective mode (min(rule, caller))
117
+ Fixer->>FS: read / write (dry-run or real)
118
+ Fixer-->>Engine: Fix[]
119
+ end
120
+ end
121
+ Engine-->>Caller: RuleEngineResult { findings, fixes }
122
+
123
+ Note over Caller,FS: 4. Output / fix application
124
+ alt Format for display
125
+ Caller->>Caller: new TextFormatter().format(result)
126
+ else Apply fixes
127
+ Caller->>Engine: applyFixes(workdir, fixes, dryRun)
128
+ Engine->>FS: write byte-range replacements
129
+ FS-->>Engine: FixApplicationResult { diff, applied, deferred }
130
+ end
131
+ ```
132
+
133
+ The key design decisions visible in this flow:
134
+
135
+ - **Rule loading is lazy**: `loadPreset()` resolves and validates rules at load time but does not evaluate. No filesystem scanning happens until `evaluate()`.
136
+ - **Evaluators are stateless plugins**: each evaluator receives `(rule, context)` and returns findings. The engine owns the loop, error boundary, and fixer dispatch.
137
+ - **Fixes are opt-in and authority-gated**: the effective fix mode is `min(rule.fix.mode, caller.maxFixMode)`. Fixes are never written during evaluation — only when the caller explicitly calls `applyFixes()`.
138
+ - **Extension loading is trust-gated**: the `allowExtensions` flag must be explicitly `true`. Without it, `loadExtensionsIntoHost()` throws, preventing untrusted code from registering capabilities.
139
+ - **Origins prevent silent override**: each `CapabilityRegistry` entry records its origin. A preset extension cannot silently replace a built-in evaluator — conflicts are surfaced.
140
+
141
+ ## Quick Start
142
+
143
+ ```ts
144
+ import { RuleEngine, TextFormatter, type ConstraintRule } from '@gobing-ai/ts-rule-engine';
145
+
146
+ const rules: ConstraintRule[] = [
147
+ {
148
+ id: 'no-console-log',
149
+ description: 'Do not commit console.log calls',
150
+ enabled: true,
151
+ severity: 'error',
152
+ include: ['src/**/*.ts'],
153
+ evaluator: {
154
+ type: 'regex',
155
+ config: {
156
+ mode: 'forbid',
157
+ pattern: 'console\\.log\\(',
158
+ },
159
+ },
160
+ },
161
+ ];
162
+ ```
163
+
72
164
 
73
165
  ## Quick Start
74
166
 
@@ -95,12 +187,13 @@ const rules: ConstraintRule[] = [
95
187
  const engine = new RuleEngine();
96
188
  const result = await engine.evaluate(rules, process.cwd());
97
189
 
190
+ // Or stop early after the first error finding:
191
+ const fastResult = await engine.evaluate(rules, process.cwd(), 'error');
192
+
98
193
  console.log(new TextFormatter().format(result));
99
- process.exitCode = result.findings.some((finding) => finding.severity === 'error') ? 1 : 0;
100
194
  ```
101
195
 
102
196
  ## Rule Files
103
-
104
197
  Rule files can be YAML, JSON, or a single rule object. File loads honor a top-level `$schema` ref by default, then validate the internal Zod schema. The `$schema` value is resolved from the bundled package schema (shipped under `node_modules/@gobing-ai/ts-rule-engine/schemas/`) — no network access. Quote the value, since YAML treats a leading `@` as reserved. Relative paths and (opt-in) remote URLs are also supported; see `@gobing-ai/ts-runtime` → *Structured config* for the full resolution rules. A multi-rule YAML file looks like this:
105
198
 
106
199
  ```yaml
@@ -143,7 +236,32 @@ const { rules, extensions } = await loadRuleFile('.rules/typescript.yaml');
143
236
 
144
237
  ## Presets
145
238
 
146
- Presets compose category folders, other presets, and rule-file subpaths across one or more roots. Preset loads also honor top-level `$schema` refs by default, resolved from the bundled package schema (no network access).
239
+ Presets compose category folders, other presets, and rule-file subpaths across one or more roots. Preset loads honor top-level `$schema` refs resolved from the bundled `schemas/` directory no network access.
240
+
241
+ ### Bundled Rules
242
+
243
+ The package ships a `rules/` directory with portable defaults. Call `bundledRulesRoot()` to get its absolute path and pass it as the lowest-priority root:
244
+
245
+ ```ts
246
+ import { loadPreset, bundledRulesRoot } from '@gobing-ai/ts-rule-engine';
247
+
248
+ const roots = ['.spur/rules', bundledRulesRoot()].filter(Boolean);
249
+ const { rules, extensions } = await loadPreset('recommended', { roots });
250
+ ```
251
+
252
+ Shipped categories:
253
+
254
+ | Preset / Category | Path | What it covers |
255
+ |-------------------|------|----------------|
256
+ | `recommended` | `rules/recommended.yaml` | Extends `typescript` + `structure` + `quality` — general-use baseline. |
257
+ | `spur-dev` | `rules/spur-dev.yaml` | Extends `typescript` + `quality` — stricter development preset, no `test-location`. |
258
+ | `typescript/` | `rules/typescript/` | TypeScript hygiene rules (e.g. no `biome-ignore` suppressions). |
259
+ | `structure/` | `rules/structure/` | Project structure rules (e.g. `test-location`). |
260
+ | `quality/` | `rules/quality/` | Quality gates (e.g. `coverage-gate`, `tsdoc-exports`). |
261
+
262
+ Use `listBundledRuleFiles()` to enumerate all shipped rule assets, useful for copying them into a writable user-global rules directory on first run.
263
+
264
+ ### Project-Local Presets
147
265
 
148
266
  Example layout:
149
267
 
@@ -248,19 +366,20 @@ rules:
248
366
  - "src/**/*.ts"
249
367
  ```
250
368
 
251
- Built-in fixer providers:
369
+ Built-in fixer providers (`RegexFixerProvider`, `PathFixerProvider`, `TestStubFixer`):
252
370
 
253
371
  | Evaluator type | Fix behavior |
254
372
  | -------------- | ------------ |
255
- | `regex`, `rg` | Replaces line matches using `fix.replacement`. |
256
- | `path`, `file-exist` | Deletes files for `must: absent` rules in `auto` mode. |
257
- | `test-location` | Creates a missing test file using the selected resolver's skeleton when available. |
373
+ | `regex`, `rg` | Replaces line matches using `fix.replacement` (`RegexFixerProvider`). |
374
+ | `path`, `file-exist` | Deletes files for `must: absent` rules in `auto` mode (`PathFixerProvider`). |
375
+ | `test-location` | Creates a missing test file using the selected resolver's skeleton (`TestStubFixer`). Never overwrites existing files. |
258
376
 
259
377
  ## Built-in Evaluators
260
378
 
261
379
  | Type | Purpose | Notes |
262
380
  | ---- | ------- | ----- |
263
- | `regex`, `rg` | Match or require text patterns in files. | Pure JS file scanning. Supports inline `(?i)` flags and `multiline`. |
381
+ | `regex` | Match or require text patterns in files (JS `RegExp` engine). | Pure JS file scanning. Supports lookbehind/backreferences, inline `(?i)` flags, and `multiline`. Not ReDoS-bounded. |
382
+ | `rg` | Match or require text patterns in files (real ripgrep engine). | Runs the `rg` CLI: ReDoS-immune, parallel, prunes heavy trees (`node_modules`, `dist`, …) during traversal. Ripgrep dialect — **no** lookbehind/backreferences. Requires the `rg` CLI. See [Rules Migration](#rules-migration). |
264
383
  | `path`, `file-exist` | Check required or forbidden paths. | Supports explicit `paths` or glob-style `must: present/absent`. |
265
384
  | `exit-code` | Run a command and evaluate its exit code. | Uses `ProcessExecutor`; inject one through `new RuleEngine({ processExecutor })` for tests. |
266
385
  | `forbidden-import` | Block forbidden imports/usages. | Useful for package boundary rules. |
@@ -385,6 +504,88 @@ rules:
385
504
  include: ["src/**/*.ts"]
386
505
  ```
387
506
 
507
+ ## Rules Migration
508
+
509
+ The `rg` evaluator now runs the **real ripgrep CLI** instead of being a JS-`RegExp` alias of `regex`. Ripgrep's Rust regex engine is ReDoS-immune, runs in parallel, and prunes heavy trees (`node_modules`, `dist`, …) during traversal — a meaningful speedup on large workspaces. The trade-off is a stricter dialect: ripgrep has **no lookbehind and no backreferences**.
510
+
511
+ This section explains how to move existing `regex` rules onto `rg` safely.
512
+
513
+ ### What changed
514
+
515
+ | | `regex` (JS `RegExp`) | `rg` (ripgrep) |
516
+ | --- | --- | --- |
517
+ | Engine | In-process `new RegExp(...)` | `rg` CLI subprocess |
518
+ | Performance | Walks files in-process | Parallel, prunes `node_modules`/`dist`/… during traversal |
519
+ | ReDoS | Not bounded — a pathological pattern can hang | Linear-time — immune |
520
+ | Lookbehind `(?<=…)` / `(?<!…)` | Supported | **Not supported** |
521
+ | Backreferences `\1`, `\k<name>` | Supported | **Not supported** |
522
+ | Inline `(?i)` flags, `multiline` | Supported | Supported (`(?i)` is native; `multiline` → `rg -U --multiline-dotall`) |
523
+ | `mode: forbid` / `mode: require` | Supported | Supported (`require` → `rg --files-without-match`) |
524
+
525
+ The rule config shape is identical (`pattern`, `mode`, `multiline`, `include`, `exclude`), so most rules migrate by changing one word: `type: regex` → `type: rg`.
526
+
527
+ ### The migration guard: `rg-evaluator-patterns-are-ripgrep-dialect`
528
+
529
+ A built-in meta rule scans your `.spur/rules/**/*.yaml` and **fails the gate** if any `type: rg` rule uses a construct ripgrep cannot compile (lookbehind or backreference). This catches an incompatible migration at lint time instead of letting it explode at scan time. A violation looks like:
530
+
531
+ ```text
532
+ INVALID: .spur/rules/typescript/my-rule.yaml: rules[0] rg pattern uses lookbehind (unsupported by ripgrep) — use type: regex
533
+ ```
534
+
535
+ The fix is exactly what the message says: keep that rule on `type: regex`. The two evaluators coexist — use `rg` for the speed/safety win where the pattern allows, and `regex` where you genuinely need lookbehind/backreferences.
536
+
537
+ ### Migration steps
538
+
539
+ 1. **Ensure `rg` is installed** in every environment that runs the gate (CI included), the same way `sg` is required. A missing `rg` makes the rule fail loud, not silently pass.
540
+
541
+ 2. **Convert eligible rules.** For each `type: regex` rule, change it to `type: rg` *unless* its `pattern` uses lookbehind or a backreference:
542
+
543
+ ```yaml
544
+ # Before — JS RegExp engine
545
+ - id: no-debugger
546
+ description: Do not commit debugger statements
547
+ evaluator:
548
+ type: regex
549
+ config:
550
+ mode: forbid
551
+ pattern: "\\bdebugger\\b"
552
+ include: ["src/**/*.ts"]
553
+
554
+ # After — real ripgrep (pattern is dialect-compatible)
555
+ - id: no-debugger
556
+ description: Do not commit debugger statements
557
+ evaluator:
558
+ type: rg
559
+ config:
560
+ mode: forbid
561
+ pattern: "\\bdebugger\\b"
562
+ include: ["src/**/*.ts"]
563
+ ```
564
+
565
+ 3. **Leave incompatible rules on `regex`.** A pattern such as `(?<=const\s)X` (lookbehind) or `(\w+)\s+\1` (backreference) stays `type: regex`. The migration guard will flag it if you convert it by mistake.
566
+
567
+ 4. **Run the gate.** `rg-evaluator-patterns-are-ripgrep-dialect` runs as part of `spur rule run` (the pre-check preset). A clean run means every `rg` rule is dialect-safe.
568
+
569
+ ```bash
570
+ spur rule run --preset recommended-pre-check --fail-on warning
571
+ ```
572
+
573
+ ### Automated conversion
574
+
575
+ `@gobing-ai/ts-rule-engine` exports `isRipgrepCompatiblePattern(pattern)` so a downstream tool can decide per rule whether to rewrite `type: regex` → `type: rg`:
576
+
577
+ ```ts
578
+ import { isRipgrepCompatiblePattern } from "@gobing-ai/ts-rule-engine";
579
+
580
+ const verdict = isRipgrepCompatiblePattern("(?<=foo)bar");
581
+ // → { compatible: false, feature: "lookbehind" } → keep this rule on `type: regex`
582
+
583
+ isRipgrepCompatiblePattern("\\bdebugger\\b");
584
+ // → { compatible: true } → safe to rewrite to `type: rg`
585
+ ```
586
+
587
+ This is the same check the migration guard uses, so a converter built on it and the spur rule never disagree.
588
+
388
589
  ## Test-Path Resolvers
389
590
 
390
591
  The `test-location` evaluator can require source files to have corresponding test files. The resolver is selected by `evaluator.config.resolver`.
@@ -501,7 +702,7 @@ const evaluator: RuleEvaluator & { name: string } = {
501
702
  export default evaluator;
502
703
  ```
503
704
 
504
- Load extensions:
705
+ Load extensions (uses the shared `loadExtensionModules` from `@gobing-ai/ts-runtime/plugin`):
505
706
 
506
707
  ```ts
507
708
  const loaded = await loadPreset('local', { roots: ['.spur/rules'] });
@@ -515,13 +716,25 @@ await loadExtensionsIntoHost(engine.host, loaded.extensions, {
515
716
 
516
717
  Supported extension kinds:
517
718
 
518
- | Kind | Registry | Required shape |
519
- | ---- | -------- | -------------- |
520
- | `resolvers` | `host.resolvers` | object with `name` and `resolveTestPath()` |
521
- | `evaluators` | `host.evaluators` | object with `name` and `evaluate()` |
522
- | `formatters` | `host.formatters` | object with `name` and `format()` |
719
+ | Kind | Host Registry | Required shape | Origin |
720
+ | ---- | ------------ | -------------- | ------ |
721
+ | `resolvers` | `host.resolvers` (`CapabilityRegistry<TestPathResolver>`) | object with `name` and `resolveTestPath()` | `'extension'` |
722
+ | `evaluators` | `host.evaluators` (`CapabilityRegistry<RuleEvaluator>`) | object with `name` and `evaluate()` | `'extension'` |
723
+ | `formatters` | `host.formatters` (`CapabilityRegistry<ResultFormatter>`) | object with `name` and `format()` | `'extension'` |
724
+
725
+ ## Traversal Control (`stopOnFirst`)
726
+
727
+ By default, the engine evaluates every enabled rule exhaustively. Pass `stopOnFirst: 'error' | 'warning' | 'info'` to break early when the severity threshold is met:
523
728
 
524
- `fixers` can be declared in preset metadata but are not loaded into `RuleEngineHost` by `loadExtensionsIntoHost()` because fixer providers live on the engine's fixer map.
729
+ ```ts
730
+ // Stop after the first error-level finding — skip remaining rules.
731
+ await engine.evaluate(rules, workdir, 'error');
732
+
733
+ // Same behavior via evaluateWithFixes.
734
+ await engine.evaluateWithFixes(rules, workdir, 'auto', 'error');
735
+ ```
736
+
737
+ The threshold compares findings against `SEVERITY_RANK` (`error: 3 > warning: 2 > info: 1`). Omitting the parameter preserves exhaustive evaluation.
525
738
 
526
739
  ## Formatting Results
527
740
 
@@ -555,13 +768,70 @@ const errors = result.findings.filter((finding) => finding.kind === 'error');
555
768
  const violations = result.findings.filter((finding) => finding.kind !== 'error');
556
769
  ```
557
770
 
771
+ ## Observability
772
+
773
+ The rule engine uses a **three-layer observability model** (ADR-015):
774
+
775
+ | Layer | Tool | Consumer |
776
+ |-------|------|----------|
777
+ | Logs | `getLogger('rule-engine')` | Human-readable debugging / file output |
778
+ | Traces | `traceAsync('rule.run')` + `addSpanEvent` per rule | Distributed perf correlation (OTel) |
779
+ | Events | `EventBus<RuleEngineEvents>` | Programmatic in-process subscription (progress bars, CI dashboards) |
780
+
781
+ All three layers are **additive** — EventBus does not replace logging or tracing. A consumer who wants a progress bar subscribes to events; a consumer who wants traces attaches an OTel collector; both work independently.
782
+
783
+ ### Event Map
784
+
785
+ `RuleEngineEvents` is a typed event map. All events are prefixed `rule.`:
786
+
787
+ | Event | Payload | When |
788
+ |-------|---------|------|
789
+ | `rule.run.start` | `{ rules, total }` | Before the first rule is evaluated |
790
+ | `rule.eval.start` | `{ ruleId, index, total }` | Before a single rule's evaluator is invoked |
791
+ | `rule.eval.done` | `{ ruleId, findings, durationMs }` | After a single rule evaluation finishes successfully |
792
+ | `rule.eval.error` | `{ ruleId, error }` | When a rule evaluator throws (distinct from a violation finding) |
793
+ | `rule.run.done` | `{ rules, findings, durationMs, stoppedEarly }` | After the last rule finishes (or short-circuited) |
794
+
795
+ ### Usage
796
+
797
+ Pass an `EventBus` to the engine constructor:
798
+
799
+ ```ts
800
+ import { RuleEngine } from '@gobing-ai/ts-rule-engine';
801
+ import { EventBus } from '@gobing-ai/ts-infra';
802
+ import type { RuleEngineEvents } from '@gobing-ai/ts-rule-engine';
803
+
804
+ const bus = new EventBus<RuleEngineEvents>();
805
+
806
+ bus.on('rule.eval.done', (data) => {
807
+ console.log(`Rule ${data.ruleId}: ${data.findings} findings (${data.durationMs}ms)`);
808
+ });
809
+
810
+ bus.on('rule.run.done', (data) => {
811
+ console.log(`Run complete: ${data.findings} total findings${data.stoppedEarly ? ' (stopped early)' : ''}`);
812
+ });
813
+
814
+ const engine = new RuleEngine({ events: bus });
815
+ const result = await engine.evaluate(rules, workdir);
816
+ ```
817
+
818
+ ### Zero-overhead default
819
+
820
+ When no `events` option is provided, the engine incurs zero observability overhead — no emit calls, no handler invocations. The event bus is purely opt-in.
821
+
822
+ ### `rule.eval.error` vs violation findings
823
+
824
+ `rule.eval.error` is emitted when an evaluator **throws** — it signals a crash, not a policy violation. The engine still produces a `kind: 'error'` finding for the thrown rule. A normal policy violation emits **no** `rule.eval.error`. Don't conflate the two: subscribe to `rule.eval.error` for crash alerting, and inspect findings for policy results.
825
+
558
826
  ## Package Boundary
559
827
 
560
- This package owns rule definitions, preset loading, evaluators, formatters, test-path resolvers, and fix application. It does not own:
828
+ This package owns rule definitions, preset loading, evaluators, formatters, test-path resolvers, fixer providers, extension loading, the `CapabilityRegistry` re-export from `@gobing-ai/ts-runtime/plugin`, and bundled rule presets.
829
+
830
+ It does **not** own:
561
831
 
562
- - CLI argument parsing
563
- - process exit policy
564
- - repository-specific rule catalogs
565
- - publishing or CI integration
832
+ - CLI argument parsing or process exit policy
833
+ - `ProcessExecutor` implementation (injected via `RuleEngineOptions`)
834
+ - Repository-specific rule catalogs (project rules live in `.spur/rules/`)
835
+ - CI integration or publishing workflows
566
836
 
567
- Those concerns should live in downstream tools that consume this library.
837
+ Downstream tools (e.g. `spur`) consume the library and add their own CLI, config discovery, and execution policy.
@@ -1,3 +1,4 @@
1
+ import type { Logger } from '@gobing-ai/ts-infra';
1
2
  import type { RuleEngineHost } from '../host/rule-engine-host';
2
3
  /** A capability kind a preset extension can contribute. */
3
4
  export type ExtensionKind = 'resolvers' | 'evaluators' | 'fixers' | 'formatters';
@@ -19,9 +20,7 @@ export interface LoadExtensionsOptions {
19
20
  */
20
21
  allowExtensions?: boolean;
21
22
  /** Optional sink for non-fatal warnings (e.g. built-in overrides). */
22
- logger?: {
23
- warn: (message: string) => void;
24
- };
23
+ logger?: Pick<Logger, 'warn'>;
25
24
  /** Optional module loader seam for tests or embedders with custom import policy. */
26
25
  moduleLoader?: (absPath: string) => Promise<Record<string, unknown>>;
27
26
  }
@@ -37,13 +36,17 @@ export declare function collectExtensions(sourceName: string, sourceDir: string,
37
36
  * Import each extension module and register its export on the matching host
38
37
  * registry.
39
38
  *
40
- * A module must default-export (or named-export `extension`) an object with a
41
- * `name: string` and the capability implementation. Loading is gated by
39
+ * Delegates generic loading (trust gate, path guard, module import, export-shape
40
+ * validation) to the shared ``loadExtensionModules`` from ts-runtime/plugin, then
41
+ * routes each capability to the correct host registry based on ``ref.kind``.
42
+ *
43
+ * A module must default-export (or named-export ``extension``) an object with a
44
+ * ``name: string`` and the capability implementation. Loading is gated by
42
45
  * {@link LoadExtensionsOptions.allowExtensions}; when refs are present but
43
46
  * loading is not allowed, this throws so the requirement is never silently dropped.
44
47
  *
45
- * @throws When extensions are present but `allowExtensions` is not true, or when
46
- * a module cannot be imported or lacks a valid `name`.
48
+ * @throws When extensions are present but ``allowExtensions`` is not true, or when
49
+ * a module cannot be imported or lacks a valid ``name``.
47
50
  */
48
51
  export declare function loadExtensionsIntoHost(host: RuleEngineHost, refs: readonly ExtensionRef[], options?: LoadExtensionsOptions): Promise<void>;
49
52
  //# sourceMappingURL=extensions.d.ts.map
@@ -1 +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;IAC7C,oFAAoF;IACpF,YAAY,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;CACxE;AASD;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAC7B,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,CAoCf"}
1
+ {"version":3,"file":"extensions.d.ts","sourceRoot":"","sources":["../../src/config/extensions.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAC;AAOlD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAC/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,IAAI,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC9B,oFAAoF;IACpF,YAAY,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;CACxE;AASD;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAC7B,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;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,sBAAsB,CACxC,IAAI,EAAE,cAAc,EACpB,IAAI,EAAE,SAAS,YAAY,EAAE,EAC7B,OAAO,GAAE,qBAA0B,GACpC,OAAO,CAAC,IAAI,CAAC,CAuDf"}
@@ -1,4 +1,6 @@
1
- import { resolve } from 'node:path';
1
+ import { fileURLToPath } from 'node:url';
2
+ import { basenamePath, dirnamePath, resolvePath, SEP } from '@gobing-ai/ts-runtime';
3
+ import { loadExtensionModules } from '@gobing-ai/ts-runtime/plugin';
2
4
  /** Host registries that can receive extension capabilities (fixers live on the engine, not the host). */
3
5
  const HOST_REGISTRY_BY_KIND = {
4
6
  resolvers: 'resolvers',
@@ -18,7 +20,7 @@ export function collectExtensions(sourceName, sourceDir, extensions) {
18
20
  const refs = [];
19
21
  for (const kind of ['resolvers', 'evaluators', 'fixers', 'formatters']) {
20
22
  for (const path of extensions[kind] ?? []) {
21
- refs.push({ kind, presetName: sourceName, absPath: resolve(sourceDir, path) });
23
+ refs.push({ kind, presetName: sourceName, absPath: resolvePath(sourceDir, path) });
22
24
  }
23
25
  }
24
26
  return refs;
@@ -27,41 +29,64 @@ export function collectExtensions(sourceName, sourceDir, extensions) {
27
29
  * Import each extension module and register its export on the matching host
28
30
  * registry.
29
31
  *
30
- * A module must default-export (or named-export `extension`) an object with a
31
- * `name: string` and the capability implementation. Loading is gated by
32
+ * Delegates generic loading (trust gate, path guard, module import, export-shape
33
+ * validation) to the shared ``loadExtensionModules`` from ts-runtime/plugin, then
34
+ * routes each capability to the correct host registry based on ``ref.kind``.
35
+ *
36
+ * A module must default-export (or named-export ``extension``) an object with a
37
+ * ``name: string`` and the capability implementation. Loading is gated by
32
38
  * {@link LoadExtensionsOptions.allowExtensions}; when refs are present but
33
39
  * loading is not allowed, this throws so the requirement is never silently dropped.
34
40
  *
35
- * @throws When extensions are present but `allowExtensions` is not true, or when
36
- * a module cannot be imported or lacks a valid `name`.
41
+ * @throws When extensions are present but ``allowExtensions`` is not true, or when
42
+ * a module cannot be imported or lacks a valid ``name``.
37
43
  */
38
44
  export async function loadExtensionsIntoHost(host, refs, options = {}) {
39
45
  if (refs.length === 0)
40
46
  return;
41
- if (options.allowExtensions !== true) {
42
- const first = refs[0];
43
- throw new Error(`preset "${first.presetName}" declares ${first.kind} extension "${first.absPath}", but extensions are disabled — pass allowExtensions: true to load preset extension modules`);
44
- }
45
- const loadModule = options.moduleLoader ?? defaultModuleLoader;
47
+ // Normalize file:// URLs to file paths so dirname/basename produce valid
48
+ // path components that isAbsolute() accepts (e.g. import.meta.url in tests).
49
+ const toFilePath = (p) => (p.startsWith('file://') ? fileURLToPath(p) : p);
50
+ // Defense-in-depth: pre-validate `..` traversal in caller-supplied absPath
51
+ // before the basename adaptation strips it. The shared loader's
52
+ // assertRelativeExtensionPath also runs on the derived (basename) path.
46
53
  for (const ref of refs) {
47
- const moduleExports = await loadModule(ref.absPath);
48
- const candidate = moduleExports.default ?? moduleExports.extension;
49
- if (candidate === null ||
50
- typeof candidate !== 'object' ||
51
- typeof candidate.name !== 'string') {
52
- throw new Error(`preset "${ref.presetName}" extension "${ref.absPath}" must export an object with a string "name"`);
54
+ const segments = toFilePath(ref.absPath).split(SEP);
55
+ if (segments.includes('..')) {
56
+ throw new Error(`extension path "${ref.absPath}" declared by "${ref.presetName}" must not contain ".." traversal`);
53
57
  }
54
- const name = candidate.name;
55
- const registryKey = HOST_REGISTRY_BY_KIND[ref.kind];
58
+ }
59
+ // Adapt rule-engine ExtensionRef → shared ExtensionRef so the generic loader
60
+ // governs every import. The shared loader resolves (baseDir, path) →
61
+ // absPath internally; we supply dirname/basename so the resolved path
62
+ // reconstructs the caller's original absPath.
63
+ const sharedRefs = refs.map((ref) => {
64
+ const fp = toFilePath(ref.absPath);
65
+ return {
66
+ kind: ref.kind,
67
+ path: `./${basenamePath(fp)}`,
68
+ baseDir: dirnamePath(fp),
69
+ sourceName: ref.presetName,
70
+ };
71
+ });
72
+ const moduleLoader = options.moduleLoader ?? defaultModuleLoader;
73
+ const sharedOptions = {
74
+ allowExtensions: options.allowExtensions,
75
+ logger: options.logger,
76
+ moduleLoader,
77
+ };
78
+ await loadExtensionModules(sharedRefs, sharedOptions, async (sharedRef, extension) => {
79
+ const name = extension.name;
80
+ const registryKey = HOST_REGISTRY_BY_KIND[sharedRef.kind];
56
81
  if (registryKey === undefined) {
57
- throw new Error(`preset "${ref.presetName}" ${ref.kind} extensions are not supported`);
82
+ throw new Error(`"${sharedRef.sourceName}" ${sharedRef.kind} extensions are not supported`);
58
83
  }
59
84
  const registry = host[registryKey];
60
85
  if (options.logger && registry.has?.(name)) {
61
- options.logger.warn(`preset "${ref.presetName}" ${ref.kind} extension overrides existing "${name}"`);
86
+ options.logger.warn(`"${sharedRef.sourceName}" ${sharedRef.kind} extension overrides existing "${name}"`);
62
87
  }
63
- registry.register(name, candidate, 'extension');
64
- }
88
+ registry.register(name, extension, 'extension');
89
+ });
65
90
  }
66
91
  async function defaultModuleLoader(absPath) {
67
92
  return (await import(absPath));