apcore-js 0.1.0
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/.claude/settings.local.json +11 -0
- package/.gitmessage +60 -0
- package/.pre-commit-config.yaml +28 -0
- package/CHANGELOG.md +47 -0
- package/CLAUDE.md +68 -0
- package/README.md +131 -0
- package/apcore-logo.svg +79 -0
- package/package.json +37 -0
- package/planning/acl-system/overview.md +54 -0
- package/planning/acl-system/plan.md +92 -0
- package/planning/acl-system/state.json +76 -0
- package/planning/acl-system/tasks/acl-core.md +226 -0
- package/planning/acl-system/tasks/acl-rule.md +92 -0
- package/planning/acl-system/tasks/conditional-rules.md +259 -0
- package/planning/acl-system/tasks/pattern-matching.md +152 -0
- package/planning/acl-system/tasks/yaml-loading.md +271 -0
- package/planning/core-executor/overview.md +53 -0
- package/planning/core-executor/plan.md +88 -0
- package/planning/core-executor/state.json +76 -0
- package/planning/core-executor/tasks/async-support.md +106 -0
- package/planning/core-executor/tasks/execution-pipeline.md +113 -0
- package/planning/core-executor/tasks/redaction.md +85 -0
- package/planning/core-executor/tasks/safety-checks.md +65 -0
- package/planning/core-executor/tasks/setup.md +75 -0
- package/planning/decorator-bindings/overview.md +62 -0
- package/planning/decorator-bindings/plan.md +104 -0
- package/planning/decorator-bindings/state.json +87 -0
- package/planning/decorator-bindings/tasks/binding-directory.md +79 -0
- package/planning/decorator-bindings/tasks/binding-loader.md +148 -0
- package/planning/decorator-bindings/tasks/explicit-schemas.md +85 -0
- package/planning/decorator-bindings/tasks/function-module.md +127 -0
- package/planning/decorator-bindings/tasks/module-factory.md +89 -0
- package/planning/decorator-bindings/tasks/schema-modes.md +142 -0
- package/planning/middleware-system/overview.md +48 -0
- package/planning/middleware-system/plan.md +102 -0
- package/planning/middleware-system/state.json +65 -0
- package/planning/middleware-system/tasks/adapters.md +170 -0
- package/planning/middleware-system/tasks/base.md +115 -0
- package/planning/middleware-system/tasks/logging-middleware.md +304 -0
- package/planning/middleware-system/tasks/manager.md +313 -0
- package/planning/observability/overview.md +53 -0
- package/planning/observability/plan.md +119 -0
- package/planning/observability/state.json +98 -0
- package/planning/observability/tasks/context-logger.md +201 -0
- package/planning/observability/tasks/exporters.md +121 -0
- package/planning/observability/tasks/metrics-collector.md +162 -0
- package/planning/observability/tasks/metrics-middleware.md +141 -0
- package/planning/observability/tasks/obs-logging-middleware.md +179 -0
- package/planning/observability/tasks/span-model.md +120 -0
- package/planning/observability/tasks/tracing-middleware.md +179 -0
- package/planning/overview.md +81 -0
- package/planning/registry-system/overview.md +57 -0
- package/planning/registry-system/plan.md +114 -0
- package/planning/registry-system/state.json +109 -0
- package/planning/registry-system/tasks/dependencies.md +157 -0
- package/planning/registry-system/tasks/entry-point.md +148 -0
- package/planning/registry-system/tasks/metadata.md +198 -0
- package/planning/registry-system/tasks/registry-core.md +323 -0
- package/planning/registry-system/tasks/scanner.md +172 -0
- package/planning/registry-system/tasks/schema-export.md +261 -0
- package/planning/registry-system/tasks/types.md +124 -0
- package/planning/registry-system/tasks/validation.md +177 -0
- package/planning/schema-system/overview.md +56 -0
- package/planning/schema-system/plan.md +121 -0
- package/planning/schema-system/state.json +98 -0
- package/planning/schema-system/tasks/exporter.md +153 -0
- package/planning/schema-system/tasks/loader.md +106 -0
- package/planning/schema-system/tasks/ref-resolver.md +133 -0
- package/planning/schema-system/tasks/strict-mode.md +140 -0
- package/planning/schema-system/tasks/typebox-generation.md +133 -0
- package/planning/schema-system/tasks/types-and-annotations.md +160 -0
- package/planning/schema-system/tasks/validator.md +149 -0
- package/src/acl.ts +188 -0
- package/src/bindings.ts +208 -0
- package/src/config.ts +24 -0
- package/src/context.ts +75 -0
- package/src/decorator.ts +110 -0
- package/src/errors.ts +369 -0
- package/src/executor.ts +348 -0
- package/src/index.ts +81 -0
- package/src/middleware/adapters.ts +54 -0
- package/src/middleware/base.ts +33 -0
- package/src/middleware/index.ts +6 -0
- package/src/middleware/logging.ts +103 -0
- package/src/middleware/manager.ts +105 -0
- package/src/module.ts +41 -0
- package/src/observability/context-logger.ts +201 -0
- package/src/observability/index.ts +4 -0
- package/src/observability/metrics.ts +212 -0
- package/src/observability/tracing.ts +187 -0
- package/src/registry/dependencies.ts +99 -0
- package/src/registry/entry-point.ts +64 -0
- package/src/registry/index.ts +8 -0
- package/src/registry/metadata.ts +111 -0
- package/src/registry/registry.ts +314 -0
- package/src/registry/scanner.ts +150 -0
- package/src/registry/schema-export.ts +177 -0
- package/src/registry/types.ts +32 -0
- package/src/registry/validation.ts +38 -0
- package/src/schema/annotations.ts +67 -0
- package/src/schema/exporter.ts +93 -0
- package/src/schema/index.ts +14 -0
- package/src/schema/loader.ts +270 -0
- package/src/schema/ref-resolver.ts +235 -0
- package/src/schema/strict.ts +128 -0
- package/src/schema/types.ts +73 -0
- package/src/schema/validator.ts +82 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/pattern.ts +30 -0
- package/tests/helpers.ts +30 -0
- package/tests/integration/test-acl-safety.test.ts +268 -0
- package/tests/integration/test-binding-executor.test.ts +194 -0
- package/tests/integration/test-e2e-flow.test.ts +117 -0
- package/tests/integration/test-error-propagation.test.ts +259 -0
- package/tests/integration/test-middleware-chain.test.ts +120 -0
- package/tests/integration/test-observability-integration.test.ts +438 -0
- package/tests/observability/test-context-logger.test.ts +123 -0
- package/tests/observability/test-metrics.test.ts +89 -0
- package/tests/observability/test-tracing.test.ts +131 -0
- package/tests/registry/test-dependencies.test.ts +70 -0
- package/tests/registry/test-entry-point.test.ts +133 -0
- package/tests/registry/test-metadata.test.ts +265 -0
- package/tests/registry/test-registry.test.ts +140 -0
- package/tests/registry/test-scanner.test.ts +257 -0
- package/tests/registry/test-schema-export.test.ts +224 -0
- package/tests/registry/test-validation.test.ts +75 -0
- package/tests/schema/test-loader.test.ts +97 -0
- package/tests/schema/test-ref-resolver.test.ts +105 -0
- package/tests/schema/test-strict.test.ts +139 -0
- package/tests/schema/test-validator.test.ts +64 -0
- package/tests/test-acl.test.ts +206 -0
- package/tests/test-bindings.test.ts +227 -0
- package/tests/test-config.test.ts +76 -0
- package/tests/test-context.test.ts +151 -0
- package/tests/test-decorator.test.ts +173 -0
- package/tests/test-errors.test.ts +204 -0
- package/tests/test-executor.test.ts +252 -0
- package/tests/test-middleware-manager.test.ts +185 -0
- package/tests/test-middleware.test.ts +86 -0
- package/tsconfig.build.json +8 -0
- package/tsconfig.json +20 -0
- package/vitest.config.ts +18 -0
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
# Task: Registry Core
|
|
2
|
+
|
|
3
|
+
## Goal
|
|
4
|
+
|
|
5
|
+
Implement the central `Registry` class that orchestrates the full 8-step async discovery pipeline and provides module query, registration, event, and cache management APIs. The constructor accepts optional configuration for extension roots and ID maps. The async `discover()` method coordinates scanning, metadata loading, entry point resolution, validation, dependency resolution, and ordered registration. Manual `register()`/`unregister()` methods support programmatic module management with lifecycle hooks (`onLoad`/`onUnload`) and event callbacks.
|
|
6
|
+
|
|
7
|
+
## Files Involved
|
|
8
|
+
|
|
9
|
+
- `src/registry/registry.ts` -- Registry class implementation
|
|
10
|
+
- `src/registry/scanner.ts` -- `scanExtensions()`, `scanMultiRoot()`
|
|
11
|
+
- `src/registry/metadata.ts` -- `loadMetadata()`, `mergeModuleMetadata()`, `loadIdMap()`, `parseDependencies()`
|
|
12
|
+
- `src/registry/dependencies.ts` -- `resolveDependencies()`
|
|
13
|
+
- `src/registry/entry-point.ts` -- `resolveEntryPoint()`
|
|
14
|
+
- `src/registry/validation.ts` -- `validateModule()`
|
|
15
|
+
- `src/registry/types.ts` -- `ModuleDescriptor`, `DependencyInfo`
|
|
16
|
+
- `src/config.ts` -- `Config` class
|
|
17
|
+
- `src/errors.ts` -- `InvalidInputError`, `ModuleNotFoundError`
|
|
18
|
+
- `tests/registry/test-registry.test.ts` -- Registry integration tests
|
|
19
|
+
|
|
20
|
+
## Steps (TDD)
|
|
21
|
+
|
|
22
|
+
### Step 1: Constructor with extension root options
|
|
23
|
+
|
|
24
|
+
```typescript
|
|
25
|
+
import { describe, it, expect } from 'vitest';
|
|
26
|
+
import { Registry } from '../../src/registry/registry.js';
|
|
27
|
+
|
|
28
|
+
describe('Registry constructor', () => {
|
|
29
|
+
it('should default to ./extensions root', () => {
|
|
30
|
+
const reg = new Registry();
|
|
31
|
+
expect(reg.count).toBe(0);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('should accept extensionsDir option', () => {
|
|
35
|
+
const reg = new Registry({ extensionsDir: '/custom/path' });
|
|
36
|
+
expect(reg.count).toBe(0);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should throw when both extensionsDir and extensionsDirs are provided', () => {
|
|
40
|
+
expect(
|
|
41
|
+
() => new Registry({ extensionsDir: '/a', extensionsDirs: ['/b'] }),
|
|
42
|
+
).toThrow(/Cannot specify both/);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Step 2: Manual register() and unregister()
|
|
48
|
+
|
|
49
|
+
```typescript
|
|
50
|
+
describe('register/unregister', () => {
|
|
51
|
+
it('should register a module and make it queryable', () => {
|
|
52
|
+
const reg = new Registry();
|
|
53
|
+
const mod = {
|
|
54
|
+
inputSchema: { type: 'object' },
|
|
55
|
+
outputSchema: { type: 'object' },
|
|
56
|
+
description: 'Test',
|
|
57
|
+
execute: async () => ({}),
|
|
58
|
+
};
|
|
59
|
+
reg.register('test.mod', mod);
|
|
60
|
+
expect(reg.has('test.mod')).toBe(true);
|
|
61
|
+
expect(reg.get('test.mod')).toBe(mod);
|
|
62
|
+
expect(reg.count).toBe(1);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should throw on duplicate registration', () => {
|
|
66
|
+
const reg = new Registry();
|
|
67
|
+
const mod = { execute: async () => ({}) };
|
|
68
|
+
reg.register('dup', mod);
|
|
69
|
+
expect(() => reg.register('dup', mod)).toThrow(/already exists/);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should throw on empty module ID', () => {
|
|
73
|
+
const reg = new Registry();
|
|
74
|
+
expect(() => reg.register('', {})).toThrow(/non-empty/);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should unregister and return true', () => {
|
|
78
|
+
const reg = new Registry();
|
|
79
|
+
reg.register('mod', {});
|
|
80
|
+
expect(reg.unregister('mod')).toBe(true);
|
|
81
|
+
expect(reg.has('mod')).toBe(false);
|
|
82
|
+
expect(reg.count).toBe(0);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should return false for unregistering non-existent module', () => {
|
|
86
|
+
const reg = new Registry();
|
|
87
|
+
expect(reg.unregister('nonexistent')).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Step 3: Lifecycle hooks (onLoad/onUnload)
|
|
93
|
+
|
|
94
|
+
```typescript
|
|
95
|
+
describe('lifecycle hooks', () => {
|
|
96
|
+
it('should call onLoad during register()', () => {
|
|
97
|
+
const reg = new Registry();
|
|
98
|
+
let loaded = false;
|
|
99
|
+
const mod = { onLoad: () => { loaded = true; } };
|
|
100
|
+
reg.register('hook.mod', mod);
|
|
101
|
+
expect(loaded).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should rollback registration if onLoad throws', () => {
|
|
105
|
+
const reg = new Registry();
|
|
106
|
+
const mod = { onLoad: () => { throw new Error('init failed'); } };
|
|
107
|
+
expect(() => reg.register('fail.mod', mod)).toThrow('init failed');
|
|
108
|
+
expect(reg.has('fail.mod')).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('should call onUnload during unregister()', () => {
|
|
112
|
+
const reg = new Registry();
|
|
113
|
+
let unloaded = false;
|
|
114
|
+
const mod = { onUnload: () => { unloaded = true; } };
|
|
115
|
+
reg.register('unload.mod', mod);
|
|
116
|
+
reg.unregister('unload.mod');
|
|
117
|
+
expect(unloaded).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('should swallow onUnload errors', () => {
|
|
121
|
+
const reg = new Registry();
|
|
122
|
+
const mod = { onUnload: () => { throw new Error('cleanup failed'); } };
|
|
123
|
+
reg.register('err.mod', mod);
|
|
124
|
+
expect(() => reg.unregister('err.mod')).not.toThrow();
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### Step 4: Query methods (get, has, list, iter, count, moduleIds)
|
|
130
|
+
|
|
131
|
+
```typescript
|
|
132
|
+
describe('query methods', () => {
|
|
133
|
+
it('should return null for non-existent module via get()', () => {
|
|
134
|
+
const reg = new Registry();
|
|
135
|
+
expect(reg.get('nope')).toBeNull();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('should throw ModuleNotFoundError for empty string via get()', () => {
|
|
139
|
+
const reg = new Registry();
|
|
140
|
+
expect(() => reg.get('')).toThrow();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('should list module IDs in sorted order', () => {
|
|
144
|
+
const reg = new Registry();
|
|
145
|
+
reg.register('z.mod', {});
|
|
146
|
+
reg.register('a.mod', {});
|
|
147
|
+
reg.register('m.mod', {});
|
|
148
|
+
expect(reg.list()).toEqual(['a.mod', 'm.mod', 'z.mod']);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('should filter list by prefix', () => {
|
|
152
|
+
const reg = new Registry();
|
|
153
|
+
reg.register('math.add', {});
|
|
154
|
+
reg.register('math.sub', {});
|
|
155
|
+
reg.register('text.upper', {});
|
|
156
|
+
expect(reg.list({ prefix: 'math.' })).toEqual(['math.add', 'math.sub']);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('should filter list by tags', () => {
|
|
160
|
+
const reg = new Registry();
|
|
161
|
+
reg.register('tagged', { tags: ['math', 'core'] });
|
|
162
|
+
reg.register('untagged', {});
|
|
163
|
+
expect(reg.list({ tags: ['math'] })).toEqual(['tagged']);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('should iterate over modules via iter()', () => {
|
|
167
|
+
const reg = new Registry();
|
|
168
|
+
reg.register('a', { value: 1 });
|
|
169
|
+
reg.register('b', { value: 2 });
|
|
170
|
+
const entries = [...reg.iter()];
|
|
171
|
+
expect(entries).toHaveLength(2);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('should return sorted moduleIds', () => {
|
|
175
|
+
const reg = new Registry();
|
|
176
|
+
reg.register('z', {});
|
|
177
|
+
reg.register('a', {});
|
|
178
|
+
expect(reg.moduleIds).toEqual(['a', 'z']);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
### Step 5: getDefinition() returns ModuleDescriptor
|
|
184
|
+
|
|
185
|
+
```typescript
|
|
186
|
+
describe('getDefinition', () => {
|
|
187
|
+
it('should return ModuleDescriptor for registered module', () => {
|
|
188
|
+
const reg = new Registry();
|
|
189
|
+
const mod = {
|
|
190
|
+
name: 'Adder',
|
|
191
|
+
description: 'Adds numbers',
|
|
192
|
+
inputSchema: { type: 'object' },
|
|
193
|
+
outputSchema: { type: 'object' },
|
|
194
|
+
version: '2.0.0',
|
|
195
|
+
tags: ['math'],
|
|
196
|
+
};
|
|
197
|
+
reg.register('math.add', mod);
|
|
198
|
+
const def = reg.getDefinition('math.add');
|
|
199
|
+
expect(def).not.toBeNull();
|
|
200
|
+
expect(def!.moduleId).toBe('math.add');
|
|
201
|
+
expect(def!.name).toBe('Adder');
|
|
202
|
+
expect(def!.description).toBe('Adds numbers');
|
|
203
|
+
expect(def!.version).toBe('2.0.0');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('should return null for non-existent module', () => {
|
|
207
|
+
const reg = new Registry();
|
|
208
|
+
expect(reg.getDefinition('nope')).toBeNull();
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
### Step 6: Event system via on()
|
|
214
|
+
|
|
215
|
+
```typescript
|
|
216
|
+
describe('event system', () => {
|
|
217
|
+
it('should fire register event callback', () => {
|
|
218
|
+
const reg = new Registry();
|
|
219
|
+
const events: string[] = [];
|
|
220
|
+
reg.on('register', (id) => events.push(id));
|
|
221
|
+
reg.register('evented', {});
|
|
222
|
+
expect(events).toEqual(['evented']);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('should fire unregister event callback', () => {
|
|
226
|
+
const reg = new Registry();
|
|
227
|
+
const events: string[] = [];
|
|
228
|
+
reg.on('unregister', (id) => events.push(id));
|
|
229
|
+
reg.register('temp', {});
|
|
230
|
+
reg.unregister('temp');
|
|
231
|
+
expect(events).toEqual(['temp']);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('should throw on invalid event name', () => {
|
|
235
|
+
const reg = new Registry();
|
|
236
|
+
expect(() => reg.on('invalid', () => {})).toThrow(/Invalid event/);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('should swallow callback errors silently', () => {
|
|
240
|
+
const reg = new Registry();
|
|
241
|
+
reg.on('register', () => { throw new Error('callback failed'); });
|
|
242
|
+
expect(() => reg.register('safe', {})).not.toThrow();
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
### Step 7: clearCache()
|
|
248
|
+
|
|
249
|
+
```typescript
|
|
250
|
+
describe('clearCache', () => {
|
|
251
|
+
it('should clear the schema cache without affecting modules', () => {
|
|
252
|
+
const reg = new Registry();
|
|
253
|
+
reg.register('cached', {});
|
|
254
|
+
reg.clearCache();
|
|
255
|
+
expect(reg.has('cached')).toBe(true);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
### Step 8: Async discover() pipeline (integration)
|
|
261
|
+
|
|
262
|
+
```typescript
|
|
263
|
+
describe('discover', () => {
|
|
264
|
+
it('should execute the 8-step pipeline and return registered count', async () => {
|
|
265
|
+
// Create temp directory with valid module files
|
|
266
|
+
// const reg = new Registry({ extensionsDir: tempDir });
|
|
267
|
+
// const count = await reg.discover();
|
|
268
|
+
// expect(count).toBeGreaterThanOrEqual(0);
|
|
269
|
+
// expect(reg.count).toBe(count);
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
The 8-step `discover()` pipeline:
|
|
275
|
+
|
|
276
|
+
```typescript
|
|
277
|
+
async discover(): Promise<number> {
|
|
278
|
+
// Step 1: Scan extension roots (scanExtensions or scanMultiRoot)
|
|
279
|
+
// Step 2: Apply ID map overrides
|
|
280
|
+
// Step 3: Load metadata (loadMetadata for each discovered module)
|
|
281
|
+
// Step 4: Resolve entry points (await resolveEntryPoint for each)
|
|
282
|
+
// Step 5: Validate modules (validateModule, drop invalid)
|
|
283
|
+
// Step 6: Collect dependencies (parseDependencies from metadata)
|
|
284
|
+
// Step 7: Resolve dependency order (resolveDependencies via Kahn's sort)
|
|
285
|
+
// Step 8: Register in dependency order (mergeModuleMetadata, onLoad, trigger events)
|
|
286
|
+
return registeredCount;
|
|
287
|
+
}
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
## Acceptance Criteria
|
|
291
|
+
|
|
292
|
+
- [x] Constructor accepts optional `config`, `extensionsDir`, `extensionsDirs`, `idMapPath`
|
|
293
|
+
- [x] `extensionsDir` and `extensionsDirs` are mutually exclusive (throws `InvalidInputError`)
|
|
294
|
+
- [x] `discover()` is async, returns `Promise<number>` of registered modules
|
|
295
|
+
- [x] `discover()` executes all 8 pipeline steps in order
|
|
296
|
+
- [x] `register()` adds modules with `onLoad` lifecycle hook
|
|
297
|
+
- [x] `register()` throws on empty ID or duplicate ID
|
|
298
|
+
- [x] `register()` rolls back on `onLoad` failure
|
|
299
|
+
- [x] `unregister()` removes module, clears metadata and schema cache, calls `onUnload`
|
|
300
|
+
- [x] `unregister()` swallows `onUnload` errors
|
|
301
|
+
- [x] `get()` returns module or null; throws `ModuleNotFoundError` for empty string
|
|
302
|
+
- [x] `has()` returns boolean for module existence
|
|
303
|
+
- [x] `list()` returns sorted IDs with optional prefix and tags filtering
|
|
304
|
+
- [x] `iter()` returns `IterableIterator<[string, unknown]>`
|
|
305
|
+
- [x] `count` getter returns module count
|
|
306
|
+
- [x] `moduleIds` getter returns sorted ID array
|
|
307
|
+
- [x] `getDefinition()` returns `ModuleDescriptor` or null
|
|
308
|
+
- [x] `on()` registers event callbacks for 'register' and 'unregister'
|
|
309
|
+
- [x] Event callbacks that throw are silently swallowed
|
|
310
|
+
- [x] `clearCache()` clears schema cache without affecting registered modules
|
|
311
|
+
- [x] All tests pass with `vitest`
|
|
312
|
+
|
|
313
|
+
## Dependencies
|
|
314
|
+
|
|
315
|
+
- `scanner` task
|
|
316
|
+
- `metadata` task
|
|
317
|
+
- `dependencies` task
|
|
318
|
+
- `entry-point` task
|
|
319
|
+
- `validation` task
|
|
320
|
+
|
|
321
|
+
## Estimated Time
|
|
322
|
+
|
|
323
|
+
5 hours
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# Task: Scanner
|
|
2
|
+
|
|
3
|
+
## Goal
|
|
4
|
+
|
|
5
|
+
Implement `scanExtensions()` and `scanMultiRoot()` functions that recursively walk extension directories, discover `.ts`/`.js` module files, build dot-notation canonical IDs from relative paths, detect companion `_meta.yaml` files, handle case collisions, and support configurable depth limits and symlink following. `scanMultiRoot()` coordinates multiple extension roots with namespace prefixing and uniqueness enforcement.
|
|
6
|
+
|
|
7
|
+
## Files Involved
|
|
8
|
+
|
|
9
|
+
- `src/registry/scanner.ts` -- Scanner implementation
|
|
10
|
+
- `src/registry/types.ts` -- `DiscoveredModule` interface
|
|
11
|
+
- `src/errors.ts` -- `ConfigError`, `ConfigNotFoundError`
|
|
12
|
+
- `tests/registry/test-scanner.test.ts` -- Scanner tests with temp directory fixtures
|
|
13
|
+
|
|
14
|
+
## Steps (TDD)
|
|
15
|
+
|
|
16
|
+
### Step 1: Implement scanExtensions() basic file discovery
|
|
17
|
+
|
|
18
|
+
Write a test that creates a temp directory with `.ts` files and verifies discovery:
|
|
19
|
+
|
|
20
|
+
```typescript
|
|
21
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
22
|
+
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
import { tmpdir } from 'node:os';
|
|
25
|
+
import { scanExtensions } from '../../src/registry/scanner.js';
|
|
26
|
+
|
|
27
|
+
describe('scanExtensions', () => {
|
|
28
|
+
let tempDir: string;
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
tempDir = mkdtempSync(join(tmpdir(), 'scan-'));
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should discover .ts and .js files', () => {
|
|
39
|
+
writeFileSync(join(tempDir, 'add.ts'), 'export default {}');
|
|
40
|
+
writeFileSync(join(tempDir, 'sub.js'), 'module.exports = {}');
|
|
41
|
+
const results = scanExtensions(tempDir);
|
|
42
|
+
const ids = results.map((r) => r.canonicalId).sort();
|
|
43
|
+
expect(ids).toEqual(['add', 'sub']);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Implement the recursive directory walker with `readdirSync`/`statSync`, filtering valid extensions.
|
|
49
|
+
|
|
50
|
+
### Step 2: Skip .d.ts, test files, dot/underscore prefixed entries
|
|
51
|
+
|
|
52
|
+
```typescript
|
|
53
|
+
it('should skip declaration and test files', () => {
|
|
54
|
+
writeFileSync(join(tempDir, 'types.d.ts'), '');
|
|
55
|
+
writeFileSync(join(tempDir, 'foo.test.ts'), '');
|
|
56
|
+
writeFileSync(join(tempDir, 'bar.spec.js'), '');
|
|
57
|
+
writeFileSync(join(tempDir, 'valid.ts'), 'export default {}');
|
|
58
|
+
const results = scanExtensions(tempDir);
|
|
59
|
+
expect(results).toHaveLength(1);
|
|
60
|
+
expect(results[0].canonicalId).toBe('valid');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('should skip dot-prefixed and underscore-prefixed entries', () => {
|
|
64
|
+
writeFileSync(join(tempDir, '.hidden.ts'), '');
|
|
65
|
+
writeFileSync(join(tempDir, '_private.ts'), '');
|
|
66
|
+
writeFileSync(join(tempDir, 'public.ts'), 'export default {}');
|
|
67
|
+
const results = scanExtensions(tempDir);
|
|
68
|
+
expect(results).toHaveLength(1);
|
|
69
|
+
});
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Add `SKIP_SUFFIXES` array and entry name filters.
|
|
73
|
+
|
|
74
|
+
### Step 3: Build canonical IDs with dot notation from nested paths
|
|
75
|
+
|
|
76
|
+
```typescript
|
|
77
|
+
it('should build dot-notation canonical IDs from nested directories', () => {
|
|
78
|
+
mkdirSync(join(tempDir, 'math'), { recursive: true });
|
|
79
|
+
writeFileSync(join(tempDir, 'math', 'add.ts'), 'export default {}');
|
|
80
|
+
const results = scanExtensions(tempDir);
|
|
81
|
+
expect(results[0].canonicalId).toBe('math.add');
|
|
82
|
+
});
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Use `relative()` and replace path separators with dots, strip extensions.
|
|
86
|
+
|
|
87
|
+
### Step 4: Detect companion _meta.yaml files
|
|
88
|
+
|
|
89
|
+
```typescript
|
|
90
|
+
it('should detect companion _meta.yaml metadata files', () => {
|
|
91
|
+
writeFileSync(join(tempDir, 'greet.ts'), 'export default {}');
|
|
92
|
+
writeFileSync(join(tempDir, 'greet_meta.yaml'), 'description: Hello');
|
|
93
|
+
const results = scanExtensions(tempDir);
|
|
94
|
+
expect(results[0].metaPath).toContain('greet_meta.yaml');
|
|
95
|
+
});
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Step 5: Respect maxDepth configuration
|
|
99
|
+
|
|
100
|
+
```typescript
|
|
101
|
+
it('should respect maxDepth limit', () => {
|
|
102
|
+
const deep = join(tempDir, 'a', 'b', 'c');
|
|
103
|
+
mkdirSync(deep, { recursive: true });
|
|
104
|
+
writeFileSync(join(deep, 'mod.ts'), 'export default {}');
|
|
105
|
+
const shallow = scanExtensions(tempDir, 2);
|
|
106
|
+
expect(shallow).toHaveLength(0);
|
|
107
|
+
const deeper = scanExtensions(tempDir, 4);
|
|
108
|
+
expect(deeper).toHaveLength(1);
|
|
109
|
+
});
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Step 6: Throw ConfigNotFoundError for missing root directory
|
|
113
|
+
|
|
114
|
+
```typescript
|
|
115
|
+
it('should throw ConfigNotFoundError for non-existent directory', () => {
|
|
116
|
+
expect(() => scanExtensions('/nonexistent/path')).toThrow();
|
|
117
|
+
});
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Step 7: Implement scanMultiRoot() with namespace prefixing
|
|
121
|
+
|
|
122
|
+
```typescript
|
|
123
|
+
import { scanMultiRoot } from '../../src/registry/scanner.js';
|
|
124
|
+
|
|
125
|
+
describe('scanMultiRoot', () => {
|
|
126
|
+
it('should prepend namespace to canonical IDs', () => {
|
|
127
|
+
const root1 = mkdtempSync(join(tmpdir(), 'root1-'));
|
|
128
|
+
writeFileSync(join(root1, 'add.ts'), 'export default {}');
|
|
129
|
+
const roots = [{ root: root1, namespace: 'math' }];
|
|
130
|
+
const results = scanMultiRoot(roots);
|
|
131
|
+
expect(results[0].canonicalId).toBe('math.add');
|
|
132
|
+
expect(results[0].namespace).toBe('math');
|
|
133
|
+
rmSync(root1, { recursive: true, force: true });
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('should throw on duplicate namespaces', () => {
|
|
137
|
+
const root1 = mkdtempSync(join(tmpdir(), 'dup1-'));
|
|
138
|
+
const root2 = mkdtempSync(join(tmpdir(), 'dup2-'));
|
|
139
|
+
const roots = [
|
|
140
|
+
{ root: root1, namespace: 'ns' },
|
|
141
|
+
{ root: root2, namespace: 'ns' },
|
|
142
|
+
];
|
|
143
|
+
expect(() => scanMultiRoot(roots)).toThrow(/Duplicate namespace/);
|
|
144
|
+
rmSync(root1, { recursive: true, force: true });
|
|
145
|
+
rmSync(root2, { recursive: true, force: true });
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Acceptance Criteria
|
|
151
|
+
|
|
152
|
+
- [x] `scanExtensions()` discovers `.ts` and `.js` files recursively
|
|
153
|
+
- [x] `.d.ts`, `.test.ts`, `.test.js`, `.spec.ts`, `.spec.js` files are skipped
|
|
154
|
+
- [x] Dot-prefixed and underscore-prefixed entries are skipped
|
|
155
|
+
- [x] `node_modules` and `__pycache__` directories are skipped
|
|
156
|
+
- [x] Canonical IDs use dot notation derived from relative paths
|
|
157
|
+
- [x] Case collisions are detected (warning-level, not blocking)
|
|
158
|
+
- [x] Companion `_meta.yaml` files are detected and recorded in `metaPath`
|
|
159
|
+
- [x] `maxDepth` parameter limits recursion depth
|
|
160
|
+
- [x] `followSymlinks` parameter controls symlink traversal (note: `statSync` bug)
|
|
161
|
+
- [x] `ConfigNotFoundError` thrown for non-existent root directories
|
|
162
|
+
- [x] `scanMultiRoot()` prepends namespace to canonical IDs
|
|
163
|
+
- [x] `scanMultiRoot()` throws `ConfigError` on duplicate namespaces
|
|
164
|
+
- [x] All tests pass with `vitest`
|
|
165
|
+
|
|
166
|
+
## Dependencies
|
|
167
|
+
|
|
168
|
+
- `types` task (DiscoveredModule interface)
|
|
169
|
+
|
|
170
|
+
## Estimated Time
|
|
171
|
+
|
|
172
|
+
3 hours
|