@gobing-ai/ts-runtime 0.2.6 → 0.2.8

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/README.md CHANGED
@@ -4,13 +4,13 @@ Runtime abstraction layer — environment detection, file system, process execut
4
4
 
5
5
  ## Overview
6
6
 
7
- `ts-runtime` decouples application code from platform specifics. Instead of importing `node:fs` or `node:child_process` directly, consumers go through interfaces (`FileSystem`, `ProcessExecutor`) that resolve to the correct implementation at startup based on `RuntimeContext`.
7
+ `ts-runtime` decouples application code from platform specifics. Instead of importing `node:fs` or `node:child_process` directly, consumers go through interfaces (`FileSystem`, `ProcessExecutor`) that resolve to the correct implementation at startup based on `RuntimeContext`. Node filesystem modules are loaded lazily by `NodeFileSystem`, so importing the package remains safe for Worker bundles that select `CloudflareFileSystem`.
8
8
 
9
9
  **Key abstractions:**
10
10
 
11
11
  | Concept | Interface | Bun/Node impl | Cloudflare impl |
12
12
  |---------|-----------|---------------|-----------------|
13
- | File system | `FileSystem` | `NodeFileSystem` | `CloudflareFileSystem` (stub) |
13
+ | File system | `FileSystem` | `NodeFileSystem` | `CloudflareFileSystem` (unsupported filesystem facade) |
14
14
  | Process execution | `ProcessExecutor` | `NodeProcessExecutor` | — |
15
15
  | Configuration | `Config` (Zod schema) | YAML + env vars | YAML + env vars |
16
16
  | Context | `RuntimeContext` | service locator | service locator |
@@ -174,7 +174,48 @@ const config = buildConfigFromYaml(yaml, {
174
174
  // config.logging.level === 'debug' (from YAML)
175
175
  ```
176
176
 
177
- ### 4. File system abstraction
177
+ ### 4. Structured config + JSON-schema validation
178
+
179
+ `loadStructuredConfig` reads a `.json`/`.yaml` file and — if it declares a top-level `$schema` — validates
180
+ it against that schema before returning. This powers schema-checked rule and workflow files in
181
+ `ts-rule-engine` and `ts-dual-workflow-engine`.
182
+
183
+ ```ts
184
+ import { loadStructuredConfig } from '@gobing-ai/ts-runtime';
185
+
186
+ const config = await loadStructuredConfig('rules.yaml'); // validates against its $schema, throws on violation
187
+ const raw = await loadStructuredConfig('rules.yaml', { validateSchema: false }); // skip validation
188
+ ```
189
+
190
+ A `StructuredConfigSchemaError` (with a `violations` array of `{ path, message }`) is thrown on any
191
+ schema violation.
192
+
193
+ #### `$schema` reference styles
194
+
195
+ There are three ways a config file can name its schema. **Prefer the bundled package specifier** — it is
196
+ the most secure and performant default:
197
+
198
+ | Style | Example | Resolution | Notes |
199
+ |-------|---------|------------|-------|
200
+ | **Package specifier** (recommended) | `$schema: "@gobing-ai/ts-rule-engine/schemas/rule-file.schema.json"` | Resolved through `node_modules` via the module resolver, then read from disk | No network, no path guessing; survives hoisting/pnpm/monorepo layouts. Schemas ship in each package's `schemas/` (declared in `files`). **Quote the value** — YAML treats a leading `@` as reserved. |
201
+ | Relative path | `$schema: ./schemas/rule-file.schema.json` | Resolved against the config file's directory | Fine for repo-local schemas; brittle if the config moves. |
202
+ | Remote URL | `$schema: https://json-schema.org/.../rule-file.schema.json` | Fetched over HTTP(S) — **off by default** | SSRF/DoS surface for third-party configs. Opt in with `{ allowRemote: true }` (5s timeout) or supply your own `fetch`. |
203
+
204
+ ```ts
205
+ // Bundled package schema (default path — no extra options needed):
206
+ // $schema: "@gobing-ai/ts-rule-engine/schemas/rule-file.schema.json"
207
+ await loadStructuredConfig('rules.yaml');
208
+
209
+ // Remote schema is refused unless explicitly enabled:
210
+ await loadStructuredConfig('rules.yaml', { allowRemote: true }); // built-in fetch, time-bounded
211
+ await loadStructuredConfig('rules.yaml', { fetch: myFetch }); // or inject your own
212
+ ```
213
+
214
+ > **Security:** remote schema fetching is disabled by default. Resolving a bundled schema from
215
+ > `node_modules` keeps validation entirely local — no outbound request, no dependency on a schema host's
216
+ > availability, and no chance for a malicious config to point validation at an internal URL.
217
+
218
+ ### 5. File system abstraction
178
219
 
179
220
  All file operations go through the `FileSystem` interface. Swap implementations for testing:
180
221
 
@@ -186,7 +227,7 @@ await fs.writeFile('output.json', JSON.stringify(data));
186
227
  const content = await fs.readFile('output.json');
187
228
  ```
188
229
 
189
- ### 5. Graceful disposal
230
+ ### 6. Graceful disposal
190
231
 
191
232
  `RuntimeContext.dispose()` calls `dispose()` on every registered service that implements the pattern:
192
233
 
package/dist/fs.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"fs.d.ts","sourceRoot":"","sources":["../src/fs.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"fs.d.ts","sourceRoot":"","sources":["../src/fs.ts"],"names":[],"mappings":"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"}
package/dist/fs.js CHANGED
@@ -1,22 +1,34 @@
1
- import { createWriteStream, mkdirSync } from 'node:fs';
2
- import { access, appendFile, cp, mkdir, readdir, readFile, realpath, rename, rm, stat, writeFile, } from 'node:fs/promises';
3
- import { dirname, join, resolve } from 'node:path';
1
+ let fsPromisesModule = null;
2
+ let fsModule = null;
3
+ function nodeFsPromises() {
4
+ fsPromisesModule ??= import('node:fs/promises');
5
+ return fsPromisesModule;
6
+ }
7
+ function nodeFs() {
8
+ fsModule ??= import('node:fs');
9
+ return fsModule;
10
+ }
4
11
  export class NodeFileSystem {
5
12
  async readFile(path) {
13
+ const { readFile } = await nodeFsPromises();
6
14
  return await readFile(path, 'utf-8');
7
15
  }
8
16
  async writeFile(path, content) {
17
+ const { writeFile } = await nodeFsPromises();
9
18
  await ensureDirForFile(path, this);
10
19
  await writeFile(path, content, 'utf-8');
11
20
  }
12
21
  async appendFile(path, content) {
22
+ const { appendFile } = await nodeFsPromises();
13
23
  await ensureDirForFile(path, this);
14
24
  await appendFile(path, content, 'utf-8');
15
25
  }
16
26
  async mkdir(path) {
27
+ const { mkdir } = await nodeFsPromises();
17
28
  await mkdir(path, { recursive: true });
18
29
  }
19
30
  async exists(path) {
31
+ const { access } = await nodeFsPromises();
20
32
  try {
21
33
  await access(path);
22
34
  return true;
@@ -26,12 +38,15 @@ export class NodeFileSystem {
26
38
  }
27
39
  }
28
40
  async readDir(path) {
41
+ const { readdir } = await nodeFsPromises();
29
42
  return await readdir(path);
30
43
  }
31
44
  async unlink(path) {
45
+ const { rm } = await nodeFsPromises();
32
46
  await rm(path, { recursive: true, force: true });
33
47
  }
34
48
  async stat(path) {
49
+ const { stat } = await nodeFsPromises();
35
50
  try {
36
51
  const value = await stat(path);
37
52
  return {
@@ -46,17 +61,52 @@ export class NodeFileSystem {
46
61
  }
47
62
  }
48
63
  async realpath(path) {
64
+ const { realpath } = await nodeFsPromises();
49
65
  return await realpath(path);
50
66
  }
51
67
  async copy(src, dest) {
68
+ const { cp } = await nodeFsPromises();
52
69
  await cp(src, dest, { recursive: true });
53
70
  }
54
71
  async rename(src, dest) {
72
+ const { rename } = await nodeFsPromises();
55
73
  await rename(src, dest);
56
74
  }
57
75
  createLogStream(path) {
58
- mkdirSync(dirname(path), { recursive: true });
59
- return createWriteStream(path, { flags: 'a' });
76
+ return new LazyNodeLogStream(path);
77
+ }
78
+ }
79
+ class LazyNodeLogStream {
80
+ ready;
81
+ ended = false;
82
+ pending = [];
83
+ constructor(path) {
84
+ this.ready = nodeFs().then(({ createWriteStream, mkdirSync }) => {
85
+ mkdirSync(dirnamePath(path), { recursive: true });
86
+ const stream = createWriteStream(path, { flags: 'a' });
87
+ for (const chunk of this.pending.splice(0))
88
+ stream.write(chunk);
89
+ if (this.ended)
90
+ stream.end();
91
+ return {
92
+ write: (chunk) => stream.write(chunk),
93
+ end: () => stream.end(),
94
+ };
95
+ });
96
+ }
97
+ write(chunk) {
98
+ if (this.ended)
99
+ return;
100
+ this.pending.push(chunk);
101
+ void this.ready.then((stream) => {
102
+ const next = this.pending.shift();
103
+ if (next !== undefined)
104
+ stream.write(next);
105
+ });
106
+ }
107
+ end() {
108
+ this.ended = true;
109
+ void this.ready.then((stream) => stream.end());
60
110
  }
61
111
  }
62
112
  const CLOUDFLARE_FS_ERROR = 'FileSystem is not available on Cloudflare Workers. Use D1, KV, or R2.';
@@ -113,11 +163,11 @@ export function getFs() {
113
163
  return activeFileSystem;
114
164
  }
115
165
  export async function ensureDirForFile(path, fs = getFs()) {
116
- await fs.mkdir(dirname(path));
166
+ await fs.mkdir(dirnamePath(path));
117
167
  }
118
168
  export async function atomicWriteFile(path, content, fs = getFs()) {
119
169
  await ensureDirForFile(path, fs);
120
- const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`;
170
+ const tempPath = `${path}.${getProcessPid()}.${Date.now()}.tmp`;
121
171
  await fs.writeFile(tempPath, content);
122
172
  await fs.rename(tempPath, path);
123
173
  }
@@ -134,7 +184,7 @@ export async function walkDir(path, fs = getFs()) {
134
184
  const entries = (await fs.readDir(path)).sort();
135
185
  const result = [];
136
186
  for (const entry of entries) {
137
- const fullPath = join(path, entry);
187
+ const fullPath = joinPath(path, entry);
138
188
  const entryStat = await fs.stat(fullPath);
139
189
  if (entryStat?.isDirectory()) {
140
190
  result.push(...(await walkDir(fullPath, fs)));
@@ -145,13 +195,13 @@ export async function walkDir(path, fs = getFs()) {
145
195
  }
146
196
  return result;
147
197
  }
148
- export function getProjectRoot(startDir = process.cwd()) {
149
- let current = resolve(startDir);
198
+ export function getProjectRoot(startDir = getProcessCwd()) {
199
+ let current = resolvePath(startDir);
150
200
  for (let i = 0; i < 12; i++) {
151
- if (Bun.file(join(current, 'bun.lock')).size !== 0 || Bun.file(join(current, 'package.json')).size !== 0) {
201
+ if (hasBunFile(joinPath(current, 'bun.lock')) || hasBunFile(joinPath(current, 'package.json'))) {
152
202
  return current;
153
203
  }
154
- const parent = dirname(current);
204
+ const parent = dirnamePath(current);
155
205
  if (parent === current)
156
206
  return startDir;
157
207
  current = parent;
@@ -159,8 +209,69 @@ export function getProjectRoot(startDir = process.cwd()) {
159
209
  return startDir;
160
210
  }
161
211
  export function resolveProjectPath(...segments) {
162
- return resolve(getProjectRoot(), ...segments);
212
+ return resolvePath(getProjectRoot(), ...segments);
163
213
  }
164
214
  export function createLogStream(path, fs = getFs()) {
165
215
  return fs.createLogStream(path);
166
216
  }
217
+ function hasBunFile(path) {
218
+ const bun = globalThis.Bun;
219
+ if (bun === undefined)
220
+ return false;
221
+ return bun.file(path).size !== 0;
222
+ }
223
+ function getProcessPid() {
224
+ return globalThis.process?.pid ?? 0;
225
+ }
226
+ function getProcessCwd() {
227
+ return globalThis.process?.cwd?.() ?? '/';
228
+ }
229
+ function normalizeSeparators(path) {
230
+ return path.replaceAll('\\', '/');
231
+ }
232
+ function isAbsolutePath(path) {
233
+ return path.startsWith('/') || /^[A-Za-z]:\//.test(normalizeSeparators(path));
234
+ }
235
+ function dirnamePath(path) {
236
+ const input = normalizeSeparators(path);
237
+ if (/^\/+$/.test(input))
238
+ return '/';
239
+ const normalized = input.replace(/\/+$/, '');
240
+ if (normalized === '' || normalized === '/')
241
+ return normalized || '.';
242
+ const index = normalized.lastIndexOf('/');
243
+ if (index < 0)
244
+ return '.';
245
+ if (index === 0)
246
+ return '/';
247
+ return normalized.slice(0, index);
248
+ }
249
+ function joinPath(...segments) {
250
+ const filtered = segments.filter((segment) => segment.length > 0).map(normalizeSeparators);
251
+ if (filtered.length === 0)
252
+ return '.';
253
+ const absolute = isAbsolutePath(filtered[0] ?? '');
254
+ const joined = filtered.join('/').replace(/\/+/g, '/');
255
+ return absolute ? joined : joined.replace(/^\//, '');
256
+ }
257
+ function resolvePath(...segments) {
258
+ const candidates = segments.length === 0 ? [getProcessCwd()] : segments;
259
+ let resolved = '';
260
+ for (const segment of candidates.map(normalizeSeparators)) {
261
+ if (segment.length === 0)
262
+ continue;
263
+ resolved = isAbsolutePath(segment) ? segment : joinPath(resolved || getProcessCwd(), segment);
264
+ }
265
+ const parts = [];
266
+ const absolute = isAbsolutePath(resolved);
267
+ for (const part of resolved.split('/')) {
268
+ if (part === '' || part === '.')
269
+ continue;
270
+ if (part === '..') {
271
+ parts.pop();
272
+ continue;
273
+ }
274
+ parts.push(part);
275
+ }
276
+ return `${absolute ? '/' : ''}${parts.join('/')}` || (absolute ? '/' : '.');
277
+ }
package/dist/index.d.ts CHANGED
@@ -2,5 +2,6 @@ export * from './config';
2
2
  export * from './context';
3
3
  export * from './fs';
4
4
  export * from './process-executor';
5
+ export * from './schema-validation';
5
6
  export * from './types';
6
7
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,UAAU,CAAC;AACzB,cAAc,WAAW,CAAC;AAC1B,cAAc,MAAM,CAAC;AACrB,cAAc,oBAAoB,CAAC;AACnC,cAAc,SAAS,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,UAAU,CAAC;AACzB,cAAc,WAAW,CAAC;AAC1B,cAAc,MAAM,CAAC;AACrB,cAAc,oBAAoB,CAAC;AACnC,cAAc,qBAAqB,CAAC;AACpC,cAAc,SAAS,CAAC"}
package/dist/index.js CHANGED
@@ -2,4 +2,5 @@ export * from './config.js';
2
2
  export * from './context.js';
3
3
  export * from './fs.js';
4
4
  export * from './process-executor.js';
5
+ export * from './schema-validation.js';
5
6
  export * from './types.js';
@@ -0,0 +1,42 @@
1
+ export interface JsonSchemaViolation {
2
+ path: string;
3
+ message: string;
4
+ }
5
+ export interface JsonSchema {
6
+ type?: string | string[];
7
+ required?: string[];
8
+ properties?: Record<string, JsonSchema>;
9
+ additionalProperties?: boolean | JsonSchema;
10
+ items?: JsonSchema;
11
+ enum?: unknown[];
12
+ const?: unknown;
13
+ oneOf?: JsonSchema[];
14
+ anyOf?: JsonSchema[];
15
+ $ref?: string;
16
+ $defs?: Record<string, JsonSchema>;
17
+ }
18
+ export interface StructuredConfigLoadOptions {
19
+ validateSchema?: boolean;
20
+ /**
21
+ * Allow `http(s)://` `$schema` refs. Off by default: remote fetches are an SSRF/DoS surface when
22
+ * configs are authored by third parties. Prefer bundled package-specifier refs (resolved from
23
+ * `node_modules`). Supplying `fetch` explicitly also opts into remote resolution.
24
+ */
25
+ allowRemote?: boolean;
26
+ fetch?: (input: string) => Promise<Response>;
27
+ /**
28
+ * Module resolver for bare package-specifier `$schema` refs (e.g.
29
+ * `@gobing-ai/ts-rule-engine/schemas/rule-file.schema.json`). Defaults to `Bun.resolveSync`.
30
+ * Injectable for testing.
31
+ */
32
+ resolve?: (specifier: string, from: string) => string;
33
+ }
34
+ export declare class StructuredConfigSchemaError extends Error {
35
+ readonly violations: readonly JsonSchemaViolation[];
36
+ constructor(message: string, violations?: readonly JsonSchemaViolation[]);
37
+ }
38
+ export declare function loadStructuredConfig(path: string, options?: StructuredConfigLoadOptions): Promise<unknown>;
39
+ export declare function parseStructuredConfig(content: string, source: string, options?: StructuredConfigLoadOptions): Promise<unknown>;
40
+ export declare function validateDeclaredJsonSchema(value: unknown, source: string, options?: StructuredConfigLoadOptions): Promise<void>;
41
+ export declare function validateJsonSchema(value: unknown, schema: JsonSchema, path?: string, defs?: Record<string, JsonSchema>): JsonSchemaViolation[];
42
+ //# sourceMappingURL=schema-validation.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"schema-validation.d.ts","sourceRoot":"","sources":["../src/schema-validation.ts"],"names":[],"mappings":"AAOA,MAAM,WAAW,mBAAmB;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,UAAU;IACvB,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IACzB,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC;IACxC,oBAAoB,CAAC,EAAE,OAAO,GAAG,UAAU,CAAC;IAC5C,KAAK,CAAC,EAAE,UAAU,CAAC;IACnB,IAAI,CAAC,EAAE,OAAO,EAAE,CAAC;IACjB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,KAAK,CAAC,EAAE,UAAU,EAAE,CAAC;IACrB,KAAK,CAAC,EAAE,UAAU,EAAE,CAAC;IACrB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC;CACtC;AAED,MAAM,WAAW,2BAA2B;IACxC,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB;;;;OAIG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;IAC7C;;;;OAIG;IACH,OAAO,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC;CACzD;AAED,qBAAa,2BAA4B,SAAQ,KAAK;IAG9C,QAAQ,CAAC,UAAU,EAAE,SAAS,mBAAmB,EAAE;gBADnD,OAAO,EAAE,MAAM,EACN,UAAU,GAAE,SAAS,mBAAmB,EAAO;CAK/D;AAED,wBAAsB,oBAAoB,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,2BAAgC,GAAG,OAAO,CAAC,OAAO,CAAC,CAGpH;AAED,wBAAsB,qBAAqB,CACvC,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,MAAM,EACd,OAAO,GAAE,2BAAgC,GAC1C,OAAO,CAAC,OAAO,CAAC,CAMlB;AAED,wBAAsB,0BAA0B,CAC5C,KAAK,EAAE,OAAO,EACd,MAAM,EAAE,MAAM,EACd,OAAO,GAAE,2BAAgC,GAC1C,OAAO,CAAC,IAAI,CAAC,CA+Bf;AAED,wBAAgB,kBAAkB,CAC9B,KAAK,EAAE,OAAO,EACd,MAAM,EAAE,UAAU,EAClB,IAAI,SAAK,EACT,IAAI,GAAE,MAAM,CAAC,MAAM,EAAE,UAAU,CAAM,GACtC,mBAAmB,EAAE,CAgDvB"}
@@ -0,0 +1,255 @@
1
+ import { dirname, isAbsolute, join } from 'node:path';
2
+ import { parse as parseYaml } from 'yaml';
3
+ import { getFs } from './fs.js';
4
+ /** Default time budget for a single remote schema fetch. */
5
+ const REMOTE_SCHEMA_FETCH_TIMEOUT_MS = 5_000;
6
+ export class StructuredConfigSchemaError extends Error {
7
+ violations;
8
+ constructor(message, violations = []) {
9
+ super(message);
10
+ this.violations = violations;
11
+ this.name = 'StructuredConfigSchemaError';
12
+ }
13
+ }
14
+ export async function loadStructuredConfig(path, options = {}) {
15
+ const content = await getFs().readFile(path);
16
+ return await parseStructuredConfig(content, path, options);
17
+ }
18
+ export async function parseStructuredConfig(content, source, options = {}) {
19
+ const parsed = source.endsWith('.json') ? JSON.parse(content) : parseYaml(content);
20
+ if (options.validateSchema !== false) {
21
+ await validateDeclaredJsonSchema(parsed, source, options);
22
+ }
23
+ return parsed;
24
+ }
25
+ export async function validateDeclaredJsonSchema(value, source, options = {}) {
26
+ if (!isObject(value))
27
+ return;
28
+ const schemaRef = value.$schema;
29
+ if (typeof schemaRef !== 'string' || schemaRef.length === 0)
30
+ return;
31
+ const schemaLocation = resolveSchemaRef(schemaRef, source, options.resolve);
32
+ const schemaText = await readSchema(schemaLocation, options);
33
+ let schema;
34
+ try {
35
+ schema = JSON.parse(schemaText);
36
+ }
37
+ catch (error) {
38
+ throw new StructuredConfigSchemaError(`Invalid JSON schema "${schemaLocation}" referenced by "${source}": ${errorMessage(error)}`);
39
+ }
40
+ if (!isObject(schema)) {
41
+ throw new StructuredConfigSchemaError(`JSON schema "${schemaLocation}" referenced by "${source}" must be an object`);
42
+ }
43
+ const violations = validateJsonSchema(value, schema, '', (schema.$defs ?? {}));
44
+ if (violations.length > 0) {
45
+ throw new StructuredConfigSchemaError(`Configuration "${source}" failed JSON schema validation against "${schemaLocation}": ${violations
46
+ .map((violation) => `${violation.path}: ${violation.message}`)
47
+ .join('; ')}`, violations);
48
+ }
49
+ }
50
+ export function validateJsonSchema(value, schema, path = '', defs = {}) {
51
+ const violations = [];
52
+ // Applicator keywords compose with their siblings (logical AND), per JSON Schema 2020-12 —
53
+ // a node may carry `$ref`/`oneOf`/`anyOf` *and* `type`/`properties`/... and all apply.
54
+ if (schema.$ref !== undefined) {
55
+ const resolved = resolveRef(schema.$ref, defs, schema.$defs);
56
+ if (resolved !== undefined)
57
+ violations.push(...validateJsonSchema(value, resolved, path, defs));
58
+ }
59
+ if (schema.oneOf !== undefined) {
60
+ violations.push(...validateCombinator(value, schema.oneOf, path, defs, 'oneOf'));
61
+ }
62
+ if (schema.anyOf !== undefined) {
63
+ violations.push(...validateCombinator(value, schema.anyOf, path, defs, 'anyOf'));
64
+ }
65
+ if (schema.const !== undefined && !jsonEqual(value, schema.const)) {
66
+ violations.push({ path: path || '(root)', message: `expected constant ${JSON.stringify(schema.const)}` });
67
+ }
68
+ if (schema.enum !== undefined && !schema.enum.some((entry) => jsonEqual(value, entry))) {
69
+ violations.push({ path: path || '(root)', message: `expected one of ${schema.enum.map(String).join(', ')}` });
70
+ }
71
+ if (schema.type !== undefined) {
72
+ const types = Array.isArray(schema.type) ? schema.type : [schema.type];
73
+ if (!types.some((type) => matchesType(value, type))) {
74
+ violations.push({
75
+ path: path || '(root)',
76
+ message: `expected ${types.join(' or ')}, got ${typeName(value)}`,
77
+ });
78
+ return violations;
79
+ }
80
+ }
81
+ const hasObjectKeywords = schema.properties !== undefined || schema.required !== undefined || schema.additionalProperties !== undefined;
82
+ if (schema.type === 'object' || (hasObjectKeywords && isObject(value))) {
83
+ violations.push(...validateObject(value, schema, path, defs));
84
+ }
85
+ if (schema.type === 'array' || (schema.items !== undefined && Array.isArray(value))) {
86
+ violations.push(...validateArray(value, schema, path, defs));
87
+ }
88
+ return violations;
89
+ }
90
+ function validateObject(value, schema, path, defs) {
91
+ if (!isObject(value))
92
+ return [{ path: path || '(root)', message: `expected object, got ${typeName(value)}` }];
93
+ const violations = [];
94
+ for (const key of schema.required ?? []) {
95
+ if (!(key in value)) {
96
+ violations.push({ path: path ? `${path}.${key}` : key, message: `missing required field "${key}"` });
97
+ }
98
+ }
99
+ const properties = schema.properties ?? {};
100
+ for (const [key, childSchema] of Object.entries(properties)) {
101
+ if (key in value) {
102
+ violations.push(...validateJsonSchema(value[key], childSchema, path ? `${path}.${key}` : key, defs));
103
+ }
104
+ }
105
+ if (schema.additionalProperties === false) {
106
+ const allowed = new Set(Object.keys(properties));
107
+ for (const key of Object.keys(value)) {
108
+ if (!allowed.has(key)) {
109
+ violations.push({ path: path ? `${path}.${key}` : key, message: `unknown field "${key}"` });
110
+ }
111
+ }
112
+ }
113
+ else if (isObject(schema.additionalProperties)) {
114
+ for (const [key, child] of Object.entries(value)) {
115
+ if (!(key in properties)) {
116
+ violations.push(...validateJsonSchema(child, schema.additionalProperties, path ? `${path}.${key}` : key, defs));
117
+ }
118
+ }
119
+ }
120
+ return violations;
121
+ }
122
+ function validateArray(value, schema, path, defs) {
123
+ if (!Array.isArray(value))
124
+ return [{ path: path || '(root)', message: `expected array, got ${typeName(value)}` }];
125
+ if (schema.items === undefined)
126
+ return [];
127
+ return value.flatMap((entry, index) => validateJsonSchema(entry, schema.items, `${path}[${index}]`, defs));
128
+ }
129
+ function validateCombinator(value, schemas, path, defs, mode) {
130
+ const branchViolations = schemas.map((schema) => validateJsonSchema(value, schema, path, defs));
131
+ const passing = branchViolations.filter((violations) => violations.length === 0).length;
132
+ if (mode === 'anyOf' && passing >= 1)
133
+ return [];
134
+ if (mode === 'oneOf' && passing === 1)
135
+ return [];
136
+ const at = path || '(root)';
137
+ // oneOf matching more than one branch is a failure, not a pass — report it explicitly.
138
+ if (mode === 'oneOf' && passing > 1) {
139
+ return [{ path: at, message: `expected to match exactly one oneOf branch, matched ${passing}` }];
140
+ }
141
+ // No branch matched: surface every branch's reason so the author sees all options, not just branch 0.
142
+ const detail = branchViolations
143
+ .map((branch, index) => `[${index}] ${branch.map((v) => `${v.path}: ${v.message}`).join(', ')}`)
144
+ .join(' | ');
145
+ return [
146
+ {
147
+ path: at,
148
+ message: `expected to match ${mode === 'oneOf' ? 'exactly one' : 'at least one'} branch — ${detail}`,
149
+ },
150
+ ];
151
+ }
152
+ function resolveRef(ref, defs, localDefs) {
153
+ if (!ref.startsWith('#/$defs/'))
154
+ return undefined;
155
+ const name = ref.slice('#/$defs/'.length);
156
+ return defs[name] ?? localDefs?.[name];
157
+ }
158
+ function resolveSchemaRef(schemaRef, source, resolve) {
159
+ if (isRemoteRef(schemaRef) || isAbsolute(schemaRef))
160
+ return schemaRef;
161
+ // Relative ref — resolve against the config file's directory.
162
+ if (schemaRef.startsWith('./') || schemaRef.startsWith('../')) {
163
+ return join(isRemoteRef(source) ? '.' : dirname(source), schemaRef);
164
+ }
165
+ // Bare package specifier (e.g. "@scope/pkg/schemas/x.json") — resolve through node_modules.
166
+ return resolvePackageSchema(schemaRef, source, resolve);
167
+ }
168
+ function resolvePackageSchema(specifier, source, resolve) {
169
+ const resolveFn = resolve ?? defaultResolve;
170
+ if (resolveFn === undefined) {
171
+ throw new StructuredConfigSchemaError(`Cannot resolve package schema "${specifier}" referenced by "${source}": no module resolver available`);
172
+ }
173
+ const { pkg, subpath } = splitPackageSpecifier(specifier);
174
+ if (subpath.length === 0) {
175
+ throw new StructuredConfigSchemaError(`Package schema ref "${specifier}" referenced by "${source}" must include a path within the package`);
176
+ }
177
+ const from = isRemoteRef(source) ? process.cwd() : dirname(source);
178
+ try {
179
+ // Resolve the package root via its always-present package.json, then join the subpath.
180
+ // This sidesteps `exports` gating on arbitrary JSON subpaths.
181
+ const manifest = resolveFn(`${pkg}/package.json`, from);
182
+ return join(dirname(manifest), subpath);
183
+ }
184
+ catch (error) {
185
+ throw new StructuredConfigSchemaError(`Cannot resolve package schema "${specifier}" referenced by "${source}": ${errorMessage(error)}`);
186
+ }
187
+ }
188
+ function splitPackageSpecifier(specifier) {
189
+ const parts = specifier.split('/');
190
+ const segments = specifier.startsWith('@') ? 2 : 1;
191
+ return { pkg: parts.slice(0, segments).join('/'), subpath: parts.slice(segments).join('/') };
192
+ }
193
+ async function readSchema(schemaLocation, options) {
194
+ if (isRemoteRef(schemaLocation)) {
195
+ const fetchFn = options.fetch ?? (options.allowRemote ? boundedFetch : undefined);
196
+ if (fetchFn === undefined) {
197
+ throw new StructuredConfigSchemaError(`Refusing to fetch remote JSON schema "${schemaLocation}": pass { allowRemote: true } or a fetch implementation to opt in`);
198
+ }
199
+ const response = await fetchFn(schemaLocation);
200
+ if (!response.ok) {
201
+ throw new StructuredConfigSchemaError(`Failed to fetch JSON schema "${schemaLocation}": HTTP ${response.status}`);
202
+ }
203
+ return await response.text();
204
+ }
205
+ return await getFs().readFile(schemaLocation);
206
+ }
207
+ const defaultResolve = typeof Bun !== 'undefined' ? (specifier, from) => Bun.resolveSync(specifier, from) : undefined;
208
+ /** Default remote fetch, time-bounded so a slow/hung schema host cannot stall config loading. */
209
+ function boundedFetch(input) {
210
+ return globalThis.fetch(input, { signal: AbortSignal.timeout(REMOTE_SCHEMA_FETCH_TIMEOUT_MS) });
211
+ }
212
+ function isObject(value) {
213
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
214
+ }
215
+ function matchesType(value, type) {
216
+ if (type === 'object')
217
+ return isObject(value);
218
+ if (type === 'array')
219
+ return Array.isArray(value);
220
+ if (type === 'integer')
221
+ return typeof value === 'number' && Number.isInteger(value);
222
+ if (type === 'null')
223
+ return value === null;
224
+ return typeof value === type;
225
+ }
226
+ function typeName(value) {
227
+ if (Array.isArray(value))
228
+ return 'array';
229
+ if (value === null)
230
+ return 'null';
231
+ return typeof value;
232
+ }
233
+ function jsonEqual(left, right) {
234
+ if (left === right)
235
+ return true;
236
+ if (Array.isArray(left) || Array.isArray(right)) {
237
+ if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length)
238
+ return false;
239
+ return left.every((entry, index) => jsonEqual(entry, right[index]));
240
+ }
241
+ if (isObject(left) && isObject(right)) {
242
+ const keys = Object.keys(left);
243
+ if (keys.length !== Object.keys(right).length)
244
+ return false;
245
+ // Object member order is insignificant in JSON — compare by key, not by serialization.
246
+ return keys.every((key) => key in right && jsonEqual(left[key], right[key]));
247
+ }
248
+ return false;
249
+ }
250
+ function errorMessage(error) {
251
+ return error instanceof Error ? error.message : String(error);
252
+ }
253
+ function isRemoteRef(ref) {
254
+ return /^https?:\/\//i.test(ref);
255
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gobing-ai/ts-runtime",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "description": "@gobing-ai/ts-runtime — Runtime abstractions for Bun, Node, and Cloudflare Workers.",
5
5
  "keywords": [
6
6
  "typescript",
@@ -54,7 +54,7 @@
54
54
  "release": "echo 'Manual publish is disabled. Releases go through GitHub Actions via Trusted Publishing — push a tag: git tag @gobing-ai/ts-runtime-v<version> && git push --tags' && exit 1"
55
55
  },
56
56
  "dependencies": {
57
- "@gobing-ai/ts-utils": "^0.2.6",
57
+ "@gobing-ai/ts-utils": "^0.2.8",
58
58
  "execa": "^9.5.0",
59
59
  "yaml": "^2.7.0",
60
60
  "zod": "^4.1.0"
package/src/fs.ts CHANGED
@@ -1,19 +1,3 @@
1
- import { createWriteStream, mkdirSync } from 'node:fs';
2
- import {
3
- access,
4
- appendFile,
5
- cp,
6
- mkdir,
7
- readdir,
8
- readFile,
9
- realpath,
10
- rename,
11
- rm,
12
- stat,
13
- writeFile,
14
- } from 'node:fs/promises';
15
- import { dirname, join, resolve } from 'node:path';
16
-
17
1
  export interface FileStat {
18
2
  isFile(): boolean;
19
3
  isDirectory(): boolean;
@@ -41,26 +25,47 @@ export interface FileSystem {
41
25
  createLogStream(path: string): LogStream;
42
26
  }
43
27
 
28
+ type NodeFsPromises = typeof import('node:fs/promises');
29
+ type NodeFs = typeof import('node:fs');
30
+
31
+ let fsPromisesModule: Promise<NodeFsPromises> | null = null;
32
+ let fsModule: Promise<NodeFs> | null = null;
33
+
34
+ function nodeFsPromises(): Promise<NodeFsPromises> {
35
+ fsPromisesModule ??= import('node:fs/promises');
36
+ return fsPromisesModule;
37
+ }
38
+
39
+ function nodeFs(): Promise<NodeFs> {
40
+ fsModule ??= import('node:fs');
41
+ return fsModule;
42
+ }
43
+
44
44
  export class NodeFileSystem implements FileSystem {
45
45
  async readFile(path: string): Promise<string> {
46
+ const { readFile } = await nodeFsPromises();
46
47
  return await readFile(path, 'utf-8');
47
48
  }
48
49
 
49
50
  async writeFile(path: string, content: string): Promise<void> {
51
+ const { writeFile } = await nodeFsPromises();
50
52
  await ensureDirForFile(path, this);
51
53
  await writeFile(path, content, 'utf-8');
52
54
  }
53
55
 
54
56
  async appendFile(path: string, content: string): Promise<void> {
57
+ const { appendFile } = await nodeFsPromises();
55
58
  await ensureDirForFile(path, this);
56
59
  await appendFile(path, content, 'utf-8');
57
60
  }
58
61
 
59
62
  async mkdir(path: string): Promise<void> {
63
+ const { mkdir } = await nodeFsPromises();
60
64
  await mkdir(path, { recursive: true });
61
65
  }
62
66
 
63
67
  async exists(path: string): Promise<boolean> {
68
+ const { access } = await nodeFsPromises();
64
69
  try {
65
70
  await access(path);
66
71
  return true;
@@ -70,14 +75,17 @@ export class NodeFileSystem implements FileSystem {
70
75
  }
71
76
 
72
77
  async readDir(path: string): Promise<string[]> {
78
+ const { readdir } = await nodeFsPromises();
73
79
  return await readdir(path);
74
80
  }
75
81
 
76
82
  async unlink(path: string): Promise<void> {
83
+ const { rm } = await nodeFsPromises();
77
84
  await rm(path, { recursive: true, force: true });
78
85
  }
79
86
 
80
87
  async stat(path: string): Promise<FileStat | null> {
88
+ const { stat } = await nodeFsPromises();
81
89
  try {
82
90
  const value = await stat(path);
83
91
  return {
@@ -92,20 +100,58 @@ export class NodeFileSystem implements FileSystem {
92
100
  }
93
101
 
94
102
  async realpath(path: string): Promise<string> {
103
+ const { realpath } = await nodeFsPromises();
95
104
  return await realpath(path);
96
105
  }
97
106
 
98
107
  async copy(src: string, dest: string): Promise<void> {
108
+ const { cp } = await nodeFsPromises();
99
109
  await cp(src, dest, { recursive: true });
100
110
  }
101
111
 
102
112
  async rename(src: string, dest: string): Promise<void> {
113
+ const { rename } = await nodeFsPromises();
103
114
  await rename(src, dest);
104
115
  }
105
116
 
106
117
  createLogStream(path: string): LogStream {
107
- mkdirSync(dirname(path), { recursive: true });
108
- return createWriteStream(path, { flags: 'a' });
118
+ return new LazyNodeLogStream(path);
119
+ }
120
+ }
121
+
122
+ class LazyNodeLogStream implements LogStream {
123
+ private readonly ready: Promise<{
124
+ write: (chunk: string) => void;
125
+ end: () => void;
126
+ }>;
127
+ private ended = false;
128
+ private readonly pending: string[] = [];
129
+
130
+ constructor(path: string) {
131
+ this.ready = nodeFs().then(({ createWriteStream, mkdirSync }) => {
132
+ mkdirSync(dirnamePath(path), { recursive: true });
133
+ const stream = createWriteStream(path, { flags: 'a' });
134
+ for (const chunk of this.pending.splice(0)) stream.write(chunk);
135
+ if (this.ended) stream.end();
136
+ return {
137
+ write: (chunk: string) => stream.write(chunk),
138
+ end: () => stream.end(),
139
+ };
140
+ });
141
+ }
142
+
143
+ write(chunk: string): void {
144
+ if (this.ended) return;
145
+ this.pending.push(chunk);
146
+ void this.ready.then((stream) => {
147
+ const next = this.pending.shift();
148
+ if (next !== undefined) stream.write(next);
149
+ });
150
+ }
151
+
152
+ end(): void {
153
+ this.ended = true;
154
+ void this.ready.then((stream) => stream.end());
109
155
  }
110
156
  }
111
157
 
@@ -180,12 +226,12 @@ export function getFs(): FileSystem {
180
226
  }
181
227
 
182
228
  export async function ensureDirForFile(path: string, fs = getFs()): Promise<void> {
183
- await fs.mkdir(dirname(path));
229
+ await fs.mkdir(dirnamePath(path));
184
230
  }
185
231
 
186
232
  export async function atomicWriteFile(path: string, content: string, fs = getFs()): Promise<void> {
187
233
  await ensureDirForFile(path, fs);
188
- const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`;
234
+ const tempPath = `${path}.${getProcessPid()}.${Date.now()}.tmp`;
189
235
  await fs.writeFile(tempPath, content);
190
236
  await fs.rename(tempPath, path);
191
237
  }
@@ -206,7 +252,7 @@ export async function walkDir(path: string, fs = getFs()): Promise<string[]> {
206
252
  const entries = (await fs.readDir(path)).sort();
207
253
  const result: string[] = [];
208
254
  for (const entry of entries) {
209
- const fullPath = join(path, entry);
255
+ const fullPath = joinPath(path, entry);
210
256
  const entryStat = await fs.stat(fullPath);
211
257
  if (entryStat?.isDirectory()) {
212
258
  result.push(...(await walkDir(fullPath, fs)));
@@ -217,13 +263,13 @@ export async function walkDir(path: string, fs = getFs()): Promise<string[]> {
217
263
  return result;
218
264
  }
219
265
 
220
- export function getProjectRoot(startDir = process.cwd()): string {
221
- let current = resolve(startDir);
266
+ export function getProjectRoot(startDir = getProcessCwd()): string {
267
+ let current = resolvePath(startDir);
222
268
  for (let i = 0; i < 12; i++) {
223
- if (Bun.file(join(current, 'bun.lock')).size !== 0 || Bun.file(join(current, 'package.json')).size !== 0) {
269
+ if (hasBunFile(joinPath(current, 'bun.lock')) || hasBunFile(joinPath(current, 'package.json'))) {
224
270
  return current;
225
271
  }
226
- const parent = dirname(current);
272
+ const parent = dirnamePath(current);
227
273
  if (parent === current) return startDir;
228
274
  current = parent;
229
275
  }
@@ -231,9 +277,70 @@ export function getProjectRoot(startDir = process.cwd()): string {
231
277
  }
232
278
 
233
279
  export function resolveProjectPath(...segments: string[]): string {
234
- return resolve(getProjectRoot(), ...segments);
280
+ return resolvePath(getProjectRoot(), ...segments);
235
281
  }
236
282
 
237
283
  export function createLogStream(path: string, fs = getFs()): LogStream {
238
284
  return fs.createLogStream(path);
239
285
  }
286
+
287
+ function hasBunFile(path: string): boolean {
288
+ const bun = (globalThis as { Bun?: { file: (path: string) => { size: number } } }).Bun;
289
+ if (bun === undefined) return false;
290
+ return bun.file(path).size !== 0;
291
+ }
292
+
293
+ function getProcessPid(): number {
294
+ return (globalThis as { process?: { pid?: number } }).process?.pid ?? 0;
295
+ }
296
+
297
+ function getProcessCwd(): string {
298
+ return (globalThis as { process?: { cwd?: () => string } }).process?.cwd?.() ?? '/';
299
+ }
300
+
301
+ function normalizeSeparators(path: string): string {
302
+ return path.replaceAll('\\', '/');
303
+ }
304
+
305
+ function isAbsolutePath(path: string): boolean {
306
+ return path.startsWith('/') || /^[A-Za-z]:\//.test(normalizeSeparators(path));
307
+ }
308
+
309
+ function dirnamePath(path: string): string {
310
+ const input = normalizeSeparators(path);
311
+ if (/^\/+$/.test(input)) return '/';
312
+ const normalized = input.replace(/\/+$/, '');
313
+ if (normalized === '' || normalized === '/') return normalized || '.';
314
+ const index = normalized.lastIndexOf('/');
315
+ if (index < 0) return '.';
316
+ if (index === 0) return '/';
317
+ return normalized.slice(0, index);
318
+ }
319
+
320
+ function joinPath(...segments: string[]): string {
321
+ const filtered = segments.filter((segment) => segment.length > 0).map(normalizeSeparators);
322
+ if (filtered.length === 0) return '.';
323
+ const absolute = isAbsolutePath(filtered[0] ?? '');
324
+ const joined = filtered.join('/').replace(/\/+/g, '/');
325
+ return absolute ? joined : joined.replace(/^\//, '');
326
+ }
327
+
328
+ function resolvePath(...segments: string[]): string {
329
+ const candidates = segments.length === 0 ? [getProcessCwd()] : segments;
330
+ let resolved = '';
331
+ for (const segment of candidates.map(normalizeSeparators)) {
332
+ if (segment.length === 0) continue;
333
+ resolved = isAbsolutePath(segment) ? segment : joinPath(resolved || getProcessCwd(), segment);
334
+ }
335
+ const parts: string[] = [];
336
+ const absolute = isAbsolutePath(resolved);
337
+ for (const part of resolved.split('/')) {
338
+ if (part === '' || part === '.') continue;
339
+ if (part === '..') {
340
+ parts.pop();
341
+ continue;
342
+ }
343
+ parts.push(part);
344
+ }
345
+ return `${absolute ? '/' : ''}${parts.join('/')}` || (absolute ? '/' : '.');
346
+ }
package/src/index.ts CHANGED
@@ -2,4 +2,5 @@ export * from './config';
2
2
  export * from './context';
3
3
  export * from './fs';
4
4
  export * from './process-executor';
5
+ export * from './schema-validation';
5
6
  export * from './types';
@@ -0,0 +1,374 @@
1
+ import { dirname, isAbsolute, join } from 'node:path';
2
+ import { parse as parseYaml } from 'yaml';
3
+ import { getFs } from './fs';
4
+
5
+ /** Default time budget for a single remote schema fetch. */
6
+ const REMOTE_SCHEMA_FETCH_TIMEOUT_MS = 5_000;
7
+
8
+ export interface JsonSchemaViolation {
9
+ path: string;
10
+ message: string;
11
+ }
12
+
13
+ export interface JsonSchema {
14
+ type?: string | string[];
15
+ required?: string[];
16
+ properties?: Record<string, JsonSchema>;
17
+ additionalProperties?: boolean | JsonSchema;
18
+ items?: JsonSchema;
19
+ enum?: unknown[];
20
+ const?: unknown;
21
+ oneOf?: JsonSchema[];
22
+ anyOf?: JsonSchema[];
23
+ $ref?: string;
24
+ $defs?: Record<string, JsonSchema>;
25
+ }
26
+
27
+ export interface StructuredConfigLoadOptions {
28
+ validateSchema?: boolean;
29
+ /**
30
+ * Allow `http(s)://` `$schema` refs. Off by default: remote fetches are an SSRF/DoS surface when
31
+ * configs are authored by third parties. Prefer bundled package-specifier refs (resolved from
32
+ * `node_modules`). Supplying `fetch` explicitly also opts into remote resolution.
33
+ */
34
+ allowRemote?: boolean;
35
+ fetch?: (input: string) => Promise<Response>;
36
+ /**
37
+ * Module resolver for bare package-specifier `$schema` refs (e.g.
38
+ * `@gobing-ai/ts-rule-engine/schemas/rule-file.schema.json`). Defaults to `Bun.resolveSync`.
39
+ * Injectable for testing.
40
+ */
41
+ resolve?: (specifier: string, from: string) => string;
42
+ }
43
+
44
+ export class StructuredConfigSchemaError extends Error {
45
+ constructor(
46
+ message: string,
47
+ readonly violations: readonly JsonSchemaViolation[] = [],
48
+ ) {
49
+ super(message);
50
+ this.name = 'StructuredConfigSchemaError';
51
+ }
52
+ }
53
+
54
+ export async function loadStructuredConfig(path: string, options: StructuredConfigLoadOptions = {}): Promise<unknown> {
55
+ const content = await getFs().readFile(path);
56
+ return await parseStructuredConfig(content, path, options);
57
+ }
58
+
59
+ export async function parseStructuredConfig(
60
+ content: string,
61
+ source: string,
62
+ options: StructuredConfigLoadOptions = {},
63
+ ): Promise<unknown> {
64
+ const parsed = source.endsWith('.json') ? JSON.parse(content) : parseYaml(content);
65
+ if (options.validateSchema !== false) {
66
+ await validateDeclaredJsonSchema(parsed, source, options);
67
+ }
68
+ return parsed;
69
+ }
70
+
71
+ export async function validateDeclaredJsonSchema(
72
+ value: unknown,
73
+ source: string,
74
+ options: StructuredConfigLoadOptions = {},
75
+ ): Promise<void> {
76
+ if (!isObject(value)) return;
77
+ const schemaRef = value.$schema;
78
+ if (typeof schemaRef !== 'string' || schemaRef.length === 0) return;
79
+
80
+ const schemaLocation = resolveSchemaRef(schemaRef, source, options.resolve);
81
+ const schemaText = await readSchema(schemaLocation, options);
82
+ let schema: unknown;
83
+ try {
84
+ schema = JSON.parse(schemaText);
85
+ } catch (error) {
86
+ throw new StructuredConfigSchemaError(
87
+ `Invalid JSON schema "${schemaLocation}" referenced by "${source}": ${errorMessage(error)}`,
88
+ );
89
+ }
90
+
91
+ if (!isObject(schema)) {
92
+ throw new StructuredConfigSchemaError(
93
+ `JSON schema "${schemaLocation}" referenced by "${source}" must be an object`,
94
+ );
95
+ }
96
+
97
+ const violations = validateJsonSchema(value, schema, '', (schema.$defs ?? {}) as Record<string, JsonSchema>);
98
+ if (violations.length > 0) {
99
+ throw new StructuredConfigSchemaError(
100
+ `Configuration "${source}" failed JSON schema validation against "${schemaLocation}": ${violations
101
+ .map((violation) => `${violation.path}: ${violation.message}`)
102
+ .join('; ')}`,
103
+ violations,
104
+ );
105
+ }
106
+ }
107
+
108
+ export function validateJsonSchema(
109
+ value: unknown,
110
+ schema: JsonSchema,
111
+ path = '',
112
+ defs: Record<string, JsonSchema> = {},
113
+ ): JsonSchemaViolation[] {
114
+ const violations: JsonSchemaViolation[] = [];
115
+
116
+ // Applicator keywords compose with their siblings (logical AND), per JSON Schema 2020-12 —
117
+ // a node may carry `$ref`/`oneOf`/`anyOf` *and* `type`/`properties`/... and all apply.
118
+ if (schema.$ref !== undefined) {
119
+ const resolved = resolveRef(schema.$ref, defs, schema.$defs);
120
+ if (resolved !== undefined) violations.push(...validateJsonSchema(value, resolved, path, defs));
121
+ }
122
+
123
+ if (schema.oneOf !== undefined) {
124
+ violations.push(...validateCombinator(value, schema.oneOf, path, defs, 'oneOf'));
125
+ }
126
+
127
+ if (schema.anyOf !== undefined) {
128
+ violations.push(...validateCombinator(value, schema.anyOf, path, defs, 'anyOf'));
129
+ }
130
+
131
+ if (schema.const !== undefined && !jsonEqual(value, schema.const)) {
132
+ violations.push({ path: path || '(root)', message: `expected constant ${JSON.stringify(schema.const)}` });
133
+ }
134
+
135
+ if (schema.enum !== undefined && !schema.enum.some((entry) => jsonEqual(value, entry))) {
136
+ violations.push({ path: path || '(root)', message: `expected one of ${schema.enum.map(String).join(', ')}` });
137
+ }
138
+
139
+ if (schema.type !== undefined) {
140
+ const types = Array.isArray(schema.type) ? schema.type : [schema.type];
141
+ if (!types.some((type) => matchesType(value, type))) {
142
+ violations.push({
143
+ path: path || '(root)',
144
+ message: `expected ${types.join(' or ')}, got ${typeName(value)}`,
145
+ });
146
+ return violations;
147
+ }
148
+ }
149
+
150
+ const hasObjectKeywords =
151
+ schema.properties !== undefined || schema.required !== undefined || schema.additionalProperties !== undefined;
152
+ if (schema.type === 'object' || (hasObjectKeywords && isObject(value))) {
153
+ violations.push(...validateObject(value, schema, path, defs));
154
+ }
155
+
156
+ if (schema.type === 'array' || (schema.items !== undefined && Array.isArray(value))) {
157
+ violations.push(...validateArray(value, schema, path, defs));
158
+ }
159
+
160
+ return violations;
161
+ }
162
+
163
+ function validateObject(
164
+ value: unknown,
165
+ schema: JsonSchema,
166
+ path: string,
167
+ defs: Record<string, JsonSchema>,
168
+ ): JsonSchemaViolation[] {
169
+ if (!isObject(value)) return [{ path: path || '(root)', message: `expected object, got ${typeName(value)}` }];
170
+
171
+ const violations: JsonSchemaViolation[] = [];
172
+ for (const key of schema.required ?? []) {
173
+ if (!(key in value)) {
174
+ violations.push({ path: path ? `${path}.${key}` : key, message: `missing required field "${key}"` });
175
+ }
176
+ }
177
+
178
+ const properties = schema.properties ?? {};
179
+ for (const [key, childSchema] of Object.entries(properties)) {
180
+ if (key in value) {
181
+ violations.push(...validateJsonSchema(value[key], childSchema, path ? `${path}.${key}` : key, defs));
182
+ }
183
+ }
184
+
185
+ if (schema.additionalProperties === false) {
186
+ const allowed = new Set(Object.keys(properties));
187
+ for (const key of Object.keys(value)) {
188
+ if (!allowed.has(key)) {
189
+ violations.push({ path: path ? `${path}.${key}` : key, message: `unknown field "${key}"` });
190
+ }
191
+ }
192
+ } else if (isObject(schema.additionalProperties)) {
193
+ for (const [key, child] of Object.entries(value)) {
194
+ if (!(key in properties)) {
195
+ violations.push(
196
+ ...validateJsonSchema(child, schema.additionalProperties, path ? `${path}.${key}` : key, defs),
197
+ );
198
+ }
199
+ }
200
+ }
201
+
202
+ return violations;
203
+ }
204
+
205
+ function validateArray(
206
+ value: unknown,
207
+ schema: JsonSchema,
208
+ path: string,
209
+ defs: Record<string, JsonSchema>,
210
+ ): JsonSchemaViolation[] {
211
+ if (!Array.isArray(value)) return [{ path: path || '(root)', message: `expected array, got ${typeName(value)}` }];
212
+ if (schema.items === undefined) return [];
213
+ return value.flatMap((entry, index) =>
214
+ validateJsonSchema(entry, schema.items as JsonSchema, `${path}[${index}]`, defs),
215
+ );
216
+ }
217
+
218
+ function validateCombinator(
219
+ value: unknown,
220
+ schemas: JsonSchema[],
221
+ path: string,
222
+ defs: Record<string, JsonSchema>,
223
+ mode: 'oneOf' | 'anyOf',
224
+ ): JsonSchemaViolation[] {
225
+ const branchViolations = schemas.map((schema) => validateJsonSchema(value, schema, path, defs));
226
+ const passing = branchViolations.filter((violations) => violations.length === 0).length;
227
+ if (mode === 'anyOf' && passing >= 1) return [];
228
+ if (mode === 'oneOf' && passing === 1) return [];
229
+
230
+ const at = path || '(root)';
231
+ // oneOf matching more than one branch is a failure, not a pass — report it explicitly.
232
+ if (mode === 'oneOf' && passing > 1) {
233
+ return [{ path: at, message: `expected to match exactly one oneOf branch, matched ${passing}` }];
234
+ }
235
+
236
+ // No branch matched: surface every branch's reason so the author sees all options, not just branch 0.
237
+ const detail = branchViolations
238
+ .map((branch, index) => `[${index}] ${branch.map((v) => `${v.path}: ${v.message}`).join(', ')}`)
239
+ .join(' | ');
240
+ return [
241
+ {
242
+ path: at,
243
+ message: `expected to match ${mode === 'oneOf' ? 'exactly one' : 'at least one'} branch — ${detail}`,
244
+ },
245
+ ];
246
+ }
247
+
248
+ function resolveRef(
249
+ ref: string,
250
+ defs: Record<string, JsonSchema>,
251
+ localDefs?: Record<string, JsonSchema>,
252
+ ): JsonSchema | undefined {
253
+ if (!ref.startsWith('#/$defs/')) return undefined;
254
+ const name = ref.slice('#/$defs/'.length);
255
+ return defs[name] ?? localDefs?.[name];
256
+ }
257
+
258
+ function resolveSchemaRef(
259
+ schemaRef: string,
260
+ source: string,
261
+ resolve: ((specifier: string, from: string) => string) | undefined,
262
+ ): string {
263
+ if (isRemoteRef(schemaRef) || isAbsolute(schemaRef)) return schemaRef;
264
+ // Relative ref — resolve against the config file's directory.
265
+ if (schemaRef.startsWith('./') || schemaRef.startsWith('../')) {
266
+ return join(isRemoteRef(source) ? '.' : dirname(source), schemaRef);
267
+ }
268
+ // Bare package specifier (e.g. "@scope/pkg/schemas/x.json") — resolve through node_modules.
269
+ return resolvePackageSchema(schemaRef, source, resolve);
270
+ }
271
+
272
+ function resolvePackageSchema(
273
+ specifier: string,
274
+ source: string,
275
+ resolve: ((specifier: string, from: string) => string) | undefined,
276
+ ): string {
277
+ const resolveFn = resolve ?? defaultResolve;
278
+ if (resolveFn === undefined) {
279
+ throw new StructuredConfigSchemaError(
280
+ `Cannot resolve package schema "${specifier}" referenced by "${source}": no module resolver available`,
281
+ );
282
+ }
283
+ const { pkg, subpath } = splitPackageSpecifier(specifier);
284
+ if (subpath.length === 0) {
285
+ throw new StructuredConfigSchemaError(
286
+ `Package schema ref "${specifier}" referenced by "${source}" must include a path within the package`,
287
+ );
288
+ }
289
+ const from = isRemoteRef(source) ? process.cwd() : dirname(source);
290
+ try {
291
+ // Resolve the package root via its always-present package.json, then join the subpath.
292
+ // This sidesteps `exports` gating on arbitrary JSON subpaths.
293
+ const manifest = resolveFn(`${pkg}/package.json`, from);
294
+ return join(dirname(manifest), subpath);
295
+ } catch (error) {
296
+ throw new StructuredConfigSchemaError(
297
+ `Cannot resolve package schema "${specifier}" referenced by "${source}": ${errorMessage(error)}`,
298
+ );
299
+ }
300
+ }
301
+
302
+ function splitPackageSpecifier(specifier: string): { pkg: string; subpath: string } {
303
+ const parts = specifier.split('/');
304
+ const segments = specifier.startsWith('@') ? 2 : 1;
305
+ return { pkg: parts.slice(0, segments).join('/'), subpath: parts.slice(segments).join('/') };
306
+ }
307
+
308
+ async function readSchema(schemaLocation: string, options: StructuredConfigLoadOptions): Promise<string> {
309
+ if (isRemoteRef(schemaLocation)) {
310
+ const fetchFn = options.fetch ?? (options.allowRemote ? boundedFetch : undefined);
311
+ if (fetchFn === undefined) {
312
+ throw new StructuredConfigSchemaError(
313
+ `Refusing to fetch remote JSON schema "${schemaLocation}": pass { allowRemote: true } or a fetch implementation to opt in`,
314
+ );
315
+ }
316
+ const response = await fetchFn(schemaLocation);
317
+ if (!response.ok) {
318
+ throw new StructuredConfigSchemaError(
319
+ `Failed to fetch JSON schema "${schemaLocation}": HTTP ${response.status}`,
320
+ );
321
+ }
322
+ return await response.text();
323
+ }
324
+ return await getFs().readFile(schemaLocation);
325
+ }
326
+
327
+ const defaultResolve: ((specifier: string, from: string) => string) | undefined =
328
+ typeof Bun !== 'undefined' ? (specifier, from) => Bun.resolveSync(specifier, from) : undefined;
329
+
330
+ /** Default remote fetch, time-bounded so a slow/hung schema host cannot stall config loading. */
331
+ function boundedFetch(input: string): Promise<Response> {
332
+ return globalThis.fetch(input, { signal: AbortSignal.timeout(REMOTE_SCHEMA_FETCH_TIMEOUT_MS) });
333
+ }
334
+
335
+ function isObject(value: unknown): value is Record<string, unknown> {
336
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
337
+ }
338
+
339
+ function matchesType(value: unknown, type: string): boolean {
340
+ if (type === 'object') return isObject(value);
341
+ if (type === 'array') return Array.isArray(value);
342
+ if (type === 'integer') return typeof value === 'number' && Number.isInteger(value);
343
+ if (type === 'null') return value === null;
344
+ return typeof value === type;
345
+ }
346
+
347
+ function typeName(value: unknown): string {
348
+ if (Array.isArray(value)) return 'array';
349
+ if (value === null) return 'null';
350
+ return typeof value;
351
+ }
352
+
353
+ function jsonEqual(left: unknown, right: unknown): boolean {
354
+ if (left === right) return true;
355
+ if (Array.isArray(left) || Array.isArray(right)) {
356
+ if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false;
357
+ return left.every((entry, index) => jsonEqual(entry, right[index]));
358
+ }
359
+ if (isObject(left) && isObject(right)) {
360
+ const keys = Object.keys(left);
361
+ if (keys.length !== Object.keys(right).length) return false;
362
+ // Object member order is insignificant in JSON — compare by key, not by serialization.
363
+ return keys.every((key) => key in right && jsonEqual(left[key], right[key]));
364
+ }
365
+ return false;
366
+ }
367
+
368
+ function errorMessage(error: unknown): string {
369
+ return error instanceof Error ? error.message : String(error);
370
+ }
371
+
372
+ function isRemoteRef(ref: string): boolean {
373
+ return /^https?:\/\//i.test(ref);
374
+ }