@gobing-ai/ts-rule-engine 0.3.1 → 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.
- package/README.md +328 -58
- package/dist/config/extensions.d.ts +10 -7
- package/dist/config/extensions.d.ts.map +1 -1
- package/dist/config/extensions.js +48 -23
- package/dist/config/loader.d.ts +7 -0
- package/dist/config/loader.d.ts.map +1 -1
- package/dist/config/loader.js +17 -12
- package/dist/engine.d.ts +13 -2
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +107 -45
- package/dist/evaluators/agent-detection-evaluator.d.ts.map +1 -1
- package/dist/evaluators/agent-detection-evaluator.js +2 -9
- package/dist/evaluators/coverage-gate-evaluator.d.ts.map +1 -1
- package/dist/evaluators/coverage-gate-evaluator.js +4 -5
- package/dist/evaluators/exit-code-evaluator.d.ts.map +1 -1
- package/dist/evaluators/exit-code-evaluator.js +6 -23
- package/dist/evaluators/file-utils.d.ts +25 -1
- package/dist/evaluators/file-utils.d.ts.map +1 -1
- package/dist/evaluators/file-utils.js +48 -8
- package/dist/evaluators/forbidden-import-evaluator.js +2 -10
- package/dist/evaluators/path-evaluator.d.ts.map +1 -1
- package/dist/evaluators/path-evaluator.js +5 -18
- package/dist/evaluators/regex-evaluator.js +3 -11
- package/dist/evaluators/ripgrep-evaluator.d.ts +50 -0
- package/dist/evaluators/ripgrep-evaluator.d.ts.map +1 -0
- package/dist/evaluators/ripgrep-evaluator.js +145 -0
- package/dist/evaluators/schema-artifact-evaluator.d.ts.map +1 -1
- package/dist/evaluators/schema-artifact-evaluator.js +3 -7
- package/dist/evaluators/sg-evaluator.d.ts +10 -2
- package/dist/evaluators/sg-evaluator.d.ts.map +1 -1
- package/dist/evaluators/sg-evaluator.js +21 -4
- package/dist/evaluators/test-location-evaluator.d.ts +2 -2
- package/dist/evaluators/test-location-evaluator.d.ts.map +1 -1
- package/dist/evaluators/test-location-evaluator.js +2 -15
- package/dist/events.d.ts +33 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +0 -0
- package/dist/fixers/fixers.d.ts +1 -1
- package/dist/fixers/fixers.d.ts.map +1 -1
- package/dist/fixers/fixers.js +4 -5
- package/dist/fixers/test-stub-fixer.d.ts +1 -1
- package/dist/fixers/test-stub-fixer.d.ts.map +1 -1
- package/dist/fixers/test-stub-fixer.js +3 -4
- package/dist/host/builtins.d.ts.map +1 -1
- package/dist/host/builtins.js +5 -3
- package/dist/host/rule-engine-host.d.ts +1 -1
- package/dist/host/rule-engine-host.d.ts.map +1 -1
- package/dist/host/rule-engine-host.js +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -1
- package/dist/types.d.ts +2 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +6 -0
- package/package.json +4 -5
- package/src/config/extensions.ts +58 -29
- package/src/config/loader.ts +27 -12
- package/src/engine.ts +132 -47
- package/src/evaluators/agent-detection-evaluator.ts +2 -8
- package/src/evaluators/coverage-gate-evaluator.ts +4 -5
- package/src/evaluators/exit-code-evaluator.ts +6 -23
- package/src/evaluators/file-utils.ts +70 -8
- package/src/evaluators/forbidden-import-evaluator.ts +2 -9
- package/src/evaluators/path-evaluator.ts +5 -18
- package/src/evaluators/regex-evaluator.ts +4 -11
- package/src/evaluators/ripgrep-evaluator.ts +167 -0
- package/src/evaluators/schema-artifact-evaluator.ts +3 -8
- package/src/evaluators/sg-evaluator.ts +21 -4
- package/src/evaluators/test-location-evaluator.ts +3 -16
- package/src/events.ts +13 -0
- package/src/fixers/fixers.ts +12 -6
- package/src/fixers/test-stub-fixer.ts +4 -5
- package/src/host/builtins.ts +5 -3
- package/src/host/rule-engine-host.ts +1 -1
- package/src/index.ts +8 -1
- package/src/types.ts +7 -0
- package/dist/host/capability-registry.d.ts +0 -10
- package/dist/host/capability-registry.d.ts.map +0 -1
- package/dist/host/capability-registry.js +0 -9
- package/src/host/capability-registry.ts +0 -9
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
|
-
|
|
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
|
-
|
|
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{
|
|
21
|
-
|
|
25
|
+
PRESET ||--o{ EXTENSION_REF : declares
|
|
26
|
+
EXTENSION_REF }o--|| RULE_ENGINE_HOST : loads_into
|
|
22
27
|
EVALUATOR ||--o{ FINDING : emits
|
|
23
|
-
|
|
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
|
|
30
|
-
| `RuleEngineHost` |
|
|
31
|
-
| `
|
|
32
|
-
| `
|
|
33
|
-
| `
|
|
34
|
-
| `
|
|
35
|
-
| `
|
|
36
|
-
| `
|
|
37
|
-
| `
|
|
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
|
|
40
|
-
| `RuleEngineResult` | Aggregate
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
53
|
+
Bundled["bundled rules/ directory"] --> Loader["loadRuleFile() / loadPreset()"]
|
|
54
|
+
Files["project .spur/rules/ YAML/JSON"] --> Loader
|
|
53
55
|
Loader --> Rules["ConstraintRule[]"]
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
Host -->
|
|
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`:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
-
|
|
564
|
-
-
|
|
565
|
-
-
|
|
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
|
-
|
|
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
|
-
*
|
|
41
|
-
*
|
|
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
|
|
46
|
-
* a module cannot be imported or lacks a valid
|
|
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;
|
|
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 {
|
|
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:
|
|
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
|
-
*
|
|
31
|
-
*
|
|
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
|
|
36
|
-
* a module cannot be imported or lacks a valid
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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(`
|
|
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(`
|
|
86
|
+
options.logger.warn(`"${sharedRef.sourceName}" ${sharedRef.kind} extension overrides existing "${name}"`);
|
|
62
87
|
}
|
|
63
|
-
registry.register(name,
|
|
64
|
-
}
|
|
88
|
+
registry.register(name, extension, 'extension');
|
|
89
|
+
});
|
|
65
90
|
}
|
|
66
91
|
async function defaultModuleLoader(absPath) {
|
|
67
92
|
return (await import(absPath));
|