apcore-js 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/.claude/settings.local.json +11 -0
  2. package/.gitmessage +60 -0
  3. package/.pre-commit-config.yaml +28 -0
  4. package/CHANGELOG.md +47 -0
  5. package/CLAUDE.md +68 -0
  6. package/README.md +131 -0
  7. package/apcore-logo.svg +79 -0
  8. package/package.json +37 -0
  9. package/planning/acl-system/overview.md +54 -0
  10. package/planning/acl-system/plan.md +92 -0
  11. package/planning/acl-system/state.json +76 -0
  12. package/planning/acl-system/tasks/acl-core.md +226 -0
  13. package/planning/acl-system/tasks/acl-rule.md +92 -0
  14. package/planning/acl-system/tasks/conditional-rules.md +259 -0
  15. package/planning/acl-system/tasks/pattern-matching.md +152 -0
  16. package/planning/acl-system/tasks/yaml-loading.md +271 -0
  17. package/planning/core-executor/overview.md +53 -0
  18. package/planning/core-executor/plan.md +88 -0
  19. package/planning/core-executor/state.json +76 -0
  20. package/planning/core-executor/tasks/async-support.md +106 -0
  21. package/planning/core-executor/tasks/execution-pipeline.md +113 -0
  22. package/planning/core-executor/tasks/redaction.md +85 -0
  23. package/planning/core-executor/tasks/safety-checks.md +65 -0
  24. package/planning/core-executor/tasks/setup.md +75 -0
  25. package/planning/decorator-bindings/overview.md +62 -0
  26. package/planning/decorator-bindings/plan.md +104 -0
  27. package/planning/decorator-bindings/state.json +87 -0
  28. package/planning/decorator-bindings/tasks/binding-directory.md +79 -0
  29. package/planning/decorator-bindings/tasks/binding-loader.md +148 -0
  30. package/planning/decorator-bindings/tasks/explicit-schemas.md +85 -0
  31. package/planning/decorator-bindings/tasks/function-module.md +127 -0
  32. package/planning/decorator-bindings/tasks/module-factory.md +89 -0
  33. package/planning/decorator-bindings/tasks/schema-modes.md +142 -0
  34. package/planning/middleware-system/overview.md +48 -0
  35. package/planning/middleware-system/plan.md +102 -0
  36. package/planning/middleware-system/state.json +65 -0
  37. package/planning/middleware-system/tasks/adapters.md +170 -0
  38. package/planning/middleware-system/tasks/base.md +115 -0
  39. package/planning/middleware-system/tasks/logging-middleware.md +304 -0
  40. package/planning/middleware-system/tasks/manager.md +313 -0
  41. package/planning/observability/overview.md +53 -0
  42. package/planning/observability/plan.md +119 -0
  43. package/planning/observability/state.json +98 -0
  44. package/planning/observability/tasks/context-logger.md +201 -0
  45. package/planning/observability/tasks/exporters.md +121 -0
  46. package/planning/observability/tasks/metrics-collector.md +162 -0
  47. package/planning/observability/tasks/metrics-middleware.md +141 -0
  48. package/planning/observability/tasks/obs-logging-middleware.md +179 -0
  49. package/planning/observability/tasks/span-model.md +120 -0
  50. package/planning/observability/tasks/tracing-middleware.md +179 -0
  51. package/planning/overview.md +81 -0
  52. package/planning/registry-system/overview.md +57 -0
  53. package/planning/registry-system/plan.md +114 -0
  54. package/planning/registry-system/state.json +109 -0
  55. package/planning/registry-system/tasks/dependencies.md +157 -0
  56. package/planning/registry-system/tasks/entry-point.md +148 -0
  57. package/planning/registry-system/tasks/metadata.md +198 -0
  58. package/planning/registry-system/tasks/registry-core.md +323 -0
  59. package/planning/registry-system/tasks/scanner.md +172 -0
  60. package/planning/registry-system/tasks/schema-export.md +261 -0
  61. package/planning/registry-system/tasks/types.md +124 -0
  62. package/planning/registry-system/tasks/validation.md +177 -0
  63. package/planning/schema-system/overview.md +56 -0
  64. package/planning/schema-system/plan.md +121 -0
  65. package/planning/schema-system/state.json +98 -0
  66. package/planning/schema-system/tasks/exporter.md +153 -0
  67. package/planning/schema-system/tasks/loader.md +106 -0
  68. package/planning/schema-system/tasks/ref-resolver.md +133 -0
  69. package/planning/schema-system/tasks/strict-mode.md +140 -0
  70. package/planning/schema-system/tasks/typebox-generation.md +133 -0
  71. package/planning/schema-system/tasks/types-and-annotations.md +160 -0
  72. package/planning/schema-system/tasks/validator.md +149 -0
  73. package/src/acl.ts +188 -0
  74. package/src/bindings.ts +208 -0
  75. package/src/config.ts +24 -0
  76. package/src/context.ts +75 -0
  77. package/src/decorator.ts +110 -0
  78. package/src/errors.ts +369 -0
  79. package/src/executor.ts +348 -0
  80. package/src/index.ts +81 -0
  81. package/src/middleware/adapters.ts +54 -0
  82. package/src/middleware/base.ts +33 -0
  83. package/src/middleware/index.ts +6 -0
  84. package/src/middleware/logging.ts +103 -0
  85. package/src/middleware/manager.ts +105 -0
  86. package/src/module.ts +41 -0
  87. package/src/observability/context-logger.ts +201 -0
  88. package/src/observability/index.ts +4 -0
  89. package/src/observability/metrics.ts +212 -0
  90. package/src/observability/tracing.ts +187 -0
  91. package/src/registry/dependencies.ts +99 -0
  92. package/src/registry/entry-point.ts +64 -0
  93. package/src/registry/index.ts +8 -0
  94. package/src/registry/metadata.ts +111 -0
  95. package/src/registry/registry.ts +314 -0
  96. package/src/registry/scanner.ts +150 -0
  97. package/src/registry/schema-export.ts +177 -0
  98. package/src/registry/types.ts +32 -0
  99. package/src/registry/validation.ts +38 -0
  100. package/src/schema/annotations.ts +67 -0
  101. package/src/schema/exporter.ts +93 -0
  102. package/src/schema/index.ts +14 -0
  103. package/src/schema/loader.ts +270 -0
  104. package/src/schema/ref-resolver.ts +235 -0
  105. package/src/schema/strict.ts +128 -0
  106. package/src/schema/types.ts +73 -0
  107. package/src/schema/validator.ts +82 -0
  108. package/src/utils/index.ts +1 -0
  109. package/src/utils/pattern.ts +30 -0
  110. package/tests/helpers.ts +30 -0
  111. package/tests/integration/test-acl-safety.test.ts +268 -0
  112. package/tests/integration/test-binding-executor.test.ts +194 -0
  113. package/tests/integration/test-e2e-flow.test.ts +117 -0
  114. package/tests/integration/test-error-propagation.test.ts +259 -0
  115. package/tests/integration/test-middleware-chain.test.ts +120 -0
  116. package/tests/integration/test-observability-integration.test.ts +438 -0
  117. package/tests/observability/test-context-logger.test.ts +123 -0
  118. package/tests/observability/test-metrics.test.ts +89 -0
  119. package/tests/observability/test-tracing.test.ts +131 -0
  120. package/tests/registry/test-dependencies.test.ts +70 -0
  121. package/tests/registry/test-entry-point.test.ts +133 -0
  122. package/tests/registry/test-metadata.test.ts +265 -0
  123. package/tests/registry/test-registry.test.ts +140 -0
  124. package/tests/registry/test-scanner.test.ts +257 -0
  125. package/tests/registry/test-schema-export.test.ts +224 -0
  126. package/tests/registry/test-validation.test.ts +75 -0
  127. package/tests/schema/test-loader.test.ts +97 -0
  128. package/tests/schema/test-ref-resolver.test.ts +105 -0
  129. package/tests/schema/test-strict.test.ts +139 -0
  130. package/tests/schema/test-validator.test.ts +64 -0
  131. package/tests/test-acl.test.ts +206 -0
  132. package/tests/test-bindings.test.ts +227 -0
  133. package/tests/test-config.test.ts +76 -0
  134. package/tests/test-context.test.ts +151 -0
  135. package/tests/test-decorator.test.ts +173 -0
  136. package/tests/test-errors.test.ts +204 -0
  137. package/tests/test-executor.test.ts +252 -0
  138. package/tests/test-middleware-manager.test.ts +185 -0
  139. package/tests/test-middleware.test.ts +86 -0
  140. package/tsconfig.build.json +8 -0
  141. package/tsconfig.json +20 -0
  142. package/vitest.config.ts +18 -0
@@ -0,0 +1,227 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import { BindingLoader } from '../src/bindings.js';
6
+ import { Registry } from '../src/registry/registry.js';
7
+ import {
8
+ BindingInvalidTargetError,
9
+ BindingFileInvalidError,
10
+ BindingModuleNotFoundError,
11
+ BindingCallableNotFoundError,
12
+ BindingNotCallableError,
13
+ } from '../src/errors.js';
14
+
15
+ let tmpDir: string;
16
+ let loader: BindingLoader;
17
+ let registry: Registry;
18
+
19
+ beforeEach(() => {
20
+ tmpDir = mkdtempSync(join(tmpdir(), 'apcore-binding-test-'));
21
+ loader = new BindingLoader();
22
+ registry = new Registry();
23
+ });
24
+
25
+ afterEach(() => {
26
+ rmSync(tmpDir, { recursive: true, force: true });
27
+ });
28
+
29
+ function writeTempModule(filename: string, content: string): string {
30
+ const filePath = join(tmpDir, filename);
31
+ writeFileSync(filePath, content, 'utf-8');
32
+ return filePath;
33
+ }
34
+
35
+ function writeTempYaml(filename: string, content: string): string {
36
+ const filePath = join(tmpDir, filename);
37
+ writeFileSync(filePath, content, 'utf-8');
38
+ return filePath;
39
+ }
40
+
41
+ describe('BindingLoader', () => {
42
+ describe('instantiation', () => {
43
+ it('creates a new instance', () => {
44
+ expect(new BindingLoader()).toBeInstanceOf(BindingLoader);
45
+ });
46
+
47
+ it('has loadBindings, loadBindingDir, and resolveTarget methods', () => {
48
+ const bl = new BindingLoader();
49
+ expect(typeof bl.loadBindings).toBe('function');
50
+ expect(typeof bl.loadBindingDir).toBe('function');
51
+ expect(typeof bl.resolveTarget).toBe('function');
52
+ });
53
+ });
54
+
55
+ describe('resolveTarget', () => {
56
+ it('throws BindingInvalidTargetError for target without colon', async () => {
57
+ await expect(loader.resolveTarget('no_colon_here')).rejects.toThrow(BindingInvalidTargetError);
58
+ });
59
+
60
+ it('throws BindingModuleNotFoundError for non-existent module path', async () => {
61
+ await expect(
62
+ loader.resolveTarget('/nonexistent/path/to/module.mjs:someFunc'),
63
+ ).rejects.toThrow(BindingModuleNotFoundError);
64
+ });
65
+
66
+ it('successfully resolves a function export from a real JS module', async () => {
67
+ const modPath = writeTempModule(
68
+ 'func_export.mjs',
69
+ 'export function greet(name) { return `Hello, ${name}`; }\n',
70
+ );
71
+ const fn = await loader.resolveTarget(`${modPath}:greet`);
72
+ expect(typeof fn).toBe('function');
73
+ expect(fn('World')).toBe('Hello, World');
74
+ });
75
+
76
+ it('successfully resolves a class method', async () => {
77
+ const modPath = writeTempModule(
78
+ 'class_export.mjs',
79
+ `export class Calculator {\n add(a, b) { return a + b; }\n}\n`,
80
+ );
81
+ const fn = await loader.resolveTarget(`${modPath}:Calculator.add`);
82
+ expect(typeof fn).toBe('function');
83
+ expect(fn(2, 3)).toBe(5);
84
+ });
85
+
86
+ it('throws BindingCallableNotFoundError for missing callable', async () => {
87
+ const modPath = writeTempModule('missing_callable.mjs', 'export function exists() { return true; }\n');
88
+ await expect(loader.resolveTarget(`${modPath}:doesNotExist`)).rejects.toThrow(BindingCallableNotFoundError);
89
+ });
90
+
91
+ it('throws BindingNotCallableError for non-function export', async () => {
92
+ const modPath = writeTempModule('non_callable.mjs', 'export const MY_CONSTANT = 42;\n');
93
+ await expect(loader.resolveTarget(`${modPath}:MY_CONSTANT`)).rejects.toThrow(BindingNotCallableError);
94
+ });
95
+ });
96
+
97
+ describe('loadBindings', () => {
98
+ it('throws BindingFileInvalidError for non-existent file', async () => {
99
+ await expect(
100
+ loader.loadBindings('/nonexistent/path/binding.yaml', registry),
101
+ ).rejects.toThrow(BindingFileInvalidError);
102
+ });
103
+
104
+ it('throws BindingFileInvalidError for invalid YAML', async () => {
105
+ const yamlPath = writeTempYaml('invalid.binding.yaml', '{ invalid yaml: [unclosed');
106
+ await expect(loader.loadBindings(yamlPath, registry)).rejects.toThrow(BindingFileInvalidError);
107
+ });
108
+
109
+ it('throws BindingFileInvalidError for empty file', async () => {
110
+ const yamlPath = writeTempYaml('empty.binding.yaml', '');
111
+ await expect(loader.loadBindings(yamlPath, registry)).rejects.toThrow(BindingFileInvalidError);
112
+ });
113
+
114
+ it('throws BindingFileInvalidError for missing bindings key', async () => {
115
+ const yamlPath = writeTempYaml('nokey.binding.yaml', 'other_key: value\n');
116
+ await expect(loader.loadBindings(yamlPath, registry)).rejects.toThrow(BindingFileInvalidError);
117
+ });
118
+
119
+ it('throws BindingFileInvalidError for non-array bindings value', async () => {
120
+ const yamlPath = writeTempYaml('notarray.binding.yaml', 'bindings: "not an array"\n');
121
+ await expect(loader.loadBindings(yamlPath, registry)).rejects.toThrow(BindingFileInvalidError);
122
+ });
123
+
124
+ it('throws BindingFileInvalidError for binding entry missing module_id', async () => {
125
+ const modPath = writeTempModule('dummy_mod.mjs', 'export function dummy() { return {}; }\n');
126
+ const yamlPath = writeTempYaml('noid.binding.yaml', `bindings:\n - target: "${modPath}:dummy"\n`);
127
+ await expect(loader.loadBindings(yamlPath, registry)).rejects.toThrow(BindingFileInvalidError);
128
+ });
129
+
130
+ it('throws BindingFileInvalidError for binding entry missing target', async () => {
131
+ const yamlPath = writeTempYaml('notarget.binding.yaml', 'bindings:\n - module_id: "test.module"\n');
132
+ await expect(loader.loadBindings(yamlPath, registry)).rejects.toThrow(BindingFileInvalidError);
133
+ });
134
+
135
+ it('successfully loads valid binding with inline schemas', async () => {
136
+ const modPath = writeTempModule(
137
+ 'inline_schema_mod.mjs',
138
+ 'export function process(inputs) { return { result: inputs.name }; }\n',
139
+ );
140
+ const yamlPath = writeTempYaml(
141
+ 'inline.binding.yaml',
142
+ `bindings:\n - module_id: "test.inline"\n target: "${modPath}:process"\n description: "Inline schema test"\n version: "2.0.0"\n tags:\n - demo\n input_schema:\n type: object\n properties:\n name:\n type: string\n output_schema:\n type: object\n properties:\n result:\n type: string\n`,
143
+ );
144
+ const results = await loader.loadBindings(yamlPath, registry);
145
+ expect(results).toHaveLength(1);
146
+ expect(results[0].moduleId).toBe('test.inline');
147
+ expect(results[0].description).toBe('Inline schema test');
148
+ expect(results[0].version).toBe('2.0.0');
149
+ });
150
+
151
+ it('successfully loads binding with permissive fallback (no schema)', async () => {
152
+ const modPath = writeTempModule('permissive_mod.mjs', 'export function loose(inputs) { return { ok: true }; }\n');
153
+ const yamlPath = writeTempYaml(
154
+ 'permissive.binding.yaml',
155
+ `bindings:\n - module_id: "test.permissive"\n target: "${modPath}:loose"\n`,
156
+ );
157
+ const results = await loader.loadBindings(yamlPath, registry);
158
+ expect(results).toHaveLength(1);
159
+ expect(results[0].moduleId).toBe('test.permissive');
160
+ expect(results[0].inputSchema).toBeDefined();
161
+ expect(results[0].outputSchema).toBeDefined();
162
+ });
163
+
164
+ it('registers modules in the registry', async () => {
165
+ const modPath = writeTempModule('registered_mod.mjs', 'export function handler() { return {}; }\n');
166
+ const yamlPath = writeTempYaml(
167
+ 'register.binding.yaml',
168
+ `bindings:\n - module_id: "test.registered"\n target: "${modPath}:handler"\n`,
169
+ );
170
+ await loader.loadBindings(yamlPath, registry);
171
+ expect(registry.has('test.registered')).toBe(true);
172
+ });
173
+
174
+ it('loads multiple binding entries from single file', async () => {
175
+ const modPath = writeTempModule(
176
+ 'multi_mod.mjs',
177
+ `export function funcA() { return { a: true }; }\nexport function funcB() { return { b: true }; }\n`,
178
+ );
179
+ const yamlPath = writeTempYaml(
180
+ 'multi.binding.yaml',
181
+ `bindings:\n - module_id: "test.multi.a"\n target: "${modPath}:funcA"\n - module_id: "test.multi.b"\n target: "${modPath}:funcB"\n`,
182
+ );
183
+ const results = await loader.loadBindings(yamlPath, registry);
184
+ expect(results).toHaveLength(2);
185
+ expect(registry.has('test.multi.a')).toBe(true);
186
+ expect(registry.has('test.multi.b')).toBe(true);
187
+ });
188
+ });
189
+
190
+ describe('loadBindingDir', () => {
191
+ it('throws BindingFileInvalidError for non-existent directory', async () => {
192
+ await expect(loader.loadBindingDir('/nonexistent/dir/path', registry)).rejects.toThrow(BindingFileInvalidError);
193
+ });
194
+
195
+ it('loads all *.binding.yaml files in directory', async () => {
196
+ const bindDir = join(tmpDir, 'bindings');
197
+ mkdirSync(bindDir);
198
+
199
+ const modPath = writeTempModule(
200
+ 'dir_mod.mjs',
201
+ `export function alpha() { return { alpha: true }; }\nexport function beta() { return { beta: true }; }\n`,
202
+ );
203
+
204
+ writeTempYaml(
205
+ join('bindings', 'alpha.binding.yaml'),
206
+ `bindings:\n - module_id: "dir.alpha"\n target: "${modPath}:alpha"\n`,
207
+ );
208
+ writeTempYaml(
209
+ join('bindings', 'beta.binding.yaml'),
210
+ `bindings:\n - module_id: "dir.beta"\n target: "${modPath}:beta"\n`,
211
+ );
212
+
213
+ const results = await loader.loadBindingDir(bindDir, registry);
214
+ expect(results).toHaveLength(2);
215
+ expect(registry.has('dir.alpha')).toBe(true);
216
+ expect(registry.has('dir.beta')).toBe(true);
217
+ });
218
+
219
+ it('returns empty array for directory with no binding files', async () => {
220
+ const emptyDir = join(tmpDir, 'empty');
221
+ mkdirSync(emptyDir);
222
+
223
+ const results = await loader.loadBindingDir(emptyDir, registry);
224
+ expect(results).toHaveLength(0);
225
+ });
226
+ });
227
+ });
@@ -0,0 +1,76 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { Config } from '../src/config.js';
3
+
4
+ describe('Config', () => {
5
+ it('creates with provided data', () => {
6
+ const cfg = new Config({ name: 'test' });
7
+ expect(cfg.get('name')).toBe('test');
8
+ });
9
+
10
+ it('creates with no arguments', () => {
11
+ const cfg = new Config();
12
+ expect(cfg.get('anything')).toBeUndefined();
13
+ });
14
+
15
+ it('returns various value types', () => {
16
+ const cfg = new Config({
17
+ str: 'hello',
18
+ num: 42,
19
+ bool: true,
20
+ arr: [1, 2, 3],
21
+ obj: { nested: true },
22
+ nil: null,
23
+ });
24
+ expect(cfg.get('str')).toBe('hello');
25
+ expect(cfg.get('num')).toBe(42);
26
+ expect(cfg.get('bool')).toBe(true);
27
+ expect(cfg.get('arr')).toEqual([1, 2, 3]);
28
+ expect(cfg.get('obj')).toEqual({ nested: true });
29
+ expect(cfg.get('nil')).toBeNull();
30
+ });
31
+
32
+ it('traverses nested objects with dot-path', () => {
33
+ const cfg = new Config({
34
+ database: {
35
+ host: 'db.example.com',
36
+ port: 5432,
37
+ credentials: { user: 'admin', password: 'secret' },
38
+ },
39
+ });
40
+ expect(cfg.get('database.host')).toBe('db.example.com');
41
+ expect(cfg.get('database.port')).toBe(5432);
42
+ expect(cfg.get('database.credentials.user')).toBe('admin');
43
+ });
44
+
45
+ it('returns nested object for partial path', () => {
46
+ const cfg = new Config({ a: { b: { c: 'deep' } } });
47
+ expect(cfg.get('a.b')).toEqual({ c: 'deep' });
48
+ });
49
+
50
+ it('returns undefined when key missing and no default', () => {
51
+ const cfg = new Config({ x: 1 });
52
+ expect(cfg.get('y')).toBeUndefined();
53
+ });
54
+
55
+ it('returns default value when key missing', () => {
56
+ const cfg = new Config({ x: 1 });
57
+ expect(cfg.get('y', 'fallback')).toBe('fallback');
58
+ expect(cfg.get('y', 42)).toBe(42);
59
+ });
60
+
61
+ it('returns default when dot-path partially exists', () => {
62
+ const cfg = new Config({ a: { b: 1 } });
63
+ expect(cfg.get('a.c', 'default')).toBe('default');
64
+ expect(cfg.get('a.b.c.d', 'deep-default')).toBe('deep-default');
65
+ });
66
+
67
+ it('returns default when traversal hits non-object', () => {
68
+ const cfg = new Config({ a: 'string-value' });
69
+ expect(cfg.get('a.b', 'default')).toBe('default');
70
+ });
71
+
72
+ it('returns default when traversal hits null', () => {
73
+ const cfg = new Config({ a: null });
74
+ expect(cfg.get('a.b', 'default')).toBe('default');
75
+ });
76
+ });
@@ -0,0 +1,151 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { Context, createIdentity } from '../src/context.js';
3
+ import type { Identity } from '../src/context.js';
4
+
5
+ describe('createIdentity', () => {
6
+ it('creates identity with defaults', () => {
7
+ const id = createIdentity('user1');
8
+ expect(id.id).toBe('user1');
9
+ expect(id.type).toBe('user');
10
+ expect(id.roles).toEqual([]);
11
+ expect(id.attrs).toEqual({});
12
+ });
13
+
14
+ it('creates identity with all fields', () => {
15
+ const id = createIdentity('admin1', 'admin', ['superuser'], { org: 'acme' });
16
+ expect(id.id).toBe('admin1');
17
+ expect(id.type).toBe('admin');
18
+ expect(id.roles).toEqual(['superuser']);
19
+ expect(id.attrs).toEqual({ org: 'acme' });
20
+ });
21
+
22
+ it('returns frozen object', () => {
23
+ const id = createIdentity('u1');
24
+ expect(Object.isFrozen(id)).toBe(true);
25
+ expect(Object.isFrozen(id.roles)).toBe(true);
26
+ expect(Object.isFrozen(id.attrs)).toBe(true);
27
+ });
28
+ });
29
+
30
+ describe('Context.create()', () => {
31
+ it('creates context with unique traceId', () => {
32
+ const ctx1 = Context.create();
33
+ const ctx2 = Context.create();
34
+ expect(ctx1.traceId).toBeDefined();
35
+ expect(ctx2.traceId).toBeDefined();
36
+ expect(ctx1.traceId).not.toBe(ctx2.traceId);
37
+ });
38
+
39
+ it('has null callerId by default', () => {
40
+ const ctx = Context.create();
41
+ expect(ctx.callerId).toBeNull();
42
+ });
43
+
44
+ it('has empty callChain by default', () => {
45
+ const ctx = Context.create();
46
+ expect(ctx.callChain).toEqual([]);
47
+ });
48
+
49
+ it('accepts executor and identity', () => {
50
+ const identity = createIdentity('u1', 'admin');
51
+ const executor = { name: 'test-executor' };
52
+ const ctx = Context.create(executor, identity);
53
+ expect(ctx.executor).toBe(executor);
54
+ expect(ctx.identity).toBe(identity);
55
+ });
56
+
57
+ it('defaults identity to null', () => {
58
+ const ctx = Context.create();
59
+ expect(ctx.identity).toBeNull();
60
+ });
61
+
62
+ it('defaults executor to null', () => {
63
+ const ctx = Context.create();
64
+ expect(ctx.executor).toBeNull();
65
+ });
66
+
67
+ it('defaults data to empty object', () => {
68
+ const ctx = Context.create();
69
+ expect(ctx.data).toEqual({});
70
+ });
71
+
72
+ it('accepts custom data', () => {
73
+ const ctx = Context.create(null, null, { key: 'value' });
74
+ expect(ctx.data).toEqual({ key: 'value' });
75
+ });
76
+
77
+ it('has null redactedInputs by default', () => {
78
+ const ctx = Context.create();
79
+ expect(ctx.redactedInputs).toBeNull();
80
+ });
81
+ });
82
+
83
+ describe('Context.child()', () => {
84
+ it('preserves traceId from parent', () => {
85
+ const parent = Context.create();
86
+ const child = parent.child('module.a');
87
+ expect(child.traceId).toBe(parent.traceId);
88
+ });
89
+
90
+ it('sets callerId to null when parent callChain is empty', () => {
91
+ const parent = Context.create();
92
+ const child = parent.child('module.a');
93
+ expect(child.callerId).toBeNull();
94
+ });
95
+
96
+ it('sets callerId to last element of parent callChain', () => {
97
+ const parent = Context.create();
98
+ const child1 = parent.child('module.a');
99
+ expect(child1.callChain).toEqual(['module.a']);
100
+
101
+ const child2 = child1.child('module.b');
102
+ expect(child2.callerId).toBe('module.a');
103
+ expect(child2.callChain).toEqual(['module.a', 'module.b']);
104
+ });
105
+
106
+ it('builds up callChain through multiple levels', () => {
107
+ const root = Context.create();
108
+ const c1 = root.child('a');
109
+ const c2 = c1.child('b');
110
+ const c3 = c2.child('c');
111
+ expect(c3.callChain).toEqual(['a', 'b', 'c']);
112
+ expect(c3.callerId).toBe('b');
113
+ });
114
+
115
+ it('shares data reference with parent', () => {
116
+ const parent = Context.create(null, null, { shared: true });
117
+ const child = parent.child('mod');
118
+ expect(child.data).toBe(parent.data);
119
+
120
+ child.data['newKey'] = 'newValue';
121
+ expect(parent.data['newKey']).toBe('newValue');
122
+ });
123
+
124
+ it('preserves executor from parent', () => {
125
+ const executor = { id: 'exec' };
126
+ const parent = Context.create(executor);
127
+ const child = parent.child('mod');
128
+ expect(child.executor).toBe(executor);
129
+ });
130
+
131
+ it('preserves identity from parent', () => {
132
+ const identity = createIdentity('u1', 'admin', ['role1']);
133
+ const parent = Context.create(null, identity);
134
+ const child = parent.child('mod');
135
+ expect(child.identity).toBe(identity);
136
+ });
137
+
138
+ it('resets redactedInputs to null', () => {
139
+ const parent = Context.create();
140
+ parent.redactedInputs = { field: '***' };
141
+ const child = parent.child('mod');
142
+ expect(child.redactedInputs).toBeNull();
143
+ });
144
+
145
+ it('does not modify parent callChain', () => {
146
+ const parent = Context.create();
147
+ const chainBefore = [...parent.callChain];
148
+ parent.child('mod.a');
149
+ expect(parent.callChain).toEqual(chainBefore);
150
+ });
151
+ });
@@ -0,0 +1,173 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { Type } from '@sinclair/typebox';
3
+ import { FunctionModule, module, normalizeResult, makeAutoId } from '../src/decorator.js';
4
+ import { Context, createIdentity } from '../src/context.js';
5
+ import { Registry } from '../src/registry/registry.js';
6
+
7
+ const inputSchema = Type.Object({ name: Type.String() });
8
+ const outputSchema = Type.Object({ greeting: Type.String() });
9
+
10
+ describe('FunctionModule', () => {
11
+ it('wraps an execute function', async () => {
12
+ const fm = new FunctionModule({
13
+ execute: (inputs) => ({ greeting: `Hello, ${inputs['name']}!` }),
14
+ moduleId: 'greet.hello',
15
+ inputSchema,
16
+ outputSchema,
17
+ description: 'Says hello',
18
+ });
19
+ const ctx = Context.create(null, createIdentity('test-user'));
20
+ const result = await fm.execute({ name: 'World' }, ctx);
21
+ expect(result).toEqual({ greeting: 'Hello, World!' });
22
+ });
23
+
24
+ it('exposes correct properties', () => {
25
+ const fm = new FunctionModule({
26
+ execute: () => ({}),
27
+ moduleId: 'test.props',
28
+ inputSchema,
29
+ outputSchema,
30
+ description: 'Test props',
31
+ documentation: 'Some docs',
32
+ tags: ['tag1', 'tag2'],
33
+ version: '2.0.0',
34
+ });
35
+ expect(fm.moduleId).toBe('test.props');
36
+ expect(fm.description).toBe('Test props');
37
+ expect(fm.documentation).toBe('Some docs');
38
+ expect(fm.tags).toEqual(['tag1', 'tag2']);
39
+ expect(fm.version).toBe('2.0.0');
40
+ });
41
+
42
+ it('uses sensible defaults', () => {
43
+ const fm = new FunctionModule({
44
+ execute: () => ({}),
45
+ moduleId: 'test.defaults',
46
+ inputSchema,
47
+ outputSchema,
48
+ });
49
+ expect(fm.description).toBe('Module test.defaults');
50
+ expect(fm.documentation).toBeNull();
51
+ expect(fm.tags).toBeNull();
52
+ expect(fm.version).toBe('1.0.0');
53
+ expect(fm.annotations).toBeNull();
54
+ expect(fm.metadata).toBeNull();
55
+ expect(fm.examples).toBeNull();
56
+ });
57
+
58
+ it('normalizes null return value', async () => {
59
+ const fm = new FunctionModule({
60
+ execute: () => null as unknown as Record<string, unknown>,
61
+ moduleId: 'test.normalize',
62
+ inputSchema,
63
+ outputSchema,
64
+ });
65
+ const ctx = Context.create(null, createIdentity('test-user'));
66
+ const result = await fm.execute({}, ctx);
67
+ expect(result).toEqual({});
68
+ });
69
+ });
70
+
71
+ describe('normalizeResult', () => {
72
+ it('null returns empty object', () => {
73
+ expect(normalizeResult(null)).toEqual({});
74
+ });
75
+
76
+ it('undefined returns empty object', () => {
77
+ expect(normalizeResult(undefined)).toEqual({});
78
+ });
79
+
80
+ it('plain object passes through', () => {
81
+ const obj = { a: 1, b: 'two' };
82
+ expect(normalizeResult(obj)).toBe(obj);
83
+ });
84
+
85
+ it('string is wrapped in { result }', () => {
86
+ expect(normalizeResult('hello')).toEqual({ result: 'hello' });
87
+ });
88
+
89
+ it('number is wrapped in { result }', () => {
90
+ expect(normalizeResult(42)).toEqual({ result: 42 });
91
+ });
92
+
93
+ it('boolean is wrapped in { result }', () => {
94
+ expect(normalizeResult(true)).toEqual({ result: true });
95
+ });
96
+
97
+ it('array is wrapped in { result }', () => {
98
+ expect(normalizeResult([1, 2, 3])).toEqual({ result: [1, 2, 3] });
99
+ });
100
+ });
101
+
102
+ describe('module() factory', () => {
103
+ it('creates FunctionModule with correct properties', () => {
104
+ const fm = module({
105
+ id: 'factory.test',
106
+ inputSchema,
107
+ outputSchema,
108
+ description: 'Factory module',
109
+ execute: () => ({ greeting: 'hi' }),
110
+ });
111
+ expect(fm).toBeInstanceOf(FunctionModule);
112
+ expect(fm.moduleId).toBe('factory.test');
113
+ expect(fm.description).toBe('Factory module');
114
+ });
115
+
116
+ it('generates auto ID when not provided', () => {
117
+ const fm = module({
118
+ inputSchema,
119
+ outputSchema,
120
+ execute: () => ({}),
121
+ });
122
+ expect(fm.moduleId).toBe('anonymous');
123
+ });
124
+
125
+ it('passes through optional fields', () => {
126
+ const fm = module({
127
+ id: 'opts.check',
128
+ inputSchema,
129
+ outputSchema,
130
+ description: 'desc',
131
+ documentation: 'docs here',
132
+ tags: ['t1'],
133
+ version: '3.0.0',
134
+ metadata: { key: 'val' },
135
+ execute: () => ({}),
136
+ });
137
+ expect(fm.documentation).toBe('docs here');
138
+ expect(fm.tags).toEqual(['t1']);
139
+ expect(fm.version).toBe('3.0.0');
140
+ expect(fm.metadata).toEqual({ key: 'val' });
141
+ });
142
+
143
+ it('auto-registers with registry', () => {
144
+ const registry = new Registry();
145
+ const fm = module({
146
+ id: 'auto.registered',
147
+ inputSchema,
148
+ outputSchema,
149
+ execute: () => ({ ok: true }),
150
+ registry,
151
+ });
152
+ expect(registry.has('auto.registered')).toBe(true);
153
+ expect(registry.get('auto.registered')).toBe(fm);
154
+ });
155
+ });
156
+
157
+ describe('makeAutoId', () => {
158
+ it('lowercases and replaces non-alphanumeric', () => {
159
+ expect(makeAutoId('Hello World')).toBe('hello_world');
160
+ });
161
+
162
+ it('preserves dots', () => {
163
+ expect(makeAutoId('my.module.name')).toBe('my.module.name');
164
+ });
165
+
166
+ it('prefixes digit-leading segments', () => {
167
+ expect(makeAutoId('2fast.4you')).toBe('_2fast._4you');
168
+ });
169
+
170
+ it('handles valid IDs unchanged', () => {
171
+ expect(makeAutoId('valid_id')).toBe('valid_id');
172
+ });
173
+ });