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.
@@ -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
- ['register', []],
24
- ['unregister', []],
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
- discovered = scanMultiRoot(this._extensionRoots, maxDepth, followSymlinks);
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
- // 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;
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
- // Step 3: Load metadata
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
- // Step 4: Resolve entry points
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
- continue;
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
- // Step 5: Validate modules
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
- const errors = validateModule(mod);
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
- // Step 6: Collect dependencies
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
- const deps = depsRaw.length > 0 ? parseDependencies(depsRaw) : [];
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
- const loadOrder = resolveDependencies(modulesWithDeps, knownIds);
176
+ return resolveDependencies(modulesWithDeps, knownIds);
177
+ }
145
178
 
146
- // Step 8: Register in dependency order
147
- let registeredCount = 0;
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, meta);
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('register', modId, mod);
170
- registeredCount++;
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('module_id must be a non-empty string');
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('register', moduleId, module);
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
- // Swallow
253
+ } catch (e) {
254
+ console.warn(`[apcore:registry] onUnload failed for ${moduleId}:`, e);
216
255
  }
217
256
  }
218
257
 
219
- this._triggerEvent('unregister', moduleId, module);
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
- if (!this._callbacks.has(event)) {
295
- throw new InvalidInputError(`Invalid event: ${event}. Must be 'register' or 'unregister'`);
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
- // Swallow callback errors
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;
@@ -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,
@@ -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
- const properties = schema['properties'] as Record<string, Record<string, unknown>> | undefined;
196
- const required = new Set((schema['required'] as string[]) ?? []);
197
-
198
- if (properties) {
199
- const typeboxProps: Record<string, TSchema> = {};
200
- for (const [name, propSchema] of Object.entries(properties)) {
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
- if (schemaType === 'array') {
210
- const items = schema['items'] as Record<string, unknown> | undefined;
211
- if (items) {
212
- return Type.Array(jsonSchemaToTypeBox(items));
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.Array(Type.Unknown());
215
+ return Type.Object(typeboxProps);
215
216
  }
217
+ return Type.Record(Type.String(), Type.Unknown());
218
+ }
216
219
 
217
- if (schemaType === 'string') {
218
- const opts: Record<string, unknown> = {};
219
- if ('minLength' in schema) opts['minLength'] = schema['minLength'];
220
- if ('maxLength' in schema) opts['maxLength'] = schema['maxLength'];
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
- if (schemaType === 'integer') {
227
- const opts: Record<string, unknown> = {};
228
- if ('minimum' in schema) opts['minimum'] = schema['minimum'];
229
- if ('maximum' in schema) opts['maximum'] = schema['maximum'];
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
- if (schemaType === 'number') {
237
- const opts: Record<string, unknown> = {};
238
- if ('minimum' in schema) opts['minimum'] = schema['minimum'];
239
- if ('maximum' in schema) opts['maximum'] = schema['maximum'];
240
- if ('exclusiveMinimum' in schema) opts['exclusiveMinimum'] = schema['exclusiveMinimum'];
241
- if ('exclusiveMaximum' in schema) opts['exclusiveMaximum'] = schema['exclusiveMaximum'];
242
- if ('multipleOf' in schema) opts['multipleOf'] = schema['multipleOf'];
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
- if (schemaType === 'boolean') return Type.Boolean();
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
- const schemas = schema['oneOf'] as Record<string, unknown>[];
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
- const schemas = schema['anyOf'] as Record<string, unknown>[];
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
- const schemas = schema['allOf'] as Record<string, unknown>[];
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;
@@ -2,9 +2,7 @@
2
2
  * Strict mode conversion for JSON Schemas (Algorithm A23).
3
3
  */
4
4
 
5
- function deepCopy<T>(obj: T): T {
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);
@@ -1 +1,5 @@
1
1
  export { matchPattern } from './pattern.js';
2
+
3
+ export function deepCopy<T>(obj: T): T {
4
+ return JSON.parse(JSON.stringify(obj));
5
+ }
@@ -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.addTen',
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.addTen', modB);
81
+ registry.register('math.add_ten', modB);
82
82
 
83
83
  const executor = new Executor({ registry });
84
- const ctx = Context.create(executor, createIdentity('test-user'));
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.addTen', { x: r1['value'] as number }, ctx);
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
+ });