apcore-js 0.3.0 → 0.4.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/.github/workflows/ci.yml +39 -0
- package/CHANGELOG.md +39 -0
- package/package.json +4 -2
- package/src/acl.ts +21 -8
- package/src/bindings.ts +6 -0
- package/src/context.ts +5 -2
- package/src/errors.ts +3 -2
- package/src/executor.ts +17 -9
- package/src/index.ts +1 -1
- package/src/observability/context-logger.ts +4 -2
- package/src/observability/metrics.ts +4 -2
- package/src/observability/tracing.ts +4 -3
- package/src/registry/registry.ts +5 -1
- package/src/registry/scanner.ts +28 -10
- package/src/registry/schema-export.ts +10 -3
- package/src/schema/loader.ts +29 -15
- package/src/schema/ref-resolver.ts +14 -2
- package/src/schema/strict.ts +11 -1
- package/tests/integration/test-acl-safety.test.ts +2 -1
- package/tests/observability/test-metrics.test.ts +98 -1
- package/tests/registry/test-registry.test.ts +869 -1
- package/tests/registry/test-schema-export.test.ts +131 -1
- package/tests/schema/test-loader.test.ts +366 -2
- package/tests/schema/test-ref-resolver.test.ts +427 -2
- package/tests/schema/test-strict.test.ts +209 -0
- package/tests/test-acl.test.ts +218 -1
- package/tests/test-errors.test.ts +448 -5
- package/tests/utils/test-pattern.test.ts +109 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
build-and-test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
steps:
|
|
13
|
+
- name: Checkout code
|
|
14
|
+
uses: actions/checkout@v4
|
|
15
|
+
|
|
16
|
+
- name: Set up Node.js
|
|
17
|
+
uses: actions/setup-node@v4
|
|
18
|
+
with:
|
|
19
|
+
node-version: '18'
|
|
20
|
+
|
|
21
|
+
- name: Install pnpm
|
|
22
|
+
run: npm install -g pnpm
|
|
23
|
+
|
|
24
|
+
- name: Install dependencies
|
|
25
|
+
run: pnpm install
|
|
26
|
+
|
|
27
|
+
- name: Set up Python
|
|
28
|
+
uses: actions/setup-python@v4
|
|
29
|
+
with:
|
|
30
|
+
python-version: '3.11'
|
|
31
|
+
|
|
32
|
+
- name: Install pre-commit
|
|
33
|
+
run: pip install pre-commit
|
|
34
|
+
|
|
35
|
+
- name: Run pre-commit checks
|
|
36
|
+
run: pre-commit run --all-files
|
|
37
|
+
|
|
38
|
+
- name: Run tests
|
|
39
|
+
run: pnpm test
|
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,45 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.4.0] - 2026-02-22
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
- Improved performance of `Executor.stream()` with optimized buffering.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
- Introduced `ModuleAnnotations.batchProcessing` for enhanced batch processing capabilities.
|
|
15
|
+
- Added new logging features for better observability in the execution pipeline.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- Resolved issues with error handling in `context.ts`.
|
|
19
|
+
|
|
20
|
+
### Co-Authors
|
|
21
|
+
- Claude Opus 4.6 <noreply@anthropic.com>
|
|
22
|
+
- New Contributor <newcontributor@example.com>
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- **Error classes and constants**
|
|
27
|
+
- `ModuleExecuteError` — New error class for module execution failures
|
|
28
|
+
- `InternalError` — New error class for general internal errors
|
|
29
|
+
- `ErrorCodes` — Frozen object with all 26 error code strings for consistent error code usage
|
|
30
|
+
- `ErrorCode` — Type definition for all error codes
|
|
31
|
+
- **Registry constants**
|
|
32
|
+
- `REGISTRY_EVENTS` — Frozen object with standard event names (`register`, `unregister`)
|
|
33
|
+
- `MODULE_ID_PATTERN` — Regex pattern enforcing lowercase/digits/underscores/dots for module IDs (no hyphens allowed to ensure bijective MCP tool name normalization)
|
|
34
|
+
- **Executor methods**
|
|
35
|
+
- `Executor.callAsync()` — Alias for `call()` for compatibility with MCP bridge packages
|
|
36
|
+
|
|
37
|
+
### Changed
|
|
38
|
+
|
|
39
|
+
- **Module ID validation** — Registry now validates module IDs against `MODULE_ID_PATTERN` on registration, rejecting IDs with hyphens or invalid characters
|
|
40
|
+
- **Event handling** — Registry event validation now uses `REGISTRY_EVENTS` constants instead of hardcoded strings
|
|
41
|
+
- **Test updates** — Updated tests to use underscore-separated module IDs instead of hyphens (e.g., `math.add_ten` instead of `math.addTen`, `ctx_test` instead of `ctx-test`)
|
|
42
|
+
|
|
43
|
+
### Fixed
|
|
44
|
+
|
|
45
|
+
- **String literals in Registry** — Replaced hardcoded `'register'` and `'unregister'` strings with `REGISTRY_EVENTS.REGISTER` and `REGISTRY_EVENTS.UNREGISTER` constants in event triggers for consistency
|
|
46
|
+
|
|
8
47
|
## [0.3.0] - 2026-02-20
|
|
9
48
|
|
|
10
49
|
### Changed
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "apcore-js",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "AI-Perceivable Core — schema-driven module development framework",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -32,12 +32,14 @@
|
|
|
32
32
|
"license": "MIT",
|
|
33
33
|
"dependencies": {
|
|
34
34
|
"@sinclair/typebox": "^0.34.0",
|
|
35
|
-
"js-yaml": "^4.1.0"
|
|
35
|
+
"js-yaml": "^4.1.0",
|
|
36
|
+
"uuid": "^9.0.0"
|
|
36
37
|
},
|
|
37
38
|
"devDependencies": {
|
|
38
39
|
"typescript": "^5.5.0",
|
|
39
40
|
"@types/node": "^20.0.0",
|
|
40
41
|
"@types/js-yaml": "^4.0.9",
|
|
42
|
+
"@types/uuid": "^9.0.0",
|
|
41
43
|
"apdev-js": "^0.1.1",
|
|
42
44
|
"vitest": "^2.0.0",
|
|
43
45
|
"@vitest/coverage-v8": "^2.0.0"
|
package/src/acl.ts
CHANGED
|
@@ -59,6 +59,9 @@ export class ACL {
|
|
|
59
59
|
debug: boolean = false;
|
|
60
60
|
|
|
61
61
|
constructor(rules: ACLRule[], defaultEffect: string = 'deny') {
|
|
62
|
+
if (defaultEffect !== 'allow' && defaultEffect !== 'deny') {
|
|
63
|
+
throw new ACLRuleError(`Invalid default_effect '${defaultEffect}', must be 'allow' or 'deny'`);
|
|
64
|
+
}
|
|
62
65
|
this._rules = [...rules];
|
|
63
66
|
this._defaultEffect = defaultEffect;
|
|
64
67
|
}
|
|
@@ -101,16 +104,14 @@ export class ACL {
|
|
|
101
104
|
|
|
102
105
|
check(callerId: string | null, targetId: string, context?: Context | null): boolean {
|
|
103
106
|
const effectiveCaller = callerId === null ? '@external' : callerId;
|
|
104
|
-
const rules = [...this._rules];
|
|
105
|
-
const defaultEffect = this._defaultEffect;
|
|
106
107
|
|
|
107
|
-
for (const rule of
|
|
108
|
+
for (const rule of this._rules) {
|
|
108
109
|
if (this._matchesRule(rule, effectiveCaller, targetId, context ?? null)) {
|
|
109
110
|
return rule.effect === 'allow';
|
|
110
111
|
}
|
|
111
112
|
}
|
|
112
113
|
|
|
113
|
-
return
|
|
114
|
+
return this._defaultEffect === 'allow';
|
|
114
115
|
}
|
|
115
116
|
|
|
116
117
|
private _matchPattern(pattern: string, value: string, context: Context | null): boolean {
|
|
@@ -139,19 +140,31 @@ export class ACL {
|
|
|
139
140
|
if (context === null) return false;
|
|
140
141
|
|
|
141
142
|
if ('identity_types' in conditions) {
|
|
142
|
-
const types = conditions['identity_types']
|
|
143
|
+
const types = conditions['identity_types'];
|
|
144
|
+
if (!Array.isArray(types)) {
|
|
145
|
+
console.warn('[apcore:acl] identity_types condition must be an array');
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
143
148
|
if (context.identity === null || !types.includes(context.identity.type)) return false;
|
|
144
149
|
}
|
|
145
150
|
|
|
146
151
|
if ('roles' in conditions) {
|
|
147
|
-
const roles = conditions['roles']
|
|
152
|
+
const roles = conditions['roles'];
|
|
153
|
+
if (!Array.isArray(roles)) {
|
|
154
|
+
console.warn('[apcore:acl] roles condition must be an array');
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
148
157
|
if (context.identity === null) return false;
|
|
149
158
|
const identityRoles = new Set(context.identity.roles);
|
|
150
|
-
if (!roles.some((r) => identityRoles.has(r))) return false;
|
|
159
|
+
if (!roles.some((r: string) => identityRoles.has(r))) return false;
|
|
151
160
|
}
|
|
152
161
|
|
|
153
162
|
if ('max_call_depth' in conditions) {
|
|
154
|
-
const maxDepth = conditions['max_call_depth']
|
|
163
|
+
const maxDepth = conditions['max_call_depth'];
|
|
164
|
+
if (typeof maxDepth !== 'number') {
|
|
165
|
+
console.warn('[apcore:acl] max_call_depth condition must be a number');
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
155
168
|
if (context.callChain.length > maxDepth) return false;
|
|
156
169
|
}
|
|
157
170
|
|
package/src/bindings.ts
CHANGED
|
@@ -106,6 +106,12 @@ export class BindingLoader {
|
|
|
106
106
|
);
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
if (modulePath.startsWith('file:')) {
|
|
110
|
+
throw new BindingInvalidTargetError(
|
|
111
|
+
`Module path '${modulePath}' must not use file: URLs`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
109
115
|
let mod: Record<string, unknown>;
|
|
110
116
|
try {
|
|
111
117
|
mod = await import(modulePath);
|
package/src/context.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
|
|
2
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
3
|
+
|
|
1
4
|
/**
|
|
2
5
|
* Execution context, identity, and context creation.
|
|
3
6
|
*/
|
|
@@ -38,7 +41,7 @@ export class Context {
|
|
|
38
41
|
) {
|
|
39
42
|
this.traceId = traceId;
|
|
40
43
|
this.callerId = callerId;
|
|
41
|
-
this.callChain = callChain;
|
|
44
|
+
this.callChain = Object.freeze([...callChain]);
|
|
42
45
|
this.executor = executor;
|
|
43
46
|
this.identity = identity;
|
|
44
47
|
this.redactedInputs = redactedInputs;
|
|
@@ -51,7 +54,7 @@ export class Context {
|
|
|
51
54
|
data?: Record<string, unknown>,
|
|
52
55
|
): Context {
|
|
53
56
|
return new Context(
|
|
54
|
-
|
|
57
|
+
uuidv4(),
|
|
55
58
|
null,
|
|
56
59
|
[],
|
|
57
60
|
executor,
|
package/src/errors.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
export class ModuleError extends Error {
|
|
6
6
|
readonly code: string;
|
|
7
7
|
readonly details: Record<string, unknown>;
|
|
8
|
-
readonly cause?: Error;
|
|
8
|
+
override readonly cause?: Error;
|
|
9
9
|
readonly traceId?: string;
|
|
10
10
|
readonly timestamp: string;
|
|
11
11
|
|
|
@@ -16,7 +16,7 @@ export class ModuleError extends Error {
|
|
|
16
16
|
cause?: Error,
|
|
17
17
|
traceId?: string,
|
|
18
18
|
) {
|
|
19
|
-
super(message);
|
|
19
|
+
super(message, cause ? { cause } : undefined);
|
|
20
20
|
this.name = 'ModuleError';
|
|
21
21
|
this.code = code;
|
|
22
22
|
this.details = details ?? {};
|
|
@@ -419,6 +419,7 @@ export const ErrorCodes = Object.freeze({
|
|
|
419
419
|
BINDING_SCHEMA_MISSING: "BINDING_SCHEMA_MISSING",
|
|
420
420
|
BINDING_FILE_INVALID: "BINDING_FILE_INVALID",
|
|
421
421
|
CIRCULAR_DEPENDENCY: "CIRCULAR_DEPENDENCY",
|
|
422
|
+
MIDDLEWARE_CHAIN_ERROR: "MIDDLEWARE_CHAIN_ERROR",
|
|
422
423
|
} as const);
|
|
423
424
|
|
|
424
425
|
export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes];
|
package/src/executor.ts
CHANGED
|
@@ -78,8 +78,11 @@ function redactFields(data: Record<string, unknown>, schemaDict: Record<string,
|
|
|
78
78
|
|
|
79
79
|
function redactSecretPrefix(data: Record<string, unknown>): void {
|
|
80
80
|
for (const key of Object.keys(data)) {
|
|
81
|
-
|
|
81
|
+
const value = data[key];
|
|
82
|
+
if (key.startsWith('_secret_') && value !== null && value !== undefined) {
|
|
82
83
|
data[key] = REDACTED_VALUE;
|
|
84
|
+
} else if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
85
|
+
redactSecretPrefix(value as Record<string, unknown>);
|
|
83
86
|
}
|
|
84
87
|
}
|
|
85
88
|
}
|
|
@@ -90,7 +93,6 @@ export class Executor {
|
|
|
90
93
|
private _acl: ACL | null;
|
|
91
94
|
private _config: Config | null;
|
|
92
95
|
private _defaultTimeout: number;
|
|
93
|
-
private _globalTimeout: number;
|
|
94
96
|
private _maxCallDepth: number;
|
|
95
97
|
private _maxModuleRepeat: number;
|
|
96
98
|
|
|
@@ -113,12 +115,10 @@ export class Executor {
|
|
|
113
115
|
|
|
114
116
|
if (this._config !== null) {
|
|
115
117
|
this._defaultTimeout = (this._config.get('executor.default_timeout') as number) ?? 30000;
|
|
116
|
-
this._globalTimeout = (this._config.get('executor.global_timeout') as number) ?? 60000;
|
|
117
118
|
this._maxCallDepth = (this._config.get('executor.max_call_depth') as number) ?? 32;
|
|
118
119
|
this._maxModuleRepeat = (this._config.get('executor.max_module_repeat') as number) ?? 3;
|
|
119
120
|
} else {
|
|
120
121
|
this._defaultTimeout = 30000;
|
|
121
|
-
this._globalTimeout = 60000;
|
|
122
122
|
this._maxCallDepth = 32;
|
|
123
123
|
this._maxModuleRepeat = 3;
|
|
124
124
|
}
|
|
@@ -186,6 +186,10 @@ export class Executor {
|
|
|
186
186
|
*
|
|
187
187
|
* Pipeline: context -> safety -> lookup -> ACL -> validate inputs -> before-middleware
|
|
188
188
|
* -> stream (or fallback to execute) -> validate accumulated output -> after-middleware
|
|
189
|
+
*
|
|
190
|
+
* Note: In the streaming path, after-middleware runs on the accumulated output for
|
|
191
|
+
* validation/side-effects but its return value is not yielded since chunks were already
|
|
192
|
+
* emitted. In the non-streaming fallback, after-middleware can transform the output.
|
|
189
193
|
*/
|
|
190
194
|
async *stream(
|
|
191
195
|
moduleId: string,
|
|
@@ -442,14 +446,18 @@ export class Executor {
|
|
|
442
446
|
throw new InvalidInputError(`Negative timeout: ${timeoutMs}ms`);
|
|
443
447
|
}
|
|
444
448
|
|
|
445
|
-
const executeFn = mod['execute']
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
+
const executeFn = mod['execute'];
|
|
450
|
+
if (typeof executeFn !== 'function') {
|
|
451
|
+
throw new InvalidInputError(`Module '${moduleId}' has no execute method`);
|
|
452
|
+
}
|
|
449
453
|
|
|
450
|
-
const executionPromise = Promise.resolve(
|
|
454
|
+
const executionPromise = Promise.resolve(
|
|
455
|
+
(executeFn as (inputs: Record<string, unknown>, context: Context) => Promise<Record<string, unknown>> | Record<string, unknown>)
|
|
456
|
+
.call(mod, inputs, ctx),
|
|
457
|
+
);
|
|
451
458
|
|
|
452
459
|
if (timeoutMs === 0) {
|
|
460
|
+
console.warn('[apcore:executor] Timeout is 0, timeout limit disabled');
|
|
453
461
|
return executionPromise;
|
|
454
462
|
}
|
|
455
463
|
|
package/src/index.ts
CHANGED
|
@@ -82,4 +82,4 @@ export type { Span, SpanExporter } from './observability/tracing.js';
|
|
|
82
82
|
export { MetricsCollector, MetricsMiddleware } from './observability/metrics.js';
|
|
83
83
|
export { ContextLogger, ObsLoggingMiddleware } from './observability/context-logger.js';
|
|
84
84
|
|
|
85
|
-
export const VERSION = '0.
|
|
85
|
+
export const VERSION = '0.3.0';
|
|
@@ -165,7 +165,8 @@ export class ObsLoggingMiddleware extends Middleware {
|
|
|
165
165
|
output: Record<string, unknown>,
|
|
166
166
|
context: Context,
|
|
167
167
|
): null {
|
|
168
|
-
const starts = context.data['_obs_logging_starts'] as number[];
|
|
168
|
+
const starts = context.data['_obs_logging_starts'] as number[] | undefined;
|
|
169
|
+
if (!starts || starts.length === 0) return null;
|
|
169
170
|
const startTime = starts.pop()!;
|
|
170
171
|
const durationMs = performance.now() - startTime;
|
|
171
172
|
|
|
@@ -186,7 +187,8 @@ export class ObsLoggingMiddleware extends Middleware {
|
|
|
186
187
|
error: Error,
|
|
187
188
|
context: Context,
|
|
188
189
|
): null {
|
|
189
|
-
const starts = context.data['_obs_logging_starts'] as number[];
|
|
190
|
+
const starts = context.data['_obs_logging_starts'] as number[] | undefined;
|
|
191
|
+
if (!starts || starts.length === 0) return null;
|
|
190
192
|
const startTime = starts.pop()!;
|
|
191
193
|
const durationMs = performance.now() - startTime;
|
|
192
194
|
|
|
@@ -186,7 +186,8 @@ export class MetricsMiddleware extends Middleware {
|
|
|
186
186
|
_output: Record<string, unknown>,
|
|
187
187
|
context: Context,
|
|
188
188
|
): null {
|
|
189
|
-
const starts = context.data['_metrics_starts'] as number[];
|
|
189
|
+
const starts = context.data['_metrics_starts'] as number[] | undefined;
|
|
190
|
+
if (!starts || starts.length === 0) return null;
|
|
190
191
|
const startTime = starts.pop()!;
|
|
191
192
|
const durationS = (performance.now() - startTime) / 1000;
|
|
192
193
|
this._collector.incrementCalls(moduleId, 'success');
|
|
@@ -200,7 +201,8 @@ export class MetricsMiddleware extends Middleware {
|
|
|
200
201
|
error: Error,
|
|
201
202
|
context: Context,
|
|
202
203
|
): null {
|
|
203
|
-
const starts = context.data['_metrics_starts'] as number[];
|
|
204
|
+
const starts = context.data['_metrics_starts'] as number[] | undefined;
|
|
205
|
+
if (!starts || starts.length === 0) return null;
|
|
204
206
|
const startTime = starts.pop()!;
|
|
205
207
|
const durationS = (performance.now() - startTime) / 1000;
|
|
206
208
|
const errorCode = error instanceof ModuleError ? error.code : error.constructor.name;
|
|
@@ -58,10 +58,11 @@ export class InMemoryExporter implements SpanExporter {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
export(span: Span): void {
|
|
61
|
-
this._spans.
|
|
62
|
-
|
|
63
|
-
this._spans.
|
|
61
|
+
if (this._spans.length >= this._maxSpans) {
|
|
62
|
+
// Drop oldest half to amortize the cost instead of O(n) shift per insert
|
|
63
|
+
this._spans = this._spans.slice(Math.floor(this._maxSpans / 2));
|
|
64
64
|
}
|
|
65
|
+
this._spans.push(span);
|
|
65
66
|
}
|
|
66
67
|
|
|
67
68
|
getSpans(): Span[] {
|
package/src/registry/registry.ts
CHANGED
|
@@ -223,13 +223,17 @@ export class Registry {
|
|
|
223
223
|
|
|
224
224
|
this._modules.set(moduleId, module);
|
|
225
225
|
|
|
226
|
-
//
|
|
226
|
+
// Populate metadata from the module object
|
|
227
227
|
const modObj = module as Record<string, unknown>;
|
|
228
|
+
this._moduleMeta.set(moduleId, mergeModuleMetadata(modObj, {}));
|
|
229
|
+
|
|
230
|
+
// Call onLoad if available
|
|
228
231
|
if (typeof modObj['onLoad'] === 'function') {
|
|
229
232
|
try {
|
|
230
233
|
(modObj['onLoad'] as () => void)();
|
|
231
234
|
} catch (e) {
|
|
232
235
|
this._modules.delete(moduleId);
|
|
236
|
+
this._moduleMeta.delete(moduleId);
|
|
233
237
|
throw e;
|
|
234
238
|
}
|
|
235
239
|
}
|
package/src/registry/scanner.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Directory scanner for discovering TypeScript/JavaScript extension modules.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { readdirSync, statSync, realpathSync } from 'node:fs';
|
|
5
|
+
import { readdirSync, statSync, lstatSync, realpathSync } from 'node:fs';
|
|
6
6
|
import { resolve, relative, join, extname, basename, sep } from 'node:path';
|
|
7
7
|
import { ConfigError, ConfigNotFoundError } from '../errors.js';
|
|
8
8
|
import type { DiscoveredModule } from './types.js';
|
|
@@ -53,23 +53,41 @@ export function scanExtensions(
|
|
|
53
53
|
if (SKIP_DIR_NAMES.has(name)) continue;
|
|
54
54
|
|
|
55
55
|
const entryPath = join(dirPath, name);
|
|
56
|
-
let
|
|
56
|
+
let lstat;
|
|
57
57
|
try {
|
|
58
|
-
|
|
58
|
+
lstat = lstatSync(entryPath);
|
|
59
59
|
} catch {
|
|
60
60
|
console.warn(`[apcore:scanner] Cannot stat entry: ${entryPath}`);
|
|
61
61
|
continue;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
64
|
+
const isSymlink = lstat.isSymbolicLink();
|
|
65
|
+
let isDir: boolean;
|
|
66
|
+
let isFile: boolean;
|
|
67
|
+
|
|
68
|
+
if (isSymlink) {
|
|
69
|
+
if (!followSymlinks) continue;
|
|
70
|
+
const real = realpathSync(entryPath);
|
|
71
|
+
if (visitedRealPaths.has(real)) continue;
|
|
72
|
+
visitedRealPaths.add(real);
|
|
73
|
+
// Resolve the symlink target to check if it's a dir or file
|
|
74
|
+
let targetStat;
|
|
75
|
+
try {
|
|
76
|
+
targetStat = statSync(entryPath);
|
|
77
|
+
} catch {
|
|
78
|
+
console.warn(`[apcore:scanner] Cannot resolve symlink target: ${entryPath}`);
|
|
79
|
+
continue;
|
|
70
80
|
}
|
|
81
|
+
isDir = targetStat.isDirectory();
|
|
82
|
+
isFile = targetStat.isFile();
|
|
83
|
+
} else {
|
|
84
|
+
isDir = lstat.isDirectory();
|
|
85
|
+
isFile = lstat.isFile();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (isDir) {
|
|
71
89
|
scanDir(entryPath, depth + 1);
|
|
72
|
-
} else if (
|
|
90
|
+
} else if (isFile) {
|
|
73
91
|
const ext = extname(name);
|
|
74
92
|
if (!VALID_EXTENSIONS.has(ext)) continue;
|
|
75
93
|
if (SKIP_SUFFIXES.some((s) => name.endsWith(s))) continue;
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import type { TSchema } from '@sinclair/typebox';
|
|
6
|
-
import
|
|
6
|
+
import yaml from 'js-yaml';
|
|
7
7
|
import type { ModuleAnnotations, ModuleExample } from '../module.js';
|
|
8
|
-
import { ModuleNotFoundError } from '../errors.js';
|
|
8
|
+
import { InvalidInputError, ModuleNotFoundError } from '../errors.js';
|
|
9
9
|
import { deepCopy } from '../utils/index.js';
|
|
10
10
|
import { SchemaExporter } from '../schema/exporter.js';
|
|
11
11
|
import { stripExtensions, toStrictSchema } from '../schema/strict.js';
|
|
@@ -129,6 +129,13 @@ function exportWithProfile(
|
|
|
129
129
|
const examples = module ? ((module as Record<string, unknown>)['examples'] as ModuleExample[]) ?? [] : [];
|
|
130
130
|
const name = module ? (module as Record<string, unknown>)['name'] as string | undefined : undefined;
|
|
131
131
|
|
|
132
|
+
const validProfiles = new Set(Object.values(ExportProfile));
|
|
133
|
+
if (!validProfiles.has(profile as ExportProfile)) {
|
|
134
|
+
throw new InvalidInputError(
|
|
135
|
+
`Invalid export profile: '${profile}'. Must be one of: ${[...validProfiles].join(', ')}`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
132
139
|
const exported = new SchemaExporter().export(
|
|
133
140
|
schemaDef,
|
|
134
141
|
profile as ExportProfile,
|
|
@@ -168,7 +175,7 @@ function truncateDescription(description: string): string {
|
|
|
168
175
|
|
|
169
176
|
function serialize(data: unknown, format: string): string {
|
|
170
177
|
if (format === 'yaml') {
|
|
171
|
-
return
|
|
178
|
+
return yaml.dump(data, { flowLevel: -1 });
|
|
172
179
|
}
|
|
173
180
|
return JSON.stringify(data, null, 2);
|
|
174
181
|
}
|
package/src/schema/loader.ts
CHANGED
|
@@ -111,11 +111,13 @@ export class SchemaLoader {
|
|
|
111
111
|
const cached = this._modelCache.get(moduleId);
|
|
112
112
|
if (cached) return cached;
|
|
113
113
|
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
114
|
+
const strategyMap: Record<string, SchemaStrategy> = {
|
|
115
|
+
yaml_first: SchemaStrategy.YamlFirst,
|
|
116
|
+
native_first: SchemaStrategy.NativeFirst,
|
|
117
|
+
yaml_only: SchemaStrategy.YamlOnly,
|
|
118
|
+
};
|
|
119
|
+
const rawStrategy = this._config.get('schema.strategy', 'yaml_first') as string;
|
|
120
|
+
const strategy = strategyMap[rawStrategy] ?? SchemaStrategy.YamlFirst;
|
|
119
121
|
|
|
120
122
|
let result: [ResolvedSchema, ResolvedSchema] | null = null;
|
|
121
123
|
|
|
@@ -191,15 +193,21 @@ export class SchemaLoader {
|
|
|
191
193
|
export function jsonSchemaToTypeBox(schema: Record<string, unknown>): TSchema {
|
|
192
194
|
const schemaType = schema['type'] as string | undefined;
|
|
193
195
|
|
|
194
|
-
|
|
195
|
-
if (schemaType === '
|
|
196
|
-
if (schemaType === '
|
|
197
|
-
if (schemaType === '
|
|
198
|
-
if (schemaType === '
|
|
199
|
-
if (schemaType === '
|
|
200
|
-
if (schemaType === '
|
|
201
|
-
|
|
202
|
-
|
|
196
|
+
let result: TSchema;
|
|
197
|
+
if (schemaType === 'object') result = convertObjectSchema(schema);
|
|
198
|
+
else if (schemaType === 'array') result = convertArraySchema(schema);
|
|
199
|
+
else if (schemaType === 'string') result = convertStringSchema(schema);
|
|
200
|
+
else if (schemaType === 'integer') result = convertNumericSchema(schema, Type.Integer);
|
|
201
|
+
else if (schemaType === 'number') result = convertNumericSchema(schema, Type.Number);
|
|
202
|
+
else if (schemaType === 'boolean') result = Type.Boolean();
|
|
203
|
+
else if (schemaType === 'null') result = Type.Null();
|
|
204
|
+
else result = convertCombinatorSchema(schema);
|
|
205
|
+
|
|
206
|
+
// Preserve JSON Schema metadata
|
|
207
|
+
if (typeof schema['description'] === 'string') result['description'] = schema['description'];
|
|
208
|
+
if (typeof schema['title'] === 'string') result['title'] = schema['title'];
|
|
209
|
+
|
|
210
|
+
return result;
|
|
203
211
|
}
|
|
204
212
|
|
|
205
213
|
function convertObjectSchema(schema: Record<string, unknown>): TSchema {
|
|
@@ -244,7 +252,13 @@ function convertNumericSchema(
|
|
|
244
252
|
function convertCombinatorSchema(schema: Record<string, unknown>): TSchema {
|
|
245
253
|
if ('enum' in schema) {
|
|
246
254
|
const values = schema['enum'] as unknown[];
|
|
247
|
-
return Type.Union(values.map((v) =>
|
|
255
|
+
return Type.Union(values.map((v) =>
|
|
256
|
+
v === null ? Type.Null() : Type.Literal(v as string | number | boolean),
|
|
257
|
+
));
|
|
258
|
+
}
|
|
259
|
+
if ('const' in schema) {
|
|
260
|
+
const value = schema['const'];
|
|
261
|
+
return value === null ? Type.Null() : Type.Literal(value as string | number | boolean);
|
|
248
262
|
}
|
|
249
263
|
if ('oneOf' in schema) {
|
|
250
264
|
return Type.Union((schema['oneOf'] as Record<string, unknown>[]).map(jsonSchemaToTypeBox));
|
|
@@ -139,11 +139,23 @@ export class RefResolver {
|
|
|
139
139
|
if (refString.includes('#')) {
|
|
140
140
|
const [filePart, pointer] = refString.split('#', 2);
|
|
141
141
|
const base = currentFile ? dirname(currentFile) : this._schemasDir;
|
|
142
|
-
|
|
142
|
+
const resolvedPath = resolve(base, filePart);
|
|
143
|
+
this._assertWithinSchemasDir(resolvedPath, refString);
|
|
144
|
+
return [resolvedPath, pointer];
|
|
143
145
|
}
|
|
144
146
|
|
|
145
147
|
const base = currentFile ? dirname(currentFile) : this._schemasDir;
|
|
146
|
-
|
|
148
|
+
const resolvedPath = resolve(base, refString);
|
|
149
|
+
this._assertWithinSchemasDir(resolvedPath, refString);
|
|
150
|
+
return [resolvedPath, ''];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private _assertWithinSchemasDir(resolvedPath: string, refString: string): void {
|
|
154
|
+
if (!resolvedPath.startsWith(this._schemasDir + '/') && resolvedPath !== this._schemasDir) {
|
|
155
|
+
throw new SchemaNotFoundError(
|
|
156
|
+
`Reference '${refString}' resolves outside schemas directory`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
147
159
|
}
|
|
148
160
|
|
|
149
161
|
private _convertCanonicalToPath(uri: string): [string, string] {
|
package/src/schema/strict.ts
CHANGED
|
@@ -15,7 +15,7 @@ export function applyLlmDescriptions(node: unknown): void {
|
|
|
15
15
|
if (typeof node !== 'object' || node === null || Array.isArray(node)) return;
|
|
16
16
|
|
|
17
17
|
const obj = node as Record<string, unknown>;
|
|
18
|
-
if ('x-llm-description' in obj
|
|
18
|
+
if ('x-llm-description' in obj) {
|
|
19
19
|
obj['description'] = obj['x-llm-description'];
|
|
20
20
|
}
|
|
21
21
|
|
|
@@ -93,6 +93,16 @@ function convertToStrict(node: unknown): void {
|
|
|
93
93
|
(prop['type'] as string[]).push('null');
|
|
94
94
|
}
|
|
95
95
|
}
|
|
96
|
+
} else if ('oneOf' in prop && Array.isArray(prop['oneOf'])) {
|
|
97
|
+
const variants = prop['oneOf'] as Record<string, unknown>[];
|
|
98
|
+
if (!variants.some((v) => v['type'] === 'null')) {
|
|
99
|
+
variants.push({ type: 'null' });
|
|
100
|
+
}
|
|
101
|
+
} else if ('anyOf' in prop && Array.isArray(prop['anyOf'])) {
|
|
102
|
+
const variants = prop['anyOf'] as Record<string, unknown>[];
|
|
103
|
+
if (!variants.some((v) => v['type'] === 'null')) {
|
|
104
|
+
variants.push({ type: 'null' });
|
|
105
|
+
}
|
|
96
106
|
} else {
|
|
97
107
|
properties[name] = { oneOf: [prop, { type: 'null' }] };
|
|
98
108
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
2
3
|
import { Type } from '@sinclair/typebox';
|
|
3
4
|
import { Executor } from '../../src/executor.js';
|
|
4
5
|
import { FunctionModule } from '../../src/decorator.js';
|
|
@@ -133,7 +134,7 @@ describe('ACL Integration', () => {
|
|
|
133
134
|
|
|
134
135
|
// Deep call chain (depth > 2) - denied by ACL condition
|
|
135
136
|
const deepCtx = new Context(
|
|
136
|
-
|
|
137
|
+
uuidv4(),
|
|
137
138
|
'caller1',
|
|
138
139
|
['mod1', 'mod2', 'mod3'],
|
|
139
140
|
executor,
|