apcore-js 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +2 -1
- package/.pre-commit-config.yaml +2 -2
- package/CHANGELOG.md +38 -0
- package/package.json +1 -1
- package/src/acl.ts +37 -38
- package/src/bindings.ts +11 -18
- package/src/context.ts +1 -1
- package/src/errors.ts +2 -2
- package/src/executor.ts +62 -49
- package/src/index.ts +1 -1
- 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 +72 -52
- 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/schema/test-annotations.test.ts +135 -0
- package/tests/schema/test-exporter.test.ts +171 -0
- package/tests/test-logging-middleware.test.ts +150 -0
package/src/registry/registry.ts
CHANGED
|
@@ -63,6 +63,18 @@ export class Registry {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
async discover(): Promise<number> {
|
|
66
|
+
const discovered = this._scanRoots();
|
|
67
|
+
this._applyIdMapOverrides(discovered);
|
|
68
|
+
|
|
69
|
+
const rawMetadata = this._loadAllMetadata(discovered);
|
|
70
|
+
const resolvedModules = await this._resolveAllEntryPoints(discovered, rawMetadata);
|
|
71
|
+
const validModules = this._validateAll(resolvedModules);
|
|
72
|
+
const loadOrder = this._resolveLoadOrder(validModules, rawMetadata);
|
|
73
|
+
|
|
74
|
+
return this._registerInOrder(loadOrder, validModules, rawMetadata);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private _scanRoots(): import('./types.js').DiscoveredModule[] {
|
|
66
78
|
let maxDepth = 8;
|
|
67
79
|
let followSymlinks = false;
|
|
68
80
|
if (this._config !== null) {
|
|
@@ -70,96 +82,105 @@ export class Registry {
|
|
|
70
82
|
followSymlinks = (this._config.get('extensions.follow_symlinks', false) as boolean);
|
|
71
83
|
}
|
|
72
84
|
|
|
73
|
-
// Step 1: Scan extension roots
|
|
74
85
|
const hasNamespace = this._extensionRoots.some((r) => 'namespace' in r);
|
|
75
|
-
let discovered;
|
|
76
86
|
if (this._extensionRoots.length > 1 || hasNamespace) {
|
|
77
|
-
|
|
78
|
-
} else {
|
|
79
|
-
const rootPath = this._extensionRoots[0]['root'] as string;
|
|
80
|
-
discovered = scanExtensions(rootPath, maxDepth, followSymlinks);
|
|
87
|
+
return scanMultiRoot(this._extensionRoots, maxDepth, followSymlinks);
|
|
81
88
|
}
|
|
89
|
+
return scanExtensions(this._extensionRoots[0]['root'] as string, maxDepth, followSymlinks);
|
|
90
|
+
}
|
|
82
91
|
|
|
83
|
-
|
|
84
|
-
if (Object.keys(this._idMap).length
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
} catch {
|
|
97
|
-
continue;
|
|
92
|
+
private _applyIdMapOverrides(discovered: import('./types.js').DiscoveredModule[]): void {
|
|
93
|
+
if (Object.keys(this._idMap).length === 0) return;
|
|
94
|
+
|
|
95
|
+
const resolvedRoots = this._extensionRoots.map((r) => resolve(r['root'] as string));
|
|
96
|
+
for (const dm of discovered) {
|
|
97
|
+
for (const root of resolvedRoots) {
|
|
98
|
+
try {
|
|
99
|
+
const relPath = dm.filePath.startsWith(root)
|
|
100
|
+
? dm.filePath.slice(root.length + 1)
|
|
101
|
+
: null;
|
|
102
|
+
if (relPath && relPath in this._idMap) {
|
|
103
|
+
dm.canonicalId = this._idMap[relPath]['id'] as string;
|
|
104
|
+
break;
|
|
98
105
|
}
|
|
106
|
+
} catch (e) {
|
|
107
|
+
console.warn(`[apcore:registry] Failed to apply ID map for ${dm.canonicalId}:`, e);
|
|
108
|
+
continue;
|
|
99
109
|
}
|
|
100
110
|
}
|
|
101
111
|
}
|
|
112
|
+
}
|
|
102
113
|
|
|
103
|
-
|
|
114
|
+
private _loadAllMetadata(
|
|
115
|
+
discovered: import('./types.js').DiscoveredModule[],
|
|
116
|
+
): Map<string, Record<string, unknown>> {
|
|
104
117
|
const rawMetadata = new Map<string, Record<string, unknown>>();
|
|
105
118
|
for (const dm of discovered) {
|
|
106
|
-
rawMetadata.set(
|
|
107
|
-
dm.canonicalId,
|
|
108
|
-
dm.metaPath ? loadMetadata(dm.metaPath) : {},
|
|
109
|
-
);
|
|
119
|
+
rawMetadata.set(dm.canonicalId, dm.metaPath ? loadMetadata(dm.metaPath) : {});
|
|
110
120
|
}
|
|
121
|
+
return rawMetadata;
|
|
122
|
+
}
|
|
111
123
|
|
|
112
|
-
|
|
124
|
+
private async _resolveAllEntryPoints(
|
|
125
|
+
discovered: import('./types.js').DiscoveredModule[],
|
|
126
|
+
rawMetadata: Map<string, Record<string, unknown>>,
|
|
127
|
+
): Promise<Map<string, unknown>> {
|
|
113
128
|
const resolvedModules = new Map<string, unknown>();
|
|
114
129
|
for (const dm of discovered) {
|
|
115
130
|
const meta = rawMetadata.get(dm.canonicalId) ?? {};
|
|
116
131
|
try {
|
|
117
132
|
const mod = await resolveEntryPoint(dm.filePath, meta);
|
|
118
133
|
resolvedModules.set(dm.canonicalId, mod);
|
|
119
|
-
} catch {
|
|
120
|
-
|
|
134
|
+
} catch (e) {
|
|
135
|
+
console.warn(`[apcore:registry] Failed to resolve entry point for ${dm.canonicalId}:`, e);
|
|
121
136
|
}
|
|
122
137
|
}
|
|
138
|
+
return resolvedModules;
|
|
139
|
+
}
|
|
123
140
|
|
|
124
|
-
|
|
141
|
+
private _validateAll(resolvedModules: Map<string, unknown>): Map<string, unknown> {
|
|
125
142
|
const validModules = new Map<string, unknown>();
|
|
126
143
|
for (const [modId, mod] of resolvedModules) {
|
|
127
|
-
|
|
128
|
-
if (errors.length === 0) {
|
|
144
|
+
if (validateModule(mod).length === 0) {
|
|
129
145
|
validModules.set(modId, mod);
|
|
130
146
|
}
|
|
131
147
|
}
|
|
148
|
+
return validModules;
|
|
149
|
+
}
|
|
132
150
|
|
|
133
|
-
|
|
151
|
+
private _resolveLoadOrder(
|
|
152
|
+
validModules: Map<string, unknown>,
|
|
153
|
+
rawMetadata: Map<string, Record<string, unknown>>,
|
|
154
|
+
): string[] {
|
|
134
155
|
const modulesWithDeps: Array<[string, DependencyInfo[]]> = [];
|
|
135
156
|
for (const modId of validModules.keys()) {
|
|
136
157
|
const meta = rawMetadata.get(modId) ?? {};
|
|
137
158
|
const depsRaw = (meta['dependencies'] as Array<Record<string, unknown>>) ?? [];
|
|
138
|
-
|
|
139
|
-
modulesWithDeps.push([modId, deps]);
|
|
159
|
+
modulesWithDeps.push([modId, depsRaw.length > 0 ? parseDependencies(depsRaw) : []]);
|
|
140
160
|
}
|
|
141
|
-
|
|
142
|
-
// Step 7: Resolve dependency order
|
|
143
161
|
const knownIds = new Set(modulesWithDeps.map(([id]) => id));
|
|
144
|
-
|
|
162
|
+
return resolveDependencies(modulesWithDeps, knownIds);
|
|
163
|
+
}
|
|
145
164
|
|
|
146
|
-
|
|
147
|
-
|
|
165
|
+
private _registerInOrder(
|
|
166
|
+
loadOrder: string[],
|
|
167
|
+
validModules: Map<string, unknown>,
|
|
168
|
+
rawMetadata: Map<string, Record<string, unknown>>,
|
|
169
|
+
): number {
|
|
170
|
+
let count = 0;
|
|
148
171
|
for (const modId of loadOrder) {
|
|
149
172
|
const mod = validModules.get(modId)!;
|
|
150
|
-
const meta = rawMetadata.get(modId) ?? {};
|
|
151
|
-
|
|
152
173
|
const modObj = mod as Record<string, unknown>;
|
|
153
|
-
const mergedMeta = mergeModuleMetadata(modObj,
|
|
174
|
+
const mergedMeta = mergeModuleMetadata(modObj, rawMetadata.get(modId) ?? {});
|
|
154
175
|
|
|
155
176
|
this._modules.set(modId, mod);
|
|
156
177
|
this._moduleMeta.set(modId, mergedMeta);
|
|
157
178
|
|
|
158
|
-
// Call onLoad if available
|
|
159
179
|
if (typeof modObj['onLoad'] === 'function') {
|
|
160
180
|
try {
|
|
161
181
|
(modObj['onLoad'] as () => void)();
|
|
162
|
-
} catch {
|
|
182
|
+
} catch (e) {
|
|
183
|
+
console.warn(`[apcore:registry] onLoad failed for ${modId}, skipping:`, e);
|
|
163
184
|
this._modules.delete(modId);
|
|
164
185
|
this._moduleMeta.delete(modId);
|
|
165
186
|
continue;
|
|
@@ -167,10 +188,9 @@ export class Registry {
|
|
|
167
188
|
}
|
|
168
189
|
|
|
169
190
|
this._triggerEvent('register', modId, mod);
|
|
170
|
-
|
|
191
|
+
count++;
|
|
171
192
|
}
|
|
172
|
-
|
|
173
|
-
return registeredCount;
|
|
193
|
+
return count;
|
|
174
194
|
}
|
|
175
195
|
|
|
176
196
|
register(moduleId: string, module: unknown): void {
|
|
@@ -211,8 +231,8 @@ export class Registry {
|
|
|
211
231
|
if (typeof modObj['onUnload'] === 'function') {
|
|
212
232
|
try {
|
|
213
233
|
(modObj['onUnload'] as () => void)();
|
|
214
|
-
} catch {
|
|
215
|
-
|
|
234
|
+
} catch (e) {
|
|
235
|
+
console.warn(`[apcore:registry] onUnload failed for ${moduleId}:`, e);
|
|
216
236
|
}
|
|
217
237
|
}
|
|
218
238
|
|
|
@@ -302,8 +322,8 @@ export class Registry {
|
|
|
302
322
|
for (const cb of callbacks) {
|
|
303
323
|
try {
|
|
304
324
|
cb(moduleId, module);
|
|
305
|
-
} catch {
|
|
306
|
-
|
|
325
|
+
} catch (e) {
|
|
326
|
+
console.warn(`[apcore:registry] Event callback error for '${event}' on ${moduleId}:`, e);
|
|
307
327
|
}
|
|
308
328
|
}
|
|
309
329
|
}
|
|
@@ -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
|
@@ -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
|
+
});
|