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.
Files changed (142) hide show
  1. package/.claude/settings.local.json +11 -0
  2. package/.gitmessage +60 -0
  3. package/.pre-commit-config.yaml +28 -0
  4. package/CHANGELOG.md +47 -0
  5. package/CLAUDE.md +68 -0
  6. package/README.md +131 -0
  7. package/apcore-logo.svg +79 -0
  8. package/package.json +37 -0
  9. package/planning/acl-system/overview.md +54 -0
  10. package/planning/acl-system/plan.md +92 -0
  11. package/planning/acl-system/state.json +76 -0
  12. package/planning/acl-system/tasks/acl-core.md +226 -0
  13. package/planning/acl-system/tasks/acl-rule.md +92 -0
  14. package/planning/acl-system/tasks/conditional-rules.md +259 -0
  15. package/planning/acl-system/tasks/pattern-matching.md +152 -0
  16. package/planning/acl-system/tasks/yaml-loading.md +271 -0
  17. package/planning/core-executor/overview.md +53 -0
  18. package/planning/core-executor/plan.md +88 -0
  19. package/planning/core-executor/state.json +76 -0
  20. package/planning/core-executor/tasks/async-support.md +106 -0
  21. package/planning/core-executor/tasks/execution-pipeline.md +113 -0
  22. package/planning/core-executor/tasks/redaction.md +85 -0
  23. package/planning/core-executor/tasks/safety-checks.md +65 -0
  24. package/planning/core-executor/tasks/setup.md +75 -0
  25. package/planning/decorator-bindings/overview.md +62 -0
  26. package/planning/decorator-bindings/plan.md +104 -0
  27. package/planning/decorator-bindings/state.json +87 -0
  28. package/planning/decorator-bindings/tasks/binding-directory.md +79 -0
  29. package/planning/decorator-bindings/tasks/binding-loader.md +148 -0
  30. package/planning/decorator-bindings/tasks/explicit-schemas.md +85 -0
  31. package/planning/decorator-bindings/tasks/function-module.md +127 -0
  32. package/planning/decorator-bindings/tasks/module-factory.md +89 -0
  33. package/planning/decorator-bindings/tasks/schema-modes.md +142 -0
  34. package/planning/middleware-system/overview.md +48 -0
  35. package/planning/middleware-system/plan.md +102 -0
  36. package/planning/middleware-system/state.json +65 -0
  37. package/planning/middleware-system/tasks/adapters.md +170 -0
  38. package/planning/middleware-system/tasks/base.md +115 -0
  39. package/planning/middleware-system/tasks/logging-middleware.md +304 -0
  40. package/planning/middleware-system/tasks/manager.md +313 -0
  41. package/planning/observability/overview.md +53 -0
  42. package/planning/observability/plan.md +119 -0
  43. package/planning/observability/state.json +98 -0
  44. package/planning/observability/tasks/context-logger.md +201 -0
  45. package/planning/observability/tasks/exporters.md +121 -0
  46. package/planning/observability/tasks/metrics-collector.md +162 -0
  47. package/planning/observability/tasks/metrics-middleware.md +141 -0
  48. package/planning/observability/tasks/obs-logging-middleware.md +179 -0
  49. package/planning/observability/tasks/span-model.md +120 -0
  50. package/planning/observability/tasks/tracing-middleware.md +179 -0
  51. package/planning/overview.md +81 -0
  52. package/planning/registry-system/overview.md +57 -0
  53. package/planning/registry-system/plan.md +114 -0
  54. package/planning/registry-system/state.json +109 -0
  55. package/planning/registry-system/tasks/dependencies.md +157 -0
  56. package/planning/registry-system/tasks/entry-point.md +148 -0
  57. package/planning/registry-system/tasks/metadata.md +198 -0
  58. package/planning/registry-system/tasks/registry-core.md +323 -0
  59. package/planning/registry-system/tasks/scanner.md +172 -0
  60. package/planning/registry-system/tasks/schema-export.md +261 -0
  61. package/planning/registry-system/tasks/types.md +124 -0
  62. package/planning/registry-system/tasks/validation.md +177 -0
  63. package/planning/schema-system/overview.md +56 -0
  64. package/planning/schema-system/plan.md +121 -0
  65. package/planning/schema-system/state.json +98 -0
  66. package/planning/schema-system/tasks/exporter.md +153 -0
  67. package/planning/schema-system/tasks/loader.md +106 -0
  68. package/planning/schema-system/tasks/ref-resolver.md +133 -0
  69. package/planning/schema-system/tasks/strict-mode.md +140 -0
  70. package/planning/schema-system/tasks/typebox-generation.md +133 -0
  71. package/planning/schema-system/tasks/types-and-annotations.md +160 -0
  72. package/planning/schema-system/tasks/validator.md +149 -0
  73. package/src/acl.ts +188 -0
  74. package/src/bindings.ts +208 -0
  75. package/src/config.ts +24 -0
  76. package/src/context.ts +75 -0
  77. package/src/decorator.ts +110 -0
  78. package/src/errors.ts +369 -0
  79. package/src/executor.ts +348 -0
  80. package/src/index.ts +81 -0
  81. package/src/middleware/adapters.ts +54 -0
  82. package/src/middleware/base.ts +33 -0
  83. package/src/middleware/index.ts +6 -0
  84. package/src/middleware/logging.ts +103 -0
  85. package/src/middleware/manager.ts +105 -0
  86. package/src/module.ts +41 -0
  87. package/src/observability/context-logger.ts +201 -0
  88. package/src/observability/index.ts +4 -0
  89. package/src/observability/metrics.ts +212 -0
  90. package/src/observability/tracing.ts +187 -0
  91. package/src/registry/dependencies.ts +99 -0
  92. package/src/registry/entry-point.ts +64 -0
  93. package/src/registry/index.ts +8 -0
  94. package/src/registry/metadata.ts +111 -0
  95. package/src/registry/registry.ts +314 -0
  96. package/src/registry/scanner.ts +150 -0
  97. package/src/registry/schema-export.ts +177 -0
  98. package/src/registry/types.ts +32 -0
  99. package/src/registry/validation.ts +38 -0
  100. package/src/schema/annotations.ts +67 -0
  101. package/src/schema/exporter.ts +93 -0
  102. package/src/schema/index.ts +14 -0
  103. package/src/schema/loader.ts +270 -0
  104. package/src/schema/ref-resolver.ts +235 -0
  105. package/src/schema/strict.ts +128 -0
  106. package/src/schema/types.ts +73 -0
  107. package/src/schema/validator.ts +82 -0
  108. package/src/utils/index.ts +1 -0
  109. package/src/utils/pattern.ts +30 -0
  110. package/tests/helpers.ts +30 -0
  111. package/tests/integration/test-acl-safety.test.ts +268 -0
  112. package/tests/integration/test-binding-executor.test.ts +194 -0
  113. package/tests/integration/test-e2e-flow.test.ts +117 -0
  114. package/tests/integration/test-error-propagation.test.ts +259 -0
  115. package/tests/integration/test-middleware-chain.test.ts +120 -0
  116. package/tests/integration/test-observability-integration.test.ts +438 -0
  117. package/tests/observability/test-context-logger.test.ts +123 -0
  118. package/tests/observability/test-metrics.test.ts +89 -0
  119. package/tests/observability/test-tracing.test.ts +131 -0
  120. package/tests/registry/test-dependencies.test.ts +70 -0
  121. package/tests/registry/test-entry-point.test.ts +133 -0
  122. package/tests/registry/test-metadata.test.ts +265 -0
  123. package/tests/registry/test-registry.test.ts +140 -0
  124. package/tests/registry/test-scanner.test.ts +257 -0
  125. package/tests/registry/test-schema-export.test.ts +224 -0
  126. package/tests/registry/test-validation.test.ts +75 -0
  127. package/tests/schema/test-loader.test.ts +97 -0
  128. package/tests/schema/test-ref-resolver.test.ts +105 -0
  129. package/tests/schema/test-strict.test.ts +139 -0
  130. package/tests/schema/test-validator.test.ts +64 -0
  131. package/tests/test-acl.test.ts +206 -0
  132. package/tests/test-bindings.test.ts +227 -0
  133. package/tests/test-config.test.ts +76 -0
  134. package/tests/test-context.test.ts +151 -0
  135. package/tests/test-decorator.test.ts +173 -0
  136. package/tests/test-errors.test.ts +204 -0
  137. package/tests/test-executor.test.ts +252 -0
  138. package/tests/test-middleware-manager.test.ts +185 -0
  139. package/tests/test-middleware.test.ts +86 -0
  140. package/tsconfig.build.json +8 -0
  141. package/tsconfig.json +20 -0
  142. 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