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,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Central module registry for discovering, registering, and querying modules.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { resolve } from 'node:path';
|
|
6
|
+
import type { Config } from '../config.js';
|
|
7
|
+
import { InvalidInputError, ModuleNotFoundError } from '../errors.js';
|
|
8
|
+
import type { ModuleAnnotations, ModuleExample } from '../module.js';
|
|
9
|
+
import { resolveDependencies } from './dependencies.js';
|
|
10
|
+
import { resolveEntryPoint } from './entry-point.js';
|
|
11
|
+
import { loadIdMap, loadMetadata, mergeModuleMetadata, parseDependencies } from './metadata.js';
|
|
12
|
+
import { scanExtensions, scanMultiRoot } from './scanner.js';
|
|
13
|
+
import type { DependencyInfo, ModuleDescriptor } from './types.js';
|
|
14
|
+
import { validateModule } from './validation.js';
|
|
15
|
+
|
|
16
|
+
type EventCallback = (moduleId: string, module: unknown) => void;
|
|
17
|
+
|
|
18
|
+
export class Registry {
|
|
19
|
+
private _extensionRoots: Array<Record<string, unknown>>;
|
|
20
|
+
private _modules: Map<string, unknown> = new Map();
|
|
21
|
+
private _moduleMeta: Map<string, Record<string, unknown>> = new Map();
|
|
22
|
+
private _callbacks: Map<string, EventCallback[]> = new Map([
|
|
23
|
+
['register', []],
|
|
24
|
+
['unregister', []],
|
|
25
|
+
]);
|
|
26
|
+
private _idMap: Record<string, Record<string, unknown>> = {};
|
|
27
|
+
private _schemaCache: Map<string, Record<string, unknown>> = new Map();
|
|
28
|
+
private _config: Config | null;
|
|
29
|
+
|
|
30
|
+
constructor(options?: {
|
|
31
|
+
config?: Config | null;
|
|
32
|
+
extensionsDir?: string | null;
|
|
33
|
+
extensionsDirs?: Array<string | Record<string, unknown>> | null;
|
|
34
|
+
idMapPath?: string | null;
|
|
35
|
+
}) {
|
|
36
|
+
const config = options?.config ?? null;
|
|
37
|
+
const extensionsDir = options?.extensionsDir ?? null;
|
|
38
|
+
const extensionsDirs = options?.extensionsDirs ?? null;
|
|
39
|
+
const idMapPath = options?.idMapPath ?? null;
|
|
40
|
+
|
|
41
|
+
if (extensionsDir !== null && extensionsDirs !== null) {
|
|
42
|
+
throw new InvalidInputError('Cannot specify both extensionsDir and extensionsDirs');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (extensionsDir !== null) {
|
|
46
|
+
this._extensionRoots = [{ root: extensionsDir }];
|
|
47
|
+
} else if (extensionsDirs !== null) {
|
|
48
|
+
this._extensionRoots = extensionsDirs.map((item) =>
|
|
49
|
+
typeof item === 'string' ? { root: item } : item,
|
|
50
|
+
);
|
|
51
|
+
} else if (config !== null) {
|
|
52
|
+
const extRoot = config.get('extensions.root') as string | undefined;
|
|
53
|
+
this._extensionRoots = [{ root: extRoot ?? './extensions' }];
|
|
54
|
+
} else {
|
|
55
|
+
this._extensionRoots = [{ root: './extensions' }];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
this._config = config;
|
|
59
|
+
|
|
60
|
+
if (idMapPath !== null) {
|
|
61
|
+
this._idMap = loadIdMap(idMapPath);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async discover(): Promise<number> {
|
|
66
|
+
let maxDepth = 8;
|
|
67
|
+
let followSymlinks = false;
|
|
68
|
+
if (this._config !== null) {
|
|
69
|
+
maxDepth = (this._config.get('extensions.max_depth', 8) as number);
|
|
70
|
+
followSymlinks = (this._config.get('extensions.follow_symlinks', false) as boolean);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Step 1: Scan extension roots
|
|
74
|
+
const hasNamespace = this._extensionRoots.some((r) => 'namespace' in r);
|
|
75
|
+
let discovered;
|
|
76
|
+
if (this._extensionRoots.length > 1 || hasNamespace) {
|
|
77
|
+
discovered = scanMultiRoot(this._extensionRoots, maxDepth, followSymlinks);
|
|
78
|
+
} else {
|
|
79
|
+
const rootPath = this._extensionRoots[0]['root'] as string;
|
|
80
|
+
discovered = scanExtensions(rootPath, maxDepth, followSymlinks);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Step 2: Apply ID Map overrides
|
|
84
|
+
if (Object.keys(this._idMap).length > 0) {
|
|
85
|
+
const resolvedRoots = this._extensionRoots.map((r) => resolve(r['root'] as string));
|
|
86
|
+
for (const dm of discovered) {
|
|
87
|
+
for (const root of resolvedRoots) {
|
|
88
|
+
try {
|
|
89
|
+
const relPath = dm.filePath.startsWith(root)
|
|
90
|
+
? dm.filePath.slice(root.length + 1)
|
|
91
|
+
: null;
|
|
92
|
+
if (relPath && relPath in this._idMap) {
|
|
93
|
+
dm.canonicalId = this._idMap[relPath]['id'] as string;
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
} catch {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Step 3: Load metadata
|
|
104
|
+
const rawMetadata = new Map<string, Record<string, unknown>>();
|
|
105
|
+
for (const dm of discovered) {
|
|
106
|
+
rawMetadata.set(
|
|
107
|
+
dm.canonicalId,
|
|
108
|
+
dm.metaPath ? loadMetadata(dm.metaPath) : {},
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Step 4: Resolve entry points
|
|
113
|
+
const resolvedModules = new Map<string, unknown>();
|
|
114
|
+
for (const dm of discovered) {
|
|
115
|
+
const meta = rawMetadata.get(dm.canonicalId) ?? {};
|
|
116
|
+
try {
|
|
117
|
+
const mod = await resolveEntryPoint(dm.filePath, meta);
|
|
118
|
+
resolvedModules.set(dm.canonicalId, mod);
|
|
119
|
+
} catch {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Step 5: Validate modules
|
|
125
|
+
const validModules = new Map<string, unknown>();
|
|
126
|
+
for (const [modId, mod] of resolvedModules) {
|
|
127
|
+
const errors = validateModule(mod);
|
|
128
|
+
if (errors.length === 0) {
|
|
129
|
+
validModules.set(modId, mod);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Step 6: Collect dependencies
|
|
134
|
+
const modulesWithDeps: Array<[string, DependencyInfo[]]> = [];
|
|
135
|
+
for (const modId of validModules.keys()) {
|
|
136
|
+
const meta = rawMetadata.get(modId) ?? {};
|
|
137
|
+
const depsRaw = (meta['dependencies'] as Array<Record<string, unknown>>) ?? [];
|
|
138
|
+
const deps = depsRaw.length > 0 ? parseDependencies(depsRaw) : [];
|
|
139
|
+
modulesWithDeps.push([modId, deps]);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Step 7: Resolve dependency order
|
|
143
|
+
const knownIds = new Set(modulesWithDeps.map(([id]) => id));
|
|
144
|
+
const loadOrder = resolveDependencies(modulesWithDeps, knownIds);
|
|
145
|
+
|
|
146
|
+
// Step 8: Register in dependency order
|
|
147
|
+
let registeredCount = 0;
|
|
148
|
+
for (const modId of loadOrder) {
|
|
149
|
+
const mod = validModules.get(modId)!;
|
|
150
|
+
const meta = rawMetadata.get(modId) ?? {};
|
|
151
|
+
|
|
152
|
+
const modObj = mod as Record<string, unknown>;
|
|
153
|
+
const mergedMeta = mergeModuleMetadata(modObj, meta);
|
|
154
|
+
|
|
155
|
+
this._modules.set(modId, mod);
|
|
156
|
+
this._moduleMeta.set(modId, mergedMeta);
|
|
157
|
+
|
|
158
|
+
// Call onLoad if available
|
|
159
|
+
if (typeof modObj['onLoad'] === 'function') {
|
|
160
|
+
try {
|
|
161
|
+
(modObj['onLoad'] as () => void)();
|
|
162
|
+
} catch {
|
|
163
|
+
this._modules.delete(modId);
|
|
164
|
+
this._moduleMeta.delete(modId);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
this._triggerEvent('register', modId, mod);
|
|
170
|
+
registeredCount++;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return registeredCount;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
register(moduleId: string, module: unknown): void {
|
|
177
|
+
if (!moduleId) {
|
|
178
|
+
throw new InvalidInputError('module_id must be a non-empty string');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (this._modules.has(moduleId)) {
|
|
182
|
+
throw new InvalidInputError(`Module already exists: ${moduleId}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
this._modules.set(moduleId, module);
|
|
186
|
+
|
|
187
|
+
// Call onLoad if available
|
|
188
|
+
const modObj = module as Record<string, unknown>;
|
|
189
|
+
if (typeof modObj['onLoad'] === 'function') {
|
|
190
|
+
try {
|
|
191
|
+
(modObj['onLoad'] as () => void)();
|
|
192
|
+
} catch (e) {
|
|
193
|
+
this._modules.delete(moduleId);
|
|
194
|
+
throw e;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
this._triggerEvent('register', moduleId, module);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
unregister(moduleId: string): boolean {
|
|
202
|
+
if (!this._modules.has(moduleId)) return false;
|
|
203
|
+
|
|
204
|
+
const module = this._modules.get(moduleId)!;
|
|
205
|
+
this._modules.delete(moduleId);
|
|
206
|
+
this._moduleMeta.delete(moduleId);
|
|
207
|
+
this._schemaCache.delete(moduleId);
|
|
208
|
+
|
|
209
|
+
// Call onUnload if available
|
|
210
|
+
const modObj = module as Record<string, unknown>;
|
|
211
|
+
if (typeof modObj['onUnload'] === 'function') {
|
|
212
|
+
try {
|
|
213
|
+
(modObj['onUnload'] as () => void)();
|
|
214
|
+
} catch {
|
|
215
|
+
// Swallow
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
this._triggerEvent('unregister', moduleId, module);
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
get(moduleId: string): unknown | null {
|
|
224
|
+
if (moduleId === '') {
|
|
225
|
+
throw new ModuleNotFoundError('');
|
|
226
|
+
}
|
|
227
|
+
return this._modules.get(moduleId) ?? null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
has(moduleId: string): boolean {
|
|
231
|
+
return this._modules.has(moduleId);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
list(options?: { tags?: string[]; prefix?: string }): string[] {
|
|
235
|
+
let ids = [...this._modules.keys()];
|
|
236
|
+
|
|
237
|
+
if (options?.prefix != null) {
|
|
238
|
+
ids = ids.filter((id) => id.startsWith(options.prefix!));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (options?.tags != null) {
|
|
242
|
+
const tagSet = new Set(options.tags);
|
|
243
|
+
ids = ids.filter((id) => {
|
|
244
|
+
const mod = this._modules.get(id) as Record<string, unknown>;
|
|
245
|
+
const modTags = new Set((mod['tags'] as string[]) ?? []);
|
|
246
|
+
const metaTags = (this._moduleMeta.get(id) ?? {})['tags'];
|
|
247
|
+
if (Array.isArray(metaTags)) {
|
|
248
|
+
for (const t of metaTags) modTags.add(t as string);
|
|
249
|
+
}
|
|
250
|
+
for (const t of tagSet) {
|
|
251
|
+
if (!modTags.has(t)) return false;
|
|
252
|
+
}
|
|
253
|
+
return true;
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return ids.sort();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
iter(): IterableIterator<[string, unknown]> {
|
|
261
|
+
return this._modules.entries();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
get count(): number {
|
|
265
|
+
return this._modules.size;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
get moduleIds(): string[] {
|
|
269
|
+
return [...this._modules.keys()].sort();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
getDefinition(moduleId: string): ModuleDescriptor | null {
|
|
273
|
+
const module = this._modules.get(moduleId);
|
|
274
|
+
if (module == null) return null;
|
|
275
|
+
const meta = this._moduleMeta.get(moduleId) ?? {};
|
|
276
|
+
const mod = module as Record<string, unknown>;
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
moduleId,
|
|
280
|
+
name: ((meta['name'] as string) ?? (mod['name'] as string)) ?? null,
|
|
281
|
+
description: ((meta['description'] as string) ?? (mod['description'] as string)) ?? '',
|
|
282
|
+
documentation: ((meta['documentation'] as string) ?? (mod['documentation'] as string)) ?? null,
|
|
283
|
+
inputSchema: (mod['inputSchema'] as Record<string, unknown>) ?? {},
|
|
284
|
+
outputSchema: (mod['outputSchema'] as Record<string, unknown>) ?? {},
|
|
285
|
+
version: ((meta['version'] as string) ?? (mod['version'] as string)) ?? '1.0.0',
|
|
286
|
+
tags: (meta['tags'] as string[]) ?? (mod['tags'] as string[]) ?? [],
|
|
287
|
+
annotations: (mod['annotations'] as ModuleAnnotations) ?? null,
|
|
288
|
+
examples: (mod['examples'] as ModuleExample[]) ?? [],
|
|
289
|
+
metadata: (meta['metadata'] as Record<string, unknown>) ?? {},
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
on(event: string, callback: EventCallback): void {
|
|
294
|
+
if (!this._callbacks.has(event)) {
|
|
295
|
+
throw new InvalidInputError(`Invalid event: ${event}. Must be 'register' or 'unregister'`);
|
|
296
|
+
}
|
|
297
|
+
this._callbacks.get(event)!.push(callback);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private _triggerEvent(event: string, moduleId: string, module: unknown): void {
|
|
301
|
+
const callbacks = this._callbacks.get(event) ?? [];
|
|
302
|
+
for (const cb of callbacks) {
|
|
303
|
+
try {
|
|
304
|
+
cb(moduleId, module);
|
|
305
|
+
} catch {
|
|
306
|
+
// Swallow callback errors
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
clearCache(): void {
|
|
312
|
+
this._schemaCache.clear();
|
|
313
|
+
}
|
|
314
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Directory scanner for discovering TypeScript/JavaScript extension modules.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { readdirSync, statSync, realpathSync } from 'node:fs';
|
|
6
|
+
import { resolve, relative, join, extname, basename, sep } from 'node:path';
|
|
7
|
+
import { ConfigError, ConfigNotFoundError } from '../errors.js';
|
|
8
|
+
import type { DiscoveredModule } from './types.js';
|
|
9
|
+
|
|
10
|
+
const SKIP_DIR_NAMES = new Set(['node_modules', '__pycache__']);
|
|
11
|
+
const VALID_EXTENSIONS = new Set(['.ts', '.js']);
|
|
12
|
+
const SKIP_SUFFIXES = ['.d.ts', '.test.ts', '.test.js', '.spec.ts', '.spec.js'];
|
|
13
|
+
|
|
14
|
+
function existsAndIsDir(p: string): boolean {
|
|
15
|
+
try {
|
|
16
|
+
return statSync(p).isDirectory();
|
|
17
|
+
} catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function scanExtensions(
|
|
23
|
+
root: string,
|
|
24
|
+
maxDepth: number = 8,
|
|
25
|
+
followSymlinks: boolean = false,
|
|
26
|
+
): DiscoveredModule[] {
|
|
27
|
+
const rootResolved = resolve(root);
|
|
28
|
+
if (!existsAndIsDir(rootResolved)) {
|
|
29
|
+
throw new ConfigNotFoundError(rootResolved);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const visitedRealPaths = new Set([realpathSync(rootResolved)]);
|
|
33
|
+
const results: DiscoveredModule[] = [];
|
|
34
|
+
const seenIds = new Map<string, string>();
|
|
35
|
+
const seenIdsLower = new Map<string, string>();
|
|
36
|
+
|
|
37
|
+
function scanDir(dirPath: string, depth: number): void {
|
|
38
|
+
if (depth > maxDepth) {
|
|
39
|
+
console.warn(`[apcore:scanner] Max depth ${maxDepth} exceeded at: ${dirPath}`);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let entries: string[];
|
|
44
|
+
try {
|
|
45
|
+
entries = readdirSync(dirPath);
|
|
46
|
+
} catch {
|
|
47
|
+
console.warn(`[apcore:scanner] Cannot read directory: ${dirPath}`);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
for (const name of entries) {
|
|
52
|
+
if (name.startsWith('.') || name.startsWith('_')) continue;
|
|
53
|
+
if (SKIP_DIR_NAMES.has(name)) continue;
|
|
54
|
+
|
|
55
|
+
const entryPath = join(dirPath, name);
|
|
56
|
+
let stat;
|
|
57
|
+
try {
|
|
58
|
+
stat = statSync(entryPath);
|
|
59
|
+
} catch {
|
|
60
|
+
console.warn(`[apcore:scanner] Cannot stat entry: ${entryPath}`);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (stat.isDirectory()) {
|
|
65
|
+
if (stat.isSymbolicLink()) {
|
|
66
|
+
if (!followSymlinks) continue;
|
|
67
|
+
const real = realpathSync(entryPath);
|
|
68
|
+
if (visitedRealPaths.has(real)) continue;
|
|
69
|
+
visitedRealPaths.add(real);
|
|
70
|
+
}
|
|
71
|
+
scanDir(entryPath, depth + 1);
|
|
72
|
+
} else if (stat.isFile()) {
|
|
73
|
+
const ext = extname(name);
|
|
74
|
+
if (!VALID_EXTENSIONS.has(ext)) continue;
|
|
75
|
+
if (SKIP_SUFFIXES.some((s) => name.endsWith(s))) continue;
|
|
76
|
+
|
|
77
|
+
const rel = relative(rootResolved, entryPath);
|
|
78
|
+
const canonicalId = rel
|
|
79
|
+
.replace(new RegExp(`\\${sep}`, 'g'), '.')
|
|
80
|
+
.replace(/\.(ts|js)$/, '');
|
|
81
|
+
|
|
82
|
+
if (seenIds.has(canonicalId)) {
|
|
83
|
+
console.warn(`[apcore:scanner] Duplicate module ID '${canonicalId}', skipping: ${entryPath}`);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const lowerId = canonicalId.toLowerCase();
|
|
88
|
+
if (seenIdsLower.has(lowerId) && seenIdsLower.get(lowerId) !== canonicalId) {
|
|
89
|
+
console.warn(`[apcore:scanner] Case collision: '${canonicalId}' vs '${seenIdsLower.get(lowerId)}'`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Check for companion metadata file
|
|
93
|
+
const stem = basename(entryPath, ext);
|
|
94
|
+
const metaPath = join(dirPath, stem + '_meta.yaml');
|
|
95
|
+
let metaPathResult: string | null = null;
|
|
96
|
+
try {
|
|
97
|
+
if (statSync(metaPath).isFile()) metaPathResult = metaPath;
|
|
98
|
+
} catch {
|
|
99
|
+
// no meta file
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
seenIds.set(canonicalId, entryPath);
|
|
103
|
+
seenIdsLower.set(lowerId, canonicalId);
|
|
104
|
+
results.push({
|
|
105
|
+
filePath: entryPath,
|
|
106
|
+
canonicalId,
|
|
107
|
+
metaPath: metaPathResult,
|
|
108
|
+
namespace: null,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
scanDir(rootResolved, 1);
|
|
115
|
+
return results;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function scanMultiRoot(
|
|
119
|
+
roots: Array<Record<string, unknown>>,
|
|
120
|
+
maxDepth: number = 8,
|
|
121
|
+
followSymlinks: boolean = false,
|
|
122
|
+
): DiscoveredModule[] {
|
|
123
|
+
const allResults: DiscoveredModule[] = [];
|
|
124
|
+
const seenNamespaces = new Set<string>();
|
|
125
|
+
|
|
126
|
+
const resolved: Array<[string, string]> = [];
|
|
127
|
+
for (const entry of roots) {
|
|
128
|
+
const rootPath = entry['root'] as string;
|
|
129
|
+
const namespace = (entry['namespace'] as string) || basename(rootPath);
|
|
130
|
+
if (seenNamespaces.has(namespace)) {
|
|
131
|
+
throw new ConfigError(`Duplicate namespace: '${namespace}'`);
|
|
132
|
+
}
|
|
133
|
+
seenNamespaces.add(namespace);
|
|
134
|
+
resolved.push([rootPath, namespace]);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
for (const [rootPath, namespace] of resolved) {
|
|
138
|
+
const modules = scanExtensions(rootPath, maxDepth, followSymlinks);
|
|
139
|
+
for (const m of modules) {
|
|
140
|
+
allResults.push({
|
|
141
|
+
filePath: m.filePath,
|
|
142
|
+
canonicalId: `${namespace}.${m.canonicalId}`,
|
|
143
|
+
metaPath: m.metaPath,
|
|
144
|
+
namespace,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return allResults;
|
|
150
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema query and export functions for the registry system.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { TSchema } from '@sinclair/typebox';
|
|
6
|
+
import json from 'js-yaml';
|
|
7
|
+
import type { ModuleAnnotations, ModuleExample } from '../module.js';
|
|
8
|
+
import { ModuleNotFoundError } from '../errors.js';
|
|
9
|
+
import { SchemaExporter } from '../schema/exporter.js';
|
|
10
|
+
import { stripExtensions, toStrictSchema } from '../schema/strict.js';
|
|
11
|
+
import { ExportProfile, type SchemaDefinition } from '../schema/types.js';
|
|
12
|
+
import type { Registry } from './registry.js';
|
|
13
|
+
|
|
14
|
+
function deepCopy<T>(obj: T): T {
|
|
15
|
+
return JSON.parse(JSON.stringify(obj));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function getSchema(registry: Registry, moduleId: string): Record<string, unknown> | null {
|
|
19
|
+
const module = registry.get(moduleId);
|
|
20
|
+
if (module === null) return null;
|
|
21
|
+
|
|
22
|
+
const mod = module as Record<string, unknown>;
|
|
23
|
+
|
|
24
|
+
// TypeBox schemas are already JSON Schema
|
|
25
|
+
const inputSchemaDict = mod['inputSchema'] as Record<string, unknown> ?? {};
|
|
26
|
+
const outputSchemaDict = mod['outputSchema'] as Record<string, unknown> ?? {};
|
|
27
|
+
|
|
28
|
+
const annotations = mod['annotations'] as ModuleAnnotations | undefined;
|
|
29
|
+
let annotationsDict: Record<string, unknown> | null = null;
|
|
30
|
+
if (annotations) {
|
|
31
|
+
annotationsDict = { ...annotations };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const examplesRaw = (mod['examples'] as ModuleExample[] | undefined) ?? [];
|
|
35
|
+
const examplesList = examplesRaw.map((ex) => ({ ...ex }));
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
module_id: moduleId,
|
|
39
|
+
name: (mod['name'] as string) ?? null,
|
|
40
|
+
description: (mod['description'] as string) ?? '',
|
|
41
|
+
version: (mod['version'] as string) ?? '1.0.0',
|
|
42
|
+
tags: [...((mod['tags'] as string[]) ?? [])],
|
|
43
|
+
input_schema: inputSchemaDict,
|
|
44
|
+
output_schema: outputSchemaDict,
|
|
45
|
+
annotations: annotationsDict,
|
|
46
|
+
examples: examplesList,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function exportSchema(
|
|
51
|
+
registry: Registry,
|
|
52
|
+
moduleId: string,
|
|
53
|
+
format: string = 'json',
|
|
54
|
+
strict: boolean = false,
|
|
55
|
+
compact: boolean = false,
|
|
56
|
+
profile?: string | null,
|
|
57
|
+
): string {
|
|
58
|
+
const schemaDict = getSchema(registry, moduleId);
|
|
59
|
+
if (schemaDict === null) {
|
|
60
|
+
throw new ModuleNotFoundError(moduleId);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (profile != null) {
|
|
64
|
+
return exportWithProfile(registry, moduleId, schemaDict, profile, format);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const result = deepCopy(schemaDict);
|
|
68
|
+
|
|
69
|
+
if (strict) {
|
|
70
|
+
result['input_schema'] = toStrictSchema(result['input_schema'] as Record<string, unknown>);
|
|
71
|
+
result['output_schema'] = toStrictSchema(result['output_schema'] as Record<string, unknown>);
|
|
72
|
+
} else if (compact) {
|
|
73
|
+
applyCompact(result);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return serialize(result, format);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function getAllSchemas(registry: Registry): Record<string, Record<string, unknown>> {
|
|
80
|
+
const result: Record<string, Record<string, unknown>> = {};
|
|
81
|
+
for (const moduleId of registry.moduleIds) {
|
|
82
|
+
const schema = getSchema(registry, moduleId);
|
|
83
|
+
if (schema !== null) {
|
|
84
|
+
result[moduleId] = schema;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return result;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function exportAllSchemas(
|
|
91
|
+
registry: Registry,
|
|
92
|
+
format: string = 'json',
|
|
93
|
+
strict: boolean = false,
|
|
94
|
+
compact: boolean = false,
|
|
95
|
+
profile?: string | null,
|
|
96
|
+
): string {
|
|
97
|
+
const allSchemas = getAllSchemas(registry);
|
|
98
|
+
|
|
99
|
+
if (strict || compact) {
|
|
100
|
+
for (const [moduleId, schema] of Object.entries(allSchemas)) {
|
|
101
|
+
const result = deepCopy(schema);
|
|
102
|
+
if (strict) {
|
|
103
|
+
result['input_schema'] = toStrictSchema(result['input_schema'] as Record<string, unknown>);
|
|
104
|
+
result['output_schema'] = toStrictSchema(result['output_schema'] as Record<string, unknown>);
|
|
105
|
+
} else if (compact) {
|
|
106
|
+
applyCompact(result);
|
|
107
|
+
}
|
|
108
|
+
allSchemas[moduleId] = result;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return serialize(allSchemas, format);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function exportWithProfile(
|
|
116
|
+
registry: Registry,
|
|
117
|
+
moduleId: string,
|
|
118
|
+
schemaDict: Record<string, unknown>,
|
|
119
|
+
profile: string,
|
|
120
|
+
format: string,
|
|
121
|
+
): string {
|
|
122
|
+
const schemaDef: SchemaDefinition = {
|
|
123
|
+
moduleId,
|
|
124
|
+
description: schemaDict['description'] as string,
|
|
125
|
+
inputSchema: schemaDict['input_schema'] as Record<string, unknown>,
|
|
126
|
+
outputSchema: schemaDict['output_schema'] as Record<string, unknown>,
|
|
127
|
+
definitions: {},
|
|
128
|
+
version: (schemaDict['version'] as string) ?? '1.0.0',
|
|
129
|
+
};
|
|
130
|
+
const module = registry.get(moduleId);
|
|
131
|
+
const annotations = module ? (module as Record<string, unknown>)['annotations'] as ModuleAnnotations | undefined : undefined;
|
|
132
|
+
const examples = module ? ((module as Record<string, unknown>)['examples'] as ModuleExample[]) ?? [] : [];
|
|
133
|
+
const name = module ? (module as Record<string, unknown>)['name'] as string | undefined : undefined;
|
|
134
|
+
|
|
135
|
+
const exported = new SchemaExporter().export(
|
|
136
|
+
schemaDef,
|
|
137
|
+
profile as ExportProfile,
|
|
138
|
+
annotations,
|
|
139
|
+
examples,
|
|
140
|
+
name,
|
|
141
|
+
);
|
|
142
|
+
return serialize(exported, format);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function applyCompact(schemaDict: Record<string, unknown>): void {
|
|
146
|
+
const desc = schemaDict['description'] as string;
|
|
147
|
+
if (desc) {
|
|
148
|
+
schemaDict['description'] = truncateDescription(desc);
|
|
149
|
+
}
|
|
150
|
+
stripExtensions(schemaDict['input_schema'] as Record<string, unknown> ?? {});
|
|
151
|
+
stripExtensions(schemaDict['output_schema'] as Record<string, unknown> ?? {});
|
|
152
|
+
delete schemaDict['documentation'];
|
|
153
|
+
delete schemaDict['examples'];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function truncateDescription(description: string): string {
|
|
157
|
+
const dotSpace = description.indexOf('. ');
|
|
158
|
+
const newline = description.indexOf('\n');
|
|
159
|
+
|
|
160
|
+
const candidates: number[] = [];
|
|
161
|
+
if (dotSpace >= 0) candidates.push(dotSpace + 1);
|
|
162
|
+
if (newline >= 0) candidates.push(newline);
|
|
163
|
+
|
|
164
|
+
if (candidates.length > 0) {
|
|
165
|
+
const cut = Math.min(...candidates);
|
|
166
|
+
return description.slice(0, cut).trimEnd();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return description;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function serialize(data: unknown, format: string): string {
|
|
173
|
+
if (format === 'yaml') {
|
|
174
|
+
return json.dump(data, { flowLevel: -1 });
|
|
175
|
+
}
|
|
176
|
+
return JSON.stringify(data, null, 2);
|
|
177
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry types: ModuleDescriptor, DiscoveredModule, DependencyInfo.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ModuleAnnotations, ModuleExample } from '../module.js';
|
|
6
|
+
|
|
7
|
+
export interface ModuleDescriptor {
|
|
8
|
+
moduleId: string;
|
|
9
|
+
name: string | null;
|
|
10
|
+
description: string;
|
|
11
|
+
documentation: string | null;
|
|
12
|
+
inputSchema: Record<string, unknown>;
|
|
13
|
+
outputSchema: Record<string, unknown>;
|
|
14
|
+
version: string;
|
|
15
|
+
tags: string[];
|
|
16
|
+
annotations: ModuleAnnotations | null;
|
|
17
|
+
examples: ModuleExample[];
|
|
18
|
+
metadata: Record<string, unknown>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface DiscoveredModule {
|
|
22
|
+
filePath: string;
|
|
23
|
+
canonicalId: string;
|
|
24
|
+
metaPath: string | null;
|
|
25
|
+
namespace: string | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface DependencyInfo {
|
|
29
|
+
moduleId: string;
|
|
30
|
+
version: string | null;
|
|
31
|
+
optional: boolean;
|
|
32
|
+
}
|