apcore-js 0.1.1 → 0.2.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 +2 -1
- package/.pre-commit-config.yaml +2 -2
- package/CHANGELOG.md +63 -0
- package/package.json +1 -1
- package/planning/overview.md +1 -1
- package/src/acl.ts +37 -38
- package/src/bindings.ts +11 -18
- package/src/context.ts +1 -1
- package/src/errors.ts +57 -2
- package/src/executor.ts +74 -49
- package/src/index.ts +6 -2
- package/src/middleware/logging.ts +1 -1
- package/src/middleware/manager.ts +2 -2
- package/src/observability/tracing.ts +1 -1
- package/src/registry/registry.ts +103 -61
- package/src/registry/schema-export.ts +1 -4
- package/src/schema/exporter.ts +1 -4
- package/src/schema/loader.ts +45 -56
- package/src/schema/ref-resolver.ts +1 -4
- package/src/schema/strict.ts +1 -3
- package/src/utils/index.ts +4 -0
- package/tests/integration/test-e2e-flow.test.ts +4 -4
- package/tests/schema/test-annotations.test.ts +135 -0
- package/tests/schema/test-exporter.test.ts +171 -0
- package/tests/test-executor.test.ts +3 -3
- package/tests/test-logging-middleware.test.ts +150 -0
package/src/registry/registry.ts
CHANGED
|
@@ -13,6 +13,20 @@ import { scanExtensions, scanMultiRoot } from './scanner.js';
|
|
|
13
13
|
import type { DependencyInfo, ModuleDescriptor } from './types.js';
|
|
14
14
|
import { validateModule } from './validation.js';
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Standard registry event names.
|
|
18
|
+
*/
|
|
19
|
+
export const REGISTRY_EVENTS = Object.freeze({
|
|
20
|
+
REGISTER: "register",
|
|
21
|
+
UNREGISTER: "unregister",
|
|
22
|
+
} as const);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Valid module ID pattern. Only lowercase letters, digits, underscores, and dots.
|
|
26
|
+
* Hyphens are prohibited to ensure bijective MCP/OpenAI tool name normalization.
|
|
27
|
+
*/
|
|
28
|
+
export const MODULE_ID_PATTERN = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$/;
|
|
29
|
+
|
|
16
30
|
type EventCallback = (moduleId: string, module: unknown) => void;
|
|
17
31
|
|
|
18
32
|
export class Registry {
|
|
@@ -20,8 +34,8 @@ export class Registry {
|
|
|
20
34
|
private _modules: Map<string, unknown> = new Map();
|
|
21
35
|
private _moduleMeta: Map<string, Record<string, unknown>> = new Map();
|
|
22
36
|
private _callbacks: Map<string, EventCallback[]> = new Map([
|
|
23
|
-
[
|
|
24
|
-
[
|
|
37
|
+
[REGISTRY_EVENTS.REGISTER, []],
|
|
38
|
+
[REGISTRY_EVENTS.UNREGISTER, []],
|
|
25
39
|
]);
|
|
26
40
|
private _idMap: Record<string, Record<string, unknown>> = {};
|
|
27
41
|
private _schemaCache: Map<string, Record<string, unknown>> = new Map();
|
|
@@ -63,6 +77,18 @@ export class Registry {
|
|
|
63
77
|
}
|
|
64
78
|
|
|
65
79
|
async discover(): Promise<number> {
|
|
80
|
+
const discovered = this._scanRoots();
|
|
81
|
+
this._applyIdMapOverrides(discovered);
|
|
82
|
+
|
|
83
|
+
const rawMetadata = this._loadAllMetadata(discovered);
|
|
84
|
+
const resolvedModules = await this._resolveAllEntryPoints(discovered, rawMetadata);
|
|
85
|
+
const validModules = this._validateAll(resolvedModules);
|
|
86
|
+
const loadOrder = this._resolveLoadOrder(validModules, rawMetadata);
|
|
87
|
+
|
|
88
|
+
return this._registerInOrder(loadOrder, validModules, rawMetadata);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private _scanRoots(): import('./types.js').DiscoveredModule[] {
|
|
66
92
|
let maxDepth = 8;
|
|
67
93
|
let followSymlinks = false;
|
|
68
94
|
if (this._config !== null) {
|
|
@@ -70,112 +96,125 @@ export class Registry {
|
|
|
70
96
|
followSymlinks = (this._config.get('extensions.follow_symlinks', false) as boolean);
|
|
71
97
|
}
|
|
72
98
|
|
|
73
|
-
// Step 1: Scan extension roots
|
|
74
99
|
const hasNamespace = this._extensionRoots.some((r) => 'namespace' in r);
|
|
75
|
-
let discovered;
|
|
76
100
|
if (this._extensionRoots.length > 1 || hasNamespace) {
|
|
77
|
-
|
|
78
|
-
} else {
|
|
79
|
-
const rootPath = this._extensionRoots[0]['root'] as string;
|
|
80
|
-
discovered = scanExtensions(rootPath, maxDepth, followSymlinks);
|
|
101
|
+
return scanMultiRoot(this._extensionRoots, maxDepth, followSymlinks);
|
|
81
102
|
}
|
|
103
|
+
return scanExtensions(this._extensionRoots[0]['root'] as string, maxDepth, followSymlinks);
|
|
104
|
+
}
|
|
82
105
|
|
|
83
|
-
|
|
84
|
-
if (Object.keys(this._idMap).length
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
} catch {
|
|
97
|
-
continue;
|
|
106
|
+
private _applyIdMapOverrides(discovered: import('./types.js').DiscoveredModule[]): void {
|
|
107
|
+
if (Object.keys(this._idMap).length === 0) return;
|
|
108
|
+
|
|
109
|
+
const resolvedRoots = this._extensionRoots.map((r) => resolve(r['root'] as string));
|
|
110
|
+
for (const dm of discovered) {
|
|
111
|
+
for (const root of resolvedRoots) {
|
|
112
|
+
try {
|
|
113
|
+
const relPath = dm.filePath.startsWith(root)
|
|
114
|
+
? dm.filePath.slice(root.length + 1)
|
|
115
|
+
: null;
|
|
116
|
+
if (relPath && relPath in this._idMap) {
|
|
117
|
+
dm.canonicalId = this._idMap[relPath]['id'] as string;
|
|
118
|
+
break;
|
|
98
119
|
}
|
|
120
|
+
} catch (e) {
|
|
121
|
+
console.warn(`[apcore:registry] Failed to apply ID map for ${dm.canonicalId}:`, e);
|
|
122
|
+
continue;
|
|
99
123
|
}
|
|
100
124
|
}
|
|
101
125
|
}
|
|
126
|
+
}
|
|
102
127
|
|
|
103
|
-
|
|
128
|
+
private _loadAllMetadata(
|
|
129
|
+
discovered: import('./types.js').DiscoveredModule[],
|
|
130
|
+
): Map<string, Record<string, unknown>> {
|
|
104
131
|
const rawMetadata = new Map<string, Record<string, unknown>>();
|
|
105
132
|
for (const dm of discovered) {
|
|
106
|
-
rawMetadata.set(
|
|
107
|
-
dm.canonicalId,
|
|
108
|
-
dm.metaPath ? loadMetadata(dm.metaPath) : {},
|
|
109
|
-
);
|
|
133
|
+
rawMetadata.set(dm.canonicalId, dm.metaPath ? loadMetadata(dm.metaPath) : {});
|
|
110
134
|
}
|
|
135
|
+
return rawMetadata;
|
|
136
|
+
}
|
|
111
137
|
|
|
112
|
-
|
|
138
|
+
private async _resolveAllEntryPoints(
|
|
139
|
+
discovered: import('./types.js').DiscoveredModule[],
|
|
140
|
+
rawMetadata: Map<string, Record<string, unknown>>,
|
|
141
|
+
): Promise<Map<string, unknown>> {
|
|
113
142
|
const resolvedModules = new Map<string, unknown>();
|
|
114
143
|
for (const dm of discovered) {
|
|
115
144
|
const meta = rawMetadata.get(dm.canonicalId) ?? {};
|
|
116
145
|
try {
|
|
117
146
|
const mod = await resolveEntryPoint(dm.filePath, meta);
|
|
118
147
|
resolvedModules.set(dm.canonicalId, mod);
|
|
119
|
-
} catch {
|
|
120
|
-
|
|
148
|
+
} catch (e) {
|
|
149
|
+
console.warn(`[apcore:registry] Failed to resolve entry point for ${dm.canonicalId}:`, e);
|
|
121
150
|
}
|
|
122
151
|
}
|
|
152
|
+
return resolvedModules;
|
|
153
|
+
}
|
|
123
154
|
|
|
124
|
-
|
|
155
|
+
private _validateAll(resolvedModules: Map<string, unknown>): Map<string, unknown> {
|
|
125
156
|
const validModules = new Map<string, unknown>();
|
|
126
157
|
for (const [modId, mod] of resolvedModules) {
|
|
127
|
-
|
|
128
|
-
if (errors.length === 0) {
|
|
158
|
+
if (validateModule(mod).length === 0) {
|
|
129
159
|
validModules.set(modId, mod);
|
|
130
160
|
}
|
|
131
161
|
}
|
|
162
|
+
return validModules;
|
|
163
|
+
}
|
|
132
164
|
|
|
133
|
-
|
|
165
|
+
private _resolveLoadOrder(
|
|
166
|
+
validModules: Map<string, unknown>,
|
|
167
|
+
rawMetadata: Map<string, Record<string, unknown>>,
|
|
168
|
+
): string[] {
|
|
134
169
|
const modulesWithDeps: Array<[string, DependencyInfo[]]> = [];
|
|
135
170
|
for (const modId of validModules.keys()) {
|
|
136
171
|
const meta = rawMetadata.get(modId) ?? {};
|
|
137
172
|
const depsRaw = (meta['dependencies'] as Array<Record<string, unknown>>) ?? [];
|
|
138
|
-
|
|
139
|
-
modulesWithDeps.push([modId, deps]);
|
|
173
|
+
modulesWithDeps.push([modId, depsRaw.length > 0 ? parseDependencies(depsRaw) : []]);
|
|
140
174
|
}
|
|
141
|
-
|
|
142
|
-
// Step 7: Resolve dependency order
|
|
143
175
|
const knownIds = new Set(modulesWithDeps.map(([id]) => id));
|
|
144
|
-
|
|
176
|
+
return resolveDependencies(modulesWithDeps, knownIds);
|
|
177
|
+
}
|
|
145
178
|
|
|
146
|
-
|
|
147
|
-
|
|
179
|
+
private _registerInOrder(
|
|
180
|
+
loadOrder: string[],
|
|
181
|
+
validModules: Map<string, unknown>,
|
|
182
|
+
rawMetadata: Map<string, Record<string, unknown>>,
|
|
183
|
+
): number {
|
|
184
|
+
let count = 0;
|
|
148
185
|
for (const modId of loadOrder) {
|
|
149
186
|
const mod = validModules.get(modId)!;
|
|
150
|
-
const meta = rawMetadata.get(modId) ?? {};
|
|
151
|
-
|
|
152
187
|
const modObj = mod as Record<string, unknown>;
|
|
153
|
-
const mergedMeta = mergeModuleMetadata(modObj,
|
|
188
|
+
const mergedMeta = mergeModuleMetadata(modObj, rawMetadata.get(modId) ?? {});
|
|
154
189
|
|
|
155
190
|
this._modules.set(modId, mod);
|
|
156
191
|
this._moduleMeta.set(modId, mergedMeta);
|
|
157
192
|
|
|
158
|
-
// Call onLoad if available
|
|
159
193
|
if (typeof modObj['onLoad'] === 'function') {
|
|
160
194
|
try {
|
|
161
195
|
(modObj['onLoad'] as () => void)();
|
|
162
|
-
} catch {
|
|
196
|
+
} catch (e) {
|
|
197
|
+
console.warn(`[apcore:registry] onLoad failed for ${modId}, skipping:`, e);
|
|
163
198
|
this._modules.delete(modId);
|
|
164
199
|
this._moduleMeta.delete(modId);
|
|
165
200
|
continue;
|
|
166
201
|
}
|
|
167
202
|
}
|
|
168
203
|
|
|
169
|
-
this._triggerEvent(
|
|
170
|
-
|
|
204
|
+
this._triggerEvent(REGISTRY_EVENTS.REGISTER, modId, mod);
|
|
205
|
+
count++;
|
|
171
206
|
}
|
|
172
|
-
|
|
173
|
-
return registeredCount;
|
|
207
|
+
return count;
|
|
174
208
|
}
|
|
175
209
|
|
|
176
210
|
register(moduleId: string, module: unknown): void {
|
|
177
|
-
if (!moduleId) {
|
|
178
|
-
throw new InvalidInputError(
|
|
211
|
+
if (!moduleId || typeof moduleId !== "string") {
|
|
212
|
+
throw new InvalidInputError("Module ID must be a non-empty string");
|
|
213
|
+
}
|
|
214
|
+
if (!MODULE_ID_PATTERN.test(moduleId)) {
|
|
215
|
+
throw new InvalidInputError(
|
|
216
|
+
`Invalid module ID: "${moduleId}". Must match pattern: ${MODULE_ID_PATTERN} (lowercase, digits, underscores, dots only; no hyphens)`,
|
|
217
|
+
);
|
|
179
218
|
}
|
|
180
219
|
|
|
181
220
|
if (this._modules.has(moduleId)) {
|
|
@@ -195,7 +234,7 @@ export class Registry {
|
|
|
195
234
|
}
|
|
196
235
|
}
|
|
197
236
|
|
|
198
|
-
this._triggerEvent(
|
|
237
|
+
this._triggerEvent(REGISTRY_EVENTS.REGISTER, moduleId, module);
|
|
199
238
|
}
|
|
200
239
|
|
|
201
240
|
unregister(moduleId: string): boolean {
|
|
@@ -211,12 +250,12 @@ export class Registry {
|
|
|
211
250
|
if (typeof modObj['onUnload'] === 'function') {
|
|
212
251
|
try {
|
|
213
252
|
(modObj['onUnload'] as () => void)();
|
|
214
|
-
} catch {
|
|
215
|
-
|
|
253
|
+
} catch (e) {
|
|
254
|
+
console.warn(`[apcore:registry] onUnload failed for ${moduleId}:`, e);
|
|
216
255
|
}
|
|
217
256
|
}
|
|
218
257
|
|
|
219
|
-
this._triggerEvent(
|
|
258
|
+
this._triggerEvent(REGISTRY_EVENTS.UNREGISTER, moduleId, module);
|
|
220
259
|
return true;
|
|
221
260
|
}
|
|
222
261
|
|
|
@@ -291,8 +330,11 @@ export class Registry {
|
|
|
291
330
|
}
|
|
292
331
|
|
|
293
332
|
on(event: string, callback: EventCallback): void {
|
|
294
|
-
|
|
295
|
-
|
|
333
|
+
const validEvents = Object.values(REGISTRY_EVENTS) as string[];
|
|
334
|
+
if (!validEvents.includes(event)) {
|
|
335
|
+
throw new InvalidInputError(
|
|
336
|
+
`Invalid event: ${event}. Must be one of: ${validEvents.map((e) => `'${e}'`).join(', ')}`,
|
|
337
|
+
);
|
|
296
338
|
}
|
|
297
339
|
this._callbacks.get(event)!.push(callback);
|
|
298
340
|
}
|
|
@@ -302,8 +344,8 @@ export class Registry {
|
|
|
302
344
|
for (const cb of callbacks) {
|
|
303
345
|
try {
|
|
304
346
|
cb(moduleId, module);
|
|
305
|
-
} catch {
|
|
306
|
-
|
|
347
|
+
} catch (e) {
|
|
348
|
+
console.warn(`[apcore:registry] Event callback error for '${event}' on ${moduleId}:`, e);
|
|
307
349
|
}
|
|
308
350
|
}
|
|
309
351
|
}
|
|
@@ -6,15 +6,12 @@ import type { TSchema } from '@sinclair/typebox';
|
|
|
6
6
|
import json from 'js-yaml';
|
|
7
7
|
import type { ModuleAnnotations, ModuleExample } from '../module.js';
|
|
8
8
|
import { ModuleNotFoundError } from '../errors.js';
|
|
9
|
+
import { deepCopy } from '../utils/index.js';
|
|
9
10
|
import { SchemaExporter } from '../schema/exporter.js';
|
|
10
11
|
import { stripExtensions, toStrictSchema } from '../schema/strict.js';
|
|
11
12
|
import { ExportProfile, type SchemaDefinition } from '../schema/types.js';
|
|
12
13
|
import type { Registry } from './registry.js';
|
|
13
14
|
|
|
14
|
-
function deepCopy<T>(obj: T): T {
|
|
15
|
-
return JSON.parse(JSON.stringify(obj));
|
|
16
|
-
}
|
|
17
|
-
|
|
18
15
|
export function getSchema(registry: Registry, moduleId: string): Record<string, unknown> | null {
|
|
19
16
|
const module = registry.get(moduleId);
|
|
20
17
|
if (module === null) return null;
|
package/src/schema/exporter.ts
CHANGED
|
@@ -3,13 +3,10 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import type { ModuleAnnotations, ModuleExample } from '../module.js';
|
|
6
|
+
import { deepCopy } from '../utils/index.js';
|
|
6
7
|
import { applyLlmDescriptions, stripExtensions, toStrictSchema } from './strict.js';
|
|
7
8
|
import { ExportProfile, type SchemaDefinition } from './types.js';
|
|
8
9
|
|
|
9
|
-
function deepCopy<T>(obj: T): T {
|
|
10
|
-
return JSON.parse(JSON.stringify(obj));
|
|
11
|
-
}
|
|
12
|
-
|
|
13
10
|
export class SchemaExporter {
|
|
14
11
|
export(
|
|
15
12
|
schemaDef: SchemaDefinition,
|
package/src/schema/loader.ts
CHANGED
|
@@ -191,80 +191,69 @@ export class SchemaLoader {
|
|
|
191
191
|
export function jsonSchemaToTypeBox(schema: Record<string, unknown>): TSchema {
|
|
192
192
|
const schemaType = schema['type'] as string | undefined;
|
|
193
193
|
|
|
194
|
-
if (schemaType === 'object')
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
const propType = jsonSchemaToTypeBox(propSchema);
|
|
202
|
-
typeboxProps[name] = required.has(name) ? propType : Type.Optional(propType);
|
|
203
|
-
}
|
|
204
|
-
return Type.Object(typeboxProps);
|
|
205
|
-
}
|
|
206
|
-
return Type.Record(Type.String(), Type.Unknown());
|
|
207
|
-
}
|
|
194
|
+
if (schemaType === 'object') return convertObjectSchema(schema);
|
|
195
|
+
if (schemaType === 'array') return convertArraySchema(schema);
|
|
196
|
+
if (schemaType === 'string') return convertStringSchema(schema);
|
|
197
|
+
if (schemaType === 'integer') return convertNumericSchema(schema, Type.Integer);
|
|
198
|
+
if (schemaType === 'number') return convertNumericSchema(schema, Type.Number);
|
|
199
|
+
if (schemaType === 'boolean') return Type.Boolean();
|
|
200
|
+
if (schemaType === 'null') return Type.Null();
|
|
208
201
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
202
|
+
return convertCombinatorSchema(schema);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function convertObjectSchema(schema: Record<string, unknown>): TSchema {
|
|
206
|
+
const properties = schema['properties'] as Record<string, Record<string, unknown>> | undefined;
|
|
207
|
+
const required = new Set((schema['required'] as string[]) ?? []);
|
|
208
|
+
|
|
209
|
+
if (properties) {
|
|
210
|
+
const typeboxProps: Record<string, TSchema> = {};
|
|
211
|
+
for (const [name, propSchema] of Object.entries(properties)) {
|
|
212
|
+
const propType = jsonSchemaToTypeBox(propSchema);
|
|
213
|
+
typeboxProps[name] = required.has(name) ? propType : Type.Optional(propType);
|
|
213
214
|
}
|
|
214
|
-
return Type.
|
|
215
|
+
return Type.Object(typeboxProps);
|
|
215
216
|
}
|
|
217
|
+
return Type.Record(Type.String(), Type.Unknown());
|
|
218
|
+
}
|
|
216
219
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
if ('pattern' in schema) opts['pattern'] = schema['pattern'];
|
|
222
|
-
if ('format' in schema) opts['format'] = schema['format'];
|
|
223
|
-
return Type.String(opts);
|
|
224
|
-
}
|
|
220
|
+
function convertArraySchema(schema: Record<string, unknown>): TSchema {
|
|
221
|
+
const items = schema['items'] as Record<string, unknown> | undefined;
|
|
222
|
+
return items ? Type.Array(jsonSchemaToTypeBox(items)) : Type.Array(Type.Unknown());
|
|
223
|
+
}
|
|
225
224
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
if (
|
|
230
|
-
if ('exclusiveMinimum' in schema) opts['exclusiveMinimum'] = schema['exclusiveMinimum'];
|
|
231
|
-
if ('exclusiveMaximum' in schema) opts['exclusiveMaximum'] = schema['exclusiveMaximum'];
|
|
232
|
-
if ('multipleOf' in schema) opts['multipleOf'] = schema['multipleOf'];
|
|
233
|
-
return Type.Integer(opts);
|
|
225
|
+
function convertStringSchema(schema: Record<string, unknown>): TSchema {
|
|
226
|
+
const opts: Record<string, unknown> = {};
|
|
227
|
+
for (const key of ['minLength', 'maxLength', 'pattern', 'format']) {
|
|
228
|
+
if (key in schema) opts[key] = schema[key];
|
|
234
229
|
}
|
|
230
|
+
return Type.String(opts);
|
|
231
|
+
}
|
|
235
232
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
if (
|
|
243
|
-
return Type.Number(opts);
|
|
233
|
+
function convertNumericSchema(
|
|
234
|
+
schema: Record<string, unknown>,
|
|
235
|
+
factory: (opts?: Record<string, unknown>) => TSchema,
|
|
236
|
+
): TSchema {
|
|
237
|
+
const opts: Record<string, unknown> = {};
|
|
238
|
+
for (const key of ['minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'multipleOf']) {
|
|
239
|
+
if (key in schema) opts[key] = schema[key];
|
|
244
240
|
}
|
|
241
|
+
return factory(opts);
|
|
242
|
+
}
|
|
245
243
|
|
|
246
|
-
|
|
247
|
-
if (schemaType === 'null') return Type.Null();
|
|
248
|
-
|
|
244
|
+
function convertCombinatorSchema(schema: Record<string, unknown>): TSchema {
|
|
249
245
|
if ('enum' in schema) {
|
|
250
246
|
const values = schema['enum'] as unknown[];
|
|
251
247
|
return Type.Union(values.map((v) => Type.Literal(v as string | number | boolean)));
|
|
252
248
|
}
|
|
253
|
-
|
|
254
249
|
if ('oneOf' in schema) {
|
|
255
|
-
|
|
256
|
-
return Type.Union(schemas.map((s) => jsonSchemaToTypeBox(s)));
|
|
250
|
+
return Type.Union((schema['oneOf'] as Record<string, unknown>[]).map(jsonSchemaToTypeBox));
|
|
257
251
|
}
|
|
258
|
-
|
|
259
252
|
if ('anyOf' in schema) {
|
|
260
|
-
|
|
261
|
-
return Type.Union(schemas.map((s) => jsonSchemaToTypeBox(s)));
|
|
253
|
+
return Type.Union((schema['anyOf'] as Record<string, unknown>[]).map(jsonSchemaToTypeBox));
|
|
262
254
|
}
|
|
263
|
-
|
|
264
255
|
if ('allOf' in schema) {
|
|
265
|
-
|
|
266
|
-
return Type.Intersect(schemas.map((s) => jsonSchemaToTypeBox(s)));
|
|
256
|
+
return Type.Intersect((schema['allOf'] as Record<string, unknown>[]).map(jsonSchemaToTypeBox));
|
|
267
257
|
}
|
|
268
|
-
|
|
269
258
|
return Type.Unknown();
|
|
270
259
|
}
|
|
@@ -6,13 +6,10 @@ import { readFileSync, existsSync } from 'node:fs';
|
|
|
6
6
|
import { resolve, dirname, join } from 'node:path';
|
|
7
7
|
import yaml from 'js-yaml';
|
|
8
8
|
import { SchemaCircularRefError, SchemaNotFoundError, SchemaParseError } from '../errors.js';
|
|
9
|
+
import { deepCopy } from '../utils/index.js';
|
|
9
10
|
|
|
10
11
|
const INLINE_SENTINEL = '__inline__';
|
|
11
12
|
|
|
12
|
-
function deepCopy<T>(obj: T): T {
|
|
13
|
-
return JSON.parse(JSON.stringify(obj));
|
|
14
|
-
}
|
|
15
|
-
|
|
16
13
|
export class RefResolver {
|
|
17
14
|
private _schemasDir: string;
|
|
18
15
|
private _maxDepth: number;
|
package/src/schema/strict.ts
CHANGED
|
@@ -2,9 +2,7 @@
|
|
|
2
2
|
* Strict mode conversion for JSON Schemas (Algorithm A23).
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
return JSON.parse(JSON.stringify(obj));
|
|
7
|
-
}
|
|
5
|
+
import { deepCopy } from '../utils/index.js';
|
|
8
6
|
|
|
9
7
|
export function toStrictSchema(schema: Record<string, unknown>): Record<string, unknown> {
|
|
10
8
|
const result = deepCopy(schema);
|
package/src/utils/index.ts
CHANGED
|
@@ -72,21 +72,21 @@ describe('E2E Flow', () => {
|
|
|
72
72
|
});
|
|
73
73
|
const modB = new FunctionModule({
|
|
74
74
|
execute: (inputs) => ({ value: (inputs['x'] as number) + 10 }),
|
|
75
|
-
moduleId: 'math.
|
|
75
|
+
moduleId: 'math.add_ten',
|
|
76
76
|
inputSchema: Type.Object({ x: Type.Number() }),
|
|
77
77
|
outputSchema: Type.Object({ value: Type.Number() }),
|
|
78
78
|
description: 'Add ten',
|
|
79
79
|
});
|
|
80
80
|
registry.register('math.double', modA);
|
|
81
|
-
registry.register('math.
|
|
81
|
+
registry.register('math.add_ten', modB);
|
|
82
82
|
|
|
83
83
|
const executor = new Executor({ registry });
|
|
84
|
-
const ctx = Context.create(executor, createIdentity('
|
|
84
|
+
const ctx = Context.create(executor, createIdentity('test_user'));
|
|
85
85
|
|
|
86
86
|
const r1 = await executor.call('math.double', { x: 5 }, ctx);
|
|
87
87
|
expect(r1['value']).toBe(10);
|
|
88
88
|
|
|
89
|
-
const r2 = await executor.call('math.
|
|
89
|
+
const r2 = await executor.call('math.add_ten', { x: r1['value'] as number }, ctx);
|
|
90
90
|
expect(r2['value']).toBe(20);
|
|
91
91
|
});
|
|
92
92
|
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for schema/annotations.ts — annotation conflict resolution.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect } from 'vitest';
|
|
6
|
+
import { mergeAnnotations, mergeExamples, mergeMetadata } from '../../src/schema/annotations.js';
|
|
7
|
+
import { DEFAULT_ANNOTATIONS } from '../../src/module.js';
|
|
8
|
+
import type { ModuleAnnotations, ModuleExample } from '../../src/module.js';
|
|
9
|
+
|
|
10
|
+
describe('mergeAnnotations', () => {
|
|
11
|
+
it('returns defaults when both inputs are null', () => {
|
|
12
|
+
const result = mergeAnnotations(null, null);
|
|
13
|
+
expect(result).toEqual(DEFAULT_ANNOTATIONS);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('returns defaults when both inputs are undefined', () => {
|
|
17
|
+
const result = mergeAnnotations(undefined, undefined);
|
|
18
|
+
expect(result).toEqual(DEFAULT_ANNOTATIONS);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('applies code annotations over defaults', () => {
|
|
22
|
+
const codeAnnotations: ModuleAnnotations = {
|
|
23
|
+
readonly: true,
|
|
24
|
+
destructive: false,
|
|
25
|
+
idempotent: true,
|
|
26
|
+
requiresApproval: false,
|
|
27
|
+
openWorld: false,
|
|
28
|
+
};
|
|
29
|
+
const result = mergeAnnotations(null, codeAnnotations);
|
|
30
|
+
expect(result.readonly).toBe(true);
|
|
31
|
+
expect(result.idempotent).toBe(true);
|
|
32
|
+
expect(result.openWorld).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('yaml annotations override code annotations', () => {
|
|
36
|
+
const codeAnnotations: ModuleAnnotations = {
|
|
37
|
+
readonly: true,
|
|
38
|
+
destructive: false,
|
|
39
|
+
idempotent: false,
|
|
40
|
+
requiresApproval: false,
|
|
41
|
+
openWorld: true,
|
|
42
|
+
};
|
|
43
|
+
const yamlAnnotations = { readonly: false, destructive: true };
|
|
44
|
+
const result = mergeAnnotations(yamlAnnotations, codeAnnotations);
|
|
45
|
+
expect(result.readonly).toBe(false);
|
|
46
|
+
expect(result.destructive).toBe(true);
|
|
47
|
+
expect(result.idempotent).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('ignores unknown yaml keys', () => {
|
|
51
|
+
const yamlAnnotations = { unknownKey: 'value', readonly: true };
|
|
52
|
+
const result = mergeAnnotations(yamlAnnotations, null);
|
|
53
|
+
expect(result.readonly).toBe(true);
|
|
54
|
+
expect((result as unknown as Record<string, unknown>)['unknownKey']).toBeUndefined();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('mergeExamples', () => {
|
|
59
|
+
it('returns empty array when both inputs are null', () => {
|
|
60
|
+
expect(mergeExamples(null, null)).toEqual([]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('returns empty array when both inputs are undefined', () => {
|
|
64
|
+
expect(mergeExamples(undefined, undefined)).toEqual([]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('returns yaml examples when present', () => {
|
|
68
|
+
const yamlExamples = [
|
|
69
|
+
{ title: 'Test', inputs: { a: 1 }, output: { b: 2 }, description: 'desc' },
|
|
70
|
+
];
|
|
71
|
+
const result = mergeExamples(yamlExamples, null);
|
|
72
|
+
expect(result).toHaveLength(1);
|
|
73
|
+
expect(result[0].title).toBe('Test');
|
|
74
|
+
expect(result[0].inputs).toEqual({ a: 1 });
|
|
75
|
+
expect(result[0].output).toEqual({ b: 2 });
|
|
76
|
+
expect(result[0].description).toBe('desc');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('uses code examples when yaml is null', () => {
|
|
80
|
+
const codeExamples: ModuleExample[] = [
|
|
81
|
+
{ title: 'Code', inputs: { x: 1 }, output: { y: 2 } },
|
|
82
|
+
];
|
|
83
|
+
const result = mergeExamples(null, codeExamples);
|
|
84
|
+
expect(result).toEqual(codeExamples);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('yaml examples take precedence over code examples', () => {
|
|
88
|
+
const yamlExamples = [{ title: 'YAML', inputs: {}, output: {} }];
|
|
89
|
+
const codeExamples: ModuleExample[] = [
|
|
90
|
+
{ title: 'Code', inputs: {}, output: {} },
|
|
91
|
+
];
|
|
92
|
+
const result = mergeExamples(yamlExamples, codeExamples);
|
|
93
|
+
expect(result).toHaveLength(1);
|
|
94
|
+
expect(result[0].title).toBe('YAML');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('handles yaml examples with missing optional fields', () => {
|
|
98
|
+
const yamlExamples = [{ title: 'Minimal' }];
|
|
99
|
+
const result = mergeExamples(yamlExamples as Array<Record<string, unknown>>, null);
|
|
100
|
+
expect(result[0].inputs).toEqual({});
|
|
101
|
+
expect(result[0].output).toEqual({});
|
|
102
|
+
expect(result[0].description).toBeUndefined();
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('mergeMetadata', () => {
|
|
107
|
+
it('returns empty object when both inputs are null', () => {
|
|
108
|
+
expect(mergeMetadata(null, null)).toEqual({});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('returns code metadata when yaml is null', () => {
|
|
112
|
+
const code = { key: 'value' };
|
|
113
|
+
expect(mergeMetadata(null, code)).toEqual({ key: 'value' });
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('returns yaml metadata when code is null', () => {
|
|
117
|
+
const yaml = { key: 'value' };
|
|
118
|
+
expect(mergeMetadata(yaml, null)).toEqual({ key: 'value' });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('yaml overrides code on conflicting keys', () => {
|
|
122
|
+
const code = { a: 1, b: 2 };
|
|
123
|
+
const yaml = { b: 3, c: 4 };
|
|
124
|
+
const result = mergeMetadata(yaml, code);
|
|
125
|
+
expect(result).toEqual({ a: 1, b: 3, c: 4 });
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('does not mutate input objects', () => {
|
|
129
|
+
const code = { a: 1 };
|
|
130
|
+
const yaml = { b: 2 };
|
|
131
|
+
mergeMetadata(yaml, code);
|
|
132
|
+
expect(code).toEqual({ a: 1 });
|
|
133
|
+
expect(yaml).toEqual({ b: 2 });
|
|
134
|
+
});
|
|
135
|
+
});
|